用 Terraform 照著 production 重建一份主控台手動建立的 AWS 帳戶 — 唯讀規格、設定差異比對與 GitHub OIDC

以唯讀方式擷取 production 的設定,把由 AWS 主控台建立的 AWS 資料層寫成 Terraform 程式碼。接著用設定差異比對驗證,並透過 GitHub OIDC 執行部署。

白底上由四個紫色平行四邊形組成 T 字的 Terraform 標誌,旁邊是近黑色的 Terraform 字樣
本頁目錄

引言

我有一個專案,其 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 都比較有優勢:

  1. 匯入。兩種工具都能把 Bedrock knowledge base 納入管理,差別在於由誰來寫定義。terraform plan -generate-config-out 會從實際運行中的資源寫出 HCL,如此一來,下次 plan 的時候就可以看出設定上發生了哪些變化。cdk import 則需要自己手寫出一個和實際資源完全相符的 construct,而且 cdk import 不會檢查這些屬性是否正確、完整。
Terraform 從實際運行中的資源寫出定義,CDK 則要自己手寫左邊一個在主控台建立的 knowledge base,延伸出兩條泳道。Terraform 泳道中,只寫了資源 ID 的 import 區塊交給 terraform plan -generate-config-out,由它讀取實際運行中的資源並寫出 HCL。下一次 plan 會顯示任何不一致,資源最後納入 Terraform state。CDK 泳道中,必須和實際資源完全相符的手寫 construct 交給 cdk import,由它要求輸入資源名稱並執行 CloudFormation 的 IMPORT 變更集,資源最後納入 CloudFormation stack。CDK 泳道下方有一條警告:不會檢查 construct 的屬性是否正確、完整。Terraform:由 Terraform 寫出定義CDK:定義要自己手寫在主控台建立的knowledge base實際運行中的資源import 區塊只需要資源 IDterraform plan-generate-config-out讀取實際運行中的資源產生的 HCL下一次 plan顯示任何不一致Terraform state納入管理手寫的construct必須和實際資源完全相符cdk import要求輸入資源名稱CloudFormationIMPORT 變更集CloudFormationstack納入管理不會檢查 construct 的屬性是否正確、完整
兩條路徑最後都會把 knowledge base 納入管理。Terraform 從實際運行中的資源寫出定義,下一次 plan 就會檢查;CDK 則要手寫 construct,而且沒有任何步驟會檢查它。
  1. 影響範圍。納入既有資源的 CloudFormation stack 會把這些資源綁在一起,只要一次更新失敗,整個 stack 就會回滾。Terraform 則以資源為單位套用,成功的部分會保留下來,並記錄在 state 中。
一個資源失敗時,CloudFormation 撤銷整次更新,Terraform 只影響那個資源兩個面板執行同一次更新,這次更新要變更資源 A、B、C,而 B 失敗了。CloudFormation stack 的面板中,A 的變更撤銷,B 失敗,C 回滾或根本還沒開始,所以 stack 回到更新之前的樣子。Terraform 的面板中,A 已套用並記錄在 state 中,B 失敗,下一次 apply 會再試一次,C 只要不依賴 B 就會套用,所以成功的部分保留在 state 中。一次更新要變更 A、B、C,而 B 失敗了CloudFormation stackA回滾撤銷變更B失敗觸發回滾C回滾撤銷,或根本還沒開始stack 回到更新之前的樣子TerraformA已套用記錄在 state 中B失敗下一次 apply 會再試一次C已套用只有依賴 B 時才會略過成功的部分保留在 state 中
一次更新要變更 A、B、C,而 B 失敗了。CloudFormation 會撤銷整次更新;Terraform 則把 A 記錄在 state 中,只有依賴 B 的部分不會套用。

除了語言之外,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 指令修正。

CDK 的狀態存放在 CloudFormation 裡,Terraform 的 state 是要自己持續管理的檔案兩個面板。CDK 面板中,stack 的狀態存放在 CloudFormation 服務裡,已經 bootstrap 的 CDKToolkit stack 有一個只放資產的暫存 bucket,下方的說明條寫著鎖定、歷史紀錄與回滾由 AWS 處理。Terraform 面板中,只做一次的 bootstrap 分成三步:第一次 apply 先把 state 存在本機,接著 state bucket 建好了,再用 terraform init -migrate-state 把 state 搬進去。之後 bucket 裡存放 terraform.tfstate。這個檔案可能以明文保存屬性,並以 use_lockfile 鎖定,下方的說明條寫著存取控制、版本控制與 terraform state 修正由自己處理。CDKCDKToolkit stack暫存 bucket:只放資產已經 bootstrap鎖定、歷史紀錄、回滾由 AWS 處理Terraformbucket 裡的 terraform.tfstate可能以明文保存屬性以 use_lockfile 鎖定存取控制、版本控制、terraform state 修正由自己處理CloudFormation 服務stack 的狀態存放在這裡bootstrap(只做一次)1. applystate 先存在本機2. bucket建好了3. init-migrate-state搬進 bucket
CDK 的狀態存放在 CloudFormation 裡,由 AWS 負責管理;Terraform 的 state 是需要手動 bootstrap 的 bucket 裡的一個檔案,之後也要一直自己管理。

最後,測試帳戶只匯入了兩個資源,都是我先前手動加上的 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 實際存在的資源。

production 只讀取,測試帳戶建好之後再比對三條泳道連接左邊的 production 帳戶與右邊的測試帳戶。基礎設施泳道中,只呼叫 List、Get、Describe 的擷取指令碼寫出 spec 目錄,Terraform 模組再讀取它並套用到測試帳戶。資料泳道中,種子指令碼以 production 的 session 匯出到本機快取,再以測試的 session 改寫 ID 後匯入。驗證泳道中,快照差異比對讀取兩個帳戶,回報無法解釋的差異為 0。基礎設施資料(另一支指令碼)驗證production 帳戶只讀取擷取指令碼只呼叫List / Get / Describespec/Glue 欄位定義index mappinggateway 工具Terraform 模組讀取 spec/測試帳戶terraform applyexportproduction 的 session本機快取已列入 gitignoreimport測試的 session改寫 ID快照差異比對以名稱查找兩帳戶的資源無法解釋的差異:0
production 永遠只讀取。模組套用到測試帳戶,資料由另一支指令碼複製,快照差異比對則讀取兩個帳戶來比較。資料泳道與快照差異比對會在後面說明。

Terraform 模組本身不含任何帳戶 ID。模組需要的外部資源都不會去寫死 ID,而是 Terraform 自己查找出來的:每個 AWS 帳戶裡的 landing zone 都會給 VPC 和 subnet 相同的 Name 標籤,讓 Terraform 可以用標籤來查找;應用程式 stack 的 security group 與 task role 則用名稱查找。因為這些名稱在每個帳戶裡都一樣,之後要讓 production 也使用這個模組時,模組本身不必修改,只需要新增一個 tfvars 檔案。

一個 Terraform 模組以標籤和名稱查找資源,所以能用在任何帳戶中間是不含帳戶 ID 的資料層 Terraform 模組,左邊是測試帳戶,右邊是標為下一步的 production 帳戶。兩個帳戶裡都有一個 landing zone,其中的 VPC 與 subnet 有相同的 Name 標籤,也都有一個應用程式的 CloudFormation stack,其中的 security group 與 task role 名稱相同。虛線箭頭表示模組在每個帳戶裡以 Name 標籤查找網路、以名稱查找 stack 的資源。每個帳戶也各有一個 tfvars 檔案,test.tfvars 與 prod.tfvars,把各環境的值傳給模組。下方的說明條寫著每個帳戶裡模組相同、名稱也相同,只有 tfvars 檔案不同。測試帳戶landing zoneVPC 與 subnet相同的 Name 標籤CloudFormation stack應用程式的 security group、task role相同的名稱test.tfvars各環境的值production 帳戶(下一步)landing zoneVPC 與 subnet相同的 Name 標籤CloudFormation stack應用程式的 security group、task role相同的名稱prod.tfvars各環境的值Terraform 模組資料層不含帳戶 ID以 Name 標籤查找網路以名稱查找stack 的資源每個帳戶裡模組相同、名稱也相同,只有 tfvars 檔案不同
一個模組,兩個帳戶。模組以 Name 標籤查找網路,以名稱查找應用程式 stack 的資源,所以不同的只有 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 建好資源之後才能取得。

scripts/seed_test_env.py
# 一次替換完成,並優先替換較長的字串。測試 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 會讀取這些資源,但一個也不會建立。

網路與應用程式 stack 只查找,其餘由 Terraform 管理左右兩個區域並排。左邊以 data source 查找的區域,包含以 Name 標籤查找的 landing zone VPC 與 subnet,以及以名稱查找的應用程式 CloudFormation stack(ECS 服務的 security group 與 task role)。右邊由 Terraform 管理的區域,包含資料層,以及一張附加在 stack 上的規則卡片:來自 load balancer subnet 的 3000 埠、作為 VPC endpoint 前提的 443 自我參照,以及 task role 的 inline policy。一條箭頭從這張卡片指向應用程式 stack。查找(data source)由 Terraform 管理landing zoneVPC 與 subnet以 Name 標籤查找應用程式的 CloudFormation stackECS 服務的 security groupECS task role以名稱查找資料層S3 bucket、Glue 資料表OpenSearch Serverless 與 indexBedrock knowledge baseLambdaAgentCore gateway、code interpreterbedrock-runtime VPC endpoint附加在 stack 上的規則來自 LB subnet 的 3000 埠443 自我參照(endpoint 的前提)task role 的 inline policy
Terraform 查找 landing zone 的網路與應用程式 stack,並管理資料層,以及過去手動加上的規則。

比較 production 與測試帳戶裡新建的應用程式 stack 之後,找出了我之前在 production 手動做過的變更。這些變更都在由範本管理的資源上:

  1. 我在應用程式的 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)」,這樣不管封包走哪條路都能放行。
經過 inspection VPC 之後,只有 CIDR 規則還對得上上下兩排。上排是範本規則預期的路徑:public subnet 裡的 load balancer 直接把流量送到 protected subnet 裡的應用程式,允許來自 load balancer 的 security group 的規則對得上。下排是實際的路徑:來自 load balancer 的流量會先經過 landing zone 的 GWLB endpoint,進入 landing zone 提供者的 inspection VPC 再回來,才抵達應用程式。在這條路徑上,允許來自 load balancer 的 security group 的規則對不上,手動加上的、允許 load balancer 所在 subnet 的 CIDR 的規則才對得上。範本規則預期的路徑實際的路徑load balancerpublic subnet應用程式(ECS task)protected subnetload balancerpublic subnetGWLB endpointlanding zone應用程式(ECS task)protected subnetinspection VPClanding zone 提供者規則:來自 LB 的security group → 對得上規則:來自 LB 的security group → 對不上規則:來自 LB subnet 的CIDR(手動加上)→ 對得上
經過 landing zone 的 inspection VPC 之後,封包就不再算是來自 load balancer 的 security group,所以只有 CIDR 規則還對得上。
  1. 一條允許 443 連接埠、來源是 security group 自己的 inbound rule,意思是「同一個 security group 裡的成員之間可以互相連線」。ECS task、Lambda 和 bedrock-runtime 的 VPC endpoint 都掛在這個 security group 上,所以有了這條規則,ECS task 和 Lambda 才能連到 endpoint。
  2. task role 上的兩條 inline policy,一條允許存取 Athena 與 Glue,另一條允許讀取原始 PDF。

現在這三項都交給 Terraform 來管理,作為附加在範本物件上的獨立資源。如果應用程式的 stack 更新移除了其中一項,下一次 apply 就會自動把它加回來。endpoint 也要等 443 規則就位之後才建立:這個 endpoint 啟用了 private DNS,endpoint 一建立,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 index

vector index 由指令碼透過原生 API 建立,而且只在它還不存在時才建立,之後 Terraform 不會去更新或刪除它。Terraform 並不是完全沒辦法管理 index,但每一種方式都有代價:

  1. Terraform 的 AWS provider(hashicorp/aws)沒有 index 的 resource。它的原始碼 internal/service/opensearchserverless/ 裡有 collection、policy 和 VPC endpoint,但沒有 index.go。
  2. opensearch provider 可以管理 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 中回報的,這裡沒有實際重現。
  3. 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 呼叫都能正常運作,所以我改用一支小指令碼,只做「不存在就建立」,其他什麼都不做:

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
# 剛建立的 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 15
done
exit 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,兩邊的順序才會一致。

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)",
}

第一次執行列出了三個差異,都是 Terraform 模組設定錯的地方:

  1. S3 Bucket Keys 在 production 啟用,在測試帳戶卻停用。
  2. 主控台在 production 會自動加到每個 knowledge base role 上的 marketplace statement,模組漏掉了。
  3. 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:

  1. plan role:plan job 會執行 PR 裡的程式碼,所以 plan role 只信任 pull_request subject,而且是唯讀的。
  2. apply role:apply job 在 GitHub 的 environment test 中執行,所以 apply role 只信任 environment:test subject,擁有管理員權限。
Pull request 只能 plan,只有經過 test environment 的 main 才能 apply兩條泳道。上方泳道中,pull request 出示 subject 以 pull_request 結尾的 OIDC token,只有 plan role 信任它。plan role 在 ReadOnlyAccess 之外加上 Bedrock、OpenSearch Serverless 與 AgentCore 的讀取權限,拒絕讀取資料,但仍可讀取 state bucket。下方泳道中,合併到 main 後,只從 main 部署的 test environment 中的 job 會出示 subject 以 environment:test 結尾的 token,只有 apply role 信任它。apply role 擁有 AdministratorAccess,會對測試帳戶執行 terraform apply。Pull requestterraform plansub 宣告repo:<owner>@<owner-id>/<repo>@<repo-id>:pull_requestplan roleReadOnlyAccess+ bedrock、aoss、agentcore 讀取拒絕讀取資料仍可讀取 state bucket合併到 mainterraform applysub 宣告repo:<owner>@<owner-id>/<repo>@<repo-id>:environment:testapply roleAdministratorAccess對測試帳戶執行terraform applyenvironment:test只從 main 部署
plan role 信任 PR,而且讀不到資料。apply role 只信任 test environment,而這個 environment 只從 main 部署。

我把兩個 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 都改成比對這個前綴:

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 讀取權限,並拒絕讀取資料

我修好 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:ListTagsForResource

ReadOnlyAccess 不包含 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 以外的所有資料讀取:

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

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

AgentCore 的 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 object

code 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。

參考連結

分享這篇文章