Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

AbortSignal.timeout無しのfetchでSlack webhook無応答がJobごと殺し記録が残る

公開 読了時間 約9分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

Slackへのfetchにタイムアウトが無いと、Webhookが無応答のままCloud Run Jobの120秒killで丸ごと死ぬ。その直前にGCSへ作った「今日はもう送った」記録だけが残り、実際には届いていないダイジェストが二度と出なくなる。

Slackへのfetchにタイムアウトが無いと、Webhookが無応答のままCloud Run Jobの120秒killで丸ごと死ぬ。その直前にGCSへ作った「今日はもう送った」記録だけが残り、実際には届いていないダイジェストが二度と出なくなる。

TV死活監視Jobは平日 08:30〜09:29 JST の間、最初に成功した1回だけ「✅ TV監視稼働中」のダイジェストをSlackへ送る。多重送信を防ぐ仕組みと、Slack送信を実装する fetch 呼び出しの間に、見落とされていた前提のズレがあった。

症状

ダイジェストは dead-man’s switch として設計されている。届かない=監視が止まっていると誤読されるため、「1日1回だけ送る」ことを厳密に保証する必要がある。その保証は、GCSオブジェクトの原子的な作成で実現していた。

// apps/jobs/src/tv-liveness/digest.ts(修正前と同一)
export function createGcsDigestMarker(bucketName: string): DigestMarkerPort {
  const bucket = new Storage({ retryOptions: { autoRetry: false } }).bucket(bucketName);
  const objectFor = (day: string) => bucket.file(`tv-liveness/digest/${day}`);
  return {
    async claim(day) {
      try {
        await objectFor(day).save("", {
          resumable: false,
          contentType: "text/plain",
          preconditionOpts: { ifGenerationMatch: 0 },
        });
        return true;
      } catch (err) {
        // 412 Precondition Failed = 既に今日の記録がある(=送信済み or 送信中)。
        if ((err as { code?: number }).code === 412) return false;
        throw err;
      }
    },
    async release(day) {
      await objectFor(day).delete({ ignoreNotFound: true });
    },
  };
}

claim が ifGenerationMatch: 0(オブジェクトが存在しない場合のみ作成)を使うことで、毎分起動されるJobが同じ分や近い分に重なって走っても「今日の記録」を作れるのは1回だけになる。ここまでは正しく機能していた。

問題はこの記録をいつ作るかにあった。

// apps/jobs/src/tv-liveness/tv-liveness-job.ts(修正前、main() の該当部分)
const summary = await runTvLivenessCheckBatch(config);
// ...
const bucket = env.TV_LIVENESS_DIGEST_BUCKET?.trim();
const marker = bucket ? createGcsDigestMarker(bucket) : null;
const digest = await decideDailyDigest(now, marker);
const { heartbeatSent } = await deliverTvLivenessAlerts(
  summary,
  now,
  boolEnv("TV_LIVENESS_HEARTBEAT") || digest.send,
  boolEnv("TV_ALERT_ON_RECOVERY"),
);
// 送れなかったら記録を消して次の分に譲る(記録だけ残ると、その日のダイジェストが永久に出ない)。
if (digest.send && digest.claimed && !heartbeatSent && marker) {
  await marker.release(digest.day).catch(() => undefined);
}

decideDailyDigest が marker.claim(day) を呼んで記録を作るのは、deliverTvLivenessAlerts(内部で postSlack を呼ぶ)より先。つまり「今日はもう送った」という記録は、Slackへの送信が成功したかどうかに関係なく、送信を試みる前に既に存在している。これ自体は設計上の前提で、release の呼び出しが安全網として用意されている——postSlack が false を返せば、heartbeatSent が false になり、記録は消して次の分に再挑戦させる。

ところが postSlack 自身に、応答を待ち続ける以外の挙動が無かった。

// apps/jobs/src/tv-liveness/slack.ts(修正前)
export async function postSlack(webhookUrl: string, text: string): Promise<boolean> {
  try {
    const res = await fetch(webhookUrl, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ text }),
    });
    if (!res.ok) {
      console.warn(JSON.stringify({ event: "tv.health_check.slack_non_2xx", status: res.status }));
      return false;
    }
    return true;
  } catch (err) {
    console.warn(JSON.stringify({ event: "tv.health_check.slack_error", message: err instanceof Error ? err.name : "unknown" }));
    return false;
  }
}

fetch に signal が渡されていない。SlackのWebhookエンドポイントがTCP接続を受け付けたまま応答を返さない状態になると、この await fetch(...) は res.ok の判定にも catch にも到達せず、永久に pending のままになる。

原因

Cloud Run Jobにはタスク単位のハードタイムアウトがある。

# infrastructure/terraform/modules/cloud_run_job_tv_liveness/variables.tf
variable "task_timeout" {
  description = "Job タスクのタイムアウト(全校横断の軽量走査 + 少数 Slack POST、短め)"
  type        = string
  default     = "120s"
}

このJobはprodで既定値の120秒をそのまま使っている。fetch が無応答のまま120秒を超えると、Cloud Run自体がタスクを強制終了する。これはJavaScriptの例外でもrejectでもなく、プロセスの外側からのkillなので、main() 内のどのtry/catchもif (digest.send && digest.claimed && !heartbeatSent && marker)の安全網も実行される機会がない。

結果として、その日のGCSの「今日はもう送った」記録だけが残り、ダイジェストは実際には1バイトも届いていないのに「送信済み」として扱われる。平日08:30〜09:29の窓は1日1回しか巡ってこないため、この状態になるとその日はもう二度と送れない。

この設計の欠陥は「Slack送信の失敗」をpostSlackの戻り値(true/false)か例外のどちらかでしか観測できない前提で安全網を作っていたこと。「応答しないまま固まり続け、外部のタイムアウトで丸ごと殺される」という失敗モードは、戻り値も例外も発生しないため、この前提の外にあった。

直し方

fetch自体に短いタイムアウトを持たせ、Slackの無応答を「失敗」としてpostSlackが観測できる形に変えた。

// apps/jobs/src/tv-liveness/slack.ts(修正後)
const SLACK_TIMEOUT_MS = 10_000;

export async function postSlack(webhookUrl: string, text: string): Promise<boolean> {
  try {
    const res = await fetch(webhookUrl, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ text }),
      // Slack が応答しないまま Job の timeout(120 秒)で殺されると、ダイジェストの送信済み記録だけが残り
      // その日のダイジェストが出なくなる。10 秒で打ち切って false を返し、記録を消して次の分に回す(2026-10-09)。
      signal: AbortSignal.timeout(SLACK_TIMEOUT_MS),
    });
    if (!res.ok) {
      console.warn(JSON.stringify({ event: "tv.health_check.slack_non_2xx", status: res.status }));
      return false;
    }
    return true;
  } catch (err) {
    console.warn(JSON.stringify({ event: "tv.health_check.slack_error", message: err instanceof Error ? err.name : "unknown" }));
    return false;
  }
}

AbortSignal.timeout(10_000) は10秒経ってもfetchが決着しなければAbortErrorを発生させる。これは既存のcatchブロックがそのまま拾うので、postSlackはfalseを返し、main()側のreleaseが実行され、記録は消える。タイムアウトの10秒は、Jobのtask_timeout120秒に対して十分短く、タスクがkillされる前に必ず失敗として確定する。

次の分(08:31以降、窓の範囲内)でJobが再度走れば、記録が消えているのでclaimが再び成功し、ダイジェストの再送に挑戦できる。

再発防止

このコードベースには元々「Slack送信に失敗したら記録を消し、次の分で再挑戦させる」という安全網が明示的に用意されていた(digest.ts冒頭のコメント、tv-liveness-job.tsのrelease呼び出し)。つまり「送信が失敗しうる」こと自体は設計時から意識されていた。見落とされていたのは、失敗の形が「戻り値false」か「例外」のどちらかだけで、「どちらも発生せず、ただ応答が来ないまま時間が過ぎる」という第三の形がある、という点だった。

外部サービスへのI/O(HTTP・gRPC・DBクライアントなど)を呼ぶ関数を「成功/失敗の二値で返す」ように作るときは、失敗側に「相手が完全に無応答のまま固まる」ケースを明示的に含める必要がある。今回の修正のように、呼び出し側のタイムアウトをJob全体のタイムアウトより十分短く設定し、AbortSignalで確実に打ち切ることで、「応答しない」を「失敗した」に変換できる。安全網(release・リトライ)がどれだけよく設計されていても、その安全網に辿り着く前にプロセスごと消される経路が残っていれば機能しない。

よくある質問

Q1Slack側が普通にエラーを返す(4xxや5xx)だけなら、この問題は起きないのでは?

その通り。postSlackはres.okがfalseでも例外を投げず、ログを出してfalseを返すだけで、GCSの送信済み記録はそのまま呼び出し元が消す。問題になるのは「エラーも返ってこず、TCP接続だけ開いたまま応答が来ない」無応答のケースで、そこではfetch自体のPromiseが決着せず、res.okの判定にすら到達できない。

Q2Cloud Run Jobのtask_timeoutを長くすれば直せたのでは?

延命はできても解決にはならない。120秒を300秒にしても、Slackの無応答がそれより長く続けば同じ現象が再現する。かつJobの実行時間課金が伸びる副作用もある。fetch側に短いタイムアウトを持たせて『失敗』として扱える形に変えるほうが、上限の長さに依存しない直し方になる。

Q3GCSの原子的claim(ifGenerationMatch: 0)自体に欠陥があったわけではない?

無い。claim/releaseの対称設計自体は正しく動いていた。ギャップは『Slack送信の失敗」がpostSlackの戻り値(true/false)か例外のどちらかで観測できる前提で設計されていたこと。プロセスが外部からkillされて戻り値も例外も発生しない『応答しないまま消える』失敗モードは、この設計が最初から想定していなかった。

確認した環境

  • Node.js 20+ / undici(グローバル fetch)
  • Google Cloud Storage ノードライブラリ @google-cloud/storage(ifGenerationMatch: 0 の原子的 create)
  • Cloud Run Job task_timeout 既定 120s(Terraform)

この記事の根拠

  • TypeScriptファイル 139〜152行目コミット 8515053
  • TypeScriptファイル 143〜152行目コミット dd09a67
  • TypeScriptファイル 50〜72行目コミット 8515053
  • TypeScriptファイル 74〜98行目コミット 8515053
  • TypeScriptファイル 107〜136行目コミット 8515053
  • Terraformファイル 182〜186行目コミット df03fd9

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。