mybook

グローバルスケーリング — 世界展開する

グローバルへの挑戦

Buzzが日本で100万ユーザーを突破した6ヶ月後、アメリカのインフルエンサーが「This Japanese app is amazing!」とポストした。一夜にして北米ユーザーが10万人増えた。

しかしパフォーマンスは最悪だった。

北米ユーザーからのフィードバック:
  "The app is incredibly slow. 8 seconds to load my feed?"
  "Everything times out. Unusable."
  "Great concept but the latency makes it unworkable."
  "It's fine for a few seconds, then just... freezes."

計測結果(ニューヨーク → 東京サーバー):
  物理遅延(往復):        270ms(光ファイバーの物理的限界)
  DNS解決:                  50ms
  TCP接続:                  90ms
  TLS 1.3 ハンドシェイク:  180ms
  Time to First Byte:      320ms
  ─────────────────────────────
  最初のバイトまで合計:    910ms
  ページ完全読み込み:      7.2秒

  ※日本ユーザーの場合: 200ms

「北米ユーザーはリージョンを変えるだけで解決する」とユイが言った。「でもデータの整合性はどうする?東京のユーザーと北米のユーザーが同じ投稿にいいねしたら、どっちが正確?」

これがグローバルスケーリングの核心問題だ。一貫性(Consistency)とレイテンシ(Latency)はトレードオフの関係にある。

INFO

CAP定理(Consistency・Availability・Partition Tolerance)のうち、分散システムではパーティション耐性は必須なので、一貫性と可用性のどちらを優先するかを選ぶ。グローバルSNSでは「少し古い情報でも速く見せる」(結果整合性)が正解であることが多い。

マルチリージョン戦略の選択

Loading diagram...

BuzzはまずActive-Passiveから始める。読み取りはローカルリージョン、**書き込みはホームリージョン(東京)**に集約する。

リージョン構成(フェーズ1):
  Primary   : ap-northeast-1(東京)   — 書き込み + 読み取り
  Secondary : us-east-1(バージニア) — 読み取りのみ
  Secondary : eu-west-1(アイルランド)— 読み取りのみ
  Secondary : ap-southeast-1(シンガポール) — 読み取りのみ

書き込みフロー:
  北米ユーザーが投稿 → us-east-1の Rails → 東京のAurora Primary
  ※ 書き込みに270msの追加レイテンシ(投稿は頻度が低いので許容範囲)

読み取りフロー:
  北米ユーザーがタイムライン → us-east-1のRails → us-east-1のAurora Read Replica
  ※ レイテンシ: 270ms → 15msに改善!

Aurora Global Database

Aurora Global Databaseは1つのPrimaryと最大5つのSecondaryリージョンを持てる。レプリケーションラグは通常1秒未満だ。

# terraform/aurora_global.tf
 
# グローバルクラスター(コントロールプレーン)
resource "aws_rds_global_cluster" "buzz" {
  global_cluster_identifier = "buzz-global"
  engine                    = "aurora-postgresql"
  engine_version            = "15.4"
  database_name             = "buzz_production"
  storage_encrypted         = true
}
 
# 東京リージョン(Primary)
resource "aws_rds_cluster" "tokyo_primary" {
  provider                  = aws.ap_northeast_1
  cluster_identifier        = "buzz-tokyo-primary"
  global_cluster_identifier = aws_rds_global_cluster.buzz.id
  engine                    = "aurora-postgresql"
  engine_version            = "15.4"
 
  master_username  = var.db_username
  master_password  = var.db_password
 
  db_subnet_group_name   = aws_db_subnet_group.tokyo.id
  vpc_security_group_ids = [aws_security_group.aurora_tokyo.id]
 
  lifecycle {
    ignore_changes = [replication_source_identifier]
  }
}
 
resource "aws_rds_cluster_instance" "tokyo_writer" {
  provider           = aws.ap_northeast_1
  identifier         = "buzz-tokyo-writer"
  cluster_identifier = aws_rds_cluster.tokyo_primary.id
  instance_class     = "db.r7g.xlarge"
  engine             = "aurora-postgresql"
}
 
# バージニアリージョン(Secondary)
resource "aws_rds_cluster" "us_east_secondary" {
  provider                  = aws.us_east_1
  cluster_identifier        = "buzz-us-east-secondary"
  global_cluster_identifier = aws_rds_global_cluster.buzz.id
  engine                    = "aurora-postgresql"
  engine_version            = "15.4"
 
  # Secondaryは master_username/password を指定しない
  db_subnet_group_name   = aws_db_subnet_group.us_east.id
  vpc_security_group_ids = [aws_security_group.aurora_us_east.id]
 
  lifecycle {
    ignore_changes = [replication_source_identifier, master_password, master_username]
  }
}
 
resource "aws_rds_cluster_instance" "us_east_reader" {
  provider           = aws.us_east_1
  count              = 2  # 2台のReaderで負荷分散
  identifier         = "buzz-us-east-reader-${count.index}"
  cluster_identifier = aws_rds_cluster.us_east_secondary.id
  instance_class     = "db.r7g.large"
  engine             = "aurora-postgresql"
}
# config/database.yml(マルチリージョン対応)
production:
  # 書き込み: 常に東京のWriter(全リージョン共通)
  primary:
    adapter: postgresql
    url:     <%= ENV['DATABASE_WRITER_URL'] %>
    pool:    10
 
  # 読み取り: 現在のリージョンのReader
  primary_replica:
    adapter: postgresql
    url:     <%= ENV['DATABASE_READER_URL'] %>
    pool:    10
    replica: true
# config/application.rb(リージョン自動検出)
module Buzz
  class Application < Rails::Application
    config.after_initialize do
      current_region = ENV.fetch('AWS_REGION', 'ap-northeast-1')
 
      # ECSのメタデータからリージョンを取得
      if ENV['ECS_CONTAINER_METADATA_URI_V4']
        metadata = JSON.parse(
          Net::HTTP.get(URI("#{ENV['ECS_CONTAINER_METADATA_URI_V4']}/task"))
        )
        current_region = metadata.dig('TaskARN', /arn:aws:ecs:([^:]+):/).to_a[1] || current_region
      end
 
      reader_url = case current_region
                   when 'us-east-1'
                     ENV['DATABASE_READER_US_URL']
                   when 'eu-west-1'
                     ENV['DATABASE_READER_EU_URL']
                   when 'ap-southeast-1'
                     ENV['DATABASE_READER_SG_URL']
                   else
                     ENV['DATABASE_READER_URL']  # 東京(デフォルト)
                   end
 
      ENV['DATABASE_READER_URL'] = reader_url if reader_url.present?
 
      Rails.logger.info "Region: #{current_region}, Reader: #{reader_url&.split('@')&.last}"
    end
  end
end

Global Accelerator

AWS Global Acceleratorは世界中のAWSエッジロケーションからAWSバックボーンネットワーク経由でリクエストをルーティングする。公共インターネットより遅延が少なく安定している。

Global Acceleratorの効果:
  通常のインターネット経由:
    NYから東京: 270ms(ルーティングが最適でない場合もある)
    パケットロス: 変動あり

  Global Accelerator経由:
    最寄りのAWSエッジからAWSバックボーンで東京へ
    NYから東京: 220ms(AWSの専用回線)
    パケットロス: 極めて少ない
    DDoS保護: 標準搭載

  ただし:
    Buzzのマルチリージョン構成では、
    us-east-1のECSに直接接続する方がずっと速い(15ms)
    → Global Acceleratorは「単一リージョン + DDoS保護」に有効
# terraform/global_accelerator.tf
 
resource "aws_globalaccelerator_accelerator" "buzz" {
  name            = "buzz-global"
  ip_address_type = "IPV4"
  enabled         = true
 
  attributes {
    flow_logs_enabled   = true
    flow_logs_s3_bucket = aws_s3_bucket.logs.bucket
    flow_logs_s3_prefix = "global-accelerator/"
  }
}
 
resource "aws_globalaccelerator_listener" "https" {
  accelerator_arn = aws_globalaccelerator_accelerator.buzz.id
  client_affinity = "NONE"  # スティッキーセッションなし
  protocol        = "TCP"
 
  port_range {
    from_port = 443
    to_port   = 443
  }
}
 
# 東京エンドポイント
resource "aws_globalaccelerator_endpoint_group" "tokyo" {
  listener_arn          = aws_globalaccelerator_listener.https.id
  endpoint_group_region = "ap-northeast-1"
 
  health_check_path                = "/health"
  health_check_port                = 443
  health_check_protocol            = "HTTPS"
  health_check_interval_seconds    = 30
  threshold_count                  = 3
  traffic_dial_percentage          = 100
 
  endpoint_configuration {
    endpoint_id                    = aws_alb.tokyo.arn
    weight                         = 128
    client_ip_preservation_enabled = true
  }
}
 
# 北米エンドポイント
resource "aws_globalaccelerator_endpoint_group" "us_east" {
  listener_arn          = aws_globalaccelerator_listener.https.id
  endpoint_group_region = "us-east-1"
 
  health_check_path             = "/health"
  health_check_port             = 443
  health_check_protocol         = "HTTPS"
  health_check_interval_seconds = 30
  threshold_count               = 3
  traffic_dial_percentage       = 100
 
  endpoint_configuration {
    endpoint_id                    = aws_alb.us_east.arn
    weight                         = 128
    client_ip_preservation_enabled = true
  }
}

Route53 レイテンシーベースルーティング

Global AcceleratorとRoute53を組み合わせてリージョンごとにルーティングする。

# terraform/route53.tf
 
# ヘルスチェック(東京)
resource "aws_route53_health_check" "tokyo" {
  fqdn              = aws_alb.tokyo.dns_name
  port              = 443
  type              = "HTTPS"
  resource_path     = "/health"
  failure_threshold = 3
  request_interval  = 30
}
 
# レイテンシーベースレコード(東京)
resource "aws_route53_record" "api_tokyo" {
  zone_id         = aws_route53_zone.buzz.zone_id
  name            = "api.buzz-app.com"
  type            = "A"
  set_identifier  = "tokyo"
 
  latency_routing_policy {
    region = "ap-northeast-1"
  }
 
  health_check_id = aws_route53_health_check.tokyo.id
 
  alias {
    name                   = aws_alb.tokyo.dns_name
    zone_id                = aws_alb.tokyo.zone_id
    evaluate_target_health = true
  }
}
 
# レイテンシーベースレコード(北米)
resource "aws_route53_record" "api_us_east" {
  zone_id         = aws_route53_zone.buzz.zone_id
  name            = "api.buzz-app.com"
  type            = "A"
  set_identifier  = "us-east-1"
 
  latency_routing_policy {
    region = "us-east-1"
  }
 
  health_check_id = aws_route53_health_check.us_east.id
 
  alias {
    name                   = aws_alb.us_east.dns_name
    zone_id                = aws_alb.us_east.zone_id
    evaluate_target_health = true
  }
}

INFO

Route53のレイテンシーベースルーティングはユーザーを最もレイテンシーが低いリージョンに自動的にルーティングする。ヘルスチェックと組み合わせて、障害時は自動でフェイルオーバーする。応答時間が最短のリージョンに振られるため、地理的に近いリージョンが選ばれることが多い。

データ整合性の管理

グローバル展開での最大の課題はデータ整合性だ。

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # 書き込みは常に東京のPrimaryへ(全リージョン共通)
  def write_to_primary(&block)
    ActiveRecord::Base.connected_to(role: :writing) do
      yield
    end
  end
 
  # 読み取りはローカルリージョンのReplicaへ(15ms程度)
  def read_from_local_replica(&block)
    ActiveRecord::Base.connected_to(role: :reading) do
      yield
    end
  end
end
# レプリケーションラグを考慮した読み取り
class PostsController < ApplicationController
  def show
    post_id = params[:id]
 
    # 直近1分以内に自分で書き込んだデータはPrimaryから読む
    if session[:recent_writes]&.any? { |w| w[:id] == post_id && w[:at] > 1.minute.ago }
      @post = write_to_primary { Post.find(post_id) }
    else
      @post = read_from_local_replica { Post.find(post_id) }
    end
  end
 
  def create
    @post = write_to_primary { Post.create!(post_params) }
 
    # 自分の書き込みを記録(1分間)
    session[:recent_writes] ||= []
    session[:recent_writes] << { id: @post.id.to_s, at: Time.current }
    session[:recent_writes].reject! { |w| w[:at] < 1.minute.ago }
 
    redirect_to @post
  end
 
  def update
    @post = write_to_primary { Post.find(params[:id]) }
    @post.update!(post_params)
 
    session[:recent_writes] ||= []
    session[:recent_writes] << { id: @post.id.to_s, at: Time.current }
 
    redirect_to @post
  end
end
# リージョン情報をヘッダーに含める(デバッグ用)
class ApplicationController < ActionController::Base
  after_action :add_region_header
 
  private
 
  def add_region_header
    response.headers['X-Buzz-Region']     = ENV.fetch('AWS_REGION', 'unknown')
    response.headers['X-Buzz-DB-Role']    = @db_role || 'unknown'
    response.headers['X-Buzz-Cache-Hit']  = @cache_hit ? 'true' : 'false'
  end
end

フェイルオーバーの実装と検証

グローバル展開では障害が必ず起きる。事前に訓練する。

# サーキットブレーカーパターン
# Gemfile: gem 'stoplight'
class DatabaseCircuitBreaker
  def self.with_read_fallback(&block)
    Stoplight("aurora-read-#{ENV.fetch('AWS_REGION', 'unknown')}") { yield }
      .with_fallback do |error|
        Rails.logger.error "Aurora Read Replica unavailable: #{error.message}"
 
        # フォールバック: キャッシュから読む
        nil  # 呼び出し元でnilを処理
      end
      .with_cool_off_time(30)      # 30秒はオープン状態を維持
      .with_threshold(5)           # 5回失敗でオープン
      .run
  end
end
#!/bin/bash
# scripts/failover_test.sh - フェイルオーバーテスト
 
echo "=== Buzz グローバルフェイルオーバーテスト ==="
echo "開始時刻: $(date)"
 
# 東京リージョンのALBのヘルスチェックを意図的に失敗させる
echo "東京ALBの全ターゲットを unhealthy に設定中..."
 
# ECSサービスのタスク数を0にして全タスクを停止
aws ecs update-service \
  --cluster buzz-cluster-tokyo \
  --service buzz-web \
  --desired-count 0 \
  --region ap-northeast-1
 
echo "東京タスクを停止しました。Route53のフェイルオーバーを待機..."
 
# Route53のヘルスチェック間隔: 30秒
# フェイルオーバーまでの時間: 約90秒
sleep 90
 
echo "フェイルオーバー後のレスポンス確認(東京ユーザーが北米に振られることを確認):"
for i in {1..10}; do
  result=$(curl -s -o /dev/null -w "%{http_code} %{time_total}s %header{x-buzz-region}" \
    https://api.buzz-app.com/health)
  echo "  リクエスト $i: $result"
  sleep 2
done
 
echo ""
echo "東京サービスを復旧中..."
aws ecs update-service \
  --cluster buzz-cluster-tokyo \
  --service buzz-web \
  --desired-count 2 \
  --region ap-northeast-1
 
aws ecs wait services-stable \
  --cluster buzz-cluster-tokyo \
  --services buzz-web \
  --region ap-northeast-1
 
echo "東京サービス復旧完了"
echo "=== テスト完了 ==="
# カオスエンジニアリング(ステージング環境で定期実行)
# lib/chaos/chaos_monkey.rb
module Chaos
  class ChaosMonkey
    ENABLED = ENV['CHAOS_ENABLED'] == 'true' && Rails.env.staging?
 
    def self.inject_failure!(probability: 0.005)  # 0.5%の確率
      return unless ENABLED
      return unless rand < probability
 
      case rand(4)
      when 0
        # DBタイムアウトをシミュレート
        raise ActiveRecord::StatementInvalid, "[Chaos] DB timeout"
      when 1
        # ランダムな遅延
        delay = rand(1..5)
        Rails.logger.warn "[Chaos] Injecting #{delay}s delay"
        sleep(delay)
      when 2
        # Redisが落ちたシミュレート
        raise Redis::CannotConnectError, "[Chaos] Redis unavailable"
      when 3
        # 大量メモリアロケーション(OOMに近い状況)
        _ = Array.new(100_000) { '0' * 1024 }  # 約100MB確保
      end
    end
  end
end
 
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :chaos_test
 
  private
 
  def chaos_test
    Chaos::ChaosMonkey.inject_failure!
  rescue Chaos::ChaosMonkey::ChaosError => e
    render json: { error: 'chaos', message: e.message }, status: :service_unavailable
  end
end

グローバル展開の効果

Global Accelerator + マルチリージョン導入後:

各地域のページ読み込み時間:
  日本ユーザー:
    導入前: 200ms(東京)
    導入後: 180ms(東京)
    改善: 10%

  北米ユーザー:
    導入前: 7,200ms(東京から)
    導入後: 180ms(バージニアから)
    改善: 40倍!

  東南アジアユーザー:
    導入前: 800ms(東京から)
    導入後: 120ms(シンガポールから)
    改善: 6.7倍

可用性の改善:
  単一リージョン時: 月間ダウンタイム 約43分(99.9%)
  マルチリージョン後: 月間ダウンタイム 約2.1分(99.995%)
  ※ 一方のリージョンが落ちても他方で継続

グローバルユーザー増加(3ヶ月後):
  日本:         350万ユーザー
  北米:         250万ユーザー
  東南アジア:   200万ユーザー
  欧州:         100万ユーザー
  合計:         900万ユーザー ← 大台が見えてきた
Loading diagram...

マルチリージョンのコスト最適化

マルチリージョン構成の月次コスト:

東京(Primary):
  Aurora Primary: $340
  ECS Fargate (5タスク平均): $435
  ALB: $45
  ElastiCache: $270
  小計: $1,090

北米(Secondary):
  Aurora Secondary: $240  ← Primaryより安い(ストレージ共有)
  ECS Fargate (3タスク平均): $261
  ALB: $45
  ElastiCache: $270
  小計: $816

シンガポール・アイルランド(各Secondary):
  各 $700 程度

Global Accelerator: $75/月 + $0.015/GB転送料
Route53: $1/ゾーン + $0.40/100万クエリ

合計: 約 $3,500/月

コスト削減のヒント:
  □ Aurora の reserved instance(1年予約で30%割引)
  □ ECS の Fargate Spot(最大70%割引、中断許容が必要)
  □ 各リージョンのタスク数を需要に応じてAuto Scaling
  □ CloudFrontでオリジンへのアクセスを削減

「100万ユーザー突破おめでとう」とユイが言った。アキラは笑った——でも思った。「これからどんな判断が待っているだろう?技術的な問題ではなく、ビジネス判断としてのスケーリングの問題が」

最終章では、スケーリングの判断基準——早すぎる最適化の罠、コストとのバランス、技術的負債との向き合い方を学ぶ。


付録: マルチリージョン構成の考慮事項

データ主権(Data Sovereignty):
  □ EUユーザーのデータをEU外に保存してはいけない(GDPR)
  □ 日本のユーザーデータは国内に保存(個人情報保護法)
  □ リージョンごとにデータ分離が必要な場合がある

レプリケーション:
  □ Aurora Global Databaseのレプリカラグを監視(目標: 1秒以内)
  □ ElastiCacheは各リージョンで独立(レプリケーションなし)
  □ DynamoDBは Global Tables で自動レプリケーション

障害対応:
  □ フェイルオーバーの手順を文書化する
  □ 月1回はフェイルオーバーテストを実施する
  □ Auroraのフェイルオーバー(秒単位)とRoute53の切り替え(分単位)は別

コスト:
  □ リージョン間のデータ転送料金($0.02/GB)に注意
  □ CloudFrontがオリジンアクセスを減らすことでコスト削減
  □ 需要の少ないリージョンは小さいインスタンスから始める