73
72

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

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 - ProfessionalAWS Certified Security - Specialty を保有してます)

リポジトリ

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

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

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

Terraform

Rails

アーキテクチャ

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

全体概要

rails-on-ecs-01

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

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

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

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

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

え? rails c したい? Execute Command でOK!

ネットワーク

rails-on-ecs-02

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

Security Group

rails-on-ecs-03

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

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

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

CDパイプライン

rails-on-ecs-04

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

ECRへのプッシュをトリガーにすることで、Railsアプリケーション開発者とインフラエンジニアの自然な責任境界を実現できます。
参考:ECS用のCDパイプラインに対する考察

イメージビルドは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によるTerraformコード静的解析によるセキュリティ指摘

ロギング

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

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

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

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

証明書とDNSレコード

このリポジトリでは、route53.tf の実装のようにRoute53で取得したドメインを利用しています。

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

Route53でドメインを設定できると、acm.tf のようにACMの検証などもスムーズに実装できます。

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

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で以下のカスタムヘッダーを設定します。

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へ流すように設定します。

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編

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

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

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

初期構築時のダミー用ECSタスク定義

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

medpeer/health_check イメージを使うことで指定したパスのヘルスチェックに合格するだけのECSタスク定義が作成できます。

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は以下のように作成します。

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 という変数を定義しています。

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関数 を利用することで、一定ルールでのCIDRの管理を楽に実現できます。

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

values(aws_subnet.public)[*].id

KMSを利用した秘匿情報管理

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

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

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

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

    $ aws kms encrypt --key alias/terraform --plaintext "secret_value" --output text --query CiphertextBlob
    
  • aws_kms_secrets データリソースを作成する。

    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のパスワード等、復号した値を利用したい部分に以下のコードを記述する。

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

Rails側のTips

イメージビルド

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

docker/build-push-action を利用することで、イメージビルドとプッシュを簡潔に記述できます。

Dockerfile の実装のようにマルチステージビルドを利用する実装になっています。

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

      # 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 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パイプラインの境界とシンプルさが気に入っています。

73
72
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
73
72

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?