mybook

可用性と障害対策 — 落ちないシステム

「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は影響を受けない。

Loading diagram...

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 AuroraParameterGroup

INFO

Auroraのフェイルオーバー時間について

AuroraのAuto Failoverは公式には「通常30秒以内」とされているが、実測では以下が典型的だ:

  • プライマリ障害検知: 10〜15秒
  • レプリカの昇格: 5〜15秒
  • DNSの切り替え: 最大30秒(TTLに依存)

合計で20〜60秒のダウンタイムが発生する。アプリケーション側でDB接続エラーに対してRetryを実装しておくことで、ユーザーへの影響を最小限に抑えられる。mysql2 gem の reconnect: true オプションと組み合わせると効果的だ。


Circuit Breaker パターン — 障害の波及を止める

依存サービスが障害を起こしたとき、リクエストを送り続けるのをやめるパターン。 障害中のサービスへの接続試行はリソースを消費し、障害を悪化させる。

状態遷移の詳細

Loading diagram...
状態動作遷移条件
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
end

Bulkhead パターン — コンパートメント化

船体が一箇所で浸水しても沈まないよう、船はコンパートメント(隔壁)で仕切られている。 同様にスレッドプールやコネクションプールを機能ごとに分離することで、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
end

Timeout / 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
end

WARNING

Retryが危険なケース

冪等でない操作(決済・メール送信など)に無条件でRetryをかけると、二重課金・二重送信が発生する。Retryは Net::ReadTimeout などのネットワーク系エラーに限定し、HTTP 4xx422 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
end

Route53 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>

次の章では、**モニタリングとオブザーバビリティ**を学ぶ。