AWS初心者がTerraformでECSクラスタを構築してみた

先日、Terraformを使って、ECSのクラスタを構築する機会がありましたので、やったことをメモとして残したいと思います。開始当初の知識レベルは「EC2完全に理解した(※)」「VPC？なにそれおいしいの」という状態です。Terraformは存在自体知りませんでした。従って、間違ってること言ってたらご指摘頂けるとありがたいです。

# tl;dr
https://github.com/foo-543674/terraform-aws-ecr-with-ec2

# 書かないこと

ECSやTerraform自体の説明や導入手順については参考にしたサイトを貼るだけに留めて割愛します。

- https://docs.aws.amazon.com/AmazonECS/latest/developerguide/Welcome.html
- https://registry.terraform.io/providers/hashicorp/aws/latest/docs
- https://qiita.com/NewGyu/items/9597ed2eda763bd504d7
- https://buildersbox.corp-sansan.com/entry/2020/01/28/110000
- https://hub.docker.com/r/zenika/terraform-aws-cli

# Terraform準備

TerraformはCLIコマンドを実行したディレクトリの.tfファイルを参照してそこに書いてあるリソースを作成します。自動的に参照されるのはコマンドを実行したディレクトリの.tfファイルのみで、上位はもちろん、サブディレクトリも明示的に呼ばない限りは読み込まれません。今回は小難しい設定はなしにして最低限の初期設定だけ行います。

```terraform:provider.tf
provider "aws" {
  region = "ap-northeast-1"
}

```

まずはプロバイダです。これでAWS用のプラグインが読み込まれ、AWSのリソースを作成できるようになります。

```terraform:versions.tf
terraform {
  required_version = ">= 0.12"
}

```

こちらは実行するバージョンを限定できます。上記で"0.12より上のバージョンでのみ動作する"ということを明示します。
作り込むと実行結果(作成されたリソースのARNとか)をS3に保存してリモートステータスとして参照とかできますが、割愛します。
2つのファイルを作成したら`terraform init`を実行してみましょう。実行するために必要なファイルが色々作成されます。

# ECRリポジトリ

Terraformの準備が整ったところで、作成するリソースをゴリゴリ書いていきます。まずはECRのリポジトリから作ります。ECRは、DockerHubのようにAWSが提供しているコンテナイメージのレジストリです。DockerHubのイメージそのまま使ってもいいのですが、せっかくなのでこちらにイメージを作ってそこからpullしたいと思います。

### リポジトリ作成

早速、コンテナイメージを格納するリポジトリをECRにを作成します。

```terraform:ecr.tf
resource "aws_ecr_repository" "nginx" {
  name                 = "nginx"
  image_tag_mutability = "IMMUTABLE"

  image_scanning_configuration {
    scan_on_push = true
  }
}
```

`image_tag_mutability`は一度作成したTagの上書きの可否です。
`image_scanning_configuration.scan_on_push`はイメージをプッシュした際に、脆弱性を診断してくれます。
リポジトリを作成したらイメージをプッシュします。AWS CLIを使うとECRへのログインコマンドを作ってくれますので、以下でECRにログインします。

```bash
aws ecr get-login --region ap-northeast-1 --no-include-email | bash
```

あとはDockerイメージをビルドしてプッシュするだけです。イメージ名は作成したECRリポジトリのURIにしてください。

```bash
URI="************.dkr.ecr.[region].amazonaws.com/[image]"
docker build -t $URI .
docker push $URI:latest
```

### VPCエンドポイント作成

次に、VPCエンドポイントを作成します。これがないと、VPCの中からAWSのサービスを参照できませんので、ECRからイメージをpullできません。

```terraform:vpc.tf
...
resource "aws_vpc_endpoint" "ecr" {
  vpc_id       = aws_vpc.main.id
  subnet_ids   = [aws_subnet.main.id]
  service_name = "com.amazonaws.ap-northeast-1.ecr.dkr"
  vpc_endpoint_type = "Interface"
  security_group_ids = [
    aws_security_group.allows_tls.id
  ]

  policy = <<EOF
{
  "Statement": [
    {
      "Action": "*",
      "Effect": "Allow",
      "Resource": "*",
      "Principal": "*"
    }
  ]
}
EOF
}
...
```

更に、ECRはイメージの実体をS3に保存してますので、イメージをダウンロードするためにS3のVPCエンドポイントも必要です。

```terraform:vpc.tf
...
resource "aws_vpc_endpoint" "s3" {
  vpc_id       = aws_vpc.main.id
  service_name = "com.amazonaws.ap-northeast-1.s3"
  vpc_endpoint_type = "Gateway"

  policy = <<EOF
{
  "Statement": [
    {
      "Action": [
        "s3:GetObject"
      ],
      "Effect": "Allow",
      "Resource": [
        "arn:aws:s3:::prod-region-starport-layer-bucket/*"
      ],
      "Principal": "*"
    }
  ]
}
  EOF
}
...
```

この時、リソース名を制限する場合は`arn:aws:s3:::prod-region-starport-layer-bucket/*`を許可するようにしてください。

# ECSタスク定義

タスク定義は、ECS上でどんなコンテナを動かすのかを決めるところです。docker-compose.yamlを作るイメージに近いと思います。

### タスク定義

```terraform:task_definition.tf
resource "aws_ecs_task_definition" "nginx" {
  family = "nginx"
  container_definitions = templatefile("container_definition.json", {
    repository_url = aws_ecr_repository.nginx.repository_url
    log_group_name = aws_cloudwatch_log_group.nginx_log.name
  })
  task_role_arn      = aws_iam_role.task.arn
  execution_role_arn = aws_iam_role.task_execution.arn

  requires_compatibilities = ["EC2"]
  network_mode             = "awsvpc"
}
```

`family`がタスク定義の名前です。`container_definition`はどんなコンテナを動かすのかをJSONで書きます(後述)。

分かりづらいところで、ロールの割り当てが`task_role_arn`と`execution_role_arn`の２つがあります。`execution_role_arn`はタスクを実行するプロセスに割り当てられるロールで、ECRにあるイメージを使う場合はECRへの参照権限が必要です。基本的にはデフォルトで用意されている`AmazonECSTaskExecutionRolePolicy`がアタッチされてるロールであれば問題ないと思います。`task_role_arn`は実行されたタスクに割り当てられるロールです。例えば、起動したコンテナの中で稼働するアプリが、S3にファイルを書き込んだりしてる場合は、S3への書き込み権限を持ったロールを割り当てる必要があります。

`requires_compatibilities`でこのタスク定義がどの起動タイプで起動できるかを設定します。今回はEC2でのみ起動可能なタスクとして設定してます。

`network_mode`はタスクのネットワークドライバを指定します。docker-compose.yamlによく書かれてるのは`bridge`ですが、今回は`awsvpc`を使います。`awsvpc`にすると、タスクに直接ENIがアタッチされ、タスクにサブネットのIPアドレスが振られます。注意点として、EC2を使う場合、インスタンスタイプ毎のENI上限に引っかからないようにする必要があります。具体例として、`t2.small`のインスタンスタイプを使ってるEC2インスタンス上の場合、上限は2なので、3つ目のタスクを起動しようとするとコンテナの起動に失敗します。この上限はECSのアカウント設定で緩和できるので忘れずにしておきましょう。以下のコマンドで設定できます。

```bash
aws ecs put-account-setting --name awsvpcTrunking --value enabled --region a-^northeast-1
```

緩和後の上限などについては以下がわかりやすいです。
https://dev.classmethod.jp/articles/ecs-eni-limit-add/

### コンテナ定義

```json:container_definition.json
[
  {
    "name": "nginx",
    "image": "${repository_url}",
    "portMappings": [
      {
        "containerPort": 80,
        "protocol": "tcp"
      }
    ],
    "memoryReservation": 512,
    "logConfiguration": {
      "logDriver": "awslogs",
      "secretOptions": null,
      "options": {
        "awslogs-group": "${log_group_name}",
        "awslogs-region": "ap-northeast-1",
        "awslogs-stream-prefix": "ecs"
      }
    },
    "essential": true
  }
]
```

`image`は使用するイメージです。今回はECRのリポジトリなので、ECRのURLを書きます。DockerHubからpullする場合はイメージの名前だけで大丈夫です。
`logConfiguration`を設定すると`docker logs`で出力される内容が、設定した場所に送信されるようになります。今回はCloudwatchのロググループに書き込むようにしてます。
`portMappings`は基本的にはコンテナのポートだけ記載すれば問題ありません。`awsvpc`を使う時はホストのポートが使われることはありません。`bridge`等を使う場合も動的ポートマッピングを使ったほうがいいと思われますので、ホストのポートを指定しなくていいです。

また、コンテナの定義が配列なのでお察しかと思いますが、複数のコンテナを定義できます。同一タスク上のコンテナ同士は`localhost`で通信可能です。

もっと詳しく設定内容について知りたい方は以下のリンクをご参照ください。
https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html
https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_task_definition

# ECSクラスタ

次にクラスタを作成していきます。クラスタ自体の設定内容はほぼないのですが、EC2を使う場合EC2インスタンス関連の設定が必要になります。Faegate使う場合途中から読み飛ばしても問題ないです。

### クラスタ

```terraform:cluster.tf
locals {
  cluster_name = "test-cluster"
}

resource "aws_ecs_cluster" "test" {
  name = local.cluster_name

  capacity_providers = [aws_ecs_capacity_provider.test.name]

  default_capacity_provider_strategy {
    capacity_provider = aws_ecs_capacity_provider.test.name
    weight = 1
    base = 0
  }
}
```

`capacity_providers`でこのクラスター内で使用できるキャパシティープロバイダーを指定します。キャパシティープロバイダーについての説明は後述します。
`default_capacity_provider_strategy`でスケールイン・アウト時のタスクの配分を決められます。この数字が変わるとどうなるのかは、以下の記事の"Capacity Provider strategyによるタスク配分戦略"の項がわかりやすかったです。
https://dev.classmethod.jp/articles/regrwoth-capacity-provider/
ちなみにFargate使う場合こちらで用意する必要ありません。これだけでいいです。

```terraform:cluster.tf
locals {
  cluster_name = "test-cluster"
}

resource "aws_ecs_cluster" "test" {
  name = local.cluster_name

  capacity_providers = ["FARGATE", "FARGATE_SPOT"]

  default_capacity_provider_strategy {
    capacity_provider = "FARGATE"
    weight            = 1
    base              = 0
  }
}
```

### キャパシティープロバイダー

起動タイプにEC2を使う場合、どんなEC2インスタンスをどのように起動して、どれくらい使うかという~~面倒な~~設定が必要になります。それがこのキャパシティープロバイダーです。

```terraform:cluster.tf
resource "aws_ecs_capacity_provider" "test" {
  name = "ec2"

  auto_scaling_group_provider {
    auto_scaling_group_arn         = aws_autoscaling_group.test.arn
    managed_termination_protection = "ENABLED"

    managed_scaling {
      maximum_scaling_step_size = 1
      minimum_scaling_step_size = 1
      status                    = "ENABLED"
      target_capacity           = 100
    }
  }
}
```
EC2インスタンス自体の設定やスケールイン・アウトの設定はEC2のオートスケーリンググループを使います。`managed_termination_protection`はコンテナがまだ起動してるのに、オートスケーリンググループによってEC2インスタンスを停止させられてしまうことをECSの方で防いでくれるようになります。
`managed_scaling.target_capacity`はこのオートスケーリンググループによって起動されたEC2インスタンスのリソースを、どれくらいECSのタスクに割くかを設定します。上記だと100%ECSが専有します。
`maximum|minimum_scaling_step_size`というのは一度のスケールイン・アウトでいくつコンテナを起動・終了させるかです。

### オートスケーリンググループ

こちらはECSの機能ではないので、詳しい説明は省いて、ECSに関わる部分の注意点のみ説明します。

```terraform:ec2.tf
resource "aws_autoscaling_group" "test" {
  name = "test"

  launch_template {
    id      = aws_launch_template.test.id
    version = "$Latest"
  }

  protect_from_scale_in = true
  max_size              = 1
  min_size              = 1
}
```
ポイントは2つです。
- `protect_from_scale_in`をtrueにすること。
- `launch_configuration`ではなく`launch_template`を使うこと。

`protect_from_scale_in`はキャパシティープロバイダーの`managed_termination_protection`を有効にする場合は`true`にする必要があります。`launch_template`についてはこの後説明します。

### 起動テンプレート

これもECSの機能ではないので、詳しい説明は省きます。

```terraform:ec2.tf
resource "aws_launch_template" "test" {
  name          = "test"
  image_id      = data.aws_ssm_parameter.amzn2_for_ecs_ami.value
  instance_type = "t2.micro"
  ebs_optimized = true
  user_data = base64encode(templatefile("./userdata.sh",
    {
      cluster_name = local.cluster_name
  }))

  block_device_mappings {
    device_name = "/dev/sda1"
    ebs {
      volume_size = 40
      volume_type = "gp2"
    }
  }
}

data "aws_ssm_parameter" "amzn2_for_ecs_ami" {
  name = "/aws/service/ecs/optimized-ami/amazon-linux-2/recommended/image_id"
}
```
こちらもポイントは2つです。
- AMIにECSに最適化されたイメージを使う。
- `user_data`でECSの設定を行う。

AMIにはECSに最適化されたイメージを使います。以下のページでどんなイメージがあるか確認できます。
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/ecs-ami-versions.html
`user_data`の方ですが、まずテンプレートで読み込んでるファイルは以下の通りです。

```shell:userdata.sh
#!/bin/bash
echo 'ECS_CLUSTER=${cluster_name}' >> /etc/ecs/ecs.config
```
何をやってるかというと、ECSのエージェントに自分が所属すべきクラスターの名前を教えています。これがないと、ECSのエージェントは所属するクラスター名を`default`と判断します。作成したクラスター名は`default`ではないので、これを設定しないと、起動したEC2インスタンスがクラスターに登録されず、コンテナを起動できません。オートスケーリンググループの作成時に`launch_configuration`ではなく`launch_template`を使ったのはこの`user_data`を設定する必要があるためです。

# ECSサービス

ようやくクラスター内で稼働するサービスを作成します。

### サービス

```terraform
resource "aws_ecs_service" "nginx" {
  name                               = "nginx"
  cluster                            = aws_ecs_cluster.test.id
  task_definition                    = aws_ecs_task_definition.nginx.arn
  desired_count                      = 1
  deployment_minimum_healthy_percent = 100

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

  network_configuration {
    subnets         = [aws_subnet.main.id]
    security_groups = [aws_security_group.allows_http]
  }
}
```

`desired_count`で稼働を維持させるタスクの数を指定します。
`deployment_minimum_healthy_percent`は、サービスを更新してタスクをアップグレードする際、維持させるタスクの数を`desired_count`に対するパーセンテージで指定します。上記だと100%なので、1個はタスクを維持したまま新しいタスクを起動します。
WEBサービスの場合はロードバランサを指定します。同時に、ロードバランサと繋がるコンテナと、コンテナの通信を受け付けるポートを指定します。
タスク定義で設定した`network_mode`が`awsvpc`の場合は、`network_configuration`でタスクが所属するサブネットや適用するセキュリティグループ、パブリックIPをタスクに割り当てるかどうかを指定します。

### オートスケーリンググループ

「オートスケーリンググループはさっき作ったじゃん。ボケた？」と思った方、安心してください。まだボケてません。
さっき作成したのはEC2インスタンスのオートスケーリンググループで、こちらはECSのサービスによって稼働するタスクのオートスケーリンググループです。

```terraform
resource "aws_appautoscaling_target" "ecs_service" {
  max_capacity       = 2
  min_capacity       = 1
  resource_id        = "service/${aws_ecs_cluster.test.name}/${aws_ecs_service.nginx.name}"
  scalable_dimension = "ecs:service:DesiredCount"
  service_namespace  = "ecs"
}
```

そのままなので大体わかるかと思いますが、`max|min_capacity`で稼働を維持するタスクの最低数と、スケールアウトする最大数を指定します。コンピューティングリソースではなく、別のメトリクスでスケールイン・アウトする場合はその設定も必要になります。

# 終わり

以上です。WEBサービスの場合は、この後ロードバランサーの設定やらRoute53やらCertificateやら色々やってインターネットからアクセスできるようにする必要が出てきます。AWSやインフラの知識がなかったので結構色んな罠にハマりました。ただ、ハマったおかげで色々知識として吸収できたのでそこは良かったです。次はEKSにチャレンジしたい。