Amazon CloudWatch Logsにログを保存するようなAWS Fargateクラスタを、Terraformで構築する

# What?

やりたいこと。

* AWS Fargateを使って、クラスタを作る
* コンテナのログは、Amazon CloudWatch Logsに送る
* 以上のことを、Terraformで実現する

というのを書いていきます。


# 環境

利用するTerraformと、AWS Providerのバージョンです。

```shell
$ terraform version
Terraform v0.12.25
+ provider.aws v2.63.0
```


# 構築する内容

まっさらなところから、VPCを作ってAWS Fargateクラスタを構築、簡単な動作確認を行うところまでやります。

* シングルAZ
* 動作させるコンテナはnginx
* ALBはHTTP

Terraformでの実装内容は、すべて`main.tf`に書いています。

# TerraformとAWS Providerのバージョン指定

```go
terraform {
  required_version = "=  0.12.25"
}

provider "aws" {
  version = "2.63.0"
}
```

AWSのクレデンシャルは、環境変数で設定。

```shell
export AWS_ACCESS_KEY_ID=.....
export AWS_SECRET_ACCESS_KEY=.....
export AWS_DEFAULT_REGION=.....
```

この状態で、進めていきましょう。


# VPC〜ALBまで

このあたりは、今回のお題であるFargateやCloudWatch Logsの前提となる環境を作っていくのでコミュニティモジュールを積極的に使って、簡単に。

## VPC

VPCは、こちらのコミュニティモジュールを使用して定義。

[AWS VPC Terraform module](https://github.com/terraform-aws-modules/terraform-aws-vpc)

```golang
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "2.33.0"

  name = "my-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["ap-northeast-1a", "ap-northeast-1c"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]

  map_public_ip_on_launch = true

  enable_nat_gateway     = true
  single_nat_gateway     = true
  one_nat_gateway_per_az = false
}
```

シングルAZにするので、NAT Gatewayはひとつ。


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

セキュリティグループは、ALBとFargateに割り当てるものをそれぞれ定義します。コミュニティモジュールは、こちらを利用。

[AWS EC2-VPC Security Group Terraform module](https://github.com/terraform-aws-modules/terraform-aws-security-group)

```golang
module "load_balancer_sg" {
  source  = "terraform-aws-modules/security-group/aws//modules/http-80"
  version = "3.10.0"

  name   = "load-balancer-sg"
  vpc_id = module.vpc.vpc_id

  ingress_cidr_blocks = ["0.0.0.0/0"]
}

module "nginx_cluster_sg" {
  source  = "terraform-aws-modules/security-group/aws//modules/http-80"
  version = "3.10.0"

  name   = "nginx-cluster-sg"
  vpc_id = module.vpc.vpc_id

  ingress_cidr_blocks = ["10.0.0.0/16"]
}
```

`vpc_id`に関しては、先ほどのVPCを作った時のモジュールのOutputを利用します。

```golang
  vpc_id = module.vpc.vpc_id
```


## ALB

HTTPを受け付ける、ALBを定義します。リスナーや、ターゲットグループも同時に定義することになります。コミュニティモジュールは、こちらを利用。

[AWS Application and Network Load Balancer (ALB & NLB) Terraform module](https://github.com/terraform-aws-modules/terraform-aws-alb)

```golang
module "load_balancer" {
  source  = "terraform-aws-modules/alb/aws"
  version = "5.6.0"

  name = "nginx"

  vpc_id             = module.vpc.vpc_id
  load_balancer_type = "application"
  internal           = false

  subnets         = module.vpc.public_subnets
  security_groups = [module.load_balancer_sg.this_security_group_id]

  target_groups = [
    {
      backend_protocol = "HTTP"
      backend_port     = 80
      target_type      = "ip"

      health_check = {
        interval = 20
      }
    }
  ]

  http_tcp_listeners = [
    {
      port     = 80
      protocol = "HTTP"
    }
  ]
}
```

確認の都合上、ヘルスチェックの時間はちょっと短めにしておきました。

VPCで作成したパブリックサブネットと、ALB用のセキュリティグループの情報を使っています。


## ここまでの結果で、Fargate構築の際に利用するもの

ここまで作成したVPCやセキュリティグループ、ALBの情報のうち、この後のFargateの構築で使うものをローカル変数に切り出しておきます。

```golang
locals {
  vpc_id = module.vpc.vpc_id

  private_subnets                = [module.vpc.private_subnets[0]]
  ecs_service_security_groups    = [module.nginx_cluster_sg.this_security_group_id]
  load_balancer_target_group_arn = module.load_balancer.target_group_arns[0]
}
```

単純に、こうやって分けた方が記事を読む時にわかりやすいかな、と。


# CloudWatch Logs

CloudWatch Logs側は、ロググループを定義。

```golang
resource "aws_cloudwatch_log_group" "nginx_cluster_log_group" {
  name = "nginx-cluster-log-group"
}
```


# IAMロール

コンテナからCloudWatch Logsにログを送る際に、権限が必要になります。このため、IAMロールを定義しましょう。

今回は、`AmazonECSTaskExecutionRolePolicy`というポリシーをそのまま利用します。

[Amazon ECS タスク実行 IAM ロール](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task_execution_IAM_role.html)

`AmazonECSTaskExecutionRolePolicy`でどんなことができるか、見てみましょう。

arnを調べます。

```shell
$ aws iam list-policies | grep ECS
            "PolicyName": "AWSCodeDeployRoleForECS",
            "Arn": "arn:aws:iam::aws:policy/AWSCodeDeployRoleForECS",
            "PolicyName": "AmazonECSServiceRolePolicy",
            "Arn": "arn:aws:iam::aws:policy/aws-service-role/AmazonECSServiceRolePolicy",
            "PolicyName": "AWSCodeDeployRoleForECSLimited",
            "Arn": "arn:aws:iam::aws:policy/AWSCodeDeployRoleForECSLimited",
            "PolicyName": "AmazonECS_FullAccess",
            "Arn": "arn:aws:iam::aws:policy/AmazonECS_FullAccess",
            "PolicyName": "AWSApplicationAutoscalingECSServicePolicy",
            "Arn": "arn:aws:iam::aws:policy/aws-service-role/AWSApplicationAutoscalingECSServicePolicy",
            "PolicyName": "AmazonECSTaskExecutionRolePolicy",
            "Arn": "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy",


$ aws iam get-policy --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
{
    "Policy": {
        "PolicyName": "AmazonECSTaskExecutionRolePolicy",
        "PolicyId": "ANPAJG4T4G4PV56DE72PY",
        "Arn": "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy",
        "Path": "/service-role/",
        "DefaultVersionId": "v1",
        "AttachmentCount": 0,
        "PermissionsBoundaryUsageCount": 0,
        "IsAttachable": true,
        "Description": "Provides access to other AWS service resources that are required to run Amazon ECS tasks",
        "CreateDate": "2017-11-16T18:48:22+00:00",
        "UpdateDate": "2017-11-16T18:48:22+00:00"
    }
}
```

確認。

```shell
$ aws iam get-policy-version --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy --version-id v1
{
    "PolicyVersion": {
        "Document": {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Action": [
                        "ecr:GetAuthorizationToken",
                        "ecr:BatchCheckLayerAvailability",
                        "ecr:GetDownloadUrlForLayer",
                        "ecr:BatchGetImage",
                        "logs:CreateLogStream",
                        "logs:PutLogEvents"
                    ],
                    "Resource": "*"
                }
            ]
        },
        "VersionId": "v1",
        "IsDefaultVersion": true,
        "CreateDate": "2017-11-16T18:48:22+00:00"
    }
}
```

まあ、同じ内容はドキュメントにも書かれているのですが、あらためて見た感じですね。

今回必要なのは、`logs:CreateLogStream`と`logs:PutLogEvents`です。

で、定義したのがこちら。

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

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

resource "aws_iam_role" "ecs_task" {
  name               = "MyEcsTaskRole"
  assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

resource "aws_iam_role_policy_attachment" "ecs_task" {
  role       = aws_iam_role.ecs_task.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
```

Assume Roleについても、ドキュメントに書かれています（信頼関係という表現になっていますが）。

CloudWatch Logsに関するロールをタスクに付与しなかった場合、コンテナの作成時にこんなエラーを見ることになります。

```
Error: ClientException: Fargate requires task definition to have execution role ARN to support log driver awslogs.
```


# Fargateクラスタ

最後に、Fargateクラスタの定義を行います。

* クラスタ定義
* タスク定義
* サービス定義

の3つを行います。

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

resource "aws_ecs_task_definition" "nginx" {
  family                   = "nginx-task-definition"
  cpu                      = "256"
  memory                   = "512"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  execution_role_arn       = aws_iam_role.ecs_task.arn

  container_definitions = <<JSON
[
  {
    "name": "nginx",
    "image": "nginx:1.17.10",
    "essential": true,
    "portMappings": [
      {
        "protocol": "tcp",
        "containerPort": 80
      }
    ],
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-region": "ap-northeast-1",
        "awslogs-group": "nginx-cluster-log-group",
        "awslogs-stream-prefix": "nginx-container-log-stream"
      }
    }
  }
]
  JSON
}

resource "aws_ecs_service" "nginx" {
  name             = "nginx-service"
  cluster          = aws_ecs_cluster.nginx.arn
  task_definition  = aws_ecs_task_definition.nginx.arn
  desired_count    = 3
  launch_type      = "FARGATE"
  platform_version = "1.4.0"

  network_configuration {
    assign_public_ip = false
    security_groups  = local.ecs_service_security_groups
    subnets          = local.private_subnets
  }

  load_balancer {
    target_group_arn = local.load_balancer_target_group_arn
    container_name   = "nginx"
    container_port   = 80
  }
}
```

先ほど定義したIAMロールは、タスク定義で使います。

```golang

resource "aws_ecs_task_definition" "nginx" {
...

  execution_role_arn       = aws_iam_role.ecs_task.arn
...
```

また、コンテナがCloudWatch Logsにログを送信するという設定は、コンテナ定義の`logConfiguration`で行います。`awslogs-group`に、ログ送信対象となるCloudWatch Logsのロググループを指定しましょう。

```golang
  container_definitions = <<JSON
[
  {
    "name": "nginx",
    "image": "nginx:1.17.10",
    "essential": true,
    "portMappings": [
      {
        "protocol": "tcp",
        "containerPort": 80
      }
    ],
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-region": "ap-northeast-1",
        "awslogs-group": "nginx-cluster-log-group",
        "awslogs-stream-prefix": "nginx-container-log-stream"
      }
    }
  }
]
  JSON
```

こちらを参考に。

[awslogs ログドライバーを使用する](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/userguide/using_awslogs.html)

あとは、このタスク定義や、ALB、セキュリティグループの定義を設定しておしまいです。

```golang
resource "aws_ecs_service" "nginx" {
  name             = "nginx-service"
  cluster          = aws_ecs_cluster.nginx.arn
  task_definition  = aws_ecs_task_definition.nginx.arn
  desired_count    = 3
  launch_type      = "FARGATE"
  platform_version = "1.4.0"

  network_configuration {
    assign_public_ip = false
    security_groups  = local.ecs_service_security_groups
    subnets          = local.private_subnets
  }

  load_balancer {
    target_group_arn = local.load_balancer_target_group_arn
    container_name   = "nginx"
    container_port   = 80
  }
}
```


# 動作確認

`terraform apply`して

```shell
$ terraform apply -auto-approve
```

構築されたALBの情報から、DNS名を取得します。

```shell
$ aws elbv2 describe-load-balancers --names nginx
```

取得した`DNSName`から、`curl`で確認。

```shell
$ curl [DNSName]
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
    body {
        width: 35em;
        margin: 0 auto;
        font-family: Tahoma, Verdana, Arial, sans-serif;
    }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>
```

この時の、CloudWatch Logsを`tail`してみます。

```shell
$ aws logs tail nginx-cluster-log-group --follow
```

ログがCloudWatch Logsに登録されているのが確認できました。

```
2020-05-24T09:47:46.409000+00:00 nginx-container-log-stream/nginx/194c1f45-f056-4e4c-8cbf-8306c8ca52b0 10.0.101.4 - - [24/May/2020:09:47:46 +0000] "GET / HTTP/1.1" 200 612 "-" "ELB-HealthChecker/2.0" "-"
2020-05-24T09:47:46.409000+00:00 nginx-container-log-stream/nginx/a89ccf53-31a2-4a24-bb55-e643b30ea3a4 10.0.101.4 - - [24/May/2020:09:47:46 +0000] "GET / HTTP/1.1" 200 612 "-" "ELB-HealthChecker/2.0" "-"
2020-05-24T09:47:54.271000+00:00 nginx-container-log-stream/nginx/00f8ba07-8ac4-4885-854b-88b79bdd0789 10.0.101.4 - - [24/May/2020:09:47:54 +0000] "GET / HTTP/1.1" 200 612 "-" "ELB-HealthChecker/2.0" "-"
2020-05-24T09:48:00.197000+00:00 nginx-container-log-stream/nginx/00f8ba07-8ac4-4885-854b-88b79bdd0789 10.0.101.4 - - [24/May/2020:09:48:00 +0000] "GET / HTTP/1.1" 200 612 "-" "curl/7.58.0" "xxx.xxx.xxx.xxx"
```

OKですね。