障害対応 ·

障害の振り返りで「注意する」を再発防止策にしない — 防ぐ・気づく・狭める

  • 障害対応
  • 振り返り
  • 運用

この記事で扱う問題

例えば、次のような障害があったとします。

別の不具合の影響で、未確定の注文が重複して作られました。担当者が本番の DB に接続し、重複した注文を UPDATE orders SET status = 'cancelled' WHERE ... でキャンセル済みにしました。ところが WHERE の条件が広すぎて、重複していない注文まで、確定済みのものを含めてキャンセル済みになりました。気づいたのは翌日、利用者からの問い合わせででした。

振り返りの会では、再発防止策として次のように書かれがちです。

  • 本番で SQL を実行する前に、条件をよく確認する
  • 本番の作業は、2人でダブルチェックする
  • 今回の件をチームに周知する

どれも間違ってはいません。ただ、どれも「次に作業する人が、そのときに覚えていて、正しく実行する」ことに頼っています。今回の担当者も、注意していなかったわけではないはずです。障害の対応中で急いでいて、条件の誤りに気づけなかった。次に同じ状況になれば、別の人が同じように間違えます。

しかも、「注意する」には担当も期限もなく、いつ終わったのかを確かめられません。振り返りの記録には対策が並んでいるのに、仕組みは何も変わっていない、という状態になります。

判断

原因を「人が間違えた」で止めず、「仕組みが間違いを通した」まで書く

振り返りでは、人の操作の誤りを、原因の終点にしません。誤りは起点として扱い、そこから次の3つを問います。

  1. なぜ、その間違いができたのか:本番の注文を、ルールを通さずに書き換えられる経路があった
  2. なぜ、気づくのが遅れたのか:キャンセルの件数が急に増えても、誰にも知らせが届かなかった
  3. なぜ、影響がその範囲まで広がったのか:1回の操作で変えられる件数に上限がなく、何を変えたかの記録も残らなかった

3つの問いは、そのまま3種類の対策に対応します。

種類 問い この障害での対策の例
防ぐ なぜ間違えられたか 作業用のアカウントから、本番の DB を更新する権限を外す。注文の補正は、ドメインのルールを通るコマンドで行う
気づく なぜ遅れたか キャンセルの件数を監視し、普段より大きく増えたら知らせる
狭める なぜ広がったか コマンドは ID の一覧で対象を受け取り、件数の上限を付け、変更した ID を記録する

1つの障害に、3種類すべてが要るとは限りません。ただ、「防ぐ」だけを考えると、防ぎきれなかったときに同じ規模の障害がまた起きます。3つの問いを順に当てると、対策の抜けに気づけます。

対策は、コード・設定・監視の変更として書く

今回の例なら、「防ぐ」と「狭める」は1つのコマンドにまとめられます。補正の SQL を手で書く代わりに、既存の CancelOrder(生成コードのレビューの記事で定義した UseCase)を呼ぶ Artisan コマンドを用意します。

use Illuminate\Console\Command;
use Illuminate\Database\ConnectionInterface;
use Psr\Log\LoggerInterface;

final class CancelOrdersCommand extends Command
{
    protected $signature = 'orders:cancel
        {file : キャンセルする注文の ID を1行に1つ書いたファイル}
        {--reason= : 補正の理由。チケットの番号など}
        {--max=100 : 1回でキャンセルしてよい件数の上限}
        {--execute : 付けなければ、対象の状態を表示するだけで何も変えない}';

    protected $description = '指定した ID の注文を、CancelOrder を通してキャンセルする';

    public function handle(
        CancelOrder $cancelOrder,
        RetryOnConcurrentModification $retry,
        ConnectionInterface $db,
        LoggerInterface $logger,
    ): int {
        $reason = (string) $this->option('reason');
        if ($reason === '') {
            $this->error('--reason を指定してください');

            return self::INVALID;
        }

        $max = filter_var($this->option('max'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
        if ($max === false) {
            $this->error('--max には 1 以上の整数を指定してください');

            return self::INVALID;
        }

        $ids = $this->readIds((string) $this->argument('file'));
        if ($ids === [] || count($ids) > $max) {
            $this->error(sprintf('対象が %d 件です。1〜%d 件にしてください', count($ids), $max));

            return self::INVALID;
        }

        // 変える前に、対象の今の状態を件数で見せる。存在しない ID もここで止める
        $statuses = $db->table('orders')->whereIn('id', $ids)->pluck('status', 'id');
        $missing = array_diff($ids, $statuses->keys()->all());
        if ($missing !== []) {
            $this->error('存在しない注文があります: ' . implode(', ', $missing));

            return self::INVALID;
        }
        foreach ($statuses->countBy() as $status => $count) {
            $this->line("{$status}: {$count} 件");
        }

        if (! $this->option('execute')) {
            $this->info('確認だけで終了しました。変更するには --execute を付けます');

            return self::SUCCESS;
        }

        $skipped = [];
        foreach ($ids as $id) {
            try {
                // 1件ずつ、CancelOrder の中の別のトランザクションで変える。ほかの更新と衝突したら読み直してやり直す
                $retry->run(fn () => $cancelOrder->execute(new OrderId($id)));
                $logger->info('order cancelled by correction', ['order_id' => $id, 'reason' => $reason]);
            } catch (OrderCannotBeCancelled $e) {
                // 確定済み・キャンセル済みの注文は、ルールのとおり止まる。補正のコマンドを、ルールの抜け道にしない
                $skipped[] = $id;
            }
        }

        $this->info(sprintf('キャンセル %d 件、対象外 %d 件', count($ids) - count($skipped), count($skipped)));
        if ($skipped !== []) {
            // 確定済みの注文が対象外に出るのは、一覧が誤っていた知らせ。今の状態と一緒に残す
            $current = $db->table('orders')->whereIn('id', $skipped)->pluck('status', 'id')->all();
            foreach ($current as $id => $status) {
                $this->warn("対象外: {$id}({$status})");
            }
            $logger->warning('orders skipped by correction', ['orders' => $current, 'reason' => $reason]);
        }

        return self::SUCCESS;
    }

    /** @return list<string> */
    private function readIds(string $path): array
    {
        if (! is_readable($path)) {
            return [];
        }

        $lines = file($path, FILE_IGNORE_NEW_LINES) ?: [];
        $ids = array_filter(array_map('trim', $lines), fn (string $line): bool => $line !== '');

        return array_values(array_unique($ids));
    }
}

このコマンドで、例の障害の原因のうち、仕組みで止められるものを止め、止められないものには気づける場所を作ります。

  • 確定済みの注文をキャンセルできない:状態を変えるのは Order::cancel() だけなので、手で書いた SQL では守られなかったルールを、補正でも通ります。誤って一覧に入った確定済みの注文は、変わらずに「対象外」として状態と一緒に出ます
  • 対象を条件ではなく ID の一覧で渡す:WHERE の条件を本番で組み立てる代わりに、対象を探すクエリと、変える操作を分けます。2人目が確かめるのは SQL の条件ではなく、ID の一覧と、--execute を付けずに実行したときの状態ごとの件数です。「confirmed: 12 件」と出れば、変える前に止められます
  • 未確定の注文の誤りは、ルールでは止まらない:重複していない未確定の注文が一覧に入っても、cancel() は通します。一覧が同じ広すぎる条件から作られていれば、件数は「pending: 60 件」のように出るだけです。ここで頼りにするのは、件数が予定(重複して作られた件数)と合うかどうかです。予定の件数は、一覧を作る前に、重複の原因になった不具合の側から数えておきます
  • 件数の上限と記録:上限を超えれば何も変えません。キャンセルした ID は理由と一緒にログに残るので、誤りがあっても、戻す対象を探し直さずに済みます

途中で例外が出てコマンドが止まった場合は、同じファイルでもう一度実行します。キャンセル済みの注文は OrderCannotBeCancelled で飛ばされるので、2回実行しても二重には変わりません。保存の途中で接続が切れると、コミットされたのにログに残らない注文があり得ます。ログは「キャンセルしたことが確かな分」として扱い、実際の状態は、--execute を付けずに実行し直して件数で確かめます。

そのうえで、作業用のアカウントからは本番の DB を更新する権限を外します。コマンドを用意しても、SQL を直接打てる経路が残っていれば、急いでいるときにはそちらが使われるからです。ただし、コマンドは本番のサーバーで、アプリの DB の認証情報を使って動きます。そこにログインできる人は、php artisan tinker を開くことも .env を読むこともできるので、権限を外しただけでは経路は残ります。コマンドは、ジョブの実行基盤のような決まった入口から、決まったコマンドだけを流せるようにし、本番のサーバーへのログインや tinker は、別の権限として扱います。

コマンドには、今回の障害そのものをテストとして残します。

use Illuminate\Console\Command;
use Illuminate\Foundation\Testing\RefreshDatabase;

final class CancelOrdersCommandTest extends TestCase
{
    use RefreshDatabase;

    public function test_確定済みの注文は補正のコマンドでもキャンセルされない(): void
    {
        $pending = OrderModel::factory()->pending()->create();
        $confirmed = OrderModel::factory()->confirmed()->create();

        $this->artisan('orders:cancel', [
            'file' => $this->idFile([$pending->id, $confirmed->id]),
            '--reason' => 'TICKET-123',
            '--execute' => true,
        ])->assertSuccessful();

        $this->assertSame('cancelled', $pending->fresh()->status);
        $this->assertSame('confirmed', $confirmed->fresh()->status);
    }

    public function test_executeを付けなければ1件も変えない(): void
    {
        $order = OrderModel::factory()->pending()->create();

        $this->artisan('orders:cancel', [
            'file' => $this->idFile([$order->id]),
            '--reason' => 'TICKET-123',
        ])->assertSuccessful();

        $this->assertSame('pending', $order->fresh()->status);
    }

    public function test_上限を超える一覧では1件も変えない(): void
    {
        $orders = OrderModel::factory()->pending()->count(3)->create();

        $this->artisan('orders:cancel', [
            'file' => $this->idFile($orders->pluck('id')->all()),
            '--reason' => 'TICKET-123',
            '--max' => 2,
            '--execute' => true,
        ])->assertExitCode(Command::INVALID);

        foreach ($orders as $order) {
            $this->assertSame('pending', $order->fresh()->status);
        }
    }

    /** @param list<int|string> $ids */
    private function idFile(array $ids): string
    {
        $path = tempnam(sys_get_temp_dir(), 'order-ids');
        file_put_contents($path, implode("\n", $ids));

        return $path;
    }
}

「注意する」は、次の人に伝わる保証がありません。テストに書いた「確定済みの注文は補正でもキャンセルされない」は、誰かがコマンドを書き換えたときに CI で止まります。

対策には、担当・期限・完了の確かめ方を付ける

振り返りの記録では、対策を次の形で書きます。

## 再発防止策
| 種類 | 対策 | 担当 | 期限 | 完了の確かめ方 |
|---|---|---|---|---|
| 防ぐ | 作業用アカウントから本番 DB の更新権限を外す | (名前) | 2週間後 | 作業用アカウントで UPDATE が拒否される |
| 防ぐ | 補正のコマンドを実行基盤からだけ流す。本番サーバーへのログインと tinker は別の権限にする | (名前) | 1か月後 | 普段の権限では本番サーバーにログインできない |
| 防ぐ | 注文の補正を orders:cancel で行う | (名前) | 2週間後 | コマンドとテストがマージされている |
| 気づく | キャンセル件数の監視と通知 | (名前) | 1か月後 | 検証環境で閾値を超えて通知が届く |
| 受け入れる | ID の一覧の誤り(未確定の注文) | - | - | 件数の確認で気づける。誤ってキャンセルした注文は、補正のログに残った ID から利用者に連絡できるため、これ以上の対策はしない |

「完了の確かめ方」が書けない対策は、たいてい「注意する」の言い換えです。書けなければ、対策をもう一段具体的にします。対策をしないと決めたものも、理由と一緒に残します。次に同じ種類の障害が起きたとき、前回は何を判断して対策しなかったのかが分かるからです。

なぜそう判断したか

「注意する」を再発防止策にしない理由

人の注意は、一番必要なときに一番働きません。本番の作業が発生するのは、多くの場合、別の障害の対応中です。時間に追われ、確認の手順を省きたくなる状況で、「よく確認する」に頼ることになります。

もう1つは、注意を求める対策は、責任を個人に戻してしまうことです。「次は注意する」と書かれた振り返りでは、担当者は自分の誤りを認めることになり、次からは自分の操作の誤りを報告しにくくなります。振り返りで問うのは「誰が間違えたか」ではなく、「どこで間違いを止められたか」です。それも、原因を切り分けたときと同じく、推測ではなく事実をもとに問います。

ダブルチェックを主な対策にしない理由

2人で見るのは有効です。ただ、2人目が SQL の条件を読んで確かめるのは、1人目と同じ作業の繰り返しです。2人とも同じ思い込みを持っていれば、同じ誤りを見逃します。上のコマンドで2人目が見るのは、条件ではなく「何件が、どの状態から変わるか」という結果です。確かめる対象を、間違えにくいものに変えています。

補正にもドメインのルールを通す理由

状態の変更をオブジェクトに持たせると決めても、補正だけ SQL で直接書けるなら、そこがルールの抜け道になります。補正は普段の操作より急いで、しかも一度に多くの行に対して行われます。ルールが一番必要な場面です。今回は既存の cancel() で足りましたが、普段の操作にない遷移(キャンセル済みを未確定に戻すなど)が必要なら、Order に補正のための振る舞いを足し、そこでも記録すべきことを決めます。

「防ぐ」だけで終わらせない理由

すべての誤りを防ぐことはできません。権限を外しても、コマンドに渡す ID の一覧そのものを誤ることはあります。防ぎきれなかったときに、早く気づき、影響が小さいうちに止められれば、同じ誤りでも障害の大きさが変わります。切り戻しの記事で書いた、デプロイしないで新しい振る舞いを止めるフラグも、「狭める」の対策です。

どこから設計過剰になるか

  • すべての障害に、3種類の対策をそろえる:影響が小さく、めったに起きず、対策のほうが高くつくなら、「受け入れる」と決めて理由を残せば足ります。対策の数は、振り返りの質の指標ではありません
  • 年に一度の作業を、すべてコマンドにする:手順が長く、毎回中身が違う作業(例えば年に一度の大きなデータ移行)は、コマンドより手順書と事前の検証環境での実行のほうが合います。手順書を使うときは、各段階で何を確かめれば次に進んでよいかまで書きます
  • 監視を足し続ける:通知が多すぎると、本当の異常の通知も読まれなくなります。足す監視は「その通知が来たら、誰が何をするか」が決まっているものだけにします
  • 権限を細かく分けすぎる:障害の対応で必要な調査(読み取り)まで止めると、復旧が遅れます。外すのは更新の権限で、読み取りは残します

まとめ

  • 「注意する」「ダブルチェックする」は、次に作業する人の注意に頼るだけで、仕組みは何も変わらない
  • 原因を「なぜ間違えられたか・なぜ遅れたか・なぜ広がったか」まで問い、防ぐ・気づく・狭めるの対策に分ける
  • 対策はコード・設定・監視の変更として書き、担当・期限・完了の確かめ方を付ける。対策しないものは理由を残す

本記事の設計判断・事例・技術的見解は筆者によるものです。構成・文章編集には生成AIを利用しています。