Article

停電・災害時のシステム復旧手順書の作り方

高田晃太郎
停電・災害時のシステム復旧手順書の作り方

Uptime Instituteの年次報告では、重大障害の約4割が電力関連に起因し[1]、ITICの2023年調査ではダウンタイムの1時間当たりコストが30万ドル超の企業が8割超[2]とされている。電源断は珍事ではなく、いつか必ず起こる前提だ。複数の公開資料を照合すると、機器故障よりも電力・冷却の外的要因が引き金になりやすい傾向が見える[3]。だからこそ、手順書は“棚にあるPDF”では足りない。停電・災害の具体シナリオに結びついた復旧フロー、バックアップの可用性検証、権限移譲と意思決定のガードレール、そしてタイムボックスと計測の仕組みを一体で設計する必要がある。ここではCTOやエンジニアリングリーダーがすぐに使える視点で、実装可能な復旧手順書の作り方を示す。

手順書の目的と指標を先に決める:RTO/RPOを文章で落とし込む

有名な落とし穴は、バックアップがあることと復旧できることを同一視してしまう思考だ。必要なのは“何分以内に、どの一貫性レベルで、どの優先度のサービスから順に戻すか”の合意であり、これを手順書の冒頭に明文化する[4]。ここでいうRTO(復旧に許容される停止時間の目標)とRPO(許容できるデータ損失の目標)は、ビジネスと技術の橋渡しとなる基準だ。復旧時間目標としてのRTOは“復旧完了の定義”を伴って初めて意味を持つ。単にプロセスが終わった時点ではなく、主要SLO(サービスレベル目標)が満たされ、エラー率とレイテンシが平常レンジに復帰した瞬間をもってRTO達成と定義する。復旧時点目標としてのRPOは、バックアップ方式とトランザクションログ(例:WAL=Write-Ahead Logging)の設計に依存するため、サービスごとに差が出る。たとえば決済はRPOを秒単位、分析系は分単位でも許容などの線引きを、データストア別に分けて言葉で書く。さらに意思決定の権限と優先順位も数式ではなく文章で固定する。「決済APIとログインは第一群、管理画面は第二群。第一群の健康が戻るまでは第二群への作業を保留」など、現場で読み替えなくて済む記述が望ましい。実際の障害では、“どれから戻すか”の迷いが時間を溶かす最大要因になる。

シナリオを固定する:停電はパターンで語る

手順書は抽象論でなく具体のシナリオを扱う。データセンター全域停電、ラック単位の電源断、コロケーションの全断、クラウドのAZ(アベイラビリティゾーン)障害、地域災害による通信断など、復旧対象を意図的に狭める。各シナリオで使用するバックアップ媒体と経路、切り替え先、チームの動線を物語のように連結し、関係のない分岐は果断に削る。読点の多い包括的な段落より、短い能動文で工程を積み上げるほうが読み飛ばしを防げる。バックアップはS3互換のオブジェクトストレージ、ログはWALの継続送信、メタデータはIaC(Infrastructure as Code)リポジトリとCMDB(構成管理データベース)というように、資産の所在を手順書内で固定する。社内の障害レビューから学びを取り込み、毎回の訓練で過不足を修正する循環が要る。

計測を織り込む:RTO/RPOは時計とログで管理する

目標は測ってこそ管理できる。復旧の各段階にタイムスタンプを打ち、RTOを“分解能のある合計”として扱う。たとえば災害宣言から意思決定まで、ストレージの復元、データベースのリプレイ、アプリのウォームアップ、トラフィック受け入れ再開の各区間を時刻で切り出す。RPOはポイント・イン・タイムの復元(PITR=Point-in-Time Recovery)の試行でしか検証できないため、毎週の演習でランダム時刻の復元を行い、実測値を記録する。これらの数字は次回の投資判断の根拠として、より高速なストレージやネットワーク、ログ冗長化の費用対効果を評価する材料になる。

構造化された“読むための文書”にする:一枚目から動く

手順書は最初の一枚に意思決定と連絡の道筋を置き、二枚目以降にシステムごとの実行フローを置く構造が機能する。最初のページは災害宣言の基準、インシデント指揮の引き継ぎ条件、合意済みのRTO/RPO、そしてコミュニケーション規約を簡潔に並べる。以降のセクションでは、環境別に前提と前処理、実施手順、検証、ロールバック、エスカレーションの順で記述し、各工程に完了条件と失敗時の分岐を添える。命令形と平叙文を混在させず、工程は一文一意で書くと読み間違いが減る。監査を意識し、実行ログの保全方法、チャット・会議体の記録方法、チケットIDの付与規則を本文内に明記する。時刻の表記はUTCに統一し、ローカル時刻が必要な場合は添字で併記する。

依存関係と資産台帳は“手順書の外”ではなく内側に引き込む

復旧で時間を失うのは、多くが「前提の探索」だ。依存関係の図や資産の所在を外部リンクにせず、手順書の章としてサマリを取り込む。IaCが整っていれば、最新状態はコードで表現される。CMDBやKubernetes APIから最小限の依存グラフを抽出し、主要サービスの上位三段分だけをテキストで再掲すると、停電時の“探索の旅”を回避できる。負荷分散、DNS、証明書、シークレットの更新系は復旧の終盤で詰まる場所なので、あらかじめ前倒し実行の可否を決めておく。

自動化と検証を組み込む:実装可能なコード例

手順書は人が読むだけでなく、機械が実行できる断片を持つと強い。ここではバックアップの検証、フェイルオアリストアの分岐、データベースの復元、Kubernetesの再稼働、ベアメタルの起動順制御まで、実際に使えるコード断片を示す。

バックアップ健全性の自動監査:S3上の整合性とWAL連結を検証する

オブジェクトストレージの可用性は高いが、人為的ミスやライフサイクル設定の誤りが起きる。Pythonで最新フルバックアップの存在とWALチェーンの連結を確認し、欠損時にアラートを送る。

#!/usr/bin/env python3
import os
import sys
import hashlib
import logging
from datetime import datetime, timezone, timedelta

import boto3
from botocore.exceptions import ClientError

logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')

BUCKET = os.environ.get('BACKUP_BUCKET')
PREFIX = os.environ.get('BACKUP_PREFIX', 'postgresql/')
WAL_PREFIX = os.environ.get('WAL_PREFIX', 'postgresql/wal/')
ALERT_TOPIC_ARN = os.environ.get('ALERT_TOPIC_ARN')
MAX_FULL_AGE_HOURS = int(os.environ.get('MAX_FULL_AGE_HOURS', '24'))

s3 = boto3.client('s3')
sns = boto3.client('sns')


def latest_full_backup():
    paginator = s3.get_paginator('list_objects_v2')
    latest = None
    for page in paginator.paginate(Bucket=BUCKET, Prefix=PREFIX):
        for obj in page.get('Contents', []):
            key = obj['Key']
            if key.endswith('.full.tar.zst'):
                if latest is None or obj['LastModified'] > latest['LastModified']:
                    latest = obj
    return latest


def wal_chain_ok(since):
    paginator = s3.get_paginator('list_objects_v2')
    wal_found = False
    for page in paginator.paginate(Bucket=BUCKET, Prefix=WAL_PREFIX):
        for obj in page.get('Contents', []):
            if obj['LastModified'] >= since:
                wal_found = True
                break
    return wal_found


def alert(message):
    if ALERT_TOPIC_ARN:
        sns.publish(TopicArn=ALERT_TOPIC_ARN, Message=message, Subject='Backup Auditor')
    logging.error(message)


if __name__ == '__main__':
    try:
        full = latest_full_backup()
        if not full:
            alert('No full backup found under %s' % PREFIX)
            sys.exit(2)
        age_hours = (datetime.now(timezone.utc) - full['LastModified']).total_seconds() / 3600
        if age_hours > MAX_FULL_AGE_HOURS:
            alert(f'Full backup too old: {age_hours:.1f}h')
            sys.exit(3)
        if not wal_chain_ok(full['LastModified']):
            alert('No WAL segments after last full backup; RPO at risk')
            sys.exit(4)
        logging.info('Backup audit passed: full=%s age=%.1fh', full['Key'], age_hours)
    except ClientError as e:
        alert(f'AWS error: {e}')
        sys.exit(5)
    except Exception as e:
        alert(f'Unexpected error: {e}')
        sys.exit(6)

フェイルオーバーかリストアかを判断し、両方を自動化する入口

電源断で第一サイトが再起動不可のとき、二地域冗長のDNSフェイルオーバー(名前解決の切り替え)が最速のRTOを出しやすい。一方、データ破損ならポイント・イン・タイムのリストア(PITR)が必要だ。初動スクリプトではヘルスチェックとラグを見て分岐させる。

#!/usr/bin/env bash
set -euo pipefail
trap 'echo "[ERROR] line $LINENO" >&2' ERR

PRIMARY_HEALTH_URL=${PRIMARY_HEALTH_URL:-"https://primary.example.com/healthz"}
SECONDARY_HEALTH_URL=${SECONDARY_HEALTH_URL:-"https://secondary.example.com/healthz"}
MAX_REPLAG=${MAX_REPLAG:-30} # seconds

check_lag() {
  curl -fsS "$1/replication_lag" | jq -r .seconds
}

if ! curl -fsS "$PRIMARY_HEALTH_URL" >/dev/null; then
  echo "Primary down; evaluating secondary"
  lag=$(check_lag "$SECONDARY_HEALTH_URL" || echo 99999)
  if [[ "$lag" -le "$MAX_REPLAG" ]]; then
    echo "Promoting secondary via DNS swing"
    aws route53 change-resource-record-sets --hosted-zone-id ZZZ --change-batch file://dns-swing.json
  else
    echo "Lag too large; initiating PITR restore"
    ./db_restore_pitr.sh
  fi
else
  echo "Primary healthy; no action"
fi

PostgreSQLのPITR復元:WAL-Gで高速に巻き戻す

WAL-Gを用いた復元はスループットが鍵になる。暗号化・圧縮を有効にしつつ、復元先ボリュームを事前にウォームアップしてI/Oのコールドスタートを避ける。

#!/usr/bin/env bash
set -euo pipefail
export PGDATA=/var/lib/postgresql/14/main
export WALG_S3_PREFIX=s3://my-backup-bucket/postgres
export AWS_REGION=ap-northeast-1
export WALG_DELTA_MAX_STEPS=7
export WALG_DOWNLOAD_CONCURRENCY=8
export PG_VERSION=14

systemctl stop postgresql || true
rm -rf "$PGDATA"/*

# base backup restore
wal-g backup-fetch "$PGDATA" LATEST

# point-in-time
cat <<EOF > "$PGDATA"/recovery.signal
EOF
cat <<EOF > "$PGDATA"/postgresql.auto.conf
recovery_target_time = '${RESTORE_TIME}'
recovery_target_action = 'promote'
EOF

# WAL replay
wal-g wal-fetch --since "${RESTORE_TIME}" --verify false || true

# warm filesystem cache to avoid initial latency spikes
fio --name=warm --rw=read --bs=1M --size=80% --filename="$PGDATA/base" || true

systemctl start postgresql
pg_isready -t 60

Kubernetesの再稼働:ジョブでスキーマ移行、レディネスで受け入れを遅延

アプリ群はKubernetesならば移行ジョブとレディネスゲートで復旧を制御しやすい。以下はジョブでマイグレーションを走らせ、成功後にDeploymentがトラフィックを受ける基本形だ。

apiVersion: batch/v1
kind: Job
metadata:
  name: migrate-after-restore
spec:
  ttlSecondsAfterFinished: 600
  template:
    spec:
      restartPolicy: OnFailure
      containers:
        - name: migrate
          image: ghcr.io/org/app-migrator:1.2.3
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: app-secrets
                  key: database_url
          args: ["--timeout=300", "--safe"]
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  replicas: 6
  selector:
    matchLabels: { app: web }
  template:
    metadata:
      labels: { app: web }
    spec:
      containers:
        - name: web
          image: ghcr.io/org/app:2.4.0
          readinessProbe:
            httpGet: { path: /healthz, port: 8080 }
            periodSeconds: 5
            failureThreshold: 6

ベアメタルや仮想基盤の起動順を守る:Ansibleで電源とサービスを段階化

停電からの復帰では電源とサービスの順序が品質を左右する。Ansibleで帯域制御と待機条件を組み込むと、ストレージのスパイクやキャッシュミスを抑えられる。

---
- hosts: storage
  become: yes
  tasks:
    - name: Power on storage chassis
      community.general.ipmi_power:
        name: on
      register: ipmi
      retries: 5
      delay: 10
      until: ipmi is succeeded

- hosts: db
  become: yes
  tasks:
    - name: Start PostgreSQL with ionice
      shell: ionice -c2 -n4 systemctl start postgresql
    - name: Wait until ready
      shell: pg_isready -t 60
      register: ready
      retries: 10
      delay: 6
      until: ready.rc == 0

- hosts: app
  become: yes
  tasks:
    - name: Warm application cache
      shell: curl -fsS http://{{ inventory_hostname }}:8080/warmup || true

依存サービスの起動整合:systemdのUnitで順序を固定

OSレベルではsystemdの依存記述で“勝手に起動した結果の整合不良”を避ける。以下はネットワークとDB起動後にアプリを立ち上げ、失敗時は指数バックオフする例だ。

[Unit]
Description=My App
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
User=app
ExecStart=/usr/local/bin/myapp --port 8080
Restart=on-failure
RestartSec=10s
StartLimitIntervalSec=300
StartLimitBurst=5

[Install]
WantedBy=multi-user.target

これらの断片は手順書から直接参照できるように、バージョン固定のリポジトリURLとハッシュを併記する。障害時に最新のmainブランチを引く運用は避け、タグで不変性を担保する。

検証・ベンチマーク・演習:数字で語れる手順書にする

実装の良し悪しは、本番に近い条件でどれだけ速く、再現性高く戻せるかに尽きる。検証環境の一例として、AWS上の汎用的なインスタンスにgp3ボリュームを割り当て、IOPSやスループットを十分に確保し、S3 Transfer Accelerationを有効にした構成で、1TB級のPostgreSQLクラスタをWAL-Gで復旧する手順を計測すると、ベースバックアップの展開、WALリプレイ、アプリのウォームアップ、DNSの切り戻しとヘルス判定といった区間の合算RTOが約1時間前後に収まる例がある。連続WALの最終到達時刻との差(RPO)は数十秒〜数分で収束する構成も一般的に確認できる。いずれの値も環境やデータ特性に大きく依存するため、ここでは方法論の参考値として捉えてほしい。バックアップ保管先を標準S3からGlacier Instant Retrievalへ移すと、復元レイテンシが数分増加する一方で月額コストが低下する、といったトレードオフもよく見られる。こうした測定は人手でストップウォッチを押すのではなく、先のスクリプトに時刻ロギングを加え、チャットOpsに自動投稿して記録を残す。I/Oの初期遅延を避けるため、復元直後にファイルシステムをfioで読み温める処理を入れると、初回リクエストのレイテンシp95が有意に改善する傾向がある。スロットリングのためにioniceとcgroup v2のIO制限(OSのリソース制御機構)を組み合わせると、復旧とバックグラウンドの監視系が衝突せず、ヘルスシグナルが安定する。

訓練の設計:テーブルトップと実動演習を往復する

訓練は低コストのテーブルトップ(机上演習)で前提合わせを行い、月次で実動の復元演習を走らせるリズムが現実的だ[5]。テーブルトップでは意思決定の遅延を洗い出し、役割と権限を磨く。実動演習ではランダムな時刻を指定してポイント・イン・タイムの復元を行い、RTO/RPOとSLO復帰時間を記録する。演習の成果は手順書にマージし、次回の仮説を立てる。重要なのは、訓練を“合格/不合格”で終わらせず、次に短縮できる工程がどこかを特定して、投資の優先度を更新することだ。障害レビューの文化を育て、責任追及ではなく学習の最大化に意識を置くと、長期的に復旧は確実に速くなる。

ビジネス価値と運用の持続性:ROIで語ると組織が動く

復旧手順書は安全資産であると同時に、投資案件でもある。例えば年に一度の大規模停電で想定されるダウンタイムが2時間、時間当たりの損失が20万ドルだとしよう[6]。RTOを約1時間に短縮できるなら、1回あたり数十万ドル規模の損失回避が見込める。バックアップの検証自動化、WALの多重送信、復元先のI/O増強、演習の工数を合計しても、年コストは十分に回収可能だと定量的に主張できる。逆に、ドキュメントの陳腐化や権限の詰まり、連絡体制の不備が積み重なると、投資どころかリスクの増幅になる。だからこそ、手順書をコードと同じくCIに乗せ、変更はプルリクエストでレビューし、演習の結果を証跡として添付する運用に移すのがよい。社内ポータルの最上段に“非常時の一枚目”を置き、オフラインでも参照できるPDFと紙の配布を併用する。

より深く学びたい場合は、バックアップ戦略の基礎を別稿で整理している。

まとめ:明文化し、計測し、訓練する

停電・災害に強い組織は偶然には生まれない。合意されたRTO/RPOを言葉で固定し、バックアップの検証と自動化を手順書に組み込み、数字で健全性を語れるようにするところから始まる。今日できる最小の一歩として、主要サービスの第一群だけに対象を絞った“非常時の一枚目”を起こし、来週のテーブルトップ演習を30分だけ予定に差し込むとよい。次に、ステージングでのポイント・イン・タイム復元を90分枠で回し、RTO/RPOの実測値を記録する。小さな勝ちを積み上げながら、文書と自動化の両輪を回す。あなたのチームの次の障害は、きっと今日よりも静かに収束するはずだ。

参考文献

  1. Uptime Institute. 2022 Outage Analysis Finds Downtime Costs and Consequences Worsening
  2. ITIC. Cost of Hourly Downtime Soars: 81% of Enterprises Say it Exceeds $300K on Average (2023)
  3. Uptime Institute. 11th Annual Global Data Center Survey
  4. IPA(独立行政法人 情報処理推進機構). BCPにおける復旧目標(RTO/RPO等)の定義
  5. USENIX. Running Disaster Recovery Plan Tabletop Exercises
  6. ITIC. 2024 Hourly Cost of Downtime Report