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

用 _override.tf 在沙箱裡執行已提交的 Terraform — apply 抓到了 validate 沒抓到的問題

已被 gitignore 的 Terraform 覆寫檔,把一份已提交的設定重新導向自己的 AWS 沙箱帳號:本機後端、覆寫過的 locals,以及只有 apply 才找得到的問題。

本頁目錄

引言

我需要回答一批手動建立的 AWS 資源能不能透過 CI/CD 重現,而這個答案需要的是證據,不是意見。麻煩的地方在於要在哪裡跑:這份設定屬於一個共用儲存庫,目標是我沒有存取權的 AWS 帳號,而我唯一能真正部署的只有一個沙箱帳號。

代理採取的做法是,依照共用儲存庫的慣例寫好 Terraform,再用兩個已被 gitignore 的覆寫檔,把它重新導向沙箱,這樣一來,已提交的設定裡就完全不會出現任何沙箱專屬的東西。這個做法成功了,也把一場設計討論變成了一個有實際測量結果的答案。

更有用的是,apply 揪出了四個乾淨的 terraform validate 沒抓到的問題,其中一個是某個 CLI 悄悄丟棄的設定值。本文涵蓋這個覆寫技巧、每個問題的代價,以及這個結果所指向的那套流程。

情境:一份無法在原地套用的設定

這種情況很常見,值得特別點名。一份設定依照共用儲存庫的慣例寫成,參照了一個 state 後端、一個 permission boundary,以及只存在於你無法到達的環境裡的帳號專屬識別碼。內容全都正確,卻什麼也跑不起來。

誘人的權宜之計是把值改成本機用的、套用,然後記得改回來。 這個方法確實有效,直到你忘記為止,而被提交上去的就是一份指向你沙箱的設定。

兩個覆寫檔

Terraform 會把任何名為 *_override.tf 的檔案,合併到它旁邊的設定上。標準的 Terraform .gitignore 早就排除了這個模式,這正是它預設安全、而不是靠自律安全的原因:

.gitignore
# Ignore override files as they are usually used to override resources locally and so
# are not checked in
override.tf
override.tf.json
*_override.tf
*_override.tf.json

第一個覆寫檔取代了 backend,讓 state 留在本機磁碟,而不是放進一個還不存在的儲存桶:

backend_override.tf
terraform {
backend "local" {}
}

第二個覆寫檔取代個別的 locals。只有指名的那些鍵會變,locals 區塊裡其他的一切照舊套用:

sandbox_override.tf
locals {
# 沙箱沒有 permission boundary;已提交的值指向的是這次執行看不到的
# 帳號裡的一條政策。
existing_permission_boundary_arn = null
runtime_name = "example_sandbox"
artifact_bucket_name = "sandbox-artifacts-<account-id>"
}

模組的引數也能用同樣的方式覆寫,當沙箱需要的產出物跟 CI 會建置出來的不一樣時,這一點就很重要:

sandbox_override.tf (continued)
module "compute" {
entry_point = ["app/server.py"]
runtime_type = "PYTHON_3_12"
}

模組的覆寫是合併而不是取代,所以 source 和其他所有引數依然來自已提交的 main.tf。兩個短短的檔案,沒有任何東西被提交,git status 保持乾淨。

同一份已提交的設定,兩個適用對象已提交的 Terraform 設定位於上方,向兩側分岔。左側加上兩個已被 gitignore 的覆寫檔,以本機 state 對沙箱帳號執行。右側不加任何檔案,由 CI 以遠端 state 對目標帳號執行。已提交的設定main.tflocal.tfbackend.tf已被 gitignore 的 *_override.tfbackend_override.tfsandbox_override.tf沙箱帳號state 放在本機不加任何檔案目標帳號state 放在儲存桶
沙箱執行會多加兩個已被 gitignore 的檔案。CI 執行則什麼都不加。

plan 證明了什麼,又沒證明什麼

plan 回報新增 12、變更 0、刪除 0,而讀懂這份輸出,抓到了一個 validatefmt 都不會標示出來的命名錯誤。有個輸出被渲染成 https://mcp-mcp-dv-...,原因是範本把一個專案名稱,插進了一個已經含有這個專案名稱的字串裡。一旦看見就一目了然,沒東西把它渲染出來之前完全看不見。

但 plan 成功,只能證明提供者接受了這份設定,不能證明 AWS 也接受。 這個區別,正是本文剩下部分要處理的東西。

apply 抓到了什麼,而 validate 沒抓到

出現了什麼 為什麼只有 apply 才看得出來
提供者對一個空的 environment_variables map 回傳了 null 送出去的內容跟收回來的內容不一致
省略 max_lifetime 讓 AWS 補上 28800,造成永久性差異 預設值是在伺服器端套用的
對一個已啟用版本控制的儲存桶執行 destroy,以 409 BucketNotEmpty 失敗 只有真正的拆除才會走到刪除這一步
某個 CLI 悄悄丟棄了一個它宣稱有套用的設定值 需要比對兩個真正部署出來的資源

前兩個是同一類錯誤:

Error: Provider produced inconsistent result after apply
… produced an unexpected new value: .environment_variables: was
cty.MapValEmpty(cty.String), but now null.

兩個都是提供者本身的錯誤,而且一旦被看見都很容易避開。傳 null 而不是 {},解決了第一個。把值設成 AWS 會預設套用的值,解決了第二個,而解釋原因的那行註解,比程式碼本身更有價值:

# 省略 max_lifetime 會讓 AWS 設定並回傳 28800,所以提供者每次
# plan 都會回報「was null, now 28800」。
lifecycle_configuration = [{
idle_runtime_session_timeout = var.idle_timeout
max_lifetime = var.max_lifetime
}]

第三個只有在拆除時才會出現。一個啟用了版本控制的儲存桶,刪掉內容之後並不會變空。 刪除標記和非目前版本還留著,destroy 就會卡在 409 BucketNotEmpty。解法是加上 force_destroy 旗標,在有計畫的拆除過程中發現它幾乎不用付出什麼代價;但如果是急著要清掉一個環境時才發現,代價就大得多。

被悄悄丟棄的設定值

這是讓整個實驗有意義的關鍵發現。

這些資源原本是用一個廠商提供的 CLI 建立的。那個 CLI 接受一個設定閒置工作階段逾時的旗標,會把值回顯在自己的摘要輸出裡,也會寫進自己的設定檔。本機的每一份記錄都一致顯示這個設定值是 300 秒。

查詢兩個真正部署出來的資源,結果卻是另一回事:

資源 建立方式 部署後的閒置逾時
原始資源 廠商 CLI,旗標設為 300 900
沙箱 這次的 Terraform 300

同一個帳號、同一個地區、同樣的預期設定。API 接受這個值,但 CLI 從來沒送出去過。 讀了那個工具的原始碼才找到原因:容器部署的程式碼路徑,會把生命週期設定一路傳到 API 呼叫裡;zip 部署的程式碼路徑則不會。那個旗標被解析、驗證、回顯、寫入設定檔,然後被丟掉。

計費模式讓這件事不只是外觀上的問題,因為工作階段只要還活著,記憶體就會持續計費。但更值得注意的是它的通用形式:唯一跟這個錯誤對不上的東西,就是已部署的資源本身。 本機沒有任何一份記錄能揭露它,不管把設定重讀幾遍都沒用。

確認設定描述的就是現況

apply 之後,再跑一次 plan:

No changes. Your infrastructure matches the configuration.

這句話就是驗收測試。它證明的是這份設定描述的就是現況,而不只是曾經被接受過一次。如果放著不修,上面那兩個提供者不一致的問題,都會在這裡以永久性差異的形式再次出現。

接著執行 destroy,確認帳號裡不相干的資源沒被波及。 一次連帶拆掉其他東西的拆除,正是你想在沙箱裡先發現的問題。

這個結果指向的流程

這部分是設計,還不是已經在跑的東西。 它卡在儲存庫和目標帳號之間還沒建立 OIDC 信任關係,那是別人的工作,有自己的前置時間。之所以把它寫進來,是因為沙箱的結果正是這個設計的依據,而且這個形狀可以推而廣之。

PR 執行 plan,合併後才 apply圖中有三條流程。Pull Request 會執行 fmt、validate、lint 與 plan,只顯示差異而不套用。合併到 main 會建置產出物、上傳,並套用到測試環境。手動觸發則套用到正式環境。每一條流程都以 OIDC 取得該環境的角色,而非使用已保存的憑證。各環境以 OIDC 取得角色,不保存任何憑證。Pull Requestfmt / validate / lintplan顯示差異,不套用合併到 main建置產出物上傳apply測試環境手動觸發apply正式環境
plan 在 Pull Request 上跑,apply 在合併後才跑。跟以容器為基礎的部署相比,唯一不同的是產出物那一步。

預期的流程是很傳統的那種。 Pull Request 會執行 fmtvalidate、lint 和 plan,只公開差異供審查,不套用任何東西。合併到 main 會建置部署用的產出物、上傳它,並套用到測試環境。正式環境則是手動觸發。

兩個容易搞錯的細節

認證用的是 OIDC,不是儲存起來的金鑰。 工作流程會要求 id-token: write,並依環境各自取得一個角色,所以儲存庫裡不會存放任何長期有效的憑證。這是標準做法,也是需要前置時間的部分,因為信任關係必須由對目標帳號有管理權限的人來建立。

產出物這一步,是唯一真正新增的部分。 對以容器為基礎的服務來說,CI 建置一個映像檔、推送到登錄檔,部署再參照它。對以 zip 為基礎的服務來說,CI 建置一個 zip、上傳到物件儲存,資源再參照那個鍵值。其他部分都沒變:PR 上的 plan 工作、合併後的 apply 工作,以及各環境的把關機制。

apply 也揭露了這個設計裡的一個順序問題,而代理刻意把它留著沒解決。儲存桶和讀取它的資源是在同一次 apply 裡建立的,所以第一次執行時,資源被建立的當下,產出物根本還不存在。改用 Terraform 上傳、變成一個物件資源可以解決順序問題,卻會讓 plan 在本機沒有產出物時直接失敗。與其把一個沒驗證過的答案提交上去,不如乾脆先不實作,畢竟這個分支唯一的價值就在於一切都經過驗證,這比提交一個未經測試的猜測誠實得多。

總結

*_override.tf 是個小功能,解決的卻是一個具體又反覆出現的問題:在設定不是為它而寫的地方執行它,同時完全不用去改動它。這個模式本來就被 gitignore 排除在外,所以安全性是結構性的,不是靠記性。

更大的重點在於什麼才算得上證據。validate 和一次乾淨的 plan,只能說明設定能被剖析、提供者也接受它。這裡找到的四個問題全都落在那條界線的另一側,其中一個在本機每一份記錄都堅稱沒問題的情況下,悄悄錯了好幾天。一個沙箱帳號幾乎不花什麼成本,卻是唯一能安全驗證這個差別的地方。

參考連結

分享這篇文章