10
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AWS Batch と ECS Service は何が違うのか - Terraform で最小構成を作って理解する

10
Last updated at Posted at 2026-08-05

はじめに

自社サービスの運用ですでに ECS を使っています。ただし用途は AWS Batch 経由のバッチ処理で、
Web サービスを常時稼働させる、いわゆる普通の ECS の使い方をしたことがありませんでした。

次の案件でコンテナ基盤の選択肢として ECS を検討するにあたり、
「バッチで使っている ECS」と「Web で使う ECS」の差分をきちんと理解しておきたかったので、
Terraform で最小構成を組んで手を動かしました。この記事はその記録です。

結論から書くと、AWS Batch で ECS に触れている人が Web 用途で新しく覚えることは、実質 4 つだけでした。

  • ECS Service(タスクを指定数だけ維持し続ける仕組み)
  • Target Group(ALB からタスクへの振り分け先)
  • ヘルスチェック(「正常」の定義がバッチとまったく違う)
  • ローリングデプロイ(新旧タスクの入れ替え)

逆に言うと、Task Definition、ECR、IAM ロール、CloudWatch Logs あたりは
AWS Batch でやっていることとほぼ同じです。ここが分かっていれば学習コストは思ったより低いです。

想定読者

  • AWS Batch や単発タスクで ECS を使っているが、常時稼働の Web 用途は未経験の方
  • Terraform の基本文法は分かるが、ECS のリソース構成が頭に入っていない方

Terraform の文法そのものの説明はしません。ECS 固有の話に絞ります。

検証環境

項目 バージョン
Terraform 1.13.x
hashicorp/aws provider 6.x 系
リージョン ap-northeast-1
起動タイプ Fargate
作業環境 MacOSX 26.3.1

1. AWS Batch と ECS Service は何が同じで何が違うのか

まず概念の整理からです。ここを押さえておくと、後のコードが「何のために書いているか」で読めます。

そもそも AWS Batch は内部で ECS を使っています。
Batch のコンソールでジョブを実行したあとに ECS のクラスターを覗くと、タスクが起動しているのが見えます。

対応関係を表にするとこうなります。

観点 AWS Batch ECS Service(Web 用途)
コンテナの設計図 Job Definition Task Definition
実行の単位 Job(実行して終わる) Task(起動し続ける)
実行のトリガー ジョブキューに投入する Service が常に desired_count 個を維持する
「正常」の定義 終了コードが 0 ヘルスチェックに応答し続けている
異常時の挙動 リトライ設定に従って再実行 Service がタスクを自動的に置き換える
外部からの受け口 基本的に無い ALB → Target Group → タスク
デプロイ 新しい Job Definition を投入する ローリングデプロイで新旧を入れ替える
スケール キューの詰まり具合で Compute Environment がスケール desired_count / Application Auto Scaling

バッチでは、コンテナが終了コード 0 で終わることが成功です。終わらないジョブは異常です。
Web では逆で、コンテナが終了したら異常です。終わらずに応答し続けることが正常となります。

なぜ Fargate を選ぶか

2026年現在、ECS のコンピューティング選択肢は EC2 起動タイプ、Fargate、
そして 2025年10月に追加された ECS Managed Instances の 3 つがあります。

今回は Web 用途の ECS そのものを理解するのが目的なので、
ホストの管理を一切考えなくてよい Fargate を選びました。

2. 作るもの

ディレクトリ構成は次の通りです。ステップごとにファイルを増やしていきます。

.
├── main.tf          # provider, terraform block
├── variables.tf
├── outputs.tf       # 各ステップで使う値を随時追加
├── network.tf       # STEP 1
├── ecr.tf           # STEP 2
├── iam.tf           # STEP 3
├── ecs.tf           # STEP 3
├── alb.tf           # STEP 5
└── service.tf       # STEP 6

コストについて。この構成で常時かかるのは主に ALB です。
ALB は起動しているだけで時間課金されるので、検証が終わったら忘れずに destroy してください。
Fargate タスク(0.25 vCPU / 0.5 GB を 2 本)と ALB を合わせて、1 日つけっぱなしで数百円程度の感覚です。

3. ステップバイステップで組み立てる

一気に全部のコードを載せると、どのリソースがどの役割なのか分からなくなります。
ここからは apply しながら段階的に積み上げます。

ポイントは STEP 4 です。
STEP 4 の時点で「AWS Batch でやっていることと同じ状態」に到達します。
そこから STEP 5、STEP 6 で Web 特有の要素を足していく、という流れで読んでください。

STEP 0. 下準備

main.tf と variables.tf を用意します。

# main.tf
terraform {
  required_version = "~> 1.13"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.region
}
# variables.tf
variable "region" {
  type    = string
  default = "ap-northeast-1"
}

variable "project" {
  type    = string
  default = "ecs-handson"
}

variable "allowed_cidr" {
  description = "ALB へのアクセスを許可する CIDR"
  type        = string
  default     = "0.0.0.0/0"
}

allowed_cidr を変数に切り出しているのは、アクセス範囲を絞れるようにするためです。
この記事では手順を単純にするためデフォルトで全開放にしていますが、
検証環境とはいえ公開したままにしたくない場合は、自分のグローバル IP を指定してください。

# terraform.tfvars
allowed_cidr = "xxx.xxx.xxx.xxx/32"

いずれにせよ、検証が終わったら忘れずに destroy してください。

STEP 1. ネットワークを作る

VPC、パブリックサブネット 2 つ、インターネットゲートウェイ、ルートテーブル、
そしてセキュリティグループ 2 つを作ります。

サブネットを 2 つ作るのは、ALB が 2 つ以上のアベイラビリティゾーンを要求するためです。

# network.tf
data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

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

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
}

resource "aws_subnet" "public" {
  count                   = 2
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index)
  availability_zone       = data.aws_availability_zones.available.names[count.index]
  map_public_ip_on_launch = true

  tags = { Name = "${var.project}-public-${count.index}" }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }
}

resource "aws_route_table_association" "public" {
  count          = 2
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

セキュリティグループは 2 つに分けます。ALB 用とタスク用です。

# ALB 用: allowed_cidr からの 80 番を許可
resource "aws_security_group" "alb" {
  name   = "${var.project}-alb-sg"
  vpc_id = aws_vpc.main.id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = [var.allowed_cidr]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# タスク用: ALB のセキュリティグループからの 80 番だけ許可
resource "aws_security_group" "task" {
  name   = "${var.project}-task-sg"
  vpc_id = aws_vpc.main.id

  ingress {
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

タスク用セキュリティグループの ingress に security_groups = [aws_security_group.alb.id]
書いている点が重要です。ここを忘れると後でヘルスチェックが通らず延々と悩みます。

Fargate は network_mode が awsvpc 固定なので、タスク 1 つ 1 つが自分の ENI を持ちます。
つまりタスクにセキュリティグループが直接ぶら下がる構造です。
EC2 上でコンテナを動かしていた感覚だと、ここは意外と盲点でした。

terraform init
terraform plan
terraform apply

コンソール画面でVPCやサブネット作成されたことが確認できます。

CleanShot 2026-07-29 at 09.49.30@2x.png
CleanShot 2026-07-29 at 09.49.40@2x.png

STEP 2. ECR にイメージを push する

ここは AWS Batch でやっていることとまったく同じなので、慣れている方は読み飛ばして大丈夫です。

# ecr.tf
resource "aws_ecr_repository" "app" {
  name         = "${var.project}-app"
  force_delete = true

  image_scanning_configuration {
    scan_on_push = true
  }
}

force_delete = true を入れているのは、後片付けのためです。
イメージが残っているとリポジトリが削除できず、destroy が失敗します。

push 先の URL をコマンドから参照したいので、outputs.tf を作って出力しておきます。
output はリソースに差分がなくても apply しないと state に反映されないので、
定義したら必ず apply してください。

# outputs.tf
output "ecr_repository_url" {
  value = aws_ecr_repository.app.repository_url
}

サンプルアプリは nginx だけの最小構成にします。

# Dockerfile
FROM public.ecr.aws/nginx/nginx:1.27-alpine
RUN echo '<h1>Hello ECS</h1>' > /usr/share/nginx/html/index.html

ベースイメージに Docker Hub ではなく public.ecr.aws を使っているのは、
Docker Hub の pull レート制限を避けるためです。

terraform apply

結果はこうなります。

...
Outputs:

ecr_repository_url = "xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/ecs-handson-app"

出力された URL を使って、ビルドと push を行います。

REPO=$(terraform output -raw ecr_repository_url)
REGION=ap-northeast-1

# ECR にログイン
aws ecr get-login-password --region $REGION \
  | docker login --username AWS --password-stdin ${REPO%/*}

docker build --platform linux/amd64 -t $REPO:latest .

# ECS で利用するコンテナイメージを ECR に push
docker push $REPO:latest

docker login に渡すのはリポジトリ名を除いたレジストリのホスト名です。
Login Succeeded を確認してから push してください。
ここを飛ばすと no basic auth credentials で弾かれます。

--platform linux/amd64 は必須です。
Apple Silicon の Mac でそのままビルドすると arm64 のイメージができあがり、
Fargate(X86_64 指定)で起動した瞬間に exec format error で落ちます。

STEP 3. ECS Cluster と Task Definition を作る

ここも Batch でいう Job Definition とほぼ同じ発想です。

# iam.tf
data "aws_iam_policy_document" "ecs_task_assume" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "task_execution" {
  name               = "${var.project}-task-execution-role"
  assume_role_policy = data.aws_iam_policy_document.ecs_task_assume.json
}

resource "aws_iam_role_policy_attachment" "task_execution" {
  role       = aws_iam_role.task_execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

Task Execution Role は「ECS のエージェントが ECR から pull し、CloudWatch Logs に書き込むための権限」です。
コンテナの中のアプリが使う権限(Task Role)とは別物です。

# ecs.tf
resource "aws_ecs_cluster" "main" {
  name = "${var.project}-cluster"
}

resource "aws_cloudwatch_log_group" "app" {
  name              = "/ecs/${var.project}"
  retention_in_days = 7
}

resource "aws_ecs_task_definition" "app" {
  family                   = "${var.project}-app"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = "256"
  memory                   = "512"
  execution_role_arn       = aws_iam_role.task_execution.arn

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

  container_definitions = jsonencode([
    {
      name      = "app"
      image     = "${aws_ecr_repository.app.repository_url}:latest"
      essential = true

      portMappings = [
        {
          containerPort = 80
          protocol      = "tcp"
        }
      ]

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

logConfiguration は必ず書いてください。
書き忘れるとコンテナの標準出力がどこにも残らず、
タスクが起動しないときの調査が完全に手詰まりになります。

terraform plan
terraform apply

この時点ではまだ何も動いていません。設計図を置いただけです。

STEP 4. 単発タスクを起動してみる(ここまでは Batch と同じ世界)

Service を作る前に、タスクを 1 回だけ手動で起動してみます。
これは AWS Batch がジョブを投げているのと、ほぼ同じことをしている状態です。

まず、コマンドで使う値を outputs.tf に追加します。

# outputs.tf に追記
output "cluster_name" {
  value = aws_ecs_cluster.main.name
}

output "public_subnet_id" {
  value = aws_subnet.public[0].id
}

output "task_sg_id" {
  value = aws_security_group.task.id
}

追加したら apply して state に反映させます。

terraform apply

CLUSTER=$(terraform output -raw cluster_name)
SUBNET=$(terraform output -raw public_subnet_id)
SG=$(terraform output -raw task_sg_id)

aws ecs run-task \
  --cluster $CLUSTER \
  --task-definition ecs-handson-app \
  --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={subnets=[$SUBNET],securityGroups=[$SG],assignPublicIp=ENABLED}"

コンソールで RUNNING になり、CloudWatch Logs(/ecs/ecs-handson)に
nginx の起動ログが出れば成功です。

CleanShot 2026-08-04 at 09.25.11@2x.png

この状態は「タスクが 1 つ起動しているだけ」で、外からアクセスする手段がなく、
落ちても誰も面倒を見てくれません。Batch なら、これでジョブが完走すれば目的達成です。

Web サービスとして成立させるために足りないのは次の 2 つです。

  1. 外からのリクエストを受けて、タスクに振り分ける入口
  2. タスクが落ちたら自動的に立て直し、常に指定数を維持する仕組み

この 1 が ALB と Target Group、2 が ECS Service です。

確認できたら、このタスクは停止しておきます。

aws ecs stop-task --cluster $CLUSTER --task <task-arn>

STEP 5. ALB を作る(入口を用意する)

# alb.tf
resource "aws_lb" "main" {
  name               = "${var.project}-alb"
  load_balancer_type = "application"
  subnets            = aws_subnet.public[*].id
  security_groups    = [aws_security_group.alb.id]
}

resource "aws_lb_target_group" "app" {
  name        = "${var.project}-tg"
  port        = 80
  protocol    = "HTTP"
  vpc_id      = aws_vpc.main.id
  target_type = "ip"

  health_check {
    path                = "/"
    matcher             = "200"
    interval            = 30
    timeout             = 5
    healthy_threshold   = 2
    unhealthy_threshold = 3
  }

  deregistration_delay = 30
}

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.main.arn
  port              = 80
  protocol          = "HTTP"

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

アクセス先の DNS 名も output に追加しておきます。

# outputs.tf に追記
output "alb_dns_name" {
  value = aws_lb.main.dns_name
}

target_type = "ip" が Fargate では必須です。
デフォルトの instance は EC2 インスタンス ID を登録する方式なので、
awsvpc モードのタスク(ENI の IP アドレスで登録される)には使えません。

deregistration_delay はデフォルト 300 秒です。
デプロイのたびに古いタスクが 5 分間残ることになるので、検証中は短くしておくと待ち時間が減ります。

terraform plan
terraform apply
Outputs:

alb_dns_name = "ecs-handson-alb-xxxxxxx.ap-northeast-1.elb.amazonaws.com"
cluster_name = "ecs-handson-cluster"
ecr_repository_url = "xxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/ecs-handson-app"
public_subnet_id = "subnet-0d03axxxxxx"
task_sg_id = "sg-0e536aca0xxxxxxx"

ALB の作成は自分の環境では 3 分強かかりました。
この時点で ALB にアクセスすると 503 が返ります。
Target Group に何も登録されていないので当然です。次でつなぎます。

STEP 6. ECS Service を作る(ここが Web 用途の本体)

# service.tf
resource "aws_ecs_service" "app" {
  name            = "${var.project}-service"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 2
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = aws_subnet.public[*].id
    security_groups  = [aws_security_group.task.id]
    assign_public_ip = true
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.app.arn
    container_name   = "app"
    container_port   = 80
  }

  health_check_grace_period_seconds = 60

  deployment_minimum_healthy_percent = 100
  deployment_maximum_percent         = 200

  deployment_circuit_breaker {
    enable   = true
    rollback = true
  }

  depends_on = [aws_lb_listener.http]

  lifecycle {
    ignore_changes = [desired_count]
  }
}

引数ごとに、Batch との対比で意味を書いておきます。

引数 意味
desired_count 常に維持するタスク数。Batch の「投げた分だけ動く」とは逆に、こちらが数を宣言する
load_balancer どの Target Group の、どのコンテナの、どのポートにリクエストを流すか。container_name は Task Definition の name と一致させる
health_check_grace_period_seconds 起動直後の猶予時間。これが短いと、アプリの初期化中にヘルスチェックが失敗して殺され、再起動ループに入る
deployment_minimum_healthy_percent / maximum_percent デプロイ中に維持する最小の正常タスク割合と、一時的に許容する最大割合。100/200 なら、新タスクを 2 本立ててから古い 2 本を落とす
deployment_circuit_breaker 新しいタスクが安定しない場合に自動で切り戻す。これを有効にしておくと、壊れたイメージを push したときの被害が小さい
depends_on Listener より先に Service を作ろうとすると失敗するため明示
ignore_changes = [desired_count] Auto Scaling や手動でタスク数を変えたあとに terraform plan で差分が出るのを防ぐ

assign_public_ip = true は割り切りです。
パブリックサブネットに直接タスクを置いており、本来は NAT Gateway か VPC Endpoint を用意して
プライベートサブネットに置くべきです。今回は最小構成と ALB 以外のコストを抑えるためにこうしています。

terraform plan
terraform apply

open "http://$(terraform output -raw alb_dns_name)"

Hello ECS が表示されれば完成です。

CleanShot 2026-08-04 at 09.33.16@2x.png

STEP 7. ローリングデプロイを体験する

Web 用途の ECS でもう 1 つ、Batch には無い概念がデプロイです。
イメージを更新して、実際に入れ替わる様子を見ておきます。

# index.html を書き換えて再ビルド
docker build --platform linux/amd64 -t $REPO:latest .
docker push $REPO:latest

aws ecs update-service \
  --cluster $CLUSTER \
  --service ecs-handson-service \
  --force-new-deployment

コンソールの Service イベントを見ていると、次の順番で進みます。

  1. 新しいタスクが 2 本起動する(合計 4 本 = maximum_percent 200%)
  2. 新タスクが Target Group に登録され、ヘルスチェックを通過する
  3. 古いタスクが Target Group から登録解除される
  4. deregistration_delay の秒数だけ待ってから古いタスクが停止する

:latest タグで運用していると Task Definition に差分が出ないため、
terraform apply だけではデプロイされません。
実務ではイメージタグにコミットハッシュを使い、
Task Definition のリビジョンを更新する形が運用想定になります。

更新したHTMLが表示されれば入れ替え完了です。
CleanShot 2026-08-04 at 09.37.02@2x.png

4. ハマるところ

実際に詰まったところと、詰まりかけたところをまとめます。

コンテナが終了すると無限に再起動する

Batch では終了コード 0 で終わることが成功ですが、Service 配下では終了は異常とみなされ、
Service が「desired_count を下回った」と判断して新しいタスクを起動します。
アプリがすぐ終了する作りだと、起動と終了を延々と繰り返します。

コンソールで「タスクが増減し続けている」ときは、まずコンテナが即終了していないかを疑うとよいです。

ヘルスチェックが通らずタスクが殺され続ける

原因はだいたい次のどれかでした。

  • タスク用セキュリティグループが、ALB からの通信を許可していない
  • Target Group の target_typeip になっていない
  • ヘルスチェックのパスがアプリのルーティングに存在しない(/health を指定したのに実装がない、など)
  • health_check_grace_period_seconds が短く、初期化が終わる前に落とされている

exec format error

Apple Silicon でビルドした arm64 イメージを X86_64 指定の Fargate で動かしたときに出ます。
docker build --platform linux/amd64 を付けるか、
Task Definition の runtime_platformARM64 にそろえるかのどちらかです。

5. 後片付け

terraform destroy

ECR にイメージが残っているとリポジトリの削除で失敗しますが、
今回は force_delete = true を入れているのでそのまま消えます。
ALB は消し忘れると地味に課金が続くので、destroy 後にコンソールで残骸がないか確認しておくと安心です。

まとめ

AWS Batch 経由で ECS を使っていた立場から Web 用途の ECS を組んでみて、分かったことは 3 つです。

  • Task Definition、ECR、IAM ロール、CloudWatch Logs はそのまま知識が使える
  • 新しく覚えるのは Service、Target Group、ヘルスチェック、ローリングデプロイの 4 つ
  • 「正常 = 終了コード 0」から「正常 = 応答し続けている」への発想の転換が起点になる

実際に運用となるとまた気にするべき点が出てくると思いますが、足がかりとしての学習ができました。
この構成をベースに、気になる点はまた検証していきます。

参考リンク

お知らせ

技術ブログを週1〜2本更新中、ソーイをフォローして最新記事をチェック!
https://qiita.com/organizations/sewii

10
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?