🔧

Amplify Gen2 で OpenSearch を設定したらホットリロードの度にサンドボックス反映が15分かかるようになった話

に公開

はじめに

この記事は
Amplify Gen2 × Amazon OpenSearch Service 実践解説 — OSISでDynamoDB連携をCDK構築
で紹介した構成を、実際の業務プロジェクトに適用し、複数の開発者が同時に開発できるようにしたときに何が起きたか を振り返る実録記事です。

上記の記事では、Amplify Gen2 のバックエンドに OpenSearch / OSIS を組み込むための CDK 実装と設定の考え方 を整理しました。
OSIS パイプライン定義を YAML ではなく JSON で扱う方法については、関連する補足記事
AWS CDK で Amazon OpenSearch Service のパイプラインは YAML の代わりに JSON で定義できる!
で補足しています。

出典: Amplify Docs「Connect to Amazon OpenSearch for search and aggregate queries」
https://docs.amplify.aws/react/build-a-backend/data/custom-business-logic/search-and-aggregate-queries/

一方、実際の業務プロジェクトでこの構成を採用し、
「複数の開発者がそれぞれの sandbox 環境で同時開発する」前提で OpenSearch を常に有効にしたところ、

  • 初回の npx ampx sandbox 実行時は、OpenSearch の初期構築により 約 100 秒 → 40〜50 分へ
  • さらに、その後のホットリロードのたびに OSIS パイプライン再作成で 10〜15 分待たされる

という 2 段階の問題が発生し、開発体験に大きな影響が出ていました。

本記事では、

  • なぜ初回構築が 40〜50 分かかるようになったのか(OpenSearch ドメイン+パイプライン)
  • なぜ npx ampx sandbox のホットリロードのたびに 10〜15 分待たされるようになったのか
  • 最終的に、日常的な開発サイクルにおける「15 分待ち」を解消するために行った工夫

を、AWS 公式ドキュメントのサンプルコード をベースにした構成を用いながら解説します。
実話ベースですが、コード断片は公式サンプルから簡略化したもののみを扱い、業務固有の実装は省略します。

前回の記事を読んでいない方でも、本記事は「なぜ遅くなるのか」「どう改善するのか」という観点で読み進められるようにしていますが、OpenSearch / OSIS の CDK 実装そのものに興味がある場合は、先に前回の記事に目を通しておくと理解しやすいと思います。

前提と環境

前回の記事と同様、前提は次の通りです。

  • Amplify Gen2(Node.js ベースのバックエンド)
  • AWS CDK v2
  • OpenSearch Service(Managed)+ OpenSearch Ingestion Service(OSIS)
  • DynamoDB Streams → OSIS → OpenSearch の連携構成
  • npx ampx sandbox でローカル環境を立ち上げる開発フロー

OpenSearch 周りの基本的な CDK 実装は、前回の記事で扱った公式サンプルとほぼ同じです。

// amplify/backend.ts(イメージ)
import * as opensearch from "aws-cdk-lib/aws-opensearchservice";
import * as osis from "aws-cdk-lib/aws-osis";
import { defineBackend } from "@aws-amplify/backend";
import { auth } from "./auth/resource";
import { data } from "./data/resource";
import { storage } from "./storage/resource";

const backend = defineBackend({
  auth,
  data,
  storage,
});

// OpenSearch ドメイン
const openSearchDomain = new opensearch.Domain(
  backend.data.stack,
  "OpenSearchDomain",
  {
    /* 公式サンプルに準拠した設定 */
  }
);

// OSIS パイプライン
const pipelineConfiguration = { /* 前回の記事で扱った JSON 定義 */ };

const pipeline = new osis.CfnPipeline(
  backend.data.stack,
  "OpenSearchIntegrationPipeline",
  {
    pipelineName: "todo-opensearch-pipeline",
    pipelineConfigurationBody: JSON.stringify(pipelineConfiguration),
    /* 公式サンプルに準拠した設定 */
  }
);

ここまでが Amplify Docs の公式サンプル相当の構成です。
この例のように pipelineName が固定であれば、ホットリロードのたびにパイプラインが作り直される問題は発生しません。

今回の業務プロジェクトでは、複数の開発者がそれぞれ自分の sandbox 環境を立ち上げる 前提だったため、

  • pipelineName が固定だと、CloudFormation が OSIS パイプラインを作成する際に「すでに同名のパイプラインが存在する」とエラーになる
  • そのため、pipelineName に UUID を含めて「毎回一意なパイプライン名」にする

という方針を採用していました(具体例は後述の「悪い例」の節で扱います)。
この「pipelineName を毎回変える」という設計が、結果的にホットリロードのたびに 15 分待たされる原因となりました。

問題の発生: 5分 → 50分

まずは、実際に計測した起動時間のイメージです(値は実際のプロジェクトで計測したものに基づきます)。

$ time npx ampx sandbox

# OpenSearch 導入前(他のリソースのみ)
real    0m100s

# OpenSearch ドメイン + OSIS パイプライン導入後(初回)
real    40〜50m  # ドメイン + パイプライン作成

初回は OpenSearch ドメインの作成に 20〜25 分前後かかるだろう、とある程度は覚悟していましたが、問題はそこではありませんでした。

実際に困ったのは、

  • OpenSearch に関係のない軽微な修正(フロントエンドの文言修正など)を行っただけでも
  • sandbox のホットリロードのたびに OSIS パイプラインが削除→再作成され
  • そのたびに 10〜15 分待たされる

という開発体験でした。

「ほとんど関係ない変更なのに、なぜ毎回 15 分も待たされるのか?」
この疑問を解消するために、CloudFormation の挙動を詳しく追いかけることにしました。

CloudFormation イベントからボトルネックを探す

OpenSearch 関連リソースのイベントを確認

まず、CloudFormation のスタックイベントから OpenSearch 関連リソースの作成・削除タイミングを確認しました。

aws cloudformation describe-stack-events \
  --stack-name amplify-<app-name>-sandbox-<identifier> \
  --query 'StackEvents[?contains(ResourceType, `OpenSearch`)].{Time: Timestamp, Type: ResourceType, Status: ResourceStatus}' \
  --output table

ここで、次のような傾向が見えてきました。

  • OpenSearch ドメイン(AWS::OpenSearchService::Domain)の作成には初回だけ 20〜25 分かかる
  • それとは別に、OSIS パイプライン(AWS::OSIS::Pipeline)の DELETE_COMPLETECREATE_COMPLETE に 10〜15 分かかっている

さらに、全体のイベントを確認すると、次のような動きも見えてきました。

aws cloudformation describe-stack-events \
  --stack-name amplify-<app-name>-sandbox-<identifier> \
  --max-items 100

OSIS パイプラインに注目して抜粋すると、以下のような流れです。

# 1回目のデプロイ
10:15 - OpenSearchIntegrationPipeline  CREATE_IN_PROGRESS
10:30 - OpenSearchIntegrationPipeline  CREATE_COMPLETE

# ホットリロード後
10:32 - OpenSearchIntegrationPipeline  DELETE_IN_PROGRESS
10:42 - OpenSearchIntegrationPipeline  DELETE_COMPLETE
10:42 - OpenSearchIntegrationPipeline  CREATE_IN_PROGRESS
10:57 - OpenSearchIntegrationPipeline  CREATE_COMPLETE

つまり、

  • 「OpenSearch に関係のない修正でも、ホットリロードのたびに OSIS パイプラインが削除され、再度作り直されている」

ことが毎回起きており、そのたびに 10〜15 分かかっている、という状況でした。

原因: pipelineName を毎回変えていた

CloudFormation は、同じ Logical ID(CDK では Stack パス + Construct ID で決まる) のリソースであっても、特定のプロパティが変わると「置き換え(Replacement)」として扱う場合があります。

OSIS パイプラインでは、pipelineName が物理名に相当するプロパティで、これが変わると古いパイプラインを削除してから新しいパイプラインを作成する挙動になります。

今回の事例でボトルネックになっていたのは、この pipelineName を実行のたびに UUID で変えていたこと でした。

  • OpenSearch ドメイン側は domainName を明示的に指定しておらず、ドメイン自体は頻繁に作り直されてはいなかった
  • 一方で、OSIS パイプラインの pipelineName を「各開発者の sandbox ごとにユニークにしたい」という理由から UUID ベースにしており、sandbox のホットリロードのたびに CloudFormation 上は「別のパイプライン」と判断されていた

その結果、

  • CloudFormation から見ると 毎回 pipelineName が変わる → 「別リソース」と判断 → パイプラインを削除してから再作成
  • npx ampx sandbox を実行するたび、OSIS パイプラインだけが毎回 10〜15 分かけて作り直される

という挙動になり、軽微な修正でも 15 分待たされる状態になっていました。

対策1: 物理名を「環境ごとに安定」「デプロイごとには変えない」

次に見直したのが、OpenSearch ドメインや OSIS パイプラインの物理名(特に pipelineName です。

悪い例: 実行ごとに変わる pipelineName

複数の開発者が同じテンプレートから sandbox を立ち上げるとき、pipelineName が固定だと「すでにその名前のパイプラインがある」といったエラーになることがあります。
これを避けるために、当初は次のように実行ごとに異なる UUID を suffix として付けていました(イメージです)。

import { v4 as uuidv4 } from "uuid";

const uniqueSuffix = uuidv4();

const pipeline = new osis.CfnPipeline(
  backend.data.stack,
  "OpenSearchIntegrationPipeline",
  {
    pipelineName: `todo-opensearch-pipeline-${uniqueSuffix}`, // ← 実行のたびに変わる
    pipelineConfigurationBody: JSON.stringify(pipelineConfiguration),
    /* ... */
  }
);

このように pipelineName に毎回異なる UUID を含めてしまうと、設定が変わっていなくても 物理名の変更 = リソース置き換え になり、ホットリロードのたびにパイプラインの作り直しが発生します。

良い例: 環境識別子にだけ依存させる

「開発者ごと・環境ごとに OpenSearch パイプラインを分けたい」という要件自体は正当なので、環境識別子にだけ依存した物理名 に変更しました。

// 例: --identifier オプションや環境変数から取得
const identifier = process.env.AMPLIFY_ENV ?? "dev";

const openSearchDomain = new opensearch.Domain(stack, "OpenSearchDomain", {
  domainName: `todo-search-${identifier}`, // ← 環境ごとに安定
  /* ... */
});

const pipeline = new osis.CfnPipeline(stack, "OpenSearchIntegrationPipeline", {
  pipelineName: `todo-opensearch-pipeline-${identifier}`,
  pipelineConfigurationBody: JSON.stringify(pipelineConfiguration),
  /* ... */
});

ポイントは次の通りです。

  • 物理名は「環境に対して一意」であればよく、デプロイのたびに変わる必要はない
  • 環境識別子(dev / stg / prod / 開発者名など)だけに依存させることで、
    • 環境ごとに OpenSearch ドメインを分離しつつ
    • 同じ環境内では物理名が変わらないため CloudFormation が「更新」として扱える

実際のプロジェクトでは、環境識別子から短いハッシュ値を生成するヘルパーを使って suffix を決めていました。このヘルパーの実装詳細については、別記事「Amplify Gen2 のCDKで環境識別子から固定のハッシュを生成して、リソース名の衝突と再作成を防ぐ方法」で解説しています。

対策2: 開発環境では OpenSearch 自体を OFF にする

ここまでの対策で、

  • OSIS パイプラインがホットリロードのたびに「毎回 DELETE/CREATE される」状況は解消
  • パイプライン設定を変えない限り、sandbox の再起動時間は「数分」で済む

という状態までは持っていけました。

しかし、OpenSearch ドメインの初回作成に 20〜25 分かかる こと自体は変わりません。

そのため、最終的には次のような運用に落ち着きました。

  • 日常的な開発(UI 修正やビジネスロジックの変更など)では OpenSearch を OFF
  • OpenSearch 関連の機能を開発・検証するときだけ OpenSearch を ON

この運用では、OpenSearch を OFF にしている間は検索機能そのものは動かないため、検索に関わる変更を検証したいタイミングだけ一時的に ON にする、という割り切りが必要になります。

イメージとしては、次のようなフラグで制御します。

// backend.ts
const isEnabledOpenSearch =
  process.env.ENABLE_OPENSEARCH === "true"; // デフォルトは OFF

if (isEnabledOpenSearch) {
  setupOpensearchResources({
    stack: backend.data.stack,
    backend,
  });
}
  • 普段は ENABLE_OPENSEARCH を設定せず(または false)にしておき、sandbox の起動時間を 約 100 秒前後にキープ
  • OpenSearch 周りを触るときだけ ENABLE_OPENSEARCH=true を付けて sandbox を立ち上げ、ドメイン作成(20〜25 分)を許容する

という「重いリソースは必要なときだけ作る」運用にすることで、開発体験としてはかなり楽になりました。

最終的な起動時間の改善

上記のような見直しと運用変更を行った結果、npx ampx sandbox の起動体験は次のように変わりました(実際の計測値に基づくイメージです)。

  • OpenSearch なし(従来の構成)
    • 約 100 秒で起動
  • OpenSearch 導入直後(初回 / ドメイン + パイプライン作成)
    • 40〜50 分(うちドメイン作成に 20〜25 分、パイプライン作成に 10〜15 分)
  • OpenSearch 導入後、ホットリロード(対策前)
    • OpenSearch に関係ない修正でも毎回パイプライン DELETE/CREATE が走り、10〜15 分待たされる
  • OpenSearch 導入後、ホットリロード(対策後)
    • パイプライン設定を変えない限り、数分で完了
  • 日常開発で OpenSearch フラグを OFF にした場合
    • 常に 約 100 秒前後で sandbox が立ち上がる

特に、

  • 「軽微な修正でもホットリロードのたびに 15 分待たされる」という状態から
  • 「OpenSearch を使うとき以外は 約 100 秒前後 / 使うときでもホットリロード自体は数分」

という形にできたのが、開発体験として非常に大きかったです。

学びとベストプラクティスまとめ

ここまでの内容を、実務で意識したいポイントに整理します。

  • CloudFormation の更新検知に寄せる設計をする
    • Logical ID(Stack パス + Construct ID)を意識して、CloudFormation の更新単位を設計する
    • 物理名(domainName / pipelineName など)は環境識別子にだけ依存させ、デプロイごとには変えない
  • 「重いリソースほど変えない」方針を持つ
    • OpenSearch ドメインは極力作り直さず、パイプライン/アプリケーション側の工夫で吸収する
  • Amplify Gen2 特有の Stack 管理を理解しておく
    • backend.createStack() を安易に複数箇所で呼ばず、Stack の生成位置と共有方法を明確にする
  • 起動時間を定期的に計測し、CloudFormation イベントを観察する
    • 「なんとなく遅い」で放置せず、どのリソースがボトルネックになっているかをログから特定する

前回の記事および補足記事で扱った CDK 実装は、機能面 の観点からは十分ですが、
実際の開発現場では、今回のように パフォーマンス(デプロイ時間) の観点も含めて設計することが重要だと痛感しました。

おわりに

本記事では、Amplify Gen2 + OpenSearch の構成を導入した結果、npx ampx sandbox が 50 分かかるようになってしまった実例と、そのボトルネックを解消するまでのプロセスを紹介しました。

ポイントは、

  • CloudFormation/ CDK の 更新単位(Logical ID と物理名) を意識して、「どの変更でどのリソースが作り直されるか」を設計段階から考えておくこと
  • OpenSearch のような 重いマネージドリソースを「いかに作り直さないか」 を設計レベルで考えること

に尽きます。

同じように Amplify Gen2 + OpenSearch を検討している方や、「npx ampx sandbox がやたら遅い」と感じている方の参考になれば幸いです。

引き続き、関連するトラブルシュートや運用ノウハウが溜まってきたら、本シリーズの形で整理していく予定です。

リバナレテックブログ

Discussion