repository_dispatch・二段階切替・fail-close の設計ノート
1. はじめに ― 前回の「宿題」を回収する
前回の記事で、kintone を SQL で操作する CLI(kSQL)+ GitHub Actions で日次バッチを組み、**「静かに壊れても気づける」**ところまで作りました。その中で、GitHub Actions の schedule(cron)についてこう書いています。
scheduleの cron はベストエフォートです。(中略)高負荷時には数分〜数十分の遅延や、まれに実行のスキップが起こり得る。(中略)厳密な時刻や高頻度が要るバッチは、そもそもこの基盤に載せない。
(この遅延・スキップは GitHub 公式(events that trigger workflows: schedule) にも明記されています。)
さらにもう一つ、Public リポジトリは 60 日間活動がないと schedule が自動的に無効化される(同上)という「静かな停止」の芽も指摘しました。そして、こう逃げ道を書いていました。
厳密な時刻要件がある場合は外部トリガー(例: 別システムからの
workflow_dispatchAPI 呼び出し)を検討する。
この記事は、その**「外部トリガー」を実際に作った記録**です。定期起動だけを GitHub の schedule から切り離し、Google Apps Script(GAS)の時間主導トリガーに持たせました。ワークフロー本体・SQL・kintone 側はほぼそのまま、スケジューラ層だけを外出しします。
要点
- 定期起動を GitHub
schedule→ GAS がrepository_dispatchを叩く方式へ移行。実行基盤(GitHub Actions)は据え置き。 - GAS を選んだのは、無料・GCP 不要・祝日カレンダーが使える・「起動要求を送れたか」を GAS 自身が検知できるから。
-
素朴に条件式を置換すると本番が壊れる(
scheduleとrepository_dispatchの共存)。自由入力のclient_payloadを信じると fail-open で本番 DML に転落する。この 2 つを潰した過程が本題。 - 切替は **二段階(追加 → 撤去)**でしか安全にできない。順序を間違えると「起動できない」か「二重起動」になる。
- 沈黙の検知は三層(起動要求の送信失敗 / トリガー自体の失敗 / dead-man's switch)。前回の死活監視がここでも最後の砦。
想定読者: kintone / GitHub Actions の定期処理を運用していて、
scheduleの遅延・スキップ・自動無効化が気になっている方。外部スケジューラの選択と、その切替を事故なく行う設計に関心がある方。前回記事の内容(論理参照・profile 分割・多層ゲート・死活監視)を前提にします。読み方(目的別):
- 動機と選択だけ: §1〜§3、§12
- 「素朴にやると壊れる」話だけ: §4、§5
- 切替の実務: §6〜§8
- 失敗検知と運用: §9〜§11
お断り: kSQL(
@rex0220/kintone-sql-tools)は筆者の OSS です。宣伝目的ではなく設計知見の共有が主旨です。本移行の実装・検証は AI コーディング支援(Claude Code)を併用し、dev の dry-run から prod の実 DML 実行まで実際に動作確認しています。本文の落とし穴は、その過程で実際に踏んで潰したものです。
1.1 前回から引き継ぐ要素(本記事から読む人向け)
前回記事の設計をそのまま土台にします。未読でも追えるよう、引き継ぐ要素と本記事での役割を先にまとめます。
| 引き継ぐ要素 | 前回での意味 | 本記事での役割 |
|---|---|---|
| profile 分割(用途 × 環境の 6 本) | トークンの最小スコープ | 自動経路を prod profile に固定(§4) |
| confirm_dml ゲート | 手動実行の誤 DML 防止 | 手動と自動経路の分岐に流用(§4) |
| 終了コード規約(1/2/3) | 失敗の一次切り分け | payload 不正を exit 2 で停止(§5) |
dead-man's switch(LAPP_HEALTH) |
「そもそも起動したか」の検知 | GAS を含む未実行の最終検知(§9) |
論理参照 LAPP_* |
同じ SQL を dev/prod で | 無変更(スケジューラ層のみ差し替え) |
1.2 全体像(1 枚)
2. なぜ GAS だったのか ― 外部スケジューラの選択肢
「schedule をやめて外から叩く」と決めたあと、叩く側を何にするかで 3 つ検討しました。落とした理由込みで並べます。
| 選択肢 | 評価 |
|---|---|
GitHub schedule のまま(据え置き) |
遅延・スキップ・60 日自動無効化が解消しない。今回の動機そのものが残る |
| Cloud Scheduler + Cloud Run Jobs | 高精度で堅牢。ただし実行基盤ごと GCP へ移す大工事になり、Secret も Secret Manager へ。今回の「スケジューラだけ替えたい」には過剰 |
| GAS の時間主導トリガー | 無料・Google アカウントだけで完結。実行基盤は GitHub Actions のまま。祝日カレンダーで営業日制御も容易。起動要求を送れたかを GAS 自身が即検知できる |
決め手は 3 つです。
- 据え置き基盤との相性 ― 実行は GitHub Actions のまま、スケジューラ層だけ差し替えられる。前回作り込んだ多層ゲート・死活監視・profile 分割は 1 行も捨てない。
-
scheduleの弱点をピンポイントで消す ― GAS の時間主導トリガーは遅延が概ね ±15 分内で、60 日自動無効化のような"活動ゼロで勝手に止まる"挙動がない(GAS も止まりうるが、その場合は下記③の死活監視で検知する)。 -
新しい検知層が増える ― GAS は「GitHub に起動要求を送れたか(API が 204 を返したか)」をその場で知れる。
scheduleにはこの層がそもそも無かった(起動しなければログすら出ない)。
補足: Cloud Scheduler 案も設計としては有力です。kintone トークンや実行基盤ごと自社 GCP に寄せたい・IAM で環境分離を厳格化したい、という要件ならそちらが向きます。本記事は「GitHub Actions 資産を活かしたまま、
scheduleの弱点だけ消す」ケースの記録です。
3. GitHub Actions を「起動される側」に回す ― 2 つの API
外から GitHub Actions を起動する API は 2 つあります。前回の「schedule は常に prod・自動 DML・失敗時 Issue 起票」という挙動をどう再現するかが選定軸でした。
| API | イベント | 入力 | 特徴 |
|---|---|---|---|
repository_dispatch |
POST /repos/{o}/{r}/dispatches |
event_type + 自由形式 client_payload
|
既定ブランチでのみ実行。手動フォームと独立 |
workflow_dispatch |
POST .../workflows/{id}/dispatches |
ref + typed inputs
|
任意 ref・環境/モードを渡せる |
方式A(repository_dispatch)を主にしました。理由:
-
手動フォームを一切変えない ― 前回作った
workflow_dispatchの dev/prod × mode の UI はそのまま。スケジューラ経路が独立イベントになる。 - 詐称できない ― 「これはスケジュール起動だ」というシグナルが独立イベントなので、手動 UI から誤って自動 DML 経路(confirm 免除)に乗る事故が起きない。
-
差分が小さい ― ワークフローの
github.event_name == 'schedule'を「scheduleまたはrepository_dispatch」に広げるだけで、旧挙動がそのまま移る……はずでした。ここに最初の落とし穴があります(§4)。
GAS から repository_dispatch を送るのは、UrlFetchApp の数行です。
function dispatch_(clientPayload) {
const url = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/dispatches`;
const res = UrlFetchApp.fetch(url, {
method: 'post',
contentType: 'application/json',
headers: {
Authorization: 'Bearer ' + ghPat_(),
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
payload: JSON.stringify({ event_type: EVENT_TYPE, client_payload: clientPayload || {} }),
muteHttpExceptions: true,
});
const code = res.getResponseCode();
if (code !== 204) { // 204 = 受付成功。それ以外は起動要求を送れていない
notifyDispatchFailure_(code, res.getContentText());
throw new Error(`repository_dispatch 失敗 code=${code}`);
}
return code;
}
4. 素朴な置換が本番を壊す ― schedule と repository_dispatch の共存
前回のワークフローは、「自動経路(prod・mode=run・confirm 免除・失敗時 Issue 起票)」を github.event_name == 'schedule' で判定していました。方式A に移すなら、素朴にはこう置換したくなります。
# 素朴な発想(これは壊れる)
environment: ${{ github.event_name == 'repository_dispatch' && 'prod' || inputs.environment }}
これをやると、切替期間中に本番が壊れます。
理由は、切替を二段階で行うことにあります(詳細は §7)。第1段階では repository_dispatch を追加しつつ、旧 schedule をまだ残す(GAS 側の検証が済むまで本番を止めないため)。ところが上の置換をしてしまうと、この期間に旧 schedule が発火したとき ―
-
environmentとKSQL_PROFILEが 空になる(repository_dispatchではないので) - 自動経路と見なされず、
confirm_dml未指定で exit 2 で停止 - Issue バックストップの条件(
schedule)にも当たらず、気づけない
つまり「切替が済むまでは schedule が本番を回す」という前提そのものが崩れます。
正解は、両イベントを OR で「自動経路」として束ねること。
# schedule または repository_dispatch を prod 自動経路として扱う
environment: ${{ (github.event_name == 'schedule' || github.event_name == 'repository_dispatch') && 'prod' || inputs.environment }}
同じ OR を、concurrency / KSQL_PROFILE / confirm_dml 免除 / Issue バックストップ ×2 のすべての判定に適用します。bash 側も同様。
# 自動経路(schedule / repository_dispatch)は client_payload、手動は inputs を見る
if [ "$EVENT_NAME" = "schedule" ] || [ "$EVENT_NAME" = "repository_dispatch" ]; then
MODE="${CP_MODE:-run}"
EFFECTIVE_DRY_RUN="${CP_DRY_RUN:-false}"
else
MODE="${INPUT_MODE:-run}"
EFFECTIVE_DRY_RUN="${INPUT_DRY_RUN:-false}"
fi
schedule は client_payload を持たない(CP_MODE は空 → 既定 run、CP_DRY_RUN は空 → 既定 false)ので、OR で束ねても従来の「schedule は run 固定・dry-run なし」と完全に等価です。第2段階で schedule を撤去したら、OR の schedule 項は「発火しない死条件」になりますが無害です(読みやすさのため後で消してもよい)。
教訓: イベントを1つ足す移行では、既存イベントの挙動を置換してはいけない。移行期間は両者が同時に存在しうるので、「新旧どちらでも同じ経路」に広げるのが正解。単純置換は、移行の谷間でだけ壊れる(=レビューやテストをすり抜けやすい)性質の悪いバグになる。
5. 自由入力を信じない ― client_payload の fail-close
方式A の repository_dispatch は、client_payload に自由な JSON を載せられます。GAS からは mode(run / test)と dry_run を渡し、ワークフローが尊重します。
CP_MODE: ${{ github.event.client_payload.mode }}
CP_DRY_RUN: ${{ github.event.client_payload.dry_run }}
ここに、前回の記事の教訓(§6.2 の偽陽性)と同じ匂いの落とし穴があります。client_payload は typed input ではない自由形式なのに、typed input のように信頼してしまう危険です。
具体的に考えます。もし GAS 側で mode: 'tset'(test のタイプミス)と送ったら、素朴な実装では ―
-
MODE="${CP_MODE:-run}"→MODE=tset(空でないので既定runにも落ちない) -
tsetはtestではない → dry-run にならない -
repository_dispatchは自動経路なので confirm 免除 -
runのtarget制限にも当たらない
結果、全 SQL が prod で実 DML 実行されます。1 文字のタイポが本番書き込みに直結する fail-open。前回「npx が無関係パッケージを引いて無出力・exit 0 で握り潰す」偽陽性を潰したのと、根は同じ ―「信頼できない入力を検証せず通す」問題です。
対策は、自動経路の値を実行前に fail-close で検証すること。方式A では recovery を使わないので、mode は run|test に限定します。
# repository_dispatch の client_payload を fail-close 検証(不正なら実行前に exit 2)
if [ "${EVENT_NAME:-}" = "repository_dispatch" ]; then
case "$MODE" in
run|test) ;;
*) echo "::error::repository_dispatch の mode は run または test のみ許可: '$MODE'"; exit 2 ;;
esac
case "$EFFECTIVE_DRY_RUN" in
true|false) ;;
*) echo "::error::repository_dispatch の dry_run は true または false のみ許可"; exit 2 ;;
esac
fi
exit 2 は前回定めた終了コード規約(引数・設定エラー)に一致させています。mode: 'tset' は SQL を 1 文も実行する前に止まり、Issue バックストップで気づけます。
教訓:
client_payload(やrepository_dispatchの payload 全般)は外部から任意に送れる自由入力。typed なworkflow_dispatchのinputs(GitHub が choice で制約)とは信頼度が違う。自動 DML 経路に載る値ほど、allowlist で fail-close する。「不正なら止まる」側に倒すのが、ロールバックの無い kintone(前回 §9.4)では特に効く。
6. ロジックを 1 箇所に ― 共有スクリプトと単体テスト
§4・§5 の判定(入力源の切替・dry-run 判定・fail-close)は、ワークフローのインライン bash に直接書くと、テストできません。そこで純ロジックを 1 ファイルに切り出し、ワークフローは source で読み込む形にしました。
# 入力源切替・dry-run 判定・fail-close を共有スクリプトに集約(単一の真実の源)
# shellcheck source=scripts/derive-run-mode.sh
. scripts/derive-run-mode.sh
こうすると、同じスクリプトを単体テストにかけられます。とくに検証したいのは「mode=run のまま dry_run=true だけで dry-run になるか(CP_DRY_RUN 統合)」ですが、これを本番ワークフロー上で live 検証するのは危険です ― もし統合が壊れていたら、その検証自体が prod 実 DML を走らせてしまう(また fail-open!)。
だから API にも kintone にも触れない純ロジックの単体テストで担保します。
run_case "repo_dispatch run + dry_run=true" run "--dry-run" EVENT_NAME=repository_dispatch CP_MODE=run CP_DRY_RUN=true
expect_fail "mode=tset(タイポ)を拒否" EVENT_NAME=repository_dispatch CP_MODE=tset
expect_fail "dry_run=yes を拒否" EVENT_NAME=repository_dispatch CP_MODE=run CP_DRY_RUN=yes
これを pr-check.yml(前回 §12.1 の Secret 不要検証)に追加し、PR ごとに緑を必須にしました。fail-close の各ケース(タイポ・recovery・TRUE/yes などの不正値)が厳密に exit 2 で止まることまで assert しています。
教訓: 「安全のためのロジック」ほど、テスト可能な形(=副作用のない純関数/スクリプト)に切り出す。本番経路で live 検証すると、検証したい欠陥が存在するときに限って被害が出る。GAS からの live dispatch は常に
mode=test(dry-run 強制)に限定し、危険な組み合わせはオフラインで確かめる。
7. 二段階切替 ― 順序を間違えると起動できない
ここが移行の実務の核心です。repository_dispatch には、見落としやすい制約があります。
repository_dispatchは、そのイベント定義を含むワークフローが既定ブランチに存在しないと起動しない。(GitHub 公式(events: repository_dispatch))
つまり「まず GAS から dry-run を送って観測 → 良ければワークフローを対応させる」という順序は成立しません。ワークフローが先に既定ブランチへ入っていないと、GAS が送っても 422 が返るだけ。逆に、旧 schedule を消してから GAS トリガーを有効化するまでに空白があると、その間は誰も定期起動しない。両方が生きていれば二重 DML。
そこで二段階にします。
第1段階(PR-1): repository_dispatch を追加し、schedule は残す
-
on.repository_dispatch(types: [daily-scheduled])を追加。 - 判定を §4 の OR に広げ、§5 の fail-close、§6 の共有スクリプトを入れる。
-
on.scheduleはまだ残す ← この間も本番はscheduleが回すので、業務は止まらない。 - マージ後、GAS から
dispatch_({ mode: 'test' })で prod を dry-run 起動して経路を実証(本番 DML はscheduleが担当し続けているので安全に確認できる)。
第2段階(PR-2): schedule を撤去し、GAS 本番トリガーを有効化
-
on.scheduleを削除(起動源を GAS に一本化)。差分はon:ブロックのみ・数行。 -
マージ直後に GAS で
installTrigger()を実行し、時間主導トリガー(毎日 JST 21:15 付近・mode=run・実 DML)を登録。 - 「
schedule撤去」と「GAS トリガー有効化」を近接させ、二重起動も空白も作らない。
function installTrigger() {
removeTriggers(); // 冪等(既存を消してから作る)
ScriptApp.newTrigger('runDailyBatchProd')
.timeBased().atHour(21).nearMinute(15).everyDays(1).create();
}
不調時のロールバックは「PR-2 を revert(schedule 復活)+ GAS の removeTriggers()」で、どちらか一方に戻します。
教訓: イベント駆動の移行は「①新経路をデフォルトブランチに入れる → ②旧経路で本番を回したまま新経路を dry-run 実証 → ③旧経路を外すと同時に新経路を本番化」の順序が鉄則。この順序を崩すと、必ず「起動できない」か「二重起動」のどちらかに落ちる。
8. GAS 側の実装 ― 最小権限と責務分離
GAS プロジェクトは、責務でファイルを分けました(GAS は全ファイルを 1 名前空間に平坦化するので、分割は可読性のため)。
gas/src/
Config.js 定数 + Script Properties アクセサ
GitHubClient.js dispatch_ / dispatchManual_ / ping_
Schedule.js runDailyBatchProd(本番入口)/ isBusinessDay_
Notify.js notifyDispatchFailure_(起動失敗の通報)
Triggers.js installTrigger / removeTriggers
Tests.js test_ping / test_scheduledDryRun / test_runProdReal
8.1 PAT の権限を取り違えない ― repository_dispatch は Actions ではなく Contents
ここで一つ、API と権限の対応で罠を踏みかけました。repository_dispatch を叩く PAT に必要なのは Actions ではなく Contents: Write です(GitHub 公式)。「Actions を起動するんだから Actions 権限だろう」と素朴に付けると、POST /dispatches が 403 で落ちます。
したがって方式A の最小権限は、対象リポジトリ 1 個に絞った Fine-grained PAT に Contents: Read and write のみ。workflow_dispatch(方式B)も使うなら、それに加えて Actions: Read and write が要ります。疎通確認 ping_() は repo メタデータ(GET /repos/{o}/{r})を叩くので、Metadata:Read(Fine-grained PAT は常時付与)だけで 200 が返る ―/actions/... を叩かないのは、最小権限を崩さないための意図的な選択です。
8.2 機密はコードに書かない ― Script Properties
kintone トークンは前回どおり GitHub Secrets に置き、GAS は起動用 PAT だけを持ちます(責務分離: GAS = 起動、GitHub = 実行と機密)。その PAT もコードに書かず Script Properties に置きます。
function ghPat_() {
const pat = PropertiesService.getScriptProperties().getProperty('GH_PAT');
if (!pat) throw new Error('Script Property GH_PAT が未設定です');
return pat;
}
タイムゾーンは appsscript.json で Asia/Tokyo に固定(getDay()・祝日判定・トリガー時刻がこれで解釈される)。営業日スキップ(土日・祝日)は既定 OFF ―「毎日実行」という現行仕様を勝手に変えないためで、合意が取れたら Script Property で有効化する形にしています。
9. 沈黙を三層で拾う ― 起動失敗・トリガー失敗・dead-man's switch
前回の記事の主題は「動かなくなったことの検知」でした。スケジューラを外に出すと、新しい「動かない」パターン(GAS が送れない/止まる)が増えます。そこで検知を三層にしました。
| 層 | 検知するもの | 手段 |
|---|---|---|
| ① 起動要求を送れたか | PAT 失効・権限不足・repo 名誤り・GitHub 障害 |
GAS: dispatch_ が 204 以外 → ALERT_EMAIL へ即メール |
| ② トリガー自体の失敗 | GAS 関数が例外・タイムアウト | Apps Script 標準の失敗通知メール(トリガー所有者へ自動送信。Google 公式(インストール可能なトリガー)) |
| ③ そもそも走らない | 204 後に走らない / GAS ごと停止 |
LAPP_HEALTH の dead-man's switch(前回 §11.2。更新が止まると約 26 時間後にリマインダー発火) |
①は schedule 時代には無かった層です。GAS は「送った直後」に成否が分かるので、最速で気づけます。
function notifyDispatchFailure_(code, body) {
MailApp.sendEmail(alertEmail_(), '[ksql-actions2] GitHub 起動要求の送信失敗',
`repository_dispatch が 204 を返しませんでした(起動要求を送れていない)。\ncode=${code}\n\n${body}\n\n` +
'Contents:Write 権限・PAT の失効・リポジトリ名を確認してください。' +
'※204 が返っても実際にバッチが走ったかは死活監視で別途確認すること。');
}
9.1 204 を過信しない
重要な注意です。repository_dispatch の 204 は「イベントを受け付けた」ことしか保証しません。 ワークフローが実際に起動・完走したことは保証しない(受付後に条件不一致・並行制限などで走らないこともある)。だから①は「送れた」層に留め、「バッチが実際に走ったか」は必ず③の dead-man's switch で担保します。前回作った死活監視が、スケジューラを替えてもそのまま最後の砦になっているわけです。
そして GAS 自身が止まる(トリガー削除・アカウント停止)ケースは①でも②でも拾えない(そもそも動いていない)。ここでも③が効きます。「GAS を過信せず死活監視を必ず維持する」 ― これが三層構成の結論です。
10. 起動精度という現実 ― ±15 分と everyMinutes(15) のトレードオフ
GAS の時間主導トリガー atHour(21).nearMinute(15) は、21:15 きっかりには発火しません。概ね ±15 分の窓で発火します(Google 公式も時間主導トリガーは指定時刻から「最大 15 分ずれうる」としています。time-driven triggers)。「schedule の遅延が嫌で外出ししたのに、GAS も遅れるのか」と思うかもしれません。
ここは正直に。精度で GAS が圧勝するわけではありません。GAS の利点は「窓が狭い(数十分の遅延やスキップが起きにくい)」「60 日自動無効化が無い」であって、秒単位の正確さではない。前回の設計思想「その日のうちに 1 回動けば良い / 死活監視に 26 時間の猶予」がそのまま効くので、±15 分は許容範囲です。
10.1 everyMinutes の間隔ジッターを実測する
「だいたい何秒ズレる基盤なのか」を掴むため、発火時刻を記録するだけの無害なプローブ(DML も dispatch もしない別関数)を everyMinutes(5) で回し、連続する発火間隔を測りました。約 2 時間・22 サンプルの結果です。
idx JST時刻 間隔(秒) 5分との差(秒)
1 07-13 11:21:49 300 +0
6 07-13 11:46:50 301 +1
9 07-13 12:01:51 302 +2
10 07-13 12:06:50 299 -1
...
サンプル 22 件 / 5分からのズレ: 平均 0秒 ・ 最大 2秒
間隔ジッターは平均 0 秒・最大 2 秒。ほぼ理想的な等間隔でした。発火が毎回 :49〜:51 秒に来ているのは、everyMinutes の位相が登録時刻を起点にしているため(時計の :00/:05 には揃わない)。GAS のスケジューリング・エンジン自体は、少なくともこの計測では極めて律儀に動いています。
ただしこの 2 秒を過大評価しないこと。これは everyMinutes の間隔ジッターであって、本番で使う日次トリガー atHour(21).nearMinute(15) の壁時計に対する ±窓とは別メカニズムです。GAS 公式は日次(時刻指定)トリガーを「目標の ±15 分程度」としており、間隔ジッターが 2 秒だからといって「21:15 に ±2 秒で発火する」わけではありません。**「GAS の内部時計は正確・ただし日次発火の窓は別途 ±15 分を見込む」**が正しい読み方です(本番同型の日次トリガーの窓そのものは §11.5 で別途プローブ計測 ― 初日は 2 回とも約 6 分早かった)。
10.2 everyMinutes + ガードという選択肢
より詰めたいなら、everyMinutes(15) で回して「JST 21:15 を過ぎ、かつ本日未実行なら 1 回だけ dispatch」するガード方式もあります。トレードオフはこう。
| 日次トリガー(採用) |
everyMinutes(15) + ガード |
|
|---|---|---|
| 取りこぼし耐性 | 単発を落とすとその日は未実行(→ 死活監視で検知・手動 recovery) | 後続 tick が拾う(自己回復) |
| 二重起動リスク | ほぼ無い | ガードのバグ・LAST_RUN_DATE 書き込み失敗で同日二重 DML の危険
|
| 複雑さ | 低 | 高(日付比較・冪等判定が安全上クリティカル) |
非冪等な集計 DML では、「稀に 1 日落とす(復旧可能)」方が「ガードのバグで二重集計(データ破損)」より安全です。しかも③の dead-man's switch が「走らなかった日」を検知するので、everyMinutes(15) の自己回復性はかなり冗長。まず日次で運用し、実際に取りこぼしが観測されたら(+SQL 側も冪等化してから)切り替える、という順序にしました。
11. 実際に動かした記録
理屈だけでは信用できないので、dev 相当の dry-run から prod の実 DML まで、経路を実測しました。
11.1 疎通確認(test_ping)
repo メタデータへ GET。
ping code=200 (OK)
最小権限(Contents:RW のみ)の PAT で 200。/actions/... を使わない設計が効いています。
11.2 スケジュール経路の dry-run(test_scheduledDryRun)
dispatch_({ mode: 'test' })。GitHub Actions に event=repository_dispatch の Run が起動し、prod に解決・dry-run で完走。ステップの内訳がこう。
success Run daily batches
success Upload result logs
skipped Record success to kintone (dead-man's switch) ← real_run=false
skipped Notify kintone on batch failure ← real_run=false
死活監視・通知が skipped = real_run=false が効いており、kintone への書き込みゼロ・prod データ無傷。§5 の fail-close 付き共有スクリプトも本番経路で問題なく動きました。
11.3 本番相当の実 DML(test_runProdReal)
dispatch_({ mode: 'run' })。PR-2 マージ後に 1 回。
KSQL_PROFILE: prod / ksql version: 1.13.2
success Run daily batches ← 010/020 を実 DML 実行
success Record success to kintone (dead-man's switch) ← 今度は実行された(real_run=true)
skipped Notify kintone on batch failure ← 失敗なし
dry-run 時は skipped だった死活監視 UPSERT が、実 DML 時は実行された ―real_run ゲートが経路の切替を正しく反映しています。LAPP_HEALTH の最終 RunID・更新日時がこの Run で前進し、dead-man's switch の基準が更新されました。
11.4 GitHub schedule の実発火 ― そして「なぜ移したか」の実データ
移行の動機(§2)を裏付ける、少し強烈な実データが Actions のログに残っていました。切替前に一度だけ走った schedule 起動の Run です。
| 項目 | 値 |
|---|---|
| cron 設定 |
15 12 * * *(目標 12:15:00 UTC = JST 21:15) |
実際の発火(Run の createdAt) |
13:54:17 UTC(JST 22:54) |
| 目標との差 | +99 分(1 時間 39 分)遅延 |
| 結果 | success(実行自体は成功) |
1 時間 39 分の遅れ。前回記事で「数分〜数十分の遅延・スキップがある」と書きましたが、実測は「数十分」を超えました。バッチ自体は成功しているので気づきにくいのも厄介です(21:15 のつもりが 22:54 に動いていた)。
対して、同時期の GAS プローブ(§10.1)は間隔ジッター平均 0 秒・最大 2 秒。並べるとこうなります。
| 基盤 | 目標からのズレ(実測) | サンプル | 測っているもの |
|---|---|---|---|
GitHub schedule |
+99 分 | n=1(切替前の唯一の自動発火) | 壁時計に対する起動遅延 |
GAS everyMinutes(5) |
平均 0 秒 / 最大 2 秒 | n=22(約 2 時間) | 間隔ジッター |
公平のための注記: この 2 つはサンプル数も測定対象も揃っていません。
scheduleは n=1(たまたま混雑した日だった可能性は残る)で「壁時計に対する起動遅延」、GAS は n=22 で「間隔ジッター」。厳密な同条件比較ではありません。それでも、桁が違うという事実と、「1 回目の自動発火がいきなり 99 分遅れた」という体験は、scheduleのベストエフォート性を十分に物語ります。より揃えた比較を、次の §11.5 で無害なプローブとして走らせました。
11.5 同条件プローブでの実測(暫定・継続中)
「本命」の比較 ― 本番と同じ時刻指定型トリガーで、GitHub と GAS に同じ壁時計を狙わせ、目標からのズレを測る ― を、無害なプローブで始めました。GitHub 側は DML を持たない専用ワークフロー(schedule)、GAS 側は本番 21:15 と同じ atHour.nearMinute 型の日次トリガーで、それぞれ発火時刻だけを記録します。初日の暫定結果です。
GAS 日次トリガー(本番同型):
| 目標 | 実際の発火 | ズレ |
|---|---|---|
| 13:30:00 | 13:24:13 | -347 秒(約 5.8 分早い) |
| 14:30:00 | 14:23:39 | -381 秒(約 6.4 分早い) |
2 回とも発火し、いずれも約 6 分早い。§10.1 の everyMinutes 間隔ジッター(秒オーダー)と違い、日次トリガーの窓は分オーダーで、しかも今回は早い側に一貫して寄りました(安定した前倒しバイアスなのか ±15 分窓の揺れなのかは、数日ぶんの追加サンプルで切り分け予定)。
GitHub schedule(同日・13:30 目標):
| 目標 | 結果 |
|---|---|
| 13:30 | 発火せず(当日スキップ) |
新規に追加した schedule が、初日は 1 度も発火しませんでした(GitHub 公式も「新規スケジュールは登録直後に走らないことがある/高負荷時は遅延・スキップ」と明記)。切替前に観測した +99 分遅延と合わせ、GitHub schedule には**「遅延」と「欠火」の両方**が実データで揃いました。
暫定まとめ:
| 基盤 | 発火 | 時刻ズレ |
|---|---|---|
| GAS 日次(本番同型) | 2/2 発火 | 約 6 分早い(安定) |
GitHub schedule |
過去 +99 分遅延 / 当日欠火 | 遅延も欠火もあり |
注記(暫定・n が小さい): これは初日 2〜3 サンプルの速報で、統計的な結論ではありません。GAS の「6 分早い」も、安定した前倒しバイアスか ±15 分窓の揺れかは数日貯めてから判断します。それでも今回の観測範囲では、GAS は分オーダーの窓内で 2 回とも発火し、GitHub
scheduleでは遅延と欠火の両方が確認された、という方向性の違いが出ました。「時刻の正確さ」より「起動が観測できること」を重視するなら、定期起動を外部スケジューラに出す判断は支持されます(GAS も止まりうるので、最終的な担保は §9 の死活監視)。なお 6 分の前倒しは、日次スナップショット用途では実害がない範囲です(「定刻以降が前提」の処理には向かない、という留保付き)。
この ② → ③ で、**「GAS が叩く → prod に解決 → dry-run では書かない / 実行では書く・死活監視が前進する」**という移行のフルパスが、本番データ上で確認できました。
12. まとめ ― スケジューラだけを、事故なく外に出す
やったことを一言でいえば、**「実行基盤は GitHub Actions のまま、定期起動だけを外部(GAS)に出して、GitHub schedule 固有の遅延・スキップ・自動無効化リスクを回避した」**です(GAS 側にも遅延・欠火はありうるので"消した"ではなく"回避・低減した"。最終担保は §9 の死活監視)。前回の資産(論理参照・profile 分割・多層ゲート・死活監視)は 1 つも捨てていません。
設計の背骨は 3 つ。
- 素朴な置換を避ける ― イベントを足す移行では、既存判定を「置換」せず「OR で広げる」。移行の谷間で壊れるバグを作らない(§4)。
-
自由入力を信じない ―
client_payloadは fail-close で allowlist 検証。1 文字のタイポが prod DML に転落する fail-open を塞ぐ(§5)。安全ロジックはテスト可能な形に切り出す(§6)。 - 順序を守って切り替える ― 「新経路を既定ブランチへ → 旧経路で本番を回したまま dry-run 実証 → 旧経路撤去と同時に新経路本番化」。起動できない/二重起動のどちらにも落ちない(§7)。
そして、スケジューラを外に出したぶん増える「沈黙」を、起動要求の送信失敗(新層)・トリガー自体の失敗・dead-man's switch の三層で拾う(§9)。204 は受付でしかないので、実行の成否は前回作った死活監視に委ねる ― 結局、最後に守ってくれるのは前回の設計でした。
外部スケジューラは「精度で殴る」道具ではありません。狙いは、GitHub schedule より遅延・欠火のリスクを下げ、かつ「静かに止まった場合も外部の死活監視で検知できる」観測可能性を、実行基盤を作り替えずに手に入れること ― そのための、地味だが効く一手です。GAS 自身も止まりうる以上、"止まらない"ではなく"止まっても気づける"に設計の重心を置きます。
本記事の config・ワークフロー差分・GAS・単体テストは、実際に dev の dry-run から prod の実 DML まで動作確認したものです。前回の記事と合わせて読むと、「kintone を SQL で回す日次バッチ」を組んで・壊れに強くして・スケジューラごと安全に載せ替えるまでが一通りたどれます。