メインコンテンツまでスキップ

デプロイの標準化と環境分離: 疎通確認からmodule + envs構成へ

前章までで、TasukuのインフラはVPCからECSサービス・RDSまでひととおり組み上がりました。ただし、これまでの動作確認は各章の担当範囲に閉じたものでした。この章では、まずインフラ全体を貫くE2Eの疎通確認と、ECSデプロイの標準手順を押さえます。そのうえで、第2章からname_prefixに環境識別子を含めてきた伏線を回収し、単一環境フラット構成だったmain/を、modules/ + envs/dev,prodという複数環境を扱える構成へリファクタします。

11.1. E2Eチェックリスト: 全体疎通の確認

これまでの章は、各章が追加した部分だけを確認してきました。第10章ならALBからECSサービスまで、第9章ならRDSへの接続まで、という具合です。ここで一度、インターネットからRDSまでの経路を通しで確認するチェックリストを作ります。

#確認項目確認方法期待結果
1DNS解決 (apex)dig tasuku.exampleALBのDNS名へのAliasレコードが返る
2DNS解決 (api)dig api.tasuku.example同上
3TLS証明書の検証curl -v https://tasuku.example証明書チェーンの検証が通り、警告なくレスポンスを受け取れる
4ALBターゲットの健全性 (web)aws elbv2 describe-target-health --target-group-arn <web_target_group_arn>登録された全ターゲットのTargetHealth.Statehealthy
5ALBターゲットの健全性 (api)同上 (api_target_group_arn)同上
6ECSサービスのデプロイ状態aws ecs describe-services --cluster <cluster> --services <web_service_name> <api_service_name>deployments[].rolloutStateCOMPLETED
7HTTP応答 (web)curl https://tasuku.example200、webコンテナからのレスポンス
8HTTP応答 (api)curl https://api.tasuku.example200、apiコンテナからのレスポンス。webと異なるコンテナから返っていることも確認する
9RDSへの接続性第9章9.9節と同じpsql-checkのrun-taskSELECT version()が正常応答
10ECSタスクからの送信インターネット疎通タスクがRUNNING状態に到達していること自体ECR pull・CloudWatch Logs書き込みがNAT Gateway経由で成立している間接証拠になる

このチェックリストは、これまでの章の動作確認を経路の観点で束ねただけで、新しい確認手法を導入するものではありません。すべて既存の出力 (Terraform outputs) を使い、第10章までと同じ検証水準 (実際にAWS環境で試す場合の手順として提示し、著者はapply結果を保証しない) を維持します。

11.2. デプロイ標準手順

ここまでの章では、コンテナイメージの更新手順そのものには触れていません。実運用でTasukuを継続的にデプロイする場合の標準手順を決めておきます。

ECRリポジトリのimage_tag_mutabilityは既定でMUTABLEです。1 同じタグ (latestなど) に新しいイメージを繰り返しpushでき、手軽な一方、「いまデプロイされているのがどのビルドか」をタグから追跡できなくなります。この連載では、CIパイプラインでコミットSHAをイメージタグにする方式を標準とします。web:a1b2c3dのように一意なタグでpushし、ECSタスク定義のimageをそのタグに更新してからaws ecs update-serviceで新しいタスク定義を反映します。

サービス定義自体は変えず、同じタグのイメージを再度pullさせたいだけの場合は、force_new_deploymentを使います。2 タスク定義やサービス設定に変更がなくても、新しいデプロイをトリガーできます。ECSタスク定義自体は作成後イミュータブルで、コンテナイメージや環境変数を変更するたびに新しいリビジョンが作られる仕組みのため、2 通常のデプロイはタスク定義の新規リビジョン作成→サービス更新という流れになります。

第10章10.7節で有効にしたデプロイサーキットブレーカーは、この標準デプロイ手順でも変わらず働きます。新しいタスク定義への切り替えが失敗と判断されれば、自動的に直前の正常な状態へロールバックします。

11.3. なぜ今、環境を分離するか

第2章から、リソース名の接頭辞にname_prefix = "${var.project}-${var.env}"という形で環境識別子を含めてきました。第9章までのvar.envは事実上常に"dev"で、複数の環境を実際に使い分ける場面はありませんでした。ここでようやく、この伏線を回収します。

複数の環境 (dev/staging/prodなど) を扱う手段として、TerraformにはCLI workspaceという機能があります。同じ設定・同じbackendのまま、state間を切り替える仕組みです。3 ただし、長期間存続する環境の分離には、CLI workspaceでなくディレクトリ分離を使うべきというのが実務上のコンセンサスです。3 workspaceによる環境分離は、同一設定内で環境間のドリフトを見えにくくし、環境ごとに異なるアクセス制御 (IAMポリシーやbackendの権限) を必要とする場合にも不向きです。この連載でも、envs/dev/envs/prod/という別ディレクトリに分離します。

11.4. モジュール境界の設計

これまでmain/は、章ごとに.tfファイルを追加していくフラットな構成でした。14ファイル・約1,600行に積み上がったこのコードを、再利用可能な単位に切り出します。

素朴に「1リソースファイル=1モジュール」で分割すると、循環参照にぶつかります。実際にこのコードベースで確認できる循環は、ALBとRoute 53/ACMの間の1箇所です。443番リスナーがACM証明書のARNを参照し、Route 53のAliasレコードがALBのDNS名を参照するため、loadbalancerモジュールとdnsモジュールを別々に切り出すと、互いを参照し合う形になってしまいます。4 この連載では、ALB・Route 53・ACMをまとめて1つのedgeモジュールとし、この循環を解消します。

最終的な分割は次の5つです。

モジュール内容
networkVPC・6サブネット・IGW・NAT Gateway・ルートテーブル・default SG締め
storage添付ファイル用バケット・S3 Gatewayエンドポイント
edgeALB・ターゲットグループ・リスナー・ALBログバケット・Route 53ゾーン・ACM証明書
computeECR・ECSクラスタ・実行/タスクロール・タスク定義・サービス・Auto Scaling
databaseDBサブネットグループ・パラメータグループ・RDSインスタンス

第4章以来作ってきたALB用・ECSタスク用・RDS用の3つのセキュリティグループは、あえてどのモジュールにも含めません。ALB⇄ECS⇄RDSの3つが相互に参照し合う構造 (ALBのegressがECSタスク用SGを、ECSタスク用SGのegressがRDS用SGを参照するなど) のため、モジュール内に閉じ込めると同じ循環参照の問題が再発します。3つのSGとそのingress/egressルールは、呼び出し側のenvs/dev,prodに直接書き、生成したSGのIDを各モジュールへ入力変数として渡します。第3章terraform_executors(Terraform実行者自身のアカウントレベルの関心事) と予算アラートも、同じ理由でモジュール化せず呼び出し側に残します。

モジュール間の依存方向は一方向です。networkを土台に、storageedgedatabaseが並列にそこへ依存し、最後にcomputeが3つの出力 (添付バケットのARN・ターゲットグループのARN・RDSの接続情報) をすべて受け取ります。

11.5. envs/dev・prodとtfvars戦略

環境ごとに値を変える対象を、次の4項目に絞ります。

項目devprod教える軸
RDSのmulti_azfalsetrueコスト
RDSのdeletion_protectionfalsetrue安全性
Auto Scalingのmin_capacity/max_capacity1 / 22 / 4スケール
予算アラートのbudget_limit_usd30100コスト

ECSサービスのdesired_count初期値は、このmin_capacityにそのまま連動させます。第10章10.8節では、min_capacityを2にする根拠として「複数のアベイラビリティゾーンにまたがるサブネットを指定したサービスに対し、ECSが新規タスクをAZ間で均等に配置しようとするため、最小1では常時稼働するタスクが1つのAZに偏りかねない」という可用性要件を挙げていました。dev環境でmin_capacity = 1を選ぶことは、この可用性要件を意図的に後退させることを意味します。開発環境ではコストを優先し、1つのAZに偏ってもよいと判断するトレードオフです。prod環境はmin_capacity = 2のままのため、この要件を引き続き満たします。

コンテナのcpu/memory・ALBのssl_policy・CloudWatch Logsの保持期間といった他のハードコード値は、変数として宣言はしますが、dev・prod共通のデフォルト値のまま踏襲します。4項目に絞るのは、コスト・安全性・スケールという3つの意思決定軸を代表させつつ、tfvarsの分量を抑えるためです。

state管理は、第5章5.4節で作った単一のtfstateバケットを共有し、キー (バケット内のパス) でenvを分離します。envs/dev/terraform.tfstateenvs/prod/terraform.tfstateという具合です。envごとにバケット自体を分ける方式も考えられますが、この連載では採用しません。aws_s3_bucketbucket引数はForces new resource(名前を変えると削除して作り直す動作になる)属性です。5 同じbootstrap/ディレクトリで-var="env=prod"のように変数だけ変えて再applyすると、既存のdevバケットを壊してから新しいバケットを作ろうとしてしまいます。共有バケット+キー分離の方が、この種の事故が起きにくく、複数環境でのstate管理として一般的に推奨される構成でもあります。6

domain_nameは要注意です。envs/dev/envs/prod/は個別にaws_route53_zoneを作成するため、同じドメイン名を設定すると同一ドメイン名のホストゾーンが2つ存在する状態になりえます。第7章以来のvar.domain_nameは既定値を持たないため、この事故は「うっかりコピペしたtfvarsをそのまま両方に使う」形で起こりえます。ドメインレジストラでのネームサーバー委任は1箇所にしかできないため、委任されない側のホストゾーンではACM証明書のDNS検証がいつまでも完了しません。この連載のサンプルでは、envs/dev/にサブドメイン (dev.tasuku.example)、envs/prod/にapex (tasuku.example) を割り当てて区別します。

11.6. 無停止移行という選択肢: movedブロック

既存のmain/から新しいmodules/ + envs/構成へ移す方法として、Terraformにはmovedブロックという機構があります。from(移動元のアドレス)・to(移動先のアドレス)を書くと、Terraformは移動元に対応する既存オブジェクトを探し、destroy・createを発生させずに新しいアドレスへ付け替えます。7 モジュール境界を跨ぐ付け替え (ルート直下のリソースをモジュール化する、いわゆるshimパターン) にも対応しています。8

ただし、これは同一のTerraform構成・同一のstate内でのリファクタを前提にした機構です。今回の移行はmain/という1つのroot moduleからenvs/dev/という別のroot module (別のstate) への移動であり、前提が異なります。モジュール間で値を渡すと暗黙の依存関係が生まれるのが、Terraformのモジュール解決の基本原則です。9 ただし、state自体を跨いだ移動をこの仕組みだけで完結させることはできません。実際に手元のstateを新しい構成へ持ち込みたい場合は、terraform state mv等でstateファイル自体を移したうえで、新しい構成の中でmovedブロックによりモジュールを整合させるという2段階の作業になります。

この連載では、次の11.8節で説明する通り、新規applyを主な手順とします。movedブロックは、RDS等に実データを投入済みの読者が無停止で移行したい場合の代替パスとして紹介するにとどめ、具体的なstate操作の実行検証はしません。

11.7. Terraform実装

terraform-verify/refactored/に、modules/envs/dev,prod/を新設します。既存のbootstrap/main/のコード構造には手を入れません(11.8節で述べるmain/撤去に伴う一時的な設定変更・destroyは除きます)。第5章5.5節で「backend "s3"はサンプルとして示すにとどめ、検証プロジェクトのmain/はローカルstateのまま運用する」としていた設計を、ここで初めて実際に配線します。

代表としてnetworkモジュールを示します。variables.tfは全文、main.tfoutputs.tfは主要部分の抜粋です。他の4モジュール (storageedgecomputedatabase) は、この後の設計の要点だけを文章で説明します。

# modules/network/variables.tf
variable "name_prefix" {
description = "全リソース名の接頭辞 (呼び出し側でproject名とenv名を組み合わせた値をそのまま受け取る)"
type = string
}

variable "vpc_cidr" {
description = "VPC全体のIPv4 CIDRブロック"
type = string
}
# modules/network/main.tf (抜粋、VPC・サブネット定義部分)
locals {
subnets = {
"public-az1" = { tier = "public", az_key = "az1", cidr = "10.0.0.0/24", public_ip_launch = true }
"public-az2" = { tier = "public", az_key = "az2", cidr = "10.0.1.0/24", public_ip_launch = true }
"private-app-az1" = { tier = "private-app", az_key = "az1", cidr = "10.0.10.0/24", public_ip_launch = false }
"private-app-az2" = { tier = "private-app", az_key = "az2", cidr = "10.0.11.0/24", public_ip_launch = false }
"private-data-az1" = { tier = "private-data", az_key = "az1", cidr = "10.0.20.0/24", public_ip_launch = false }
"private-data-az2" = { tier = "private-data", az_key = "az2", cidr = "10.0.21.0/24", public_ip_launch = false }
}
# ... (第4章のvpc.tfと同じロジック、name_prefixだけがvar.name_prefixに置き換わる)
}

resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true

tags = {
Name = "${var.name_prefix}-vpc"
}
}

# aws_subnet.this / aws_internet_gateway.main / aws_route_table.* /
# aws_nat_gateway.this / aws_default_security_group.main は第4章のvpc.tfと同じ内容
# modules/network/outputs.tf (抜粋)
output "vpc_id" {
description = "VPCのID"
value = aws_vpc.main.id
}

output "private_app_route_table_ids" {
description = "private-appルートテーブルのIDリスト (S3 Gatewayエンドポイントの関連付けで参照)"
value = [for v in aws_route_table.private_app : v.id]
}

# public_subnet_ids / private_app_subnet_ids / private_data_subnet_ids /
# nat_gateway_public_ips も出力する

他の4モジュール (storageedgecomputedatabase) も、既存main/の該当ファイルとほぼ同じリソース定義を持ちます。違いは、local.name_prefixaws_vpc.main.idのような同一ファイル内参照が、var.name_prefixvar.vpc_idという入力変数に置き換わる点だけです。edgeモジュールには理由があって出力を追加しています。ECSサービスには「ターゲットグループがリスナーにまだ関連付けられていない状態でサービスを作成するとエラーになる」問題があり (第10章10.9節既出)、これを避けるためリスナー・リスナールールのARNも出力します。

呼び出し側のenvs/dev/main.tfは、セキュリティグループを直接定義したうえで、5つのモジュールをこの順で呼び出します。

module "network" {
source = "../../modules/network"

name_prefix = local.name_prefix
vpc_cidr = var.vpc_cidr
}

resource "aws_security_group" "alb" {
vpc_id = module.network.vpc_id
# ... ingress/egressルールは第6・10章のalb.tfと同じ内容
}

# ecs_tasks・rds用のセキュリティグループも同様にここで定義

module "compute" {
source = "../../modules/compute"

name_prefix = local.name_prefix
ecs_tasks_security_group_id = aws_security_group.ecs_tasks.id
web_target_group_arn = module.edge.web_target_group_arn
alb_listener_https_arn = module.edge.alb_listener_https_arn
rds_address = module.database.rds_address
autoscaling_min_capacity = var.autoscaling_min_capacity
autoscaling_max_capacity = var.autoscaling_max_capacity
# ... 他の入力は省略
}

computeモジュール内のaws_ecs_service.webには、第10章と同じ理由でdepends_onを書きますが、対象は同一モジュール内のリソースでなく、入力変数var.alb_listener_https_arnです。

resource "aws_ecs_service" "web" {
# ... task_definition / network_configuration / load_balancer は第10章と同じ

depends_on = [var.alb_listener_https_arn]
}

モジュールをまたいだ変数越しのdepends_onが実際に機能するかは、terraform graphで確認しました。生成されたグラフにはmodule.compute.aws_ecs_service.web -> module.edge.aws_lb_listener.httpsという依存エッジが実際に含まれており、10 意図した順序がグラフ上で保証されていることを確認できます。

envs/prod/envs/dev/と同じmain.tfoutputs.tfを持ちますが、variables.tfの既定値 (11.5節の4項目) とversions.tfのbackendキーだけが異なります。

11.8. 環境の切り替え: 旧main/の撤去 → envs/devの起動 → 動作確認

新しい構成への移行手順を考える際、見過ごせない制約があります。main/name_prefix"${var.project}-${var.env}"で、var.envの既定値は"dev"です。envs/dev/も同じenv=devを使うため、両者のname_prefixは同じtasuku-devになります。S3バケット名・RDSインスタンス識別子・ECRリポジトリ名といったリソースは、すべてこのname_prefixから一意な名前を作っています。つまり、旧main/を残したままenvs/dev/へ新規applyすると、同名リソースの重複作成でapplyが失敗します。

このため、実行順序は次の通りになります。

  1. main/を完全にdestroyする。RDSはdeletion_protection = trueのため、まずdeletion_protection = falseに変更して再applyしてからterraform destroyします。S3の添付バケット・ALBログバケットはforce_destroyを設定していないため、中身が残っていると削除できません。バケットを空にするか、一時的にforce_destroy = trueを設定して再applyします。ECRリポジトリも同様に、イメージが残っていれば削除するかforce_delete = trueを一時設定します。
  2. destroy完了後、一時的に緩めた設定 (deletion_protectionforce_destroyforce_delete) を元の値に戻し、コミットしておく。次のロールバック手順は、このコードをそのまま再applyする前提のためです。緩めたままのコードが残っていると、万一ロールバックで再applyした際、削除保護なし・強制削除有効という本来より弱い設定で環境が再構築されてしまいます。
  3. envs/dev/へ新規applyする。terraform initterraform applyという通常の手順です。
  4. 11.1節のE2Eチェックリストで動作確認する。

グローバル/アカウントユニークな名前を持つリソースが衝突するため、新旧の構成を並行稼働させることはできません。ロールバックが必要な場合は、リソースレベルではなくコードレベルで対応します。main/のコードはrefactored/とは別ディレクトリとしてそのまま残るため (手順2で保護設定を戻したうえで)、envs/dev/側で問題が起きても、main/のコードを使って元の構成を再applyできます。

実際にRDS等へ実データを投入済みの読者は、この手順は使えません。11.6節で触れたmovedブロックによる無停止移行を検討してください。

terraform fmt -check -recursiveterraform validateは、modules/の5つとenvs/dev,prodの両方で実行し、構文とプロバイダスキーマの整合を確認しています。ただし、第10章10.10節と同じく、この確認が保証する範囲は限定的です。モジュール間の入力変数の受け渡しが意味的に正しいか (たとえばSGのIDを間違ったモジュールに渡していないか) は、validateでは検出されません。

本章のまとめ

  • インターネットからRDSまでの経路を通しで確認するE2Eチェックリストを作りました。個々の章の担当範囲でなく、経路全体の疎通を確認する視点です
  • ECSデプロイの標準手順として、コミットSHAをタグにする方式とforce_new_deploymentの使い分けを整理しました
  • 第2章以来のname_prefixへの環境識別子導入という伏線を回収し、CLI workspaceでなくディレクトリ分離 (envs/dev,prod) を選びました
  • networkstorageedgecomputedatabaseの5モジュールに分割しました。ALB⇄Route 53/ACMの循環参照を避けるためedgeモジュールに統合し、3つのセキュリティグループはモジュール化せず呼び出し側に残しました
  • dev/prodで値を変える項目を、RDSのmulti_azdeletion_protection、Auto Scalingのmin/max_capacity、予算アラートの4つに絞りました
  • state管理は単一のtfstateバケット+キー分離とし、domain_nameをdev/prodで必ず異ならせる必要があることを確認しました
  • movedブロックは同一state内のリファクタが前提で、別々のroot module間の移行には使えないことを確認しました
  • main/と新envs/dev/はリソース名が衝突するため並行稼働できず、「旧main/の完全destroy→envs/devの新規apply」という順序で移行する設計にしました

次に読む

Footnotes

  1. 出典: terraform MCP (hashicorp/aws provider v6.53.0、aws_ecr_repository)。image_tag_mutabilityMUTABLEIMMUTABLEIMMUTABLE_WITH_EXCLUSIONMUTABLE_WITH_EXCLUSIONの4値で、既定はMUTABLEだとしています。

  2. 出典: WebSearch (複数の実務記事による要約、ECSタスク定義のイミュータブル性はAWS公式ECSデベロッパーガイドの一般知識と整合)。force_new_deploymentはサービス定義に変更がなくても新しいデプロイをトリガーできるとしています。 2

  3. 出典: WebSearch (developer.hashicorp.com/terraform/language/state/workspaces等、複数の実務記事による要約)。長期間存続する環境の分離にはCLI workspaceでなくディレクトリ分離を使うべきだとしています。 2

  4. 出典: リポジトリ内grep (terraform-verify/main/alb.tfroute53-acm.tf)。ALBのHTTPSリスナーがACM証明書ARNを参照し、Route 53のAliasレコードがALBのDNS名を参照するという、相互参照の実在を確認しました。

  5. 出典: terraform MCP (hashicorp/aws provider v6.53.0、aws_s3_bucket)。bucket引数は「Optional, Forces new resource」だとしています。

  6. 出典: WebSearch (developer.hashicorp.com/terraform/language/backend/s3等、複数の実務記事による要約)。環境ごとにS3のキーを分ける方式が、バケット自体を分ける方式より推奨されるとしています。

  7. 出典: moved block referencefromtoを指定すると、既存オブジェクトを新しいアドレスへリネームしてからプランを作成するとしています。

  8. 出典: Refactor modules。モジュール参照は定義されているモジュールインスタンスからの相対で解決されるとし、ルート直下のリソースをモジュール化するshimパターンの構文例を示しています。

  9. 出典: Module-aware explicit dependencies (hashicorp/terraform issue #17101)。モジュール間で値を受け渡す場合、変数値の構築時点で参照元モジュールの出力完了が必須となり、暗黙的な依存関係が成立するとしています。

  10. 出典: リポジトリ内実機検証 (terraform-verify/refactored/envs/devterraform graphを実行)。module.compute.aws_ecs_service.webからmodule.edge.aws_lb_listener.httpsへの依存エッジが生成されることを確認しました。