障害の振り返りで「注意する」を再発防止策にしない — 防ぐ・気づく・狭める
この記事で扱う問題
例えば、次のような障害があったとします。
別の不具合の影響で、未確定の注文が重複して作られました。担当者が本番の DB に接続し、重複した注文を UPDATE orders SET status = 'cancelled' WHERE ... でキャンセル済みにしました。ところが WHERE の条件が広すぎて、重複していない注文まで、確定済みのものを含めてキャンセル済みになりました。気づいたのは翌日、利用者からの問い合わせででした。
振り返りの会では、再発防止策として次のように書かれがちです。
- 本番で SQL を実行する前に、条件をよく確認する
- 本番の作業は、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種類の対策をそろえる:影響が小さく、めったに起きず、対策のほうが高くつくなら、「受け入れる」と決めて理由を残せば足ります。対策の数は、振り返りの質の指標ではありません
- 年に一度の作業を、すべてコマンドにする:手順が長く、毎回中身が違う作業(例えば年に一度の大きなデータ移行)は、コマンドより手順書と事前の検証環境での実行のほうが合います。手順書を使うときは、各段階で何を確かめれば次に進んでよいかまで書きます
- 監視を足し続ける:通知が多すぎると、本当の異常の通知も読まれなくなります。足す監視は「その通知が来たら、誰が何をするか」が決まっているものだけにします
- 権限を細かく分けすぎる:障害の対応で必要な調査(読み取り)まで止めると、復旧が遅れます。外すのは更新の権限で、読み取りは残します
まとめ
- 「注意する」「ダブルチェックする」は、次に作業する人の注意に頼るだけで、仕組みは何も変わらない
- 原因を「なぜ間違えられたか・なぜ遅れたか・なぜ広がったか」まで問い、防ぐ・気づく・狭めるの対策に分ける
- 対策はコード・設定・監視の変更として書き、担当・期限・完了の確かめ方を付ける。対策しないものは理由を残す