![]() |
| Amazon DynamoDB × Amazon OpenSearch Service |
はじめに
DynamoDB での Query 機能、用途が限定できるとめっちゃ使えます。ただ、自由な検索が必要になると、途端に痒いところに手が届かなくなります。前回の検証では、業務システムの在庫管理画面をテーマに DynamoDB だけでどこまで必要な検索条件を実現できるか試しました。
結果として、部分一致検索や複数検索条件の組み合わせが苦手ということが分かりました。
その調査を進める中で、DynamoDB のデータを OpenSearch に流すことで自由な検索を手に入れることができると分かり、最近のアップデートで OpenSearch Serverless もゼロ・スケールに対応していたので、OpenSearch を取り入れた構成がどのように実現できるのかを試してみることにしました。
具体的には、DynamoDB Streams から OpenSearch Serverless にデータを同期して、同じデータ・同じ検索条件で両者を比較してみましたので結果をご覧ください。
まずは結論から
- 単純な検索は DynamoDB の方が速い
- OpenSearch を使うことで複雑な検索条件も実現
- OpenSearch Serverless のゼロ・スケールはコールドスタートになる
- データ同期に便利な OSIS パイプラインがあるが、常時稼働でコストが発生
想定される読者
- DynamoDB の検索制約に突き当たっている方
- OpenSearch との連携を検討しているが、コストや複雑さで踏み切れていない方
- ゼロ・スケール対応の OpenSearch Serverless が気になっている方
前回のおさらい
架空のコーヒー焙煎メーカー「Kiro Roasters」の在庫管理画面を題材にしています。データは 5,000 SKU × 3倉庫 = 15,000レコードです。
DynamoDB 側のテーブル設計は前回のままです。
ベーステーブル: kiro-roasters-inventory-good
PK = itemId
SK = warehouseId
GSI byWarehouse : PK = warehouseId, SK = itemId
GSI byLocation : PK = warehouseId, SK = location
GSI byUnitPrice : PK = warehouseId, SK = unitPrice
前回たどり着いた結論は2つです。
1. GSI のキーで表現できる検索なら問題なし
倉庫別一覧、商品コードの前方一致、棚番号の前方一致、単価の範囲検索。これらは GSI を3本立てることで対応できました。
2. 部分一致と複合条件は実現できなかった
商品名の部分一致(「ブラジル」を含む商品を探す)は、DynamoDB の索引の仕組みでは表現できませんでした。DynamoDB がキーで絞り込めるのは「〜で始まる」「〜の範囲」といった順序で表現できる条件だけで、「文字列のどこかに含まれる」は対象外です。
複数条件の組み合わせも同じ理由で行き詰まりました。DynamoDB が1回の検索でキーとして使えるのは、インデックス1本分の条件だけです。GSI を3本用意していても、1回の検索で使えるのは1本だけです。
どちらの場合も、代わりに FilterExpression という後付けのフィルタ機能を使うことはできます。
ただしこれは「読み取ったデータを後から捨てる」仕組みのため、ページネーションで必要な件数だけ読み込んで表示するような画面構成とは相性が悪いです。
この2つを OpenSearch で埋められるかが今回のテーマです。
Amazon OpenSearch Service とは
構成の話に入る前に、今回使う OpenSearch Service について整理しておきます。
筆者も RAG をお手軽に生成するための仕組みというイメージしかなかったので、改めての確認です。
OpenSearch は検索と分析のためのエンジン
OpenSearch は全文検索・ログ分析のためのオープンソースの検索エンジンです。AWS はこれをマネージドサービスとして提供しており、それが Amazon OpenSearch Service です。
DynamoDB との一番の違いは、データの持ち方です。
| DynamoDB | OpenSearch | |
|---|---|---|
| データの持ち方 | キーで引くための Key-Value ストア | 検索するための転置インデックス |
| 得意なこと | キー指定の高速な取得・更新 | 任意の条件での検索・集計 |
| 検索条件 | PK/SK の設計に依存 | インデックスされた全フィールドを自由に組み合わせ |
転置インデックスは「単語 → その単語を含むドキュメント一覧」という形でデータを保持する仕組みです。文章を単語に分解して索引を作っておくため、「商品名に『ブラジル』を含むもの」のような検索が高速にできます。前回 DynamoDB でできなかった部分一致検索が OpenSearch では素直に書けるのは、この構造の違いによるものです。
その代わり、DynamoDB のような1桁ミリ秒の Key-Value アクセスや、高頻度の書き込みには向きません。検索のためのデータストアという位置づけです。
OpenSearch Serverless — クラスター管理が不要なモード
従来の OpenSearch Service はノード数やインスタンスタイプを自分で決めてクラスターを運用する形でした。OpenSearch Serverless はそれをマネージドにしたもので、キャパシティを OCU(OpenSearch Compute Unit) という単位で自動スケールしてくれます(他の Serverless 系サービスでもよく見る形ですね)。
クラスターに相当する検索エンジンの実体は「Collection」と呼ばれ、この中に inventory のようなインデックスを作ります。
そして Serverless の検索エンジンとしては現状 Classic と NextGen の2つの世代を選択できます。
| Classic | NextGen | |
|---|---|---|
| 最小 OCU | 1以上(常時稼働) | 0(ゼロ・スケール) |
| アイドル時のコンピュート課金 | 発生する | 発生しない |
| 初回リクエスト | すぐ応答 | コールドスタートあり |
Classic は最小1 OCU が常時動くため、使っていない時間も課金されます。NextGen はアイドル時に 0 OCU まで縮退するので、検証用途や低頻度の管理画面ならコンピュート費用がほぼ掛かってきません。その代わり 0 から立ち上げる時間(コールドスタート)が必要となります。業務要件として、このコールドスタートを許容できるかが、検索エンジンを選定するひとつの基準でしょうか。
OpenSearch Ingestion(OSIS)— データを流し込む仕組み
DynamoDB のデータを OpenSearch で検索するには、OpenSearch 側にデータを持ってくる必要があります。DynamoDB を直接検索してくれるわけではないので、ここが構成上の要になります。
その手段が OpenSearch Ingestion(OSIS) です。DynamoDB Streams を購読して OpenSearch に書き込むパイプラインを、YAML の設定だけで用意できます。
# パイプライン設定のイメージ
version: "2"
dynamodb-pipeline:
source:
dynamodb:
tables:
- table_arn: "arn:aws:dynamodb:..."
stream:
start_position: "LATEST"
sink:
- opensearch:
index: "inventory"
同じことは Lambda を書いても実現できますが、リトライ・バッチング・初回の全件ロードといった面倒な部分をサービス側が引き受けてくれるのが OSIS の利点です。
システム構成
では、今回のシステム構成に入っていきます。
今回は OpenSearch へのデータ投入パイプラインも合わせて構築しています。
アーキテクチャ図
前回検証の構成に、OpenSearch 側(図の右半分)を足した形です。
| 区分 | リソース |
|---|---|
| オンライン処理 | Amplify Hosting + Cognito + API Gateway。検索用の Lambda を DynamoDB 側と OpenSearch 側で分けて用意 |
| データストア | DynamoDB Good Table(PK=itemId / SK=warehouseId、GSI 3本、PITR 有効) |
| データ同期 | DynamoDB Streams + OSIS Ingestion Pipeline。初回の全件ロード用に S3 も使用 |
| 検索エンジン | OpenSearch Serverless(NextGen) |
ポイントは検索用の Lambda を2本に分けているところです。画面から同じ検索条件を投げて、DynamoDB 経路と OpenSearch 経路のどちらでも叩けるようにしてあります。これで同一条件での比較ができます。
DynamoDB のデータを OpenSearch に同期する部分は、OpenSearch Ingestion(OSIS) のパイプラインに任せました。Lambda を書いて Streams を処理する構成もありますが、OSIS なら YAML の設定だけで済みます。
検索比較画面
今回の検証のために既存Web画面に検証比較用のタブを追加しました。
各検索条件を入力して「検索」ボタンを押すことで、DynamoDB と OpenSearch の両方に検索しにいって、それぞれの取得結果を左右に表示するようになっています。同時にそれぞれの検索時間も計測して表示します。

同じ条件を2経路に投げる仕組み
比較を成立させるために、検索用の Lambda を DynamoDB 側と OpenSearch 側で別々に用意しています。画面から受け取る検索条件は共通で、それを2つの Lambda がそれぞれのサービスに合わせたクエリに変換します。
| OpenSearch 経路 | DynamoDB 経路 | |
|---|---|---|
| Lambda | opensearch-search | inventory-query |
| 呼び出し | GET /search |
GET /inventory/{warehouseId} |
| クエリの組み立て | 条件を Query DSL の bool.must に並べる |
条件に応じて GSI を選び、KeyCondition と FilterExpression に振り分ける |
| 認証 | SigV4 で署名して OpenSearch を呼ぶ | IAM ロールで DynamoDB を直接呼ぶ |
クエリの組み立てに関して、OpenSearch 側は、受け取った条件を Query DSL の bool.must に積んでいきます。
const must: object[] = [];
if (warehouseId) must.push({ term: { 'warehouseId.keyword': warehouseId } });
if (itemIdPrefix) must.push({ prefix: { 'itemId.keyword': itemIdPrefix } });
if (locationPrefix) must.push({ prefix: { 'location.keyword': locationPrefix } });
if (itemName) must.push({ match: { itemName } });
if (minPrice || maxPrice) must.push({ range: { unitPrice: { gte: minPrice, lte: maxPrice } } });
if (minQty || maxQty) must.push({ range: { quantity: { gte: minQty, lte: maxQty } } });
const body = {
query: { bool: { must } },
from: (page - 1) * 20,
size: 20,
};
指定された条件を配列に足していくだけで、条件の組み合わせによって処理を分ける必要はありません。from / size でページ位置と件数を指定します。
また、DynamoDB 側の Lambda には、条件からどの GSI を使うか選ぶロジックを入れてあります。1回の Query で使えるインデックスは1本だけなので、優先順位を決めておく必要があります。
1. 単価の範囲指定あり → byUnitPrice(SK=unitPrice で BETWEEN)
2. 棚番号の前方一致あり → byLocation(SK=location で begins_with)
3. 商品コードの前方一致あり → byWarehouse(SK=itemId で begins_with)
4. どれもなし → byWarehouse(倉庫の全件)
そして選ばれなかった条件はすべて FilterExpression に回ります。FilterExpression は後付けのフィルタになるので、データ取得時ではなく、Query で絞り込んだデータに対してフィルタリングする流れとなります。
それぞれの処理で検索時間を計測しています。計測しているのは Lambda 内で DynamoDB / OpenSearch を呼んでいる区間の時間です。画面表示までの時間ではないので、ネットワークやレンダリングの影響は含みません。
検証① DynamoDB が得意な検索
まずは DynamoDB が本来得意とする、GSI の KeyCondition で完結するパターンです。
| # | 検索条件 | OpenSearch | DynamoDB | 使用 GSI |
|---|---|---|---|---|
| 1 | 倉庫: WH-TOKYO | 104〜276ms | 32〜55ms | byWarehouse |
| 2 | 倉庫 + 商品ID ITEM#ETH-
|
77〜123ms | 11〜41ms | byWarehouse |
| 3 | 倉庫 + ロケーション D-
|
46〜77ms | 61〜221ms | byLocation |
| 4 | 倉庫 + 単価 2000〜3000 | 71〜84ms | 43〜62ms | byUnitPrice |
DynamoDB は 11〜221ms で安定しています。OpenSearch はウォーム後で 46〜276ms なので、体感はほぼ変わりませんが数字の上では DynamoDB の方が優位です。GSI のキー設計が検索条件と噛み合っていれば、余計なデータを読まずに済むぶん有利になります。
ただ #3 だけは OpenSearch の方が速く、DynamoDB 側のばらつきも大きくなっています。この条件は該当件数が626件と多いため、DynamoDB は該当範囲を読み進める必要があるぶん不利になったのかと思います。該当件数が増えるほど差は縮まる傾向がありそうです。
この領域なら OpenSearch を足す理由はありません。 GSI で足りるなら DynamoDB だけで完結させるのが正解です。
検証② DynamoDB が苦手な検索
こちらの検証が本題です。前回実現できなかった複合条件と部分一致を投げてみます。
複合条件検索
5条件を同時に指定してみます。
棚卸しの現場で「ブラジル産で、E エリアにあって、単価1,500〜3,000円で、在庫が50〜300個のもの」を探すイメージです。
| 検索条件 | 値 |
|---|---|
| 商品ID 前方一致 | ITEM#BRA- |
| ロケーション 前方一致 | E- |
| 商品名 部分一致 | ブラジル |
| 単価 範囲 | 1,500〜3,000 |
| 数量 範囲 | 50〜300 |
OpenSearch に投げるクエリはこうなります。条件を bool.must に並べるだけです。
{
"query": {
"bool": {
"must": [
{ "term": { "warehouseId.keyword": "WH-TOKYO" } },
{ "prefix": { "itemId.keyword": "ITEM#BRA-" } },
{ "prefix": { "location.keyword": "E-" } },
{ "match": { "itemName": "ブラジル" } },
{ "range": { "unitPrice": { "gte": 1500, "lte": 3000 } } },
{ "range": { "quantity": { "gte": 50, "lte": 300 } } }
]
}
}
}
RDB の WHERE 句に条件を並べるのと同じ感覚で書けます。条件をいくつ増やしてもクエリの構造は変わらず、インデックスを選ぶ必要もありません。
結果は 45〜60ms で7件。条件に一致するデータがちょうど7件なので、全件が返っています。あわせて total で総件数も取れるので、画面に「該当7件」と表示できます。
一方の DynamoDB は 8〜20ms で0件でした。速いのですが、答えが返っていません。
GSI byUnitPrice が選ばれて「倉庫 + 単価」までは KeyCondition で処理できますが、残る4条件(商品ID・ロケーション・商品名・数量)はすべて FilterExpression に回ります。読み取った20件の中に4条件すべてを満たすものがなければ0件になります。
部分一致検索
他のケースも見てみます。OpenSearch 側の件数が「実際に条件に一致する件数」です。
| # | 検索条件 | OpenSearch | DynamoDB |
|---|---|---|---|
| 5 | 倉庫 + 商品名「ベリー シティ」 | 568件 / 42〜59ms | 3件 / 5〜9ms |
| 6 | 倉庫 + 商品名「ブラジル」+ 単価1000〜2500 | 78件 / 39〜56ms | 1件 / 8〜36ms |
| 7 | 倉庫 + ロケ B- + 数量100〜500 |
259件 / 42〜68ms | 6件 / 10〜45ms |
#5 の「ベリー シティ」のように単語を並べた検索でも期待どおりに動きます。match は文章を単語に分解して索引を引く仕組みなので、「ベリー」と「シティ」の両方を含む商品名がヒットします。DynamoDB の contains() は文字列としての部分一致なので、この書き方はできません。
レイテンシは40〜70ms 台で安定していて、条件の数や該当件数が増えても大きく変わりません。転置インデックスを引いているだけなので、絞り込みのコストが条件数に比例して増えないわけです。
DynamoDB の件数は先ほどの複合条件と同じく、ページネーションで取得した20件中に該当するレコードがあれば表示するだけになります。全体件数としては把握できません。
検証③ DynamoDB が構造的に不可能な検索
もう一つ、DynamoDB では手が出ない検索があります。倉庫を横断した検索です。
「全倉庫のブラジル産の在庫を見たい」という要望を投げてみます。
| OpenSearch | DynamoDB | |
|---|---|---|
| レイテンシ | 34〜44ms | 0ms |
| 取得件数 | 531件 | 0件(制約メッセージを返却) |
OpenSearch は 34〜44ms で531件(東京・大阪・福岡が混在)を返しました。インデックスに倉庫の区別がないので、そもそも横断という概念がありません。DynamoDB はアプリ側の制約メッセージを返却して終了してます。
DynamoDB について、今回はアプリケーションとしての制約で Query 検索のみにしています。PK を warehouseId にしているので倉庫を横断しての検索はできません。もちろん Scan を使えば全倉庫を操作できますが、一度全件を読み込む形になるのでオンライン画面としてはイケてない設計になってしまいます。
OpenSearch Serverless のコールドスタート
ここまで OpenSearch が有利な話が続きましたが、NextGen には明確な弱点があります。
ゼロ・スケールでアイドル時に OCU が 0 になるため、そこからの復帰に時間がかかります。
| 試行 | OpenSearch | DynamoDB |
|---|---|---|
| 1回目(コールドスタート) | 13,958ms | 347ms |
| 2回目 | 455ms | 101ms |
| 3回目 | 276ms | 55ms |
約14秒です。AWS のドキュメントには10〜30秒と書かれているので想定の範囲内ですが、検索ボタンを押してから14秒待たされるのは業務システムとしては厳しいところです。
2回目以降は数100msまで落ちるので、一度動き出せば実用的な速度になります。
コストとのトレードオフにはなりますが、判断としてはこうなるかと思います。
| ユースケース | 判断 |
|---|---|
| 管理画面・分析用途(多少待てる) | NextGen で問題なし |
| 常時使う業務画面・リアルタイム検索 | Classic(常時 OCU 稼働)を検討 |
| 定期的にウォームアップできる | NextGen + 定期実行で緩和 |
検証結果の整理
ここまでの検証結果を整理しておきます。
| 観点 | OpenSearch | DynamoDB GSI |
|---|---|---|
| 単純条件 | ◎ 46〜276ms | ◎ 11〜221ms |
| 複合条件 | ◎ 39〜60ms(全件返却) | ✗ FilterExpression 依存で不完全 |
| 部分一致 | ◎ | ✗ |
| 倉庫横断検索 | ◎ | ✗ 構造的に不可能 |
| 総件数の取得 | ◎ total で即時 |
✗ 全ページ走査が必要 |
| ページネーション | ◎ offset/size で自由 | ○ KeyCondition のみなら正確 |
| コールドスタート | ✗ 約14秒(NextGen) | なし |
線引きとしてはこうなります。
DynamoDB だけで足りるケース
検索の起点が決まっていて、単一の軸で絞り込む場合。倉庫を選んで商品コードで絞る、単価の範囲で絞る、といった使い方なら GSI で完結します。レイテンシも DynamoDB が有利です。
OpenSearch を足すケース
- 商品名などの部分一致・全文検索が必要
- 複数条件を自由に組み合わせたい
- 「該当◯件」を表示したい
- 検索の起点(今回なら倉庫)を指定せずに探したい
これらに該当する要件があり、RDB に移る流れもない場合は、OpenSearch の導入も視野に入れると良さそうです。
ここまでが検索機能としての比較でした。
以降は、この構成を実際に運用に乗せるときに気になった点を整理しておきます。
データ同期の仕組みと注意点
実際に運用するとなると、データ同期のパイプラインをどのように維持するかが重要になります。
DynamoDB x OpenSearch の同期の仕組みと、気をつけるべき点を見ていきます。
データ同期の2ステップ
OSIS のパイプラインはフルロードと CDC の2ステップで動きます。
シーケンス図にまとめると次のようになります。
| ステップ | 経路 | 役割 |
|---|---|---|
| フルロード | DynamoDB → PITR Export → S3 → Pipeline → OpenSearch (PITR: Point-in-Time Recovery) | 既存データの一括投入 |
| CDC | DynamoDB → Streams → Pipeline → OpenSearch | 差分のリアルタイム同期 |
パイプラインの設定で start_position: "LATEST" を指定しているため、Streams からはパイプライン起動時点以降の変更しか読みません。起動前に入っていたデータはフルロードで補完される、という役割分担です。
使用リソースについて
ここで一つ注意点があります。OSIS パイプラインは OpenSearch Service の一機能ですが、検索エンジン側とは別リソース・別のキャパシティモデルで動きます。
| 検索エンジン側 | Ingestion Pipeline | |
|---|---|---|
| リソース型 | AWS::OpenSearchServerless::Collection |
AWS::OSIS::Pipeline |
| OCU の種類 | Search OCU / Indexing OCU | Ingestion OCU(別枠) |
| 最小 OCU | 0(NextGen) | 1 |
| CLI 名前空間 | aws opensearchserverless |
aws osis |
つまり 検索エンジン側が 0 OCU に縮退しても、パイプラインは 1 OCU で動き続けます。ゼロ・スケールは検索側だけで、データを流し込む側には適用されません。停止しない限り最低 1 OCU 分(月 $175 程度)が発生し続けます。
キャパシティの見積もり
パイプラインのキャパシティが足りないと何が起きるかも押さえておきます。
1 Ingestion OCU ≈ 1,000 WCU 相当
パイプラインは minUnits〜maxUnits の範囲で自動スケールします。minUnits は下限であり、起動時の初期容量でもあります。
AWS の目安では 1 Ingestion OCU が約 1MB/秒の書き込みを処理でき、DynamoDB 側に換算すると約 1,000 WCU 相当です。例えば minUnits: 1 / maxUnits: 4 のように設定すると、上限は約 4,000 WCU 相当になります。
容量不足が続くとどうなるか
DynamoDB でパイプラインの能力を超えた大きな更新が継続すると、段階的に悪化します。
| 段階 | 何が起きるか |
|---|---|
| 1. 負荷増 |
minUnits → maxUnits の範囲で自動スケールアップ |
2. maxUnits でも不足 |
OpenSearch への反映が遅れ始める(EndtoEndLatency が上昇) |
| 3. 遅延が24時間超え | DynamoDB Streams の保持期間が24時間のため、読む前にレコードが期限切れ → 欠損 |
| 4. 復旧 | 欠損を直すにはインデックスの再構築が必要 |
データ保持期間がリミットとなり24時間の崖があります。それまでは「同期が遅れている」だけですが、24時間を超えた瞬間に「データが永久に欠ける」に変わります。緩やかに劣化したあとに崖が来るので、遅延の段階で気づけるかどうかが分かれ目です。
AWS のアラーム推奨にも、EndtoEndLatency が高い原因として「OpenSearch 側のスケール不足」と並んで「テーブルの WCU スループットに対してパイプラインの最大 OCU が低すぎること」が挙げられています。
監視すべきメトリクス
| メトリクス | 意味 |
|---|---|
opensearch.EndtoEndLatency.avg |
同期遅延。上昇は maxUnits 不足 or OpenSearch 側の詰まり |
dynamodb.changeEventsProcessed.count |
Streams から取得したイベント数。0 が続くなら停止中か権限異常 |
BlockingBuffer.bufferUsage.value |
内部バッファ使用率。飽和は処理が追いついていない兆候 |
dynamodb.exportJobFailure.count |
初回フルロードのエクスポート失敗 |
EndtoEndLatency にアラームを張っておけば、データが欠落する前に遅延に気付けます。
代替案
パイプラインが常時 1 OCU で課金されるのを避ける方法として、DynamoDB Streams → Lambda → OpenSearch の自作構成があります。OSIS が登場する前からの定番パターンです。ステップ2の CDC で効いてくる施策です。
Lambda は呼ばれた分だけの課金なので、書き込みがなければ $0 です。 規模が小さい間は Lambda も無料枠に収まります。
CDC の実装イメージ
CDC の処理内容は「Streams から受け取った変更イベントを OpenSearch の _bulk に投げる」だけなので、コードとしては単純です。
-
INSERT/MODIFY→indexアクション -
REMOVE→deleteアクション -
StreamViewType: NEW_AND_OLD_IMAGESにしておけば新旧両方の値が取れる
失敗時のリトライや DLQ の設定は自分で用意することになりますが、CDC 自体は Lambda で十分にカバーできます。
初回フルロードは OSIS を併用
Streams には過去のデータが流れてきません。Lambda で CDC を動かしても、既存のレコードは永遠に届かず OpenSearch は空のままです。
ここは OSIS に任せるのが早いです。OSIS の課金は動かしている時間に対してかかるので、初回ロードのときだけ OSIS を起動して、終わったら止めるだけで済みます。
1 OCU × $0.24/OCU-hour
24時間 × 30日 動かす → 約 $175/月
初回ロードで1時間だけ → $0.24
停止・再開は CLI で実行できます。インデックスを作り直したくなったら、また起動すればフルロードが再実行されます。
# 停止(OCU 課金が止まる)
aws osis stop-pipeline --pipeline-name kiro-inventory-pipeline --region us-west-2
# 再開(PITR フルエクスポートが再実行され、停止中の差分も追いつく)
aws osis start-pipeline --pipeline-name kiro-inventory-pipeline --region us-west-2
フルロードが投入するのはスナップショットを取った時点の値なので、その後の更新を古い値で上書きする可能性があります。更新の少ない時間帯にフルロードを走らせるなど、タイミングは運用側と相談しながら決めましょう。
Lambda 版の実装は今回試していません。
今回の代替案はランニングコストを下げる選択肢として整理したものです。
おまけ
その他、本検証を進めた中での気付き事項を紹介しておきます。
新機能は CDK の型定義にまだない
NextGen を有効化する Generation: 'NEXTGEN' が、CDK の CfnCollectionGroup の型定義に存在しませんでした。リリース直後の機能ではよくあることで、escape hatch で CloudFormation のプロパティを直接注入して回避します。
// 型定義にないプロパティを直接追加する
collectionGroup.addPropertyOverride('Generation', 'NEXTGEN');
注意点は、プロパティ名を間違えても何も起こらないことです。型チェックを通らないので CDK は警告を出さず、CloudFormation 側でも黙って無視されます。指定するのは CDK の camelCase ではなく CloudFormation の PascalCase です。
デプロイが成功しても設定が効いていない可能性があるので、コンソールで実際の値を確認するまでは信じないほうが安全でした。
完全一致検索が0件になる
OpenSearch の検索で、term クエリで完全一致検索を投げたのに0件しか返らない、という症状に当たりました。
原因は Dynamic Mapping です。マッピングを明示せずにドキュメントを投入すると、文字列フィールドは自動的に2つの形で保存されます。
| フィールド | 中身 |
|---|---|
itemName(text) |
「ブラジル」「サントス」「シティ」のようにトークンへ分割される |
itemName.keyword(keyword) |
元の文字列がそのまま保存される |
クエリの種類によって、どちらを見るかが変わります。
// ❌ term を text に投げるとトークン単位の比較になり期待と合わない
{ "term": { "warehouseId": "WH-TOKYO" } }
// ✅ 完全一致・前方一致は .keyword に投げる
{ "term": { "warehouseId.keyword": "WH-TOKYO" } }
// ✅ 全文検索は text をそのまま使う
{ "match": { "itemName": "ブラジル" } }
DynamoDB には「解析済みの形」と「そのままの形」が併存する概念がないので、ここは OpenSearch 特有の考え方だと思います。
Dynamic Mapping には型が固定される性質もあります。フィールドの型は最初に見た値で決まり、後から変更できません。
DynamoDB はアイテムごとに属性の型が違っても書き込めるので、同じ属性が文字列と数値で混在していると、後から来た方が mapper_parsing_exception で失敗します。DynamoDB 側にはエラーが出ないため、気づかないうちに OpenSearch 側だけデータが欠けます。
同期対象の属性は型を揃えておくか、重要なものは明示的にマッピングを定義しておくのが安全です。
検索のコストは $1.55
検証期間中の OpenSearch Service の請求は $22.34 でした。内訳は以下の通り。

| 種別 | コスト | 割合 |
|---|---|---|
| Ingestion OCU(データ同期) | $20.71 | 93% |
| Search OCU(検索) | $1.55 | 7% |
| Indexing OCU | $0.05 | 0.2% |
| ストレージ | $0.0001 | ほぼ0 |
検索そのものは $1.55 で済んでいます。 ゼロ・スケールが効いて、検証で叩いた時間だけ課金され、それ以外は 0 OCU に落ちていたためです。「OpenSearch を足すと高い」というイメージがありましたが、NextGen で検索用途に限れば、実際にかかるのはこの程度でした。
残りの93%はデータ同期側です。検証したのは 8/8 の1日だけなのに、その後も毎日 $5.76 が計上され続けていました。
1 OCU × 24時間 × $0.24/OCU-hour = $5.76/日
パイプラインを止め忘れていた分です。この請求を見るまで、パイプラインは「停止しない限り課金され続ける」ことに気づきませんでした。この事故を防ぐためにも、先に書いた「初回だけ OSIS、以降は Lambda」という併用が効いてきます。
まとめ
DynamoDB に OpenSearch を足すことで、前回できなかった検索がすべて実現できました。
-
部分一致 —
matchで全文検索 -
複合条件 —
bool.mustに並べるだけ - 倉庫横断 — インデックスに倉庫の区別がないので自然に対応
-
総件数 —
totalで即時取得
一方で、GSI で完結する検索は DynamoDB の方が速いという結果も出ました。OpenSearch を入れたからといって DynamoDB 側の検索を全部移すのではなく、得意な検索は DynamoDB、苦手な検索は OpenSearch という振り分けが現実的です。
また、NextGen のコールドスタートを許容できるかどうかもコスト面では大きな分岐点になりそうですね。データパイプライン OSIS についても常時稼働モデルしかないという点には要注意です。





