# _override.tf でコミット済みの Terraform をサンドボックスで動かす — validate が見逃したものを apply が捕まえた

> gitignore 済みの Terraform オーバーライドファイルで、コミット済みの設定を自分の AWS サンドボックスに向け直す。ローカルバックエンド、locals の上書き、そして apply でしか分からなかったこと。

- Source: https://oharu121.com/ja/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` はスキーマを確認するだけで、受理されるかどうかは確認しません。この設定に潜んでいた4つの不備は、クリーンな validate を通過し、実際の apply で初めて現れました。
- 中でも一番価値があったのは、あるCLIがエコーバックして自分の設定ファイルにまで書き込んでいた設定値を、実は黙って捨てていたこと。Terraform はその値を保持していました。
- apply の後にもう一度 `plan` を実行すること。「No changes」こそが、設定が実際に存在するものを言い表せているかの検証になります。

## はじめに

手作業で作られた AWS のリソース群を、CI/CD で再現できるかどうかを答える必要があり、その答えは意見ではなく根拠を伴うものでなければなりませんでした。厄介だったのはどこで実行するかで、設定自体は共有リポジトリの規約に沿って書かれ、自分がアクセスできない AWS アカウントを対象にしていましたが、実際にデプロイできるのは手元のサンドボックスアカウントだけでした。

エージェントが取ったアプローチは、Terraform を共有リポジトリの規約どおりに書いたうえで、**2つの gitignore 済みオーバーライドファイルを使ってサンドボックスに向け直す**というものでした。これにより、コミットされる設定にはサンドボックス固有の情報が一切残りません。この方法はうまくいき、設計上の議論を実測された結果に変えました。

さらに重要なのは、apply が **クリーンな `terraform validate` では見つからなかった4つの不備** を洗い出したことです。その中には、あるCLIが黙って捨てていた設定値も含まれていました。本記事ではこのオーバーライドの手法、それぞれの不備が何を意味したか、そしてこの結果が示すパイプラインの形を扱います。

## 状況: 書かれた場所では適用できない設定

この形は名指しする価値があるほどよくある話です。設定は共有リポジトリの規約に合わせて書かれ、state バックエンド、permission boundary、そして自分が到達できない環境にしか存在しないアカウント固有の識別子を参照します。内容はすべて正しいのに、そのままでは何も実行できません。

**魅力的に見える回避策は、値をローカルで書き換えて apply し、元に戻すのを覚えておくことです。** これはうまくいきますが、忘れた瞬間に終わりで、コミットされるのはサンドボックスを指した設定になります。

## 2つのオーバーライドファイル

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
```

1つ目のオーバーライドはバックエンドを置き換え、まだ存在しないバケットではなくディスク上に state を残します。

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

2つ目は個々の `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` から引き継がれます。2つの短いファイルだけで、コミットされるものは何もなく、`git status` はきれいなままです。

*Figure — OverrideMechanism: サンドボックスでの実行は gitignore 済みファイルを2つ追加します。CI での実行は何も追加しません。*

## plan が証明したこと、証明しなかったこと

`plan` の結果は **追加12、変更0、削除0** で、その内容を読んだことで `validate` も `fmt` も検出できなかった命名のバグに気づけました。ある出力が `https://mcp-mcp-dv-...` としてレンダリングされていたのは、テンプレートがすでにプロジェクト名を含む文字列にプロジェクト名を差し込んでいたためです。見えれば一目瞭然でも、何かがレンダリングするまでは見えません。

しかし plan が成功したということは、プロバイダが設定を*受理した*ことを証明するにすぎません。**AWS がそれを受理したことまでは証明しません。** この違いこそ、本記事の残りの部分が扱う対象です。

## apply が捕まえて validate が捕まえなかったもの

| 何が現れたか | なぜ apply でしか分からなかったか |
| --- | --- |
| プロバイダが空の `environment_variables` マップに対して `null` を返した | 送信した内容と返ってきた内容の不整合だから |
| `max_lifetime` を省略すると AWS が `28800` を補完し、恒久的な差分を生んだ | デフォルト値がサーバー側で適用されるから |
| バージョニングされたバケットに対する `destroy` が `409 BucketNotEmpty` で失敗した | 実際のテアダウンでしか削除処理を通らないから |
| あるCLIが、適用したと主張していた設定値を黙って捨てていた | 実際にデプロイされた2つのリソースを比較しないと分からないから |

最初の2つは同じエラークラスとして現れました。

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

どちらもプロバイダ側のバグで、どちらも一度見えてしまえば直すのは簡単です。1つ目は `{}` ではなく `null` を渡せば直ります。2つ目は AWS がデフォルトとして設定する値をこちらでも設定すれば直り、そのことを説明したコメントは、この1行自体よりも価値があります。

```hcl
# max_lifetime を省略すると AWS が 28800 を設定して返してくるので、
# プロバイダは plan のたびに「was null, now 28800」と報告する。
lifecycle_configuration = [{
  idle_runtime_session_timeout = var.idle_timeout
  max_lifetime                 = var.max_lifetime
}]
```

3つ目はテアダウンのときにしか現れません。**バージョニングが有効なバケットは、中身を削除しただけでは空になりません。** 削除マーカーと非最新バージョンが残るため、`destroy` は `409 BucketNotEmpty` で止まります。修正は `force_destroy` フラグで、計画的なテアダウンで見つかるならコストはほぼゼロですが、環境を急いで消したいときに見つかるとかなりの代償になります。

## 黙って捨てられていた設定値

これこそが、この検証全体を正当化した一件です。

このリソースはもともとベンダー製のCLIによって作られていました。そのCLIはアイドルセッションタイムアウトを設定するフラグを受け付け、その値を自分のサマリー出力にエコーバックし、自分の設定ファイルにも書き込みます。ローカルのあらゆる痕跡が、その設定値は300秒だと一致していました。

実際にデプロイされた2つのリソースに問い合わせると、話は違っていました。

| リソース | 作成元 | デプロイされたアイドルタイムアウト |
| --- | --- | --- |
| 元のリソース | ベンダーCLI、フラグに300を設定 | **900** |
| サンドボックス | 今回のTerraform | **300** |

同じアカウント、同じリージョン、同じ意図した設定。**APIはその値を受け付けるのに、CLIは一度も送っていませんでした。** ツールのソースコードを読んで原因が分かりました。コンテナデプロイのコードパスはこのライフサイクル設定をAPI呼び出しにそのまま渡しますが、zipデプロイのコードパスは渡しません。フラグはパースされ、検証され、エコーバックされ、永続化され、そして捨てられていたのです。

課金モデルの仕組み上、これは見た目だけの問題では済みませんでした。セッションが生きている間はメモリの分だけ課金されるからです。ただ、一般化できる点はもっと重要です。**この間違いに異を唱えていた唯一の痕跡は、デプロイされたリソースそのものでした。** ローカルのどの痕跡もそれを明らかにできず、設定をどれだけ読み返しても助けにはなりませんでした。

## 設定が実態を言い表せていることの確認

apply の後、もう一度 `plan` を実行します。

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

この一文が受け入れテストです。一度受理されただけではなく、設定が実際に存在するものを言い表せていることを証明します。上記2つのプロバイダの不整合はどちらも、直さないまま放置していればここで恒久的な差分として現れていたはずです。

**それから destroy を実行し、アカウント内の無関係なリソースが無事だったかを確認します。** 別のものまで巻き込むテアダウンは、サンドボックスで見つけておきたい不備です。

## 想定しているパイプライン

**この部分は設計であって、まだ動いているものではありません。** リポジトリと対象アカウントの間で OIDC の信頼関係を確立する必要があり、これは別の人のタスクで、それ自体にリードタイムがあります。この部分を含めているのは、サンドボックスでの結果がこのパイプラインの根拠になっているのと、この形が一般化できるからです。

*Figure — Pipeline: plan はプルリクエストで、apply はマージで実行します。コンテナベースのデプロイと異なるのはアーティファクトの工程だけです。*

**想定しているフローはごく一般的なものです。** プルリクエストで `fmt`、`validate`、lint、`plan` を実行し、何も適用せずに差分をレビュー用に公開します。main へのマージではデプロイ用のアーティファクトをビルドしてアップロードし、ステージング環境に適用します。本番環境は手動トリガーです。

### 間違えやすい2点

**認証は保存された鍵ではなく OIDC を使います。** ワークフローは `id-token: write` を要求し、環境ごとにロールを assume するので、リポジトリには長期間有効な認証情報が一切保存されません。これは標準的な方法で、かつリードタイムが発生する部分でもあります。信頼関係は対象アカウントの管理権限を持つ誰かが作成しなければならないからです。

**アーティファクトの工程だけが、本当に新しく必要になる部分です。** コンテナベースのサービスなら、CIがイメージをビルドしてレジストリにプッシュし、デプロイはそれを参照します。zipベースのサービスなら、CIがzipをビルドしてオブジェクトストレージにアップロードし、リソースはそのキーを参照します。それ以外、つまりプルリクエストでのplanジョブ、マージでのapplyジョブ、環境ごとのゲーティングは変わりません。

apply はこの設計の順序に関する問題も明らかにしましたが、エージェントは意図的にそれを未解決のまま残しました。バケットとそこから読み込むリソースは同じ apply の中で作られるため、初回実行時にはリソースが作られる時点でアーティファクトがまだ存在しません。Terraform からのアップロードをオブジェクトリソースにすれば順序は解決しますが、ローカルにアーティファクトが存在しない限り `plan` が失敗するようになります。**これは未検証の答えとしてコミットするのではなく、実装しないままにしておかれました。** ブランチの価値のすべてが検証済みであることにある以上、これは推測をブランチに持ち込むより誠実な判断です。

## まとめ

`*_override.tf` は小さな機能でありながら、特定かつ繰り返し起きる問題、つまり書かれた場所とは違う場所で設定を編集せずに動かす問題を解決します。このパターンは最初からgitignoreされているので、安全性は記憶ではなく仕組みによるものです。

より大きな論点は、何が根拠として通用するかです。`validate` とクリーンな `plan` は、設定がパースできてプロバイダがそれを受理したことしか意味しません。**ここで見つかった4つの不備はすべてその境界線の向こう側にあり**、そのうちの1つはローカルのあらゆる痕跡が問題ないと主張している間、何日も黙って間違ったままでした。サンドボックスアカウントはほとんどコストがかからず、その違いを安全に検証できる唯一の場所です。

## 参考リンク

- [Terraform overrideファイル。localsとmoduleブロックのマージ規則を含む](https://developer.hashicorp.com/terraform/language/files/override)
- [Terraformのバックエンド設定。ローカル実行のためにバックエンドを上書きする方法](https://developer.hashicorp.com/terraform/language/backend)
- [`*_override.tf` をすでに除外しているgitignore.ioのTerraformテンプレート](https://www.toptal.com/developers/gitignore/api/terraform)
- [GitHub ActionsのためのAWSでの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)
