シンプルでセキュアなRails on ECSのTerraformによる実装

## TL;DR
3行で。

**シンプル**で
**セキュア**な
**Ruby on Rails** on **ECS** by **Terraform**
を作りました。


## 目次
- はじめに
- 本構成のテーマ
- リポジトリ
- アーキテクチャ
- Terraform側のTips
- Rails側のTips
- さらなるカスタマイズ

## はじめに

ここ数年、業務やプライベートで様々なパターンのRuby on Rails + AWS ECS構成を構築してきました。

例えば、構築したことがるパターンを要素ごとに列挙するとざっと以下のようになります。

- フロント部分
  - ALBのみ
  - CloudFront -> ALB
  - assetsのみCloudFront
- ECS
  - 起動タイプEC2
  - 起動タイプFargate
  - 2種の混在
- デプロイパイプライン
  - 全部GitHub Actions
  - Capistrano + ecs-cli
  - CodePipeline + CodeBuild
  - CodePipeline + CodeBuild + CodeDeploy

他にも、
「本番環境/テスト環境でAWSアカウントが分離しているかどうか」、
「ログにfirelens使うかCloudWatch Logs使うか」、
「ECSタスクとIAM Roleをどう対応させるか」、
「ECRへの接続にVPC Endpointを使うか」等、
細かい点を上げるといろんな構成があります。

これらの組み合わせは無限大ですが、いくつも構築してみて、やっと**しっくり来る組み合わせ**に行き着いたので、その構成とTerraformによる実装を紹介したいと思います。


## 本構成のテーマ
テーマは **シンプル & セキュア** です。

しっくり来る組み合わせに行き着いたと言っても、サービスの要件次第では構成は微妙に変えていかなければいけません。
なので、要件に合わせて柔軟にカスタマイズできる構成であることと、構築にかかるコストをなるべく小さくしたいことから、**シンプルな**構成であることを目指して検討しました。

また、せっかくきちんと設計し実装を公開する以上、「○○やってみた」レベルの実装ではなく、実際に本番環境での運用に耐えうるレベルの設計・実装を目指しました。

具体的には、Well-Architected FrameworkやSecurity Hubで紹介されるベストプラクティスに則り設計しています。（いちおう筆者は `AWS Certified Solutions Architect - Professional` と `AWS Certified Security - Specialty` を保有してます）


## リポジトリ
実装したものは以下のリポジトリで公開しています。
リポジトリはRails側とTerraform側(インフラ)で分けています。

これはおそらく最も一般的なリポジトリ分割パターンかなと思います。
担当エンジニア、リリースサイクル、CIなどの観点から自然とこの粒度のリポジトリになるでしょう。

余談：
ぶっちゃけ、この実装があればフリーランスの案件普通にこなせるんじゃないかなと思ってる。
よく「サービスとCI/CDパイプラインの構築」という案件目にするので。

### Terraform
<a href="https://github.com/reireias/rails-on-ecs-terraform"><img src="https://github-link-card.s3.ap-northeast-1.amazonaws.com/reireias/rails-on-ecs-terraform.png" width="460px"></a>

### Rails
<a href="https://github.com/reireias/rails-on-ecs"><img src="https://github-link-card.s3.ap-northeast-1.amazonaws.com/reireias/rails-on-ecs.png" width="460px"></a>

## アーキテクチャ

この構成のアーキテクチャに関して図を交えながらいくつかの観点で紹介していきます。

### 全体概要
![rails-on-ecs-01](https://user-images.githubusercontent.com/24800246/114057770-d7225080-98cd-11eb-8f93-507f6080ccd3.png)

CloudFront, ALB, ECS, Auroraを利用したシンプルなアーキテクチャです。

CloudFrontにはWAFを設定しXSSやSQLi等の攻撃をブロックします。

CloudFront, ALBのドメインはRoute53で管理しています。

いずれもMulti-AZレベルの可用性をもつように設計しているため、任意のAZで障害が発生してもサービスは継続できる構成です。

ECSは当然**起動タイプFargate一択**です。
令和の時代にサーバー管理はしたくないんじゃ。

え？ `rails c` したい？ [Execute Command](https://aws.amazon.com/jp/about-aws/whats-new/2021/03/amazon-ecs-now-allows-you-to-execute-commands-in-a-container-running-on-amazon-ec2-or-aws-fargate/) でOK！


### ネットワーク
![rails-on-ecs-02](https://user-images.githubusercontent.com/24800246/114059568-7562e600-98cf-11eb-9b85-b5e288dc0b93.png)

VPC内のSubnetは上記のように用途ごとに細かく分けています。

### Security Group
![rails-on-ecs-03](https://user-images.githubusercontent.com/24800246/114063905-0fc52880-98d4-11eb-9159-d9239eb9724e.png)

Security Groupも用途ごとに分けた設計になっています。

Security Group間のインバウンドルールのソースに別Security Groupを指定することで、最低限の通信しか許可していません。

また、VPC Endpointを設定することでインターネットを経由せずにS3やECRと通信できるように設定しています。

### CDパイプライン
![rails-on-ecs-04](https://user-images.githubusercontent.com/24800246/114064398-91b55180-98d4-11eb-995e-33c424d51688.png)

CDパイプラインは上記のようにECRへのイメージプッシュをトリガーに、CodePipelineで実行されます。

ECRへのプッシュをトリガーにすることで、Railsアプリケーション開発者とインフラエンジニアの自然な責任境界を実現できます。
参考：[ECS用のCDパイプラインに対する考察](https://zenn.dev/reireias/articles/8e987af2762eaa)

イメージビルドはGitHub Actions側で行います。

`rails db:migrate` をCodeBuildで実行します。

デプロイはCodeDeployを採用しており、全トラフィックを一括で新バージョンに切り替えることで、デプロイ時にassets参照が404エラーにならないように配慮しています。

## Terraform側のTips

### モジュール利用
これはTipsというより設計方針です。
Terraformでは公開されているものや自作のモジュールを使うことで実装を簡潔にできたりします。

特にAWS公式が提供しているとても便利で、短い記述でAWSのリソースを作成することができます。
https://registry.terraform.io/namespaces/terraform-aws-modules

ですが、モジュール側が対応していないため、TerraformやProviderのバージョンを更新できないといった場面に出くわすこともあります。
また、モジュールでは制御できないが、普通にaws providerを使った実装の場合は設定可能な属性がある場合もあります。
aws providerの最新バージョンで追加されたリソースをいち早く使いたいケースなんかもあります。

ですので、多少工数はかかりますが、メンテナンスコスト等を加味してAWS公式モジュール等は利用せずに、aws providerをそのまま使った実装としています。
もちろん簡潔な記述や細かい知識不要で構築できる公式モジュールにも大きなメリットはあるので、「モジュール利用は駄目！」などと言うつもりはなく、使い分けだと思います。


### セキュリティ対策
Well-Architected FrameworkやSecurity Hubによるベストプラクティスなどに従い、下記のセキュリティ対策を実施しています。

- CloudFrontを前段に配置することによるDDoS対策
- WAFのマネージドルールによるXSSやSQLi等の攻撃のブロック
- RDSの暗号化設定
- SSMパラメータストアを利用した秘匿情報の管理
- ECRによる脆弱性スキャン
- [tfsec](https://tfsec.dev/)によるTerraformコード静的解析によるセキュリティ指摘

### ロギング
以下のログ設定を行っています。

可能な限りログは取得するべきです。(このリポジトリではWAFのログは取得していませんが)

- [CloudFront](https://github.com/reireias/rails-on-ecs-terraform/blob/main/cloudfront.tf)
- [ALB](https://github.com/reireias/rails-on-ecs-terraform/blob/main/alb.tf)
- [VPC Flow Log](https://github.com/reireias/rails-on-ecs-terraform/blob/main/vpc.tf)
- [S3 Access Log](https://github.com/reireias/rails-on-ecs-terraform/blob/main/s3_codepipeline.tf)

このリポジトリではあくまでRails on ECS部分の構成がメインなので、AWSアカウントを作成した際に設定すべき、下記のセキュリティ設定は含んでいません。

- GuardDuty
- CloudTrail
- IAMパスワードポリシー
- EBSのデフォルト暗号化

### 証明書とDNSレコード
このリポジトリでは、[route53.tf](https://github.com/reireias/rails-on-ecs-terraform/blob/main/route53.tf) の実装のようにRoute53で取得したドメインを利用しています。

もし、ドメインを別の方法で取得している場合でも、Route53から設定できるように移譲するのが良いでしょう。

Route53でドメインを設定できると、[acm.tf](https://github.com/reireias/rails-on-ecs-terraform/blob/main/acm.tf) のようにACMの検証などもスムーズに実装できます。

ACM証明書とその検証DNSレコードの実装:

```terraform
resource "aws_acm_certificate" "main" {
  domain_name               = local.domain
  subject_alternative_names = ["*.${local.domain}"]
  validation_method         = "DNS"
}

resource "aws_acm_certificate_validation" "main" {
  certificate_arn         = aws_acm_certificate.main.arn
  validation_record_fqdns = [for record in aws_route53_record.acm : record.fqdn]
}
```

### カスタムヘッダーによるALBへのリクエストをCloudFront限定にする
CloudFrontで以下のカスタムヘッダーを設定します。

```terraform
resource "aws_cloudfront_distribution" "main" {

  origin {
    custom_header {
      name  = "x-pre-shared-key"
      value = data.aws_kms_secrets.secrets.plaintext["cloudfront_shared_key"]
    }
    # ...
  }
  # ...
}
```

ALBのリスナールールで、上記ヘッダーが付与されたリクエストのみ、ECSへ流すように設定します。

```terraform
resource "aws_lb_listener_rule" "app_from_cloudfront" {
  listener_arn = aws_lb_listener.app.arn
  priority     = 100

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app["blue"].arn
  }

  condition {
    http_header {
      http_header_name = "x-pre-shared-key"
      values           = [data.aws_kms_secrets.secrets.plaintext["cloudfront_shared_key"]]
    }
  }

  # NOTE: Ignore target group switch
  lifecycle {
    ignore_changes = [action]
  }
}
```

このように設定することで、CloudFrontを経由せずに直接ALBへリクエストする経路を塞ぐことができます。

### CodeDeployによりローリングアップデートを回避する
複数台のサーバーにRailsをローリングアップデートでデプロイすると、一部のjsやcss等のassets系ファイルの参照が404エラーになる瞬間が発生します。
参考： [メドピア AWS勉強会 ECS編](https://speakerdeck.com/reireias/medpeer-aws-seminar-ecs?slide=55)

これは、新サーバーからhtmlを取得し、その中のjsを旧サーバーへ取得しにいくと発生します。

この問題を回避する方法は様々ありますが、ここではシンプルにCodeDeployを利用しローリングアップデートを実施しないことで回避しています。

`deployment_config_name = "CodeDeployDefault.ECSAllAtOnce"` を指定することで、全リクエストを同時に新バージョンへ切り替えることができます。

### 初期構築時のダミー用ECSタスク定義
プロダクトの構築初期段階など、まだRailsアプリが用意できていない場合に最低限ヘルスチェックにだけ合格する軽量イメージとそれを利用したECSタスク定義が欲しくなります。

[medpeer/health_check](https://hub.docker.com/r/medpeer/health_check) イメージを使うことで指定したパスのヘルスチェックに合格するだけのECSタスク定義が作成できます。

```terraform
resource "aws_ecs_task_definition" "app" {
  # ...

  # NOTE: Dummy containers for initial.
  container_definitions = <<CONTAINERS
[
  {
    "name": "web",
    "image": "medpeer/health_check:latest",
    "portMappings": [
      {
        "hostPort": 3000,
        "containerPort": 3000
      }
    ],
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "${aws_cloudwatch_log_group.app.name}",
        "awslogs-region": "${local.region}",
        "awslogs-stream-prefix": "web"
      }
    },
    "environment": [
      {
        "name": "NGINX_PORT",
        "value": "3000"
      },
      {
        "name": "HEALTH_CHECK_PATH",
        "value": "/health_checks"
      }
    ]
  }
]
CONTAINERS
}
```

### Availability Zoneの繰り返しにはfor_eachを使う
将来、AZが追加されるケースを考慮して、Subnet等AZの数だけ作成するリソースは `for_each` を利用します。

例えばpublic subnetは以下のように作成します。

```terraform
resource "aws_subnet" "public" {
  for_each = local.availability_zones

  vpc_id            = aws_vpc.main.id
  availability_zone = each.key
  cidr_block        = cidrsubnet(local.vpc_cidr, 8, local.az_conf[each.key].index)

  tags = {
    Name = "${local.name}-public-${local.az_conf[each.key].short_name}"
  }
}
```

ここでは、以下のように `az_conf` という変数を定義しています。

```terraform
locals {
  az_conf = {
    "ap-northeast-1a" = {
      index      = 1
      short_name = "1a"
    }
    "ap-northeast-1c" = {
      index      = 2
      short_name = "1c"
    }
    "ap-northeast-1d" = {
      index      = 3
      short_name = "1d"
    }
  }
}
```

[cidrsubnet関数](https://www.terraform.io/docs/language/functions/cidrsubnet.html) を利用することで、一定ルールでのCIDRの管理を楽に実現できます。

Subnetのidのリストが欲しい場合は以下のコードで生成できます。

```terraform
values(aws_subnet.public)[*].id
```

### KMSを利用した秘匿情報管理
秘匿情報はKMSで暗号化し、このリポジトリに含めることでシンプルに管理できます。

`aws_kms_secrets` を利用することで、複合はTerraformがplan時apply時に行います。

利用方法は以下のとおりです。

- KMSを使い秘匿情報を暗号化する。

  ```console
  $ aws kms encrypt --key alias/terraform --plaintext "secret_value" --output text --query CiphertextBlob
  ```

- `aws_kms_secrets` データリソースを作成する。

  ```terraform
  locals {
    secrets = {
      foo = "encrypted_value"
    }
  }

  # NOTE: register all secrets
  data "aws_kms_secrets" "secrets" {
    dynamic "secret" {
      for_each = local.secrets

      content {
        name    = secret.key
        payload = secret.value
      }
    }
  }
  ```

- SSMパラメータストアやDBのパスワード等、復号した値を利用したい部分に以下のコードを記述する。

  ```terraform
  data.aws_kms_secrets.secrets.plaintext["foo"] # set decrypted value
  ```

## Rails側のTips
### イメージビルド
GitHub Actionsでイメージビルドを行います。

[docker/build-push-action](https://github.com/docker/build-push-action) を利用することで、イメージビルドとプッシュを簡潔に記述できます。

[Dockerfile](https://github.com/reireias/rails-on-ecs/blob/main/Dockerfile) の実装のようにマルチステージビルドを利用する実装になっています。

最終的なイメージ以外にbuilderステージもECRをプッシュし、 `--cache-from` オプションに指定することで、次のビルド時にキャッシュとして利用できるようにしています。

```yml
      # cache-from に前回の builder ステージのイメージを指定
      - name: Build and Push
        uses: docker/build-push-action@v2
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        with:
          push: true
          cache-from: |
            type=registry,ref=${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:builder
          tags: |
            ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
            ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:latest

      # 今回のbuilderイメージを保存
      - name: Save builder cache
        uses: docker/build-push-action@v2
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        with:
          target: builder
          push: true
          build-args: |
            BUILDKIT_INLINE_CACHE=1
          tags: |
            ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:builder
```

### ヘルスチェック
[okcomputer](https://github.com/sportngin/okcomputer) gem を利用することで、ヘルスチェックをシンプルに設定できます。

`/health_checks/all` にアクセスすることで、追加で指定したDB等へのヘルスチェックも行えるため疎通確認や障害の調査などで非常に便利です。

### 環境変数から読み込む
以下は環境変数から読み込むようにすることで、特定のインフラとの密結合を避けています。

- データベースの接続情報: `DATABASE_URL`
- データベースの接続情報(リードレプリカ): `READER_DATABASE_URL`

また、秘匿情報をセキュアに管理するため、 `RAILS_MASTER_KEY` も環境変数で受け取るようにしています。

## さらなるカスタマイズ
今回はさまざまな構成のベースにできるようなシンプルなアーキテクチャとしました。
要件次第ではいろいろとカスタマイズが必要でしょう。

例えば以下のようなカスタマイズはよく要求としてあがってきます。

- RedisとSidekiqを利用したWorkerサービスの追加
- GitHub Deployment APIを用いたデプロイの管理
- CodeDeployによるカナリアリリースの導入
- イメージ軽量化によるビルド・デプロイ時間とスケール速度の改善
- RailsのレスポンスをCloudFrontでキャッシュして高速化
- フロントエンドをSPA構成で配信し、RailsはAPIに徹する

どの構成も基本は今回紹介したアーキテクチャをベースに実現できるはずです。
（もちろん今回のアーキテクチャ以外をベースにしたほうが構築が楽なケースも当然あります）

## さいごに
シンプルでセキュアなRails on ECS構成、参考になりましたでしょうか？

特に個人的には、この構成の**CDパイプラインの境界とシンプルさ**が気に入っています。