スケーリングの基礎 — 垂直 vs 水平
「もっと強いサーバーにすればいい」という罠
Tech Crunchの記事が出た翌朝、ハルトはクラウドコンソールを開いた。
「2コア・2GB RAM」の今のVPSを「8コア・32GB RAM」にアップグレードすれば解決する——そう思った。
ボタンをクリック。再起動。5分後、サーバーは復活した。
レスポンスタイムは改善された。しかし、ハルトはすぐに気づいた。これは一時的な解決策に過ぎない。
垂直スケーリング(Scale Up)
垂直スケーリングとは、1台のサーバーをより強力なものにする戦略だ。
Before: 2 vCPU, 4 GB RAM, 100 GB SSD
After: 16 vCPU, 64 GB RAM, 1 TB SSD
垂直スケーリングのメリット
- シンプル: アプリのコードを変更する必要がない
- すぐに効果が出る: 再起動するだけ
- 管理が楽: サーバーが1台なので運用が単純
- セッション共有不要: 状態をローカルに持てる
AWS EC2インスタンスのコスト比較
垂直スケーリングの大きな問題は「コストが指数関数的に増加する」点だ。以下の表はEC2の東京リージョン(ap-northeast-1)における代表的なインスタンスの月額コストだ。
| インスタンスタイプ | vCPU | RAM | 月額(オンデマンド) | 前クラスとの倍率 |
|---|---|---|---|---|
| t3.small | 2 | 2 GB | 約 $15 | — |
| t3.large | 2 | 8 GB | 約 $60 | 4.0× |
| t3.2xlarge | 8 | 32 GB | 約 $240 | 4.0× |
| m6i.4xlarge | 16 | 64 GB | 約 $640 | 2.7× |
| m6i.8xlarge | 32 | 128 GB | 約 $1,280 | 2.0× |
| m6i.16xlarge | 64 | 256 GB | 約 $2,560 | 2.0× |
スペックが2倍になるとき、価格もほぼ2倍になる。しかし実際のスループットが2倍になるわけではない。CPUコアが増えても、データベースのロック競合やI/Oボトルネックがあれば、その恩恵は限定的だ。
WARNING
垂直スケーリングは「時間を買う」策
スペックアップは根本的な解決ではない。ユーザーが増え続ける限り、必ず再び限界が来る。それまでに水平スケーリングの準備をするための時間稼ぎと捉えるべきだ。月額 $15 のサーバー16台($240)と、月額 $640 の m6i.4xlarge 1台では同じ費用でも前者のほうが可用性も高く、スケール余地もある。
垂直スケーリングの限界
# EchoTaskのPuma設定(垂直スケール後)
# config/puma.rb
workers ENV.fetch("WEB_CONCURRENCY") { 4 } # CPUコア数に依存
threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
threads threads_count, threads_count
# 16コアのサーバーでも、workers を増やせばメモリが枯渇する
# 1 worker ≒ 350〜500 MB のメモリを消費する(Rails + Puma の場合)
# 16コア × 5スレッド = 最大80リクエスト同時処理... しかし
# 16 workers × 450 MB = 7.2 GB のメモリが必要- 物理的な上限がある: 世界最大のサーバーにも限界がある
- 単一障害点(SPOF): 1台が壊れたらすべて終わり
- ダウンタイムが発生する: スペックアップには再起動が必要
- コストが非線形に増加する: 2倍のスペックは2倍以上の費用
水平スケーリング(Scale Out)
水平スケーリングとは、同じサーバーを複数台に増やす戦略だ。
水平スケーリングのメリット
- 理論上、無制限にスケールできる: サーバーを追加し続ければいい
- 冗長性がある: 1台が落ちても他が引き受ける
- ダウンタイムなしでスケール: ローリングデプロイが可能
- コスト効率が高い: 小さなサーバーを必要な分だけ使える
水平スケーリングが難しい理由
問題はアプリケーション側にある。EchoTaskのコードを見てみよう。
# 問題のあるコード: ローカルファイルにセッションを保存
# config/initializers/session_store.rb(悪い例)
Rails.application.config.session_store :file_store, "/tmp/sessions"
# ↑ サーバーが複数台になると、サーバー1でログインしても
# サーバー2にリクエストが届いたときに「未ログイン」扱いになる!水平スケーリングにはステートレス設計が必要だ。
ステートフル vs ステートレス — コードで対比する
「ステートレス」という言葉は抽象的に聞こえるが、具体的には「サーバー固有の場所にデータを保存しない」ということだ。
セッション管理
# ===== ステートフル(悪い例)=====
# config/initializers/session_store.rb
Rails.application.config.session_store :file_store, "/tmp/sessions"
# /tmp/sessions はそのサーバーだけに存在する。他のサーバーには届かない
# ===== ステートレス(良い例)=====
# Gemfile
gem 'redis'
gem 'redis-session-store'
# config/initializers/session_store.rb
Rails.application.config.session_store :redis_store,
servers: [ENV['REDIS_URL']],
expire_after: 90.minutes,
key: '_echo_task_session',
secure: Rails.env.production?Redisはすべてのサーバーから参照できるため、どのサーバーがリクエストを受け取ってもセッションを読める。
ファイルアップロード
# ===== ステートフル(悪い例)=====
# app/controllers/tasks_controller.rb
def create
file = params[:attachment]
path = Rails.root.join("public/uploads", file.original_filename)
File.write(path, file.read) # このサーバーのディスクにだけ保存される
# → 他のサーバーからこのファイルは参照できない
end
# ===== ステートレス(良い例)=====
# config/storage.yml
amazon:
service: S3
region: ap-northeast-1
bucket: <%= ENV['S3_BUCKET'] %>
# アクセスキーは IAM Role(EC2/ECS のインスタンスロール)で自動付与
# ハードコードしない
# config/environments/production.rb
config.active_storage.service = :amazon
# app/models/task.rb
class Task < ApplicationRecord
has_one_attached :attachment
# Active Storage が S3 への保存・参照を透過的に処理してくれる
endキャッシュ
# ===== ステートフル(悪い例)=====
# config/environments/production.rb
config.cache_store = :file_store, "/tmp/cache"
# → 各サーバーが独自のキャッシュを持つ。キャッシュの内容が一致しない
# ===== ステートレス(良い例)=====
config.cache_store = :redis_cache_store, {
url: ENV['REDIS_URL'],
expires_in: 1.hour,
namespace: 'echo_task_cache'
}WebSocket(Action Cable)
# ===== ステートフル(悪い例)=====
# config/cable.yml(デフォルト)
production:
adapter: async
# → async アダプターはプロセス内のみ。複数台では通知が届かない
# ===== ステートレス(良い例)=====
# config/cable.yml
production:
adapter: redis
url: <%= ENV['REDIS_URL'] %>
channel_prefix: echo_task_production
# → Redis の Pub/Sub を通じて全サーバーに通知が届くINFO
ステートレス化の4つのポイント
- セッション → Redis(redis-session-store)
- ファイル → S3(Active Storage)
- キャッシュ → Redis(redis_cache_store)
- WebSocket → Redis(Action Cable の redis adapter)
これらをすべて外部化して初めて、アプリケーションは「どのサーバーが受け取っても同じ結果を返せる」状態になる。
Capistrano から ECS/ECR へ — 移行ストーリー
EchoTaskはもともと Capistrano でデプロイしていた。VPS1台に SSH して cap production deploy するだけ——シンプルだった。しかし水平スケーリングを目指すなら、コンテナ化が避けられない。
マルチステージ Dockerfile
# Dockerfile
# ===== ステージ1: ビルド =====
FROM ruby:3.3-slim AS builder
# ビルドに必要なパッケージ(本番イメージには含めない)
RUN apt-get update && 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 set --local without 'development test' \
&& bundle install --jobs 4 --retry 3
# アプリケーションコードをコピー
COPY . .
# アセットプリコンパイル
RUN SECRET_KEY_BASE=dummy RAILS_ENV=production bundle exec rails assets:precompile
# ===== ステージ2: 本番イメージ =====
FROM ruby:3.3-slim AS production
RUN apt-get update && apt-get install -y \
libpq5 \
curl \
&& rm -rf /var/lib/apt/lists/*
# 非rootユーザーで実行(セキュリティ向上)
RUN groupadd -r app && useradd -r -g app app
WORKDIR /app
# ビルドステージから成果物だけコピー
COPY --from=builder /usr/local/bundle /usr/local/bundle
COPY --from=builder /app /app
RUN chown -R app:app /app
USER app
EXPOSE 3000
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]マルチステージビルドにより、本番イメージにビルドツール(gcc、make 等)が残らない。イメージサイズを大幅に削減でき、攻撃対象も減る。
ECR へのプッシュ
# ECR リポジトリを作成(初回のみ)
aws ecr create-repository \
--repository-name echo-task \
--region ap-northeast-1
# ECR にログイン
aws ecr get-login-password --region ap-northeast-1 \
| docker login --username AWS \
--password-stdin 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com
# ビルド & タグ付け
docker build -t echo-task:latest .
docker tag echo-task:latest \
123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/echo-task:latest
# プッシュ
docker push 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/echo-task:latestECS での環境変数管理 — SSM パラメータストア
コンテナ化で悩むのがシークレットの管理だ。データベースのパスワードや API キーを Dockerfile や docker-compose.yml にハードコードしてはいけない。
ECS では AWS SSM パラメータストア を使ってシークレットを安全に注入できる。
パラメータの登録
# SSM に SecureString(暗号化)でパラメータを登録
aws ssm put-parameter \
--name "/echo-task/production/DATABASE_URL" \
--value "postgresql://user:password@rds-endpoint:5432/echo_task_production" \
--type SecureString \
--region ap-northeast-1
aws ssm put-parameter \
--name "/echo-task/production/REDIS_URL" \
--value "redis://elasticache-endpoint:6379/0" \
--type SecureString \
--region ap-northeast-1
aws ssm put-parameter \
--name "/echo-task/production/SECRET_KEY_BASE" \
--value "$(openssl rand -hex 64)" \
--type SecureString \
--region ap-northeast-1
# 登録済みのパラメータ一覧を確認(値は表示しない)
aws ssm describe-parameters \
--filters "Key=Path,Values=/echo-task/production" \
--query "Parameters[*].{Name:Name,Type:Type}" \
--output tableECS タスク定義での参照
{
"family": "echo-task-web",
"taskRoleArn": "arn:aws:iam::123456789012:role/echo-task-task-role",
"executionRoleArn": "arn:aws:iam::123456789012:role/echo-task-execution-role",
"containerDefinitions": [
{
"name": "rails",
"image": "123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/echo-task:latest",
"memory": 512,
"cpu": 256,
"essential": true,
"portMappings": [
{ "containerPort": 3000, "protocol": "tcp" }
],
"environment": [
{ "name": "RAILS_ENV", "value": "production" },
{ "name": "RAILS_LOG_TO_STDOUT", "value": "true" }
],
"secrets": [
{
"name": "DATABASE_URL",
"valueFrom": "arn:aws:ssm:ap-northeast-1:123456789012:parameter/echo-task/production/DATABASE_URL"
},
{
"name": "REDIS_URL",
"valueFrom": "arn:aws:ssm:ap-northeast-1:123456789012:parameter/echo-task/production/REDIS_URL"
},
{
"name": "SECRET_KEY_BASE",
"valueFrom": "arn:aws:ssm:ap-northeast-1:123456789012:parameter/echo-task/production/SECRET_KEY_BASE"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/echo-task",
"awslogs-region": "ap-northeast-1",
"awslogs-stream-prefix": "web"
}
}
}
]
}secrets の valueFrom に SSM パラメータの ARN を指定するだけで、ECS がコンテナ起動時に自動的に値を取得して環境変数として注入してくれる。アプリ側は ENV['DATABASE_URL'] で普通に参照できる。
WARNING
実行ロールに SSM の読み取り権限が必要
ECS タスクが SSM から値を読むには、executionRole に ssm:GetParameters と kms:Decrypt(SecureString の場合)の権限が必要だ。IAM ポリシーの設定を忘れると、コンテナが起動できずにエラーになる。AmazonSSMReadOnlyAccess マネージドポリシーを付与するか、必要なパラメータパスだけに絞った最小権限ポリシーを作成する。
ECS Service Auto Scaling の詳細設定
手動でタスク数を増減させるのは限界がある。Auto Scaling を使えば、負荷に応じてタスク数を自動調整できる。
CloudFormation による Auto Scaling 設定
# cloudformation/autoscaling.yml
# --- スケーラブルターゲット(ECS サービス)---
ECSScalableTarget:
Type: AWS::ApplicationAutoScaling::ScalableTarget
Properties:
ServiceNamespace: ecs
ResourceId: !Sub "service/${ECSCluster}/${ECSService}"
ScalableDimension: ecs:service:DesiredCount
MinCapacity: 2 # 最小2タスク(冗長性を確保)
MaxCapacity: 20 # 最大20タスク
# --- CPU 使用率によるスケーリング ---
CPUScalingPolicy:
Type: AWS::ApplicationAutoScaling::ScalingPolicy
Properties:
PolicyName: echo-task-cpu-scaling
PolicyType: TargetTrackingScaling
ScalingTargetId: !Ref ECSScalableTarget
TargetTrackingScalingPolicyConfiguration:
TargetValue: 60.0 # CPU 60% を維持
ScaleInCooldown: 300 # スケールイン後、300秒は様子見
ScaleOutCooldown: 60 # スケールアウトは60秒で素早く
PredefinedMetricSpecification:
PredefinedMetricType: ECSServiceAverageCPUUtilization
# --- メモリ使用率によるスケーリング ---
MemoryScalingPolicy:
Type: AWS::ApplicationAutoScaling::ScalingPolicy
Properties:
PolicyName: echo-task-memory-scaling
PolicyType: TargetTrackingScaling
ScalingTargetId: !Ref ECSScalableTarget
TargetTrackingScalingPolicyConfiguration:
TargetValue: 70.0 # メモリ 70% を維持
ScaleInCooldown: 300
ScaleOutCooldown: 60
PredefinedMetricSpecification:
PredefinedMetricType: ECSServiceAverageMemoryUtilization
# --- ALB リクエスト数によるスケーリング ---
RequestCountScalingPolicy:
Type: AWS::ApplicationAutoScaling::ScalingPolicy
Properties:
PolicyName: echo-task-request-scaling
PolicyType: TargetTrackingScaling
ScalingTargetId: !Ref ECSScalableTarget
TargetTrackingScalingPolicyConfiguration:
TargetValue: 1000.0 # タスク1台あたり1000リクエスト/分
ScaleInCooldown: 300
ScaleOutCooldown: 30 # リクエスト急増には即座に対応
PredefinedMetricSpecification:
PredefinedMetricType: ALBRequestCountPerTarget
ResourceLabel: !Sub
- "${ALBArn}/${TargetGroupArn}"
- ALBArn: !GetAtt ALB.LoadBalancerFullName
TargetGroupArn: !GetAtt TargetGroup.TargetGroupFullNameカスタムメトリクスによるスケーリング
CPU/メモリ/リクエスト数だけでは不十分な場合、CloudWatch のカスタムメトリクスでスケーリングを制御できる。
# Sidekiq のキュー長を CloudWatch にプッシュする例
# bin/push-sidekiq-metrics.sh(cronやサイドカーコンテナで定期実行)
#!/bin/bash
QUEUE_SIZE=$(redis-cli -u "$REDIS_URL" llen "queue:default")
aws cloudwatch put-metric-data \
--region ap-northeast-1 \
--namespace "EchoTask/Sidekiq" \
--metric-data \
MetricName=QueueDepth,Value="$QUEUE_SIZE",Unit=Count# キュー長によるワーカースケーリング
SidekiqScalingPolicy:
Type: AWS::ApplicationAutoScaling::ScalingPolicy
Properties:
PolicyName: echo-task-sidekiq-scaling
PolicyType: TargetTrackingScaling
ScalingTargetId: !Ref SidekiqScalableTarget
TargetTrackingScalingPolicyConfiguration:
TargetValue: 50.0 # ワーカー1台あたりキュー50件
CustomizedMetricSpecification:
MetricName: QueueDepth
Namespace: EchoTask/Sidekiq
Statistic: Average参考実装: Go でステートレス API サーバーを書く
Go はステートレスな API サーバーの実装によく使われる。Rails と同様の考え方を適用してみよう。
// cmd/api/main.go
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"time"
"github.com/redis/go-redis/v9"
)
// すべての状態はリクエストのコンテキストか外部ストレージに持つ
// サーバー自体はメモリに何も保持しない(ステートレス)
var redisClient *redis.Client
func init() {
redisURL := os.Getenv("REDIS_URL")
opts, err := redis.ParseURL(redisURL)
if err != nil {
log.Fatalf("Redis URL の解析に失敗: %v", err)
}
redisClient = redis.NewClient(opts)
}
// ヘルスチェックエンドポイント
// ALB や ECS のヘルスチェックはここを叩く
func healthHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Redis への疎通確認も含める
if err := redisClient.Ping(ctx).Err(); err != nil {
http.Error(w, "Redis connection failed", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"time": time.Now().UTC().Format(time.RFC3339),
})
}
// タスク取得エンドポイント
// DB クエリ結果を Redis にキャッシュ(ステートレスなキャッシュ戦略)
func tasksHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
cacheKey := "tasks:list"
// キャッシュから取得を試みる
cached, err := redisClient.Get(ctx, cacheKey).Bytes()
if err == nil {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Cache", "HIT")
w.Write(cached)
return
}
// キャッシュミス → DB から取得(省略)
tasks := fetchTasksFromDB()
data, _ := json.Marshal(tasks)
// Redis にキャッシュ(TTL: 60秒)
redisClient.Set(ctx, cacheKey, data, 60*time.Second)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Cache", "MISS")
w.Write(data)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health", healthHandler)
mux.HandleFunc("/api/tasks", tasksHandler)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("サーバー起動: :%s", port)
if err := http.ListenAndServe(":"+port, mux); err != nil {
log.Fatal(err)
}
}
func fetchTasksFromDB() []map[string]interface{} {
// 実装省略(database/sql でクエリ)
return nil
}Go サーバーもポイントは同じだ。redisClient のような接続プールは起動時に初期化して使い回すが、リクエストごとの「状態」はサーバーのメモリには残さない。
INFO
Go と Rails のステートレス設計は同じ原則
言語が違っても原則は変わらない。「リクエストが終わったら、次のリクエストに引き継ぐ情報はすべて外部ストレージ(Redis, RDS, S3)に書く」——これがステートレスの本質だ。
どちらを選ぶべきか?
| 観点 | 垂直スケーリング | 水平スケーリング |
|---|---|---|
| 実装の難しさ | 低い(コード変更不要) | 高い(ステートレス化が必要) |
| スケールの上限 | 物理的な上限あり | 理論上無制限 |
| 可用性 | 低い(SPOF) | 高い(冗長性あり) |
| コスト効率 | 低い(高スペック=割高) | 高い(小さいサーバーを多数) |
| 初期コスト | 低い | 高い(設計・移行コスト) |
| ダウンタイム | あり(再起動が必要) | なし(ローリングデプロイ) |
INFO
実際の判断基準
多くのスタートアップは最初に垂直スケーリングを行い(素早い対応)、アーキテクチャを整備しながら水平スケーリングへ移行する。どちらかを選ぶのではなく、フェーズに応じて使い分けることが重要だ。「今夜対応が必要か?来月対応できるか?」で判断しよう。
ハルトの決断
ハルトは3日かけてEchoTaskをステートレスに改修した。
セッションをRedisへ。ファイルをS3へ。WebSocketのAction CableもRedisアダプターに切り替えた。環境変数はSSMパラメータストアに集約し、Dockerfileをマルチステージビルドに書き直した。
Capistranoのデプロイスクリプトをゴミ箱に入れ、GitHub ActionsからECRにイメージをプッシュするパイプラインを組んだ。ECSサービスのタスク数を3に設定し、ALBを前に置く。Auto Scalingを有効にして、CPUが60%を超えたら自動でタスクが増えるようにした。
デプロイが走るたびにダウンタイムが発生していたあの日々は、もう終わった。
しかし、ここで次の問題が浮上した。
「3台のサーバーへのトラフィックを、どう分散させるのか?」
それが次の章のテーマ——ロードバランシングだ。
INFO
この章のキーポイント
- 垂直スケーリング(Scale Up)は手軽だが上限があり、コストは非線形に増加する
- 水平スケーリング(Scale Out)はステートレス設計が前提条件
- セッション・ファイル・キャッシュ・WebSocketの4つを外部化することでステートレスになる
- SSMパラメータストアでシークレットを安全に管理し、Dockerfileはマルチステージビルドにする
- ECS Auto Scalingを使えばCPU・メモリ・リクエスト数・カスタムメトリクスで自動スケールできる