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

目次
はじめに
私が関わっている本番の AWS データ層は、すべてのリソースがコンソールから手作業で作られていました。S3 バケット、Glue テーブル、OpenSearch Serverless のコレクション、2 つの Bedrock ナレッジベース、Lambda、そして AgentCore のゲートウェイとコードインタープリタです。どれも定義がどこにも書かれていなかったので、その設定をほかの環境に複製するのは困難でした。
その後、新しい機能を本番に出す前に試すためのテストアカウントが追加されました。このテスト環境にはメインのアプリケーションのスタックがすでにデプロイされていて、データ層は空でした。私はこれを使い、データ層を Terraform モジュールとして書きました。モジュールの拠り所は、本番から読み取り専用で採取した設定です。そのモジュールをテストアカウントに適用し、説明のつかない差分がなくなるまで 2 つのアカウントを比較しました。
今ではデータ層の定義はリポジトリにしかなく、テストアカウントはそこから作り直せます。プルリクエストごとに GitHub Actions が OIDC 経由で terraform plan を実行し、main にマージすると変更がテストアカウントに適用されます。
CDK ではなく Terraform を選んだ理由
決め手は既存リソースの取り込み方でした。リポジトリは Python で書かれていて、CDK も Python で書けるので、言語は揃えられました。それでも、インポートと影響範囲の 2 点で Terraform が上回っていました。
- インポート。どちらのツールも Bedrock ナレッジベースを管理下に入れられます。違いは、定義を誰が書くかです。
terraform plan -generate-config-outは稼働中のリソースから HCL を書き出すので、次のplanで設定にどんな変化があったかがわかります。cdk importでは稼働中のリソースと完全に一致するコンストラクトを自分で書く必要があり、cdk importはそのプロパティが正しく漏れのないものかをチェックしません。
- 影響範囲。既存リソースを取り込んだ CloudFormation スタックはそれらを 1 つに束ねるので、更新が 1 回失敗するとスタック全体がロールバックします。Terraform はリソース単位で適用するので、成功した分はそのまま残り、state に記録されます。
CDK には言語以外にもう 1 つ利点がありました。state です。CloudFormation は各スタックの状態をサービスの中で保持し、そのロック、履歴、ロールバックは AWS が担います。CDK もバケットを作りますが、それは両方のアカウントにすでにあった CDKToolkit ブートストラップスタックのもので、アセットの置き場にすぎません。
Terraform の state はテストアカウントの S3 バケットに置くファイルですが、そのバケットは手作業でブートストラップする必要がありました。バケット自体も Terraform で作るので、バケットができるまでは state の置き場所がありません。そこで最初の apply では state をローカルディスクに置き、バケットができてから terraform init -migrate-state でそのローカルの state ファイルをバケットへコピーします。
ブートストラップは一度で済みますが、管理は続きます。state ファイルにはリソースの属性が平文で入ることがあるので、バケットには機密データを置くバケットと同じアクセス制御とバージョニングが必要です。また、state と実際のリソースが食い違ったときは terraform state コマンドで直します。
結局、テストアカウントでインポートしたのは、私が以前手で追加していたセキュリティグループのルール 2 つだけで、インポートはほとんど効きませんでした。それより重要だったのは OpenSearch のインデックスです。Terraform に管理させる方法にはどれも代償があり(後の節で扱います)、CDK にしても状況は変わりませんでした。
テストアカウントのデータ層はもともと空だったので、Terraform はほとんどのリソースを新しく作りました。本番は違います。すべてのリソースがすでにコンソールで作られていて、実際のデータも入っているので、削除して作り直すことはできず、1 つずつ Terraform にインポートするしかありません。既存リソースの取り込みが本当に効いてくるのはそのときです。
本番は読み取り専用の仕様、テストはその証明
Terraform は本番に一切触れません。私は List・Get・Describe など、読み取りだけを行う AWS API を呼ぶスクリプトで本番を一度だけ読み取り、Glue の列定義、ベクトルインデックスのマッピング、AgentCore ゲートウェイが公開するツールスキーマを、モジュールの隣にある spec/ ディレクトリに保存しました。データ層の Terraform モジュールはこれらのファイルを読むので、どの設定も本番に実在するリソースと対応づけられます。
Terraform モジュール自体にはアカウント ID を一切持たせていません。モジュールが必要とする外部のリソースは、ID を書き込まずに参照で見つけます。ランディングゾーンはどのアカウントでも VPC とサブネットに同じ Name タグを付けるのでタグで、アプリケーションスタックのセキュリティグループとタスクロールは名前で参照します。これらの名前はどのアカウントでも同じなので、あとで本番にもこのモジュールを使うときは、モジュールを変えずに tfvars ファイルを 1 つ足すだけで済みます。
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 がリソースを作ってからでないと取得できません。
# 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 はそのすべてを読み取り、どれも作成しません。
本番と、テストに新しく作ったアプリケーションスタックを比べると、テンプレートが所有するリソースに私が以前本番で手作業で加えた変更が見つかりました。
- サービスのセキュリティグループに付いた、ポート 3000 のインバウンドルール。テンプレートのルールは「ロードバランサーのセキュリティグループからの通信を許可する」もので、ロードバランサーから直接届いたパケットにしか一致しません。ここでは public subnet と protected subnet の間の通信が、landing zone の Gateway Load Balancer(GWLB)エンドポイントを経由して、提供元の inspection VPC で検査されます。そのため届いたパケットはもうそのグループからのものとして扱われず、テンプレートのルールが一致しません。そこで私が以前、ロードバランサーのサブネットの IP 範囲(CIDR)を許可するルールを手で追加しました。これなら、パケットがどの経路を通っても一致します。
- 送信元がセキュリティグループ自身になっている、ポート 443 のインバウンドルール。「同じセキュリティグループのメンバー同士は通信できる」という意味です。ECS タスク、Lambda、
bedrock-runtimeの VPC エンドポイントはどれもこのセキュリティグループに属しているので、このルールがあって初めて、タスクと Lambda がエンドポイントに到達できます。 - タスクロールの 2 つのインラインポリシー。1 つは Athena と Glue へのアクセスを、もう 1 つは元の PDF の読み取りを許可します。
3 つとも、テンプレートのオブジェクトにアタッチする独立したリソースとして、今は Terraform が管理しています。スタックの更新でどれかが消えても、次の apply で元に戻ります。エンドポイントも 443 のルールができてから作成します。エンドポイントはプライベート DNS が有効なので、作成された時点で、アプリケーション自身の呼び出しも含めて VPC 内のすべての bedrock-runtime 呼び出しがそこを通ります。ルールがまだなければ、それらの呼び出しはすべて遮断されるので、depends_on で順序を保証しています。
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 でインデックスを管理する方法がないわけではありませんが、どの方法にも代償があります。
- AWS プロバイダにはインデックスのリソースがありません。
internal/service/opensearchserverless/にはコレクション、ポリシー、VPC エンドポイントがありますが、index.goはありません。 opensearchプロバイダならインデックスを管理できます。ただし、Bedrock がマッピングに動的フィールドを追加すると置き換えを計画し(#175、v2.6.0 でも未解決)、インデックスを置き換えると中のベクトルがすべて消えます。lifecycle { ignore_changes = [mappings] }とprevent_destroyを組み合わせれば、このリスクは避けられます。しかし設定の問題は残ります。このプロバイダのurlには collection のエンドポイントを指定しますが、エンドポイントは collection を作成するまで存在しません。そのため新しいアカウントでは apply を 2 回に分け、1 回目は-targetで collection だけを作る必要があり、apply を 1 回しか実行しない CI の自動 apply では対応できません。この制約はプロバイダの issue で報告されているもので、ここでは再現していません。- CloudFormation には
AWS::OpenSearchServerless::Indexがあるので、aws_cloudcontrolapi_resourceが使えそうに見えました。しかし Cloud Control は、ListResourcesで本番のインデックスを一覧できても、GetResourceではどのインデックスの設定も読み取れずに失敗します。
入力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 は動くので、私は存在しなければ作成し、それ以上は何もしない小さなスクリプトに切り替えました。
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 15doneexit 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 は順序がそろいます。
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 モジュールの設定の誤りです。
- S3 バケットキーが本番では有効で、テストアカウントでは無効でした。
- 本番ではコンソールがすべてのナレッジベースのロールに marketplace のステートメントを追加しますが、モジュールにはそれがありませんでした。
- ゲートウェイロールのステートメントのスコープが本番と違っていました。
私がこの 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 つのロールを用意しました。
- plan ロール:plan ジョブは PR のコードを実行するので、plan ロールは
pull_requestの subject だけを信頼し、読み取り専用です。 - apply ロール:apply ジョブは GitHub の environment
testで動くので、apply ロールはenvironment:testの subject だけを信頼し、管理者権限を持ちます。
私は 2 つのジョブを別々の concurrency グループに置きました。GitHub はグループごとに待機中の実行を 1 つしか保持しないので、グループを共有すると、新しい PR の plan が待機中の apply を置き換えてしまいます。Terraform は plan や apply の前に、S3 の state ファイルの隣に .tflock というロックファイルを作るので、2 つのジョブが同時に state を操作することはありません。
sub のプレフィックスを修正する
最初の PR は、Terraform が起動する前に失敗しました。
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 で確認できます。
入力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>"}私は 2 つの信頼ポリシーを、このプレフィックスで照合するように変更しました。
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 は先に進みましたが、今度は次のエラーが出ました。
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:ListTagsForResourceReadOnlyAccess には 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 バケット以外のデータの読み取りをすべて拒否するルールを追加しました。
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}/*"]}deployment branch policy で apply を main に限定する
AWS の信頼ポリシーでは、environment のジョブがどのブランチから来たのかを区別できません。environment のジョブの subject(sub)には …:environment:test のように environment の名前しか入らず、ブランチは含まれないからです。GitHub 側にブランチのルールがなければ、任意のブランチからの workflow_dispatch や、ジョブに environment: test を追加した PR でも、管理者ロールを引き受けられてしまいます。
ワークフローが指定した environment がまだ存在しない場合、その environment は自動で作られ、デフォルトではブランチの保護がありません。そこで私は、ワークフローを初めて実行する前に test environment を作成し、main からしかデプロイできないように制限しました。
gh api -X PUT repos/<owner>/<repo>/environments/test --input - <<'JSON'{"deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}}JSONgh api -X POST repos/<owner>/<repo>/environments/test/deployment-branch-policies -f name=main -f type=branchAgentCore のネットワークインターフェイスがセキュリティグループを最大 8 時間つかんだまま残る
最後に、私はテストアカウントをゼロから作り直そうとしましたが、terraform destroy が、45 分の削除タイムアウトを過ぎたあとにコードインタープリタのセキュリティグループで止まりました。
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 ブロックと設定の生成
- Terraform の S3 バックエンド(use_lockfile を含む)
- CloudFormation:リソースインポートに対応したリソースタイプ
- AWS CDK v2:cdk import
- GitHub changelog:GitHub Actions の OIDC トークンにおける変更不可の subject クレーム
- GitHub Docs:OpenID Connect リファレンス
- Amazon Bedrock AgentCore:コードインタープリタの削除と、ネットワークインターフェイスの 8 時間の保持
- terraform-provider-opensearch #175:動的プロパティによるインデックスの置き換え




