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
Rails
アーキテクチャ
この構成のアーキテクチャに関して図を交えながらいくつかの観点で紹介していきます。
全体概要
CloudFront, ALB, ECS, Auroraを利用したシンプルなアーキテクチャです。
CloudFrontにはWAFを設定しXSSやSQLi等の攻撃をブロックします。
CloudFront, ALBのドメインはRoute53で管理しています。
いずれもMulti-AZレベルの可用性をもつように設計しているため、任意のAZで障害が発生してもサービスは継続できる構成です。
ECSは当然起動タイプFargate一択です。
令和の時代にサーバー管理はしたくないんじゃ。
え? rails c
したい? Execute Command でOK!
ネットワーク
VPC内のSubnetは上記のように用途ごとに細かく分けています。
Security Group
Security Groupも用途ごとに分けた設計になっています。
Security Group間のインバウンドルールのソースに別Security Groupを指定することで、最低限の通信しか許可していません。
また、VPC Endpointを設定することでインターネットを経由せずにS3やECRと通信できるように設定しています。
CDパイプライン
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パイプラインの境界とシンプルさが気に入っています。