at-least-once の送信前予約・OIDC 認証プロキシ・起動精度の実測比較
1. はじめに ― 「精度で殴る」道具を、事故なく入れる
前回の記事で、kintone を SQL で回す日次バッチ(kSQL + GitHub Actions)の定期起動だけを GitHub schedule から Google Apps Script(GAS)の時間主導トリガーに外出ししました。そのとき、落とした選択肢としてこう書いています。
Cloud Scheduler + Cloud Run Jobs … 高精度で堅牢。ただし実行基盤ごと GCP へ移す大工事になり(中略)今回の「スケジューラだけ替えたい」には過剰
そして GAS の起動精度について、正直にこう認めていました。
精度で GAS が圧勝するわけではありません。GAS の利点は「窓が狭い」「60 日自動無効化が無い」であって、秒単位の正確さではない。(日次トリガーは)目標の ±15 分程度(中略)初日は 2 回とも約 6 分早かった。
この記事は、その続きです。実行基盤(GitHub Actions)はそのままに、定期起動の送信元だけを GAS → Google Cloud Scheduler に載せ替えました。送るイベントは前回と同じ repository_dispatch(daily-scheduled)。ワークフロー本体・SQL・kintone 通知・死活監視は1 行も変えていません。
狙いは 2 つ。起動時刻の精度(GAS の ±分オーダーを詰める)と、個人アカウント非依存(GAS は実行者のアカウントに紐づく)。ただし、Cloud Scheduler には GAS には無かった落とし穴 ― at-least-once(まれに重複配送) ― があり、非冪等 DML では「同日二重実行」の芽になります。この重複をどう潰したかが本題の半分です。
要点
- 送信元を GAS → Cloud Scheduler に載せ替え。イベントは
repository_dispatchのまま、ワークフロー差分ゼロ。 - Cloud Scheduler は at-least-once。
--max-retry-attempts=0はリトライを止めるだけで重複配送は防がない。認証プロキシ(Cloud Functions gen2)が(JobName, ScheduleTime)を冪等キーに Firestore へ原子的に「送信前予約」することで、at-most-once に倒した。 - PAT を Scheduler 定義に平文で置かない。OIDC でプロキシを認証 → プロキシが Secret Manager から PAT を読む。
- 失敗は
failed(未受理が確定)/unknown(受理されたか不明) に分類。曖昧区間を最小化し、予約は解放しない(解放すると重複配送が再送信されうる)。 - 起動精度を三者実測で比較した。GitHub
scheduleは +99 分・欠火あり、GAS は約 6 分早い、Cloud Scheduler は目標比 +0.834 秒(GitHub の Run 生成まで含めても +5 秒)(§10)。
想定読者: GitHub Actions / GAS の定期処理を運用していて、起動時刻の精度と個人アカウント非依存を GCP で取りにいきたい方。Cloud Scheduler の at-least-once をどう安全に扱うか(送信前予約・冪等キー・状態モデル)に関心がある方。前回記事(
repository_dispatch・fail-close・死活監視)を前提にします。読み方(目的別):
- 動機と選択だけ: §1〜§3、§12
- Cloud Scheduler 特有の罠(重複配送)だけ: §4〜§6
- 認証と検知の実務: §7〜§9
- 起動精度の実測比較(本記事の目玉): §10
お断り: kSQL(
@rex0220/kintone-sql-tools)は筆者の OSS です。宣伝目的ではなく設計知見の共有が主旨です。本移行の実装・検証は AI コーディング支援(Claude Code)を併用し、preprod の dry-run から prod の実 DML 発火まで実際に動作確認しています。本文の落とし穴は、その過程で実際に踏んで潰したものです。
1.1 前回(GAS 版)から引き継ぐ要素
| 引き継ぐ要素 | 前回での役割 | 本記事での役割 |
|---|---|---|
repository_dispatch(daily-scheduled) |
GAS が送る独立イベント | Cloud Scheduler が送る(同一・詐称防止も維持)(§3) |
derive-run-mode.sh(fail-close 検証) |
client_payload を実行前検証 | 無変更。送信元が替わっても効く |
real_run ゲート / 死活監視 LAPP_HEALTH |
「実際に走ったか」の検知 | 無変更。層③の最終防波堤(§9) |
profile 分割・論理参照 LAPP_* |
dev/prod の環境分離 | 無変更(スケジューラ層のみ差し替え) |
1.2 全体像(1 枚)
2. なぜ Cloud Scheduler か ― GAS の弱点をピンポイントで消す
前回 GAS を選んだ理由(無料・GCP 不要・祝日カレンダー・起動要求の即検知)は今も有効です。それでも載せ替えたのは、GAS に 2 つ残った弱点があるからです。
| GAS 時間主導トリガー(前回) | Cloud Scheduler(本記事) | |
|---|---|---|
| 起動精度 | 日次トリガーは目標の ±15 分(実測: 約 6 分早い) | 分〜秒オーダーで正確(実測: 目標比 +0.834 秒。§10) |
| アカウント依存 | 実行者の Google アカウントに紐づく(退職・停止でトリガーが止まる) | プロジェクト / SA に紐づく(個人非依存) |
| 失敗検知 | GAS 標準の失敗通知メール + notifyDispatchFailure_
|
各実行を Cloud Logging に記録 → ログベース指標 + Cloud Monitoring アラート |
| コスト | 無料・GCP 不要 | ほぼ無料(Scheduler は請求先アカウントあたり 3 ジョブまで無料 + プロキシ実行分) |
| 祝日スキップ |
CalendarApp で容易 |
Scheduler 単体では不可(要ロジック) |
| 配送保証 | 同一時刻枠の重複発火を前提とした設計にはしていなかった | 公式に at-least-once。重複排除が必須(§4) |
決め手は上 2 行 ―「±15 分を秒オーダーに詰める」と「個人アカウントから外す」。その代償が最下行の at-least-once(§4)で、これを潰せるかが採否を分けました。
公平のため: GAS が劣るわけではありません。GCP を増やさず Google アカウントだけで完結させたい・祝日スキップを手軽に入れたい・重複配送を考えたくない、なら GAS 現行維持で十分です。本記事は「精度と非依存性のために GCP を 1 つ増やす」判断の記録です。
3. 方式は変えない ― なぜ repository_dispatch を維持するのか
「送信元が GCP になるなら、いっそ workflow_dispatch + trigger_source 入力に統一すれば?」と考えたくなります。やりませんでした。前回作った安全性が下がるからです。
現行ワークフローは「自動経路 = prod・mode=run・confirm_dml 免除・失敗時 Issue 起票」を github.event_name == 'repository_dispatch' で判定しています。repository_dispatch は手動 workflow_dispatch フォームからは決して発火できない独立イベントなので、手動起動者が自動 DML 経路を詐称できない。
これを workflow_dispatch + trigger_source=scheduler に置き換えると、手動フォームで trigger_source=scheduler を選ぶだけで confirm 免除の prod 実 DML 経路に入れてしまう(derive-run-mode.sh の fail-close は run を正当値として通すので防げない)。変更量が増えて、しかも安全性が下がる。だから送信元だけ替え、イベントは触らない。ワークフロー差分はゼロになります。
送るリクエストは前回 GAS の dispatch_() とバイト単位で同一です。
POST https://api.github.com/repos/rex0220/ksql-actions2/dispatches- ヘッダ:
Authorization: Bearer <PAT>/Accept: application/vnd.github+json/X-GitHub-Api-Version: 2022-11-28/Content-Type: application/json - body:
{"event_type":"daily-scheduled","client_payload":{"mode":"run"}} - 成功判定は 2xx(現状 204。後述 §6 で「204 固定にしない」)
4. 【核心】at-least-once ― 重複配送を「送信前予約」で潰す
Cloud Scheduler は exactly-once ではなく at-least-once。まれに同一スケジュールが複数回配送されます(Cloud Scheduler overview)。ここで最初に踏みかけた誤解を潰しておきます。
--max-retry-attempts=0は重複配送を防がない。
retry_count=0 は「失敗時の自動リトライを止める」だけで、at-least-once に由来する重複配送は別メカニズムです。日次集計に将来非冪等 DML(INSERT・加算更新)が加わると、重複配送 → repository_dispatch 二重発火 → 同日 prod 実 DML の二重実行が起こりえます。
GitHub の concurrency も救いになりません。現行の concurrency: { group: daily-batch-prod, queue: max } は FIFO キューイングで、近接した 2 発火は両方順に実行されます(=二重 DML)。concurrency に依存してはいけない。
repository_dispatch は冪等キーを渡せないので、GitHub 側で弾くこともできません。そこで Cloud Scheduler とプロキシの間で潰します。Cloud Scheduler はターゲット呼び出し時に X-CloudScheduler-JobName と X-CloudScheduler-ScheduleTime ヘッダを付け、この組を発火の識別情報として公式に定めています。同一発火の重複配送は同じ ScheduleTime を持ちます。これを冪等キーにして、GitHub へ送る前に Firestore へ原子的にロックを取ります。
// docRef.create() は既存ドキュメントに対して ALREADY_EXISTS(gRPC code 6)を投げる。
// この性質をそのまま「送信前ロック」に使う。取れた 1 リクエストだけが GitHub を叩ける。
export async function reserve(jobName, scheduleTime) {
const ref = db.collection(COLLECTION).doc(docId(jobName, scheduleTime)); // SHA-256(JobName\nScheduleTime)
try {
await ref.create({ state: 'reserved', jobName, scheduleTime, /* ... expireAt(TTL) */ });
return { acquired: true, ref };
} catch (e) {
if (e.code === ALREADY_EXISTS) return { acquired: false, ref }; // 重複配送 → no-op
throw e; // それ以外(権限/障害)は上位で failed 扱い
}
}
なぜ「送信前予約」か。素朴に「処理済みか確認 → 送信 → 記録」の順(check-then-act)にすると、並行到着した 2 リクエストが両方「未処理」を見てから両方送信できてしまう(TOCTOU)。ロックを送信前に原子的に取れば、同時配送でも GitHub を叩くのは 1 回だけ。docRef.create() の「既存なら例外」が、そのまま原子的な compare-and-set になっています。
教訓: 「リトライを 0 にした」は「重複しない」ではない。配送保証(at-least-once)とリトライ設定は別物。冪等キーを渡せない相手(
repository_dispatch)には、送信元と相手の間で送信前ロックを取り、check-then-act の TOCTOU を原子操作で塞ぐ。
5. mode は予約より前に検証する ― dry-run が run に転落しない
前回 §5 で「1 文字のタイポが prod DML に転落する fail-open」を潰しました。プロキシでも同じ罠が、しかもより手前に出ます。
Cloud Scheduler の body は {"mode":"run"} を JSON で送りますが、Content-Type: application/json を付け忘れると Functions 側で本文が JSON パースされず、req.body.mode が undefined になります。素朴に「mode が無ければ既定 run」とすると、dry-run のつもりの検証発火が本番 run に転落します。
そこで mode の厳密検証を、予約よりさらに前に置きました。
const mode = req.body?.mode;
if (mode !== 'run' && mode !== 'test') {
return res.status(400).send('mode must be run|test'); // 既定 run へフォールバックしない
}
// ↓ ここで初めて送信前予約に進む
const { acquired, ref } = await deps.reserve(jobName, scheduleTime);
Terraform 側でも二重に守ります。Scheduler ジョブ定義で Content-Type を必須指定し、「有効化(unpaused)なら mode=run 必須」の precondition(手動で dry-run body のまま本番稼働させない fail-close)を置きました。
http_target {
headers = { "Content-Type" = "application/json" } # 未指定だと octet-stream で mode 欠落
body = base64encode(jsonencode({ mode = var.scheduler_mode }))
}
lifecycle {
precondition {
condition = var.scheduler_paused || var.scheduler_mode == "run"
error_message = "An enabled (unpaused) production scheduler must use scheduler_mode=run."
}
}
教訓: HTTP ターゲットは「本文が届く前提」で書かない。
Content-Type欠落で body が消えるのは実在の経路で、それが「安全側(test)→ 危険側(run)」に転ぶ設計だと事故になる。危険な既定値へのフォールバックを断ち、IaC の precondition でも二重化する。
6. failed / unknown の分類 ― at-most-once へ倒す
送信前予約を取った後、GitHub へ送る段で「受理されたか分からない」曖昧区間が残ります。repository_dispatch に冪等キーが無い以上、これはゼロにできません。そこで「取りこぼしは重複より軽い(死活監視で拾える)」と割り切り、at-most-once 側へ倒します。鍵は 2 つ ―予約を解放しないことと、失敗を 2 種類に分けて診断を正確にすることです。
| 状態 | 意味 | HTTP | アクション |
|---|---|---|---|
accepted |
GitHub が 2xx を返した | 204 | 正常。以降は層③死活監視で完走確認 |
failed |
未受理が確定(Secret 取得失敗・構成不備・明確な非2xx 401/404/422) | 502 | 即アラート。自動再送しない |
unknown |
受理されたか不明(fetch の通信例外・タイムアウトで応答が読めない) |
504 | 即アラート。自動再送しない(二重実行回避を優先) |
分類は「GitHub がイベントを正常受理したか確定できるか」で切ります。failed は、GitHub イベントが正常受理されていないことを確定できる状態 ― 送る前に失敗した(Secret Manager 障害・構成不備でそもそも送っていない)か、応答を受け取った上での非2xx(401/404/422 = 送ったが受理されなかったことが分かる)。unknown は、通信例外により正常受理の有無を確定できない状態 ― fetch 自体が例外を投げたとき(送った瞬間に接続が切れた等)だけで、送ってしまった可能性を否定できない。
try {
res = await fetch(`${apiBase}/repos/${owner}/${repo}/dispatches`, { /* ... */ signal });
} catch (e) {
throw new DispatchError(`fetch failed: ${e.message}`, 'unknown'); // 受理不明だけが unknown
}
const ok = res.status >= 200 && res.status < 300; // ★204 固定にしない(§6.1)
if (ok) return { ok: true, status: res.status, body: '' };
let body = ''; try { body = await res.text(); } catch { body = ''; }
return { ok: false, status: res.status, body }; // 応答は得た → failed 側
予約は成否いずれでも解放しません。 解放すると同一 (JobName, ScheduleTime) の遅延重複配送が再送信されうるからです。この一貫性のため、予約レコードには十分長い TTL(既定 7 日)を持たせ、次回以降の正常発火と混ざらない長さにしています。failed / unknown は自動再送せず、既存の手動 workflow_dispatch(run/recovery・confirm_dml=true)で人手回復し、「起動できなかった日」は層③の死活監視が必ず拾います。
6.1 成功判定を 204 固定にしない
GitHub 公式が現在明示する repository_dispatch の正常応答は 204 No Content です。ただし REST の応答は API バージョンで変わりえるため、判定を 204 に固定せず、HTTP の一般的な成功範囲である status >= 200 && status < 300(2xx)を受理として扱います。現契約(X-GitHub-Api-Version: 2022-11-28 = 204)はコメントに明記し、版を上げるときに判定を見直す。
教訓: 分散配送で消せない曖昧区間は「消す」より「倒す方向を決める」。非冪等 DML なら at-most-once(取りこぼし → 死活監視で検知 → 手動回復) が、二重実行(データ破損)より安全。そのために予約を解放しない・曖昧(unknown)を最小化する分類・外部応答の 2xx 幅を設計に埋め込む。
7. 認証 ― PAT を Scheduler に平文で置かない(OIDC + Secret Manager)
Cloud Scheduler の HTTP ターゲットは Google エンドポイント向けの OIDC/OAuth は張れても、GitHub が要る Authorization: Bearer <PAT> は Google トークンでは満たせません。そして Scheduler ジョブ定義にヘッダで PAT を直書きすると、roles/cloudscheduler.viewer で平文が読めてしまう(Secret Manager からヘッダへ注入する機能は Scheduler に無い)。
そこでプロキシを一段挟みます。Scheduler は OIDC でプロキシを認証して叩き、プロキシが Secret Manager から PAT を読み、重複排除してから GitHub を叩く。
Cloud Scheduler ──OIDC──▶ Cloud Functions gen2(dispatch-proxy) ──Secret Manager(GH_PAT)──▶ GitHub
SA=ksql-scheduler SA=ksql-dispatch-proxy(secretAccessor + datastore.user)
retry:0 認証必須(run.invoker は scheduler SA のみ)= 外部直叩き不可
SA は責務で 3 つに分け, 最小権限で付与しています。
| SA | 役割 | 権限 |
|---|---|---|
ksql-scheduler |
Scheduler が OIDC で関数を叩く ID | 関数への roles/run.invoker のみ |
ksql-dispatch-proxy |
関数のランタイム |
secretAccessor(PAT 読取)+ datastore.user(予約) |
ksql-build |
gen2 ビルド | logWriter / artifactregistry.writer / storage.objectViewer |
PAT は前回 GAS の GH_PAT を流用できます(Fine-grained・対象 ksql-actions2 1 個・Contents: Read and write のみ)。repository_dispatch は Actions 権限ではなく Contents:RW で認可される、という前回の教訓はここでも同じ。kintone トークンは従来どおり GitHub Secrets に置き、GCP は起動 PAT 以外の機密を持ちません(責務分離)。ローテーションは Secret のバージョン更新だけ(Scheduler もコードも変更不要)。
運用の落とし穴(実測): Secret Manager の
latestは最大バージョン番号を指す。古い版をdisableしてもlatestは戻らないので、rotation / 復旧は「新バージョンを追加」で行う。新規 SA 直後はactAsの伝播待ちでapplyが 403 になることがある(再 apply で解消)。
8. IaC は「状態」を tfvars で正典化する ― 部分 apply をしない
切替の安全は Terraform の変数設計に効かせました。「有効化(paused=false)」と「本番 body(mode=run)」を 1 ファイルで同時に成立させ、-var の部分指定は使いません(既定へ巻き戻るため)。
schedule = "30 8 * * *" # JST 08:30(恒久の新起動時刻)
scheduler_paused = false
scheduler_mode = "run"
scheduler_paused = true # 切替前は停止 +
scheduler_mode = "test" # dry-run body(本番 DML を送らない)
ロールバックは terraform apply -var-file=preprod.tfvars の 1 コマンド(paused に戻す)。§5 の precondition が「unpaused なのに mode=test」という危険な中間状態をそもそも apply させないので、tfvars を取り違えても本番 DML が dry-run に化ける(またはその逆)事故が起きません。
運用の落とし穴(実測): paused のジョブは
gcloud scheduler jobs runできない(resume → run → pauseの順が要る)。jobs runは次の定時枠時刻をScheduleTimeに使うため、同一枠の手動再送は重複抑制されて no-op になる(手動再 dispatch には Firestore の対象予約 doc 削除が要る)。email 通知チャネルは検証不要。
9. 失敗検知の三層(+ Scheduler 到達前の層)
前回の三層(起動要求の送信失敗 / トリガー失敗 / dead-man's switch)を GCP で再現し、プロキシ到達「前」の失敗を拾う層を足しました。
| 層 | 検知対象 | 手段 |
|---|---|---|
| ①' Scheduler 試行失敗 | invoker 権限不備・OIDC audience 不一致・Content-Type/body 不備の 400 など(プロキシに届かない) |
resource.type="cloud_scheduler_job" の severity>=ERROR をログ指標化 → アラート |
| ① プロキシ到達後の失敗 |
failed/unknown = PAT 失効・非2xx・通信例外 |
プロキシの 5xx または構造化ログ event="dispatch_failed" をログ指標化 → アラート |
| ② バッチ結果 | SQL/データ/認証の失敗 | 既存: kintone LAPP_NOTIFY + Issue バックストップ(無変更) |
| ③ 起動そのものの欠落 | 受付後に走らない / Scheduler ごと停止 | 既存: LAPP_HEALTH の dead-man's switch(無変更) |
①' が要るのは、権限や OIDC の不一致は 401/403/400 でプロキシに届かず、プロキシの 5xx 指標には載らないからです。Scheduler は各試行を cloud_scheduler_job リソースにログ出力し、失敗は ERROR になるので、そこを別指標で拾います。
そして 2xx は「受け付けた」ことしか保証しない(前回 §9.1 と同じ)。「実際にバッチが走ったか」は必ず ③ の死活監視で担保します。Cloud Scheduler / GCP 自身が止まる(ジョブ削除・プロジェクト無効)ケースは ①①' では拾えない(そもそも動いていない)ため、③ が最終防波堤。GCP を過信せず死活監視は必ず維持する ― これは基盤を GAS から替えても変わらない結論です。
10. 起動精度の実測比較 ― GitHub schedule / GAS / Cloud Scheduler
ここが本記事の目玉です。前回は GitHub schedule と GAS の 2 者を比べ、「時刻の正確さより起動が観測できること」を重視するなら外部化は支持される、と結論しました。今回、Cloud Scheduler の実測が加わり、三者が揃いました。
10.1 三者の実測データ
いずれも同一リポジトリ(ksql-actions2)に届いた実データです。GitHub 側は Actions の Run 生成時刻(createdAt)、Cloud Scheduler 側はジョブの lastAttemptTime(=プロキシへ HTTP を発射した時刻)を UTC→JST 変換しています。
| 基盤 | 目標時刻 | 実際の発火 | 目標とのズレ | サンプル | 測っているもの |
|---|---|---|---|---|---|
GitHub schedule |
21:15:00 | 22:54:17 | +99 分(遅延) | n=1 | 壁時計に対する起動遅延 |
GitHub schedule(プローブ) |
13:30:00 | ― | 欠火(当日スキップ) | n=1 | 発火の有無 |
| GAS 日次トリガー | (プローブ 2 枠) | ― | 約 6 分早い(2/2 発火) | n=2 | 壁時計に対する ±窓 |
| Cloud Scheduler | 08:30:00.000 | 08:30:00.834 | +0.834 秒 | n=1 | Scheduler の発射時刻 |
| Cloud Scheduler(Run 生成まで) | 08:30:00.000 | 08:30:05 | +5 秒(end-to-end) | n=1 | 発射→予約→Secret→GitHub→Run 生成 |
Cloud Scheduler の生ログはこうでした(gcloud scheduler jobs describe)。
state: ENABLED
schedule: "30 8 * * *" # JST 08:30
lastAttemptTime: 2026-07-13T23:30:00.834577Z # = JST 08:30:00.834 → 目標比 +0.834 秒
そして、その発火で生成された GitHub Actions の Run。
event: repository_dispatch (daily-scheduled)
createdAt: 2026-07-13T23:30:05Z # = JST 08:30:05 → 目標比 +5 秒
conclusion: success # prod / mode=run / 実 DML 完走
Scheduler 自身は目標の 0.834 秒後に発射しています。GitHub の Run 生成まで含めた end-to-end でも +5 秒で、残り約 4 秒は「プロキシのコールドスタート + Firestore 予約 + Secret Manager 取得 + GitHub API + Run 生成」の合計。GAS の「約 6 分早い」、GitHub schedule の「+99 分・欠火」と並べると、今回の観測値では桁が 2〜3 つ違いました(一般的な性能差の証明ではない点は §10.2)。
目標との絶対ズレ(対数スケールのイメージ)
Cloud Scheduler |■ 0.8秒(発射)〜 5秒(Run生成)
GAS 日次 |■■■■■■ 約 360 秒(6分・早い側)
GitHub schedule |■■■■■■■■■■■■■■■■■■■■ 約 5940 秒(99分・遅い側)+ 欠火
10.2 この比較の限界(正直に)
前回同様、サンプル数も測定対象も完全には揃っていません。
-
Cloud Scheduler は n=1(prod 初回の実 DML 発火。preprod の dry-run 発火は別途 test で確認済み)。ただし測っているのは Scheduler 内部の発射時刻で、
lastAttemptTimeは運用のたびに記録される → 数日ぶんで安定性を追える。 -
GitHub
scheduleの +99 分は n=1(切替前に一度だけ走った自動発火)。たまたま混雑した日だった可能性は残る。 - GAS の「約 6 分早い」は n=2(前回の同型プローブ)。安定した前倒しバイアスか ±15 分窓の揺れかは、前回時点で未確定。
加えて、測定点も完全には同一ではありません(Cloud Scheduler は lastAttemptTime、GitHub は Actions の createdAt、GAS はトリガー発火時刻)。したがってこれはサービス一般の性能比較ではなく、本システムにおける移行前後の観測記録として扱います ― 今回の観測値では Cloud Scheduler は秒、GAS は分、GitHub schedule は約 100 分(+欠火)という差になった、という事実にとどめます。
10.3 「精度」は目的ではなく手段
とはいえ ― 前回書いたことを繰り返します。この日次バッチに秒精度は要りません。「その日のうちに 1 回動けば良い / 死活監視に 26 時間の猶予」という設計思想は変わっていない。GAS の「6 分早い」も業務上は無害でした。
では Cloud Scheduler の +5 秒に意味はあるのか。あります。ただし、狙いは「精度そのもの」ではなく「精度が担保する運用の読みやすさ」です。
- 「08:30 に動くはずが 08:30:05 に動いた」なら、ログを見る時間帯を秒単位で予測できる。「21:15 のはずが 22:54」(+99 分)だと、朝の確認で「まだ動いてないのか、それとも欠火か」の切り分けに手間が要る。
-
無通知の欠火リスクを、配送試行と監視が見える構成へ置き換えられることは、精度以上に効く。前回 GitHub
scheduleは初日に発火そのものを(気づけないまま)スキップした。Cloud Scheduler はスケジュール配送が少なくとも 1 回試行される設計(at-least-once)で、むしろ「多すぎる」側を §4 で潰す。もちろんジョブ削除・API 停止・IAM 不備・GCP 障害まで含めれば処理成立が絶対に保証されるわけではない(公式も "designed to provide at least once" という表現)――だから §9 のとおり最終担保は死活監視に置く。
つまり Cloud Scheduler の価値は「秒で殴る」ことではなく、「いつ動くかを狭い窓で読めて、無通知の欠火を検知可能にできる」可観測性です。それでも GCP 自身は止まりうるので、最終担保はやはり §9 の死活監視 ― この重心は前回と同じです。
11. 実際に動かした記録(並行運転での切替)
前回は「GAS 停止と有効化を同時に」行う二段階切替でした。今回はより保守的に、並行運転で載せ替えています。
11.1 並行運転 ― 時刻分離で二重起動を避ける
「GAS 停止と Cloud Scheduler 有効化を同時に」は瞬間的な二重起動リスクをゼロにできますが、切替の瞬間に不具合が出ると業務が止まります。そこで時刻を分離した並行運転にしました。
- Cloud Scheduler: JST 08:30・実 DML(mode=run)・ENABLED(新設)
- GAS: JST 20:30・実 DML(現行のまま停止せず)
同日に 2 回 prod 実 DML が走りますが、瞬間衝突はなく(12 時間差)、020_aggregate.sql は当日日付キーの UPSERT で冪等なので二重計上は起きません(当日データは後発=20:30 GAS が上書き)。数日の並行観測で「毎朝 08:30 の Cloud Scheduler が prod/run で完走し LAPP_HEALTH を更新する」ことを確認してから、GAS トリガーを removeTriggers() で撤去し、Cloud Scheduler 単独運用へ移ります。ロールバックは preprod.tfvars で paused に戻すだけ(GAS が 20:30 に本来処理をするので業務は守られる)。
前回との違い: 前回の集計 SQL は冪等(UPSERT)だと確認済みだったからこそ、この並行運転が安全に選べた。非冪等 DML なら並行運転は選べず、§4 の送信前予約 + 瞬間同時切替が必須になる。「冪等性が運用戦略の自由度を決める」好例。
11.2 切替前検証 ― 本番 PAT を失効させずにアラートを試す
Phase 4 で層①アラートを実証しましたが、試験のために本番 GH_PAT を失効させてはいけません(失効は不可逆で、稼働中の GAS 経路も壊す)。代わりに検証用 Secret に無効値を入れる / GitHub 応答をモックする / 存在しないリポジトリ名で 404 を作る、で非2xx を発生させました。いずれも本番 PAT・現行 GAS に触れません。
11.3 原子ロックの単体テスト
§4 の TOCTOU 対策は本番で live 検証すると危険(検証自体が二重送信しうる)なので、Firestore エミュレータ上の単体テストで担保しています。「同一 (JobName, ScheduleTime) を並行で 2 回投げて、GitHub 送信が 1 回になる」ことを assert。この 4 件が緑になるまで実環境 apply は禁止、という CI ゲートにしています。
12. まとめ ― スケジューラだけを、精度を上げて、事故なく載せ替える
やったことを一言でいえば、「実行基盤は GitHub Actions のまま、定期起動の送信元だけを GAS → Cloud Scheduler に載せ替え、起動精度(±分 → 秒オーダー)と個人アカウント非依存を得た」です。前回の資産(repository_dispatch・fail-close・profile 分割・死活監視)は 1 つも捨てていません。
設計の背骨は 3 つ。
-
at-least-once を送信前予約で潰す ―
retry:0は重複を防がない。冪等キー(JobName, ScheduleTime)を Firestore に原子的に送信前ロックし、TOCTOU を塞いで at-most-once に倒す(§4)。 -
危険な既定値へ倒れない ―
Content-Type欠落で消えるmodeを予約より前に fail-close 検証。曖昧区間はfailed/unknownに分類し、予約は解放しない(§5・§6)。 - PAT を平文で置かない ― OIDC でプロキシを認証し、Secret Manager から読む。SA は責務で 3 分割・最小権限(§7)。
そして起動精度は、三者を実測で並べました(§10)。今回の観測値では Cloud Scheduler は目標比 +0.834 秒(Run 生成まで +5 秒)、GAS は「約 6 分早い」、GitHub schedule は「+99 分・欠火」 ― サービス一般の性能比較ではなく本システムの移行前後の観測記録ですが、桁の差は明確でした。ただし ― 精度は目的ではなく、「いつ動くかを狭い窓で読め、無通知の欠火に怯えなくてよい」可観測性のための手段です。この日次バッチに秒精度は要らない。それでも「静かに止まったら外部の死活監視で気づける」という設計の重心は、基盤を GAS から Cloud Scheduler に替えても、まったく動いていません。GCP 自身も止まりうる以上、"止まらない" ではなく "止まっても気づける" ― 前回からの一貫した結論です。
本記事の Terraform・プロキシ実装・単体テストは、実際に preprod の dry-run から prod の実 DML 発火まで動作確認したものです。前回(GAS 版)と合わせて読むと、「kintone を SQL で回す日次バッチ」の定期起動を GitHub
schedule→ GAS → Cloud Scheduler と載せ替えながら、そのつど壊れ方と検知を設計してきた一連の記録になります。