mybook

アプリケーションスケーリング — 水平展開

シングルサーバーの限界

「アプリサーバーが1台だと何が起きるか、もう体験してるよな」とアキラはチームに言った。

DBは最適化した。キャッシュも入れた。しかしRailsアプリ自体が1台のEC2インスタンスで動いている限り、そのCPU・メモリが上限になる。トラフィックが増えても増やせない。障害が起きたら全停止する。デプロイのたびにサービスが止まる。

現状のシングルサーバー構成:
  t3.xlarge (1台):
    CPU: 4 vCPU
    Memory: 16 GB
    Pumaワーカー: 4プロセス × 5スレッド = 20同時リクエスト
    最大同時接続: ~200リクエスト(キューを含む)
    コスト: $0.208/時間 = $149/月
    可用性: サーバー停止 = 全停止(SLA 99.5%も難しい)

目標:
  同時接続: 10,000リクエスト(50倍)
  可用性: 99.9%(ダウンタイム月43分以内)
  デプロイ: ダウンタイムなし

答えは水平スケーリングだ。同じサーバーを複数台並べ、ロードバランサーがトラフィックを分散させる。

INFO

水平スケーリングを実現するには、アプリケーションがステートレスでなければならない。セッションをサーバーメモリに保存しているとうまくいかない。まずステートレス化してから台数を増やす。

ECS + ALB の構成

AWS ECS(Elastic Container Service)とALB(Application Load Balancer)を組み合わせた構成がBuzzの答えだ。

Loading diagram...

なぜECS Fargateを選んだか

選択肢の比較:

EC2 Auto Scaling:
  ✓ 細かいカスタマイズが可能
  ✗ EC2の管理が必要(パッチ適用、AMI更新)
  ✗ スケールアウトに数分かかる

ECS Fargate:
  ✓ インフラ管理不要(サーバーレスコンテナ)
  ✓ タスク起動が速い(30-60秒)
  ✓ タスク単位の細かいCPU/メモリ指定
  ✗ EC2より若干コストが高い

EKS(Kubernetes):
  ✓ より柔軟な構成
  ✗ 学習コストが高い
  ✗ 管理が複雑(小〜中規模チームには過剰)

→ Buzzは ECS Fargate を採用(シンプルさを優先)

Dockerfileの最適化

マルチステージビルドでイメージサイズを最小化する。本番イメージが小さいほど、ECSタスクの起動が速くなる。

# Dockerfile
 
# ステージ1: ビルド(本番には不要なツールをここで使う)
FROM ruby:3.3-slim AS builder
 
RUN apt-get update -qq && apt-get install -y \
    build-essential \
    libpq-dev \
    git \
    curl \
    && rm -rf /var/lib/apt/lists/*
 
WORKDIR /app
 
# Gemfile を先にコピーしてキャッシュを活用
COPY Gemfile Gemfile.lock ./
RUN bundle config --global frozen 1 && \
    bundle config set --local without 'development test' && \
    bundle install --jobs 4 --retry 3 && \
    bundle clean --force
 
# アプリケーションコードをコピー
COPY . .
 
# アセットのプリコンパイル
RUN RAILS_ENV=production \
    SECRET_KEY_BASE=placeholder \
    bundle exec rails assets:precompile
 
# ステージ2: 本番(最小サイズ)
FROM ruby:3.3-slim AS production
 
# ランタイムのみ必要なパッケージ
RUN apt-get update -qq && apt-get install -y \
    libpq5 \
    curl \
    && rm -rf /var/lib/apt/lists/* \
    && groupadd --gid 1000 app \
    && useradd --uid 1000 --gid app --shell /bin/bash --create-home app
 
WORKDIR /app
 
# ビルドステージから必要なものだけコピー
COPY --from=builder /usr/local/bundle /usr/local/bundle
COPY --from=builder --chown=app:app /app /app
 
USER app
 
ENV RAILS_ENV=production \
    RAILS_SERVE_STATIC_FILES=false \
    RAILS_LOG_TO_STDOUT=true \
    WEB_CONCURRENCY=2 \
    RAILS_MAX_THREADS=5
 
EXPOSE 3000
 
# ヘルスチェック(ALBが使用する)
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=60s \
  CMD curl -f http://localhost:3000/health || exit 1
 
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
# config/puma.rb(ECS Fargate向け最適化)
# ECS Fargate: 1vCPU / 2GB RAM のタスクを想定
 
# ワーカー数 = CPU数に合わせる
# Fargate 1vCPU の場合: workers 2 が最適
workers ENV.fetch("WEB_CONCURRENCY") { 2 }
 
# スレッド数 = DB接続プールサイズと一致させる
threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
threads threads_count, threads_count
 
# フォーク前にDBとRedis接続を初期化(Copy-on-Write最適化)
preload_app!
 
port        ENV.fetch("PORT")       { 3000 }
environment ENV.fetch("RAILS_ENV")  { "development" }
 
# ECSではグレースフルシャットダウンが重要
# ALBが接続を切り替えてからタスクをシャットダウンするため
on_worker_boot do
  ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
  Sidekiq.redis = { url: ENV['REDIS_URL'] } if defined?(Sidekiq)
end
 
before_fork do
  ActiveRecord::Base.connection_pool.disconnect! if defined?(ActiveRecord)
end
 
# SIGTERM受信時のグレースフルシャットダウン
on_restart do
  Rails.logger.info "Puma restarting gracefully..."
end

ECSタスク定義(完全版)

{
  "family": "buzz-web",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "1024",
  "memory": "2048",
  "executionRoleArn": "arn:aws:iam::123456789:role/ecsTaskExecutionRole",
  "taskRoleArn": "arn:aws:iam::123456789:role/buzzTaskRole",
  "containerDefinitions": [
    {
      "name": "buzz-rails",
      "image": "123456789.dkr.ecr.ap-northeast-1.amazonaws.com/buzz:latest",
      "portMappings": [
        { "containerPort": 3000, "protocol": "tcp" }
      ],
      "environment": [
        { "name": "RAILS_ENV",          "value": "production" },
        { "name": "WEB_CONCURRENCY",    "value": "2" },
        { "name": "RAILS_MAX_THREADS",  "value": "5" },
        { "name": "RAILS_LOG_LEVEL",    "value": "info" }
      ],
      "secrets": [
        { "name": "DATABASE_PRIMARY_URL", "valueFrom": "arn:aws:ssm:ap-northeast-1:123456789:parameter/buzz/prod/DATABASE_PRIMARY_URL" },
        { "name": "DATABASE_REPLICA_URL", "valueFrom": "arn:aws:ssm:ap-northeast-1:123456789:parameter/buzz/prod/DATABASE_REPLICA_URL" },
        { "name": "REDIS_URL",            "valueFrom": "arn:aws:ssm:ap-northeast-1:123456789:parameter/buzz/prod/REDIS_URL" },
        { "name": "SECRET_KEY_BASE",      "valueFrom": "arn:aws:ssm:ap-northeast-1:123456789:parameter/buzz/prod/SECRET_KEY_BASE" }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group":         "/ecs/buzz-web",
          "awslogs-region":        "ap-northeast-1",
          "awslogs-stream-prefix": "ecs",
          "awslogs-datetime-format": "%Y-%m-%dT%H:%M:%S"
        }
      },
      "healthCheck": {
        "command":     ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"],
        "interval":    30,
        "timeout":     5,
        "retries":     3,
        "startPeriod": 60
      },
      "stopTimeout": 30,
      "linuxParameters": {
        "initProcessEnabled": true
      }
    }
  ]
}

Auto Scaling の設定

トラフィックに応じてECSタスクを自動で増減させる。

# CloudFormation テンプレート(Auto Scaling 設定)
 
AutoScalingTarget:
  Type: AWS::ApplicationAutoScaling::ScalableTarget
  Properties:
    MaxCapacity: 50      # 最大50タスク
    MinCapacity: 2       # 最小2タスク(可用性確保)
    ResourceId: !Sub "service/buzz-cluster/buzz-web"
    ScalableDimension: "ecs:service:DesiredCount"
    ServiceNamespace: ecs
    RoleARN: !Sub "arn:aws:iam::${AWS::AccountId}:role/aws-service-role/ecs.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_ECSService"
 
# CPU使用率でスケールアウト
ScalePolicyByCPU:
  Type: AWS::ApplicationAutoScaling::ScalingPolicy
  Properties:
    PolicyName: buzz-cpu-scaling
    PolicyType: TargetTrackingScaling
    ScalingTargetId: !Ref AutoScalingTarget
    TargetTrackingScalingPolicyConfiguration:
      TargetValue: 70.0
      PredefinedMetricSpecification:
        PredefinedMetricType: ECSServiceAverageCPUUtilization
      ScaleOutCooldown: 60    # スケールアウト後60秒は追加スケールしない
      ScaleInCooldown: 300    # スケールイン後300秒は縮小しない
      DisableScaleIn: false
 
# ALBリクエスト数でスケールアウト
ScalePolicyByRequestCount:
  Type: AWS::ApplicationAutoScaling::ScalingPolicy
  Properties:
    PolicyName: buzz-request-scaling
    PolicyType: TargetTrackingScaling
    ScalingTargetId: !Ref AutoScalingTarget
    TargetTrackingScalingPolicyConfiguration:
      TargetValue: 1000.0   # タスクあたり毎分1000リクエストをターゲット
      PredefinedMetricSpecification:
        PredefinedMetricType: ALBRequestCountPerTarget
        ResourceLabel: !Sub "${ALB.LoadBalancerFullName}/${TargetGroup.TargetGroupFullName}"
      ScaleOutCooldown: 60
      ScaleInCooldown: 300

INFO

Auto Scalingは「スケールアウト(台数増加)は素早く、スケールイン(台数削減)はゆっくり」が鉄則。突然のトラフィック増加に備えてScaleOutCooldownを短く(60秒)、不必要なコストを避けるためにScaleInCooldownを長く(300秒)設定する。

ヘルスチェックとグレースフルシャットダウン

複数台構成では、ヘルスチェックとグレースフルシャットダウンが重要だ。ALBはヘルスチェックを使って、正常なタスクだけにリクエストを送る。

# config/routes.rb
Rails.application.routes.draw do
  get '/health',       to: 'health#show'
  get '/health/ready', to: 'health#ready'   # 準備完了チェック
  get '/health/live',  to: 'health#live'    # 生存チェック
end
# app/controllers/health_controller.rb
class HealthController < ApplicationController
  skip_before_action :authenticate_user!
  skip_before_action :verify_authenticity_token
 
  # ALBが使うヘルスチェック(依存サービスを確認)
  def show
    checks = {
      database: database_healthy?,
      redis:    redis_healthy?,
      sidekiq:  sidekiq_healthy?
    }
 
    if checks.values.all?
      render json: {
        status: 'healthy',
        checks: checks,
        version: ENV.fetch('APP_VERSION', 'unknown'),
        timestamp: Time.current.iso8601
      }, status: :ok
    else
      render json: {
        status: 'unhealthy',
        checks: checks,
        timestamp: Time.current.iso8601
      }, status: :service_unavailable
    end
  end
 
  # Kubernetes/ECS の readiness probe 用
  def ready
    # DBに接続できるか(起動直後は接続できない場合がある)
    ActiveRecord::Base.connection.execute('SELECT 1')
    render json: { ready: true }, status: :ok
  rescue => e
    render json: { ready: false, error: e.message }, status: :service_unavailable
  end
 
  # liveness probe 用(アプリが生きているか)
  def live
    render json: { alive: true }, status: :ok
  end
 
  private
 
  def database_healthy?
    ActiveRecord::Base.connection.execute('SELECT 1')
    true
  rescue => e
    Rails.logger.error "DB health check failed: #{e.message}"
    false
  end
 
  def redis_healthy?
    Redis.new(url: ENV['REDIS_URL']).ping == 'PONG'
  rescue => e
    Rails.logger.error "Redis health check failed: #{e.message}"
    false
  end
 
  def sidekiq_healthy?
    stats = Sidekiq::Stats.new
    # キューが詰まっていないかチェック
    stats.enqueued < 100_000
  rescue
    true  # Sidekiqが確認できなくてもアプリは動く
  end
end
# グレースフルシャットダウン
# config/initializers/graceful_shutdown.rb
Rails.application.config.after_initialize do
  # SIGTERM: ECSがタスクを停止するときに送るシグナル
  Signal.trap('TERM') do
    Rails.logger.info "[GracefulShutdown] SIGTERM received. Starting graceful shutdown..."
 
    # ALBがドレインするまで少し待つ
    sleep(ENV.fetch('DRAIN_WAIT_SECONDS', 5).to_i)
 
    Rails.logger.info "[GracefulShutdown] Complete."
    exit 0
  end
end

セッション管理とステートレス化

複数台構成ではサーバーをステートレスにする必要がある。セッションをサーバーメモリに保存すると、リクエストのたびに異なるサーバーに行くとセッションが共有されない。

# config/initializers/session_store.rb
Rails.application.config.session_store(
  :redis_store,
  servers: {
    url:       ENV['REDIS_URL'],
    namespace: 'buzz:session'
  },
  key:          '_buzz_session',
  expire_after: 30.days,
  secure:       Rails.env.production?,
  httponly:     true,
  same_site:    :lax
)
# JWT認証(完全なステートレス化)
# config/initializers/devise.rb
Devise.setup do |config|
  config.jwt do |jwt|
    jwt.secret = ENV['DEVISE_JWT_SECRET_KEY']
 
    jwt.dispatch_requests = [
      ['POST', %r{^/api/v1/auth/sign_in$}]
    ]
 
    jwt.revocation_requests = [
      ['DELETE', %r{^/api/v1/auth/sign_out$}]
    ]
 
    jwt.expiration_time = 24.hours.to_i
  end
end
 
# JWT の失効管理(Redis に保存)
class JwtRevocationService
  REDIS = Redis.new(url: ENV['REDIS_URL'])
 
  def self.revoke(jti:, exp:)
    # トークンの有効期限まで失効リストに入れる
    ttl = exp - Time.current.to_i
    REDIS.setex("jwt:revoked:#{jti}", ttl, '1') if ttl > 0
  end
 
  def self.revoked?(jti)
    REDIS.exists?("jwt:revoked:#{jti}")
  end
end

デプロイの自動化(ゼロダウンタイム)

複数台構成では、ダウンタイムなしのデプロイが必須だ。ECSのローリングアップデートを使う。

# .github/workflows/deploy.yml
name: Deploy to ECS
 
on:
  push:
    branches: [main]
 
env:
  AWS_REGION:  ap-northeast-1
  ECR_REGISTRY: 123456789.dkr.ecr.ap-northeast-1.amazonaws.com
  ECR_REPO:    buzz
  ECS_CLUSTER: buzz-cluster
  ECS_SERVICE: buzz-web
 
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
 
      - name: Run tests
        run: bundle exec rspec --format progress
 
      - name: Security scan
        run: bundle exec brakeman --no-pager
 
  deploy:
    needs: test  # テスト成功後のみデプロイ
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}
 
      - name: Login to ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2
 
      - name: Build, tag, and push image
        env:
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build \
            --target production \
            --build-arg BUILDKIT_INLINE_CACHE=1 \
            --cache-from $ECR_REGISTRY/$ECR_REPO:latest \
            -t $ECR_REGISTRY/$ECR_REPO:$IMAGE_TAG \
            -t $ECR_REGISTRY/$ECR_REPO:latest \
            .
          docker push $ECR_REGISTRY/$ECR_REPO:$IMAGE_TAG
          docker push $ECR_REGISTRY/$ECR_REPO:latest
 
      - name: Run DB migrations
        run: |
          # マイグレーションを専用ECSタスクで実行
          aws ecs run-task \
            --cluster $ECS_CLUSTER \
            --task-definition buzz-migrate \
            --launch-type FARGATE \
            --network-configuration "awsvpcConfiguration={subnets=[${{ secrets.SUBNET_IDS }}],securityGroups=[${{ secrets.SECURITY_GROUP_IDS }}]}" \
            --overrides '{"containerOverrides":[{"name":"buzz-rails","command":["bundle","exec","rails","db:migrate"]}]}'
 
          # マイグレーション完了を待つ
          aws ecs wait tasks-stopped --cluster $ECS_CLUSTER --tasks $TASK_ARN
 
      - name: Deploy to ECS (Rolling Update)
        env:
          IMAGE_TAG: ${{ github.sha }}
        run: |
          # タスク定義の新バージョンを作成
          NEW_TASK_DEF=$(aws ecs describe-task-definition \
            --task-definition buzz-web \
            --query 'taskDefinition' | \
            jq --arg IMAGE "$ECR_REGISTRY/$ECR_REPO:$IMAGE_TAG" \
               '.containerDefinitions[0].image = $IMAGE |
                del(.taskDefinitionArn) |
                del(.revision) |
                del(.status) |
                del(.requiresAttributes) |
                del(.placementConstraints) |
                del(.compatibilities) |
                del(.registeredAt) |
                del(.registeredBy)')
 
          aws ecs register-task-definition \
            --cli-input-json "$NEW_TASK_DEF"
 
          # ECSサービスを新タスク定義で更新(ローリングアップデート)
          aws ecs update-service \
            --cluster $ECS_CLUSTER \
            --service $ECS_SERVICE \
            --task-definition buzz-web \
            --force-new-deployment \
            --deployment-configuration \
              "maximumPercent=200,minimumHealthyPercent=100"
 
          # デプロイ完了まで待機(最大10分)
          aws ecs wait services-stable \
            --cluster $ECS_CLUSTER \
            --services $ECS_SERVICE \
            --cli-read-timeout 600
Loading diagram...

スティッキーセッションを避ける

ALBには「スティッキーセッション」(同じユーザーを同じサーバーに振り続ける)機能があるが、Buzzでは使わない。

スティッキーセッションのデメリット:
  ✗ サーバーが落ちると、そのサーバーに張り付いていたユーザーのセッションが消える
  ✗ 負荷の分散が不均等になる(人気ユーザーが多いサーバーに負荷が集中)
  ✗ スケールインしにくくなる

代わりに:
  ✓ セッションをRedisに保存(どのサーバーでも同じセッションにアクセスできる)
  ✓ JWTトークン認証(サーバーサイドにセッション状態を持たない)

スケーリングの結果

ECS Auto Scaling 導入後(3ヶ月後の実績):
  通常時: 2〜4タスク稼働(夜間や平日昼)
  ピーク時: 自動で15〜25タスクまでスケールアウト
  スケールアウト所要時間: 約90秒(新タスク起動からトラフィック受信まで)
  スケールイン所要時間: 約10分(慎重に縮小)

コスト:
  EC2 固定 m5.xlarge 1台:      $174/月
  ECS Fargate Auto Scaling: 平均5タスク × $87 = $435/月
  ※ ECSは高くなったが、20万ユーザーを安定して捌けるようになった

可用性:
  ECS導入前: 月間ダウンタイム ~43分(99.9%未満)
  ECS導入後: 月間ダウンタイム ~2分(タスク再起動のみ)
同時接続数の変化:
  シングルサーバー:         200 リクエスト/秒
  ECS 5タスク(通常時):  1,000 リクエスト/秒
  ECS 25タスク(ピーク時): 5,000 リクエスト/秒

「20万ユーザーでも安定した」アキラはSlackに投稿した。「でも週末のピーク時に処理が遅延している。画像処理とメール送信をバックグラウンドに移す時期だ」

次章では、Sidekiq と SQS を使った非同期処理でピーク負荷を平準化する方法を学ぶ。


付録: ECS + ALB 構成のベストプラクティス

ネットワーク設計:
  □ ALB はパブリックサブネット
  □ ECS タスクはプライベートサブネット(NAT Gateway経由でインターネットへ)
  □ Aurora, ElastiCache もプライベートサブネット
  □ セキュリティグループで最小権限(ALB → ECS の3000番のみ)

デプロイ:
  □ マイグレーションはデプロイと分離する
  □ 後方互換性のあるマイグレーション(expand-contract パターン)
  □ minimumHealthyPercent: 100(停止ゼロのローリングアップデート)
  □ デプロイ後にロールバック手順を確認しておく

監視:
  □ ECS タスクのCPU/メモリ使用率をCloudWatchで監視
  □ ALBのターゲット応答時間(P99)をアラート
  □ タスク起動失敗をアラート(タスクが起動できないと自動スケールが止まる)
  □ Fargateスポットを使う場合はスポット中断通知をハンドリング