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

デプロイの標準化と環境分離: 疎通確認から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.Stateがhealthy
5ALBターゲットの健全性 (api)同上 (api_target_group_arn)同上
6ECSサービスのデプロイ状態aws ecs describe-services --cluster <cluster> --services <web_service_name> <api_service_name>deployments[].rolloutStateがCOMPLETED
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を土台に、storage・edge・databaseが並列にそこへ依存し、最後に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.tfstate・envs/prod/terraform.tfstateという具合です。env ごとにバケット自体を分ける方式も考えられますが、この連載では採用しません。aws_s3_bucketのbucket引数は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.tf・outputs.tfは主要部分の抜粋です。他の 4 モジュール (storage・edge・compute・database) は、この後の設計の要点だけを文章で説明します。

# 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 モジュール (storage・edge・compute・database) も、既存main/の該当ファイルとほぼ同じリソース定義を持ちます。違いは、local.name_prefixやaws_vpc.main.idのような同一ファイル内参照が、var.name_prefix・var.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.tf・outputs.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_protection・force_destroy・force_delete) を元の値に戻し、コミットしておく。次のロールバック手順は、このコードをそのまま再 apply する前提のためです。緩めたままのコードが残っていると、万一ロールバックで再 apply した際、削除保護なし・強制削除有効という本来より弱い設定で環境が再構築されてしまいます。
  3. envs/dev/へ新規applyする。terraform init→terraform applyという通常の手順です。
  4. 11.1節のE2Eチェックリストで動作確認する。

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

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

terraform fmt -check -recursiveとterraform 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) を選びました
  • network・storage・edge・compute・databaseの 5 モジュールに分割しました。ALB⇄Route 53/ACM の循環参照を避けるためedgeモジュールに統合し、3 つのセキュリティグループはモジュール化せず呼び出し側に残しました
  • dev/prod で値を変える項目を、RDS のmulti_az・deletion_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_mutabilityはMUTABLE・IMMUTABLE・IMMUTABLE_WITH_EXCLUSION・MUTABLE_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.tf・route53-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 reference。from・toを指定すると、既存オブジェクトを新しいアドレスへリネームしてからプランを作成するとしています。 ↩

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

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

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