TerraformでさくっとFargateを構築する

# はじめに

コンテナの実行環境をAWSでサクッと構築したいよね...ということから今回Terraformを用いて、さらのAWS上にECSを構築することにします。

# 構成図

今回以下のような環境を構築することを目指します。

![terraform.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/89391/102eb3a5-cf06-c248-e9b6-378fbefe004b.png)

# 構築

まずはベースになるネットワークから構築しましょう。

## VPCネットワークの構築

構成要素は以下になります。ネットワーク制御に関しては、今回はセキュリティグループのみで制御し、ネットワークACLは使わないことにします。

- VPC
- サブネット
- インターネットゲートウェイ
- ルートテーブル
- NATゲートウェイ
- セキュリティグループ
- (ネットワークACL)

### VPC

VPCです。IPアドレスの構築規約は適当ですがVPCはCIDRの/16で構築し、サブネットは/24で構築することとします。

```
resource "aws_vpc" "example" {
  cidr_block = "10.0.0.0/16"

  tags = {
    Name = "sample"
  }
}
```

### サブネット

続いてサブネットの構築です。Multi-AZ環境とするため、publicとprivateでそれぞれap-northeast-1aとap-northeast-1cに合わせて2つのサブネットを構築します。

```
resource "aws_subnet" "public_0" {
  cidr_block        = "10.0.1.0/24"
  vpc_id            = aws_vpc.example.id
  availability_zone = "ap-northeast-1a"
}

resource "aws_subnet" "public_1" {
  cidr_block        = "10.0.2.0/24"
  vpc_id            = aws_vpc.example.id
  availability_zone = "ap-northeast-1c"
}

resource "aws_subnet" "private_0" {
  vpc_id            = aws_vpc.example.id
  cidr_block        = "10.0.101.0/24"
  availability_zone = "ap-northeast-1a"
}

resource "aws_subnet" "private_1" {
  vpc_id            = aws_vpc.example.id
  cidr_block        = "10.0.102.0/24"
  availability_zone = "ap-northeast-1c"
}
```

### インターネットゲートウェイ

VPCとインターネットを接続するためにインターネットゲートウェイを構築します。

```
resource "aws_internet_gateway" "example" {
  vpc_id = aws_vpc.example.id
}
```

### NATゲートウェイ

インターネットからprivateサブネットから直接通信できません。しかし、privateサブネットからインターネットへ通信したいことがあります。privateサブネットからインターネットに通信するためにNATゲートウェイを構築します。NATゲートウェイにはElastic IPをソースアドレスとして使用します。

```
resource "aws_eip" "nat_gateway_0" {
  vpc        = true
  depends_on = [aws_internet_gateway.example]
}

resource "aws_eip" "nat_gateway_1" {
  vpc        = true
  depends_on = [aws_internet_gateway.example]
}

resource "aws_nat_gateway" "nat_gateway_0" {
  allocation_id = aws_eip.nat_gateway_0.id
  subnet_id     = aws_subnet.public_0.id
  depends_on    = [aws_internet_gateway.example]
}

resource "aws_nat_gateway" "nat_gateway_1" {
  allocation_id = aws_eip.nat_gateway_1.id
  subnet_id     = aws_subnet.public_1.id
  depends_on    = [aws_internet_gateway.example]
}
```

### ルートテーブル

サブネットのルーティングの設定をしましょう。ルートテーブルはVPC内部の通信はlocalというローカルルートで通信します。publicサブネットのデフォルトゲートウェイをインターネットゲートウェイに設定します。

```
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.example.id
}

resource "aws_route" "public" {
  route_table_id         = aws_route_table.public.id
  gateway_id             = aws_internet_gateway.example.id
  destination_cidr_block = "0.0.0.0/0"
}
```

privateサブネットの場合はデフォルトゲートウェイはNATゲートウェイにします。

```
resource "aws_route_table" "private_0" {
  vpc_id = aws_vpc.example.id
}

resource "aws_route_table" "private_1" {
  vpc_id = aws_vpc.example.id
}

resource "aws_route" "private_0" {
  route_table_id         = aws_route_table.private_0.id
  nat_gateway_id         = aws_nat_gateway.nat_gateway_0.id
  destination_cidr_block = "0.0.0.0/0"
}

resource "aws_route" "private_1" {
  route_table_id         = aws_route_table.private_1.id
  nat_gateway_id         = aws_nat_gateway.nat_gateway_1.id
  destination_cidr_block = "0.0.0.0/0"
}
```

作成したルートテーブルをどのサブネットで使用するか設定します。

```
resource "aws_route_table_association" "public_0" {
  route_table_id = aws_route_table.public.id
  subnet_id      = aws_subnet.public_0.id
}

resource "aws_route_table_association" "public_1" {
  route_table_id = aws_route_table.public.id
  subnet_id      = aws_subnet.public_1.id
}

resource "aws_route_table_association" "private_0" {
  subnet_id      = aws_subnet.private_0.id
  route_table_id = aws_route_table.private_0.id
}

resource "aws_route_table_association" "private_1" {
  subnet_id      = aws_subnet.private_1.id
  route_table_id = aws_route_table.private_1.id
}
```

### セキュリティグループ

セキュリティグループの設定をします。ひとまず以下のような通信を許可するセキュリティグループを作成します。
ingressの通信はHTTPとHTTPSのみ許可し、egressの通信はすべてのIPアドレスに通信可能とします。

| 通信の向き | ポート | プロトコル | IPアドレス |
| ---------- | ------ | ---------- | ---------- |
| ingress    | 80     | tcp        | 0.0.0.0/0  |
| ingress    | 443    | tcp        | 0.0.0.0/0  |
| egress     | x      | x          | 0.0.0.0/0  |

```
resource "aws_security_group" "example" {
  name   = "example"
  vpc_id = aws_vpc.dev-example.id
}

resource "aws_security_group_rule" "ingress_example_http" {
  from_port         = "80"
  to_port           = "80"
  protocol          = "tcp"
  security_group_id = aws_security_group.example.id
  type              = "ingress"
  cidr_blocks       = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "ingress_example_https" {
  from_port         = "443"
  to_port           = "443"
  protocol          = "tcp"
  security_group_id = aws_security_group.example.id
  type              = "ingress"
  cidr_blocks       = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "egress_example" {
  from_port         = 0
  to_port           = 0
  protocol          = "-1"
  security_group_id = aws_security_group.example.id
  type              = "egress"
  cidr_blocks       = ["0.0.0.0/0"]
}
```

## ALBの作成

ユーザからリクエストするエンドポイントはHTTPSで通信することにします。まずはALBに紐付けるSSL証明書を取得するためにACMで証明書の取得とDNSでドメインを取得しましょう。

ALB本体は先に作成しておきます。

```
resource "aws_alb" "example" {
  name                       = "web"
  load_balancer_type         = "application"
  internal                   = false
  idle_timeout               = 60
  enable_deletion_protection = false

  subnets = [
    aws_subnet.public_0.id,
    aws_subnet.public_1.id,
  ]
  security_groups = [aws_security_group.example.id]
}
```

### DNS

ドメインを新規に取得することにします。管理コンソールから手動で取得することにします。新規に取得するのでお金がかかります。適当なドメインを取得し、ドメインが登録できたものとします。本記事ではドメイン名を sample.com としておきます。※実際に sample.com を取得しようとすると、このドメインはすでに登録されているため登録できません。別のドメインで登録します。

```
data "aws_route53_zone" "example" {
  name = "sample.com"
}
```

### ACM

取得したゾーンからACMで証明書を作成します。

```
resource "aws_acm_certificate" "example" {
  domain_name               = data.aws_route53_zone.example.name
  subject_alternative_names = []
  validation_method         = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}
```

### Route53

登録したドメインを利用できるように data でゾーンを取得しておきます。

```
data "aws_route53_zone" "example" {
  name = "sample.com"
}

resource "aws_route53_record" "example" {
  name    = data.aws_route53_zone.example.name
  zone_id = data.aws_route53_zone.example.id
  type    = "A"

  alias {
    evaluate_target_health = true
    name                   = aws_alb.example.dns_name
    zone_id                = aws_alb.example.zone_id
  }
}
```

検証用のDNSレコードの作成と検証をします。

```
# 検証用DNSレコードの作成
resource "aws_route53_record" "example" {
  name    = aws_acm_certificate.example.domain_validation_options[0].resource_record_name
  type    = aws_acm_certificate.example.domain_validation_options[0].resource_record_type
  records = [aws_acm_certificate.example.domain_validation_options[0].resource_record_value]
  zone_id = data.aws_route53_zone.example.id
  ttl     = 60
}

# DNSレコードの検証
resource "aws_acm_certificate_validation" "example" {
  certificate_arn         = aws_acm_certificate.example.arn
  validation_record_fqdns = [aws_route53_record.example.fqdn]
}
```

### ALB

リスナーとターゲットグループを作成します。

#### リスナー

リスナーとは外部からALBが接続を待ち受けるポート/プロトコルをチェックするプロセスです。HTTPSのみのリクエストを待ち受けることにします。

```
resource "aws_alb_listener" "example" {
  load_balancer_arn = aws_alb.example.arn
  port              = "443"
  protocol          = "HTTPS"
  certificate_arn   = aws_acm_certificate.example.arn
  ssl_policy        = "ELBSecurityPolicy-2016-08"

  default_action {
    type             = "forward"
    target_group_arn = aws_alb_target_group.example.arn
  }
}
```

#### ターゲットグループ

ALBで振り分け先となるターゲットグループを作成します。

```
resource "aws_alb_target_group" "example" {
  name                 = "example-target"
  vpc_id               = aws_vpc.example.id
  target_type          = "ip"
  port                 = 80
  protocol             = "HTTP"
  deregistration_delay = 300

  health_check {
    path                = "/"
    healthy_threshold   = 5
    unhealthy_threshold = 2
    timeout             = 5
    interval            = 30
    matcher             = 200
    port                = "traffic-port"
    protocol            = "HTTP"
  }

  depends_on = [aws_alb.example]
}
```

#### リスナールール

リスナールールによってマッチしたURLのパスに応じたリクエストの振り分けをすることができます。

```
resource "aws_alb_listener_rule" "example" {
  listener_arn = aws_alb_listener.example.arn
  priority     = 100
  action {
    type             = "forward"
    target_group_arn = aws_alb_target_group.example.arn
  }

  condition {
    field  = "path-pattern"
    values = ["/*"]
  }
}
```

## ECSの作成

準備が整ったのでECSの構築をします。

### クラスタの作成

まずはECSクラスタを作成します。

```
resource "aws_ecs_cluster" "example" {
  name = "nginx-cluster"
}
```

### タスク定義

続いてタスク定義です。実態は example_task_definitions.json のファイルに記述していきます。

```
resource "aws_ecs_task_definition" "example" {
  container_definitions    = file("./example_task_definitions.json")
  family                   = "example"
  cpu                      = 256
  memory                   = 512
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  execution_role_arn       = aws_iam_role.example.arn
}
```

ログを CloudWatch Logsで参照します。logConfigurationのlogDriverにawslogsを指定することで、ログ出力先をCloudWatch Logsに設定することができます。

```example_task_definitions.json
[
  {
    "name": "example",
    "image": "nginx:latest",
    "essential": true,
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-region": "ap-northeast-1",
        "awslogs-stream-prefix": "ecs",
        "awslogs-group": "/ecs/example"
      }
    },
    "portMappings": [
      {
        "protocol": "tcp",
        "containerPort": 80
      }
    ]
  }
]
```

### サービス

```
resource "aws_ecs_service" "example-nginx-service" {
  name                              = "example"
  cluster                           = aws_ecs_cluster.example.arn
  task_definition                   = aws_ecs_task_definition.example.arn
  desired_count                     = 2
  launch_type                       = "FARGATE"
  platform_version                  = "1.3.0"
  health_check_grace_period_seconds = 60

  network_configuration {
    assign_public_ip = false
    security_groups  = [aws_security_group.example.id]

    subnets = [
      aws_subnet.private_0.id,
      aws_subnet.private_1.id,
    ]
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.target.arn
    container_name   = "example"
    container_port   = 80
  }

  lifecycle {
    ignore_changes = [task_definition]
  }
}
```

## ログ

### CloudWatch Logs

```
resource "aws_cloudwatch_log_group" "for_ecs" {
  name              = "/ecs/nginx/example"
  retention_in_days = 7
}
```

### IAM

ECSに権限を付与するために、IAMロールを作成します。タスク実行IAMロールとして AmazonECSTaskExecutionRolePolicy を用いることができます。

このロールにパラメータストアからパラメータを取得する権限と、KMSから復号化するための権限を付与しておきます。

```
data "aws_iam_policy" "ecs_task_execution_role_policy" {
  arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

data "aws_iam_policy_document" "ecs_task_execution" {
  source_json = data.aws_iam_policy.ecs_task_execution_role_policy.policy

  statement {
    effect    = "Allow"
    actions   = ["ssm:GetParameters", "kms:Decrypt"]
    resources = ["*"]
  }
}

resource "aws_iam_policy" "example" {
  name   = "example-policy-ecs-task-execution"
  policy = data.aws_iam_policy_document.ecs_task_execution.json
}

data "aws_iam_policy_document" "assume_role" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "example" {
  name               = "example-role-ecs-task-execution"
  assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

resource "aws_iam_role_policy_attachment" "example" {
  role       = aws_iam_role.example.name
  policy_arn = aws_iam_policy.example.arn
}
```

# まとめ

Terraformを用いてFargateを構築しました。
今回はECRを用いずにDockerHubにあるイメージで簡易的なnginxが動く環境を構築しました。
次は、ECRに構築したイメージからタスクを実行したり、GitHubと連携してWebhookでCI/CDを回したり、ECSのAutoScalingをしたり...といったことについて書きたいと思います。

今回使用したファイルは以下にあります。

- [main.tf](https://gist.github.com/d-tsuji/4792ad2c35aba23454a6083079ebe899)
- [example_task_definitions.json](https://gist.github.com/d-tsuji/2df47f8cf9cf5a1b41e22e48f424dd8f)

なお、今回はサンプルとして構築したので1ファイルにごっそり書いてますが、tfファイル構成などは [Terraformのベストなプラクティスってなんだろうか](https://future-architect.github.io/articles/20190903/) が参考になります。