第2章 コードとしてのインフラとデプロイ自動化(中級:Terraform・IaC)

学習目標

  • 手動によるコンソール作業(ClickOps)の限界を説明し、Terraformの宣言的(Declarative)モデルとステートファイル(terraform.tfstate)が動作する原理を説明する。
  • S3リモートバックエンド(Remote Backend)とステートロック(State Locking:use_lockfileおよびDynamoDB)を構成し、同時実行によるステート破損を防ぐ。
  • 再利用可能なモジュール(Module)を設計し、ディレクトリベースの環境分離とワークスペース(Workspace)のトレードオフを比較して本番環境の隔離構造を選ぶ。
  • terraform planの出力においてインプレース更新(~)と破棄後の再作成(-/+、Replace)を区別し、lifecycle { prevent_destroy = true }およびmoved・importブロックを用いてデータ消失なしにリファクタリングする。
  • sensitive = trueやEphemeralリソースがステートファイルの暗号化の代わりにはならない理由を説明し、ステートファイルのアクセス統制とシークレット注入構造を設計する。
  • インフラのドリフト(Drift)が発生する原因を突き止め、無断の手動変更を元に戻すか、緊急ホットフィックスをコードへ取り込む手順を実行する。
  • 長期IAMアクセスキーを持たずに、GitHub Actions OIDC(AssumeRoleWithWebIdentity)と最小条件(sub、aud)によって安全なTerraformデプロイパイプラインを構築する。

はじめに

クラウドサービスを最初に立ち上げるときは、誰もがWebブラウザでAWS、Cloudflare、Supabaseのコンソールにログインし、マウスでボタンを押してリソースを作ります。セキュリティグループにポートを1つ開け、S3バケットを作り、DNSレコードを追加するのに5分もあれば十分です。いわゆる**ClickOps(クリックオプス)**です。

ところが、サービスが成長してステージング環境をまったく同じようにさらにもう1つ作らなければならなくなったり、深夜の障害対応中に誰かが急いで変えたファイアウォール設定のせいで翌日に別の機能が止まったりすると、ClickOpsはたちまち災厄へと変わります。「先月、キム主任がコンソールでチェックボックスを1つオンにしたらしいけれど、どこだったっけ?」「ステージングではうまく動くのに、なぜ本番環境でだけ権限エラーが出るのだろう?」「誤ってルーティングテーブルを消してしまったが、元の設定値が何だったのか誰も知らない。」マウスのクリックにはGitのコミット履歴も、コードレビュー(PR)も、自動化されたロールバック(Rollback)も残らないからです。

コードとしてのインフラ(Infrastructure as Code, IaC)とその標準ツールであるTerraformは、インフラをマウスクリックの結果物ではなく、**バージョン管理される宣言的コード(.tf)**へと変えます。しかしTerraformもまた、ツールの動作原理を知らないまま使えば、マウスクリックよりも恐ろしい事故を引き起こすことがあります。terraform applyを1回実行しただけで本番データベースが削除後に再作成(-/+)されてデータが蒸発したり、2人のエンジニアが同時にコマンドを回してステートファイルが壊れたり、Gitリポジトリに上げたterraform.tfstateファイルからデータベースのマスターパスワードが平文のまま流出したりすることもあります。この章では、Terraformを安全かつ予測可能に扱うための10の重要な概念と、実践的なCI/CD自動化設計を学びます。

重要な概念

宣言的(Declarative)モデルとTerraformの基本ワークフロー

インフラを自動化する方式は、大きく2つに分かれます。

  • 命令型(Imperative、手続き型)方式:シェルスクリプトやAWS CLIのように、「どのように(How)」行うかを順番に指示します。たとえば「セキュリティグループを作成せよ → 443番ポートを開けよ → サーバーを2台起動せよ」と書きます。問題は、スクリプトを2回実行したときです。すでにセキュリティグループが存在するとしてエラーが出るか、サーバーがさらに2台作られて合計4台になってしまいます。
  • 宣言的(Declarative)方式:Terraform(HCL、HashiCorp Configuration Language)のように、**「最終的にどのような状態であるべきか(What)」**だけを記述しておきます。「443番ポートが開いたセキュリティグループ1つとサーバー2台が存在すべきである」と宣言すれば、現在サーバーが0台のときは2台を新規作成し、すでに2台あるときは何もしない(べき等性、Idempotency)うえ、3台あるときは1台減らし、ポート番号を8443に書き換えればその差分(Diff)だけを計算して反映します。

Terraformの標準的な実行サイクルは、3つの段階で回ります。

  1. terraform init:作業ディレクトリを初期化し、宣言されたクラウドプロバイダーのプラグイン(AWS、Cloudflare、Supabaseなど)とモジュールをダウンロードし、ステートファイルを保存するバックエンド(Backend)を接続します。
  2. terraform plan:実際のクラウドリソースにはまだ一切手を触れずに、(ア)自分が記述した.tfコード(望ましい状態)、(イ)ステートファイル(terraform.tfstate、最後に記憶している状態)、(ウ)クラウドAPIを呼び出して確認した実際のインフラの現在状態を比較し、「何を追加(+)、更新(~)、削除(-)、置換(-/+)するのか」という実行計画書を表示します。
  3. terraform apply:レビューした計画書に基づき、クラウドAPIを呼び出して変更を実際に適用し、その結果をステートファイルに記録します。

ステートファイル(terraform.tfstate)の役割と構造

Terraformが宣言的な魔法を発揮できる秘訣は、まさに**ステートファイル(terraform.tfstate)**にあります。

自分のコードにresource "aws_instance" "web" { ... }と書かれているとき、TerraformはこのブロックがAWSアカウント上で稼働している数百台のEC2インスタンスのうち、具体的にどのインスタンス(i-0a1b2c3d4e5f)を指しているのかをどうやって知るのでしょうか。それは、最初のapplyのときに作成された実際のクラウドリソースIDと属性値を、JSON形式のterraform.tfstateファイルに帳簿のように記録しておくからです。

ステートファイルは、3つの決定的な役割を果たします。

役割 説明 ステートファイルが破損・消失したときに起きること
コードと実物リソースのマッピング .tf内の論理アドレス(aws_db_instance.main)とクラウド上の物理ID(arn:aws:rds:...)を1対1で結びつける すでに稼働中のDBがあることを知らずに同じリソースを新規作成しようとして衝突するか、重複作成してしまう
依存関係グラフの保持 どのリソースが他のリソースを参照しているかを記録し、削除・置換の順序を正確に計算する コードからブロックを消したとき、何をどの順序で削除すべきか追跡できなくなる
性能と変更検知のベースライン クラウドAPIから最後に確認した属性のスナップショットを保管する 毎回すべてのクラウドリソースを全件スキャンしなければならなくなるか、ドリフトの基準点を失う

このようにステートファイルはインフラの心臓部であるため、絶対に手で直接開いてJSONテキストを編集してはいけませんし、ローカルのノートPCのディスクにだけ置いてチームメンバー同士でチャットツール越しにやり取りしてもいけません。

リモートバックエンド(Remote Backend)とステートロック(State Locking)

1人で練習するときはローカルフォルダにterraform.tfstateができても構いませんが、2人以上で作業したりCI/CDパイプラインが回り始めたりした瞬間から、**リモートバックエンド(Remote Backend)とステートロック(State Locking)**は選択ではなく必須となります。

もしエンジニアAとエンジニアBがそれぞれのノートPCから同じインフラに対して同時にterraform applyを実行したら、何が起きるでしょうか。Aのプロセスが読み取ったステートとBのプロセスが読み取ったステートが互いに上書きし合い(Race Condition、競合状態)、片方が作ったリソースがステートファイルから蒸発(孤児リソースの発生)したり、ステートJSON自体が壊れてしまったりします。

これを防ぐため、ステートファイルは**バージョニングと暗号化が有効化された中央のリモートストレージ(例:AWS S3、Terraform Cloud、GCS)に置き、誰かがplanやapplyを実行している間は他の実行が割り込めないようにステートロック(State Locking)**をかけます。

terraform {
  backend "s3" {
    bucket       = "company-prod-tfstate-apne2"
    key          = "network/prod/terraform.tfstate"
    region       = "ap-northeast-2"
    encrypt      = true
    use_lockfile = true
  }
}

AWS S3バックエンドにおけるステートロック方式は、次の2つをどちらも知っておく必要があります。

  1. 従来方式(dynamodb_table):S3バケットとは別に、LockID(文字列パーティションキー)を持つDynamoDBテーブルを作成し、条件付き書き込み(Conditional Write)によってロックを管理していました。既存のインフラやレガシーコードでは依然として広く使われています。
  2. 現代的なS3ネイティブロック(use_lockfile = true):S3が条件付き書き込み(If-None-Matchヘッダーによるオブジェクトのアトミックな作成)をサポートしたことに伴い、最新のTerraformでは別途DynamoDBテーブルを作らなくても、use_lockfile = trueオプション1つでS3バケット内に.tflockロックファイルをアトミックに生成し、ステートを安全にロックできるようになりました。

また、S3バケットには必ずバージョニング(Bucket Versioning)とサーバーサイド暗号化(SSE-S3またはSSE-KMS)、そして**パブリックアクセスの完全ブロック(Block Public Access)**を有効にしておかなければなりません。万が一誤ってステートが上書きされたとしても、S3オブジェクトの以前のバージョンから即座に復旧できるからです。

注意

時折、CIパイプラインが異常終了してロックが解除されなかったからといって、同僚がいまapplyを回しているかどうかを確認もしないまま無条件に-lock=falseオプションを付けて実行したり、terraform force-unlock <LOCK_ID>を乱発したりするケースがあります。-lock=falseはロックの安全装置を切る危険なオプションであり、force-unlockは実際に実行中の他プロセスが1つもないことを100%確認したときにのみ、最後の手段として使わなければなりません。

モジュール(Module)設計とバージョン固定

インフラコードが数千行に増えると、同じパターン(例:VPC + サブネット + セキュリティグループ、あるいは標準S3バケット + 暗号化 + ログ設定)が複数の場所で繰り返されます。Terraformの**モジュール(Module)**は、関連するリソースの束を関数のように再利用可能なパッケージとして抽象化した単位です。

  • 入力変数(variables.tf、variableブロック):モジュールの外部から注入される設定値です。typeやvalidationブロックを使って、許可されていないリージョンや誤ったCIDR形式が入ってきたらplanの段階で即座に失敗するように作ります。
  • ローカル値(localsブロック):モジュール内部でのみ計算して使う定数や組み合わせ式です。
  • 出力値(outputs.tf、outputブロック):モジュールが作成したリソースのIDやドメインアドレスのように、上位(ルート)モジュールや他のモジュールへ渡す結果値です。

外部の公開レジストリや社内Gitリポジトリのモジュールを利用するとき、最も重要なセキュリティ・安定性の原則は**バージョン固定(Version Pinning)**です。

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.16.0"

  name = "prod-vpc"
  cidr = "10.20.0.0/16"
}

バージョンを指定しなかったり、範囲を広く開けすぎたりすると、ある日ささいな修正をデプロイしようとterraform init -upgradeを回した際にモジュールのメジャーバージョンが上がり、下位のリソース構造が変わって本番リソースが丸ごと置換(-/+)されてしまうおそれがあります。本番環境では正確なバージョン(または検証済みの社内GitコミットSHA・タグ)で固定し、依存関係ロックファイルである**.terraform.lock.hclを必ずGitリポジトリにコミット**して、すべての開発者とCIランナーが同一ハッシュのプロバイダーバイナリを使うよう強制しなければなりません。

環境隔離:ディレクトリ別ステート分離対ワークスペース(Workspace)

開発(dev)、ステージング(staging)、本番(prod)の3つの環境をTerraformで管理するとき、構造をどのように分けるべきでしょうか。2つの方法があり、両者の違いと限界を明確に区別する必要があります。

比較項目 ディレクトリ・バックエンド分離(envs/dev, envs/prod) 単一ディレクトリ + CLIワークスペース(terraform workspace)
ステートファイルの隔離 完全に異なるS3バケット・パス、互いに異なるIAM権限を付与可能 同じバックエンドバケット配下でenv:/dev/...、env:/prod/...とパスが分かれるだけ
コード・バージョン差分の許容 stagingで新しいモジュールバージョンを先に試したあと、数日後にprodへ反映可能 すべてのワークスペースが完全に同一の.tfコードとモジュールバージョンを共有する
作業者のミスによるリスク envs/devフォルダの中ではどうやってもprodリソースには触れられない 現在選択されているワークスペースがprodであることに気づかず、devだと思い込んでdestroy/applyを実行するリスク
推奨用途 本番環境と非本番環境の恒久的な環境隔離(定石) 1人の開発者がPRごとに一時的なテスト環境を作って消す用途

つまり、本番(prod)環境は必ず別個のディレクトリ、別個のステートファイルバックエンド、そして可能な限り別個のクラウドアカウント(AWS Account/プロジェクト)と専用IAMロールによって物理的・論理的な境界を分離しなければなりません。共通コードは再利用モジュール(modules/app-stack)として切り出し、envs/stage/main.tfとenvs/prod/main.tfからそれぞれそのモジュールを呼び出しつつ、独自のバックエンドとバージョンを指定する構造が最も安全です。

terraform planの精密な読み解きと破壊的変更の遮断(prevent_destroy)

コードとしてのインフラにおける事故の90%は、terraform planが出力した警告をきちんと読まずに習慣的にyesを入力したり、-auto-approveで押し切ったりしたときに起きます。planの出力でリソースの前に付く4つの記号は必ず暗記しなければなりません。

記号 意味 クラウド上で実際に起きること 危険度
+ (Create) 新規作成 コードに新しく追加されたリソースをクラウド上に作成 低(コスト・クォータを確認)
- (Destroy) 完全削除 コードから削除されたリソースをクラウドから永久に削除 極めて高い(データ消滅)
~ (Update in-place) インプレース更新 リソースを消さずに設定値(例:タグ、セキュリティグループ規則、容量拡張)のみをAPIで変更 中(瞬断の有無を確認)
-/+ または +/- (Replace) 破棄後の再作成(置換) 既存リソースを完全に削除(-)したあとに新しいリソースを作成(+)する。リソースIDと内部データが初期化される! 致命的(DB・ボリューム・固定IPの消失)

なぜコードの中で1行だけ直したのに、~(インプレース更新)ではなく-/+(破棄後の再作成)が出るのでしょうか。それは、クラウドベンダーのAPIがその属性の変更を許容していない(Immutable属性、不変属性である)ためです。たとえばAWS RDSデータベースの識別子や暗号化設定の変更、EC2インスタンスのサブネット(subnet_id)やAMIの変更、S3バケットの名前(bucket)の変更は、既存のリソースを維持したまま変えることができないため、Terraformが# forces replacementというコメントとともに-/+を表示します。

状態を持つ重要リソース(本番データベース、永続ストレージバケット、暗号化KMSキー、中核VPC)が、万が一のミスによって削除されたり置換(-/+)されたりするのをコードレベルで根本から封じ込める安全装置が、**lifecycleブロックのprevent_destroy = true**です。

resource "aws_db_instance" "production" {
  identifier     = "aidan-prod-postgres"
  engine         = "postgres"
  instance_class = "db.r6g.large"

  lifecycle {
    prevent_destroy = true
  }
}

このように宣言しておけば、誰かが誤ってDBの不変属性に触れて置換(-/+)が引き起こされたり、リソースブロックを消してしまったりしたときに、Terraformがplanの段階で即座にエラー(Instance cannot be destroyed)を出して停止します。また、無停止での置換が必要なWebサーバーや証明書のように、新しいリソースを先に作ってから既存のリソースを消さなければならないリソースにはcreate_before_destroy = trueを使用し、外部のオートスケーラーが随時変更する属性(例:現在のコンテナ数desired_capacity)はignore_changes = [desired_capacity]として指定し、不要な上書きを防ぎます。

無停止リファクタリングと既存リソースの取り込み:movedブロックとimportブロック

インフラコードを整理していると、リソース名をaws_s3_bucket.old_nameからaws_s3_bucket.assetsに変えたり、ルートにあったリソースを下位モジュール(module.storage.aws_s3_bucket.assets)の中へ移したりしたくなることがあります。

このとき、コード上で名前だけを書き換えてterraform planを回すと、衝撃的な結果が出ます。Terraformは名前が変わったとは気づかず、**「古い名前(old_name)は削除(-)し、新しい名前(assets)は新規作成(+)する」**という計画を立ててしまうのです!そのままapplyすれば、稼働中のS3バケットやデータベースが削除されてしまいます。

かつてはエンジニアがターミナルでterraform state mvコマンドを手動で叩いてステートファイルを書き換えていましたが、これはPRレビューで検証できず、他のチームメンバーやCIパイプラインと同期するのも困難でした。現代のTerraformでは、コードの中に**movedブロック**を宣言して安全にリファクタリングします。

moved {
  from = aws_s3_bucket.old_name
  to   = module.storage.aws_s3_bucket.assets
}

movedブロックをコミットすれば、terraform planはリソースを削除・作成する代わりに、ステートファイル内のアドレス表記だけを安全に移すというメッセージを表示し、実物のクラウドリソースは1秒たりとも停止することなくそのまま維持されます。

同様に、過去にコンソールでマウスを使って作った既存のクラウドリソースをTerraformの管理下へ引き入れるときは、宣言的な**importブロック**を使用します。

import {
  to = aws_s3_bucket.legacy_uploads
  id = "company-legacy-uploads-prod"
}

importブロックを記述し、terraform plan -generate-config-out=generated.tfを活用するかリソースブロックを合わせたうえでplanを実行し、差分(~や-/+)が0件(No changes)になるようにコードを合わせてからapplyすれば、既存のリソースに一切手を加えることなくステートファイルへ安全に取り込むことができます。

ステートファイルの平文シークレット露出問題と機密変数の管理

Terraformのセキュリティにおいて、最も多く誤解されている事実が1つあります。 「変数や出力にsensitive = trueを付ければ、terraform.tfstateファイルの中でも暗号化されたり隠されたりする」という思い込みは、完全に誤りです!

sensitive = trueが行うことは、ただ**terraform planとterraform applyを実行するときにターミナル画面(コンソールログ)で値が(sensitive value)として伏せられて出力されるようにすることだけです。Terraformが次回のplanで値が変わったかどうかを比較するためには元の値を知っていなければならないため、通常のリソース属性として渡されたデータベースパスワード、TLS秘密鍵、APIトークンは、terraform.tfstate JSONファイルの中に文字どおり平文(Plaintext)で保存**されます。

したがって、ステートファイルのセキュリティを守るためには、次の4つの原則を必ず組み合わせて適用しなければなりません。

  1. terraform.tfstateと.terraform/ディレクトリはいかなる場合もGitにコミットしません(.gitignoreへの登録が必須です)。
  2. リモートバックエンド(S3など)のストレージ自体をKMSなどで暗号化(encrypt = true)し、ステートファイルを読み取れるIAM権限をデプロイパイプラインと最小限の管理者ロールに厳格に制限します。ステートファイルの閲覧権限は、すなわちインフラ内の全シークレット値の閲覧権限に等しいからです。
  3. Terraformコード内でパスワードを直接生成したり平文変数として渡したりする代わりに、クラウドマネージドのマスターパスワード管理機能(例:RDSのmanage_master_user_password = true)を使うか、ランタイムにアプリケーションがSecrets Manager / Parameter Store / Vaultから直接シークレットを読み取るように設計し、ステートファイルにシークレット値自体が書き込まれないようにします。
  4. 最新のTerraform(1.10以降)がサポートする**一時(Ephemeral)変数・リソース(ephemeral = true、ephemeralブロック)**および書き込み専用属性(Write-only attributes、例:password_wo)を使用すれば、実行中にのみシークレット値を読み取って対象APIへ渡し、ステートファイルとプランファイルには一切記録しないように作ることができます。

ドリフト(Drift)の検知と復旧手順

**ドリフト(Configuration Drift)**とは、コード(.tf)とステートファイルが記憶しているインフラの姿と、実際のクラウド上で稼働しているインフラの姿が互いに食い違っている状態を指します。

なぜドリフトが生じるのでしょうか。最もよくある原因は、深夜の障害対応中に当番のエンジニアが急場をしのぐため、AWSやCloudflareのコンソールに直接入ってセキュリティグループのポート、インスタンススペック、DNSレコード、WAFルールをマウスで変更したあと、コードへ反映し忘れたケースです。

ドリフトを放置すると、1か月後にまったく別の機能をデプロイしようと誰かがterraform applyを回した瞬間、Terraformがコンソールで手動変更されたホットフィックス設定を「コードと異なる」と判断して元どおりに上書き(Revert)してしまい、障害が再発します。

ドリフトに対応する正しい手順は次のとおりです。

  1. 定期的なドリフト検知:CIスケジューラー(例:毎朝)でterraform plan -detailed-exitcode(変更なしは0、エラーは1、ドリフト/変更検知時は2を返す)またはterraform plan -refresh-onlyを実行し、コードの変更なしにクラウド側で変わった内容があれば即座にSlackやチケットへアラートを送ります。
  2. 原因の判別と2通りの復旧:
    • 無断変更やミスの場合:現在の.tfコードを基準にterraform applyを実行し、クラウドの状態を再びコードの定義どおりに戻します(コードへの収束)。
    • 維持すべき正当な緊急ホットフィックスの場合:まず.tfコードを修正してコンソールで変更した実際の設定とまったく同じに合わせたあと、terraform planを回して**追加の変更がないこと(No changes. Your infrastructure matches the configuration.)**を確認してからコミットします。

GitHub Actions OIDCによるキーレス(Keyless)CI/CDパイプライン

かつてはGitHub ActionsからTerraformでAWSインフラをデプロイするために、IAMユーザー(User)を作成し、長期アクセスキー(AWS_ACCESS_KEY_IDとAWS_SECRET_ACCESS_KEY)を発行してGitHub Secretsに入れていました。この方式には致命的な弱点があります。一度発行された静的キーには有効期限がないため、もしCIログの設定ミスやサードパーティActionのサプライチェーン攻撃によってキーが1度でも流出すれば、攻撃者がいつでもどこからでもクラウド全体を掌握できてしまいますし、キーを定期的にローテーションする作業も煩雑です。

現代のクラウドセキュリティの標準は、長期静的キーを完全に廃止し、**OIDC(OpenID Connect)ベースの短期トークン交換(AssumeRoleWithWebIdentity)**を使うことです。

動作の流れは次のとおりです。

  1. AWS IAMに、GitHubのOIDC発行者(https://token.actions.githubusercontent.com)を信頼するIDプロバイダー(Identity Provider)として登録します。
  2. GitHub Actionsのワークフローにpermissions: { id-token: write, contents: read }を付与すると、ジョブが実行される際にGitHubが「このリクエストは間違いなくorg/repoリポジトリのmainブランチで動いているワークフローである」という電子署名付きの短期OIDC JWTを発行します。
  3. ワークフローがこのJWTをAWS STS(AssumeRoleWithWebIdentity)に提示すると、AWSはIAMロールの**信頼ポリシー(Trust Policy)**の条件を照合したうえで、1時間程度だけ有効な一時的認証情報を払い出します。保存すべき長期秘密鍵がそもそも存在しないのです!

ここで試験でも実務でも最も重要な核心は、IAMロールの信頼ポリシーのCondition句をどれだけ狭く設定するかにあります。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "repo:acme-corp/infra-live:ref:refs/heads/main"
        }
      }
    }
  ]
}

もしsub(Subject)条件を書き漏らせば、世界中のあらゆるGitHubユーザーのどのリポジトリからでも、私たちのAWSロールを借りて使えるようになってしまいます!また、StringLikeで"repo:acme-corp/infra-live:*"のようにすべてのブランチに対して本番apply権限を持つロールを開放してしまうと、どの開発者でもフィーチャーブランチでワークフローファイル(.github/workflows/deploy.yml)を書き換えてPRを出すだけで、本番の管理者権限を取得できてしまいます。したがって、次のPRでのplan/mainマージ後のapply分離原則を必ず守らなければなりません。

パイプライン段階 トリガー時点 使用するIAMロールとsub条件 権限範囲
PR検証 (plan) Pull Requestの作成・更新 terraform-plannerロール(pull_requestまたは専用のplan環境に制限) 読み取り専用(Read-Only) + ステートロック・照会権限。PRコメントにplan結果のみを投稿
本番反映 (apply) コードレビュー承認後のmainブランチへのマージ terraform-applierロール(ref:refs/heads/mainまたは承認保護されたenvironment:productionに厳格制限) 実際のリソース作成・変更・削除およびステートファイル書き込み権限

さらに深く

PRベースのインフラ自動化(Atlantisパターン)の攻撃対象領域とプランファイルの完全性

「PRを開くと自動でterraform planを回してコメントに表示し、レビュアーが承認したらapplyする」というワークフローは非常に便利ですが、攻撃者の目で見ると極めて危険な落とし穴が潜んでいます。

terraform planは単なる構文チェッカーではありません。planの段階であっても、外部モジュールやプロバイダーコードをダウンロードして実行し、data "external"データソースやローカル式を評価し、リモートステートファイルをすべてメモリへ読み込みます。

もし外部の人がフォーク(Fork)して出したPRや、まだコードレビューを経ていないブランチのコードに対して、自動的に本番の認証情報を紐づけてterraform planを実行させていたらどうなるでしょうか。悪意のあるPR作成者が.tfファイルに、ステートファイルのシークレット値を読み取って外部Webhookへ送信するデータソースを1行書き加えるだけで、applyをする前に**planの段階ですでに本番ステートファイル内のシークレットがすべて奪取**されてしまいます!

したがって、安全なPR自動化パイプラインには3つの防衛線を設けます。

  1. 外部のフォークリポジトリから上がったPRでは、クラウド認証情報を必要とするワークフローを自動実行しません。
  2. PRのplanロールは実際のリソースを変更できない最小限の読み取り権限のみとし、ワークフローファイル(.github/workflows/*)やバックエンド設定自体を変更するPRは、インフラ管理者のレビュー後にのみ実行されるよう保護します(GitHub EnvironmentsのRequired Reviewersを活用)。
  3. planをレビューした時点と実際にapplyを実行する時点の間に別の変更が割り込むこと(TOCTOU)を防ぐため、terraform plan -out=tfplan.binaryで生成した**バイナリプラン成果物(Plan Artifact)**のハッシュを検証し、terraform apply tfplan.binaryによって正確にレビューされた変更のみが反映されるようにします(このときtfplan.binaryファイルの中にもステートファイルと同様に平文のシークレット値が入っているため、絶対に公開アーティファクトストレージへ上げてはいけません)。

大規模インフラにおけるステートファイルの爆発半径(Blast Radius)の縮小

初期の段階では、ネットワーク(VPC)、データベース(RDS)、Kubernetes/アプリクラスター、DNS/Cloudflare設定を、たった1つのディレクトリと1つのterraform.tfstateファイルに詰め込みがちです(モノリシックステート)。

しかしリソースが数百個に増えると、単一のステートファイルは3つの大きな問題を引き起こします。

  • plan速度の低下とAPIレート制限:planを1回実行するたびに数百個のリソースの現在状態をクラウドAPIで照会するため、10分以上かかったりAPI呼び出し上限に引っかかったりします。
  • ロックのボトルネック:フロントエンドエンジニアがCDNキャッシュルールを1つ直そうとしてplanを回している間、データベースエンジニアはステートロックに阻まれて何の作業もできません。
  • 爆発半径(Blast Radius)の拡大:誰かがアプリ設定を1つ誤って触りステートが乱れると、VPCと本番データベースまでが一挙に危険にさらされます。

解決策は、**変更周期とライフサイクル(Lifecycle)、そして所有チームに応じてステートファイルをレイヤーごとに分割すること(State Segmentation)**です。たとえば(1)年に数回しか変わらない01-network(VPC、サブネット、ZTNAコネクタ)、(2)データ保全が最優先の02-data(RDS、S3、KMS)、(3)1日に何回も変わる03-app-edge(Cloudflareルール、アプリルーティング、ワーカー)へとディレクトリとステートファイルを分離します。下位レイヤーが上位レイヤーのVPC IDやサブネットIDを参照する必要があるときは、ハードコーディングの代わりにterraform_remote_stateデータソース(またはSSM Parameter Store/タグベースのdata照会)で読み取り専用の参照だけをつなげば、爆発半径を各レイヤーの内部に完璧に封じ込めることができます。

実務の場面

ソルビットモビリティ:リソース名変更が招いたDB削除の危機とmoved・prevent_destroy

物流配車AIプラットフォームを運用する「ソルビットモビリティ」のDevOpsエンジニアは、初期に急いで書かれたTerraformコードをきれいに整理するリファクタリング作業を担当した。彼はルートモジュールにあったaws_db_instance.db1という野暮ったいリソース名をaws_db_instance.logistics_primaryに変え、あわせてストレージサイズを100GBから200GBへ増やすPRを出した。

CIパイプラインが出力したterraform planの要約の最終行には、Plan: 1 to add, 0 to change, 1 to destroy.と書かれていた。レビュアーはストレージ容量を増やすささいな変更だとばかり思い込み、詳しく見ないまま承認ボタンを押した。幸いにも手動applyの直前にシニアエンジニアがプランログをもう一度見直していて背筋が凍りついた。Terraformが名前の変更を「既存の本番DB(db1)を完全削除(-)したあと、空の新しいDB(logistics_primary)を新規作成(+)」と解釈していたのである!もしそのまま反映されていれば、直近の配車記録数十万件が入った本番データベースが蒸発するところだった。

チームは直ちにデプロイを止め、3つの措置を適用した。

  1. movedブロックによる無停止アドレス変更:コードにmoved { from = aws_db_instance.db1, to = aws_db_instance.logistics_primary }を追加した。改めてterraform planを回すと1 to destroy, 1 to addが消え、ステートファイル内のアドレス移動とともにストレージサイズだけがインプレース更新(~、1 to change)される安全な計画が出力された。
  2. prevent_destroyの義務化:すべての本番データベース、S3バケット、KMSキーリソースにlifecycle { prevent_destroy = true }を追加した。これで誰かが誤ってリソース名を変えたり不変属性に触れたりして置換(-/+)が発生すると、planの段階で即座にエラーとなってビルドが失敗する。
  3. CIのPRコメントハイライト:PRコメントボットが、to destroyやmust be replaced(-/+)が1件でも含まれていれば赤い警告バナーを表示し、インフラリードの別途承認を要求するようパイプラインを修正した。

ダオンフィンテック:退職者の静的AWSキー流出脅威を終わらせたGitHub OIDC移行とドリフト正常化

金融データ分析企業の「ダオンフィンテック」は、セキュリティ監査で2つの指摘を受けた。第1に、GitHub Actionsに登録されていたAWS_SECRET_ACCESS_KEYが2年前に退職したエンジニア名義で発行された長期IAMユーザーキーであり、最後のローテーション記録もなかった。第2に、前四半期のDDoS対応当時にセキュリティチームがAWSコンソールから直接変更したWAFおよびセキュリティグループのルール12件がTerraformコードに反映されておらず、開発チームが怖くて2か月間もterraform applyを回せずにいた。

インフラチームは1週間かけて2つの問題を順番に解決した。

課題 実行手順 検証結果
1. ドリフトの吸収 terraform planでコンソール手動変更12件を全件リスト化 → セキュリティチームと検討して維持するルール10件は.tfコードに同じように記述し、新規作成リソースはimportブロックで取り込み、不要な一時ルール2件は削除 terraform plan実行時にNo changesへ到達、以降は毎日09時のスケジュールワークフローでplan -detailed-exitcodeを監視
2. S3ステートロックの整備 既存のローカル/手動バックエンドをバージョニング・KMS暗号化が有効なS3バックエンド(use_lockfile = true)に統合し、ステートファイルの読み取り権限を最小ロールに制限 同時実行時に.tflockによって即座に待機・遮断されることを確認
3. OIDCキーレス移行 AWS IAMにGitHub OIDCプロバイダーを登録 → PR用の読み取りロール(plan)とref:refs/heads/main専用の反映ロール(apply)を分離作成 → 既存の長期IAMユーザーキーを無効化したあと削除 GitHub Secretsに保存されたクラウド長期秘密鍵が0個になり、全デプロイが1時間有効期限の一時トークンのみで動作

間違えやすいポイント

  1. **「sensitive = trueを付けたから、terraform.tfstateファイルを社内共有フォルダやGitに上げてもパスワードは見えない。」**最も危険な錯覚です。sensitive = trueはターミナル出力を隠すだけであり、ステートファイルの中には値が平文で書き込まれます。ステートファイルは絶対にGitへ上げず、暗号化されたリモートバックエンドで厳格なアクセス統制によって守らなければなりません。
  2. **「terraform planにおける-/+記号は設定を少し直すという意味だから、安心してapplyしてよい。」インプレース更新は~記号です。-/+は既存のリソースを完全に削除して新しく作り直す(Replace)**という意味であるため、データベースやストレージに表示された場合はデータ消失に直結します。
  3. **「リソース名を変えるときは、.tfファイルで名前だけを直してapplyすればTerraformが自動的に名前を変えてくれる。」**コード上でリソース識別子だけを変えると、Terraformは既存リソースを削除(-)して新しいリソースを作成(+)します。必ずmovedブロックを一緒に記述して、ステートファイルのマッピングを移さなければなりません。
  4. **「ワークスペース(terraform workspace new prod)さえ分ければ、開発環境と本番環境は完璧に隔離される。」**CLIワークスペースは同じコードと同じバックエンドストレージ設定を共有し、作業者が現在のワークスペースを取り違えやすい仕組みです。本番環境はディレクトリ・バックエンド・IAMロールを分離するのが安全です。
  5. 「GitHub Actions OIDCを設定するとき、IAM信頼ポリシーにaud: sts.amazonaws.comさえ入れておけば安全である。」sub(リポジトリ名とブランチ/環境)条件を検証しなければ、他のGitHubリポジトリのワークフローからも自分のIAMロールを乗っ取って(AssumeRole)使用できてしまいます。
  6. **「コンソールで急いで変えた設定は、Terraformが次回のデプロイ時にうまく察知して保持してくれる。」**Terraformはコード(.tf)を唯一の正解(Source of Truth)とします。コンソールで手動変更した設定(ドリフト)をコードに反映しなければ、次回のapply時に容赦なく上書きされて消えてしまいます。

ひと目でわかるまとめ

重要テーマ 試験と実務で覚えておくべき重要文
宣言的モデルとState .tfコードは望ましい最終状態を宣言し、terraform.tfstateはコードと実際のクラウドリソースIDをマッピングする
リモートバックエンドとロック ステートファイルはバージョニング・暗号化が有効なS3などのリモートバックエンドに置き、use_lockfile = true(またはDynamoDB)で同時実行をロックする
plan記号の区別 +作成、-削除、~インプレース更新(In-place)、-/+破棄後の再作成(Replace、データ消失リスク)
削除防止の安全装置 中核DB・バケット・キーにはlifecycle { prevent_destroy = true }を設定し、ミスによる削除・置換をplan段階で遮断する
無停止リファクタリング・取り込み コード内の名前・モジュールパス変更はmovedブロックで、手動作成リソースのコード取り込みはimportブロックで処理する
ステートファイルの保護 sensitive = trueはコンソール出力しか隠さない。ステートファイルには平文が残るためGitコミット禁止、バックエンドKMS暗号化、Ephemeral/書き込み専用属性を活用する
環境分離 本番環境はワークスペース共有の代わりに、別ディレクトリ・ステートファイル・クラウドアカウント/IAMロールで爆発半径を隔離する
ドリフト対応 定期的なplan -detailed-exitcodeで検知し、無断変更はapplyで戻し、正当なホットフィックスはコードへ先に反映してNo changesを合わせる
CI/CD OIDC 長期アクセスキーの代わりにAssumeRoleWithWebIdentityを使いつつ、信頼ポリシーでaudと正確なsub(リポジトリ・mainブランチ/環境)を検証する

公式ドキュメントで確認する

セルフチェック

2-1. チームメンバー2人がそれぞれのノートPCから同じインフラを対象に同時にterraform applyを実行したときに発生しうる問題を説明し、AWS S3バックエンドでこれを予防するために有効にすべき機能を2つ(ロックと復旧への備え)書きなさい。

2-2. terraform planの出力においてリソースの前に付く~記号と-/+記号の違いを説明し、稼働中のデータベースリソースで誤って-/+や-が実行されないようにHCLコード内へ入れるべき設定ブロックを書きなさい。

2-3. 稼働中のリソースの名前を.tfコード上でaws_instance.appからaws_instance.api_serverに変えたあと、すぐさまapplyするとどのようなことが起きるかを説明し、サービス停止なしに名前だけを安全に変えるために追加すべきブロックを書きなさい。

2-4. 変数にsensitive = trueを設定したにもかかわらず、terraform.tfstateファイルを公開Gitリポジトリにコミットするとすべてのシークレット値が流出する理由を説明し、ステートファイルのシークレット露出を防ぐためのセキュリティ対策を2つ以上書きなさい。

2-5. 深夜の障害対応中に当番者がAWSコンソールから直接セキュリティグループにポートルールを追加(ホットフィックス)し、このルールを今後も維持し続けることに決めた。次回のデプロイ時にこのルールが消えないようにするには、Terraformコードをどのように処理し、何を確認すべきか順番に説明しなさい。

2-6. 設計課題:GitHub Actionsから長期AWSアクセスキーなしでTerraformを実行したい。(1)PRが開かれたときに回るワークフローと(2)mainブランチにマージされたときに回るワークフローのIAMロールをなぜ分離すべきかを説明し、本番反映(apply)ロールのOIDC信頼ポリシー(Condition)で必ず検査すべきクレームを2つ書きなさい。

実習課題

実習

terraform planの危険判読と無停止リファクタリングコードを作成する(45分)

  1. 現在ルートモジュールに宣言されている本番データベースresource "aws_db_instance" "legacy_db"とS3バケットresource "aws_s3_bucket" "legacy_bucket"があると仮定する。
  2. 2つのリソースを削除または再作成(-/+)することなく、名前をそれぞれaws_db_instance.prod_primaryとmodule.storage.aws_s3_bucket.prod_assetsへ移すmovedブロックを2つ作成する。
  3. 2つのリソース定義にミスによる削除を防ぐlifecycle設定を追加し、あわせてS3バックエンドブロック(encrypt = true、use_lockfile = true)を作成する。
  4. もし同僚のPRでaws_db_instance.prod_primary must be replaced (-/+)が表示されたときに確認すべきチェックリストを3項目記述する。

完了基準: movedブロックのfrom/toアドレスが正確であり、prevent_destroy = trueとS3ロック設定がすべて含まれており、置換(-/+)を引き起こす属性を特定するレビュー基準が明記されていること。

実習

GitHub Actions OIDC最小権限信頼ポリシー(Trust Policy)を設計する(45分)

  1. 社内リポジトリtrappist-org/cloud-infraのための2つのAWS IAMロール(tf-pr-plan-role、tf-main-apply-role)の信頼ポリシーJSONをそれぞれ作成する。
  2. 両方のロールともActionはsts:AssumeRoleWithWebIdentityに指定し、aud条件がsts.amazonaws.comであるか検査する。
  3. tf-main-apply-roleのsub条件は、mainブランチ(repo:trappist-org/cloud-infra:ref:refs/heads/main)または保護されたproduction環境(repo:trappist-org/cloud-infra:environment:production)でのみ一致するよう制限する。
  4. なぜtf-main-apply-roleに"repo:trappist-org/cloud-infra:*"のようなワイルドカードを使ってはいけないのか、攻撃シナリオを3文で記述する。

完了基準: 2つのIAM信頼ポリシーJSONにaudとsub条件が正確に入っており、フィーチャーブランチのPRを通じた権限奪取シナリオと防御原理が説明されていること。