ポストモーテムの原則:Blameless

Blameless(責任追及なし)はポストモーテム文化の根幹です。 Google の SRE 本でも強調されるこの原則は、「人」ではなく「システムとプロセス」に注目することで、担当者が萎縮せず正確な情報を共有できる環境を作ります。

⚠️ 「なぜ山田さんはミスしたのか」はポストモーテムではない

個人の失敗を責めるドキュメントは組織の学習を阻害します。「なぜシステムが人間のミスを許す状態になっていたか」「なぜプロセスがミスを検出できなかったか」を問います。

💡 ポストモーテム vs インシデントレポート

インシデントレポート(Runbook)は障害対応中のリアルタイム記録。ポストモーテムは障害解決後に深く根本原因を分析し、再発防止策を導き出す振り返りドキュメントです。

障害サマリー

ポストモーテムの冒頭には、マネジメント層が 1 分で全体像を把握できるサマリーを配置します。

フィールド定義すべき内容
タイトル「注文 API 全断(2026-06-15 14:32〜15:48)」— 日時と影響を含む
インシデント番号INC-2026-0042(トラッカーへのリンク)
重大度SEV1〜4 または P1〜P4(定義に従う)
発生日時2026-06-15 14:32 JST(ユーザー影響開始時刻)
復旧日時2026-06-15 15:48 JST
ダウンタイム1時間16分
検出方法Datadog アラート / ユーザー報告 / オンコール担当者が気づく
一言概要Redis 接続プールの枯渇により注文 API が全断。新規デプロイで接続設定が誤っていた。
作成者山田太郎(オンコール担当)
レビュアー田中花子(SRE)、佐藤一郎(バックエンド TL)

影響範囲の定義

ビジネスへの影響を数字で定義します。感覚論ではなく計測値で記述します。

定義項目内容・例
影響ユーザー数約 12,000 ユーザーが注文操作不能(当該時間帯の平均アクティブユーザーの 80%)
失敗リクエスト数POST /orders エラー計 24,500 件(Datadog ログより)
売上損失(推定)約 380 万円(過去同時刻帯の平均売上 × ダウンタイム分)
カスタマーサポート問い合わせ障害時間中に 156 件の問い合わせが発生(通常の 8 倍)
SLA への影響月次可用性 99.9%(目標)に対して今月の実績が 99.72% に低下

タイムライン

障害開始から復旧まで、「誰が・何をしたか・何が起きたか」を時系列で記録します。UTC または JSTで統一します。

Markdown — タイムライン記述テンプレート
## タイムライン(全時刻 JST)

| 時刻  | イベント | 担当者 |
|-------|---------|--------|
| 14:28 | v2.3.1 本番デプロイ完了(接続プールサイズ設定ミスを含む) | CI/CD 自動 |
| 14:32 | Datadog が `/orders` の 5xx エラー率 50% 超えをアラート | — |
| 14:33 | オンコール担当(山田)が Pagerduty 通知を受信 | 山田 |
| 14:36 | Datadog ダッシュボードで Redis 接続待ち時間の急上昇を確認 | 山田 |
| 14:41 | 佐藤(バックエンドTL)に Slack でエスカレーション | 山田 |
| 14:45 | デプロイログを確認し、v2.3.1 で接続プール設定が変更されていたことを特定 | 佐藤 |
| 14:52 | v2.3.0 へのロールバック開始 | 佐藤 |
| 15:01 | ロールバック完了。/orders エラー率が 0% に回復 | 佐藤 |
| 15:10 | Redis 接続プールが完全に解放されてメトリクスが正常値に戻る | — |
| 15:15 | カスタマーサポートに障害解消をアナウンス | 山田 |
| 15:48 | 全サービスの正常動作を確認し、インシデントをクローズ | 山田 |

根本原因分析(RCA)

根本原因分析(Root Cause Analysis)は「直接原因」と「根本原因」を分けて考えます。 直接原因は「何が障害を引き起こしたか」、根本原因は「なぜそれが起きたか・なぜ防げなかったか」です。 5 Why(なぜなぜ分析)が広く使われます。

Markdown — 5 Why 分析テンプレート
## 根本原因分析(5 Why)

**直接原因:** Redis 接続プールが枯渇し、注文 API が新規接続を取得できなかった。

**なぜ 1:** v2.3.1 のデプロイで Redis 接続プールサイズが 100 → 10 に誤って変更されたから。

**なぜ 2:** 接続プールサイズを環境変数ではなくハードコードで変更し、
           開発環境の値がそのまま本番に混入したから。

**なぜ 3:** 本番デプロイ前のコードレビューで、設定値の変更が目的外の変更と
           気づかれなかったから(PR の変更差分が大きく埋もれていた)。

**なぜ 4:** 接続プールサイズの変更は CI で自動検出されず、
           レビュアーが目視確認するしかない状態だったから。

**なぜ 5:** インフラ設定値の変更に対するポリシー(Infrastructure as Code の
           必須化・設定変更の Lint チェック)が整備されていなかったから。

**根本原因:**
インフラ設定値(接続プールサイズ等)が環境変数または IaC で管理されておらず、
コード内のハードコードと混在していたため、レビューで検出できなかった。
また、設定値の異常を CI/CD パイプラインで自動検出する仕組みがなかった。

再発防止策・アクションアイテム

再発防止策は「具体的・担当者付き・期限付き」で定義します。「〜を強化する」という抽象的な表現は避けます。

IDアクション担当者期限優先度
ACT-01 接続プールサイズを環境変数(REDIS_POOL_SIZE)に移行し、環境別に設定する 佐藤 2026-06-22 🔴 高
ACT-02 CI パイプラインに設定値の範囲チェック(接続プール: 50〜200)を追加 田中 2026-06-25 🔴 高
ACT-03 本番デプロイ前のカナリアリリース(5%トラフィック)を必須化 田中 2026-07-10 🟡 中
ACT-04 Redis 接続待ち時間のアラート閾値を 100ms → 50ms に引き下げ(早期検出) 山田 2026-06-20 🔴 高
ACT-05 ロールバック手順を Runbook に追記し、全員が実行できるよう訓練 山田 2026-07-15 🟡 中

ポストモーテムテンプレート

Markdown — ポストモーテム完全テンプレート
# ポストモーテム: [タイトル]

> **ステータス:** ドラフト / レビュー中 / 最終版
> **インシデント番号:** INC-YYYY-NNNN
> **作成者:** [名前]
> **レビュアー:** [名前], [名前]
> **最終更新:** YYYY-MM-DD

---

## 1. 障害サマリー

| 項目 | 内容 |
|------|------|
| 重大度 | SEV? |
| 発生日時 | YYYY-MM-DD HH:MM JST |
| 復旧日時 | YYYY-MM-DD HH:MM JST |
| ダウンタイム | ? 時間 ? 分 |
| 検出方法 | アラート / ユーザー報告 |

**一言概要(2〜3文):**
[何が起きて、なぜ起きて、どう解決したか]

---

## 2. 影響範囲

- 影響ユーザー数:
- 失敗リクエスト数:
- 売上損失(推定):
- SLA 影響:

---

## 3. タイムライン

| 時刻 | イベント | 担当者 |
|------|---------|--------|
| HH:MM | [イベント] | [名前] |

---

## 4. 根本原因分析

**直接原因:**

**5 Why:**
1. なぜ:
2. なぜ:
3. なぜ:
4. なぜ:
5. なぜ:

**根本原因:**

---

## 5. うまくいったこと / うまくいかなかったこと

**うまくいったこと:**
- [良かった対応]

**うまくいかなかったこと:**
- [改善すべき対応]

---

## 6. アクションアイテム

| ID | アクション | 担当者 | 期限 | 優先度 |
|----|-----------|--------|------|--------|
| ACT-01 | | | | |

Python でアラートログからタイムラインを生成する

① Datadog/CloudWatch アラートログからタイムラインを自動生成

Python — JSON アラートログをポストモーテム用タイムラインに変換
"""
Datadog / PagerDuty などのアラートログ(JSON Lines 形式)から
ポストモーテム用タイムラインの Markdown 草稿を生成する。

入力例 (alerts.jsonl):
{"timestamp": "2026-06-15T05:32:00Z", "source": "datadog", "message": "5xx error rate > 50%", "level": "CRITICAL"}
{"timestamp": "2026-06-15T05:41:00Z", "source": "slack", "message": "佐藤にエスカレーション", "level": "INFO"}
{"timestamp": "2026-06-15T05:52:00Z", "source": "github", "message": "Rollback PR merged v2.3.0", "level": "INFO"}
{"timestamp": "2026-06-15T06:01:00Z", "source": "datadog", "message": "5xx error rate back to 0%", "level": "INFO"}
{"timestamp": "2026-06-15T06:48:00Z", "source": "pagerduty", "message": "Incident INC-0042 resolved", "level": "INFO"}

使い方:
  python gen_postmortem_timeline.py alerts.jsonl --tz Asia/Tokyo
"""
import json
import sys
from datetime import datetime, timezone, timedelta
from pathlib import Path
import argparse

JST = timezone(timedelta(hours=9))

LEVEL_ICON = {
    "CRITICAL": "🔴",
    "ERROR":    "🟠",
    "WARNING":  "🟡",
    "INFO":     "🔵",
}

SOURCE_LABEL = {
    "datadog":   "Datadog",
    "pagerduty": "PagerDuty",
    "slack":     "Slack",
    "github":    "GitHub",
    "aws":       "AWS",
}

def parse_alerts(path: Path, local_tz: timezone = JST) -> list[dict]:
    events = []
    for line in path.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line:
            continue
        event = json.loads(line)
        dt = datetime.fromisoformat(event["timestamp"].replace("Z", "+00:00"))
        dt_local = dt.astimezone(local_tz)
        events.append({
            "time": dt_local,
            "source": SOURCE_LABEL.get(event.get("source", ""), event.get("source", "")),
            "message": event.get("message", ""),
            "level": event.get("level", "INFO"),
        })
    return sorted(events, key=lambda e: e["time"])

def render_timeline(events: list[dict]) -> str:
    lines = ["## タイムライン(全時刻 JST)\n", "| 時刻 | ソース | レベル | イベント |",
             "|------|-------|--------|---------|"]
    for e in events:
        time_str = e["time"].strftime("%H:%M")
        icon = LEVEL_ICON.get(e["level"], "⚪")
        lines.append(f"| {time_str} | {e['source']} | {icon} {e['level']} | {e['message']} |")
    return "\n".join(lines)

def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("alerts_file", nargs="?", default="alerts.jsonl")
    args = parser.parse_args()

    path = Path(args.alerts_file)
    if not path.exists():
        print(f"ファイルが見つかりません: {path}", file=sys.stderr)
        sys.exit(1)

    events = parse_alerts(path)
    print(f"# タイムライン草稿({len(events)} 件のイベント)")
    print(f"# ※ 手動で対応内容・担当者を追記してください\n")
    print(render_timeline(events))

if __name__ == "__main__":
    main()

② 障害頻度のトレンド分析

Python — ポストモーテム一覧から障害パターンを分析
"""
postmortems/ ディレクトリの Markdown ファイルを解析して
障害の発生パターン・重大度分布・根本原因カテゴリを集計する。

ファイル名規則: YYYY-MM-DD-INC-NNNN-title.md
フロントマター例:
  severity: SEV1
  root_cause_category: configuration / code / infra / external
  downtime_minutes: 76
"""
import re
from collections import Counter, defaultdict
from datetime import datetime
from pathlib import Path

FRONTMATTER = re.compile(r"^---\n(.*?)\n---", re.DOTALL)
KV = re.compile(r"^(\w+):\s*(.+)$", re.MULTILINE)

def parse_postmortem(path: Path) -> dict | None:
    text = path.read_text(encoding="utf-8")
    m = FRONTMATTER.match(text)
    if not m:
        return None
    meta = dict(KV.findall(m.group(1)))
    # ファイル名から日付を抽出
    date_m = re.search(r"(\d{4}-\d{2}-\d{2})", path.name)
    meta["date"] = datetime.strptime(date_m.group(1), "%Y-%m-%d") if date_m else None
    return meta

def analyze(root: str = "postmortems/") -> None:
    files = sorted(Path(root).glob("*.md"))
    incidents = [m for f in files if (m := parse_postmortem(f)) is not None]

    print(f"=== ポストモーテム分析レポート ({len(incidents)} 件) ===\n")

    # 重大度分布
    sev_count = Counter(i.get("severity", "不明") for i in incidents)
    print("【重大度別件数】")
    for sev, cnt in sorted(sev_count.items()):
        print(f"  {sev}: {cnt} 件")

    # 根本原因カテゴリ
    rc_count = Counter(i.get("root_cause_category", "不明") for i in incidents)
    print("\n【根本原因カテゴリ別件数】")
    for rc, cnt in sorted(rc_count.items(), key=lambda x: -x[1]):
        bar = "█" * cnt
        print(f"  {rc:<15} {bar} ({cnt} 件)")

    # 月別件数
    monthly = defaultdict(int)
    for i in incidents:
        if i.get("date"):
            monthly[i["date"].strftime("%Y-%m")] += 1
    print("\n【月別件数】")
    for month in sorted(monthly.keys()):
        bar = "█" * monthly[month]
        print(f"  {month}: {bar} ({monthly[month]} 件)")

    # 平均ダウンタイム
    downtimes = [int(i["downtime_minutes"]) for i in incidents if "downtime_minutes" in i]
    if downtimes:
        print(f"\n【ダウンタイム統計】")
        print(f"  平均: {sum(downtimes)/len(downtimes):.1f} 分")
        print(f"  最大: {max(downtimes)} 分")
        print(f"  最小: {min(downtimes)} 分")

if __name__ == "__main__":
    import sys
    analyze(sys.argv[1] if len(sys.argv) > 1 else "postmortems/")

ポストモーテムプロセス

フェーズタイミング内容
ドラフト作成インシデント解消後 24 時間以内タイムライン・影響範囲・根本原因の初版を作成
ミーティング解消後 72 時間以内関係者全員で内容をレビュー・ディスカッション(30〜60分)
最終版公開ミーティング後 48 時間以内全チームが閲覧できる場所に公開(Notion / Confluence / GitHub)
アクション追跡毎週スプリントレビューで確認アクションアイテムの完了状況をトラッキング

まとめ

ポストモーテムで定義すべき要素

□ 障害サマリー(重大度・発生/復旧時刻・ダウンタイム・検出方法・一言概要)
□ 影響範囲(ユーザー数・失敗リクエスト数・売上損失・SLA 影響)
□ タイムライン(時系列で誰が・何をした・何が起きたか)
□ 根本原因分析(5 Why で直接原因と根本原因を分けて定義)
□ うまくいったこと / いかなかったこと
□ 再発防止策(具体的・担当者付き・期限付き・優先度付き)
□ Blameless 原則(個人ではなくシステム・プロセスに注目)
□ インシデント解消後 24 時間以内にドラフト作成