用 Terraform 照著 production 重建一份主控台手動建立的 AWS 帳戶 — 唯讀規格、設定差異比對與 GitHub OIDC
以唯讀方式擷取 production 的設定,把由 AWS 主控台建立的 AWS 資料層寫成 Terraform 程式碼。接著用設定差異比對驗證,並透過 GitHub OIDC 執行部署。

本頁目錄
引言
我有一個專案,其 production 環境的資料層,所有資源都是在 AWS 主控台手動建立的。其中包括 S3 bucket、Glue 資料表、一個 OpenSearch Serverless collection、兩個 Bedrock knowledge base、一個 Lambda,還有 AgentCore 的 gateway 與 code interpreter。這些資源都沒有留下任何定義,所以很難把其設定複製到其他環境。
後來新增了一個測試用帳戶,用來在部署到 production 環境前測試新加入的功能。這個測試環境裡面已經部署了主應用程式的 stack,資料層則是空的。我利用它把資料層寫成 Terraform 模組,模組的依據是以唯讀方式從 production 擷取的設定。接著在測試帳戶套用這個模組,再比對兩個帳戶,直到沒有任何無法解釋的差異為止。
現在,repo 是資料層唯一的定義來源,測試帳戶可以直接從 repo 重新建立。每個 pull request 都會由 GitHub Actions 透過 OIDC 執行 terraform plan,合併到 main 之後,變更就會套用到測試帳戶。
為什麼選 Terraform 而不是 CDK?
決定的關鍵是匯入既有資源的方式。專案的 repo 是用 Python 寫的,而 CDK 也能用 Python 撰寫,語言可以保持一致。但在匯入與影響範圍這兩點上,Terraform 都比較有優勢:
- 匯入。兩種工具都能把 Bedrock knowledge base 納入管理,差別在於由誰來寫定義。
terraform plan -generate-config-out會從實際運行中的資源寫出 HCL,如此一來,下次plan的時候就可以看出設定上發生了哪些變化。cdk import則需要自己手寫出一個和實際資源完全相符的 construct,而且cdk import不會檢查這些屬性是否正確、完整。
- 影響範圍。納入既有資源的 CloudFormation stack 會把這些資源綁在一起,只要一次更新失敗,整個 stack 就會回滾。Terraform 則以資源為單位套用,成功的部分會保留下來,並記錄在 state 中。
除了語言之外,CDK 還有一個優勢:state。CloudFormation 把每個 stack 的狀態保存在服務內部,鎖定、歷史紀錄與回滾都由 AWS 處理。CDK 確實也會建立一個 S3 bucket,但那是從一開始執行 cdk bootstrap 建立 CDKToolkit stack 時就自動建立的,也不需要我們去管理。
Terraform 的 state 則是 S3 bucket 裡的一個檔案,但是這個 bucket 需要我們手動 bootstrap。問題在於,這個 bucket 本身也是由 Terraform 建立的,在它存在之前,state 沒有地方可以放。所以第一次 apply 先把 state 存在本機,等 bucket 建好之後,再用 terraform init -migrate-state 把本機的 state 搬進這個 bucket。
bootstrap 只需要做一次,但是之後需要我們自己來管理維護。state 檔案可能以明文保存資源的屬性,所以這個 bucket 需要和任何存放敏感資料的 bucket 一樣的存取控制與版本控制;state 和實際資源不一致時,也要用 terraform state 指令修正。
最後,測試帳戶只匯入了兩個資源,都是我先前手動加上的 security group 規則,所以匯入在這裡幾乎沒有發揮作用。更重要的是 OpenSearch index:讓 Terraform 管理它的每一種方式都有代價(後面的段落會說明),換成 CDK 也不會比較好。
測試帳戶的資料層原本是空的,所以 Terraform 幾乎都是直接新建資源。production 則不同:所有資源都已經在主控台建好,而且裡面有實際的資料,不能刪掉重建,只能一個一個匯入 Terraform。到時候,匯入既有資源的能力才會真正派上用場。
把 production 當作唯讀規格,用測試帳戶驗證
Terraform 完全沒有去碰 production。我用一支只呼叫 List、Get 和 Describe 等 AWS API 的指令碼,讀取一次 production 裡的 Glue 欄位定義、vector index 的 mapping,以及 AgentCore gateway 公開的工具 schema,並把讀到的內容存進模組旁邊的 spec/ 目錄。接著資料層的 Terraform 模組會讀取這些檔案,所以它的設定都能對應到 production 實際存在的資源。
Terraform 模組本身不含任何帳戶 ID。模組需要的外部資源都不會去寫死 ID,而是 Terraform 自己查找出來的:每個 AWS 帳戶裡的 landing zone 都會給 VPC 和 subnet 相同的 Name 標籤,讓 Terraform 可以用標籤來查找;應用程式 stack 的 security group 與 task role 則用名稱查找。因為這些名稱在每個帳戶裡都一樣,之後要讓 production 也使用這個模組時,模組本身不必修改,只需要新增一個 tfvars 檔案。
另外,S3 bucket 的名稱在所有 AWS 帳戶之間必須是獨一無二的。production 有兩個存放文件的 bucket,名稱裡沒有帳戶 ID,所以測試帳戶無法建立同名的 bucket,比較好的做法是在 bucket 名稱後面加上帳戶 ID(例如 kb-docs → kb-docs-222222222222),或是加上環境名稱(例如 kb-docs → kb-docs-stg)。
搞定了 AWS 的資源之後,我們還需要複製 production 中儲存的資料,例如 S3 bucket 裡的檔案(包括 Athena 等資料),以及應用程式在 DynamoDB 裡的數據。我選擇不把這些資料放進 Terraform 的 state,而是用 seed 指令碼分兩個步驟複製:export 先把 production 的資料讀進本機快取,import 再把快取寫進測試帳戶裡相對應的位置。
knowledge base ID、gateway URL、code interpreter ID 和 bucket 名稱等值都必須更換,如果原封不動地複製,測試帳戶的應用程式就會去呼叫 production 的 knowledge base 和 gateway。所以 import 在寫入時,會把每一個 production 的值換成測試帳戶裡對應的值:bucket 名稱依照命名規則就能推得,knowledge base ID 這類由 AWS 產生的值,則需要等 Terraform 建好資源之後才能取得。
# 一次替換完成,並優先替換較長的字串。測試 bucket 的名稱以 production bucket# 的名稱開頭("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 與 subnet 屬於 landing zone;ECS 服務、它的 security group 與 task role,則屬於應用程式團隊管理的 CloudFormation 範本。Terraform 會讀取這些資源,但一個也不會建立。
比較 production 與測試帳戶裡新建的應用程式 stack 之後,找出了我之前在 production 手動做過的變更。這些變更都在由範本管理的資源上:
- 我在應用程式的 security group 上加了一條允許 3000 連接埠的 inbound rule。這是因為範本原本的規則是「允許來自 load balancer 的 security group 的網路流量」,只允許直接從 load balancer 送來的封包。但實際上 public subnet 與 protected subnet 之間的流量,會先經過 landing zone 的 Gateway Load Balancer(GWLB)endpoint,送進 landing zone 提供者的 inspection VPC 檢查,因此抵達時就不再是來自那個 security group,範本的規則因此對不上。所以我之前手動加了這條規則:「允許來自 load balancer 所在 subnet 的 IP 範圍(CIDR)」,這樣不管封包走哪條路都能放行。
- 一條允許 443 連接埠、來源是 security group 自己的 inbound rule,意思是「同一個 security group 裡的成員之間可以互相連線」。ECS task、Lambda 和
bedrock-runtime的 VPC endpoint 都掛在這個 security group 上,所以有了這條規則,ECS task 和 Lambda 才能連到 endpoint。 - task role 上的兩條 inline policy,一條允許存取 Athena 與 Glue,另一條允許讀取原始 PDF。
現在這三項都交給 Terraform 來管理,作為附加在範本物件上的獨立資源。如果應用程式的 stack 更新移除了其中一項,下一次 apply 就會自動把它加回來。endpoint 也要等 443 規則就位之後才建立:這個 endpoint 啟用了 private DNS,endpoint 一建立,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 index
vector index 由指令碼透過原生 API 建立,而且只在它還不存在時才建立,之後 Terraform 不會去更新或刪除它。Terraform 並不是完全沒辦法管理 index,但每一種方式都有代價:
- Terraform 的 AWS provider(
hashicorp/aws)沒有 index 的 resource。它的原始碼internal/service/opensearchserverless/裡有 collection、policy 和 VPC endpoint,但沒有index.go。 opensearchprovider 可以管理 index。但當 Bedrock 在 mapping 中加入動態欄位時,它的 plan 會要求替換 index(#175,在 v2.6.0 仍未修正),而替換 index 會刪掉裡面所有的向量。加上lifecycle { ignore_changes = [mappings] }和prevent_destroy可以避開這個風險,但避不開設定上的問題:這個 provider 的url要填 collection 的 endpoint,而 endpoint 要等 collection 建好之後才會存在。所以在全新的帳戶裡,第一次 apply 必須分成兩次(先用-target只建立 collection),而 CI 的自動 apply 只會執行一次。這個限制是 provider 的 issue 中回報的,這裡沒有實際重現。- CloudFormation 裡有
AWS::OpenSearchServerless::Index,所以aws_cloudcontrolapi_resource看起來可行。但 Cloud Control 雖然能用ListResources列出 production 的 index,用GetResource讀取任何一個 index 的設定時都會失敗。
輸入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 # 剛建立的 data access policy 可能需要一段時間才會就緒,所以要重試。 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這支指令碼也讓專案少了一個 provider:不需要第三方 provider、不需要為它再設定一次 SigV4 認證,也不必追蹤它的版本。代價是 index 不會出現在任何 plan 裡:mapping 偏移了,Terraform 也完全看不到,只有本文後面的快照差異比對才抓得到,mapping 的變更也必須手動套用。
我建立了一個 terraform_data 資源來執行這支指令碼,並以 collection ID、index 名稱和 mapping 檔案的雜湊作為觸發條件。我還在 index 與 knowledge base 之間放了一個 60 秒的 time_sleep:index 剛建立時還在傳播,這時馬上建立 knowledge base 的話,可能會因為找不到 index 而失敗(「no such index」)。
我在 mapping 檔案裡只寫了 knowledge base 指定的三個欄位(vector、文字與 metadata 欄位),其餘欄位由 Bedrock 在同步資料時動態加入。
證明兩個帳戶一致:比對正規化後的設定,並確認檢索結果相同
我寫了一支快照指令碼,它會以名稱查找 production 和測試帳戶裡的資源並比較其設定,只要有任何差異沒有列在預期差異清單(EXPECTED_DIFFS)裡,就會失敗。從 knowledge base 到 gateway,每個資源都靠名稱找到並比較。
比對之前,指令碼會先把帳戶特有的值換成 placeholder,例如 <ACCT>、<bucket:rag>、<kb:docs>、<subnet:LZPrivateSubnet1a>。這樣一來,帳戶 ID 這類本來就不同的值,就不會被列成差異。而且我們需要先換成 placeholder 之後再把 JSON 裡的清單排序,這樣 production 和測試帳戶回傳的 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)",}第一次執行列出了三個差異,都是 Terraform 模組設定錯的地方:
- S3 Bucket Keys 在 production 啟用,在測試帳戶卻停用。
- 主控台在 production 會自動加到每個 knowledge base role 上的 marketplace statement,模組漏掉了。
- gateway role 的 statement 範圍設定和 production 不同。
我修好這三個地方之後,除了預期差異清單裡的項目,其他差異都消失了。index 的 mapping 也一致,包括 Bedrock 在同步資料時加入的欄位。
但是即使設定一致,也不代表行為會一致,所以我分別在兩個帳戶執行了同樣的檢索測試:對兩個 knowledge base 各送出五個查詢,取前五筆結果。這十個查詢全都以相同順序回傳了相同的文件,而測試帳戶中的每個來源連結都指向測試帳戶的 bucket。我也用 Athena 查詢了五張資料表的列數,兩邊的結果完全一致。
GitHub OIDC:PR 時執行 plan,併入 main 時執行 apply
我設定了一個 GitHub Actions workflow:每個修改到 terraform/ 的 PR 都會執行 plan,並且在該 PR 底下張貼執行結果,等到 PR 合併到 main 之後再執行 apply 到測試帳戶。AWS 的憑證來自 GitHub 的 OIDC token,所以 repo 裡沒有存放任何金鑰。
兩個 role,兩種 subject
GitHub 發出的 OIDC token 裡有一個 subject(sub 欄位),記錄這個 token 來自哪個 repo、由哪個事件觸發,AWS 的 trust policy 就是比對這個值來決定誰能 assume 哪個 role。所以我準備了兩個 role,各自只信任一種 subject:
- plan role:plan job 會執行 PR 裡的程式碼,所以 plan role 只信任
pull_requestsubject,而且是唯讀的。 - apply role:apply job 在 GitHub 的 environment
test中執行,所以 apply role 只信任environment:testsubject,擁有管理員權限。
我把兩個 job 放在不同的 concurrency group。GitHub 每個 group 只保留一個等待中的 run,所以如果共用同一個 group,較新 PR 的 plan 會取代正在等待的 apply。Terraform 在 plan 或 apply 開始前,會先在 S3 的 state 檔旁邊建立一個 .tflock 鎖定檔,因此兩個 job 不會同時操作 state。
修正 sub 的前綴
第一個 PR 在 Terraform 啟動之前就失敗了:
Retry AssumeRole: attempt 1 of 12 failed: Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity.trust policy 預期的是 repo:<owner>/<repo>:pull_request。但這個 repo 送出的是 repo:<owner>@<owner-id>/<repo>@<repo-id>:pull_request,裡面嵌入了擁有者與 repo 的數字 ID。trust policy 是逐字比對 subject 的,因此被 AWS 拒絕了。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>"}我把兩份 trust policy 都改成比對這個前綴:
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 讀取權限,並拒絕讀取資料
我修好 trust policy 之後,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,所以除了原本就有的 bedrock-agentcore 與 aoss 讀取權限,我又替 plan role 加上了 bedrock:Get* 和 bedrock:List*。接著我用 gh run rerun <run-id> --failed 重新執行了 plan,這樣不需要新的 commit 也能以更新後的 role 重跑。
另一方面,ReadOnlyAccess 能讀的東西也遠超過 plan 所需要的範圍,包括 S3 object、DynamoDB item 和 CloudWatch log event。測試帳戶裡有一份 production 資料的副本與應用程式的聊天紀錄,而 plan role 會執行 PR 的程式碼,也就是說,能開 PR 的人都能透過 plan 讀到這些資料。所以我加了一條規則拒絕了 state bucket 以外的所有資料讀取:
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}/*"]}用 environment 的 deployment branch policy 把 apply 限定在 main
AWS 的 trust policy 無法分辨 environment job 來自哪個分支,environment job 的 subject(sub)只會列出 environment 名稱,例如 …:environment:test,但不會寫出分支。如果 GitHub 這一側沒有分支規則,從任何分支觸發的 workflow_dispatch,或是在 job 中加上 environment: test 的 pull request,都能 assume 管理員 role。
如果 workflow 執行時設定的 environment 尚未存在,該 environment 就會自動建立,而且預設沒有分支保護。所以我在 workflow 第一次執行之前,先建立了 test environment,並限制該 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 的 network interface 會占住 security group 長達 8 小時
最後,我試著把測試帳戶從零重建,但 terraform destroy 在 45 分鐘的刪除逾時之後,停在 code interpreter 的 security group 上:
Error: deleting Security Group (sg-<id>): operation error EC2: DeleteSecurityGroup, https response error StatusCode: 400, api error DependencyViolation: resource sg-<id> has a dependent objectcode interpreter 本身已經刪除了,但還有兩個類型為 agentic_ai 的 network interface 附加在 security group 上。這兩個 network interface 是由 AgentCore 的服務帳戶(由 AWS 管理的帳戶)請求建立的,不屬於這個測試帳戶。AWS 的說明文件寫明,這些 network interface 在刪除後可能會保留長達 8 小時,而且不允許 detach。
但其實這並不影響重建:其他資源都已經刪除,只剩下該 security group 還留在 state 中,所以我直接執行 terraform apply,它沿用了這個 security group,重建了帳戶的其餘部分。我重新執行 seed 指令碼之後,快照差異比對、檢索測試與 Athena 查詢到的列數都和先前一樣。
總結
把 production 的設定匯出到 spec/ 目錄,讓 Terraform 模組有了可以對照檢查的依據;我選擇 Terraform 而不是 CDK,是因為它匯入既有資源時會自己寫出設定。vector index 則交給一支呼叫原生 API 的指令碼負責;opensearch provider 在全新的帳戶裡得等 collection 建好才能設定,CI 的一次 apply 沒辦法設定。
CI 這一側遇到三個問題,都是手動建立的 role 永遠不會遇到的:不可變格式的 subject、ReadOnlyAccess 缺少的 Bedrock 讀取權限與它不該有的資料讀取,以及 apply role 所信任的 environment 需要的 deployment branch policy。
參考連結
- Terraform:import 區塊與產生設定
- Terraform S3 backend,包含 use_lockfile
- CloudFormation:支援 resource import 的 resource type
- AWS CDK v2:cdk import
- GitHub changelog:GitHub Actions OIDC token 改用不可變格式的 subject
- GitHub Docs:OpenID Connect 參考
- Amazon Bedrock AgentCore:刪除 code interpreter,以及 network interface 最長 8 小時的保留
- terraform-provider-opensearch #175:動態屬性造成 index 替換




