0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

サーキットブレーカーが開いた直後に閉じる:in-flight の成功を復旧と数えてはいけない

0
Posted at

結論から書きます。サーキットブレーカーの復旧判定を「成功したら閉じる」で実装すると、開いた直後に閉じます。

開いた瞬間、その接続先には開く前に投げたリクエストがまだ in-flight のまま残っています。遅いものは数秒後に成功して戻ってくるので、素朴な recordChannelSuccess() はそれを「復旧」と読み、開いたばかりのブレーカーを閉じます。手元で再現したら、開いてから 1.85 秒で閉じました。

サーキットブレーカーが開いた直後に閉じる:in-flight の成功を復旧と数えると、開いた 1.85 秒後に閉じる

複数の上流チャネルをフォールバックチェーンでつないでいるサービスで、この穴を踏みました。ブレーカーが早く閉じると、次のリクエストはまた同じ壊れた道に送られます。この経路の TTFT タイムアウトは 60 秒なので、閉じているはずだった時間がそのままユーザーの待ち時間になります。3 本ぶつければ、また閾値に達するまで 3 分です。

以前、単体のサーキットブレーカー実装をこちらの記事に書きました。そのときの recordChannelSuccess() は、まさにこの記事で直す形をしていました。本番では閾値の判定も「連続失敗」から「窓内の累計」に変えていて、その理由は後半に書きます。

何が起きるか

時系列で書くとこうです。

時刻 出来事
t=0 遅いリクエスト A を投げる(この時点では上流は正常。2 秒かけて成功して戻る予定)
t=50 / 100 / 150 後続の 3 本が 503 で落ちる。閾値 3 に到達してブレーカーが開く
t=2000 A が成功して戻ってくる。recordChannelSuccess() が呼ばれる
t=2000 ブレーカーが閉じる(開いてから 1.85 秒)

A が証明しているのは「開く前の一瞬、この経路が通っていた」ことだけです。開いたあとに通るかどうかは何も言っていません。それでも素朴な実装は、これを復旧として数えてしまいます。

// 素朴な実装
export function recordChannelSuccess(channel: string, model: string): void {
  const entry = snapshot.get(modelKey(channel, model));
  if (!entry) return; // 無効化されていなければ何もしない
  close(entry); // 成功した = 復旧した、と読む
}

close() を呼ぶ根拠が「成功した」ことだけで、その成功がどの時点の話かを見ていません。ここが穴でした。

再現する

本番の判定ロジックから、この部分だけを抜き出したものです。Redis はインメモリに、時計は注入して決定論的にしました。Node.js でそのまま動きます(bun breaker-demo.ts、または node --experimental-strip-types breaker-demo.ts)。

type Entry = { key: string; openedAt: number; until: number };

/** Redis の代わり。hsetnx / hget / hdel しか使わない。 */
class FakeRedis {
  private hash = new Map<string, string>();
  async hsetnx(key: string, value: string): Promise<number> {
    if (this.hash.has(key)) return 0;
    this.hash.set(key, value);
    return 1;
  }
  async hget(key: string): Promise<string | null> {
    return this.hash.get(key) ?? null;
  }
  async hdel(key: string): Promise<number> {
    return this.hash.delete(key) ? 1 : 0;
  }
  has(key: string): boolean {
    return this.hash.has(key);
  }
}

class Breaker {
  /** 各ワーカーのローカルスナップショット(本番は 2 秒ごとに Redis から引き直す)。 */
  local = new Map<string, Entry>();
  private counts = new Map<string, number>();

  private redis: FakeRedis;
  private mode: 'naive' | 'guard';
  private now: () => number;
  private threshold: number;
  private cooldownMs: number;

  constructor(
    redis: FakeRedis,
    mode: 'naive' | 'guard',
    now: () => number,
    threshold = 3,
    cooldownMs = 30_000,
  ) {
    this.redis = redis;
    this.mode = mode;
    this.now = now;
    this.threshold = threshold;
    this.cooldownMs = cooldownMs;
  }

  private key(channel: string, model: string) {
    return `mo:${channel}|${model}`;
  }

  /** ローカルスナップショットを取り直す。 */
  async refresh() {
    this.local.clear();
    for (const [key, value] of this.redis['hash'].entries()) {
      this.local.set(key, JSON.parse(value) as Entry);
    }
  }

  /** 失敗を 1 件記録し、窓内で閾値に届いたら開く。 */
  async fail(channel: string, model: string) {
    const key = this.key(channel, model);
    if (this.redis.has(key)) return; // 開いている間は数えない
    const n = (this.counts.get(key) ?? 0) + 1;
    this.counts.set(key, n);
    if (n < this.threshold) return;
    const entry: Entry = {
      key,
      openedAt: this.now(),
      until: this.now() + this.cooldownMs,
    };
    await this.redis.hsetnx(key, JSON.stringify(entry));
    this.local.set(key, entry); // 別ワーカーが開いた場合も即座に反映する
  }

  /**
   * 成功を 1 件記録する。startedAt は「そのリクエストを投げた時刻」。
   * naive: ローカルスナップショットに載っていれば閉じる。
   * guard: 投げたのが開いた後であることを確かめてから閉じる。
   */
  async success(channel: string, model: string, startedAt: number) {
    const key = this.key(channel, model);
    const entry = this.local.get(key);
    if (!entry) return { closed: false as const, reason: 'not-open' };
    if (this.mode === 'naive') {
      await this.redis.hdel(key);
      this.local.delete(key);
      this.counts.delete(key);
      return { closed: true as const, downtimeMs: this.now() - entry.openedAt };
    }
    if (entry.openedAt > startedAt)
      return { closed: false as const, reason: 'started-before-open(local)' };
    const raw = await this.redis.hget(key);
    if (!raw) return { closed: false as const, reason: 'closed-elsewhere' };
    const latest = JSON.parse(raw) as Entry;
    if (latest.openedAt > startedAt)
      return { closed: false as const, reason: 'started-before-open(redis)' };
    await this.redis.hdel(key);
    this.local.delete(key);
    this.counts.delete(key);
    return { closed: true as const, downtimeMs: this.now() - latest.openedAt };
  }
}

const pad = (label: string, value: string) =>
  console.log(`${label.padEnd(12)} ${value}`);

// ── デモ 1 ──────────────────────────────────────────────
for (const mode of ['naive', 'guard'] as const) {
  let t = 0;
  const now = () => t;
  const breaker = new Breaker(new FakeRedis(), mode, now);

  // t=0: 障害が始まる前の最後のリクエストを投げる(上流は 2 秒かけて成功する)
  const inFlightStartedAt = now();

  // t=50 / 100 / 150: 後続の 3 本が素早く 503 で落ちる
  for (const at of [50, 100, 150]) {
    t = at;
    await breaker.fail('pool-a', 'model-x');
  }
  const openedAt = now();

  // t=2000: 最初のリクエストが成功して戻ってくる
  t = 2000;
  const res = await breaker.success('pool-a', 'model-x', inFlightStartedAt);
  pad(
    `[1] ${mode}`,
    res.closed
      ? `open at t=+${openedAt}ms -> CLOSED by in-flight success (downtime ${res.downtimeMs}ms)`
      : `open at t=+${openedAt}ms -> still open (${res.reason})`,
  );
}

// ── デモ 2 ──────────────────────────────────────────────
for (const mode of ['naive', 'guard'] as const) {
  let t = 0;
  const now = () => t;
  const redis = new FakeRedis();
  // ワーカー A と B が同じ Redis を見ている。ローカルスナップショットは別物。
  const workerA = new Breaker(redis, mode, now, 1);
  const workerB = new Breaker(redis, mode, now, 1);

  // t=0: 1 回目のブレーカーが開く
  await workerB.fail('pool-a', 'model-x');
  await workerA.refresh(); // A は openedAt=0 を掴んだきり、次の更新は 2 秒後

  // t=100: 障害前に投げていたリクエスト(このあと成功して戻る)
  const inFlightStartedAt = 100;

  // t=200: プローブが通って 1 回目が閉じ、直後に 2 回目のブレーカーが開く(開いたのは B)
  t = 200;
  await redis.hdel('mo:pool-a|model-x');
  await workerB.fail('pool-a', 'model-x');

  // t=250: そのリクエストが成功して A に戻る
  t = 250;
  const res = await workerA.success('pool-a', 'model-x', inFlightStartedAt);
  pad(
    `[2] ${mode}`,
    res.closed
      ? `CLOSED the 2nd breaker opened at t=+200ms (success started at t=+100ms)`
      : `kept the 2nd breaker open (${res.reason})`,
  );
  pad(`[2] ${mode}`, `redis still has entry: ${redis.has('mo:pool-a|model-x')}`);
}

mode を 'naive' と 'guard' で差し替えて走らせた結果です。

[1] naive    open at t=+150ms -> CLOSED by in-flight success (downtime 1850ms)
[1] guard    open at t=+150ms -> still open (started-before-open(local))

naive 側は、閾値に達して開いた 1.85 秒後に閉じています。閉じた根拠は「A が成功した」だけです。

直し方:成功に「投げた時刻」を持たせる

recordChannelSuccess に、そのリクエストを投げた時刻を渡します。

export function recordChannelSuccess(
  channel: string,
  model: string,
  /** このリクエストを投げた時刻(Date.now() 口径)。 */
  startedAt: number,
): void {
  const entries = [modelKey(channel, model), channelKey(channel)]
    .map((key) => snapshot.get(key))
    .filter(
      (entry): entry is BreakerEntry =>
        !!entry && entry.openedAt <= startedAt, // ← 投げたのは無効化より後
    );
  if (entries.length === 0) return;
  // ... ここで閉じる
}

呼び出し側は、リクエストを投げる前に時刻を取ります。

const tripStartedAt = Date.now(); // 投げる前に取る
const trip = await runHedgedTrip(/* ... */);
if (trip.ok) {
  recordChannelSuccess(trip.target.channel, trip.target.model, tripStartedAt);
}

openedAt <= startedAt が選んでいるのは、無効化されてから投げたリクエストの成功だけです。無効化中は新しいリクエストが isTargetUsable() で弾かれるので、この条件を通るのは検証用のプローブだけになります(全チャネルが無効化されているときの強制再試行を除く)。判定を「成功したか」から「いつ投げたか」に移すと、復旧の証拠として使える成功が自動的にプローブだけに絞られます。

もう 1 つの罠:ワーカーのローカルスナップショットが 1 世代古い

ブレーカーの状態は Redis に置いて複数ワーカーで共有し、ホットパスはローカルスナップショットだけを読みます(2 秒ごとに引き直す)。この構成だと、閉じる判断をするワーカーの手元だけが古いことがあります。

  • t=0: 1 回目のブレーカーが開く。ワーカー A はスナップショットに openedAt=0 を掴む
  • t=100: リクエスト B を投げる(このあと成功して戻る)
  • t=200: プローブが通って 1 回目が閉じ、直後に 2 回目のブレーカーが開く(開いたのはワーカー B)
  • t=250: B が成功してワーカー A に戻る。A のスナップショットはまだ openedAt=0

A の手元だけで判定すると 0 <= 100 で条件を通ってしまい、t=200 に開いたばかりのブレーカーを、t=100 に投げたリクエストの成功で閉じます。再現スクリプトのデモ 2 がその実行結果です。

[2] naive    CLOSED the 2nd breaker opened at t=+200ms (success started at t=+100ms)
[2] naive    redis still has entry: false
[2] guard    kept the 2nd breaker open (started-before-open(redis))
[2] guard    redis still has entry: true

直しは、閉じる直前に Redis から読み直して、同じ判定をもう一度やることです。

const raw = await redis.hget(OPEN_HASH_KEY, entry.key);
if (!raw) return; // 別のワーカーが先に閉じていた
const latest = JSON.parse(raw) as BreakerEntry;
if (latest.openedAt > startedAt) return; // その間に開き直されていた
await redis.hdel(OPEN_HASH_KEY, entry.key);

ローカルスナップショットは「候補を絞る」ためだけに使い、最終判断は共有状態でやります。スナップショットの遅延は性能のための妥協なので、正しさの判定には混ぜません。

閾値は窓内の累計で数える

本番では閾値を「連続失敗 N 回」ではなく「120 秒の窓内で累計 N 回」にしています。連続で数えると、間欠的に失敗する依存先が、間に 1 回成功を挟んだ瞬間にカウントが 0 に戻り、障害が永久に隠れます。

この変更は復旧側にも同じ規律を要求します。1 回の成功で窓のカウンタを消さないことです。成功したからといってカウンタを消すと、同じことがまた起きます。カウンタを消すのは復旧が確定したとき(プローブが通ったとき)だけにしています。

割り切ったこと

  • 手動で止めたものは自動で戻さない。 人が「このチャネルは使わない」と決めた状態を、プローブの成功で勝手に開放しない。
  • 全チャネルが無効化されているときは、最も冷却の浅いものを 1 つだけ強制で試す。 全部を避けて即失敗を返すのは、こちらの(古くなっているかもしれない)判断でユーザーに失敗を作ることになります。最悪でも壁に 1 回ぶつかるだけで、ブレーカーを入れる前と等価です。
  • アラートは開いた瞬間ではなく、プローブが失敗した時点で送る。 30 秒で自己回復する揺らぎで「⛔ 開いた → ✅ 直った」の対を鳴らすと、本当の障害が埋もれます。
  • プローブは SET NX のロックで 1 本に絞る。 ワーカーごとに復旧ループが回っているので、ロックがないと同じ壊れたチャネルに同時に何本も打ちます。

まとめ

  • 復旧判定を「成功したら閉じる」にすると、開く前に投げた in-flight の成功で開いた直後に閉じます(再現で 1.85 秒)
  • 成功には「投げた時刻」を持たせ、openedAt <= startedAt のものだけを復旧として数えます。無効化中に投げられるのはプローブだけなので、これで証拠が絞られます
  • ワーカーのローカルスナップショットは 1 世代古くなります。閉じる直前に共有状態(Redis)から読み直して同じ判定をやり直します
  • 閾値を窓内の累計で数えているなら、成功 1 回でカウンタを消しません

「成功した」は「復旧した」の証明になりません。 どの時点の成功かを一緒に持たないと、開けたばかりの蓋を閉めてしまいます。

この判定は、筆者が関わっている面接練習AI(AceRound)のリアルタイム経路で保守しています。上流が落ちている間もユーザーの面接は続くので、ブレーカーを閉じる判断を間違えると、そのまま誰かの待ち時間になります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?