mybook

インシデントレスポンス — 顧客の本番環境で火を消す

午前2時、スマホが震えた

ソウタの枕元でスマホが振動した。午前2時13分。PagerDuty からのアラートだった。

[SEV-1] Nextera Financial - Arclight AI Platform - API Response Time > 30s, Error Rate 47%

ソウタは半年前に SaaS スタートアップ TechNova から、AI プラットフォーム企業 Arclight AI の FDE チームに転職したばかりだった。Nextera Financial は金融業界の大手顧客で、Arclight AI の推論 API を本番環境のリスク分析システムに組み込んでいる。

「落ち着け。まず状況を確認するんだ」

自分に言い聞かせながら、ソウタはラップトップを開いた。Slack の #incident-nextera チャンネルには既にメッセージが流れていた。

カイ (2:14 AM): @ソウタ 起きてるか。Nextera の本番が燃えてる。君が一番 Nextera の構成を知ってるから、一緒に対応しよう。War Room に入ってくれ。

カイは Arclight AI の FDE チームリードだ。10年以上のインシデント対応経験を持ち、どんな障害でも冷静さを失わない。ソウタにとって初めての本番 SEV-1 だった。

WARNING

FDE にとってインシデント対応は「自社プロダクトの障害」ではなく「顧客のビジネスの障害」だ。顧客の売上、信頼、エンドユーザーへの影響を常に意識する必要がある。


FDE がインシデントで発揮する固有の強み

ソウタが War Room の Zoom に接続すると、カイが既にタイムラインを整理していた。

「ソウタ、FDE がインシデント対応で求められる理由を覚えてるか?」

カイは障害の真っ只中でも、教えることを忘れなかった。

プロダクト知識の深さ

FDE はコードベースを熟知している。API のどのエンドポイントが何をしているか、内部のキューイング構造、レート制限の仕組み——通常のサポートエンジニアでは持ち得ない深度の知識がある。

ベンダーエンジニアリングへの直接アクセス

FDE はコアエンジニアリングチームに即座にエスカレーションできる。チケットを切って待つのではなく、Slack で直接コアチームの担当者を呼び出せる。

顧客コンテキストの理解

ソウタは Nextera Financial の環境構成を知っていた。推論 API をどのように呼び出しているか、どのモデルバージョンを使っているか、カスタム設定が何か——この知識がデバッグの速度を決定的に変える。

# ソウタは Nextera の構成を頭に入れていた
# config/customers/nextera_financial.yml に相当する知識
nextera_config = {
  api_version: "v2.3",
  model: "risk-analysis-v4",
  max_concurrent_requests: 500,
  timeout_seconds: 10,
  custom_preprocessing: true,  # 独自の前処理パイプライン
  region: "ap-northeast-1",
  dedicated_inference_pool: "nextera-pool-01"
}

INFO

FDE の価値は「技術力 x 顧客コンテキスト」の掛け算にある。どちらか一方だけでは、インシデント対応の速度と精度は出せない。


重大度(Severity)分類

「まず重大度を確認しよう」とカイが言った。「今回は SEV-1 だ。全ユーザーに影響が出ている。」

インシデントの重大度は対応のスピードとリソース投入量を決定する。Arclight AI では以下の4段階で分類していた。

重大度定義初動時間エスカレーション対応体制
SEV-1サービス全停止、全ユーザー影響、売上損失15分以内VP + コアチーム即時War Room 常設
SEV-2主要機能停止、一部ユーザー影響30分以内マネージャー通知担当チーム集合
SEV-3軽微な機能障害、回避策あり4時間以内チームリード判断担当者対応
SEV-4表示崩れ等、業務影響なし翌営業日不要通常チケット
# app/models/incident.rb
class Incident < ApplicationRecord
  enum :severity, {
    sev1: 1, # 全面停止
    sev2: 2, # 主要機能停止
    sev3: 3, # 軽微な障害
    sev4: 4  # 外観・軽微
  }
 
  enum :status, {
    detected: 0,
    acknowledged: 1,
    investigating: 2,
    identified: 3,
    mitigating: 4,
    resolved: 5,
    postmortem: 6
  }
 
  belongs_to :customer
  belongs_to :incident_commander, class_name: "User"
  has_many :timeline_entries, dependent: :destroy
  has_many :action_items, dependent: :destroy
 
  validates :title, :severity, :customer, presence: true
 
  def sla_deadline
    base = detected_at || created_at
    case severity
    when "sev1" then base + 15.minutes
    when "sev2" then base + 30.minutes
    when "sev3" then base + 4.hours
    when "sev4" then base + 24.hours
    end
  end
 
  def sla_breached?
    acknowledged_at.nil? && Time.current > sla_deadline
  end
end

WARNING

SEV-1 の SLA は 15 分。寝ぼけている暇はない。PagerDuty のアラートが鳴ったら、15分以内に状況確認と初動を完了させる必要がある。


エスカレーションの判断

エスカレーションの2つの経路

カイが War Room で説明した。「エスカレーションには2つの軸がある。上に上げるか、横に広げるかだ。」

Loading diagram...

階層エスカレーションは、マネジメントチェーンを上に辿る。リソースの追加投入や顧客への公式コミュニケーションが必要な場合に使う。

機能エスカレーションは、専門チームに横展開する。データベースの問題ならインフラチーム、推論モデルの問題なら ML チームに依頼する。

エスカレーションのトリガー

「いつエスカレーションするかの判断基準を持っておけ」とカイが言った。

# app/services/escalation_evaluator.rb
class EscalationEvaluator
  TIME_LIMITS = { sev1: 30.minutes, sev2: 2.hours, sev3: 8.hours }.freeze
 
  def initialize(incident)
    @incident = incident
  end
 
  def should_escalate?
    time_exceeded? || error_rate_exceeded? ||
      affected_users_exceeded? || vip_customer_requested?
  end
 
  def escalation_reason
    reasons = []
    reasons << "SLA時間超過" if time_exceeded?
    reasons << "エラー率閾値超過" if error_rate_exceeded?
    reasons << "影響ユーザー数超過" if affected_users_exceeded?
    reasons << "VIP顧客からの要求" if vip_customer_requested?
    reasons.join(", ")
  end
 
  private
 
  def time_exceeded?
    limit = TIME_LIMITS[@incident.severity.to_sym]
    limit && !@incident.resolved? && (Time.current - @incident.detected_at > limit)
  end
 
  def error_rate_exceeded?
    @incident.customer.current_error_rate_percent > 50.0
  end
 
  def affected_users_exceeded?
    @incident.affected_user_count > 1000
  end
 
  def vip_customer_requested?
    @incident.customer.vip? && @incident.customer_escalation_requested?
  end
end

ソウタは Nextera のダッシュボードを見た。エラー率 47%、影響ユーザー推定 3,200 人。複数のトリガーに該当していた。


Palantir の Delta/Echo チーム構造

「ここからが FDE の真骨頂だ」とカイが続けた。「Palantir の Delta/Echo 構造を使う。」

Palantir が編み出したインシデント対応の組織構造は、技術的な問題解決コミュニケーション管理を明確に分離する。

Loading diagram...

Delta チーム(技術チーム)

Delta はデバッグと修正に集中する。ログを読み、コードを追い、仮説を立てて検証する。外部とのコミュニケーションは一切行わない

Echo チーム(戦略チーム)

Echo は顧客コミュニケーション、社内への状況共有、経営層への報告を担う。Delta が技術に没頭できる環境を作る。

FDE は両方を橋渡しする

「普通のエンジニアは Delta だけ、普通のサポートは Echo だけ。FDE は両方の言語を話せる」とカイが言った。

ソウタは Delta として技術調査に入りつつ、顧客の技術責任者への説明も求められていた。FDE だからこそ、「今何が起きていて、いつ直る見込みか」を技術的根拠とともに伝えられる。

# app/models/incident_role.rb
class IncidentRole < ApplicationRecord
  enum :role_type, {
    incident_commander: 0,  # 全体統括
    delta_lead: 1,          # 技術チームリード
    delta_member: 2,        # 技術調査メンバー
    echo_lead: 3,           # コミュニケーションリード
    echo_member: 4,         # コミュニケーション担当
    fde_bridge: 5           # FDE(Delta/Echo 橋渡し)
  }
 
  belongs_to :incident
  belongs_to :user
 
  def can_update_customer?
    echo_lead? || echo_member? || fde_bridge?
  end
 
  def can_deploy_fix?
    delta_lead? || delta_member? || fde_bridge?
  end
end

INFO

Delta/Echo の分離により、技術者が顧客対応に時間を取られて調査が遅れる「割り込み地獄」を防げる。FDE はこの両方を状況に応じて切り替えられる稀有な存在だ。


体系的デバッグ手法

カイがソウタに問いかけた。「よし、Delta モードだ。最初に何をする?」

「ログを見ます」

「違う。最初にやるのは再現だ。」

Step 1: 再現(Replication)

問題を自分の手で再現する。再現できなければ、何を直しているのかわからない。

# bin/incident_reproduce.rb — 再現テストスクリプト
customer = Customer.find_by!(slug: "nextera-financial")
api_client = ArclightApi::Client.new(customer:, environment: :production)
 
10.times do |i|
  start = Time.current
  begin
    api_client.predict(model: "risk-analysis-v4", input: sample_risk_payload)
    puts "[#{i + 1}] 成功 - #{(Time.current - start).round(2)}s"
  rescue ArclightApi::TimeoutError, ArclightApi::ServerError => e
    puts "[#{i + 1}] 失敗 - #{(Time.current - start).round(2)}s - #{e.class}"
  end
end
# => 10回中6回タイムアウト、2回サーバーエラー。再現率80%

結果は明白だった。10回中6回がタイムアウト、2回がサーバーエラー。再現率80%。

Step 2: 分離(Isolation)

「再現できた。次は範囲を絞る。他の顧客でも起きてるか?」

# 他の顧客の API レスポンスタイムを確認
aws cloudwatch get-metric-statistics \
  --namespace "ArclightAI/API" \
  --metric-name "ResponseTime" \
  --dimensions Name=Customer,Value=nextera-financial \
  --start-time "2026-06-28T16:00:00Z" \
  --end-time "2026-06-28T17:15:00Z" \
  --period 300 \
  --statistics Average Maximum \
  --region ap-northeast-1

他の顧客のレスポンスタイムは正常だった。問題は Nextera Financial に限定されている。さらに絞り込む——推論プール nextera-pool-01 に問題がある。

Step 3: 分割統治(Divide and Conquer)

システムを半分に切って、問題がどちら側にあるか特定する。

Loading diagram...

Step 4: 仮説検証(Hypothesis Testing)

「仮説: Nextera 専用推論プールの GPU ノードでモデルキャッシュがエビクションされ、毎回コールドロードが発生している」

# app/services/inference_pool_diagnostics.rb
class InferencePoolDiagnostics
  def initialize(pool_id)
    @pool_id = pool_id
    @cw = Aws::CloudWatch::Client.new(region: "ap-northeast-1")
  end
 
  def check_model_cache_hit_rate
    latest = fetch_metric("ModelCacheHitRate", ["Average"])
    { cache_hit_rate: latest&.average&.round(2), healthy: latest&.average.to_f > 80.0 }
  end
 
  def check_gpu_memory_usage
    latest = fetch_metric("GPUMemoryUsage", ["Average", "Maximum"])
    { avg_usage: latest&.average&.round(2), max_usage: latest&.maximum&.round(2) }
  end
 
  private
 
  def fetch_metric(name, stats)
    resp = @cw.get_metric_statistics(
      namespace: "ArclightAI/Inference", metric_name: name,
      dimensions: [{ name: "PoolId", value: @pool_id }],
      start_time: 2.hours.ago, end_time: Time.current,
      period: 300, statistics: stats
    )
    resp.datapoints.max_by(&:timestamp)
  end
end
 
# 実行結果
diag = InferencePoolDiagnostics.new("nextera-pool-01")
diag.check_model_cache_hit_rate  # => { cache_hit_rate: 12.3, healthy: false }
diag.check_gpu_memory_usage      # => { avg_usage: 97.8, max_usage: 99.2 }

ビンゴだった。キャッシュヒット率が 12%——通常は 95% 以上あるべき値だ。GPU メモリ使用率が 97% を超えていた。

「原因がわかりました」とソウタが報告した。「昨日のデプロイで Nextera のカスタム前処理パイプラインが追加のメモリを消費するようになり、GPU メモリが逼迫してモデルキャッシュがエビクションされています。」


修正と緩和

カイが頷いた。「よくやった。じゃあ緩和策を打とう。恒久対策は後だ。まず火を消す。」

# 緩和策: 推論プールの GPU メモリ上限を一時的に拡大
# bin/incident_mitigate.rb
 
# Step 1: 推論プールのスケールアウト
ecs_client = Aws::ECS::Client.new(region: "ap-northeast-1")
ecs_client.update_service(
  cluster: "arclight-inference",
  service: "nextera-pool-01",
  desired_count: 4  # 2 → 4 にスケールアウト
)
 
# Step 2: カスタム前処理のメモリ制限を設定
config_client = ArclightConfig::Client.new
config_client.update_pool_config(
  pool_id: "nextera-pool-01",
  preprocessing_memory_limit_mb: 2048  # 制限なし → 2GB に制限
)
 
# Step 3: モデルキャッシュのウォームアップ
warmup_client = ArclightApi::WarmupClient.new
warmup_client.warmup_model(
  pool_id: "nextera-pool-01",
  model: "risk-analysis-v4"
)
 
puts "緩和策を適用しました。メトリクスの回復を監視します。"

5分後、エラー率が 47% から 3% に低下した。レスポンスタイムも正常値に戻った。

「Nextera の技術責任者に状況を報告してくれ」とカイが言った。「これが Echo の仕事だ。」

ソウタは顧客向けの状況報告を書いた。技術的な根本原因を、ビジネスインパクトの文脈で説明する——FDE ならではの仕事だった。


"砂利道から舗装道路へ" フィードバックループ

翌日、カイがソウタに話しかけた。

「昨夜の対応は手動でやったよな。次に同じことが起きたらどうする?」

「同じ手順を……あ、自動化すべきですね。」

「そうだ。FDE のインシデント対応は、プロダクト改善のインプットになる。これを"砂利道から舗装道路へ"と呼んでいる。」

Loading diagram...

今回のインシデントから生まれたプロダクト改善:

  1. 手動修正: ソウタが深夜に手動でスケールアウトとキャッシュウォームアップを実行
  2. Runbook 文書化: 手順を Confluence に記録
  3. 自動化: GPU メモリ逼迫時の自動スケールアウトスクリプトを作成
  4. プロダクト化: メモリ使用量の自動監視とオートスケーリングを推論プラットフォームの機能として実装

INFO

FDE が現場で繰り返し行う手動作業は、プロダクトの機能ギャップを示している。インシデントは「バグ」ではなく「プロダクトへのフィードバック」として扱う。


ポストモーテムを書く

インシデントが解決した翌日、カイがソウタに言った。

「さて、一番大事な仕事が残ってる。ポストモーテムだ。」

ポストモーテムの原則

ポストモーテムは非難ではなく学習のために書く。「誰が悪いか」ではなく「システムのどこが弱かったか」を明らかにする。

5 Whys による根本原因分析

「なぜ」を5回繰り返して、表面的な原因から根本原因にたどり着く。

  1. なぜ API がタイムアウトした? → 推論のレスポンスが遅かった
  2. なぜ推論が遅かった? → モデルが毎回コールドロードされていた
  3. なぜコールドロードされた? → GPU メモリ不足でキャッシュがエビクションされた
  4. なぜ GPU メモリが不足した? → カスタム前処理がメモリ制限なしで動作していた
  5. なぜメモリ制限がなかった? → 前処理パイプラインにリソース制限の仕組みがなかった

根本原因: カスタム前処理パイプラインにリソースガバナンスが未実装だった

タイムライン形式のポストモーテム

# app/services/postmortem_builder.rb
class PostmortemBuilder
  def initialize(incident)
    @incident = incident
  end
 
  def generate
    {
      title: @incident.title,
      severity: @incident.severity,
      duration: format_duration,
      timeline: build_timeline,
      root_cause: @incident.root_cause_description,
      five_whys: @incident.five_whys_entries.order(:level).map { |e|
        { level: e.level, question: e.question, answer: e.answer }
      },
      action_items: @incident.action_items.map { |item|
        { action: item.description, owner: item.assignee.name,
          priority: item.priority, deadline: item.due_date }
      }
    }
  end
 
  private
 
  def build_timeline
    @incident.timeline_entries.order(:occurred_at).map do |entry|
      { time: entry.occurred_at.strftime("%H:%M JST"),
        event: entry.description, actor: entry.user&.name || "System" }
    end
  end
 
  def format_duration
    seconds = @incident.resolved_at - @incident.detected_at
    "#{(seconds / 3600).floor}時間#{((seconds % 3600) / 60).floor}分"
  end
end

ソウタが書いたポストモーテムの Action Items:

アクション担当優先度期限チケット
前処理パイプラインにメモリ制限を実装MLチーム 田中P07/5ENG-4521
GPU メモリ使用率の閾値アラート追加インフラ 佐藤P07/3OPS-1287
推論プールの自動スケーリング実装プラットフォーム 鈴木P17/12ENG-4522
Nextera 向け Runbook を更新FDE ソウタP17/1FDE-892
前処理リソース制限の設計ドキュメント作成MLチーム 田中P27/10ENG-4523

WARNING

Action Items に担当者と期限がないポストモーテムは意味がない。「改善する」ではなく「誰が」「いつまでに」「何を」するかを明記する。


AWS モニタリングパイプラインの構築

インシデントの教訓を受けて、ソウタは Nextera 環境のモニタリングを強化した。

Loading diagram...

CloudWatch カスタムメトリクスの送信

# app/services/monitoring/cloudwatch_reporter.rb
class Monitoring::CloudwatchReporter
  def initialize
    @client = Aws::CloudWatch::Client.new(region: "ap-northeast-1")
    @namespace = "ArclightAI/CustomerMetrics"
  end
 
  def report_api_latency(customer_slug:, endpoint:, latency_ms:)
    put_metric("APILatency", [
      { name: "Customer", value: customer_slug },
      { name: "Endpoint", value: endpoint }
    ], latency_ms, "Milliseconds")
  end
 
  def report_error_rate(customer_slug:, error_count:, total_count:)
    rate = total_count.positive? ? (error_count.to_f / total_count * 100) : 0.0
    put_metric("ErrorRate", [
      { name: "Customer", value: customer_slug }
    ], rate, "Percent")
  end
 
  private
 
  def put_metric(metric_name, dimensions, value, unit)
    @client.put_metric_data(
      namespace: @namespace,
      metric_data: [{
        metric_name: metric_name, dimensions: dimensions,
        timestamp: Time.current, value: value, unit: unit
      }]
    )
  end
end

CloudWatch Alarms の設定

# lib/tasks/monitoring/setup_alarms.rake
namespace :monitoring do
  desc "顧客環境のCloudWatch Alarmsを設定"
  task setup_alarms: :environment do
    cloudwatch = Aws::CloudWatch::Client.new(region: "ap-northeast-1")
    sns_arn = ENV.fetch("PAGERDUTY_SNS_TOPIC_ARN")
 
    Customer.active.find_each do |customer|
      # エラー率アラーム(5分平均が10%超過 × 2回連続でトリガー)
      cloudwatch.put_metric_alarm(
        alarm_name: "#{customer.slug}-error-rate-high",
        namespace: "ArclightAI/CustomerMetrics",
        metric_name: "ErrorRate",
        dimensions: [{ name: "Customer", value: customer.slug }],
        statistic: "Average", period: 300,
        evaluation_periods: 2, threshold: 10.0,
        comparison_operator: "GreaterThanThreshold",
        alarm_actions: [sns_arn]
      )
 
      # P99 レイテンシアラーム(5秒超過 × 3回連続)
      cloudwatch.put_metric_alarm(
        alarm_name: "#{customer.slug}-latency-high",
        namespace: "ArclightAI/CustomerMetrics",
        metric_name: "APILatency",
        dimensions: [{ name: "Customer", value: customer.slug }],
        statistic: "p99", period: 300,
        evaluation_periods: 3, threshold: 5000.0,
        comparison_operator: "GreaterThanThreshold",
        alarm_actions: [sns_arn]
      )
    end
  end
end

インシデント自動起票

# app/services/incident_auto_creator.rb
class IncidentAutoCreator
  def self.from_cloudwatch_alarm(alarm_message)
    parsed = JSON.parse(alarm_message)
    customer = Customer.find_by!(slug: extract_customer_slug(parsed))
 
    incident = Incident.create!(
      title: "#{customer.name}: #{parsed['AlarmDescription']}",
      severity: determine_severity(parsed),
      status: :detected, customer: customer,
      detected_at: Time.current, source: "cloudwatch_alarm"
    )
 
    incident.timeline_entries.create!(
      occurred_at: Time.current,
      description: "CloudWatch Alarm トリガー: #{parsed['AlarmName']}",
      entry_type: :automated
    )
 
    PagerdutyNotifier.trigger(
      severity: incident.severity, summary: incident.title,
      source: "ArclightAI Monitoring", component: customer.slug
    )
    incident
  end
 
  private
 
  def self.determine_severity(data)
    case data.dig("Trigger", "MetricName")
    when "ErrorRate" then data.dig("Trigger", "Threshold") >= 50 ? :sev1 : :sev2
    when "APILatency" then data.dig("Trigger", "Threshold") >= 30_000 ? :sev1 : :sev2
    else :sev3
    end
  end
 
  def self.extract_customer_slug(data)
    data.dig("Trigger", "Dimensions")&.find { |d| d["name"] == "Customer" }&.dig("value")
  end
end

エピローグ — 火を消した夜に学んだこと

ポストモーテムを書き上げたソウタに、カイが声をかけた。

「初めての SEV-1、どうだった?」

「正直、最初は手が震えました。でも、手順があると落ち着けますね。重大度を分類して、エスカレーションの判断基準を持って、Delta/Echo で役割を分けて、体系的にデバッグする。フレームワークがあるだけで、パニックにならずに済みました。」

カイが微笑んだ。「そうだ。インシデント対応は度胸じゃない。プロセスだ。そしてこの経験が、FDE を普通のエンジニアから分ける。」

ソウタは振り返った。TechNova で Rails を書いていた頃、障害は怖いものだった。しかし FDE として顧客の本番環境で火を消す経験は、恐怖を能力に変えてくれた。

「あと、もう一つ大事なことがある」とカイが付け加えた。「今夜の対応で、Nextera の技術責任者から Slack で直接メッセージが来てたぞ。"ソウタさんの対応のおかげで安心できました"って。」

顧客の信頼は、機能のデモや提案書では築けない。深夜2時に一緒に火を消した経験が、最も強固な信頼関係を作る。それが FDE のインシデントレスポンスだ。

INFO

インシデントは FDE にとって最大の信頼構築の機会でもある。冷静に、体系的に、誠実に対応することで、顧客との関係は障害前より強くなる。