🦔

【Terraform】ECS×ALBで冗長化&負荷分散されたAPIのインフラ環境を構築する

2024/02/07に公開

どうも、たんです。

先日Webアプリケーションの新規受託案件にTerraformを自ら提案、導入しました。
導入の効果が既に出ているのと、私自身とても学びになったのでその中の一部をアウトプットしてお伝えできればと思っています(いくつか回数に分けてお伝えできれば嬉しいです)。

今回は、ECS(Fargate)とALBで冗長化&負荷分散を再現してみたという話になります。
※ 主題とそれるため、terraformについての細かい仕様の部分は省かせていただきます。

今回書いたコードはこちらにあります。

成果物

以下のようなアーキテクチャを想定しています

ポイントは以下

  • VPC内に2つのAZを用意し、それぞれパブリックサブネットとプライベートサブネットをセット
  • パブリックサブネット上にALBを作成、ターゲットにECSを指定
  • 2つのAZに跨るECSサービスを用意し、それぞれ1つずつタスクが起動する冗長構成にする

では、実際にコードを見ていきましょう。

Golangイメージの作成

業務で扱えるアプリケーションを想定し、GoのコードをビルドしECRにプッシュしていきます。
ALB上でヘルスチェックを行うため、そのAPIだけ生やしておきます。

main.go
package main

import (
	"encoding/json"
	"net/http"
)

func main() {
	http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		res := &struct {
			Message string `json:"message"`
		}{
			Message: "Status OK",
		}
		json.NewEncoder(w).Encode(res)
	})

	http.ListenAndServe(":8080", nil)
}

Goを起動するためのDockerfileを作成します

FROM golang:1.21.4-bullseye

WORKDIR /app

COPY ./ ./
RUN go mod download

RUN GOOS=linux GOARCH=amd64 go build -mod=readonly -v -o server

EXPOSE 8080

CMD  ./server

イメージのビルド、コンテナ起動

docker build .  -t zenn-ecs-with-terraform-api
docker run zenn-ecs-with-terraform-api 

curlでAPI叩いて確認

 curl  localhost:8080/health                                                                   ok 
{"message":"Status OK"}

問題ないことが確認できたら、ECRにプッシュします。

AWSマネージドコンソールに移動し、以下を実行

  1. ECRに移動
  2. プライベートリポジトリのzenn-ecs-with-terraform-apiを作成
  3. プッシュコマンドに従ってイメージのプッシュまで実行


ECRのプッシュができたことが確認できました。

ネットワークの作成

ここからインフラ環境の構築に入っていきます。
ディレクトリ構成は以下
※ 実際はモジュール化したり、環境毎にディレクトリを分けたりなどしますが、わかりやすさに重きを置いて単一のディレクトリにしています。

.
├── README.md
└── api
    ├── aws_alb.tf
    ├── aws_ecs.tf
    ├── aws_iam.tf
    ├── aws_route.tf
    ├── aws_sg.tf
    ├── aws_subnet.tf
    ├── aws_vpc.tf
    ├── main.tf
    ├── terraform.tfvars
    └── variables.tf

VPCの作成

AWSのネットワークを作るため、VPCとインターネットに接続するためのインターネットゲートウェイを作成します。

aws_vpc.tf
resource "aws_vpc" "zenn_vpc" {
  cidr_block           = "10.0.0.0/16"

  tags = {
    Name = "zenn-vpc"
  }
}

resource "aws_internet_gateway" "zenn_igw" {
  vpc_id = aws_vpc.zenn_vpc.id

  tags = {
    Name = "zenn-igw"
  }
}

サブネットの作成

CIDRブロックを分けたサブネットを作成します。

ローカル変数

aws_subnet.tf
locals {
  az_1a = "ap-northeast-1a"
  az_1c = "ap-northeast-1c"
}

パブリックサブネット: ALB用

aws_subnet.tf
resource "aws_subnet" "zenn_public_subnet_1a" {
  vpc_id                  = aws_vpc.zenn_vpc.id
  availability_zone       = local.az_1a
  cidr_block              = "10.0.1.0/24"
  map_public_ip_on_launch = true

  tags = {
    Name = "zenn-public-subnet-1a"
  }
}

resource "aws_subnet" "zenn_public_subnet_1c" {
  vpc_id                  = aws_vpc.zenn_vpc.id
  availability_zone       = local.az_1c
  cidr_block              = "10.0.2.0/24"
  map_public_ip_on_launch = true

  tags = {
    Name = "zenn-public-subnet-1c"
  }
}

プライベートサブネット: ECS用

aws_subnet.tf
resource "aws_subnet" "zenn_private_subnet_1a" {
  vpc_id                  = aws_vpc.zenn_vpc.id
  availability_zone       = local.az_1a
  cidr_block              = "10.0.3.0/24"
  map_public_ip_on_launch = false

  tags = {
    Name = "zenn-private-subnet-1a"
  }
}

resource "aws_subnet" "zenn_private_subnet_1c" {
  vpc_id                  = aws_vpc.zenn_vpc.id
  availability_zone       = local.az_1c
  cidr_block              = "10.0.4.0/24"
  map_public_ip_on_launch = false

  tags = {
    Name = "zenn-private-subnet-1c"
  }
}

ルートテーブルの作成

パブリックサブネットにインターネット接続ができるルートテーブルを紐付けます。

aws_route.tf
resource "aws_route_table" "zenn_public_route_table" {
  vpc_id = aws_vpc.zenn_vpc.id

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

  tags = {
    Name = "zenn-public-route-table"
  }
}

resource "aws_route_table_association" "zenn_public_1a_route_table_association" {
  subnet_id      = aws_subnet.zenn_public_subnet_1a.id
  route_table_id = aws_route_table.zenn_public_route_table.id
}

resource "aws_route_table_association" "zenn_public_1c_route_table_association" {
  subnet_id      = aws_subnet.zenn_public_subnet_1c.id
  route_table_id = aws_route_table.zenn_public_route_table.id
}

プライベートサブネットにも、インターネット接続なしのルートテーブルを紐付けます。
VPC内のリソースと、通信ができるようにするためです。
今回はVPCエンドポイントを通じてS3にアクセスする必要があります。

aws_route.tf
resource "aws_route_table" "zenn_private_route_table" {
  vpc_id = aws_vpc.zenn_vpc.id

  tags = {
    Name = "zenn-private-route-table"
  }
}

resource "aws_route_table_association" "zenn_private_1a_route_table_association" {
  subnet_id      = aws_subnet.zenn_private_subnet_1a.id
  route_table_id = aws_route_table.zenn_private_route_table.id
}

resource "aws_route_table_association" "zenn_private_1c_route_table_association" {
  subnet_id      = aws_subnet.zenn_private_subnet_1c.id
  route_table_id = aws_route_table.zenn_private_route_table.id
}

セキュリティグループの作成

ALB、ECS、VPCエンドポイント用のセキュリティグループをそれぞれ作成していきます。

ローカル変数

aws_sg.tf
locals {
  alb_sg_name = "zenn-alb-sg"
  ecs_sg_name = "zenn-ecs-sg"
  ecr_vpce_sg = "zenn-ecr-vpce-sg"
}

ALB、ECS、VPCエンドポイント用のセキュリティグループを定義します。
ALBはポート80番を、ECSのコンテナは8080番ポートを受け取れるようにします。

aws_sg.tf
resource "aws_security_group" "zenn_alb_sg" {
  name        = "zenn_alb_sg"
  description = "Security group for ALB"
  vpc_id      = aws_vpc.zenn_vpc.id

  ingress {
    protocol    = "tcp"
    from_port   = 80
    to_port     = 80
    cidr_blocks = ["0.0.0.0/0"]
  }

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

resource "aws_security_group" "zenn_ecs_sg" {
  name        = local.ecs_sg_name
  description = "Security group for ECS"
  vpc_id      = aws_vpc.zenn_vpc.id

  ingress {
    protocol    = "tcp"
    from_port   = 8080
    to_port     = 8080
    cidr_blocks = ["0.0.0.0/0"]
  }

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

VPNエンドポイントは、プライベートなネットワーク内で、ECSからECRへアクセスし、イメージをプルするのに必要なエンドポイントになります。
こちらは決まっているので、ポート番号443でTCP接続、VPCのCIDRブロックを設定します。

aws_sg.tf
resource "aws_security_group" "ecr_vpc_endpoint_sg" {
  name        = local.ecr_vpce_sg
  description = "Security Group for ECR VPC Endpoints"
  vpc_id      = aws_vpc.zenn_vpc.id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = [aws_vpc.zenn_vpc.cidr_block]
  }

  egress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = [aws_vpc.zenn_vpc.cidr_block]
  }
}

ALBの作成

次に、ECSの負荷分散を実現するためのALBを構築していきます。

先にローカル変数を定義

aws_alb.tf
locals {
  alb_name    = "zenn-alb"
  alb_tg_name = "zenn-alb-tg"
}

ALBを定義します。
ロードバランサーのタイプ、セキュリティグループ、サブネットの指定を行います。
enable_deletion_protectionで削除保護の有無を指定することもできます。

alb.tf
resource "aws_lb" "zenn_alb" {
  name               = local.alb_name
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.zenn_alb_sg.id]
  subnets            = [aws_subnet.zenn_public_subnet_1a.id, aws_subnet.zenn_public_subnet_1c.id]

  enable_deletion_protection = false
}

リスナーを定義します。
リスナーでは、ALBで受ける通信のプロトコルと、ポート番号を指定します。
今回はAPIを80番ポートでコールします。
そして、この後定義するALBの通信先のターゲットグループの指定をします。

aws_alb.tf
resource "aws_lb_listener" "zenn_alb_listener" {
  load_balancer_arn = aws_lb.zenn_alb.arn
  port              = "80"
  protocol          = "HTTP"

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

ターゲットグループを定義します。
ALBからECSへ通信を行う際に必要な設定を定義します。
また、GoのヘルスチェックAPIのパスと合うようにこちらのパスも定義します。

aws_alb.tf
resource "aws_lb_target_group" "zenn_alb_tg" {
  name        = local.alb_tg_name
  port        = 8080
  protocol    = "HTTP"
  vpc_id      = aws_vpc.zenn_vpc.id
  target_type = "ip"

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

ECS(Fargate)の作成

まず、ECSサービスからプライベートなECRにアクセス、プルできるようにするために下記3つのVPCエンドポイントを作成します。

  • ecr_dkrインターフェースエンドポイント
  • ecr_apiインターフェースエンドポイント
  • S3ゲートウェイエンドポイント

これらは、aws_vpc.tfに記載していきます。

まず、ECSからECRのリポジトリにアクセス、プルするために必要な上記2つのVPCエンドポイントを定義します。
インターフェース型は別名プライベートリンクとも呼ばれていますね(エンドポイントにENIがアタッチされる)。
ECRのサービス名、サブネット、セキュリティグループを定義します。

aws_vpc.tf
resource "aws_vpc_endpoint" "ecr_api" {
  vpc_id            = aws_vpc.zenn_vpc.id
  service_name      = "com.amazonaws.${local.region}.ecr.api"
  vpc_endpoint_type = "Interface"

  subnet_ids = [aws_subnet.zenn_private_subnet_1a.id, aws_subnet.zenn_private_subnet_1c.id]

  private_dns_enabled = true
  security_group_ids  = [aws_security_group.ecr_vpc_endpoint_sg.id]

  tags = {
    Name = "zenn-vpce-ecr-api"
  }
}

resource "aws_vpc_endpoint" "ecr_dkr" {
  vpc_id            = aws_vpc.zenn_vpc.id
  service_name      = "com.amazonaws.${local.region}.ecr.dkr"
  vpc_endpoint_type = "Interface"

  subnet_ids = [aws_subnet.zenn_private_subnet_1a.id, aws_subnet.zenn_private_subnet_1c.id]

  private_dns_enabled = true
  security_group_ids  = [aws_security_group.ecr_vpc_endpoint_sg.id]

  tags = {
    Name = "zenn-vpce-ecr-dkr"
  }
}

次にS3のゲートウェイ型エンドポイントを定義します。
こちらはECRのイメージが内部的にはS3に格納されているため、このエンドポイントがないとECRのプルに失敗してしまいます。
ゲートウェイ型はENIではなく、ルーターとして動作するようです。

aws_vpc.tf
resource "aws_vpc_endpoint" "s3" {
  vpc_id            = aws_vpc.zenn_vpc.id
  service_name      = "com.amazonaws.${local.region}.s3"
  vpc_endpoint_type = "Gateway"

  route_table_ids = [aws_route_table.zenn_private_route_table.id]

  tags = {
    Name = "zenn-vpce-s3"
  }
}

インターフェース型とゲートウェイ型の違いはこちらがとてもわかりやすかったです。
https://qiita.com/cloud-solution/items/bbeaa9a3cc9b958a24a7

次にECSの定義に移っていきます。
先に扱う変数を定義しておきます。
AWSのアカウントIDはセキュリティ上、terraform.tfvarsで定義しておきます。

aws_ecs.tf
locals {
  cluster_name  = "zenn-cluster" # ECS cluster name
  service_name  = "zenn-service" # ECS service name
  region        = "ap-northeast-1" # AWS region
  ecr_name      = "zenn-ecs-with-terraform-api" # ECR repository name
  ecr_image     = "${var.aws_account_id}.dkr.ecr.ap-northeast-1.amazonaws.com/zenn-ecs-with-terraform-api:latest" # ECR image URI
  ecs_task_role = aws_iam_role.ecs_task_execution_role.arn # ECS task role ARN
  ecs_task_cpu = 256 # ECS task CPU
  ecs_task_memory = 512 # ECS task memory
  ecs_service_desired_count = 2 # ECS service desired count
}

上記のecs_task_roleですが、ECSタスクの起動に必要なIAMロールになるので、作成しておきます。
ポリシーはAWS側で用意されているAmazonECSTaskExecutionRolePolicyを使い、作成したIAMロールにアタッチします。

aws_iam
resource "aws_iam_role" "ecs_task_execution_role" {
  name = "ecsTaskExecutionRole"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
      },
    ]
  })
}

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

ECSを起動するためのECSクラスターと、キャパシティプロバイダーを定義します。
キャパシティは計算リソースを管理するもので、常に2つのタスクが起動するように設定します。

aws_ecs.tf
resource "aws_ecs_cluster" "zenn_cluster" {
  name = local.cluster_name
}

resource "aws_ecs_cluster_capacity_providers" "zenn_cluster_capacity_providers" {
  cluster_name       = aws_ecs_cluster.zenn_cluster.name
  capacity_providers = ["FARGATE"]

  default_capacity_provider_strategy {
    base              = 2
    weight            = 100
    capacity_provider = "FARGATE"
  }
}

ECS内のAPIを実行するための、タスク定義を書きます。
ここが実際のコンテナの定義になるので、使用するイメージを記載したり、マシンのOS、スペック等を記載していきます。
コンテナ内のポートもこちらから定義できます。

aws_ecs.tf
resource "aws_ecs_task_definition" "zenn_cluster_task" {
  family                   = "zenn_cluster_task"
  requires_compatibilities = ["FARGATE"]
  cpu                      = local.ecs_task_cpu
  memory                   = local.ecs_task_memory
  network_mode             = "awsvpc"
  execution_role_arn       = local.ecr_task_role
  task_role_arn            = local.ecr_task_role
  container_definitions = jsonencode([
    {
      name      = local.ecr_name
      image     = local.ecr_image
      cpu       = local.ecs_task_cpu
      memory    = local.ecs_task_memory
      essential = true
      portMappings = [
        {
          containerPort = 8080
          hostPort      = 8080
        }
      ]
    }
  ])
  runtime_platform {
    operating_system_family = "LINUX"
    cpu_architecture        = "X86_64"
  }
}

最後に、ECSサービスを定義します。
ここが、ECSを起動するための定義の核になり、ALBとの接続情報や、前述のタスク定義などを記載する箇所になります。

具体的には以下

  • タスク定義
  • タスク数
  • ロードバランサーのターゲットグループ指定(コンテナとのポートマッピング)
  • サブネットとセキュリティグループの指定
aws_ecs.tf
resource "aws_ecs_service" "zenn_service" {
  name            = local.service_name
  cluster         = local.cluster_name
  task_definition = aws_ecs_task_definition.zenn_cluster_task.arn
  desired_count   = local.ecs_service_desired_count
  

  load_balancer {
    target_group_arn = aws_lb_target_group.zenn_alb_tg.arn
    container_name   = local.ecr_name
    container_port   = 8080
  }

  network_configuration {
    subnets         = [aws_subnet.zenn_private_subnet_1a.id, aws_subnet.zenn_private_subnet_1c.id]
    security_groups = [aws_security_group.zenn_ecs_sg.id]
  }
}

デプロイして確認

インフラ構築必要なコードができました!
ではterraform applyして確認してみましょう。

以下の手順で実行

  1. terraform initで初期化(実行が初めての場合)
  2. terraform planで構築するリソースを確認(必要であれば)
  3. terraform applyでデプロイ実行


リソースのデプロイが出来ました。問題ないかどうか、ALBとECSを確認してみましょう。

マネジメントコンソールに移動し、ロードバランサー > DNS名をコピーしてURLに貼り付けます。
そのDNS名に/healthをつけて叩いてみましょう。
以下のように表示されればOKです

ロードバランサーのリスナーをチェック
80番ポートで受けていることを確認

ターゲットグループのチェック
こちらECSのポート8080に向けていますね。
また、ヘルスチェック(/healthのエンドポイントでチェックしている)を行い、問題ないことが確認できています。

ECSサービスのチェック
問題なく2台が立っていることが確認できました!

作ったリソースを削除したい場合は、terraform destroyで簡単に削除できます。

コラム: なぜTerraformなのか

今回の案件では以下の要因が重なり、Terraform導入に対してのモチベーションが高くなりました。

  1. 環境を別のAWSアカウントでも再現したい
  2. AWSで高い可能性 + 冗長構成を再現したい
  3. 利用しない環境(開発環境)は削除 or 稼働しない

1AWSアカウント内で1回のみ構築するのであれば、インフラ構築ができる方にポチポチしてもらえばあまり問題はありません。
しかし、それなりに複雑なネットワーク環境を複数構築するとなると、いくらGUI操作といえどそれなりに大変です。
また、設定ミス等により、同じように構築できない可能性だってあります。また属人化を防ぐため、不要な仕様書や手順書が乱立される未来がうっすら見えてきます...

そういった問題をTerraformは解決してくれました。
コマンド1つ、terraform applyで期待するAWS環境を立ち上げることができます。
Terraformのコードが仕様書の役割を果たすので、わざわざ手順書を書く必要もありませんでした。

インフラ構築を行う機会が多数あり、都度マネジメントコンソールで構築されている方は、ぜひ一度TerraformでHello EC2(?)してみてはいかがでしょうか?

Terraformはいいぞ。

最後に

AWSの体系的な知識を持ち合わせつつも、terraformで実際にインフラを組んでいると、割と繋がらなかったりしたので、再度コンソールに立ち戻って考えてみたり、繋がらない部分にあたりをつけて解決することで、AWSのインフラ設定について自信を持って話すことができるようになってきました。

私自身、Terraformは定義書のようなものだと思っており、大まかなシステム構成は前述したようなアーキテクチャ図で理解していただけますが、より詳細な設定やリソースの命名などを確認したい場合は、Terraformのコードを見るという方針でも良い気がしています(学習者バイアスがかかっているかもしれない)。
今回の記事が、皆様の業務の一助となれば幸いです。

最後に、toraco株式会社ではエンジニアを積極採用中です。
フロントエンドエンジニア、バックエンドエンジニア、クラウドインフラエンジニアなど職種問わず、様々な技術領域にチャレンジできます。また、PM(プロジェクトマネージャー) や EM(エンジニアリングマネージャー)のキャリアパスも用意しています。
興味のある方は Wantedly の募集をぜひ読んでください。
https://www.wantedly.com/companies/company_5649245

toraco株式会社のテックブログ

Discussion