【Terraform】ECS×ALBで冗長化&負荷分散されたAPIのインフラ環境を構築する
どうも、たんです。
先日Webアプリケーションの新規受託案件にTerraformを自ら提案、導入しました。
導入の効果が既に出ているのと、私自身とても学びになったのでその中の一部をアウトプットしてお伝えできればと思っています(いくつか回数に分けてお伝えできれば嬉しいです)。
今回は、ECS(Fargate)とALBで冗長化&負荷分散を再現してみたという話になります。
※ 主題とそれるため、terraformについての細かい仕様の部分は省かせていただきます。
今回書いたコードはこちらにあります。
成果物
以下のようなアーキテクチャを想定しています
ポイントは以下
- VPC内に2つのAZを用意し、それぞれパブリックサブネットとプライベートサブネットをセット
- パブリックサブネット上にALBを作成、ターゲットにECSを指定
- 2つのAZに跨るECSサービスを用意し、それぞれ1つずつタスクが起動する冗長構成にする
では、実際にコードを見ていきましょう。
Golangイメージの作成
業務で扱えるアプリケーションを想定し、GoのコードをビルドしECRにプッシュしていきます。
ALB上でヘルスチェックを行うため、そのAPIだけ生やしておきます。
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マネージドコンソールに移動し、以下を実行
- ECRに移動
- プライベートリポジトリの
zenn-ecs-with-terraform-api
を作成 - プッシュコマンドに従ってイメージのプッシュまで実行
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とインターネットに接続するためのインターネットゲートウェイを作成します。
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ブロックを分けたサブネットを作成します。
ローカル変数
locals {
az_1a = "ap-northeast-1a"
az_1c = "ap-northeast-1c"
}
パブリックサブネット: ALB用
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用
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"
}
}
ルートテーブルの作成
パブリックサブネットにインターネット接続ができるルートテーブルを紐付けます。
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にアクセスする必要があります。
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エンドポイント用のセキュリティグループをそれぞれ作成していきます。
ローカル変数
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番ポートを受け取れるようにします。
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ブロックを設定します。
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を構築していきます。
先にローカル変数を定義
locals {
alb_name = "zenn-alb"
alb_tg_name = "zenn-alb-tg"
}
ALBを定義します。
ロードバランサーのタイプ、セキュリティグループ、サブネットの指定を行います。
enable_deletion_protection
で削除保護の有無を指定することもできます。
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の通信先のターゲットグループの指定をします。
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のパスと合うようにこちらのパスも定義します。
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のサービス名、サブネット、セキュリティグループを定義します。
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ではなく、ルーターとして動作するようです。
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"
}
}
インターフェース型とゲートウェイ型の違いはこちらがとてもわかりやすかったです。
次にECSの定義に移っていきます。
先に扱う変数を定義しておきます。
AWSのアカウントIDはセキュリティ上、terraform.tfvars
で定義しておきます。
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ロールにアタッチします。
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つのタスクが起動するように設定します。
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、スペック等を記載していきます。
コンテナ内のポートもこちらから定義できます。
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との接続情報や、前述のタスク定義などを記載する箇所になります。
具体的には以下
- タスク定義
- タスク数
- ロードバランサーのターゲットグループ指定(コンテナとのポートマッピング)
- サブネットとセキュリティグループの指定
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
して確認してみましょう。
以下の手順で実行
-
terraform init
で初期化(実行が初めての場合) -
terraform plan
で構築するリソースを確認(必要であれば) -
terraform apply
でデプロイ実行
リソースのデプロイが出来ました。問題ないかどうか、ALBとECSを確認してみましょう。
マネジメントコンソールに移動し、ロードバランサー > DNS名をコピーしてURLに貼り付けます。
そのDNS名に/health
をつけて叩いてみましょう。
以下のように表示されればOKです
ロードバランサーのリスナーをチェック
80番ポートで受けていることを確認
ターゲットグループのチェック
こちらECSのポート8080に向けていますね。
また、ヘルスチェック(/health
のエンドポイントでチェックしている)を行い、問題ないことが確認できています。
ECSサービスのチェック
問題なく2台が立っていることが確認できました!
作ったリソースを削除したい場合は、terraform destroy
で簡単に削除できます。
コラム: なぜTerraformなのか
今回の案件では以下の要因が重なり、Terraform導入に対してのモチベーションが高くなりました。
- 環境を別のAWSアカウントでも再現したい
- AWSで高い可能性 + 冗長構成を再現したい
- 利用しない環境(開発環境)は削除 or 稼働しない
1AWSアカウント内で1回のみ構築するのであれば、インフラ構築ができる方にポチポチしてもらえばあまり問題はありません。
しかし、それなりに複雑なネットワーク環境を複数構築するとなると、いくらGUI操作といえどそれなりに大変です。
また、設定ミス等により、同じように構築できない可能性だってあります。また属人化を防ぐため、不要な仕様書や手順書が乱立される未来がうっすら見えてきます...
そういった問題をTerraformは解決してくれました。
コマンド1つ、terraform apply
で期待するAWS環境を立ち上げることができます。
Terraformのコードが仕様書の役割を果たすので、わざわざ手順書を書く必要もありませんでした。
インフラ構築を行う機会が多数あり、都度マネジメントコンソールで構築されている方は、ぜひ一度TerraformでHello EC2(?)してみてはいかがでしょうか?
Terraformはいいぞ。
最後に
AWSの体系的な知識を持ち合わせつつも、terraformで実際にインフラを組んでいると、割と繋がらなかったりしたので、再度コンソールに立ち戻って考えてみたり、繋がらない部分にあたりをつけて解決することで、AWSのインフラ設定について自信を持って話すことができるようになってきました。
私自身、Terraformは定義書のようなものだと思っており、大まかなシステム構成は前述したようなアーキテクチャ図で理解していただけますが、より詳細な設定やリソースの命名などを確認したい場合は、Terraformのコードを見るという方針でも良い気がしています(学習者バイアスがかかっているかもしれない)。
今回の記事が、皆様の業務の一助となれば幸いです。
最後に、toraco株式会社ではエンジニアを積極採用中です。
フロントエンドエンジニア、バックエンドエンジニア、クラウドインフラエンジニアなど職種問わず、様々な技術領域にチャレンジできます。また、PM(プロジェクトマネージャー) や EM(エンジニアリングマネージャー)のキャリアパスも用意しています。
興味のある方は Wantedly の募集をぜひ読んでください。
Discussion