可用性と障害対策 — 落ちないシステム
「99.9%」の重み
月曜日の朝9時、EchoTaskがダウンした。データセンターの電源障害だった。
復旧まで2時間。ユーザーからの苦情が300件を超え、企業顧客の一部が解約を検討し始めた。 SLAに謳った「99.9%の可用性」を、ひと月の開始3日目で使い切った。
ハルトは震える手でSlackを閉じ、ホワイトボードに書いた。
「障害は必ず起きる。起きたとき何が起きるかを設計する。」
SLAの計算式と現実のコスト
可用性は数字だが、その差は金額に直結する。
99.0% → 年間87.6時間のダウンタイム(月7.3時間)
99.9% → 年間8.76時間のダウンタイム(月43.8分)
99.99% → 年間52.6分のダウンタイム(月4.4分)
99.999% → 年間5.26分のダウンタイム(月26秒)
EchoTaskのSLAは99.9%だ。月43分のダウンタイムが許容範囲——のはずだった。
可用性 = MTTF / (MTTF + MTTR)
MTTF: Mean Time To Failure(平均故障間隔)
MTTR: Mean Time To Repair(平均復旧時間)
今回の障害では MTTR が2時間だった。月のダウンタイム許容量(43分)の約3倍。
なぜ99.99%ではなく99.9%なのか
99.99%を達成するためのインフラコストを試算した結果、ハルトは現実と向き合った。
| 可用性 | 年間ダウンタイム | 追加インフラコスト(概算) | 主な要件 |
|---|---|---|---|
| 99.9% | 8.76時間 | ベースライン | Multi-AZ、基本的なフェイルオーバー |
| 99.99% | 52.6分 | ×3〜5倍 | Active-Active、リードレプリカ複数台 |
| 99.999% | 5.26分 | ×10〜20倍 | マルチリージョン、特殊なDBレプリケーション |
「99.9%を安定的に達成することが先決だ。」
コンポーネントを増やすと直列の可用性は掛け算で下がる。
Webサーバー: 99.9% × データベース: 99.9% = 99.8%
→ コンポーネントが5つになれば 99.5% に
対策:並列冗長化
1 - (1 - 0.999) × (1 - 0.999) = 99.9999%(どちらか1台でもOKなら)
Multi-AZ構成 — 物理障害への答え
AWSの「アベイラビリティゾーン(AZ)」は独立した物理データセンターだ。1つのAZが電源障害を起こしても、他のAZは影響を受けない。
ALBのクロスゾーン負荷分散設定
ALBはデフォルトでクロスゾーン負荷分散が有効だが、ターゲットグループ側で明示的に設定することが重要だ。
# CloudFormation: ALBとターゲットグループの設定
ApplicationLoadBalancer:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Name: echotask-alb
Scheme: internet-facing
Type: application
Subnets:
- !Ref PublicSubnetAZ1 # ap-northeast-1a
- !Ref PublicSubnetAZ2 # ap-northeast-1c
LoadBalancerAttributes:
# アイドル接続のタイムアウト(秒)
- Key: idle_timeout.timeout_seconds
Value: "60"
# 接続のドレイン(タスク停止前に既存接続を完了させる)
- Key: routing.http.drop_invalid_header_fields.enabled
Value: "true"
TargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
Name: echotask-tg
Port: 3000
Protocol: HTTP
TargetType: ip
VpcId: !Ref VPC
# ヘルスチェック設定
HealthCheckPath: /health
HealthCheckIntervalSeconds: 10
HealthCheckTimeoutSeconds: 5
HealthyThresholdCount: 2
UnhealthyThresholdCount: 3
TargetGroupAttributes:
# クロスゾーン負荷分散を明示的に有効化
- Key: load_balancing.cross_zone.enabled
Value: "true"
# 接続ドレイン時間(タスク停止前に最大30秒待つ)
- Key: deregistration_delay.timeout_seconds
Value: "30"ECSサービスの最小正常パーセント・最大パーセント
デプロイ時に可用性を保つためのキー設定だ。
ECSService:
Type: AWS::ECS::Service
Properties:
Cluster: !Ref ECSCluster
TaskDefinition: !Ref TaskDefinition
DesiredCount: 4
# デプロイ戦略:ローリングアップデート
DeploymentConfiguration:
# 最小正常タスク数:希望数の50%を常に維持
# DesiredCount=4なら、最低2タスクは常に動いている
MinimumHealthyPercent: 50
# 最大起動数:希望数の200%まで一時的に増やせる
# DesiredCount=4なら、最大8タスクまで起動できる
MaximumPercent: 200
DeploymentCircuitBreaker:
Enable: true
Rollback: true # デプロイ失敗時に自動ロールバック
NetworkConfiguration:
AwsvpcConfiguration:
Subnets:
- !Ref PrivateSubnetAZ1
- !Ref PrivateSubnetAZ2
SecurityGroups:
- !Ref ECSSecurityGroup
# Aurora Multi-AZ
AuroraCluster:
Type: AWS::RDS::DBCluster
Properties:
Engine: aurora-mysql
EngineVersion: "8.0.mysql_aurora.3.04.0"
AvailabilityZones:
- ap-northeast-1a
- ap-northeast-1c
# フェイルオーバーの優先度(0が最高)
# レプリカを複数持つ場合、どれをプライマリに昇格するかの優先順位
DBClusterParameterGroupName: !Ref AuroraParameterGroupINFO
Auroraのフェイルオーバー時間について
AuroraのAuto Failoverは公式には「通常30秒以内」とされているが、実測では以下が典型的だ:
- プライマリ障害検知: 10〜15秒
- レプリカの昇格: 5〜15秒
- DNSの切り替え: 最大30秒(TTLに依存)
合計で20〜60秒のダウンタイムが発生する。アプリケーション側でDB接続エラーに対してRetryを実装しておくことで、ユーザーへの影響を最小限に抑えられる。mysql2 gem の reconnect: true オプションと組み合わせると効果的だ。
Circuit Breaker パターン — 障害の波及を止める
依存サービスが障害を起こしたとき、リクエストを送り続けるのをやめるパターン。 障害中のサービスへの接続試行はリソースを消費し、障害を悪化させる。
状態遷移の詳細
| 状態 | 動作 | 遷移条件 |
|---|---|---|
| CLOSED | 通常通りリクエストを転送 | エラー率が閾値(例: 5回失敗)を超えたらOPENへ |
| OPEN | 即座にフォールバックを返す | 一定時間(例: 60秒)後にHALF-OPENへ |
| HALF-OPEN | 少数のリクエストだけ通す | 成功したらCLOSEDへ、失敗したらOPENへ戻る |
Railsによる実装
# app/services/circuit_breaker.rb
class CircuitBreaker
FAILURE_THRESHOLD = 5 # 5回連続失敗でOPEN
TIMEOUT = 60 # 60秒後にHALF-OPENへ
SUCCESS_THRESHOLD = 2 # 2回連続成功でCLOSED
def initialize(name)
@name = name
@state = :closed
@failure_count = 0
@success_count = 0
@last_failure_time = nil
end
def call(&block)
case @state
when :open
if recovery_timeout_elapsed?
@state = :half_open
attempt(&block)
else
raise CircuitOpenError, "Circuit #{@name} is OPEN (retry after #{retry_after}s)"
end
when :closed, :half_open
attempt(&block)
end
end
def state
@state
end
private
def attempt
result = yield
record_success
result
rescue StandardError => e
record_failure
raise
end
def record_success
if @state == :half_open
@success_count += 1
if @success_count >= SUCCESS_THRESHOLD
@state = :closed
@failure_count = 0
@success_count = 0
Rails.logger.info("[CircuitBreaker] #{@name}: CLOSED(回復完了)")
end
else
@failure_count = 0
end
end
def record_failure
@failure_count += 1
@last_failure_time = Time.current
@success_count = 0
if @failure_count >= FAILURE_THRESHOLD && @state == :closed
@state = :open
Rails.logger.warn("[CircuitBreaker] #{@name}: OPEN(#{@failure_count}回連続失敗)")
end
end
def recovery_timeout_elapsed?
@last_failure_time && Time.current - @last_failure_time > TIMEOUT
end
def retry_after
return 0 unless @last_failure_time
[TIMEOUT - (Time.current - @last_failure_time), 0].max.round
end
end
class CircuitOpenError < StandardError; end# 実際の使い方
# app/services/notification_service_client.rb
class NotificationServiceClient
# クラス変数にしてサーバープロセス内で状態を共有する
CIRCUIT = CircuitBreaker.new("notification_service")
def self.send_notification(user_id, type, payload)
CIRCUIT.call do
response = Faraday.post(
"#{ENV.fetch('NOTIFICATION_SERVICE_URL')}/notifications",
{ user_id: user_id, type: type, payload: payload }.to_json,
"Content-Type" => "application/json"
)
raise "Upstream error: #{response.status}" unless response.success?
response.body
end
rescue CircuitOpenError => e
Rails.logger.warn("[Notification] #{e.message} → フォールバック: キューへ積む")
# フォールバック: 後で再送するためにジョブキューへ
FallbackNotificationJob.perform_later(user_id, type, payload)
nil
end
endBulkhead パターン — コンパートメント化
船体が一箇所で浸水しても沈まないよう、船はコンパートメント(隔壁)で仕切られている。 同様にスレッドプールやコネクションプールを機能ごとに分離することで、1つのサービス障害が全体のリソースを枯渇させないようにする。
# config/initializers/connection_pools.rb
# デフォルトDBコネクションは通常のAPIに使う
# 分析クエリが詰まっても、通常APIのコネクションは影響を受けない
# 通常のAPI用コネクション(速い・小さいクエリ)
Rails.application.config.database_configuration["production"]["pool"] = 10
# 分析・バッチ処理用の専用コネクション(遅いクエリ可)
ANALYTICS_DB = ActiveRecord::Base.establish_connection(
Rails.application.config.database_configuration["analytics"]
).pool# app/services/bulkhead_executor.rb
# 外部API呼び出しのスレッドプールを機能ごとに分離する
module BulkheadExecutor
# 通知サービス専用プール(最大5スレッド)
NOTIFICATION_POOL = Concurrent::FixedThreadPool.new(5)
# 決済サービス専用プール(最大3スレッド)
PAYMENT_POOL = Concurrent::FixedThreadPool.new(3)
# 検索サービス専用プール(最大10スレッド)
SEARCH_POOL = Concurrent::FixedThreadPool.new(10)
def self.notify(user_id, message)
future = Concurrent::Future.execute(executor: NOTIFICATION_POOL) do
NotificationServiceClient.send_notification(user_id, "message", message)
end
future.value(timeout: 5) # 5秒タイムアウト
rescue Concurrent::TimeoutError
Rails.logger.warn("[Bulkhead] Notification pool timeout")
nil # タイムアウトでも他の処理には影響しない
end
endTimeout / Retry / Fallback — トリプル防御
この3つは「縦深防御」として組み合わせて使う。1つが突破されても次の層が守る。
# app/concerns/resilient_http.rb
# Timeout → Retry → Fallback の3層防御
module ResilientHttp
MAX_RETRIES = 3
BASE_DELAY = 0.5 # 秒
def with_resilience(fallback: nil, timeout: 5, &block)
with_retry(max_attempts: MAX_RETRIES, timeout: timeout, &block)
rescue Net::ReadTimeout, Net::OpenTimeout, Faraday::TimeoutError => e
Rails.logger.error("[Resilience] タイムアウト後もリトライ上限: #{e.class}")
# Fallback: nilまたは呼び出し側が指定したデフォルト値
fallback.respond_to?(:call) ? fallback.call : fallback
rescue StandardError => e
Rails.logger.error("[Resilience] 回復不能エラー: #{e.message}")
fallback.respond_to?(:call) ? fallback.call : fallback
end
private
def with_retry(max_attempts:, timeout:)
attempts = 0
begin
attempts += 1
# Timeout: 各試行に上限を設ける
Timeout.timeout(timeout) { yield }
rescue Net::ReadTimeout, Net::OpenTimeout, Faraday::TimeoutError,
Errno::ECONNREFUSED, Errno::ECONNRESET => e
if attempts < max_attempts
# Exponential Backoff + Jitter(Thundering Herd防止)
delay = BASE_DELAY * (2**(attempts - 1)) + rand(0.1..0.3)
Rails.logger.warn("[Retry] #{attempts}/#{max_attempts}回目失敗: #{e.class}, #{delay.round(2)}秒後にリトライ")
sleep(delay)
retry
end
raise
end
end
end# 使い方
class ExternalSearchController < ApplicationController
include ResilientHttp
def search
results = with_resilience(
fallback: -> { cached_search_results(params[:q]) },
timeout: 3
) do
SearchServiceClient.query(params[:q])
end
render json: { results: results, cached: results == cached_search_results(params[:q]) }
end
private
def cached_search_results(query)
Rails.cache.read("search:#{query}") || []
end
endWARNING
Retryが危険なケース
冪等でない操作(決済・メール送信など)に無条件でRetryをかけると、二重課金・二重送信が発生する。Retryは Net::ReadTimeout などのネットワーク系エラーに限定し、HTTP 4xx や 422 Unprocessable Entity では絶対にリトライしない。また外部決済API呼び出しにはベキ等キー(idempotency key)を必ず付与する。
自動復旧(Auto Healing)
ECSはタスクが異常終了したら自動的に再起動する。これをActive Health Checkと組み合わせることで、人間が気づく前に復旧できる。
ECSのタスク自動再起動
ECSはDesiredCountを常に維持しようとする。タスクが1台クラッシュしても、ECSが自動的に新しいタスクを起動する。重要なのはヘルスチェックがタイムリーに機能すること。
# app/controllers/health_controller.rb
class HealthController < ApplicationController
# ALBのヘルスチェック用(シンプル・高速)
def ping
render json: { status: "ok" }, status: :ok
end
# 詳細なヘルスチェック(内部監視・アラート用)
def detailed
checks = {
database: check_database,
redis: check_redis,
sidekiq: check_sidekiq
}
all_ok = checks.values.all? { |c| c[:status] == "ok" }
http_code = all_ok ? 200 : 503
render json: {
status: all_ok ? "ok" : "degraded",
checks: checks,
checked_at: Time.current.iso8601
}, status: http_code
end
private
def check_database
latency = measure { ActiveRecord::Base.connection.execute("SELECT 1") }
{ status: "ok", latency_ms: latency }
rescue StandardError => e
{ status: "error", message: e.message }
end
def check_redis
latency = measure { Redis.current.ping }
{ status: "ok", latency_ms: latency }
rescue StandardError => e
{ status: "error", message: e.message }
end
def check_sidekiq
stats = Sidekiq::Stats.new
# キューが溜まりすぎていたら警告
if stats.enqueued > 10_000
{ status: "degraded", enqueued: stats.enqueued }
else
{ status: "ok", enqueued: stats.enqueued, processed: stats.processed }
end
rescue StandardError => e
{ status: "error", message: e.message }
end
def measure
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
end
endRoute53 Health CheckでDNSフェイルオーバー
# CloudFormation: Route53 Health Check + フェイルオーバー
HealthCheck:
Type: AWS::Route53::HealthCheck
Properties:
HealthCheckConfig:
Type: HTTPS
FullyQualifiedDomainName: !GetAtt ApplicationLoadBalancer.DNSName
Port: 443
ResourcePath: /health/ping
RequestInterval: 10 # 10秒ごとにチェック
FailureThreshold: 2 # 2回連続失敗で障害と判定
EnableSNI: true
# プライマリ(通常はこちら)
PrimaryDNS:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneId: !Ref HostedZone
Name: api.echotask.com
Type: A
SetIdentifier: primary
Failover: PRIMARY
HealthCheckId: !Ref HealthCheck
AliasTarget:
HostedZoneId: !GetAtt ApplicationLoadBalancer.CanonicalHostedZoneID
DNSName: !GetAtt ApplicationLoadBalancer.DNSName
# セカンダリ(プライマリが落ちたら自動切り替え)
SecondaryDNS:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneId: !Ref HostedZone
Name: api.echotask.com
Type: A
SetIdentifier: secondary
Failover: SECONDARY
AliasTarget:
HostedZoneId: !GetAtt SecondaryALB.CanonicalHostedZoneID
DNSName: !GetAtt SecondaryALB.DNSName障害対応 Runbook テンプレート
高可用性は技術だけでなく、人がどう動くかにも依存する。障害のたびにゼロから考えていては、MTTRは縮まらない。
# Runbook: EchoTask APIサーバー障害
## 重大度判定
- P1(即時対応): 全ユーザーがAPIにアクセス不能
- P2(1時間以内): 特定機能が使えない、エラー率10%超
- P3(翌営業日): 一部ユーザーへの軽微な影響
## 初動(最初の5分)
### 1. 状況確認
```bash
# ECSタスクの状態確認
aws ecs list-tasks --cluster echotask-prod --desired-status RUNNING
aws ecs describe-tasks --cluster echotask-prod --tasks <task-arn>
# ALBのターゲットヘルス
aws elbv2 describe-target-health --target-group-arn <tg-arn>
# 直近のエラーログ(CloudWatch)
aws logs filter-log-events \
--log-group-name /ecs/echotask-prod \
--start-time $(date -d '5 minutes ago' +%s000) \
--filter-pattern '"ERROR"'2. Aurora の状態確認
# フェイルオーバーが起きているか確認
aws rds describe-db-clusters --db-cluster-identifier echotask-prod \
--query 'DBClusters[0].DBClusterMembers[?IsClusterWriter==`true`]'3. 切り戻し(最終手段)
# 直前の安定リビジョンにロールバック
aws ecs update-service \
--cluster echotask-prod \
--service echotask-api \
--task-definition echotask-api:<前のリビジョン番号>コミュニケーション
- 発生5分以内: Slackの #incidents に報告(テンプレ使用)
- 30分以内に解決しない場合: ステータスページを更新
- 解決後: ポストモーテムドキュメントを24時間以内に作成
ポストモーテム(必須)
- タイムライン(何時何分に何が起きたか)
- 根本原因(なぜ起きたか)
- 即時対処(何をしたか)
- 再発防止策(何をするか・担当者・期限)
---
## EchoTaskの改善結果
ハルトが3ヶ月かけて実装した構成の効果を測定した。
Before: 単一AZ構成
- データセンター障害 → 2時間ダウン
- 月の可用性: 99.72%(月2時間ダウン)
- 障害対応: 毎回アドホック、MTTRは平均90分
After: Multi-AZ + Circuit Breaker + Retry + Auto Healing
- AZ障害 → ALBが自動で別AZへ切り替え(30秒以内)
- Aurora障害 → 自動フェイルオーバー(20〜60秒)
- 依存サービス障害 → Circuit BreakerとFallbackで縮退運転
- ECSタスククラッシュ → 自動再起動(2分以内)
- 月の可用性: 99.97%(月13分のダウンタイム)
- MTTR: 平均8分(Runbookで初動が標準化)
**「障害がゼロになったわけじゃない。でも、障害が起きても大丈夫になった。」**
ハルトはモニタリングダッシュボードを眺めながらそう思った。
次の課題は「システムが動いているか」をどう知るか——だ。障害が起きても気づけなければ、すべての対策は絵に描いた餅になる。
<Callout type="info">
**この章のキーポイント**
- 可用性は「1 − (ダウンタイム/全時間)」。コンポーネントが増えると掛け算で下がる
- 99.9% → 99.99%はコスト3〜5倍。目標設定はビジネスコストと照らして決める
- Auroraのフェイルオーバーは20〜60秒。アプリ側にDB再接続のRetryが必須
- ALBのクロスゾーン設定・ECSのMinimumHealthyPercentを明示的に設定する
- Circuit Breaker(遮断)+ Bulkhead(隔離)+ Retry/Timeout(防御)で縦深防御を構築する
- Runbookを整備してMTTR(復旧時間)を短縮することが可用性向上の本質
</Callout>
次の章では、**モニタリングとオブザーバビリティ**を学ぶ。