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

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

白地に、紫の平行四辺形4つでTの形を作るTerraformのマークと、ほぼ黒のTerraformロゴタイプ
目次

はじめに

私が関わっている本番の 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 はそのプロパティが正しく漏れのないものかをチェックしません。
Terraform は稼働中のリソースから定義を書き出し、CDK では定義を手で書く左のコンソールで作ったナレッジベースから 2 本のレーンが伸びる。Terraform のレーンでは、リソース ID だけを書いた import ブロックを terraform plan -generate-config-out に渡し、稼働中のリソースを読んで HCL を書き出す。食い違いがあれば次の plan に表示され、リソースは Terraform の state に入る。CDK のレーンでは、稼働中のリソースと完全に一致させた手書きのコンストラクトを cdk import に渡す。cdk import はリソース名の入力を求め、CloudFormation の IMPORT 変更セットを実行し、リソースは CloudFormation のスタックに入る。CDK のレーンの下には、コンストラクトのプロパティが正しく漏れのないものかはチェックされないという注意がある。Terraform:定義は Terraform が書くCDK:定義は自分で書くコンソールで作ったナレッジベース稼働中のリソースimport ブロックリソース ID だけterraform plan-generate-config-out稼働中のリソースを読む生成された HCL次の plan で食い違いを表示Terraform の state管理下に入る手書きのコンストラクト稼働中のリソースと完全に一致させるcdk importリソース名を入力するCloudFormation のIMPORT 変更セットCloudFormationスタック管理下に入るコンストラクトのプロパティが正しく漏れのないものかはチェックされない
どちらの経路でもナレッジベースは管理下に入ります。Terraform は稼働中のリソースから定義を書き出し、次の plan で確かめます。CDK ではコンストラクトを手で書き、それを確かめる手順はありません。
  1. 影響範囲。既存リソースを取り込んだ CloudFormation スタックはそれらを 1 つに束ねるので、更新が 1 回失敗するとスタック全体がロールバックします。Terraform はリソース単位で適用するので、成功した分はそのまま残り、state に記録されます。
リソースが 1 つ失敗すると、CloudFormation は更新全体を、Terraform はそのリソースだけを取り消す2 つのパネルで同じ更新を実行する。更新はリソース A・B・C を変更し、B が失敗する。CloudFormation スタックのパネルでは、A はロールバックされ、B は失敗し、C はロールバックされるか未着手のままで、スタックは更新前の状態に戻る。Terraform のパネルでは、A は適用されて state に記録され、B は失敗して次の apply で再実行され、C は B に依存しない限り適用されるので、成功した分は state に残る。1 回の更新で A・B・C を変更し、B が失敗するCloudFormation スタックAロールバック変更が取り消されるB失敗ロールバックのきっかけCロールバック取り消されるか、未着手スタックは更新前の状態に戻るTerraformA適用済みstate に記録されるB失敗次の apply で再実行C適用済みB に依存する場合だけ未適用成功した分は state に残る
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 コマンドで直します。

CDK の状態は CloudFormation の中、Terraform の state は自分で管理し続けるファイル2 つのパネル。CDK のパネルでは、スタックの状態は CloudFormation サービスの中にあり、ブートストラップ済みの CDKToolkit スタックのステージング用バケットにはアセットだけが入る。ロック、履歴、ロールバックは AWS が担うと帯に書かれている。Terraform のパネルでは、1 回だけのブートストラップが 3 段階で進む。最初の apply で state をローカルディスクに書き、state バケットができ、terraform init -migrate-state で state をバケットへコピーする。その後、バケットには terraform.tfstate が入る。属性が平文で入ることがあり、use_lockfile でロックされる。アクセス、バージョニング、terraform state での修正は自分で担うと帯に書かれている。CDKCDKToolkit スタックステージング用バケット:アセットだけブートストラップ済みロック、履歴、ロールバックは AWS が担うTerraformバケット内の terraform.tfstate属性が平文で入ることがあるuse_lockfile でロックアクセス・バージョニング・state の修正は自分で担うCloudFormation サービススタックの状態はここにあるブートストラップ(1 回だけ)1. applystate はローカルに2. state バケットができる3. init-migrate-stateでバケットへ
CDK の状態は CloudFormation の中にあり、AWS が管理します。Terraform の state は手作業でブートストラップしたバケットの中のファイルで、その管理はずっと続きます。

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

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

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

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

本番は読むだけ、テストは作ってから比べる左の本番アカウントと右のテストアカウントを 3 本のレーンが結ぶ。インフラのレーンでは、List・Get・Describe だけを呼ぶ採取スクリプトが spec ディレクトリを書き、Terraform モジュールがそれを読んでテストアカウントに適用する。データのレーンでは、シードスクリプトが本番のセッションでローカルのキャッシュへ書き出し、テストのセッションで ID を書き換えて取り込む。検証のレーンでは、スナップショットの差分が両アカウントを読み、説明のつかない差分 0 を返す。インフラデータ(別スクリプト)検証本番アカウント読み取りのみ採取スクリプトList / Get / Describeだけを呼ぶspec/Glue の列定義インデックス定義ゲートウェイのツールTerraform モジュールspec/ を読むテストアカウントterraform applyexport本番のセッションローカルのキャッシュgitignore 済みimportテストのセッションID を書き換えスナップショットの差分両アカウントを名前で辿る説明のつかない差分 0
本番は読むだけです。モジュールはテストアカウントに適用し、データは別のスクリプトでコピーし、スナップショットの差分で両方のアカウントを比較します。データのレーンとスナップショットの差分は、このあと説明します。

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

Terraform モジュールはタグと名前ですべてを探すので、どのアカウントでも動く中央に、アカウント ID を持たないデータ層の Terraform モジュールがあり、左にテストアカウント、右に次の段階の本番アカウントが並ぶ。どちらのアカウントにも、同じ Name タグの VPC とサブネットを持つランディングゾーンと、同じ名前の SG とタスクロールを持つアプリの CloudFormation スタックがある。破線の矢印は、モジュールが各アカウントでネットワークを Name タグで、スタックのリソースを名前で探すことを示す。各アカウントには test.tfvars と prod.tfvars があり、環境ごとの値をモジュールに渡す。下の帯には、どのアカウントでもモジュールも名前も同じで、変わるのは tfvars ファイルだけと書かれている。テストアカウントランディングゾーンVPC とサブネット同じ Name タグCloudFormation スタックアプリの SG、タスクロール同じ名前test.tfvars環境ごとの値本番アカウント(次の段階)ランディングゾーンVPC とサブネット同じ Name タグCloudFormation スタックアプリの SG、タスクロール同じ名前prod.tfvars環境ごとの値Terraform モジュールデータ層アカウント ID なしネットワークをName タグで探すスタックのリソースを名前で探すどのアカウントでもモジュールも名前も同じ。変わるのは tfvars ファイルだけ
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 がリソースを作ってからでないと取得できません。

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 はそのすべてを読み取り、どれも作成しません。

ネットワークとアプリのスタックは参照し、残りは Terraform が管理する左右に 2 つの領域が並ぶ。左の「参照する」領域には、Name タグで参照するランディングゾーンの VPC とサブネット、名前で参照するアプリの CloudFormation スタック(ECS サービスの SG とタスクロール)がある。右の「Terraform が管理する」領域には、データ層と、スタックに付けるルールのカードがある。ルールは、ロードバランサーのサブネットからの 3000 番、VPC エンドポイントの前提になる 443 の自己参照、タスクロールのインラインポリシー。そのカードからアプリのスタックへ矢印が伸びる。参照する(data source)Terraform が管理するランディングゾーンVPC とサブネットName タグで参照アプリの CloudFormation スタックECS サービスの SGECS タスクロール名前で参照データ層S3 バケット、Glue テーブルOpenSearch Serverless とインデックスBedrock ナレッジベースLambdaAgentCore ゲートウェイ、コードインタープリタbedrock-runtime VPC エンドポイントスタックに付けるものLB サブネットからの 3000 番443 の自己参照(エンドポイントの前提)タスクロールのインラインポリシー
Terraform はランディングゾーンのネットワークとアプリケーションのスタックを参照し、データ層と、これまで手で追加していたルールを管理します。

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

  1. サービスのセキュリティグループに付いた、ポート 3000 のインバウンドルール。テンプレートのルールは「ロードバランサーのセキュリティグループからの通信を許可する」もので、ロードバランサーから直接届いたパケットにしか一致しません。ここでは public subnet と protected subnet の間の通信が、landing zone の Gateway Load Balancer(GWLB)エンドポイントを経由して、提供元の inspection VPC で検査されます。そのため届いたパケットはもうそのグループからのものとして扱われず、テンプレートのルールが一致しません。そこで私が以前、ロードバランサーのサブネットの IP 範囲(CIDR)を許可するルールを手で追加しました。これなら、パケットがどの経路を通っても一致します。
Inspection VPC を経由すると、一致するのは CIDR のルールだけになる2 段の図。上の段はテンプレートのルールが想定する経路で、public subnet のロードバランサーから protected subnet のサービスへ直接通信が届き、ロードバランサーのセキュリティグループからの通信を許可するルールが一致する。下の段は実際の経路で、ロードバランサーからの通信は landing zone の GWLB エンドポイントを通って landing zone 提供元の Inspection VPC に入り、戻ってからサービスに届く。この経路では、ロードバランサーのセキュリティグループからの通信を許可するルールは一致せず、手で追加したロードバランサーのサブネットの CIDR を許可するルールが一致する。テンプレートのルールが想定する経路実際の経路ロードバランサーpublic subnetサービス(ECS タスク)protected subnetロードバランサーpublic subnetGWLB エンドポイントlanding zoneサービス(ECS タスク)protected subnetInspection VPClanding zone の提供元ルール:LB の SG から→ 一致するルール:LB の SG から→ 一致しないルール:LB サブネットの CIDRから(手で追加)→ 一致する
landing zone の Inspection VPC を経由すると、パケットはロードバランサーのセキュリティグループからのものとして扱われないので、一致するのは CIDR のルールだけです。
  1. 送信元がセキュリティグループ自身になっている、ポート 443 のインバウンドルール。「同じセキュリティグループのメンバー同士は通信できる」という意味です。ECS タスク、Lambda、bedrock-runtime の VPC エンドポイントはどれもこのセキュリティグループに属しているので、このルールがあって初めて、タスクと Lambda がエンドポイントに到達できます。
  2. タスクロールの 2 つのインラインポリシー。1 つは Athena と Glue へのアクセスを、もう 1 つは元の PDF の読み取りを許可します。

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

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、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 ではどのインデックスの設定も読み取れずに失敗します。
ターミナルウィンドウ
入力
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 は動くので、私は存在しなければ作成し、それ以上は何もしない小さなスクリプトに切り替えました。

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 は順序がそろいます。

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 だけを信頼し、管理者権限を持ちます。
PR は plan だけ、apply は test environment を通る main からだけ2 本のレーン。上のレーンでは、プルリクエストが、subject の末尾が pull_request になる OIDC トークンを発行する。このトークンを信頼するのは plan ロールだけ。plan ロールは ReadOnlyAccess に Bedrock・OpenSearch Serverless・AgentCore の読み取りを足し、データの読み取りを拒否し、state バケットは読める。下のレーンでは、main へのマージで main からのみデプロイする test environment のジョブが走り、subject の末尾が environment:test になるトークンを発行する。このトークンを信頼するのは apply ロールだけで、AdministratorAccess を持ちテストアカウントに terraform apply する。プルリクエストterraform plansub クレームrepo:<owner>@<owner-id>/<repo>@<repo-id>:pull_requestplan ロールReadOnlyAccess+ bedrock・aoss・agentcore の読み取りデータの読み取りは拒否state バケットは読めるmain へのマージterraform applysub クレームrepo:<owner>@<owner-id>/<repo>@<repo-id>:environment:testapply ロールAdministratorAccessテストアカウントにterraform applyenvironment: testmain からのみデプロイ
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 が起動する前に失敗しました。

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 つの信頼ポリシーを、このプレフィックスで照合するように変更しました。

terraform/bootstrap/test/ci.tf
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: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 バケット以外のデータの読み取りをすべて拒否するルールを追加しました。

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}/*"]
}

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}}
JSON
gh api -X POST repos/<owner>/<repo>/environments/test/deployment-branch-policies -f name=main -f type=branch

AgentCore のネットワークインターフェイスがセキュリティグループを最大 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 です。

参考リンク

この記事をシェア