# Terraform でコンソール構築の AWS アカウントを本番から作り直す — 読み取り専用の spec、設定差分の検証、GitHub OIDC

> コンソールで作られた AWS のデータ層を、本番を読み取り専用で採取した結果から Terraform でコード化しました。設定の差分で同等性を確かめ、GitHub OIDC 経由で実行します。

- Source: https://oharu121.com/ja/blog/terraform-rebuild-console-aws-account-prod-spec-parity-oidc/
- Published: 2026-10-11T10:38:54+09:00
- Tags: Terraform, AWS, GitHub Actions

---
## はじめに

私が関わっている本番の AWS データ層は、すべてのリソースがコンソールから手作業で作られていました。S3 バケット、Glue テーブル、OpenSearch Serverless のコレクション、2 つの Bedrock ナレッジベース、Lambda、そして AgentCore のゲートウェイとコードインタープリタです。どれも定義がどこにも書かれていなかったので、その設定をほかの環境に複製するのは困難でした。

その後、新しい機能を本番に出す前に試すためのテストアカウントが追加されました。このテスト環境にはメインのアプリケーションのスタックがすでにデプロイされていて、データ層は空でした。私はこれを使い、データ層を Terraform モジュールとして書きました。モジュールの拠り所は、**本番から読み取り専用で採取した設定**です。そのモジュールをテストアカウントに適用し、**説明のつかない差分がなくなるまで** 2 つのアカウントを比較しました。

今ではデータ層の定義はリポジトリにしかなく、テストアカウントはそこから作り直せます。プルリクエストごとに GitHub Actions が OIDC 経由で `terraform plan` を実行し、`main` にマージすると変更がテストアカウントに適用されます。

## CDK ではなく Terraform を選んだ理由

**決め手は既存リソースの取り込み方でした**。リポジトリは Python で書かれていて、CDK も Python で書けるので、言語は揃えられました。それでも、インポートと影響範囲の 2 点で Terraform が上回っていました。

1. **インポート**。どちらのツールも Bedrock ナレッジベースを管理下に入れられます。違いは、定義を誰が書くかです。`terraform plan -generate-config-out` は稼働中のリソースから HCL を書き出すので、次の `plan` で設定にどんな変化があったかがわかります。`cdk import` では稼働中のリソースと完全に一致するコンストラクトを自分で書く必要があり、`cdk import` はそのプロパティが正しく漏れのないものかをチェックしません。

*Figure — ImportPaths: どちらの経路でもナレッジベースは管理下に入ります。Terraform は稼働中のリソースから定義を書き出し、次の plan で確かめます。CDK ではコンストラクトを手で書き、それを確かめる手順はありません。*

> **ちなみに**
>
> **HCL と `plan` の比較の仕組み**
>
> HCL（HashiCorp Configuration Language）は Terraform の設定言語で、各リソースのあるべき姿を記述します。Terraform は `plan` と `apply` の 2 段階で動きます。`plan` は実際のリソースの現状を読み取って HCL と比べ、作成・変更・削除する項目を一覧にします。`apply` はその計画を実行し、結果を state に書き込みます。そのため、生成された HCL と稼働中のリソースに違いがあれば、次の `plan` に表示されます。

2. **影響範囲**。既存リソースを取り込んだ CloudFormation スタックはそれらを 1 つに束ねるので、更新が 1 回失敗するとスタック全体がロールバックします。Terraform はリソース単位で適用するので、成功した分はそのまま残り、state に記録されます。

*Figure — BlastRadius: 1 回の更新で A・B・C を変更し、B が失敗した場合です。CloudFormation は更新全体を元に戻し、Terraform は A を state に残して、B に依存するものだけが未適用になります。*

CDK には言語以外にもう 1 つ利点がありました。state です。CloudFormation は各スタックの状態をサービスの中で保持し、そのロック、履歴、ロールバックは AWS が担います。CDK もバケットを作りますが、それは両方のアカウントにすでにあった `CDKToolkit` ブートストラップスタックのもので、アセットの置き場にすぎません。

Terraform の state はテストアカウントの S3 バケットに置くファイルですが、そのバケットは手作業でブートストラップする必要がありました。バケット自体も Terraform で作るので、バケットができるまでは state の置き場所がありません。そこで最初の `apply` では state をローカルディスクに置き、バケットができてから `terraform init -migrate-state` でそのローカルの state ファイルをバケットへコピーします。

ブートストラップは一度で済みますが、管理は続きます。state ファイルにはリソースの属性が平文で入ることがあるので、バケットには機密データを置くバケットと同じアクセス制御とバージョニングが必要です。また、state と実際のリソースが食い違ったときは `terraform state` コマンドで直します。

*Figure — StateOwnership: CDK の状態は CloudFormation の中にあり、AWS が管理します。Terraform の state は手作業でブートストラップしたバケットの中のファイルで、その管理はずっと続きます。*

結局、テストアカウントでインポートしたのは、私が以前手で追加していたセキュリティグループのルール 2 つだけで、インポートはほとんど効きませんでした。それより重要だったのは OpenSearch のインデックスです。Terraform に管理させる方法にはどれも代償があり（後の節で扱います）、CDK にしても状況は変わりませんでした。

テストアカウントのデータ層はもともと空だったので、Terraform はほとんどのリソースを新しく作りました。本番は違います。すべてのリソースがすでにコンソールで作られていて、実際のデータも入っているので、削除して作り直すことはできず、1 つずつ Terraform にインポートするしかありません。既存リソースの取り込みが本当に効いてくるのはそのときです。

## 本番は読み取り専用の仕様、テストはその証明

**Terraform は本番に一切触れません**。私は `List`・`Get`・`Describe` など、読み取りだけを行う AWS API を呼ぶスクリプトで本番を一度だけ読み取り、Glue の列定義、ベクトルインデックスのマッピング、AgentCore ゲートウェイが公開するツールスキーマを、モジュールの隣にある `spec/` ディレクトリに保存しました。データ層の Terraform モジュールはこれらのファイルを読むので、どの設定も本番に実在するリソースと対応づけられます。

*Figure — ProdAsSpec: 本番は読むだけです。モジュールはテストアカウントに適用し、データは別のスクリプトでコピーし、スナップショットの差分で両方のアカウントを比較します。データのレーンとスナップショットの差分は、このあと説明します。*

Terraform モジュール自体には**アカウント ID を一切持たせていません**。モジュールが必要とする外部のリソースは、ID を書き込まずに参照で見つけます。ランディングゾーンはどのアカウントでも VPC とサブネットに同じ `Name` タグを付けるのでタグで、アプリケーションスタックのセキュリティグループとタスクロールは名前で参照します。これらの名前はどのアカウントでも同じなので、**あとで本番にもこのモジュールを使うときは、モジュールを変えずに tfvars ファイルを 1 つ足すだけで済みます**。

*Figure — ModuleReuse: 1 つのモジュールを 2 つのアカウントで使います。モジュールはネットワークを Name タグで、アプリケーションスタックのリソースを名前で見つけるので、違うのは tfvars ファイルだけです。*

S3 のバケット名は、すべての AWS アカウントを通して一意でなければなりません。本番のドキュメント用バケット 2 つは名前にアカウント ID が入っていないので、テストアカウントでは同じ名前のバケットを作れません。バケット名の末尾にアカウント ID を付ける（`kb-docs` → `kb-docs-222222222222`）か、環境名を付ける（`kb-docs` → `kb-docs-stg`）のが安全です。

AWS のリソースがそろったら、次は本番に保存されているデータをコピーします。S3 バケットのファイル（Athena が参照するデータを含む）や、DynamoDB にあるアプリケーションのレコードです。私はこれらのデータを Terraform の state に入れず、シードスクリプトで 2 段階に分けてコピーすることにしました。`export` が本番のデータをローカルのキャッシュに読み出し、`import` がそのキャッシュをテストアカウントの対応する場所に書き込みます。

ナレッジベースの ID、ゲートウェイの URL、コードインタープリタの ID、バケット名などの値は、すべて置き換える必要があります。そのままコピーすると、テストアカウントのアプリケーションが本番のナレッジベースやゲートウェイを呼んでしまいます。そこで `import` は書き込みながら、本番の値をすべてテストアカウントの対応する値に置き換えます。バケット名は命名規則から決まりますが、ナレッジベースの ID のように AWS が生成する値は、Terraform がリソースを作ってからでないと取得できません。

```python title="scripts/seed_test_env.py"
# 1 回のパスで、長い一致を先に置換する。テストのバケット名は本番の
# バケット名で始まる（"docs" -> "docs-222222222222"）ため、str.replace を
# 順に呼ぶと、書き換え済みの値をもう一度書き換えてしまう。
pattern = re.compile("|".join(re.escape(k) for k in sorted(mapping, key=len, reverse=True)))
text = pattern.sub(lambda m: mapping[m.group(0)], json.dumps(item))
if leftover.search(text):
    raise SystemExit(f"prod value survived in {label}")
```

## Terraform が管理するものと、参照するだけのもの

ほかのチームが作ったものは、Terraform が data source（読み取るだけで管理しない参照）で参照し、その上に追加で必要なものだけを resource として書き、Terraform が作成して管理します。VPC とサブネットはランディングゾーンに属し、ECS サービスとそのセキュリティグループ、タスクロールは、アプリケーションチームが管理する CloudFormation テンプレートに属しています。**Terraform はそのすべてを読み取り、どれも作成しません**。

*Figure — OwnershipBoundary: Terraform はランディングゾーンのネットワークとアプリケーションのスタックを参照し、データ層と、これまで手で追加していたルールを管理します。*

本番と、テストに新しく作ったアプリケーションスタックを比べると、テンプレートが所有するリソースに**私が以前本番で手作業で加えた変更**が見つかりました。

1. サービスのセキュリティグループに付いた、ポート 3000 のインバウンドルール。テンプレートのルールは「ロードバランサーのセキュリティグループからの通信を許可する」もので、ロードバランサーから直接届いたパケットにしか一致しません。ここでは public subnet と protected subnet の間の通信が、landing zone の Gateway Load Balancer（GWLB）エンドポイントを経由して、提供元の inspection VPC で検査されます。そのため届いたパケットはもうそのグループからのものとして扱われず、テンプレートのルールが一致しません。そこで私が以前、ロードバランサーのサブネットの IP 範囲（CIDR）を許可するルールを手で追加しました。これなら、パケットがどの経路を通っても一致します。

*Figure — InspectionPath: landing zone の Inspection VPC を経由すると、パケットはロードバランサーのセキュリティグループからのものとして扱われないので、一致するのは CIDR のルールだけです。*

2. 送信元がセキュリティグループ自身になっている、ポート 443 のインバウンドルール。「同じセキュリティグループのメンバー同士は通信できる」という意味です。ECS タスク、Lambda、`bedrock-runtime` の VPC エンドポイントはどれもこのセキュリティグループに属しているので、このルールがあって初めて、タスクと Lambda がエンドポイントに到達できます。
3. タスクロールの 2 つのインラインポリシー。1 つは Athena と Glue へのアクセスを、もう 1 つは元の PDF の読み取りを許可します。

3 つとも、テンプレートのオブジェクトにアタッチする独立したリソースとして、今は Terraform が管理しています。スタックの更新でどれかが消えても、次の `apply` で元に戻ります。エンドポイントも 443 のルールができてから作成します。エンドポイントはプライベート DNS が有効なので、作成された時点で、アプリケーション自身の呼び出しも含めて VPC 内のすべての `bedrock-runtime` 呼び出しがそこを通ります。ルールがまだなければ、それらの呼び出しはすべて遮断されるので、`depends_on` で順序を保証しています。

```hcl title="terraform/modules/data-layer/network.tf"
resource "aws_vpc_endpoint" "bedrock_runtime" {
  vpc_id              = data.aws_vpc.lz.id
  service_name        = "com.amazonaws.${local.region}.bedrock-runtime"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = local.protected_subnet_ids
  security_group_ids  = [local.service_sg_id]
  private_dns_enabled = true

  depends_on = [aws_vpc_security_group_ingress_rule.self_https]
}
```

## `terraform_data` で OpenSearch Serverless のインデックスを作る

**ベクトルインデックスはスクリプトからネイティブ API で作成し、存在しないときだけ作成します。Terraform が更新や削除をすることはありません**。Terraform でインデックスを管理する方法がないわけではありませんが、どの方法にも代償があります。

1. AWS プロバイダにはインデックスのリソースがありません。`internal/service/opensearchserverless/` にはコレクション、ポリシー、VPC エンドポイントがありますが、`index.go` はありません。
2. `opensearch` プロバイダならインデックスを管理できます。ただし、Bedrock がマッピングに動的フィールドを追加すると置き換えを計画し（[#175](https://github.com/opensearch-project/terraform-provider-opensearch/issues/175)、v2.6.0 でも未解決）、インデックスを置き換えると中のベクトルがすべて消えます。`lifecycle { ignore_changes = [mappings] }` と `prevent_destroy` を組み合わせれば、このリスクは避けられます。しかし設定の問題は残ります。このプロバイダの `url` には collection のエンドポイントを指定しますが、エンドポイントは collection を作成するまで存在しません。そのため新しいアカウントでは apply を 2 回に分け、1 回目は `-target` で collection だけを作る必要があり、apply を 1 回しか実行しない CI の自動 apply では対応できません。この制約はプロバイダの issue で報告されているもので、ここでは再現していません。
3. CloudFormation には `AWS::OpenSearchServerless::Index` があるので、`aws_cloudcontrolapi_resource` が使えそうに見えました。しかし Cloud Control は、`ListResources` で本番のインデックスを一覧できても、`GetResource` ではどのインデックスの設定も読み取れずに失敗します。

```bash
aws cloudcontrol get-resource --type-name AWS::OpenSearchServerless::Index --identifier "docs-index|https://<collection-id>.ap-northeast-1.aoss.amazonaws.com"
# aws: [ERROR]: An error occurred (GeneralServiceException) when calling the GetResource operation: AWS::OpenSearchServerless::Index Handler returned status FAILED: Error occurred during operation 'GetIndex'.
```

Terraform は `plan` のたびに、まずリソースの最新の状態を読み取ります（refresh）。そのため `GetResource` が失敗する限り、`plan` も毎回失敗します。ネイティブの `create-index` と `get-index` は動くので、私は存在しなければ作成し、それ以上は何もしない小さなスクリプトに切り替えました。

```bash title="terraform/modules/data-layer/scripts/ensure_index.sh"
for i in $(seq 1 12); do
  if aws opensearchserverless get-index --id "$COLLECTION_ID" --index-name "$INDEX_NAME" >/dev/null 2>&1; then
    echo "index ${INDEX_NAME}: exists"; exit 0
  fi
  # 作成したばかりのデータアクセスポリシーは、使えるようになるまで時間がかかることがあるので、リトライする。
  if aws opensearchserverless create-index --id "$COLLECTION_ID" --index-name "$INDEX_NAME" \
      --index-schema "file://${SCHEMA_FILE}" >/dev/null 2>&1; then
    echo "index ${INDEX_NAME}: created"; exit 0
  fi
  sleep 15
done
exit 1
```

スクリプトにしたことで、プロバイダも 1 つ減りました。サードパーティのプロバイダも、そのための 2 つ目の SigV4 設定も、追いかけるバージョンもありません。その代わり、インデックスはどの plan にも現れません。マッピングのずれは Terraform からは見えず、この記事の後半で扱うスナップショットの差分でしか検出できません。マッピングの変更も手作業で適用します。

私は `terraform_data` リソースを作り、コレクション ID、インデックス名、マッピングファイルのハッシュをトリガーにしてこのスクリプトを実行するようにしました。インデックスとナレッジベースの間には、60 秒の `time_sleep` も挟んでいます。インデックスは作成直後にはまだ反映中で、そのタイミングでナレッジベースを作ると、インデックスが見つからずに失敗することがあるためです（「no such index」）。

私はマッピングファイルに、ナレッジベースが指定する 3 つのフィールド（ベクトル、テキスト、メタデータ）だけを書きました。**残りは取り込みの間に Bedrock が動的に追加します**。

## 2 つのアカウントの一致を確かめる：正規化した設定の比較と、同じ検索結果

私はスナップショットスクリプトを書きました。**このスクリプトは本番とテストアカウントのリソースを名前で探して設定を比べ、`EXPECTED_DIFFS` に載っていない差分が 1 つでもあれば失敗します**。ナレッジベースからゲートウェイまで、どのリソースも名前で見つけて比較します。

比較の前に、スクリプトはアカウント固有の値を `<ACCT>`、`<bucket:rag>`、`<kb:docs>`、`<subnet:LZPrivateSubnet1a>` のようなプレースホルダーに置き換えます。こうすれば、アカウント ID のようにもともと違う値は差分として出ません。リストはプレースホルダーを入れたあとでソートするので、本番とテストアカウントから返る JSON は順序がそろいます。

```python title="scripts/infra_snapshot.py"
EXPECTED_DIFFS = {
    "lambda.code_sha256": "test runs the repository handler; prod runs older console code",
    "lambda.handler": "same reason",
    "kb_data_sources.*.name": "prod names were generated by the console quick-create",
    "*.tags": "test carries default_tags (ManagedBy, Repository)",
}
```

初回の実行では、3 つの差分が出ました。どれも Terraform モジュールの設定の誤りです。

1. S3 バケットキーが本番では有効で、テストアカウントでは無効でした。
2. 本番ではコンソールがすべてのナレッジベースのロールに marketplace のステートメントを追加しますが、モジュールにはそれがありませんでした。
3. ゲートウェイロールのステートメントのスコープが本番と違っていました。

私がこの 3 つを直すと、**`EXPECTED_DIFFS` に載っている項目以外の差分はすべて消えました**。取り込み中に Bedrock が追加したフィールドも含めて、インデックスのマッピングも一致しました。

ただし、設定が一致していても挙動が同じとは限らないので、私は両方のアカウントで同じ検索テストを実行しました。2 つのナレッジベースにそれぞれ 5 つのクエリを投げ、上位 5 件を取ります。**10 件のクエリすべてで同じドキュメントが同じ順序で返り**、テスト側の出典リンクはすべてテストアカウントのバケットを指していました。Athena で 5 つのテーブルの行数も数え、両方の結果は完全に一致しました。

## GitHub OIDC：PR で plan、main へのマージで apply

私は GitHub Actions のワークフローを設定しました。`terraform/` に触れる PR ごとに plan を実行してその PR に結果をコメントし、PR が `main` にマージされたらテストアカウントに apply します。AWS の認証情報は GitHub の OIDC トークンから得るので、リポジトリにはキーを一切保存していません。

### 2 つのロールと 2 つの subject

GitHub が発行する OIDC トークンには subject（`sub` フィールド）があり、トークンがどのリポジトリから、どのイベントで発行されたかを記録しています。AWS の信頼ポリシーはこの値を比べて、どのロールを誰が引き受けられるかを決めます。そこで私は、それぞれ 1 種類の subject だけを信頼する 2 つのロールを用意しました。

1. **plan ロール**：plan ジョブは PR のコードを実行するので、plan ロールは `pull_request` の subject だけを信頼し、読み取り専用です。
2. **apply ロール**：apply ジョブは GitHub の environment `test` で動くので、apply ロールは `environment:test` の subject だけを信頼し、管理者権限を持ちます。

*Figure — OidcRoles: plan ロールは PR を信頼し、データは読めません。apply ロールは test environment だけを信頼し、その environment は main からのデプロイしか受け付けません。*

私は 2 つのジョブを別々の concurrency グループに置きました。GitHub はグループごとに待機中の実行を 1 つしか保持しないので、**グループを共有すると、新しい PR の plan が待機中の apply を置き換えてしまいます**。Terraform は plan や apply の前に、S3 の state ファイルの隣に `.tflock` というロックファイルを作るので、2 つのジョブが同時に state を操作することはありません。

### `sub` のプレフィックスを修正する

最初の PR は、Terraform が起動する前に失敗しました。

```text
Retry AssumeRole: attempt 1 of 12 failed: Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity.
```

信頼ポリシーが期待していたのは `repo:<owner>/<repo>:pull_request` でした。**しかし、このリポジトリが送ってくるのは `repo:<owner>@<owner-id>/<repo>@<repo-id>:pull_request` です**。オーナーとリポジトリの数値 ID が埋め込まれています。信頼ポリシーは subject を文字どおり比較するので、AWS は `AssumeRoleWithWebIdentity` を拒否しました。GitHub が実際に使っているプレフィックスは、この API で確認できます。

```bash
gh api repos/<owner>/<repo>/actions/oidc/customization/sub
# {"use_default":true,"use_immutable_subject":true,"sub_claim_prefix":"repo:<owner>@<owner-id>/<repo>@<repo-id>"}
```

> **ちなみに**
>
> **GitHub が subject を変更不可にした理由**
>
> 元のリポジトリが削除されたり、organization の名前が変わったりしたあとに、誰かが同じ名前を登録すると、GitHub はその新しいリポジトリに、古いリポジトリとまったく同じ subject のトークンを発行します。新しい所有者は元の信頼を引き継ぎ、古いリポジトリを信頼したままのクラウドリソースにアクセスできてしまいます。新しい形式は名前のあとに変更不可の ID を付け（例：`repo:octo-org@123/octo-repo@456`）、subject を元のリポジトリに結びつけます。

私は 2 つの信頼ポリシーを、このプレフィックスで照合するように変更しました。

```hcl title="terraform/bootstrap/test/ci.tf" del={2} ins={3}
locals {
  github_subject = "repo:<owner>/<repo>"
  github_subject = "repo:<owner>@<owner-id>/<repo>@<repo-id>"
}
# plan:  "${local.github_subject}:pull_request"
# apply: "${local.github_subject}:environment:test"
```

### ReadOnlyAccess に Bedrock の読み取りを足し、データの読み取りを拒否する

私が信頼ポリシーを直すと plan は先に進みましたが、今度は次のエラーが出ました。

```text
Error: listing tags for Bedrock Agents Knowledge Base (arn:aws:bedrock:ap-northeast-1:<account>:knowledge-base/<kb-id>)
AccessDeniedException: User: arn:aws:sts::<account>:assumed-role/github-terraform-plan/GitHubActions is not authorized to perform: bedrock:ListTagsForResource
```

`ReadOnlyAccess` には `bedrock:ListTagsForResource` が含まれていません。そこで私は plan ロールに、もともと持っていた `bedrock-agentcore` と `aoss` の読み取りに加えて、`bedrock:Get*` と `bedrock:List*` を付与しました。続けて `gh run rerun <run-id> --failed` で plan を再実行したので、新しいコミットなしで、更新したロールで実行し直せました。

一方で `ReadOnlyAccess` では、S3 のオブジェクト、DynamoDB の項目、CloudWatch のログイベントといった、plan には不要なものまで読めてしまいます。テストアカウントには本番データのコピーとアプリケーションのチャットログがあり、plan ロールは PR のコードを実行します。つまり、PR を作れる人なら誰でも plan を通じてこれらのデータを読めてしまいます。そこで私は**state バケット以外のデータの読み取りをすべて拒否するルールを追加しました**。

```hcl title="terraform/bootstrap/test/ci.tf"
statement {
  sid    = "DenyDataReads"
  effect = "Deny"
  actions = [
    "s3:GetObject", "s3:GetObjectVersion",
    "dynamodb:GetItem", "dynamodb:BatchGetItem", "dynamodb:Query", "dynamodb:Scan",
    "logs:GetLogEvents", "logs:FilterLogEvents", "logs:StartQuery",
    "athena:GetQueryResults", "bedrock:Retrieve", "bedrock:RetrieveAndGenerate",
  ]
  not_resources = ["${aws_s3_bucket.state.arn}/*"]
}
```

> **気を付けて**
>
> **`simulate-principal-policy` はすべて拒否した**
>
> 新しく追加した Deny を確かめるため、私は最初 `simulate-principal-policy` を使いました。ロールを 1 つ指定し、確かめたい action を並べると、action ごとに許可されるかどうかを返します。しかし、ランディングゾーンのサービスコントロールポリシーもまとめて評価するため、一律に `AllowedByOrganizations: false` を返しました。そこで `simulate-custom-policy` に切り替え、ロール自身のポリシーにすべてを許可するポリシーだけを加えて渡し、サービスコントロールポリシーの影響を除いて Deny だけを検証しました。

### deployment branch policy で apply を main に限定する

**AWS の信頼ポリシーでは、environment のジョブがどのブランチから来たのかを区別できません**。environment のジョブの subject（`sub`）には `…:environment:test` のように environment の名前しか入らず、ブランチは含まれないからです。GitHub 側にブランチのルールがなければ、任意のブランチからの `workflow_dispatch` や、ジョブに `environment: test` を追加した PR でも、管理者ロールを引き受けられてしまいます。

ワークフローが指定した environment がまだ存在しない場合、その environment は自動で作られ、デフォルトではブランチの保護がありません。そこで私は、ワークフローを初めて実行する前に `test` environment を作成し、`main` からしかデプロイできないように制限しました。

```bash
gh api -X PUT repos/<owner>/<repo>/environments/test --input - <<'JSON'
{"deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}}
JSON
gh api -X POST repos/<owner>/<repo>/environments/test/deployment-branch-policies -f name=main -f type=branch
```

> **よっしゃ！**
>
> マージ後、`main` からの apply はロールを引き受け、`Apply complete! Resources: 0 added, 0 changed, 0 destroyed.` を出力しました。Lambda のパッケージはランナー上で作り直されるので、ハッシュがローカルのビルドと違えば Terraform は Lambda を更新します。0 changed だったことで、2 つのビルドがまったく同じパッケージを作ったと確かめられました。

## AgentCore のネットワークインターフェイスがセキュリティグループを最大 8 時間つかんだまま残る

最後に、私はテストアカウントをゼロから作り直そうとしましたが、`terraform destroy` が、45 分の削除タイムアウトを過ぎたあとにコードインタープリタのセキュリティグループで止まりました。

```text
Error: deleting Security Group (sg-<id>): operation error EC2: DeleteSecurityGroup, https response error StatusCode: 400, api error DependencyViolation: resource sg-<id> has a dependent object
```

コードインタープリタ自体はすでに削除されていましたが、`agentic_ai` タイプのネットワークインターフェイス 2 つがまだセキュリティグループに付いていました。この 2 つは、テストアカウントではなく、AWS が管理する AgentCore のサービスアカウントがリクエストしたものです。AWS のドキュメントには、これらのネットワークインターフェイスが削除後**最大 8 時間残る場合がある**と書かれており、デタッチも許可されていません。

とはいえ、作り直しには影響しませんでした。ほかのリソースはすべて削除済みで、state に残っていたのはそのセキュリティグループだけだったので、私はそのまま `terraform apply` を実行しました。**apply は残ったセキュリティグループをそのまま使い、アカウントのほかの部分を作り直しました**。シードスクリプトを実行し直したあと、スナップショットの差分、検索テスト、Athena で数えた行数は、すべて以前と同じ結果になりました。

## まとめ

本番の設定を `spec/` ディレクトリに書き出したことで、Terraform モジュールには照合する基準ができました。CDK ではなく Terraform を選んだのは、既存リソースをインポートするときに設定を自動で書き出してくれるからです。ベクトルインデックスはネイティブ API を呼ぶスクリプトに任せました。新しいアカウントでは collection ができるまで `opensearch` プロバイダを設定できず、apply を 1 回しか実行しない CI では対応できないためです。

CI 側では、手作業で作ったロールでは起きない問題が 3 つありました。変更不可の形式の subject、`ReadOnlyAccess` に足りない Bedrock の読み取り権限と、持つべきでないデータの読み取り、そして apply ロールが信頼する environment に必要な deployment branch policy です。

## 参考リンク

- [Terraform：import ブロックと設定の生成](https://developer.hashicorp.com/terraform/language/import)
- [Terraform の S3 バックエンド（use_lockfile を含む）](https://developer.hashicorp.com/terraform/language/backend/s3)
- [CloudFormation：リソースインポートに対応したリソースタイプ](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/resource-import-supported-resources.html)
- [AWS CDK v2：cdk import](https://docs.aws.amazon.com/cdk/v2/guide/ref-cli-cmd-import.html)
- [GitHub changelog：GitHub Actions の OIDC トークンにおける変更不可の subject クレーム](https://github.blog/changelog/2026-04-23-immutable-subject-claims-for-github-actions-oidc-tokens/)
- [GitHub Docs：OpenID Connect リファレンス](https://docs.github.com/actions/reference/openid-connect-reference)
- [Amazon Bedrock AgentCore：コードインタープリタの削除と、ネットワークインターフェイスの 8 時間の保持](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/code-interpreter-delete.html)
- [terraform-provider-opensearch #175：動的プロパティによるインデックスの置き換え](https://github.com/opensearch-project/terraform-provider-opensearch/issues/175)
