アプリケーションスケーリング — 水平展開
シングルサーバーの限界
「アプリサーバーが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の答えだ。
なぜ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..."
endECSタスク定義(完全版)
{
"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: 300INFO
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スティッキーセッションを避ける
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スポットを使う場合はスポット中断通知をハンドリング