【Terraform】Terraformを使用したECS Webアプリ構築

[【AWS】AWS CLIとECS CLIを使用したECS Webアプリ構築 ~バッチ化まで~](https://qiita.com/Shoma0210/items/3eaad5d53d88b87362f3)の記事では、ECSのリソースをバッチで作成しました。
しかし、異常系を考慮できていないことやリソース管理が難しいという問題があります。
そこで、設定ファイルを作成することで作成・更新・削除などの管理を簡略化できるTerraformを利用します。

また、Terraformの実行環境についてもコンテナ化しています。この記事では説明を省略しますので、ご興味のある方は詳細は[こちら](https://qiita.com/Shoma0210/items/7178284e4fdbcd5f9dc2)の記事でご確認ください。

# AWS構成図

![ecs_alb_webapp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1166959/97f5cbfe-be76-b62a-7643-4b2a92845849.png)


# Terraformの作成

各リソースの定義をtfファイルで作成します。
それぞれモジュールという単位で分割して、メインから呼び出す設計です。

`terraform`フォルダの直下にある`main.tf`が最初に呼び出されるメインファイル、`terraform.tfvars`はグローバルな変数を定義するファイルです。
それぞれにある`variables.tf`は、各場所での変数を定義するファイルです。
また、`output.tf`はリソースを作成した結果で得られる値を変数として定義するファイルです。ここで変数にしておくと、その後の他のリソース作成の際に参照したりできます。

`app`フォルダにはコンテナ化されたWebアプリケーションを配置してください。
`docker-compose.yml`の使用を想定しています。

:::note info
ファイル数が多いので、重要な部分と分かりづらい部分のみ説明を書きます。
:::

```:ディレクトリ構成
└─terraform-container
    │  .env
    │  Dockerfile
    │
    └─web
        ├─app
        │  └─ docker-compose.yml
        │
        └─terraform
            │  main.tf
            │  terraform.tfvars
            │  variables.tf
            │
            └─modules
                ├─alb
                │      alb.tf
                │      outputs.tf
                │      tg.tf
                │      variables.tf
                │
                ├─cloudwatch
                │      logs.tf
                │      outputs.tf
                │      variables.tf
                │
                ├─ecr
                │      dockerbuild.sh
                │      ecr.tf
                │      outputs.tf
                │      variables.tf
                │
                ├─ecs
                │      cluster.tf
                │      outputs.tf
                │      service.tf
                │      task.tf
                │      task_definition.json
                │      variables.tf
                │
                ├─iam
                │      outputs.tf
                │      role.tf
                │      variables.tf
                │
                ├─network
                │      ig.tf
                │      outputs.tf
                │      rt.tf
                │      subnet.tf
                │      variables.tf
                │      vpc.tf
                │
                └─sg
                        outputs.tf
                        sg.tf
                        variables.tf
```

## メイン

`main.tf`がメインとなるファイルです。
このファイルから各モジュールを呼び出します。

`terraform`でTerraformのバージョンを指定します。また、`provider`で使用するプロバイダーを指定します。

`module`で後述するモジュールを呼び出します。具体的なモジュールの場所は`source`で指定します。
その他は、モジュール内で使用したい変数を渡すために設定しています。ここでは、各リソースで同じ値を使用するものを`terraform.tfvars`で定義して、それを変数として参照して渡しています。
`${}`を使用して他のモジュールで作成したリソースの値を使用することもできます。ただし、使用できるのは参照先のモジュールの`output`で登録している値のみです。

```tf:main.tf
# Terraform のバージョン指定
terraform {
  required_version = "~> 1.0.0"
}

# プロバイダーを指定
provider "aws" {}

# ECR
module "ecr" {
  source = "./modules/ecr"
  
  name_prefix = var.name_prefix
  region = var.region
  tag_name = var.tag_name
  tag_group = var.tag_group

  account_id = var.account_id
}

# IAM
module "iam" {
  source = "./modules/iam"

  name_prefix = var.name_prefix
  region = var.region
  tag_name = var.tag_name
  tag_group = var.tag_group
}

# Network
module "network" {
  source = "./modules/network"

  name_prefix = var.name_prefix
  region = var.region
  tag_name = var.tag_name
  tag_group = var.tag_group
}

# Security Group
module "sg" {
  source = "./modules/sg"
  
  name_prefix = var.name_prefix
  region = var.region
  tag_name = var.tag_name
  tag_group = var.tag_group

  vpc_id = "${module.network.vpc_id}"
  sg_ingress_ip_cidr = var.sg_ingress_ip_cidr
}

# Cloud Watch
module "cloudwatch" {
  source = "./modules/cloudwatch"

  name_prefix = var.name_prefix
  region = var.region
  tag_name = var.tag_name
  tag_group = var.tag_group
}

# ALB
module "alb" {
  source = "./modules/alb"

  name_prefix = var.name_prefix
  region = var.region
  tag_name = var.tag_name
  tag_group = var.tag_group

  vpc_id = "${module.network.vpc_id}"
  public_a_id = "${module.network.public_a_id}"
  public_c_id = "${module.network.public_c_id}"
  sg_id = "${module.sg.sg_id}"
}

# ECS
module "ecs" {
  source = "./modules/ecs"

  name_prefix = var.name_prefix
  region = var.region
  webapp_port = var.webapp_port
  tag_name = var.tag_name
  tag_group = var.tag_group

  # Service
  logs_group_name = "${module.cloudwatch.logs_group_name}"
  tg_arn = "${module.alb.tg_arn}"
  public_a_id = "${module.network.public_a_id}"
  public_c_id = "${module.network.public_c_id}"
  sg_id = "${module.sg.sg_id}"
  # Task
  ecr_repository_uri = "${module.ecr.repository_uri}"
  execution_role_arn = "${module.iam.execution_role_arn}"
}
```

`sg_ingress_ip_cidr`は、セキュリティグループのインバウンドルールで使用します。
自身のIPアドレスを定義しておくとWebアプリにアクセスできるようになります。
`webapp_port`はWebアプリの公開ポートを指定してください。

```tfvars:terraform.tfvars
tag_name = "xxxx-name"
tag_group = "xxxx-group"
name_prefix ="qiita"
region = "ap-northeast-1"
account_id = "xxxxxxxxxxxx"
sg_ingress_ip_cidr = "xxx.xxx.xxx.xxx/32"
webapp_port = xxxx
```

```tf:variables.tf
# Global
variable region {}
variable name_prefix {}
variable webapp_port {}

# Tags
variable tag_name {}
variable tag_group {}

# ECR
variable "account_id" {}

# SG
variable "sg_ingress_ip_cidr" {}
```

## ECR

`resource`でプロバイダーに存在するリソース名とその中の設定値を指定します。
リソース名を`null_resource`とすることで、shellスクリプトを実行したりもできます。ここでは、dockerコンテナのビルドからECRのプッシュまでを`dockerbuild.sh`にまとめて実行しています。

```tf:ecr.tf
resource "aws_ecr_repository" "default" {
  name                 = "${local.repository_name}"
  image_tag_mutability = "MUTABLE"

  image_scanning_configuration {
    scan_on_push = true
  }

  tags = {
    Name = "${var.tag_name}-repository"
    group = "${var.tag_group}"
  }
}

resource "null_resource" "default" {
  provisioner "local-exec" {
    command = "sh ${path.module}/dockerbuild.sh"

    environment = {
      AWS_REGION     = var.region
      AWS_ACCOUNT_ID = var.account_id
      REPO_URL       = aws_ecr_repository.default.repository_url
      CONTAINER_NAME = "${local.container_name}"
      DOCKER_DIR = "${local.docker_dir}"
    }
  }
}
```

```tf:variables.tf
# Global
variable region {}
variable name_prefix {}

# Tags
variable tag_name {}
variable tag_group {}

# ECR
variable "account_id" {}

locals {
  repository_name = "${var.name_prefix}-repository"
  container_name = "${var.name_prefix}-container"
  docker_dir = "/web/app/docker-compose.yml"
}
```

```tf:outputs.tf
output "repository_uri" {
    value = "${aws_ecr_repository.default.repository_url}"
}
```

```shell:dockerbuild.sh
#!/bin/bash

# Docker login
aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com

# Build image
export CONTAINER_NAME=$CONTAINER_NAME
# docker build -t $CONTAINER_NAME $DOCKER_DIR
docker-compose -f $DOCKER_DIR build --no-cache

# Tag
docker tag $CONTAINER_NAME:latest $REPO_URL:latest

# Push image
docker push $REPO_URL:latest
```

## IAM

```tf:role.tf
data "aws_iam_policy_document" "default" {
  statement {
    actions = ["sts:AssumeRole"]

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

resource "aws_iam_role" "default" {
  name               = "${local.role_name}"
  assume_role_policy = data.aws_iam_policy_document.default.json

  tags = {
    Name = "${var.tag_name}-repository"
    group = "${var.tag_group}"
  }
}

resource "aws_iam_role_policy_attachment" "default" {
  role       = aws_iam_role.default.name
  policy_arn = "${local.ecs_task_execution_role_policy_arn}"
}
```

```tf:variables.tf
# Global
variable region {}
variable name_prefix {}

# Tags
variable tag_name {}
variable tag_group {}

locals {
  role_name = "${var.name_prefix}-role"
  ecs_task_execution_role_policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
```

```tf:outputs.tf
output "execution_role_arn" {
    value = "${aws_iam_role.default.arn}"
}
```

## Network

```tf:ig.tf
resource "aws_internet_gateway" "default" {
  vpc_id = aws_vpc.default.id

  tags = {
    Name = "${var.tag_name}-repository"
    group = "${var.tag_group}"
  }
}
```

```tf:rt.tf
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.default.id

  tags = {
    Name = "${var.tag_name}-route-table"
    group = "${var.tag_group}"
  }
}

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

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

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

```tf:subnet.tf
resource "aws_subnet" "public_a" {
  cidr_block        = "10.0.1.0/24"
  vpc_id            = aws_vpc.default.id
  availability_zone = "ap-northeast-1a"

  tags = {
    Name = "${var.tag_name}-subnet-a"
    group = "${var.tag_group}"
  }
}

resource "aws_subnet" "public_c" {
  cidr_block        = "10.0.2.0/24"
  vpc_id            = aws_vpc.default.id
  availability_zone = "ap-northeast-1c"

  tags = {
    Name = "${var.tag_name}-subnet-c"
    group = "${var.tag_group}"
  }
}
```

```tf:vpc.tf
resource "aws_vpc" "default" {
  cidr_block = local.vpc_cidr

  tags = {
    Name = "${var.name_prefix}-vpc"
    group = "${var.tag_group}"
  }
}
```

```tf:variables.tf
# Global
variable region {}
variable name_prefix {}

# Tags
variable tag_name {}
variable tag_group {}

# Internet Gateway

locals {
  vpc_cidr = "10.0.0.0/16"
}
```

```tf:outputs.tf
output "vpc_id" {
    value = "${aws_vpc.default.id}"
}
output "public_a_id" {
    value = "${aws_subnet.public_a.id}"
}
output "public_c_id" {
    value = "${aws_subnet.public_c.id}"
}
```

## Security Group

セキュリティグループはインバウンドルールとアウトバウンドルールを一つずつ作成します。
内部の通信を許可したい場合は、インバウンドルールで自身のセキュリティグループを設定することを忘れないで下さい。

```tf:sg.tf
resource "aws_security_group" "default" {
  name   = "${local.sg_name}"
  vpc_id = "${var.vpc_id}"

  tags = {
    Name = "${var.tag_name}-cluster"
    group = "${var.tag_group}"
  }
}

resource "aws_security_group_rule" "ingress_http_myip" {
  from_port         = "80"
  to_port           = "80"
  protocol          = "tcp"
  security_group_id = aws_security_group.default.id
  type              = "ingress"
  cidr_blocks       = ["${var.sg_ingress_ip_cidr}"]
}

resource "aws_security_group_rule" "ingress_sg_all" {
  from_port                = 0
  to_port                  = 0
  protocol                 = "-1"
  security_group_id        = aws_security_group.default.id
  source_security_group_id = aws_security_group.default.id
  type                     = "ingress"
}

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

```tf:variables.tf
# Global
variable region {}
variable name_prefix {}

# Tags
variable tag_name {}
variable tag_group {}

# SG
variable sg_ingress_ip_cidr {}
variable vpc_id {}

locals {
  sg_name = "${var.name_prefix}-sg"
}
```

```tf:outputs.tf
output "sg_id" {
    value = "${aws_security_group.default.id}"
}
```

## Cloud Watch

```tf:logs.tf
resource "aws_cloudwatch_log_group" "default" {
  name              = "${local.logs_group_name}"
  retention_in_days = "${local.retention_in_days}"

  tags = {
    Name = "${var.tag_name}-logs"
    group = "${var.tag_group}"
  }
}
```

```tf:variables.tf
# Global
variable region {}
variable name_prefix {}

# Tags
variable tag_name {}
variable tag_group {}

locals {
  logs_group_name = "/ecs/${var.name_prefix}-service"
  retention_in_days = 30
}
```

```tf:outputs.tf
output "logs_group_name" {
    value = "${local.logs_group_name}"
}
```

## ALB

```tf:alb.tf
resource "aws_lb" "default" {
  name                       = "${local.alb_name}"
  load_balancer_type         = "application"
  internal                   = false
  idle_timeout               = 60
  enable_deletion_protection = false

  subnets = [
    "${var.public_a_id}",
    "${var.public_c_id}",
  ]
  security_groups = [
    "${var.sg_id}"
  ]

  tags = {
    Name = "${var.tag_name}-alb"
    group = "${var.tag_group}"
  }
}

resource "aws_alb_listener" "default" {
  load_balancer_arn = aws_lb.default.arn
  port              = "80"
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.default.arn
  }
}
```

`depends_on`でリソース間の依存関係を明示的に書くこともできます。

```tf:tg.tf
resource "aws_lb_target_group" "default" {
  name                 = "${local.tg_name}"
  vpc_id               = "${var.vpc_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_lb.default]
}
```

```tf:variables.tf
# Global
variable region {}
variable name_prefix {}

# Tags
variable tag_name {}
variable tag_group {}

# ALB
variable public_a_id {}
variable public_c_id {}
variable sg_id {}

# Target Group
variable "vpc_id" {}

locals {
  alb_name = "${var.name_prefix}-alb"
  tg_name = "${var.name_prefix}-tg"
}
```

```tf:outputs.tf
output "dns_name" {
    value = "${aws_lb.default.dns_name}"
}

output "tg_arn" {
    value = "${aws_lb_target_group.default.arn}"
}
```

## ECS

ECSはクラスター・サービス・タスク定義をそれぞれリソースとして作成します。

```tf:cluster.tf
resource "aws_ecs_cluster" "default" {
  name = "${local.ecs_cluster_name}"

  tags = {
    Name = "${var.tag_name}-cluster"
    group = "${var.tag_group}"
  }
}
```

```tf:service.tf
resource "aws_ecs_service" "default" {
  name            = "${local.service_name}"
  cluster         = aws_ecs_cluster.default.id
  task_definition = aws_ecs_task_definition.default.arn
  desired_count   = "${local.service_count}"
  launch_type     = "${local.task_requires_compatibilities}"

  load_balancer {
    target_group_arn = "${var.tg_arn}"
    container_name   = "${local.service_name}"
    container_port   = "${var.webapp_port}"
  }

  network_configuration {
    subnets = [
        "${var.public_a_id}",
        "${var.public_c_id}",
    ]
    security_groups = [
        "${var.sg_id}"
    ]
    assign_public_ip = true
  }
}
```

`data`で外部のファイルなどを参照専用で読み込むことが出来ます。
ここでは、タスク定義のJSONファイルを読み込んでいます。`vars`で変数を定義してJSONファイル内で`${}`を用いることで、置換変数を使用できます。

```tf:task.tf
data "template_file" "default" {
  template = file("${local.task_definitions_filepath}")
  vars = {
    SERVICE_NAME = "${local.service_name}"
    ECR_ARN      = "${var.ecr_repository_uri}"
    LOGS_GROUP_NAME = "${var.logs_group_name}"
    LOG_DRIVER = "${local.task_log_driver}"
    REGION = "${var.region}"
  }
}

resource "aws_ecs_task_definition" "default" {
  container_definitions    = "${data.template_file.default.rendered}"
  family                   = "${local.task_definitions_name}"
  cpu                      = "${local.task_cpu}"
  memory                   = "${local.task_memory}"
  network_mode             = "${local.task_network_mode}"
  requires_compatibilities = ["${local.task_requires_compatibilities}"]
  execution_role_arn       = "${var.execution_role_arn}"

  tags = {
    Name = "${var.tag_name}-task"
    group = "${var.tag_group}"
  }
}
```

```tf:variables.tf
# Global
variable region {}
variable name_prefix {}
variable webapp_port {}

# Tags
variable tag_name {}
variable tag_group {}

# Task
variable ecr_repository_uri {}
variable execution_role_arn {}

# Service
variable logs_group_name {}
variable tg_arn {}
variable public_a_id {}
variable public_c_id {}
variable sg_id {}

locals {
  ecs_cluster_name = "${var.name_prefix}-cluster"
  task_definitions_filepath = "${path.module}/task_definition.json"
  task_definitions_name = "${var.name_prefix}-task"
  task_cpu = 256
  task_memory = 512
  task_log_driver = "awslogs"
  task_network_mode = "awsvpc"
  task_requires_compatibilities = "FARGATE"

  service_name = "${var.name_prefix}-service"
  service_count = 1
}
```

```tf:outputs.tf

```

:::note warn
TerraformはJSONに変数渡すときは文字列に変換しますので、containerPortとhostPortのような数値は直接指定します。ここではwebapp_portと同じポートを指定してください。
:::

```json:task_definition.json
[
  {
    "name": "${SERVICE_NAME}",
    "image": "${ECR_ARN}",
    "essential": true,
    "portMappings": [
      {
        "containerPort": xxxx,
        "hostPort": xxxx
      }
    ],
    "logConfiguration": {
      "logDriver": "${LOG_DRIVER}",
      "options": {
        "awslogs-region": "${REGION}",
        "awslogs-group": "${LOGS_GROUP_NAME}",
        "awslogs-stream-prefix": "${SERVICE_NAME}"
      }
    }
  }
]

```

# 動作確認

terraformフォルダの直下で`terraform apply`を実行します。

```bash
terraform apply
```

そうすると、どのようなリソースが作成されるかが出てきます。
想定通りならば`yes`を入力しましょう。
(エラーが出た場合は適宜解決しましょう)

```bash
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the
following symbols:
  + create
 <= read (data resources)

Terraform will perform the following actions:

  # module.alb.aws_alb_listener.default will be created
  + resource "aws_alb_listener" "default" {
      + arn               = (known after apply)
      + id                = (known after apply)
      + load_balancer_arn = (known after apply)
      + port              = 80
      + protocol          = "HTTP"
      + ssl_policy        = (known after apply)

...中略

      + security_group_id        = (known after apply)
      + self                     = false
      + source_security_group_id = (known after apply)
      + to_port                  = 22
      + type                     = "ingress"
    }

Plan: 24 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value:

```
`Apply complete!`とともに追加されたリソースの数、更新されたリソースの数、削除されたリソースの数が出てきます。
初回ならば`added`以外は0です。

```bash
Apply complete! Resources: 24 added, 0 changed, 0 destroyed.
```

terraformフォルダの直下に`terraform.tfstate`ファイルが作成されますので、その中からALBのDNS名を探します。`dns_name`で検索すると見つかります。

```bash
...
            "customer_owned_ipv4_pool": "",
            "dns_name": "xxxx-xxxx-alb-xxxxxxxxx.ap-northeast-1.elb.amazonaws.com",
            "drop_invalid_header_fields": false,
...
```

ブラウザなどでアクセスしてWebアプリに接続できれば成功です。

# カイゼン案

今回は自身がバッチで実装した処理をTerraformにリプレイスしたこともあり、いくつかカイゼンすべき点を3つまとめてみました。

##### 1. ECRへのアプリケーションコンテナイメージプッシュとECSのサービスデプロイはその他のリソース作成と分離する
⇒インフラ構築とアプリケーションのデプロイはライフサイクルが別のため
##### 2. tfstate(Terraformがリソースの状態管理を行っている)ファイルは、backenf.tfを作成してS3に保存する
⇒破損するとリソースを管理できなくなる可能性があるため
##### 3. ブルーグリーンデプロイに対応するため、ターゲットグループを二つ用意して、切り替えを行えるようにする
⇒アプリのデプロイ時のダウンタイムをなくすため

# さいごに

初めてインフラをコード化しましたが、次からは必ず使うだろうなと思ってしまうくらい管理が楽です。
次は、今回で学んだカイゼン案を取り込みつつ、AzureやGCPなど他のクラウドサービスでもTerraformを使ったり、Terraform以外のIaCツールを勉強して比較したりもしてみたいです。

# 参考

[Terraform 入門](https://qiita.com/kobayashi-m42/items/247cf9708044db8a234e)
[TerraformでさくっとFargateを構築する](https://qiita.com/tutuz/items/d6181d96c943092e8c8d)
[terraformのmoduleで定義したresouseにアクセスするにはoutputしないとダメ](https://www.mpon.me/entry/2016/12/13/030907)
[TerraformでAWS ECRのリポジトリを作成してpushする](https://qiita.com/hayaosato/items/d6049cf68c84a26845d2)