障害対応 ·

切り戻すか前進修正か — リリース後の障害で迷わないための基準

  • 障害対応
  • リリース
  • データ移行

この記事で扱う問題

リリースの直後に、注文の確定がときどき 500 を返すようになったとします。原因はこのリリースにありそうです。取れる手は、おおよそ2つです。

  • 切り戻す:1つ前のバージョンのコードに戻す
  • 前進修正する:原因を直したコードを、もう一度リリースする

このとき、例えば次のことが起こり得ます。

  • 「原因はたぶんここだ、すぐ直せる」と前進修正を選び、修正を出したら別の箇所で落ちる。気づけば1時間たっていて、その間ずっと障害が続いている
  • 切り戻したら、新しいコードが書いたデータを古いコードが読めず、障害の範囲がかえって広がる
  • 切り戻すつもりで migrate:rollback を実行し、新しいコードが列に書いたデータごと消える
  • 新しいコードがディスパッチしたジョブがキューに残っていて、切り戻した後のワーカーがそれを処理できずに失敗し続ける

どれも、障害が起きてから考え始めたことが原因です。どちらを選ぶかより、いつ、何を基準に決めるかが先に決まっていないことが問題です。

判断

既定は切り戻し。前進修正は条件がそろったときだけ

私は、切り戻しを既定にします。前進修正を選ぶのは、次の3つがすべてそろったときだけです。

  1. 原因が特定できている(「たぶん」ではなく、ログや再現で確かめた)
  2. 修正が小さく、影響範囲が読める
  3. 修正を、普段と同じ CI とレビューを通して出せる

3つ目は省きがちですが、急いでテストを飛ばした修正は、2つ目の障害の入口になります。普段の手順で出せないほど急ぐなら、それは切り戻すべき状況です。

逆に、切り戻せない、または切り戻すほうが悪くなるなら、前進修正しかありません。それがどんなリリースなのかを、リリースの前に知っておく必要があります。

コードを戻しても、データとキューは戻らない

切り戻しで戻るのはコードだけです。新しいコードが動いていた間に書いたデータ、ディスパッチしたジョブ、外部に送った依頼は残ります。切り戻せるかは、残ったものを古いコードが扱えるかで決まります。

例えば、リリースで OrderStatus に「支払い待ち」を足し、新しいコードが awaiting_payment の注文を書き始めたとします。切り戻すと、古いコードの OrderStatus::from('awaiting_payment') は ValueError を投げ、その注文を読む画面や処理がすべて落ちます。

これを避けるには、リリースを2つに分けます。

enum OrderStatus: string
{
    case Pending = 'pending';
    case Confirmed = 'confirmed';
    case Cancelled = 'cancelled';

    // 1つ目のリリースで足す。この時点では、どのコードもこの値を書かない。
    // この値を書き始めるのは2つ目のリリースから。2つ目を切り戻しても、1つ目のコードはこの値を扱える
    case AwaitingPayment = 'awaiting_payment';

    public function canBeConfirmed(): bool
    {
        return $this === self::Pending;
    }

    public function canBeCancelled(): bool
    {
        // 支払い待ちの注文もキャンセルできる。このルールも1つ目のリリースに入れる
        return $this === self::Pending || $this === self::AwaitingPayment;
    }

    // 支払いが済んだら Pending に進める処理も1つ目のリリースに入れる。置き場所は Order の側(省略)
}

1つ目のリリースに入れるのは、値を読めることだけではありません。支払い待ちの注文に対して何ができるか(ここではキャンセル。支払いが済んだら確定待ちに進める処理も同じです)まで入れておきます。読めても遷移のルールがなければ、切り戻した後、支払い待ちの注文は確定もキャンセルもできずに止まったままになります。

クライアントを壊さない API 変更で書いた「各段階で1つ前に戻しても壊れないか」と同じ考え方を、自分たちのコードの新旧にも当てはめています。

ほかにも、次のものは切り戻しを難しくします。

  • 列やテーブルの削除、列の意味の書き換え:例えば、既存の total_amount を「送料込み」に書き換えるバックフィル。古いコードは送料抜きのつもりで読み、送料を二重に足します。意味を変えるなら新しい列に書き、古い列はそのまま残します
  • NULL を許さず既定値もない列の追加:古いコードはその列を知らないまま INSERT するので、切り戻した瞬間に INSERT が失敗します。NULL を許すか、既定値を付けて足します
  • ジョブのクラスやコンストラクタの引数の変更:新しいコードがディスパッチしたジョブを、古いコードのワーカーが復元できない(新しいクラスが古いコードにない)か、復元できても必要な値が欠けたまま動き、リトライを使い切って失敗します。失敗したジョブは、修正を出した後に queue:retry でやり直せますが、それにはジョブが何回実行されても結果が同じになることが前提です

なお、本番の切り戻しでは migrate:rollback を使いません。スキーマは新しいまま残し、コードだけを戻します。上のように足すだけの変更なら、古いコードはそのまま動きます。down() で列を消すと、その間に書かれたデータが消え、もう一度リリースし直すときに取り戻せません。

止めるだけなら、デプロイしない手段を用意しておく

切り戻しの難しいリリースほど、新しい振る舞いだけを止める手段を用意しておきます。例えば、送料の計算を新しくするリリースなら、計算の切り替えを実行時に読むフラグで決めます。ShippingFeePolicy は、配送業者の見積もり(既存の記事の ShippingGateway::quoteFee())を使わず、自分たちで送料を計算する場合の例です(Money は既存の記事の定義です)。

use Illuminate\Database\ConnectionInterface;

interface FeatureFlags
{
    public function isEnabled(string $name): bool;
}

final class DatabaseFeatureFlags implements FeatureFlags
{
    public function __construct(private ConnectionInterface $db)
    {
    }

    public function isEnabled(string $name): bool
    {
        // 行がなければ無効。止めるときは enabled を false にする。デプロイもワーカーの再起動も要らない。
        // 呼ぶたびに DB を読む。負荷が気になるなら、数秒だけキャッシュする
        return (bool) $this->db->table('feature_flags')->where('name', $name)->value('enabled');
    }
}

interface ShippingFeePolicy
{
    public function feeFor(string $postalCode, Money $subtotal): Money;
}

final class SwitchableShippingFeePolicy implements ShippingFeePolicy
{
    public function __construct(
        private FeatureFlags $flags,
        private ShippingFeePolicy $current,   // リリース前からの計算
        private ShippingFeePolicy $next,      // このリリースで入れた計算
    ) {
    }

    public function feeFor(string $postalCode, Money $subtotal): Money
    {
        return $this->flags->isEnabled('shipping-fee-v2')
            ? $this->next->feeFor($postalCode, $subtotal)
            : $this->current->feeFor($postalCode, $subtotal);
    }
}

フラグを .env や config/ に置かないのは、config:cache している環境では、値を変えるのに設定のキャッシュの作り直しが要り、キューのワーカーは再起動するまで古い値を持ち続けるからです。それでは、止めるのにデプロイと同じ手間がかかります。Laravel Pennant を使う場合も、一度解決した値をプロセスのメモリに覚えておくので、常駐するワーカーで切り替えた値がいつ読み直されるかを先に確かめます。止めるだけのフラグなら、上のように毎回読む小さなクラスで足りると考えています。

フラグで止められるのは、新しい計算が書いたデータを、古い計算の側でもそのまま扱える場合だけです。上の例なら、どちらの計算でも、保存するのは送料の金額という同じ意味の値です。ただし、見積もりの画面を旧の計算で見せた後に、確定を新の計算で行うと金額がずれます。送料は確定のときに計算し直し、その値を保存します。

判断にかける時間を、リリースの前に決める

障害の最中は、「あと5分で原因が分かりそう」が何度も続きます。そこで、リリースの計画に次のことを書いておきます。

## 切り戻し
- 手順:1つ前のタグを再デプロイ(スキーマは戻さない)。所要時間の目安
- 切り戻せなくなる時点:なし(このリリースは列の追加だけ)
- 止める手段:feature_flags の shipping-fee-v2 を false にする
- 判断する人:(名前)
- 判断の期限:異常を検知してから、例えば15分。それまでに前進修正の3条件がそろわなければ切り戻す

期限は、延長しない前提で決めます。期限が来た時点で原因が分かっていなければ、切り戻してから、落ち着いて原因を調べます。原因の分からない障害の切り分けでも書いたとおり、復旧が先で、原因の特定はその後です。

「切り戻せなくなる時点」がなしにならないリリース(列の削除や、意味を書き換えるデータ移行)は、ほかの変更と同じリリースに入れず、単独で出します。その時点を越えた後に障害が起きたら、前進修正しかないと分かっているので、判断で迷う時間がなくなります。

切り戻した後に、残ったものを片づける

復旧した後は、誤った送料で確定した注文や、失敗して残ったジョブなど、新しいコードが動いていた間の影響を確かめます。リリースのバージョンをログのコンテキストに入れておくと、影響の範囲を時刻だけに頼らず絞り込めます。

なぜそう判断したか

前進修正を既定にしない理由

障害の最中に立てた「原因はここだ」という仮説は、外れることが多いと考えています。外れていれば、修正を出すたびに待ち時間が増え、その間も障害は続きます。前進修正にかかる時間は、事前には読めません。

切り戻しは、上で見た「残ったもの」の確認さえ済んでいれば、かかる時間が読めます。戻す先のバージョンは、すでに本番で動いていた実績があるからです。読める時間で確実に復旧できるほうを既定にし、読めない方は条件がそろったときだけ選びます。

切り戻しを絶対の規則にしない理由

一方で、「障害が起きたら必ず切り戻す」とも決めません。上で見たとおり、データやキューの状態によっては、切り戻しが新しい障害を起こします。切り戻しも1回のデプロイで、ビルドや配布に時間がかかる構成なら、1行の明らかな修正を出すほうが早いこともあります。

規則にするのは「どちらを選ぶか」ではなく、「どの条件で選ぶか」と「いつまでに選ぶか」です。

判断の期限を決めておく理由

障害の最中に一番時間を使うのは、修正そのものより、決めることです。調べ始めると、それまでにかけた時間を無駄にしたくなくなり、切り戻しの判断がさらに遅れます。期限と判断する人を先に決めておけば、障害の最中に決めることは「3条件がそろったか」だけになります。

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

  • すべての変更にフラグを付ける:フラグは、付けた数だけ分岐と組み合わせが増え、外し忘れると負債になります。切り戻しが難しいリリースや、振る舞いの変化が大きい変更にだけ付け、安定したら外します
  • すべてのリリースに切り戻しの計画を書く:コードだけの変更で、切り戻せなくなる時点がないなら、「1つ前のタグに戻す」の1行で足ります。計画を書き込むのは、データ移行やキューのジョブの変更を含むリリースです
  • 少人数のチームで、判断に承認の手順を挟む:判断する人を1人決めておけば足ります。承認を待つ時間も、障害の時間です

まとめ

  • 切り戻しを既定にし、前進修正は「原因が特定できている・修正が小さい・普段の CI を通せる」がそろったときだけ選ぶ
  • コードを戻しても、データ・キューのジョブ・外部への依頼は戻らない。古いコードが残ったものを扱えるように、リリースを分ける
  • 切り戻しの手順、切り戻せなくなる時点、判断する人と期限を、リリースの前に決めておく

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