動機
【AWS】 Fargate CLI + Terraform で Docker コンテナを動かす簡単なチュートリアル というのを書いたんですが、下記の問題点を感じました。
- Fargate CLI のインストールが若干手間。
- Fargate CLI を使うと、 Terraform だけで完結しないため、一部ハードコーディングが必要になる。
- Fargate CLI は冪等性が無い。
- SSL 証明書や Route53 周りは Terraform で設定したいが、 Fargate CLI で設定した値の取得には結局 aws-cli を叩く必要がある。
- Fargate CLI でも設定可能だが、事前に Route53 で設定とかしないとうまく動いてくれなかった記憶があり、結局あんま信頼できなかった
よって、最近は Fargate CLI は使わずに、
- インフラ構築は Terraform に全て任せる
- デプロイは AWS CLI でやる
- 環境変数は
.env
経由で読み込む
としています。
Fargate の更新など、 Mutable な変更は Terraform は向いていないので、そこは AWS CLI から更新しに行く方が安心ですね。
また Terraform は長くなるので aws-cdk とかに移行しようとしたのですが、 CloudFormation は ECS に適用するのは難しいことが分かったので、 Terraform の方が確実だとなりました。
※ 環境変数に関して、 API キーなど秘匿が必要な情報に関しては、適切な権限を付与した S3 バケットに 設定ファイルを保存し、アプリケーション起動時にそれを読み込むのが、セキュアな方法です。 KMS などを使う方法でも良いです。
Terraform
→ Subnet も自動的にデフォルトを指定する形に変更しました"subnet-xxxxx"
だけ手抜きでハードコーディングしてるんで、そこだけ変えてもらえれば動くと思います。
ポート番号 var.port
などは変数で適当に変更する形です。
また HTTPS の設定はしていないですが、 CloudFront を通すなり、 ALB に証明書設定するなり、 Cloudflare でプロキシするなり、組み合わせは色々です。一見 Cloudflare が一番手間が少なくて良いかなと思いましたが、 WebSocket などを使う場合に問題があり、 ALB の方が良かったです。
[追記]
2021年現在でのベストプラクティスは CloudFront を使うことのようです。セキュリティとパフォーマンスの両面で改善が期待できるためのようです。この記事は使っていませんが、 CloudFront を別途用意して Origin を ALB にするのが良いです。
[/追記]
resource "aws_default_vpc" "default" {}
data "aws_subnet_ids" "default" {
vpc_id = aws_default_vpc.default.id
}
variable "port" {
type = number
}
######################
# ALB
######################
resource "aws_alb_target_group" "main" {
port = 3000
protocol = "HTTP"
vpc_id = aws_default_vpc.default.id
target_type = "ip"
health_check {
path = "/"
}
lifecycle {
create_before_destroy = true
}
}
resource "aws_alb_listener" "main" {
port = "80"
protocol = "HTTP"
load_balancer_arn = aws_alb.main.arn
default_action {
type = "forward"
target_group_arn = aws_alb_target_group.main.arn
}
}
resource "aws_alb" "main" {
security_groups = [aws_security_group.alb.id]
load_balancer_type = "application"
subnets = data.aws_subnet_ids.default.ids
}
######################
# Security Group
######################
resource "aws_security_group" "fargate_service" {
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = var.port
to_port = var.port
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
}
resource "aws_security_group" "alb" {
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
######################
# ECS
######################
resource "aws_ecr_repository" "main" {
name = "myapp"
}
resource "aws_ecs_cluster" "main" {
name = "myapp"
}
resource "aws_ecs_task_definition" "main" {
family = "myapp"
container_definitions = templatefile("./ecs-task-definition.json", {
image_uri = aws_ecr_repository.main.repository_url
})
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "256"
memory = "512"
task_role_arn = aws_iam_role.ecs_task_execution_role.arn
execution_role_arn = aws_iam_role.ecs_task_execution_role.arn
}
resource "aws_ecs_service" "main" {
name = "myapp"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.main.arn
desired_count = 2
launch_type = "FARGATE"
depends_on = [aws_alb_listener.main]
load_balancer {
target_group_arn = aws_alb_target_group.main.arn
container_name = "myapp"
container_port = var.port
}
network_configuration {
subnets = data.aws_subnet_ids.default.ids
security_groups = [aws_security_group.fargate_service.id]
assign_public_ip = true
}
lifecycle {
ignore_changes = [desired_count, task_definition]
}
}
######################
# IAM Role
######################
resource "aws_iam_role" "ecs_task_execution_role" {
name = "myapp-iam-role-ecs"
assume_role_policy = data.aws_iam_policy_document.assume_role_policy.json
}
data "aws_iam_policy_document" "assume_role_policy" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["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"
}
######################
# Cloudwatch
######################
resource "aws_cloudwatch_log_group" "main" {
name = "/ecs/myapp"
}
[
{
"name": "myapp",
"image": "${image_uri}",
"essential": true,
"portMappings": [
{
"containerPort": 3000,
"hostPort": 3000
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/myapp",
"awslogs-region": "us-west-1",
"awslogs-stream-prefix": "ecs"
}
}
}
]
デプロイ
#!/bin/bash
set -eu
test -n "$NAME"
test -n "$DOCKER_REGISTRY"
test -n "$SOURCE"
docker build -t $DOCKER_REGISTRY $SOURCE
aws ecr get-login-password | docker login --username AWS --password-stdin $DOCKER_REGISTRY
docker push $DOCKER_REGISTRY
# 前と同じ設定を適用する
# @see https://github.com/aws/aws-cli/issues/3064#issuecomment-638751296
NEW_TASK_DEFINTION=$(aws ecs describe-task-definition --task-definition $NAME \
--query '{ containerDefinitions: taskDefinition.containerDefinitions,
family: taskDefinition.family,
taskRoleArn: taskDefinition.taskRoleArn,
executionRoleArn: taskDefinition.executionRoleArn,
networkMode: taskDefinition.networkMode,
volumes: taskDefinition.volumes,
placementConstraints: taskDefinition.placementConstraints,
requiresCompatibilities: taskDefinition.requiresCompatibilities,
cpu: taskDefinition.cpu,
memory: taskDefinition.memory}')
GIT_TAG=`git log | head -n 1 | awk '{print $2}'`
aws ecs register-task-definition \
--family $NAME \
--tags key=GIT_TAG,value=$GIT_TAG \
--cli-input-json "$NEW_TASK_DEFINTION"
aws ecs update-service \
--cluster $NAME \
--service $NAME \
--task-definition $NAME
こうしておけば、 CI からデプロイするのも簡単ですね。
$ env \
NAME=myapp \
DOCKER_REGISTRY=123456789.dkr.ecr.us-west-1.amazonaws.com/core-server \
SOURCE=myapp \
bin/update-fargate-service.sh