AWS FargateとTerraformで最強＆簡単なインフラ環境を目指す

# はじめに
AWSでDocker環境を構築するとき、今までまず選択肢としてあったのがAWS Elastic BeanstalkやAmazon ECSでした。
ですが皆様ご存知の通り、2018年の7月にAWS Fargateが東京リージョンで利用できるようになりました！
Docker環境の選択肢が増え嬉しい限りです。

ということで、少々出遅れてしまいましたがAWS Fargate + Terraform構成を本格的に業務で使ってみることにしました。
<div align="center">
<img width="581" alt="スクリーンショット 2018-08-15 17.32.21.png" src="https://qiita-image-store.s3.amazonaws.com/0/106116/b14b0364-1054-c6c2-0331-44d99eb34cb6.png">
</div>


※ ちなみに、AWS Fargateは独立したサービスではなくAmazon ECSの中に組み込まれており、launch typeで「Fargate」を指定することにより利用できるサービスとなります。
よくネット上で「AWS FargateとAmazon ECSの違い」みたいな記事を目にしていたので別サービスだと勘違いしてました…

# 1. 最強のTerraform
Terraformはインフラの構築を行うためのツールです。今回はVPC[^1]、S3[^2]、SSL証明書、ECSサービス以外をTerraformで構築します。

[^1]: VPCはAWSアカウントのリージョン別で5つ（申請することで増やせます）までしか作成できないので使い回す方針で行きます。
[^2]: Terraformのtfstateファイルを保存する関係上、先に作成します。tfstateにはインフラの状態が保存されています。

## 1-1. Terraformインストール

[ここ](https://www.terraform.io/downloads.html)からそれぞれのOSごとにバイナリをダウンロードすることができます。
もしくはmacOSであればbrewでもOKです。

```sh:macOS
# Terraformインストール
brew install terraform
```

## 1-2. Terraform初期設定
利用を開始するための初期設定を行います。

### terraform.tfvars定義
まずはterraform.tfvarsを作成します。access_key、secret_keyにIAMのクレデンシャル情報を設定します。
※ db_user, db_passはRDS作るときに使います。

```sh
cat >> ./terraform.tfvars  << FIN
access_key = "xxx"
secret_key = "yyy"
token = "AWS STS一時的認証情報が必要な場合設定する"
db_user = "user"
db_pass = "pass"
FIN
```

### 初期化
次に初期化（tfstate作成済みの場合は同期）を行います。

```sh
terraform init
```

### 環境の切り替え
今回は本番環境を想定してproを作成します。

```sh
terraform workspace new pro
terraform env select pro
```

## 1-3. Terraform テンプレートファイルの定義
テンプレートファイルは名前やディレクトリ構成など特に縛りはありません。tf拡張子であれば自動的に読み込まれます。
ここでは下記の構成でファイルを整理し、重要なテンプレートのみ抜粋して説明します。すべての設定を見たい方はGithubのリポジトリをご覧ください。
https://github.com/tarumzu/fargate-sample-project/tree/master/terraform

```
├─main.tf
├─output.tf
├─modules
│ ├─alb.tf
│ ├─ecs.tf
│ ├─iam_role.tf
│ ├─iam_user.tf
│ ├─subnet.tf
│ ├─output.tf
│ └─xxx.tf
└─terraform.tfvars
```

### main.tf
まずmain.tfを作成します。テンプレートの中で各種説明します。

```sh:main.tf
// 変数を定義します。terraform.tfvarsで値を設定している場合はその値が設定されます。
variable "access_key" {}
variable "secret_key" {}
variable "token" {}
variable "db_user" {}
variable "db_pass" {}
variable "region" { default = "ap-northeast-1"}

// s3にはtfstateを保存するS3バケットの指定及び指定したs3にアクセスできるaws credentialsのprofileを指定してください。
terraform {
  backend "s3" {
    bucket = "sample-project"  // tfstateを保存するS3を指定
    key    = "sample-project.terraform.tfstate" // tfstate名
    region     = "ap-northeast-1"  // ここではvariableで定義した変数が使えないため注意！
    profile = "sample-profile" // s3にアクセスできるアカウントのawsプロファイルを指定
  }
}

// ここではプロバイダーの宣言としてawsを定義し、IAMの認証情報を設定します。
provider "aws" {
  access_key = "${var.access_key}"
  secret_key = "${var.secret_key}"
  token = "${var.token}"
  region     = "${var.region}"
}

// modulesのソースを指定。terraform.tfvarsで設定したdb_user, db_passをdb定義用のテンプレートファイルに渡します。
module "base" {
  region                            = "${var.region}"
  db_user                           = "${var.db_user}"
  db_pass                           = "${var.db_pass}"
  source = "./modules"
}
```

### ECS Cluster
ecs_clusterを定義します。サービス及びタスクの作成についてはTerraformではやらずに後述のecs-cliコマンドで行います。

```sh:modules/ecs.tf

resource "aws_ecs_cluster" "ecs_cluster" {
  // terraform.workspaceにはterraform envが入る。今回の例ではpro
  name = "${var.name}-${terraform.workspace}" // variables.tfで定義した変数を参照
}
```

### ALB
ALBの定義です。Fargateをスケールアウト/インさせるのに必要です。絶対作りましょう。

```sh:modules/alb.tf
// albを作成。
resource "aws_alb" "alb" {
  name                       = "${var.name}-${terraform.workspace}"
  security_groups            = ["${aws_security_group.alb.id}"]
  subnets = [
    "${aws_subnet.public_a.id}",
    "${aws_subnet.public_c.id}",
  ]
  internal                   = false
  enable_deletion_protection = false

  access_logs {
    bucket = "${var.bucket}"
  }
}

// albのターゲットグループ
resource "aws_alb_target_group" "alb" {
  name     = "${var.name}-${terraform.workspace}-tg"
  port     = 80
  protocol = "HTTP"
  vpc_id   = "${var.vpc_id}"
  target_type = "ip"

  health_check {
    interval            = 60
    path                = "/"
    // NOTE: defaultはtraffic-port
    //port                = 80  
    protocol            = "HTTP"
    timeout             = 20
    unhealthy_threshold = 4
    matcher             = 200
  }
}

// 443ポートの設定。今回は事前にAWS Certificate Managerで作成済みの証明書を設定。
resource "aws_alb_listener" "alb_443" {
  load_balancer_arn = "${aws_alb.alb.arn}"
  port              = "443"
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-2015-05"
  certificate_arn   = "${var.alb_certificate_arn}"

  default_action {
    target_group_arn = "${aws_alb_target_group.alb.arn}"
    type             = "forward"
  }
}

resource "aws_alb_listener" "alb" {
  load_balancer_arn = "${aws_alb.alb.arn}"
  port              = "80"
  protocol          = "HTTP"

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


### IAM Role
Fargateの実行に必要なロールを定義します。また、今回DockerリポジトリにAmazon ECRを使っているのでその権限も付与してます。

```sh:modules/iam_role.tf
resource "aws_iam_role" "iam_role" {
  assume_role_policy = "${file("${path.module}/ecs_assume_role_policy.json")}"
  name = "${var.name}_ecs"
}

data "template_file" "execution_assume_role_policy" {
  template = "${file("${path.module}/execution_assume_role_policy.json")}"

  vars {
    region = "${var.region}"
  }
}

resource "aws_iam_role" "execution" {
  assume_role_policy = "${data.template_file.execution_assume_role_policy.rendered}"
  name = "${var.name}_execution"
}

resource "aws_iam_role_policy_attachment" "ecs_service" {
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceRole"
  role       = "${aws_iam_role.iam_role.id}"
}

resource "aws_iam_role_policy_attachment" "ecs_task" {
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
  role       = "${aws_iam_role.iam_role.id}"
}
resource "aws_iam_role_policy_attachment" "ecr_power_user" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser"
  role       = "${aws_iam_role.iam_role.id}"
}

data "aws_iam_policy_document" "autoscaling-assume-role-policy" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["application-autoscaling.amazonaws.com"]
    }
  }
}
```


### IAM User
こちらは後述のecs-cliコマンドでデプロイするときに使うIAMユーザーの定義です。画像, css, javascriptなどの静的リソースの配信もs3で行うため、合わせて権限付与してます。

```sh:modules/iam_user.tf
resource "aws_iam_user" "user" {
  name = "${var.name}-${terraform.workspace}-deployer"
}

resource "aws_iam_access_key" "key" {
  user = "${aws_iam_user.user.name}"
}

data "aws_iam_policy_document" "policy_document" {
  statement {
    actions = [
      "s3:ListBucket"
    ]

    resources = [
      "arn:aws:s3:::sample-project"
    ]

    condition {
      test     = "StringEquals"
      variable = "s3:prefix"

      values = [
        ""
      ]
    }
  }

  statement {
    actions = [
      "s3:ListBucket",
    ]

    resources = [
      "arn:aws:s3:::sample-project",
    ]

    condition {
      test     = "StringLike"
      variable = "s3:prefix"

      values = [
        "production",
        "production/*"
      ]
    }
  }

  statement {
    actions = [
      "s3:*",
    ]

    resources = [
      "arn:aws:s3:::sample-project/production/*",
    ]
  }
}

resource "aws_iam_policy" "policy" {
  name        = "${var.name}-${terraform.workspace}-policy"
  description = "${var.name} policy"
  policy = "${data.aws_iam_policy_document.policy_document.json}"
}

resource "aws_iam_policy_attachment" "attach" {
  name       = "attachment"
  users      = ["${aws_iam_user.user.id}"]
  roles      = ["${aws_iam_role.iam_role.id}"]
  policy_arn = "${aws_iam_policy.policy.arn}"
}
resource "aws_iam_user_policy_attachment" "attach2" {
  user      = "${aws_iam_user.user.id}"
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerServiceFullAccess"
}
resource "aws_iam_user_policy_attachment" "attach3" {
  user      = "${aws_iam_user.user.id}"
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser"
}
```

### Subnet
今回はアベイラビリティゾーンAとCにサブネットを定義します。VPCは既存のものを利用します。

```sh:modules/subnet.tf
resource "aws_subnet" "public_a" {
  availability_zone = "${var.region}a"
  cidr_block        = "${lookup(var.subnet_public_a, "${terraform.workspace}", var.subnet_public_a["default"])}"
// このようなvariableを定義することでlookup関数で環境ごとに別の値を設定することもできる。
// variable "subnet_public_a" {
//   default = {
//     default    = "172.30.3.0/24"
//     stg        = "172.30.3.0/24"
//     pro        = "172.30.7.0/24"
//   }
// }
  vpc_id            = "${var.vpc_id}"
}

resource "aws_subnet" "public_c" {
  availability_zone = "${var.region}c"
  cidr_block        = "${var.subnet_public_c}"
  vpc_id            = "${var.vpc_id}"
}
```

### Output
albを生成したときのターゲットarn（Amazon リソースネーム）は後述のecs-cli composeコマンドでarnターゲットを指定する際に使うのでoutputも定義します。

```sh:output.tf
// modules/output.tfで定義したoutputを呼び出す
output "alb" {
  value = "${module.base.alb}"
}
```

```sh:modules/output.tf
output "alb" {
  value = {
    dns_name         = "${aws_alb.alb.dns_name}"
    arn              = "${aws_alb.alb.arn}"
    target_group_arn = "${aws_alb_target_group.alb.arn}"
  }
}
```

## 1-4. Terraformでインフラを構築
ここまでに作成したテンプレートファイルを元に、実際にインフラを構築してみます。

```sh
# 設定ファイルのチェック
terraform plan 
# AWS環境を構築
terraform apply
```

以上でAWS環境が構築されます。

Terraformは最初作成するときがとても大変ですが一度作ってしまえば、他プロジェクトへの展開や、ノウハウ共有、インフラのレビューなどが簡単になります。ブラックボックス化を防ぐことにも繋がるので是非ともやりたいところですね。


# 2. 最強のAWS Fargate
それではいよいよAWS Fargateを作成していきます。

## 2-1. Fargate 設定ファイルの定義
docker-composeでイメージの指定、環境変数の指定、CloudWatch Logsへのログ配信設定を行います。

### docker-compose.production.yml

```yaml:docker-compose.production.yml
version: '2'
services:
  rails:
    image: ${CONTAINER_REGISTRY}/${APP_NAME}-rails:${ENV}_${SHA1}
    env_file: ${ENV}.env
    ports:
      - "3000:3000"
    mem_limit: ${RAILS_MEMORY}MB
    logging:
      driver: awslogs
      options:
        awslogs-group: ${APP_NAME}
        awslogs-region: ap-northeast-1
        awslogs-stream-prefix: ${ENV}
```

### ecs-param.production.yml
ecs-paramでCPUやメモリの設定、コンテナのセキュリティーグループ、展開先のサブネットなどを指定します。

```yaml:ecs-param.production.yml
version: 1
task_definition:
  ecs_network_mode: awsvpc
  task_execution_role: ${APP_NAME}_ecs
  task_size:
    cpu_limit: ${RAILS_CPU}
    mem_limit: ${RAILS_MEMORY}
  services:
    rails:
      essential: true
run_params:
  network_configuration:
    awsvpc_configuration:
      subnets:
        - subnet-xxxxxxxxxxxxxxxxx # terraformで定義したサブネットAのID
        - subnet-yyyyyyyyyyyyyyyyy # terraformで定義したサブネットCのID
      security_groups:
        - sg-zzzzzzzzzzzzzzzzz # terraformで定義したセキュリティグループのID
      assign_public_ip: ENABLED
```

## 2-2. Fargate ecs-cliのインストール
インストールは下記リンクの方法で行なえます。
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/ECS_CLI_installation.html

CircleCIで行う場合はこんな感じでインストールします。

```docker:circleci用のDocker一部抜粋
RUN curl -o /usr/local/bin/ecs-cli https://s3.amazonaws.com/amazon-ecs-cli/ecs-cli-linux-amd64-latest
RUN chmod 755 /usr/local/bin/ecs-cli
```

## 2-3. Fargate構築、Dockerのビルド＆デプロイ
まずはDockerのビルドを行います。
※ ビルド及びデプロイはCircleCIでやっています。CircleCIの説明は割愛するので実際のコード見たい方は下記からどうぞ。
https://github.com/tarumzu/fargate-sample-project/blob/master/.circleci/config.yml


```sh:ビルド
#! /bin/bash

# エラーで処理中断
set -ex

# build&deploy共通の環境変数取り込み
source ${BASH_SOURCE%/*}/env.sh

# バージョンタグ $1=CircleCIのハッシュ値
export SHA1=$1
# デプロイ環境 $2=production
export ENV=$2

# bundle install
BUNDLE_CACHE_PATH=~/caches/bundle
bundle install --path=${BUNDLE_CACHE_PATH}

# assets precompile. asset_syncを使って静的リソースをS3に配信してます。
ASSET_SYNC=true RAILS_ENV=${ENV} bundle exec rails assets:precompile assets:clean --trace

# ecrログイン
$(aws ecr get-login --region ap-northeast-1 --no-include-email)

# rails作成
build_rails_image() {
  echo start rails cotaniner build

  if [[ -e ~/caches/docker/rails-dockerimage.tar ]]; then
    docker load -i ~/caches/docker/rails-dockerimage.tar
  fi

  local rails_image_name=${CONTAINER_REGISTRY}/${APP_NAME}-rails:${ENV}_${SHA1}
  docker build --rm=false -t ${rails_image_name} -f ./containers/ecs/rails/Dockerfile .
  mkdir -p ~/caches/docker
  docker save -o ~/caches/docker/rails-dockerimage.tar $(docker history ${rails_image_name} -q | grep -v missing)
  # ビルドしたイメージをECRにpush
  time docker push ${rails_image_name}
  echo end rails container build
}
export -f build_rails_image

build_rails_image
```

次に、ecs-cliコマンドでFargateの構築及びデプロイを行います。
※ FargateはTerraformでも構築できますが、なぜecs-cliコマンドで構築したかというとデプロイ時にFargateをスケールアップ/ダウンできるようにして弾力性[^3]を高めたかったためです。

※ この例では環境変数はCIで設定していますが、2018/12/17にFargate プラットフォームバージョン 1.3でシークレットがサポートされ、環境変数の扱い方が簡単になりました。私としてもこの方法を推奨します。
[【祝！】FargateでもECSにごっつ簡単に環境変数に機密情報を渡せるようになりました！](https://dev.classmethod.jp/cloud/aws/fargarte-secrets/)

[^3]: 需要に合わせて迅速にスケールできる性能のこと。


```sh:デプロイ
#! /bin/bash

# エラーで処理中断
set -ex

# build&deploy共通の環境変数取り込み
source ${BASH_SOURCE%/*}/env.sh

# バージョンタグ
export SHA1=$1
# デプロイ環境
export ENV=$2

if [ -n "$ENV" -a "$ENV" = "production" ]; then
  export RAILS_CPU=512     # .5 vCPU
  export RAILS_MEMORY=1024 # 1024 MB

  # コンテナに渡す環境変数(circleciで設定)
  cat >> ./containers/ecs/${ENV}.env  << FIN
RAILS_ENV=${ENV}
RAILS_MASTER_KEY=${RAILS_MASTER_KEY}
RDS_HOSTNAME=${RDS_HOSTNAME}
RDS_USER=${RDS_USER}
RDS_PASS=${RDS_PASS}
AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
FIN
fi

# Fargateサービス定義＆デプロイ
# コマンドのタイムアウトを30分に設定
# composeはdocker-composeスタイルの指定。
# NOTE: ecs-cli compose service upはコンテナにエラーがあった場合、置き換えを繰り返し無限ループに陥ってしまうためtimeoutコマンドで強制終了させる。errorcodeは123
# target-group-arnはoutput.tfで出力した時のarnを使う
timeout 30m ecs-cli compose \
  --file ./containers/ecs/docker-compose.${ENV}.yml \
  --ecs-params ./containers/ecs/ecs-param.${ENV}.yml \
  --project-name ${APP_NAME}-${ENV} \
  --cluster ${APP_NAME} \
 service up --launch-type FARGATE \
 --container-name rails \
 --container-port 3000 \
 --target-group-arn arn:aws:elasticloadbalancing:ap-northeast-1:xxx:targetgroup/sample-project-tg/yyy \
 --region ap-northeast-1 \
 --timeout 30
```



ここで補足として、ecs-cliコマンドのオプションとFargateのCPU・メモリについて説明しておきます。

|オプション|概要|
|---|---|
|file |docker-compose.ymlを指定|
|ecs-params|ecsのパラーメーターを指定|
|project-name|プロジェクト名|
|cluster |Terraformで作成したECSクラスターを指定|
|container-name|コンテナの名前。今回はrails|
|container-port|コンテナのポート。今回は3000|
|target-group-arn|Terraformで作成したALBのターゲットグループARNを指定|
|region|東京リージョンを指定|
|timeout |コマンドのタイムアウト設定。単位は分。計測してみた感じ、弊社環境では10分ほど掛かっていたので余裕を見て30分に。|

FargateのCPUとメモリの組み合わせには次の通り制限があるのでご注意ください。
<img width="631" alt="スクリーンショット 2018-08-15 19.38.08.png" src="https://qiita-image-store.s3.amazonaws.com/0/106116/f49b0cf7-75ef-6a9c-0ab1-450ecb07373d.png">
https://aws.amazon.com/jp/fargate/pricing/

※ 欠点の一つだった利用料が2019年01月08日に大幅に値下げされ、ますます使いやすくなりました
[Fargate利用料が35%〜50%値下げされました！](https://dev.classmethod.jp/cloud/aws/fargate-lower-price/)

## 2-4. Fargateスケールアウト/インの設定
Fargateのスケールアウト/イン設定については、最初はデプロイ時にecs-cliでやろうと思っていたのですが思いの外、デプロイに時間がかかってしまうため（10分くらい）、断念してWebコンソールで行うようにしました。
（スケールアウト設定変更したい時は急いでいる場合が多いですからね…）

Amazon ECS - クラスター - サービス - Auto Scalingタブから設定できます。ここからのほうが即変更できて楽です。
![Group.png](https://qiita-image-store.s3.amazonaws.com/0/106116/7e47c964-c78c-ddef-e2ab-b20d4ef30be4.png)


# 最後に
いかがでしたでしょうか？
Fargateは、弊社でも運用を始めたばかりなので他に良い方法がまだあるかもしれません。
皆さんの最強をぜひ教えてください:pray:　そして皆で最強の環境を目指しましょう！
ご意見、ご指摘お待ちしております！！