マルチリージョン運用3年で知った、Route 53だけじゃ足りない理由
東京・シンガポール・ロンドン3地域の本番運用で実際に起きた障害と、ようやく安定した設計パターンをシェア。グローバルトラフィック分散の現実的な課題と対策を解説します。
マルチリージョンネットワーク設計、3年本番運用して学んだ現実的な設計法
マルチリージョン構築、最初の年は本当に地獄だった
うちのチームが本格的にマルチリージョン化したのは2023年の初頭。きっかけは単純でした。ユーザーが東京・シンガポール・ロンドンに散らばってるから、各地域で低レイテンシが欲しい、という要件ですね。当時は楽観的でした。
でも実装が進むにつれて、ただ各リージョンにアプリをデプロイするだけでは全然ダメなことに気づいた。データの一貫性、障害時のフェイルオーバー、コスト管理、監視……あらゆる場面で「ローカルリージョン前提」の設計が通用しないんですよ。
3年経った今、チームで何度も本番障害を踏んで、ようやく「これなら運用できる」という構成に落ち着きました。今日は、そこまでの過程で学んだ実務的な設計パターンをシェアします。
グローバルトラフィック分散の落とし穴──Route 53だけじゃ足りない
最初、うちは Route 53 の地理的近接ルーティング(Geolocation Routing)を使って、各地域のユーザーを近いリージョンに振り分けるだけでした。
# 当初の素朴な設計
Route 53:
東京ユーザー → ap-northeast-1
シンガポール → ap-southeast-1
ロンドン → eu-west-2
レイテンシは確かに改善した。東京のユーザーが us-east-1 にアクセスするより、平均で70ms早くなりましたね。でも数ヶ月経つと、問題が浮き出てきました。
1つ目の問題:ヘルスチェックが甘い
Route 53 のヘルスチェックって、基本的にHTTPステータスと接続確認だけ。アプリケーションレベルの障害(DBが落ちてて500エラー返してるけどHTTP接続は生きてるとか)には対応できません。ある日、ap-southeast-1 のAPサーバーで大量のガベージコレクション停止が発生して、全リクエストが30秒タイムアウト。Route 53 は「生きてる」と判定して振り続けたんですよ。
そこから、アプリケーション層のカスタムヘルスチェック(キャッシュ削除、DB接続テストまで含む)を Route 53 に連携する設計に変更しました。
# 改善版:カスタムヘルスチェックエンドポイント
from fastapi import FastAPI, HTTPException
from sqlalchemy import text
import redis
app = FastAPI()
@app.get("/health/deep")
async def deep_health_check(db_session, redis_client):
try:
# DB接続確認
db_session.execute(text("SELECT 1"))
# Redis接続確認
redis_client.ping()
# キャッシュレイテンシ確認(100ms以上は警告)
import time
start = time.time()
redis_client.get('health_check')
latency = (time.time() - start) * 1000
if latency > 100:
return {"status": "degraded", "latency_ms": latency}, 200
return {"status": "healthy"}, 200
except Exception as e:
raise HTTPException(status_code=503, detail=str(e))
Route 53 で HealthCheckProtocol: HTTPS 使って30秒ごとにこのエンドポイント叩いて、本当に「動いてるか」を判定するようにしたわけです。
2つ目の問題:リージョン間のレイテンシがメチャクチャ予測不可能
東京からシンガポール経由でロンドンのリソースにアクセスする場合、ネットワークパスがくねくね回ることがあります。BGPルートの変動、ISPのピアリング契約、ある日突然別経路に切り替わる……。こういう状況に対応するため、僕たちは各リージョンから各リージョンへの実測レイテンシを5分ごとに計測することで、Route 53 のルーティング戦略を動的に調整する自動化を作りました。
// リージョン間レイテンシ計測の自動化
import * as AWS from 'aws-sdk';
const cloudwatch = new AWS.CloudWatch();
const regions = ['ap-northeast-1', 'ap-southeast-1', 'eu-west-2'];
const LATENCY_THRESHOLD_MS = 150; // これ以上なら警告
async function measureInterRegionLatency() {
const results: Record<string, Record<string, number>> = {};
for (const sourceRegion of regions) {
results[sourceRegion] = {};
for (const targetRegion of regions) {
if (sourceRegion === targetRegion) continue;
// CloudWatch RUM(Real User Monitoring)データから
// 実測レイテンシを取得
const latency = await getActualLatency(sourceRegion, targetRegion);
results[sourceRegion][targetRegion] = latency;
// 閾値超えたら Slack に通知
if (latency > LATENCY_THRESHOLD_MS) {
await notifySlack(
`⚠️ ${sourceRegion} → ${targetRegion}: ${latency}ms (threshold: ${LATENCY_THRESHOLD_MS}ms)`
);
}
}
}
return results;
}
これで「なぜか今日は東京→ロンドンが遅い」みたいな状況を検知できるようになりました。
マルチリージョン環境でのデータ一貫性──「最終的一貫性」の代償
うちのシステムは基本的に、各リージョンで独立したRDSインスタンスを走らせてる。理由は低レイテンシ優先だからです。でも、これが一貫性の悪夢を生み出しました。
たとえば、東京のユーザーがショップの商品を購入したとします。トランザクション完了時点では ap-northeast-1 のDBに書き込まれています。でも、その直後にシンガポール経由でアクセスするユーザーが同じ商品を見ると、「在庫あり」のままの古いデータが返ってくる可能性があるんですよ。
最初、うちはRDS のクロスリージョンレプリケーション(Read Replica)で対応しようとしました。でも Read Replica は基本的に遅延レプリケーション。数秒のタイムラグがあります。それも、ネットワーク状況によって数十秒になることも。
-- RDS Aurora Global Database の設定(Read-onlyレプリカ)
CREATE DATABASE aurora_primary
WITH
Engine=aurora-mysql
Multi-AZ=true;
-- セカンダリーリージョン(Read-only)
CREATE GLOBAL DATABASE aurora_global
FROM aurora_primary
REGION ap-southeast-1, eu-west-2;
-- ここまでで「読み取り専用の複製」ができるけど
-- タイムラグが存在する
そこで、僕たちが採用したのは Event Sourcing + CQRS パターン。全ての状態変化を EventBridge → Kinesis Data Streams で各リージョンにブロードキャスト。各リージョンがローカル状態を保持しつつ、他リージョンの変化を非同期で取り込みます。
# Event Sourcingの実装例
from dataclasses import dataclass
from datetime import datetime
import json
@dataclass
class OrderEvent:
event_id: str
order_id: str
event_type: str # 'OrderCreated', 'OrderShipped', 'OrderCancelled'
data: dict
timestamp: datetime
source_region: str
class OrderEventHandler:
def __init__(self, kinesis_client, region):
self.kinesis = kinesis_client
self.region = region
async def publish_event(self, event: OrderEvent):
"""イベントを全リージョンにブロードキャスト"""
payload = json.dumps({
'event_id': event.event_id,
'order_id': event.order_id,
'event_type': event.event_type,
'data': event.data,
'timestamp': event.timestamp.isoformat(),
'source_region': self.region
})
self.kinesis.put_record(
StreamName='global-order-events',
Data=payload,
PartitionKey=event.order_id # 同じ注文は同じシャードに
)
async def handle_remote_event(self, event: OrderEvent):
"""他リージョンから来たイベントを処理"""
if event.event_type == 'OrderCreated':
# ローカルDBの在庫を減らす
await self.db.update_inventory(
product_id=event.data['product_id'],
quantity_change=-event.data['quantity']
)
elif event.event_type == 'OrderCancelled':
# キャンセルなら在庫を戻す
await self.db.update_inventory(
product_id=event.data['product_id'],
quantity_change=event.data['quantity']
)
この方式のメリットは地味に便利なんですよ。各リージョンが独立して動作でき、レイテンシが低い。イベントの完全な履歴が残るので監査対応も楽。他リージョンの変化は非同期で取り込むから、ネットワーク遅延の影響を最小化できます。
デメリットとしては、最大で数秒間、各リージョンのデータが「ずれる」可能性があることです。在庫確認の瞬間と購入完了の間に、別リージョンでダブル売りが発生する可能性も理論上はある。でも、うちのビジネスでは「完全な一貫性が必須」ではなく、「できるだけ低レイテンシ」が優先なので、このトレードオフを受け入れました。
マルチリージョンのコスト地獄とどう戦うか
3リージョン運用って、想像以上にお金がかかるんです。
NAT Gateway だけで、1リージョンあたり月3〜5万円。ロードバランサー(ALB)が月1〜2万円。RDS が月10万以上。これが3リージョンだから……単純計算で、構築直後から月100万円超えですよ。最初、CFOから「なんでこんなに高いの」と言われて、ようやく本気でコスト最適化に向き合いました。
戦略1:必要なリージョンを本気で見直す
全ユーザーのアクセスパターンを分析したら、実は全トラフィックの90%が東京集中。シンガポール・ロンドンのユーザーは全体の10%でした。「念のため3リージョン」はコストに見合わないんですね。
そこで以下の構成に変更しました:
| リージョン | 役割 | 構成 |
|---|---|---|
| ap-northeast-1(東京) | プライマリ | フルスタック、読み書き両対応 |
| ap-southeast-1(シンガポール) | セカンダリ | 読み取り専用 + 軽量キャッシュレイヤー |
| ロンドン | キャッシュ層 | CloudFront エッジロケーション経由(フルDBなし) |
flowchart TB
subgraph Tokyo["ap-northeast-1 (Tokyo) - Primary"]
alb_tokyo["ALB"]
app_tokyo["EC2/ECS<br/>App Tier"]
rds_tokyo["RDS Aurora<br/>Master"]
redis_tokyo["ElastiCache<br/>Redis"]
alb_tokyo --> app_tokyo
app_tokyo --> rds_tokyo
app_tokyo --> redis_tokyo
end
subgraph Singapore["ap-southeast-1 (Singapore) - Secondary Read"]
alb_sg["ALB"]
app_sg["EC2/ECS<br/>App Tier"]
rds_sg["RDS Aurora<br/>Read Replica"]
redis_sg["ElastiCache<br/>Read Cache"]
alb_sg --> app_sg
app_sg --> rds_sg
app_sg --> redis_sg
end
subgraph CloudFront_Edge["CloudFront Edges<br/>(London, Sydney, etc)"]
cf["CloudFront Dist.<br/>Cache Only"]
end
subgraph Route53_Global["Route 53 Global Routing"]
r53["Route 53<br/>Geolocation Routing"]
end
User1["Tokyo Users"] --> r53
User2["Singapore Users"] --> r53
User3["London Users"] --> r53
r53 -->|Primary<br/>AP-NORTHEAST-1| Tokyo
r53 -->|Secondary Read<br/>AP-SOUTHEAST-1| Singapore
r53 -->|Cache Layer<br/>CloudFront| CloudFront_Edge
rds_tokyo -->|Async Replication| rds_sg
redis_tokyo -->|Cross-Region<br/>Replication| redis_sg
CloudFront_Edge -->|Cache Miss<br/>Origin Fallback| Tokyo
style Tokyo fill:#e1f5ff
style Singapore fill:#fff3e0
style CloudFront_Edge fill:#f3e5f5
これで月35万円まで削減しました。
戦略2:NAT Gateway のコスト削減
NAT Gateway 使うたびに「1GB あたり 0.045USD」請求されます。データ転送量が多いと、これだけで月5万超えるんですよ。
そこで以下の対策をしました:
- CloudFront でコンテンツをキャッシュして、オリジンへの転送量を减少
- VPC エンドポイント(Gateway型)で AWS サービスへの通信を NAT Gateway を経由させない
- Lambda を VPC の外で走らせて、NAT 経由を避ける
# VPC エンドポイントの CloudFormation 定義例
# NAT Gateway を経由させずにS3・DynamoDBにアクセス
gateway_endpoint_s3 = aws.ec2.VpcEndpoint(
"s3-endpoint",
vpc_id=vpc.id,
service_name=f"com.amazonaws.{region}.s3",
route_table_ids=[private_subnet_route_table.id],
tags={"Name": "s3-vpc-endpoint"}
)
gateway_endpoint_dynamodb = aws.ec2.VpcEndpoint(
"dynamodb-endpoint",
vpc_id=vpc.id,
service_name=f"com.amazonaws.{region}.dynamodb",
route_table_ids=[private_subnet_route_table.id],
tags={"Name": "dynamodb-vpc-endpoint"}
)
これだけで月1.5万円程度削減できました。
障害時のフェイルオーバー──本番で何度も失敗した話
2024年の冬、東京リージョンで大規模ネットワーク障害が発生しました。AZの一部が丸ごと落ちて、ALB が反応しない状況ですね。
その時初めて気づいたのが、Route 53 のフェイルオーバーが思ったより遅いということ。ヘルスチェックから判定まで最大60秒のラグが発生します。つまり、東京が完全に死んでから、Route 53 が「あ、死んだ」と認識して他リージョンに切り替えるまで、ユーザーはずっと失敗し続けるんですよ。
そこで、アプリケーション層でのクライアント側フェイルオーバーを実装しました。
// クライアント側のフェイルオーバー実装
import axios, { AxiosError } from 'axios';
const REGIONS = [
{ region: 'ap-northeast-1', endpoint: 'https://api.tokyo.example.com' },
{ region: 'ap-southeast-1', endpoint: 'https://api.singapore.example.com' },
];
const FAILOVER_TIMEOUT_MS = 5000;
const MAX_RETRIES = 2;
class FailoverClient {
private currentRegionIndex = 0;
private failedRegions = new Set<string>();
async request(
path: string,
method: 'GET' | 'POST' = 'GET',
data?: any
): Promise<any> {
let lastError: AxiosError | null = null;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
const region = this.getNextHealthyRegion();
try {
const response = await axios({
url: `${region.endpoint}${path}`,
method,
data,
timeout: FAILOVER_TIMEOUT_MS,
headers: {
'X-Client-Region': 'auto',
'X-Failover-Attempt': attempt.toString()
}
});
// 成功時は失敗フラグをクリア
this.failedRegions.delete(region.region);
return response.data;
} catch (error) {
lastError = error as AxiosError;
console.warn(`Region ${region.region} failed, attempt ${attempt + 1}`);
// 3回連続で失敗したら、そのリージョンを一時的に除外
if (this.getFailureCount(region.region) >= 3) {
this.failedRegions.add(region.region);
}
}
}
throw lastError || new Error('All regions exhausted');
}
private getNextHealthyRegion() {
// 健康なリージョンをラウンドロビン
const healthyRegions = REGIONS.filter(
r => !this.failedRegions.has(r.region)
);
if (healthyRegions.length === 0) {
// 全てダウンの場合は全リージョンをリセット
this.failedRegions.clear();
return REGIONS[0];
}
this.currentRegionIndex =
(this.currentRegionIndex + 1) % healthyRegions.length;
return healthyRegions[this.currentRegionIndex];
}
private getFailureCount(region: string): number {
// Redis に障害カウントを保存して、複数クライアント間で共有
// (詳細は省略)
return 0;
}
}
これで「東京が落ちたら、クライアント側で自動的にシンガポールにリトライ」という動作が、Route 53 の判定を待たずに実現できました。実測で障害検知時間が60秒から3〜5秒に短縮されたんですよ。
もう一つ重要だったのは、マルチリージョンで障害が「連鎖」するパターンへの対策ですね。
たとえば、東京が落ちると、みんなシンガポールに流れ込みます。シンガポールのリソースは東京にレプリケートされてるキャッシュに依存してるので、そこも過負荷。結果的に全リージョンが落ちる……みたいな状況が本当に起きました。
対策として、各リージョンのリソースに対して Circuit Breaker パターンを実装しました。
# Circuit Breaker の実装
from enum import Enum
from datetime import datetime, timedelta
import asyncio
class CircuitState(Enum):
CLOSED = "closed" # 正常
OPEN = "open" # 障害状態
HALF_OPEN = "half_open" # 回復試験中
class CircuitBreaker:
def __init__(
self,
failure_threshold: int = 5,
success_threshold: int = 2,
timeout_seconds: int = 60
):
self.failure_threshold = failure_threshold
self.success_threshold = success_threshold
self.timeout_seconds = timeout_seconds
self.state = CircuitState.CLOSED
self.failure_count = 0
self.success_count = 0
self.last_failure_time = None
async def call(self, func, *args, **kwargs):
"""Circuit Breaker を通して関数を呼び出し"""
if self.state == CircuitState.OPEN:
# OPENなら、タイムアウト後に HALF_OPEN に遷移
if datetime.now() - self.last_failure_time > timedelta(
seconds=self.timeout_seconds
):
self.state = CircuitState.HALF_OPEN
self.success_count = 0
else:
raise Exception(
f"Circuit is OPEN. Retry after "
f"{self.timeout_seconds}s"
)
try:
result = await func(*args, **kwargs)
if self.state == CircuitState.HALF_OPEN:
self.success_count += 1
if self.success_count >= self.success_threshold:
# 回復確認できたら CLOSED に戻す
self.state = CircuitState.CLOSED
self.failure_count = 0
else:
# CLOSED 状態で成功なら失敗カウントをリセット
self.failure_count = 0
return result
except Exception as e:
self.failure_count += 1
self.last_failure_time = datetime.now()
if self.failure_count >= self.failure_threshold:
self.state = CircuitState.OPEN
raise e
# 使用例
storage_circuit_breaker = CircuitBreaker(
failure_threshold=5,
success_threshold=2,
timeout_seconds=30
)
async def get_user_data(user_id: str):
return await storage_circuit_breaker.call(
fetch_from_cache_or_db,
user_id
)
これで、ある1つのリージョンが過負荷になっても、他リージョンへの連鎖障害を防げます。
マルチリージョン監視・ロギング──ログを一元化しないと地獄
3リージョンで本番を走らせると、ログだけで1日 10GB超えます。各リージョンで独立したログを見てると、「どこで何が起きたのか」把握が本当に難しい。
最初はCloudWatch Logs を各リージョンで使ってたんですが、クロスリージョン検索ができなくて……。結局 DataDog か Splunk に統一するしかないという結論に至りました。
うちは最終的に CloudWatch Logs Insights で簡易的な一元化をしつつ、本番障害対応時のみ DataDog で詳細分析する、ハイブリッド運用に落ち着きました。
# CloudWatch Logs の構造化ログ設計
import json
import logging
from datetime import datetime
from pythonjsonlogger import jsonlogger
class MultiRegionLogger:
def __init__(self, region: str):
self.region = region
logger = logging.getLogger(__name__)
logHandler = logging.StreamHandler()
formatter = jsonlogger.JsonFormatter()
logHandler.setFormatter(formatter)
logger.addHandler(logHandler)
self.logger = logger
def log_request(
self,
request_id: str,
method: str,
path: str,
status_code: int,
latency_ms: float,
user_region: str = None
):
"""リクエストログ"""
self.logger.info(
"http_request",
extra={
'region': self.region,
'request_id': request_id,
'method': method,
'path': path,
'status_code': status_code,
'latency_ms': latency_ms,
'user_region': user_region,
'timestamp': datetime.utcnow().isoformat()
}
)
def log_db_query(
self,
query_id: str,
query_type: str, # 'read' or 'write'
duration_ms: float,
is_replica: bool = False
):
"""DB クエリログ"""
self.logger.info(
"db_query",
extra={
'region': self.region,
'query_id': query_id,
'query_type': query_type,
'duration_ms': duration_ms,
'is_replica': is_replica,
'timestamp': datetime.utcnow().isoformat()
}
)
region フィールドを全てのログに含めることで、CloudWatch Insights でリージョン横断検索が楽になります。
-- CloudWatch Logs Insights クエリ例
fields @timestamp, region, status_code, latency_ms
| filter ispresent(region)
| stats avg(latency_ms) as avg_latency_ms by region
| sort avg_latency_ms desc
まとめ
マルチリージョン設計は、思ってるより複雑で、思ったより高い。3年運用してみて、本当に大切だと思ったことをまとめます:
-
Route 53 だけに頼るな → アプリケーション層でのフェイルオーバーが必須。Route 53 の判定遅延(60秒)は致命的です
-
データ一貫性は「完全」を目指すな → Event Sourcing で最終的一貫性を受け入れる。その代わりレイテンシが劇的に改善する
-
コスト最適化は必須 → 3リージョン全部フルスタックで走らせるのは多くの場合オーバーエンジニアリング。アクセスパターンに応じた段階的構成を検討
-
障害時の連鎖を防ぐ → Circuit Breaker とリソース枯渇検知が死活問題。1リージョンの障害が全体に波及しないような設計
-
ログは一元化して当たり前 → CloudWatch Logs Insights + DataDog のハイブリッド運用が現実的。完全統一は費用対効果が薄い
正直、マルチリージョンはシンプルなアーキテクチャより複雑さが増します。その代償として得られる低レイテンシと高可用性が本当に必要なのか、プロジェクト初期段階で厳しく吟味することをお勧めしますね。