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

ECS後編: web/apiサービスの公開とAuto Scaling

前章で RDS が加わり、Tasuku の Terraform コードはネットワーク・DNS・実行基盤・データベースがひととおり揃いました。ただし第8章の ECS は、hello-world イメージの単発run-taskで起動を確かめただけで止まっています。実際の Web アプリのように、複数のタスクが常時稼働し続ける状態にはまだなっていません。この章では、web・api の 2 つの ECS サービスを作り、ALB 経由で公開するところまで組み上げます。

10.1. タスク定義からサービスへ​

第8章で使ったrun-taskは、タスクを 1 回だけ起動する操作です。タスクが終了すれば、それきりです。対してECSサービスは、指定した数のタスクを維持し続ける仕組みです。1 タスクが異常終了すれば新しいタスクを起動し直し、常に指定数が動いている状態を保ちます。hello-world のような「起動してログを出して終わる」タスクではなく、HTTP リクエストを待ち受け続ける Web アプリや API サーバーには、サービスが必要です。

サービスには、タスクの入れ替え方を制御するdeploymentMaximumPercent・deploymentMinimumHealthyPercentという設定があります。2 既定のデプロイタイプ (rolling update) では、新しいタスク定義への切り替え時、この 2 つの値がデプロイ中に許容する実行タスク数の上限・下限をパーセントで決めます。標準スケジューラの既定値はそれぞれ 200%・100%で、2 たとえば希望タスク数 2 のサービスなら、最大 4 タスクまで一時的に増やしながら、常に 2 タスク以上が実行された状態を保って入れ替えます。この章では既定値のまま使います。

10.2. web/apiの2サービス構成​

第1章1.3 節で、Tasuku のアプリは「web: ポート 3000 で待ち受けるブラウザ向け画面」「api: ポート 8080 で待ち受ける JSON API」の 2 つのコンテナイメージがある前提だと決めています。同じ節で、イメージ名とポート番号は Terraform の変数にすると予告しました。ここでその通りに実装します。

イメージはvar.web_image・var.api_imageとし、第7章のvar.domain_nameと同じ理由で既定値を設定しません。Tasuku のアプリは実在せず、読者が自分のイメージに置き換える前提のためです。ポートはvar.web_port(既定 3000)・var.api_port(既定 8080)とし、こちらは 1.3 節で確定済みの値のため既定値を付けます。

タスク定義は、実行ロール・タスクロール (いずれも第8章で作成済み) を web と api で共有します。共有できるのは、両者とも同じ ECR リポジトリ・同じロググループ・同じ RDS インスタンス・同じ S3 バケットにアクセスするためです。個別に用意するのはポート番号とコンテナ名だけです。

10.3. ターゲットグループとヘルスチェック​

ALB が ECS タスクへリクエストを転送するには、ターゲットグループが要ります。第8章のタスクはawsvpcネットワークモードで動いており、EC2 インスタンスでなく Elastic Network Interface に直接紐づきます。このため、ターゲットグループのtarget_typeは既定のinstanceではなくipを選ぶ必要があります。3 Fargate 起動タイプを使う以上、この選択に迷う余地はありません。

ヘルスチェックのパスには/healthzを使います。第1章1.3 節で、Tasuku のアプリが満たす前提として「GET /healthzに 200 を返す口がある」と決めています。web・api それぞれに専用のターゲットグループを作り、ヘルスチェックのパスは共通で/healthzとします。

10.4. ホストベースルーティングとセキュリティグループの疎通​

第6章以来、443 番リスナーは fixed-response で固定文字列を返すだけでした。この章で、ようやく実際のターゲットグループへ転送するように切り替えます。振り分け先は、第1章1.4 節の全体図どおり、ホスト名で決めます。tasuku.example宛ては web、api.tasuku.example宛ては api です。

デフォルトの転送先は web にし、api だけをリスナールールで上書きします。リスナールールのhost_header条件には、ワイルドカードと大文字小文字を区別しないマッチングが使えます。4 今回は完全一致のapi.${var.domain_name}だけで足ります。証明書は第7章で発行したワイルドカード SAN 付きの 1 枚がそのままカバーするため、apiサブドメイン用に追加発行する必要はありません。Route 53 にはapi.${var.domain_name}の Alias レコードを追加し、同じ ALB を指します。

ルーティングを切り替えただけでは、まだ実際の通信は通りません。第6章で ALB 用のセキュリティグループの egress を意図的に空のままにしていたのは、当時ターゲットグループが存在せず、ALB が転送先に疎通する必要がまだ生じていなかったためです。ここでターゲットグループが 2 つできたので、ALB 用 SG に web・api それぞれのポートへの egress を追加します。ECS タスク用 SG の側にも、第8章で作った web 向け 3000 番の ingress に加え、api 向け 8080 番の ingress を追加します。これで、ALB から ECS タスクへの経路がセキュリティグループのレベルでもつながります。

10.5. Secrets Manager注入​

第9章9.9 節では、動作確認のために Secrets Manager からパスワードを取得し、jqで手動で取り出してコンテナに渡しました。サービスとして常時稼働する web・api には、この手作業を毎回行わせるわけにはいきません。ECS タスク定義のsecretsパラメータを使い、パスワードをコンテナ起動時に自動で注入します。

secretsのvalueFromは、シークレットの完全な ARN の末尾に:<キー名>::を付けると、JSON 形式のシークレットから特定のキーだけを取り出せます。5 第 9 章のシークレットはpasswordキーを含むことがわかっているため、<secret_arn>:password::という値をDATABASE_PASSWORDという環境変数名に紐づけます。ホスト名・ポート番号・データベース名・ユーザー名は秘密情報ではないため、通常のenvironmentで RDS の各属性から直接渡します。

secrets の解決には、実行ロールにsecretsmanager:GetSecretValue権限が要ります。第8章で作った実行ロール用のカスタムポリシーは、この用途を想定しておらず、しかも編集できません。第8章8.5 節の制約通り、AWS 管理ポリシーのアタッチが選べない以上、既存ポリシーへのiam:CreatePolicyVersionも terraform 実行者には許可されていないためです。第 9 章と同じく、別の新規ポリシーとして追加します。

このシークレットを暗号化する KMS キーが、カスタマー管理キーか AWS 管理キーかによって、必要な権限は変わります。第9章のaws_db_instanceはmaster_user_secret_kms_key_idを指定していないため、AWS 管理キー (Secrets Manager 既定のキー) が使われます。6 kms:Decrypt権限は、シークレットがカスタマー管理キーで暗号化されている場合にのみ必要で、7 既定の AWS 管理キーを使う今回の構成には要りません。実行ロールへの新規ポリシーはsecretsmanager:GetSecretValueだけを許可します。

10.6. タスクロールへのS3権限​

第3章3.4 節の予告表で、タスクロールへの権限追加はこの章で行うと決めています。第8章時点のタスクロールは信頼ポリシーだけでアタッチポリシーがなく、コンテナから S3 の添付バケットへアクセスする権限がありませんでした。

必要な S3 アクションは、オブジェクトの取得・追加・削除にあたるs3:GetObject・s3:PutObject・s3:DeleteObjectです。8 これらはオブジェクト ARN (<バケットARN>/*) を対象にします。バケット内の一覧が必要ならs3:ListBucketも加えますが、こちらはバケット ARN 自体を対象にするため、Resource の形が異なる点に注意が必要です。8 共有タスクロールにこの権限を持つ新規ポリシーをアタッチします(10.2 節の通りタスクロールは web・api で共有するため、この権限は両方のタスクに付与されます)。

10.7. デプロイ戦略: rolling + circuit breaker​

新しいタスク定義へ切り替えるとき、切り替え後のタスクが起動に失敗し続けると、サービスは新旧のタスクが入り乱れたまま止まらなくなります。デプロイサーキットブレーカーを有効にすると、デプロイが失敗と判断された時点で自動的に直前の正常な状態へロールバックします。9 enableとrollbackをどちらも有効にし、失敗したデプロイを自動的に巻き戻す設定にします。

もう 1 つ、health_check_grace_period_secondsも設定します。この値は、タスク起動直後の期間、ロードバランサーのヘルスチェック失敗を無視する猶予です。既定値は 0 で、猶予がありません。10 コンテナの起動に数秒かかる場合、ヘルスチェックの初回結果が届く前にタスクが不健全と判断され、起動と停止を繰り返しかねません。60 秒の猶予を設定し、起動直後の不安定な期間をやり過ごします。

10.8. Auto Scaling​

第8章では、タスクの CPU・メモリを 256・512MiB という固定値で決め打ちしました。この章では、実行するタスクの数を負荷に応じて自動的に増減させます。垂直方向のサイズでなく、水平方向の台数を動かす形です。

Application Auto Scaling のtarget trackingは、指定したメトリクスが目標値に近づくよう、タスク数を自動的に調整する仕組みです。ECS サービス向けの定義済みメトリクスにはECSServiceAverageCPUUtilizationがあります。11 目標値を 70%とし、この値を超えるとタスクを増やし、下回るとタスクを減らします。第1章1.2 節の規模想定 (数十テナント、同時アクセス数百ユーザー程度) を踏まえ、最小 2・最大 4 の範囲でスケールさせます。最小値を 2 にするのは、複数のアベイラビリティゾーンにまたがるサブネットを指定したサービスに対し、ECS が新規タスクをアベイラビリティゾーン間で均等に配置しようとするためです。12 最小 1 では、常時稼働するタスクが 1 つのアベイラビリティゾーンに偏りかねません。

Auto Scaling がタスク数を動かすようになると、Terraform が管理するdesired_countの値と、実際に Auto Scaling が設定した値がずれます。このままでは、次のterraform planのたびに、Auto Scaling による変更が差分として検出されてしまいます。aws_ecs_serviceのlifecycleブロックでdesired_countをignore_changesに指定すると、この値への外部からの変更 (Auto Scaling を含む) を Terraform が無視するようになります。13

10.9. Terraform実装​

ここまでの内容をecs-service.tfという新しいファイルに書きます。この連載で確立してきた「章の主なリソースを 1 つのファイルにまとめる」方針を踏襲します。ターゲットグループ・リスナー・Route 53 レコード・セキュリティグループのルールといった既存リソースへの変更は、それぞれalb.tf・ecs.tf・route53-acm.tfに置きます。実行ロール・タスクロールへの新規ポリシーとタスク定義・サービス・Auto Scaling という新規リソースだけを、このecs-service.tfに書きます。まず新規ポリシー2 つです。10.5 節・10.6 節で決めた権限を、それぞれ別のポリシーとして追加します。

resource "aws_iam_policy" "ecs_secrets_read" {
name = "${local.name_prefix}-ecs-secrets-read-policy"
path = "/${var.project}/"
description = "RDSマスターパスワードのSecrets Managerシークレットを読み取る権限。デフォルトのAWS管理KMSキーを使うためkms:Decryptは含めない"

policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ReadRdsMasterSecret"
Effect = "Allow"
Action = ["secretsmanager:GetSecretValue"]
Resource = aws_db_instance.main.master_user_secret[0].secret_arn
},
]
})
}

resource "aws_iam_role_policy_attachment" "ecs_secrets_read" {
role = aws_iam_role.ecs_task_execution.name
policy_arn = aws_iam_policy.ecs_secrets_read.arn
}

resource "aws_iam_policy" "ecs_task_s3" {
name = "${local.name_prefix}-ecs-task-s3-policy"
path = "/${var.project}/"
description = "添付ファイル用S3バケットへの読み書き削除・一覧権限"

policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AttachmentsObjectAccess"
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
]
Resource = "${aws_s3_bucket.attachments.arn}/*"
},
{
Sid = "AttachmentsListBucket"
Effect = "Allow"
Action = ["s3:ListBucket"]
Resource = aws_s3_bucket.attachments.arn
},
]
})
}

resource "aws_iam_role_policy_attachment" "ecs_task_s3" {
role = aws_iam_role.ecs_task.name
policy_arn = aws_iam_policy.ecs_task_s3.arn
}

10.4 節のターゲットグループとホストベースルーティングです。443 番リスナーのデフォルトを forward へ切り替え、api だけをリスナールールで振り分けます。

resource "aws_lb_target_group" "web" {
name = "${local.name_prefix}-web-tg"
port = var.web_port
protocol = "HTTP"
vpc_id = aws_vpc.main.id
target_type = "ip"

health_check {
path = "/healthz"
}

tags = {
Name = "${local.name_prefix}-web-tg"
}
}

resource "aws_lb_target_group" "api" {
name = "${local.name_prefix}-api-tg"
port = var.api_port
protocol = "HTTP"
vpc_id = aws_vpc.main.id
target_type = "ip"

health_check {
path = "/healthz"
}

tags = {
Name = "${local.name_prefix}-api-tg"
}
}

aws_lb_listener.https(第 7 章で作成済み) の default_action を、fixed-response から web への forward に書き換えます。

resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.main.arn
port = "443"
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09"
certificate_arn = aws_acm_certificate_validation.main.certificate_arn

default_action {
type = "forward"
target_group_arn = aws_lb_target_group.web.arn
}
}

resource "aws_lb_listener_rule" "api" {
listener_arn = aws_lb_listener.https.arn
priority = 100

action {
type = "forward"
target_group_arn = aws_lb_target_group.api.arn
}

condition {
host_header {
values = ["api.${var.domain_name}"]
}
}
}

ALB 用 SG の egress と、ECS タスク用 SG の api 向け ingress を追加します (web 向け ingress は第 8 章で作成済み)。

resource "aws_vpc_security_group_egress_rule" "alb_to_web" {
security_group_id = aws_security_group.alb.id

referenced_security_group_id = aws_security_group.ecs_tasks.id
from_port = var.web_port
to_port = var.web_port
ip_protocol = "tcp"

description = "ECSタスク用SG (webサービス) への転送"
}

resource "aws_vpc_security_group_egress_rule" "alb_to_api" {
security_group_id = aws_security_group.alb.id

referenced_security_group_id = aws_security_group.ecs_tasks.id
from_port = var.api_port
to_port = var.api_port
ip_protocol = "tcp"

description = "ECSタスク用SG (apiサービス) への転送"
}

resource "aws_vpc_security_group_ingress_rule" "ecs_tasks_from_alb_api" {
security_group_id = aws_security_group.ecs_tasks.id

referenced_security_group_id = aws_security_group.alb.id
from_port = var.api_port
to_port = var.api_port
ip_protocol = "tcp"

description = "ALBからのみ許可 (第10章のapi service化・host-basedルーティングで使う)"
}

Route 53 にapi.${var.domain_name}の Alias レコードを追加します。

resource "aws_route53_record" "api" {
zone_id = aws_route53_zone.main.zone_id
name = "api.${var.domain_name}"
type = "A"

alias {
name = aws_lb.main.dns_name
zone_id = aws_lb.main.zone_id
evaluate_target_health = true
}
}

タスク定義です。web の DB 接続用環境変数・secrets は、api と共有できるよう locals にまとめます。ユーザー名は文字列を組み立てず、aws_db_instance.main.usernameを直接参照します。RDS 側の値が将来変わっても、無言で追従できるようにするためです。

locals {
db_environment = [
{ name = "DATABASE_HOST", value = aws_db_instance.main.address },
{ name = "DATABASE_PORT", value = tostring(aws_db_instance.main.port) },
{ name = "DATABASE_NAME", value = aws_db_instance.main.db_name },
{ name = "DATABASE_USER", value = aws_db_instance.main.username },
]

db_secrets = [
{ name = "DATABASE_PASSWORD", valueFrom = "${aws_db_instance.main.master_user_secret[0].secret_arn}:password::" },
]
}

resource "aws_ecs_task_definition" "web" {
family = "${local.name_prefix}-web"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = 256
memory = 512
execution_role_arn = aws_iam_role.ecs_task_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn

runtime_platform {
operating_system_family = "LINUX"
cpu_architecture = "X86_64"
}

container_definitions = jsonencode([
{
name = "web"
image = var.web_image
essential = true

portMappings = [
{
containerPort = var.web_port
protocol = "tcp"
}
]

# WHOAMI_PORT_NUMBERは動作確認用プレースホルダ(traefik/whoami)がcontainerPortで
# 待ち受けるための設定。Tasukuの実アプリイメージはこの環境変数を参照しないため無視される
environment = concat(local.db_environment, [
{ name = "WHOAMI_PORT_NUMBER", value = tostring(var.web_port) },
])
secrets = local.db_secrets

logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.app.name
"awslogs-region" = var.region
"awslogs-stream-prefix" = "web"
}
}
}
])

tags = {
Name = "${local.name_prefix}-web"
}
}

api のタスク定義は、ポート番号と S3 バケット名の環境変数以外、web と同じです。差分だけを示します。

resource "aws_ecs_task_definition" "api" {
family = "${local.name_prefix}-api"
# requires_compatibilities / network_mode / cpu / memory / execution_role_arn /
# task_role_arn / runtime_platform はwebと同じ

container_definitions = jsonencode([
{
name = "api"
image = var.api_image
essential = true

portMappings = [
{
containerPort = var.api_port
protocol = "tcp"
}
]

# WHOAMI_PORT_NUMBERは動作確認用プレースホルダ(traefik/whoami)がcontainerPortで
# 待ち受けるための設定。Tasukuの実アプリイメージはこの環境変数を参照しないため無視される
environment = concat(local.db_environment, [
{ name = "ATTACHMENTS_BUCKET", value = aws_s3_bucket.attachments.id },
{ name = "WHOAMI_PORT_NUMBER", value = tostring(var.api_port) },
])
secrets = local.db_secrets

logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.app.name
"awslogs-region" = var.region
"awslogs-stream-prefix" = "api"
}
}
}
])

tags = {
Name = "${local.name_prefix}-api"
}
}

サービス本体です。ターゲットグループがまだリスナー・リスナールールに関連付けられていない状態でサービスを作成すると、AWS API 側のタイミングの都合でエラーになることがあります。14 depends_onでリスナー・リスナールールへの依存を明示し、この順序を保証します。

resource "aws_ecs_service" "web" {
name = "${local.name_prefix}-web"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.web.arn
desired_count = 2
launch_type = "FARGATE"

network_configuration {
subnets = [for k, v in aws_subnet.this : v.id if local.subnets[k].tier == "private-app"]
security_groups = [aws_security_group.ecs_tasks.id]
assign_public_ip = false
}

load_balancer {
target_group_arn = aws_lb_target_group.web.arn
container_name = "web"
container_port = var.web_port
}

health_check_grace_period_seconds = 60

deployment_circuit_breaker {
enable = true
rollback = true
}

lifecycle {
ignore_changes = [desired_count]
}

tags = {
Name = "${local.name_prefix}-web"
}

depends_on = [aws_lb_listener.https]
}

resource "aws_ecs_service" "api" {
name = "${local.name_prefix}-api"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.api.arn
desired_count = 2
launch_type = "FARGATE"

network_configuration {
subnets = [for k, v in aws_subnet.this : v.id if local.subnets[k].tier == "private-app"]
security_groups = [aws_security_group.ecs_tasks.id]
assign_public_ip = false
}

load_balancer {
target_group_arn = aws_lb_target_group.api.arn
container_name = "api"
container_port = var.api_port
}

health_check_grace_period_seconds = 60

deployment_circuit_breaker {
enable = true
rollback = true
}

lifecycle {
ignore_changes = [desired_count]
}

tags = {
Name = "${local.name_prefix}-api"
}

depends_on = [aws_lb_listener_rule.api]
}

最後に Auto Scaling です。web・api で同じ構成を 2 つ分繰り返します。

resource "aws_appautoscaling_target" "web" {
service_namespace = "ecs"
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.web.name}"
scalable_dimension = "ecs:service:DesiredCount"
min_capacity = 2
max_capacity = 4
}

resource "aws_appautoscaling_policy" "web" {
name = "${local.name_prefix}-web-cpu-tracking"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.web.resource_id
scalable_dimension = aws_appautoscaling_target.web.scalable_dimension
service_namespace = aws_appautoscaling_target.web.service_namespace

target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageCPUUtilization"
}

target_value = 70
}
}

# aws_appautoscaling_target.api / aws_appautoscaling_policy.api も同じ構成

outputs.tfには、動作確認で使うターゲットグループの ARN とサービス名を追加します。

output "web_target_group_arn" {
description = "webサービス用ターゲットグループのARN (動作確認でのヘルスチェック状態確認等に使用)"
value = aws_lb_target_group.web.arn
}

output "api_target_group_arn" {
description = "apiサービス用ターゲットグループのARN (動作確認でのヘルスチェック状態確認等に使用)"
value = aws_lb_target_group.api.arn
}

output "web_service_name" {
description = "webサービス名 (aws ecs describe-services等で参照)"
value = aws_ecs_service.web.name
}

output "api_service_name" {
description = "apiサービス名 (aws ecs describe-services等で参照)"
value = aws_ecs_service.api.name
}

10.10. 動作確認​

terraform fmt -check -recursive -diffとterraform validateを実行し、構文とプロバイダスキーマの整合を確認します。ただし、この章のvalidateが保証する範囲は、これまでの章より狭いことに注意が必要です。container_definitionsは JSON 文字列として渡すため、secretsのキー名やportMappingsの値に誤りがあっても、validateはそれを検出しません。タスク定義自体の登録 (RegisterTaskDefinition) やタスクの起動を試みた段階で、初めて誤りが表面化します。この章の Terraform コードの正しさは、validateの通過でなく、10.5 節・10.9 節で示した一次情報との突き合わせによって担保しています。

実際に apply して確認する場合の手順です。まず、/healthzに 200 を返すことを保証する広く使われた公開イメージが見当たらないため、traefik/whoamiというデモイメージを web・api の代わりに使います。第8章の hello-world と同じく ECR へ再ホストし、var.web_image・var.api_imageにそのタグを指定します。このイメージは/healthのような固定パスにはハンドラを持ちますが、/healthzという個別のパスは用意していません。15 ただし、登録されていないパスへのリクエストは、ルートパス/に登録されたハンドラへフォールバックする形で Go の標準ルーターが処理するため、16 結果として/healthzにも 200 が返ります。/healthz固有の応答を保証しているわけではなく、未登録パスのフォールバック挙動を借りているだけである点に注意してください。

待受ポートも、10.9 節のタスク定義で環境変数WHOAMI_PORT_NUMBERを渡すことで、containerPortに指定した 3000・8080 番に合わせています。このイメージの既定の待受ポートは 80 番です。17

docker pull traefik/whoami
docker tag traefik/whoami <account-id>.dkr.ecr.ap-northeast-1.amazonaws.com/tasuku-dev-app:web
docker tag traefik/whoami <account-id>.dkr.ecr.ap-northeast-1.amazonaws.com/tasuku-dev-app:api
docker push <account-id>.dkr.ecr.ap-northeast-1.amazonaws.com/tasuku-dev-app:web
docker push <account-id>.dkr.ecr.ap-northeast-1.amazonaws.com/tasuku-dev-app:api

apply が終わったら、aws ecs describe-servicesでweb_service_name・api_service_nameのdeployments[].rolloutStateがCOMPLETEDになっていることを確認します。次に、aws elbv2 describe-target-healthをweb_target_group_arn・api_target_group_arnそれぞれに対して実行し、登録されたターゲットのTargetHealth.Stateがすべてhealthyであることを確認します。ここまでで、サービスとしてタスクが起動し、ALB からも正常と判断されている状態です。

最後に、実際に HTTP 経由で確認します。tasuku.example・api.tasuku.exampleそれぞれへ HTTPS でアクセスし、200 が返ること、レスポンスの内容が想定通り web と api で別のコンテナから返っていることを確認します。ホストベースルーティングが実際に機能しているかは、この 2 つのレスポンスを見比べて初めてわかります。

この手順が確認するのは、あくまでタスクが起動し、ALB 経由で HTTP が通ることまでです。traefik/whoamiはDATABASE_PASSWORDのようなアプリ固有の環境変数・secrets を参照しないため、10.5 節の Secrets Manager 注入についても「IAM 権限と ARN の参照が有効で、タスクの起動自体は妨げない」ことまでしか確認できません。第9章9.9 節の psql-check のように、注入された値でデータベースへ実際に接続できることまでは確認しません。

この章の ECS サービスも、これまでと同じ検証水準です。実際に AWS へ apply してこの手順を実行した結果は保証しません。

本章のまとめ​

  • ECS サービスは指定した数のタスクを維持し続ける仕組みで、run-taskによる単発起動とは役割が異なります
  • 第1章1.3 節の予告通り、web (ポート 3000)・api (ポート 8080) の 2 サービスを実装しました。イメージ・ポートともに Terraform の変数にし、イメージはvar.domain_nameと同じ理由で既定値を設定していません
  • ターゲットグループのtarget_typeはipが必須です。awsvpcネットワークモードの Fargate タスクは EC2 インスタンスでなく ENI に紐づくためです
  • 443 番リスナーを fixed-response から forward へ切り替え、ホストヘッダでapi.サブドメインだけを api 用ターゲットグループへ振り分けました。証明書は第 7 章のワイルドカード SAN がそのままカバーします
  • 第6章で意図的に空にしていた ALB 用 SG の egress を追加し、ALB→ECS タスクの疎通経路を完成させました
  • Secrets Manager 統合済みの RDS パスワードを、ECS タスク定義のsecretsで自動注入する形に切り替え、第 9 章の手動jq抽出をサービス向けに置き換えました。KMS キーはデフォルトの AWS 管理キーのため、実行ロールにkms:Decryptは不要です
  • 第3章の予告通り、タスクロールに S3 添付バケットへのアクセス権限を新規ポリシーとして追加しました
  • デプロイサーキットブレーカーで失敗デプロイの自動ロールバックを有効にし、health_check_grace_period_secondsで起動直後のヘルスチェック猶予を設けました
  • Auto Scaling で、CPU 使用率に応じてタスク数を 2〜4 の範囲で自動調整する設定にしました。第 8 章の固定 cpu/memory が垂直方向のサイズなら、こちらは水平方向の台数です
  • Terraform が管理するdesired_countと Auto Scaling の変更が競合しないよう、lifecycle.ignore_changesで除外しました

次に読む​

Footnotes​

  1. 出典: Amazon ECS services。サービスは指定した数のタスクを維持し続ける仕組みであるとしています。 ↩

  2. 出典: Deploy Amazon ECS services by replacing tasks、およびDeploymentConfigurationProperty。REPLICAサービススケジューラの既定maximumPercentは 200%、minimumHealthyPercentの既定値は 100%だとしています。 ↩ ↩2

  3. 出典: AWS::ECS::TaskSet LoadBalancer。awsvpcネットワークモードを使うタスク定義では、ターゲットグループのtarget_typeにinstanceでなくipを選ぶ必要があり、この仕様は Fargate 起動タイプで必須だとしています。 ↩

  4. 出典: terraform MCP (hashicorp/aws provider v6.53.0、aws_lb_listener_rule)。host_header条件はワイルドカードに対応し、大文字・小文字を区別しないマッチングをサポートするとしています。 ↩

  5. 出典: Specifying sensitive data using Secrets Manager (AWS Batch User Guide、ECS と同じsecretsコンテナ定義パラメータの仕組みを使うため適用可能)。シークレット ARN の末尾に JSON キー名を付けることで、特定のキーだけを参照できる構文を例示しています。 ↩

  6. 出典: Password management with Amazon RDS and AWS Secrets Manager。カスタマー管理キーを指定しない限り、Secrets Manager 提供のデフォルト KMS キーが使われるとしています。 ↩

  7. 出典: Amazon ECS task execution IAM role。kms:Decryptは、シークレットがカスタマー管理キーを使っている場合にのみ必要だとしています。 ↩

  8. 出典: How Amazon S3 works with IAM。GetObject・PutObject・DeleteObjectはオブジェクト ARN を対象とするオブジェクト操作、ListBucketはバケットレベルの操作であるとしています。 ↩ ↩2

  9. 出典: terraform MCP (hashicorp/aws provider v6.53.0、aws_ecs_service)。rollbackを有効にすると、デプロイが失敗した場合に直前の正常完了デプロイへロールバックするとしています。 ↩

  10. 出典: Health check grace period。既定値は 0 秒で、猶予がないとしています。 ↩

  11. 出典: PredefinedMetricSpecification。ECSServiceAverageCPUUtilizationが有効な定義済みメトリクスタイプの 1 つであるとしています。 ↩

  12. 出典: Balancing an Amazon ECS service across Availability Zones。Fargate で動くサービスは、利用可能なアベイラビリティゾーン間でタスクを自動的に再分散するとしています。 ↩

  13. 出典: terraform MCP (hashicorp/aws provider v6.53.0、aws_ecs_service「Ignoring Changes to Desired Count」example)。lifecycle.ignore_changesにdesired_countを指定すると、Application Auto Scaling 等の外部要因による変更を無視できるとしています。 ↩

  14. 出典: terraform-provider-aws 公式 GitHub リポジトリの issue (#3495、hashicorp/terraform #32908ほか、AWS 公式ドキュメントでの明示的な言及はなし)。ターゲットグループがリスナーに未関連の状態で ECS サービスを作成しようとすると、タイミング依存のエラーになりうるとする複数の報告があります。 ↩

  15. 出典: traefik/whoami (GitHub 公式リポジトリ、README.md の「Paths」節)。/・/api・/bench・/data・/echo・/health・/openapi.yamlにハンドラを持つとしています。/healthzは含まれません。 ↩

  16. 出典: traefik/whoami app.go。http.NewServeMux()で各パスのハンドラを登録したうえで、mux.Handle("/", handle(whoamiHandler, verbose))としてルートパスに whoamiHandler を登録しています。Go 標準のhttp.ServeMuxは、/に登録したハンドラを他のどのパターンにもマッチしないパスの受け皿として扱う仕様のため、/healthzのような未登録パスもこのハンドラで処理されます。whoamiHandler 自体はステータスコードを明示的に設定しておらず、最初の書き込み時に Go が自動的に 200 を設定します。 ↩

  17. 出典: traefik/whoami README.md の「Flags」節、およびapp.go。待受ポートは--portフラグまたはWHOAMI_PORT_NUMBER環境変数で指定でき、既定値は 80 番だとしています。 ↩