AWS Fargate サービスを Terraform で構築、 コマンドラインからデプロイ

# 動機

[【AWS】 Fargate CLI + Terraform で Docker コンテナを動かす簡単なチュートリアル](https://qiita.com/items/512ad160fb78e421214c) というのを書いたんですが、下記の問題点を感じました。

- 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-xxxxx"` だけ手抜きでハードコーディングしてるんで、そこだけ変えてもらえれば動くと思います。~~ → Subnet も自動的にデフォルトを指定する形に変更しました

ポート番号 `var.port` などは変数で適当に変更する形です。

また HTTPS の設定はしていないですが、 CloudFront を通すなり、 ALB に証明書設定するなり、 Cloudflare でプロキシするなり、組み合わせは色々です。一見 Cloudflare が一番手間が少なくて良いかなと思いましたが、 WebSocket などを使う場合に問題があり、 ALB の方が良かったです。

[追記]
2021年現在でのベストプラクティスは CloudFront を使うことのようです。セキュリティとパフォーマンスの両面で改善が期待できるためのようです。この記事は使っていませんが、 CloudFront を別途用意して Origin を ALB にするのが良いです。
[/追記]

```ruby:fargate.tf
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"
}
```

```json:ecs-task-definition.json
[
  {
    "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"
      }
    }
  }
]
```

# デプロイ

```sh:bin/update-fargate-service.sh
#!/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

```