Terraform 実践 #9 環境分離 — ワークスペースの限界、ディレクトリ構成、moved リファクタリング

読了 5分

シリーズを通して先送りにしてきた請求書が溜まっています。NAT の個数(#2)、skip_final_snapshot と prevent_destroy(#5)のような値が、「実習ではこう、本番では逆」のままハードコードされています。本番環境を本当に作るには、同じ構造に別の値を入れる方法が必要です。今回は実践シリーズの中で最も Terraform らしい回です。新しいリソースはほとんどなく、これまで作ったものを再作成なしで再構成するリファクタリングがすべてです。

ワークスペースをまず検討して、見送ります #

Terraform には terraform workspace という組み込み機能があります。同じコードで state だけを複数セット持つ方式で、workspace new prod を作ると、同じディレクトリで dev と prod の state が分離されます。手軽に見えますが、環境分離の用途では限界がはっきりしていて、実務での採用率は低いのが実情です。

  • 環境差の表現がねじれます: コードが 1 セットしかないため、環境ごとの違いをすべて terraform.workspace == "prod" ? ... : ... の条件式で書くことになり、コードが条件式だらけになります。
  • 分離が弱いです: 同じバックエンドを共有し、今どのワークスペースにいるかは CLI の状態に依存します。dev のつもりで prod に apply する事故の構造的な原因になります。
  • 権限を分けられません: state が 1 つのバケットパスの下にあるため、「dev は誰でも、prod は CI だけ」のようなアクセス分離ができません。

ワークスペースは同じ構成の一時的な複製(PR プレビュー環境など)には有用ですが、性格の異なる dev と prod の分離には、次の方式が標準です。

目標の構成 — modules と envs #

プロジェクト構造
myapp-infra/
├── modules/
│   ├── network/    # VPC、サブネット、ルーティング、NAT
│   ├── compute/    # ECS クラスター、サービス、ALB
│   └── data/       # RDS、S3、シークレット
└── envs/
    ├── dev/
    │   ├── backend.tf    # key = "myapp/dev/terraform.tfstate"
    │   ├── main.tf       # モジュール呼び出し(dev の値)
    │   └── ...
    └── prod/
        ├── backend.tf    # key = "myapp/prod/terraform.tfstate"
        ├── main.tf       # モジュール呼び出し(prod の値)
        └── ...

構造がそのまま答えです。リソース定義は modules に 1 セットだけあり、環境ディレクトリはモジュールに値を渡す薄い殻です。state は環境ごとに別の key なので完全に分離され(#1 で key を myapp/dev/ にしておいたことがここで回収されます)、prod ディレクトリでの apply だけが prod に触れるため、ワークスペースの事故モードは消えます。モジュールを分ける基準は、基礎 #8 の「ライフサイクルを共にする単位」に従っています。

環境差はモジュールの variable で #

#5 でハードコードしていた値が、モジュールの入力に昇格します。

環境ごとのモジュール呼び出し
# modules/data/variables.tf
variable "deletion_protection" {
  type        = bool
  description = "true なら prevent_destroy の対象 + 最終スナップショットを作成"
}

# envs/dev/main.tf
module "data" {
  source              = "../../modules/network"  # など省略
  deletion_protection = false
  db_instance_class   = "db.t4g.micro"
  nat_gateway_per_az  = false
}

# envs/prod/main.tf
module "data" {
  source              = "../../modules/data"
  deletion_protection = true
  db_instance_class   = "db.t4g.small"
  nat_gateway_per_az  = true
}

ここで Terraform の制約に 1 つ遭遇します。lifecycle の prevent_destroy には変数が使えないため(plan の前に確定している必要があるメタ引数です)、deletion_protection 変数は RDS の deletion_protection 引数(AWS 側の削除保護)と skip_final_snapshot につなぎ、prevent_destroy はモジュール内で true に固定する折衷が一般的です。完璧にできないポイントで折り合いを付けるところまでが実務です。

moved ブロック — 再作成なしのアドレス移動 #

リファクタリングの技術的な核心です。リソースがモジュールの中に入ると、アドレスが aws_vpc.main から module.network.aws_vpc.main に変わります。基礎 #5 で見たとおり、Terraform はこれを「旧アドレスの削除 + 新アドレスの作成」と読みます。何の対処もなく apply すると、稼働中の VPC と RDS をすべて消して作り直す plan が出てきます。答えが moved ブロックです。

envs/dev/moved.tf
# envs/dev/moved.tf
moved {
  from = aws_vpc.main
  to   = module.network.aws_vpc.main
}

moved {
  from = aws_db_instance.main
  to   = module.data.aws_db_instance.main
}
# ... リソースごとに 1 つずつ ...

moved ブロックがあると、plan は 2 つのアドレスを同じリソースとして認識し、state のアドレスだけを移してインフラには触れません。リファクタリング後の plan の目標は明確です。0 to add, 0 to change, 0 to destroy に moved の項目だけが並ぶことです。この「変更なしリファクタリング」が確認できたら apply し、しばらく維持した後は moved ブロックを消しても構いません。リソースが多くて手作業が負担なら terraform state mv をスクリプトで回す方法もありますが、レビューに残る moved の方がチーム作業には向いています。

prod を立てます #

リファクタリングを終えた dev が「変更なし」で安定したら、prod は最初からモジュール呼び出しで始めます。envs/prod で init と apply を実行すると、同じ構造が prod の値(NAT 2 個、削除保護、大きめのインスタンス)で新しく作られます。dev と prod が同じモジュールを使うため構造のずれが原理的に発生せず、以後のインフラ変更は、モジュールを直して dev に先に適用し、検証してから prod に適用する流れになります。その流れを人の手ではなく CI に回させるのが、運用シリーズのテーマです。

まとめ #

この記事で扱った内容です。

  • ワークスペースは条件式の乱発、弱い分離、権限分離の不可により、dev・prod の分離には不向きです。同じ構成の一時的な複製にだけ使います
  • 標準の構成は modules(定義 1 セット)+ envs(環境ごとの薄い殻)です。環境ごとのバックエンド key で state が完全に分離されます
  • 環境差はモジュールの variable で表現します。prevent_destroy のように変数を受け取れないメタ引数には折衷が必要です
  • モジュールリファクタリングの核心は moved ブロックです。目標は add・change・destroy がすべて 0 の「変更なしリファクタリング」です
  • prod はモジュール呼び出しで新しく立てます。dev で検証して prod に適用する流れの土台が完成しました

次の記事(#10 ドメイン・HTTPS・完成)は実践シリーズの最終回です。Route 53 と ACM 証明書で HTTPS を有効にし、CloudFront で静的アセットを配信して、アーキテクチャ全体を完成させます。

X