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

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

- Source: https://oharu121.com/zh-tw/blog/terraform-override-tf-sandbox-testing-real-apply/
- Published: 2026-08-27T21:54:04+09:00
- Tags: Terraform, AWS, GitHub Actions

---
**重點摘要**

- `*_override.tf` 本來就在標準的 Terraform `.gitignore` 裡,所以只給沙箱用的設定可以跟已提交的設定放在一起,卻永遠不會被提交。
- 覆寫檔可以取代整個 `backend` 區塊,也可以只取代個別的 `locals`,光是這樣就足以把整份設定重新導向另一個帳號。
- `terraform validate` 檢查的是結構,不是能不能被接受。這份設定裡有四個問題,乾淨地通過了 validate,卻只在真正 apply 時才出現。
- 其中最有價值的一個:某個 CLI 把它自己回顯、還寫進自己設定檔裡的一個值,悄悄地丟掉了。Terraform 保留了那個值。
- apply 之後再跑一次 `plan`。「No changes」才是驗證設定是否真的描述了現況的檢查。

## 引言

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

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

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

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

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

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

## 兩個覆寫檔

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

```text title=".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 留在本機磁碟,而不是放進一個還不存在的儲存桶:

```hcl title="backend_override.tf"
terraform {
  backend "local" {}
}
```

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

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

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

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

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

*Figure — OverrideMechanism: 沙箱執行會多加兩個已被 gitignore 的檔案。CI 執行則什麼都不加。*

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

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

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

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

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

前兩個是同一類錯誤:

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

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

```hcl
# 省略 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`:

```text
No changes. Your infrastructure matches the configuration.
```

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

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

## 這個結果指向的流程

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

*Figure — Pipeline: plan 在 Pull Request 上跑,apply 在合併後才跑。跟以容器為基礎的部署相比,唯一不同的是產出物那一步。*

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

### 兩個容易搞錯的細節

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

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

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

## 總結

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

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

## 參考連結

- [Terraform 覆寫檔,包含 locals 與 module 區塊的合併規則](https://developer.hashicorp.com/terraform/language/files/override)
- [Terraform 後端設定,說明如何為本機執行覆寫後端](https://developer.hashicorp.com/terraform/language/backend)
- [gitignore.io 的 Terraform 範本,已經排除了 `*_override.tf`](https://www.toptal.com/developers/gitignore/api/terraform)
- [在 AWS 為 GitHub Actions 設定 OpenID Connect,移除儲存庫裡的長期憑證](https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services)
