1. はじめに ― 手作業の集計を消したい
毎朝、kintone を開いて「昨日までに完了した受注」を数え、金額を合計し、別アプリに書き写す。よくある業務です。そして、よく忘れます。忘れても誰も気づきません。
この記事は、その手作業を 「kintone を SQL で操作する CLI(kSQL)」+「GitHub Actions の定期実行」 で消し、かつ 「バッチが静かに壊れても気づける」 ところまで作り込んだ記録です。単なる手順書ではなく、「素朴にやると何が壊れるか → だからこう設計した」 を各章で追いかけます。
要点
- kintone を
SELECT/UPSERT/ASSERTで操作できる kSQL(@rex0220/kintone-sql-tools)を、GitHub Actions のscheduleで日次実行する。 - 同じ SQL を 論理参照
LAPP_<NAME>で書き、--profileの切替だけで dev / prod に流す。 - GitHub Actions は「時刻どおりに動かない・状態を持たない・でもほぼ無料」なプラットフォーム。その性格に逆らわない設計にする。
- 「動かなくなったこと」を検知するために、通知アプリへの記録と**死活監視(dead-man's switch)**を組み込む。
この記事では、実際に動作確認したファイルから主要部分を抜粋して載せます。各ファイルの役割と接続関係が分かる形にしているので、組み合わせれば同じ構成を再現できます。
想定読者: kintone の定期処理をサーバーレスで自動化したい方 / GitHub Actions・YAML・npm・SQL・kintone API トークンの基本を知っている中級以上の方。単なる実行手順ではなく、失敗検知やリカバリーまで含めた設計に関心がある方に向いています。
読み方(長めなので目的別に):
- 全体像だけ: §1〜§4、§15
- GitHub Actions の安全設計: §3、§9〜§12
- kSQL の設定例: §5〜§8
- 実際に踏んだ問題だけ: §6.2、§8、§13
お断り: kSQL(
@rex0220/kintone-sql-tools)は筆者が開発している OSS です。宣伝目的ではなく、実運用の設計知見の共有が主旨です。また本プロジェクトの実装・検証は AI コーディング支援(Claude Code + Codex)を併用して行い、dev 環境と GitHub Actions 上で実動作を確認しています。本文の落とし穴の多くは、その過程で実際に踏んで潰したものです。
2. なぜ kSQL + GitHub Actions だったのか
集計を自動化する選択肢はいくつもあります。落とした理由込みで並べます。
| 選択肢 | 落とした理由 |
|---|---|
| kintone 標準機能(集計・グラフ) | 「別アプリへ書き込む」「複数アプリをまたいで結合する」ができない。閲覧の可視化止まり |
| Google Apps Script | 動くが、kintone REST API を叩く定型コードを毎回書くことになる。トリガーの管理も GAS 側に閉じる |
| 自前サーバー + cron | 確実だが、常駐サーバーの維持・監視・パッチという別の運用が増える。日次1回のために重い |
| kSQL + GitHub Actions | kintone を SQL 一行で操作でき、実行基盤は サーバーレス(使い捨てランナー)。リポジトリに SQL とワークフローを置くだけ |
決め手は 2 つです。kSQL が kintone を宣言的な SQL に落としてくれること。そして GitHub Actions が「持たない」基盤であること ― 常駐プロセスがないので、守るべきサーバーがそもそも存在しません。
次章で、その「持たない基盤」の性格をもう少し掘ります。ここが後段の設計すべての土台になります。
3. GitHub Actions の "性格" を理解する
GitHub Actions を「無料の cron」だと思って乗せると、静かに裏切られます。バッチ基盤として付き合ううえで、最初に腹落ちさせておきたい性格が 3 つあります。
3.1 スケジュールは「時刻どおりには動かない」
schedule の cron はベストエフォートです。GitHub 公式にも、指定時刻ちょうどには起動しないこと、高負荷時には数分〜数十分の遅延や、まれに実行のスキップが起こり得ることが明記されています。とくに毎時 0 分は世界中のジョブが集中するため遅延しやすい。
これは抽象的なリスクではなく、実際に踏みます。本プロジェクトでも、目標 12:15 UTC の schedule が 13:54 UTC に発火した ― +99 分の遅延を観測しました(新規スケジュール追加直後の欠火も併発)。「数分〜数十分」どころか 1 時間半ずれることもある、というのが実データです。
本プロジェクトの cron はこうしています。
on:
schedule:
- cron: "15 12 * * *" # JST 21:15(UTC 12:15)。schedule は常に prod で実行する
00 ではなく 15 にしているのは、この混雑を避けるための実務的なズラしです。この点は運用ルールとして次を明文化しています。
GitHub Actions の schedule は保証実行ではなく、数分〜数十分の遅延や(高負荷時の)スキップがあり得る。「その日のうちに1回実行できれば良い」性質のバッチのみを載せる。厳密な時刻要件がある場合は外部トリガー(例: 別システムからの
workflow_dispatchAPI 呼び出し)を検討する。
つまり 「21:15 きっかりに動く」ことに依存する設計は、そもそも成立しません。この事実は後段に効いてきます ―
- 死活監視のリマインダーを「約 26 時間」猶予にしているのは、日次間隔 + schedule 遅延を吸収するため(§11)。
- 厳密な時刻や高頻度が要るバッチは、そもそもこの基盤に載せない(対象外と明記)。
「時刻どおりに動く」前提を捨てると、設計はむしろシンプルになります。
もう一つの罠として、Public リポジトリはリポジトリに 60 日間活動がないと schedule が自動的に無効化されます。長期間コミットのない運用リポジトリでは、これ自体が「静かな停止」の原因になります ― だからこそ死活監視が要る、という伏線でもあります。
時刻の正確さが要るなら、スケジューラだけ外に出す: この +99 分遅延・欠火を受けて、定期起動の層だけを Google Apps Script(GAS)に外出しし、GitHub Actions は
workflow_dispatchで叩かれる実行基盤に徹するという続編を書きました。実行基盤はそのまま、スケジューラ層だけ差し替える構成です。→ kintone 日次バッチのスケジューラを GitHub Actions から GAS に外出しした
3.2 日次程度なら、コストはほぼゼロ
「無料の cron」という直感は、コストに関しては概ね正しい。日次1回・1回あたり数分の実行は、無料枠にかすりもしません。
| リポジトリ / プラン | 無料枠(分/月) | 月間使用見込み | 判定 |
|---|---|---|---|
| Public リポジトリ | 標準ランナー無制限 | — | 無料 |
| Private + Free | 2,000 分 | 約 60〜155 分(2〜5分 × 約31日) | 無料枠内(使用率 3〜8%) |
| Private + Pro / Team | 3,000 分 | 同上 | 無料枠内 |
| 超過時の従量 | — | Linux $0.008/分 | 仮に月 150 分全て超過でも約 $1.2 |
実測では 1 実行 12〜23 秒(後述)。5 分見積もりは npm ci 込みの安全側なので、実際の使用率はさらに低い。(無料枠・料金は執筆時点の値です。最新は GitHub の公式料金を確認してください。)
ここで効いてくるのが「§2 でサーバーを捨てた」判断です。Self-hosted runner を建てれば Actions 課金はゼロにできますが、サーバーの維持・保守という定額コストが発生する。日次数分の規模では、GitHub ホストランナーの無料枠に乗せて、運用コストを丸ごと消すのが最適解です。
日次実行 + ログ保全 + アラートの構成は、Private リポジトリの Free プランでも実質無料(無料枠の 10% 未満)で運用できる。
3.3 ランナーは使い捨て ― 状態は外に置く
3 つめが、設計思想として一番大きい。**ランナーは実行ごとに新品で立ち上がり、終われば消えます。**実行と実行の間で状態を持てません。前回の RunID も、前回の集計結果も、ランナー内には残らない。
これは制約に見えて、実は指針になります。状態は kintone 側に置く。
- 「最後に成功した実行」は、死活監視アプリのレコードとして kintone に書く(§11)。
- 「その日の集計結果」は、売上集計アプリに
UPSERTする。集計キー=実行日にすることで、再実行しても同じレコードを更新するだけ(冪等)。
ランナーが状態を持たないからこそ、冪等性と外部ストア(kintone)への記録が自然と設計に組み込まれます。使い捨て基盤は、ステートレスに書くことを強制してくれる、とも言えます。
その他、バッチ基盤として効く GitHub Actions の性質:
-
手動起動(
workflow_dispatch) が標準で使える ― 臨時集計・リカバリーに(§9)。 -
concurrencyでジョブの直列化ができる ― DML の二重実行を防ぐ(§9・§13)。 -
environmentで Secret のスコープを分離できる ― dev/prod のトークンを混ぜない(§10)。
4. 全体アーキテクチャ
作るものは、3 系統(業務 / 通知 / 死活監視)× 2 環境(dev = テスト・検証用 / prod = 本番運用用) です。
論理名と物理 ID の対応(同一ドメイン xxxxx.cybozu.com 上の別アプリ):
| 論理名 | 役割 | dev ID(テスト・検証用) | prod ID(本番運用用) |
|---|---|---|---|
LAPP_ORDERS |
受注 | 4216 | 4208 |
LAPP_SALES |
売上集計 | 4217 | 4209 |
LAPP_NOTIFY |
通知 | 4214 | 4206 |
LAPP_HEALTH |
死活監視 | 4215 | 4207 |
SQL は物理 ID を一切知りません。「受注」は常に LAPP_ORDERS と書き、それが dev で 4216・prod で 4208 のどちらに解決されるかは profile が決める。この分離が、次章以降の中心テーマです。
補足(別ドメインも可): 今回は dev/prod を同一ドメイン上の別アプリにしていますが、profile はそれぞれ独自の
baseUrlを持てます(§7 の config 参照)。したがって dev をdev.cybozu.com、prod をprod.cybozu.comのようにまったく別のドメイン(サブドメイン/kintone 環境)に置くことも可能です。SQL はLAPP_ORDERSのままで、接続先ドメインとアプリ ID の両方を profile 側に閉じ込められます。検証環境と本番環境を物理的に分離したい場合はこちらが向きます。
お断り(業務構成について): この「受注 → 売上集計」という業務例は、記事の解説用に単純化したもので、そのままでは実用的ではありません。実際には「その日に完了した受注の日次差分」を集計したいことが多く、その場合は受注アプリに
完了日フィールドを設けてWHERE 完了日 IN (CURRENT_DATE())とするなど、業務要件に応じた設計が要ります(本例は毎回「全完了受注の累積スナップショット」を実行日キーで記録する形)。ここで見てほしいのは**業務ロジックそのものではなく、論理参照・profile 分離・多層ゲート・死活監視という"骨格"**です。骨格は業務が変わってもそのまま流用できます。
5. 事前準備 ― kintone アプリと最小権限トークン
kintone 側に 4 アプリを作り、それぞれに API トークンを発行します。ポイントは トークン権限を「そのアプリに対して SQL が実際に行う操作」ぴったりに絞ること。
| SQL 操作 | 必要なトークン権限 |
|---|---|
SELECT / ASSERT(参照のみ) |
閲覧 |
INSERT |
追加 |
UPDATE |
閲覧 + 編集 |
UPSERT |
閲覧 + 追加 + 編集 |
DELETE |
閲覧 + 削除 |
たとえば通知アプリは INSERT しかしないので 追加のみ。死活監視は UPSERT なので 追加 + 編集。「本番と同等」のような曖昧な付与をせず、操作単位の最小権限にします。これは後述する profile 分割(§8)と対になる考え方です ― トークンを絞るから、それを載せる profile も分ける必要が出てくる。
受注アプリのフィールド設計(抜粋)はこうなっています。健全性チェックと集計が依存するフィールドです。
-
受注番号(文字列・重複禁止)、顧客名、受注日(日付)、金額(数値) -
状態(ドロップダウン: 新規 / 処理中 / 完了) -
異常フラグ(ラジオ: 0 / 1)
ドロップダウン・ラジオ型は、後で「WHERE で = が使えない」という落とし穴になります(§13)。
実際のアプリ画面。業務系の 2 アプリ ― 受注管理(LAPP_ORDERS)と売上集計(LAPP_SALES)です。バッチはこの受注データを読み、集計結果を売上集計アプリへ書き込みます。
6. kSQL の基本と、最初の落とし穴
6.1 SQL で kintone を操作する
kSQL は kintone アプリを SQL ライクな構文で操作する CLI です。日次バッチが使うのは主に 3 つ。
SELECT ― 疎通確認用の read-only SQL:
SELECT 受注番号 FROM LAPP_ORDERS LIMIT 1;
SELECT 集計キー FROM LAPP_SALES LIMIT 1;
ASSERT ― 前提条件のゲート。不成立なら exit 1 でバッチが止まり、後続は実行されません(fail-fast):
-- 受注データが存在すること(取得ゼロ = 連携断の検知)
ASSERT (SELECT COUNT(*) FROM LAPP_ORDERS) >= 1;
-- 完了済み受注に異常フラグが残っていないこと
ASSERT (SELECT COUNT(*) FROM LAPP_ORDERS WHERE 状態 IN ('完了') AND 異常フラグ IN ('1')) = 0;
UPSERT ― 集計結果の書き込み。キーで既存を探し、あれば更新・なければ追加:
UPSERT INTO LAPP_SALES (集計キー, 集計日, 件数, 合計金額)
SELECT CURRENT_DATE(), CURRENT_DATE(), COUNT(*), COALESCE(SUM(金額), 0)
FROM LAPP_ORDERS
WHERE 状態 IN ('完了')
ON DUPLICATE (集計キー);
集計キーを CURRENT_DATE()(実行日)にしているのがミソです。同日に何度実行しても同じレコードを更新するだけなので、リカバリーや再実行で二重集計になりません。§3.3 で触れた「状態を kintone に、冪等に置く」の実装がこれです。
6.2 最初の落とし穴: npx を使ってはいけない
ここで、このプロジェクト最大の教訓を先に共有します。npx ksql は使わない。
依存パッケージ名は @rex0220/kintone-sql-tools、実行ファイル名は ksql。ところが 公開レジストリには無関係な別の ksql(Confluent 系)が実在し、npx ksql はローカルの bin ではなく そちらを取得して無出力・exit 0 で終了してしまう。CI 上では、これが「成功と誤報告される失敗(偽陽性)」を生みました。connectivity が 0.4 秒で success、stderr は 0 バイト ― 実 API を叩いていれば数秒かかるはずが、そもそも何も実行していなかった。
対策は node で実ファイルを絶対パス直起動し、バージョンを実測照合すること。
env:
# kSQL CLI を実ファイル直指定で起動する(公開レジストリに無関係な ksql@x があり、
# bare/no-install の npx がそれを拾って無出力・code=0 で握り潰す事故を根絶するため)
KSQL_BIN: ${{ github.workspace }}/node_modules/@rex0220/kintone-sql-tools/dist-cli/ksql.js
KSQL_EXPECTED_VERSION: "1.13.2"
- name: Install kSQL CLI
run: |
set -euo pipefail
npm ci
# kSQL 実ファイルの存在と版を厳密検証(空出力・版不一致も fail — 誤パッケージ実行を防ぐ)
test -f "$KSQL_BIN" || { echo "::error::$KSQL_BIN が見つかりません"; exit 1; }
actual_version="$(node "$KSQL_BIN" --version)"
if [ "$actual_version" != "$KSQL_EXPECTED_VERSION" ]; then
echo "::error::kSQL version mismatch: expected=$KSQL_EXPECTED_VERSION actual=${actual_version:-<empty>}"
exit 1
fi
教訓は普遍的です。CLI 名と npm パッケージ名が違うとき、npx <cli名> は同名の別パッケージを引く危険がある。 CI では bin を直接起動し、「動いた」ではなく「正しいものが動いた」をバージョン実測で確認する。
6.3 なぜバージョンを固定するのか
KSQL_EXPECTED_VERSION: "1.13.2" と package.json の完全一致指定(^ や ~ を付けない)は、単なる作法ではなく明確な理由があります。
"devDependencies": {
"@rex0220/kintone-sql-tools": "1.13.2"
}
理由は 3 つ。
-
SQL の意味論と終了コードがバージョンで変わるから。 この記事だけでも、版差の実例がいくつも出てきます ― 0 件集計の返り方(v1.11 は「0 行」、v1.12 から「0 の 1 行」を返す。§13)、SQL コメント内
APPxxxxのトークン解決(v1.12.1 で除外に修正)、そして論理参照LAPP_自体が v1.13.0 で入った機能。バッチの正しさが特定版の挙動に依存している以上、版が動くと「昨日まで通っていた ASSERT が急に落ちる」が起こり得ます。 -
CI とローカルで同じ版を保証するため。
package.jsonを完全一致にし、npm ci(package-lock.json固定)でインストールすることで、「手元では動いたが CI で挙動が違う」を版の面から潰します。§12 で後述する「ローカル成功 ≠ CI 成功」を、依存の版という一番ずれやすい軸で固定する。 -
意図しない自動アップグレードと、誤パッケージの二重検出。
KSQL_EXPECTED_VERSIONとの実測照合(§6.2)は、「正しいパッケージか」と「想定した版か」を同時に検証します。範囲指定にしておくと、npm ciが勝手に新しい版を引いても気づけない。完全一致 + 実測照合なら、上げるときはKSQL_EXPECTED_VERSIONとpackage.jsonを一緒に上げるという明示的な操作を強制でき、更新のたびに「新版で全 SQL が通るか」を検証する運びになります。
要するに、バージョン固定は「再現性」と「バッチの正しさ」を守るためのロックです。kSQL を上げること自体は歓迎ですが、それは気づかないうちにではなく意図して行い、その版で検証してから固定し直す ― という運用にするのが肝です。
7. 論理参照 LAPP_ ― 同じ SQL を dev/prod で回す
kintone のアプリ ID は物理的な採番です。dev の受注アプリは 4216、prod は 4208。素朴に SQL を書くと、こうなります。
-- dev 用
SELECT * FROM APP4216;
-- prod 用
SELECT * FROM APP4208;
環境ごとに SQL を分岐させる、あるいは実行前に sed で ID を置換する ― どちらも破綻の入り口です。置換ミスは本番事故に直結します。
kSQL v1.13.0 で入った 論理アプリ参照 LAPP_<NAME> が、これを構造的に解決します。SQL は論理名で書き、物理 ID への解決は profile の logicalApps に任せる。
"dev": {
"baseUrl": "https://xxxxx.cybozu.com",
"auth": "token",
"logicalApps": {
"ORDERS": 4216,
"SALES": 4217
},
"tokenMap": {
"APP4216": "env:KSQL_TOKEN_DEV_APP4216",
"APP4217": "env:KSQL_TOKEN_DEV_APP4217"
}
},
"prod": {
"baseUrl": "https://xxxxx.cybozu.com",
"auth": "token",
"allowPhysicalAppRefs": false,
"logicalApps": {
"ORDERS": 4208,
"SALES": 4209
},
"tokenMap": {
"APP4208": "env:KSQL_TOKEN_PROD_APP4208",
"APP4209": "env:KSQL_TOKEN_PROD_APP4209"
}
}
SQL は LAPP_ORDERS としか書かない。--profile dev なら 4216、--profile prod なら 4208 に解決される。環境差は config に閉じ込められ、SQL は環境非依存になります。
もう一つ、prod だけに付けている "allowPhysicalAppRefs": false に注目してください。これは 本番 SQL に生の APPxxx を書いたら、API を叩く前に拒否する fail-closed ガードです。テスト用の物理 ID がうっかり本番 SQL に混入するのを、実行時ではなく構文レベルで止めます。
解決結果は実行前に EXPLAIN で確認できます。
node $KSQL -e "EXPLAIN SELECT * FROM LAPP_ORDERS" --config config/ksql.config.json --profile prod
8. profile を 6 本に割る ― tokenMap eager 解決という制約
ここが設計の一番の勘所です。profile は業務の dev/prod だけでなく、用途 × 環境で 6 本あります。
dev / prod … 業務(受注・売上集計)
notify-dev / notify-prod … 通知
health-dev / health-prod … 死活監視
なぜこんなに分けるのか。「dev profile に全アプリ(業務・通知・死活監視)を載せておけば 1 本で済むのでは?」と最初は考えます。それが動かない理由が、kSQL の重要な仕様です。
kSQL は profile ロード時に、tokenMap の全エントリを eager(先読み)で解決します。 SQL が実際に参照するアプリだけの遅延解決ではありません。ソース上はこうなっています ―
kSQL は profile ロード時に tokenMap の全
env:値を解決する(Object.entries(tokenMap).map(resolveTokenValue))。
これが何を意味するか。もし業務・通知・死活監視を 1 つの profile に混ぜると、業務ステップを実行するだけでも、通知・死活監視のトークンが環境変数に無いと AuthError で落ちる。このプロジェクトでも実際にこれを踏みました ―
通知/死活監視を論理参照化する際に業務用の
dev/prodprofile へ統合しようとしたところ、業務ステップが通知/死活監視トークン未設定でAuthErrorになった。
対策が「用途 × 環境の 6 分割」です。各ステップは自分の profile が要求するトークンだけを受け取ればよく、§5 で絞った最小権限トークンと自然に噛み合います。業務ステップは業務トークンだけを持ち、通知ステップは通知トークンだけを持つ。トークンの権限境界に、profile の境界を合わせる ― これが結論です。
"notify-dev": {
"baseUrl": "https://xxxxx.cybozu.com",
"auth": "token",
"logicalApps": { "NOTIFY": 4214 },
"tokenMap": { "APP4214": "env:KSQL_TOKEN_DEV_NOTIFY_APP4214" }
},
"health-dev": {
"baseUrl": "https://xxxxx.cybozu.com",
"auth": "token",
"logicalApps": { "HEALTH": 4215 },
"tokenMap": { "APP4215": "env:KSQL_TOKEN_DEV_HEALTH_APP4215" }
}
9. ワークフロー本体 ― 多層の安全ゲート
daily-batch.yml の設計を、上から追います。テーマは 「schedule は自動で prod、手動は慎重に」 です。
まず全体の流れを1枚で。起動イベント・モード・2 つの DML ゲート・fail-fast・成功/失敗時の分岐(§11 で詳述)を通しで示します。
9.1 environment と profile を同じ式で連動させる
まず、environment(どの Secrets を引くか)と KSQL_PROFILE(論理名の解決先)を、まったく同じ式で決めます。
environment: ${{ github.event_name == 'schedule' && 'prod' || inputs.environment }}
env:
KSQL_PROFILE: ${{ github.event_name == 'schedule' && 'prod' || inputs.environment }}
読み下すと「schedule なら prod、手動なら入力値(既定 dev)」。この 2 つが同じ式なので、「Secrets は prod なのに profile は dev」という食い違いが構造的に起きません。
9.2 起動モードと、二段の DML ゲート
手動起動には 3 つのモードがあります。
mode:
description: "起動モード: test=設定確認(dry-run + 疎通、既定) / run=随時実行 / recovery=失敗バッチから再開"
options:
- test
- run
- recovery
default: test
既定が test(dry-run)なのが安全設計の一段目。うっかり手動実行しても、実 DML は走らない。
二段目が confirm_dml。手動で実書き込みするには、mode: run に加えて confirm_dml: true を明示的に付ける必要があります。
# 手動起動(schedule 以外)で実 DML を走らせる run/recovery は confirm_dml=true を必須にする(誤操作防止)。
# schedule は自動 DML 経路のため対象外。dry_run では書き込みが無いため不要。
if [ "$EVENT_NAME" != "schedule" ] && [ -z "$DRY_RUN_FLAG" ] \
&& { [ "$MODE" = "run" ] || [ "$MODE" = "recovery" ]; } \
&& [ "${INPUT_CONFIRM_DML:-false}" != "true" ]; then
echo "::error::mode=$MODE の実 DML 実行には confirm_dml=true が必要です(誤操作防止)。計画のみ見るなら dry_run=true"
fail "DML_CONFIRM_REQUIRED" 2
fi
「モードを run にする」と「confirm_dml にチェックを入れる」の AND を満たさないと書き込まない。手動操作の事故を二重で防ぎます。一方、schedule は自動 DML 経路なので confirm 不要 ― 無人運転が承認待ちで止まっては本末転倒だからです。
9.3 番号順 SQL の fail-fast 実行
バッチ本体は sql/daily/*.sql をファイル名昇順で回すだけ。SQL を追加してもワークフローの変更は不要です。
for f in "${files[@]}"; do
name=$(basename "$f" .sql)
...
set +e
node "$KSQL_BIN" -f "$f" \
--config config/ksql.config.json --profile "$KSQL_PROFILE" \
--allow-dml --yes --dml-max-rows 1000 \
--no-color $DRY_RUN_FLAG \
--format json --pretty --output "logs/$name.json" \
2>"logs/$name.stderr.log"
code=$?
set -e
...
if [ "$code" -ne 0 ]; then
...
exit "$code" # 1つでも失敗したらそこで停止(fail-fast)
fi
done
010_health_check.sql が先頭で ASSERT ゲートを張り、不成立なら後続の 020_aggregate.sql は実行されない ― 前提が崩れた状態で集計を走らせないという順序設計です。
なお set +e; …; code=$?; set -e と、stderr をファイルへ確定リダイレクトしてから終了コードを保存しているのも教訓由来です。2> >(tee ...) のようなプロセス置換は CI で stderr を取りこぼしたので、単純コマンド + 直後の $? 保存という最も確実な形に倒しています。
自由入力(target など)を run: に直接展開せず、必ず env: 経由で受けているのも重要です。直接展開はシェルインジェクションになります。
# workflow_dispatch の自由入力は env 経由で受ける(run: へ直接展開するとシェルインジェクションになる)
INPUT_MODE: ${{ inputs.mode }}
INPUT_TARGET: ${{ inputs.target }}
9.4 ロールバックが無い前提でリカバリーを設計する
fail-fast には、見落としがちな裏面があります。kintone REST API にはトランザクションもロールバックもありません。 バッチが 3 文目で失敗して停止しても、1〜2 文目で書き込んだレコードはそのまま残ります。複数レコードを書く 1 文の途中で失敗した場合も同様で、kintone は 100 件ごとに API を分割するため、**前半だけ書き込まれた「部分実行」**が起こり得ます。「失敗したら全部なかったことになる」という DB の直感は、ここでは通用しません。
だから リカバリーはバッチ処理側で設計する必要があります。このプロジェクトで取っている対策は 4 つ。
-
冪等に書く(最重要) ― §6.1 の
UPSERT ... ON DUPLICATE (集計キー)、集計キー=実行日。同じ日に何度実行しても同じレコードを更新するだけなので、途中失敗後にまるごと再実行しても二重集計にならない。ロールバックが無い世界では、「取り消す」より「何度やっても同じ結果になる」形に倒すのが基本戦略です。 -
前提を先にゲートする ―
010_health_check.sqlの ASSERT で「受注が 0 件でない」等を確認してから集計に進む。前提が崩れた状態で書き込みを始めさせない(=部分実行の芽を摘む)。 -
リカバリー起動を用意する ― 失敗したバッチから再開できる
mode: recovery+start_from(どちらも手動起動workflow_dispatchの入力)を持つ。ファイル名昇順で「このバッチから後だけ」を再実行できる。 -
書き込み量に上限ガード ―
--dml-max-rowsで、想定外の大量書き込みを実行前に止める。部分実行の被害範囲そのものを小さく保つ。
そして運用ルール(ランブック)として、非冪等な DML が部分実行された可能性があるときは、いきなり再実行せず、先にデータの状態を確認することを定めています。具体的には ―
失敗バッチの DML が非冪等で部分実行された可能性がある場合は、先にデータ状態を確認し、必要なら修正 SQL を
mode: run+targetで個別実行してから再開する。
この記事で一番持ち帰ってほしい設計原則:
アトミックでない基盤(=ロールバックが無い kintone)の上では、「冪等性」が実質的なトランザクションの代わりになる。 「取り消す」ではなく「何度やっても同じ結果になる」形に倒すこと。
§3.3 で「状態を kintone に冪等に置く」と書いたのは、ランナーが使い捨てだからという理由に加えて、kintone 側にロールバックが無いからでもあるのです。
10. Secrets と Environment ― 名前で環境を語らせる
トークンの命名規約はこれ。
KSQL_TOKEN_<ENV>_[<ROLE>_]APP<実ID>
例) KSQL_TOKEN_DEV_APP4216 / KSQL_TOKEN_PROD_NOTIFY_APP4206
ENV(DEV/PROD)を先頭、アプリ ID を末尾に埋め込む。名前だけで「どの環境の・どのアプリの」トークンか分かり、dev/prod は自動的に別名になります。同名にすると 1 プロセスで両方を保持できず、環境間の取り違えも起きやすい。
GitHub 側は Environment(dev / prod / validation-notify)を作り、それぞれに対応する Secret だけを登録します。validation-notify は §12 で触れる失敗検証ワークフロー(validate.yml)が dev の通知アプリ 4214 だけを使うための専用 Environment で、本番系(prod)と Secret スコープを分けるために独立させています。gh CLI ならこう。
# Environment を作成
gh api --method PUT repos/<owner>/<repo>/environments/prod
# その Environment に Secret を登録
gh secret set KSQL_TOKEN_PROD_APP4208 --env prod --repo <owner>/<repo> --body "<token>"
ここで踏む落とし穴が 2 つ。
- Environment 承認ゲートと無人 schedule は両立しない。 日次ジョブの Environment に required reviewers を付けると、schedule 実行も毎回承認待ちになる。加えて private リポジトリの required reviewers は実質 Enterprise 前提。→ 日次ジョブの Environment には承認を付けず、承認が要る操作は別ワークフロー + 別 Environment に分離する。
-
Environment Secret は他 Environment から見えない。
prodに登録した Secret はvalidation-notifyのジョブからは参照できない。使う Environment ごとに登録する。
ワークフロー側では、業務トークンをジョブ全体ではなく、それを使うステップの env: にだけ渡します。dev/prod 両方を書いておき、選ばれた Environment に無い側は空文字に解決される ― §8 の profile 分離のおかげで、空側は読まれず無害です。
- name: Run daily batches
id: batch
env:
# dev/prod 両方を渡すが、選ばれた Environment に無い側の Secret は空文字に解決される。
# 実行される profile は自分の DEV_* / PROD_* だけを読むため、空側は無害。
KSQL_TOKEN_DEV_APP4216: ${{ secrets.KSQL_TOKEN_DEV_APP4216 }}
KSQL_TOKEN_DEV_APP4217: ${{ secrets.KSQL_TOKEN_DEV_APP4217 }}
KSQL_TOKEN_PROD_APP4208: ${{ secrets.KSQL_TOKEN_PROD_APP4208 }}
KSQL_TOKEN_PROD_APP4209: ${{ secrets.KSQL_TOKEN_PROD_APP4209 }}
11. 止まったことに気づく仕組み ― 通知と dead-man's switch
バッチ自動化の本当の難所は、「失敗の通知」ではなく 「動かなくなったことの検知」 です。失敗すればログに残る。でも、そもそも起動しなかったら? schedule がスキップされたら? ログにすら残りません(§3.1 の 60 日自動無効化を思い出してください)。
そこで 2 系統を組みます。
11.1 失敗時 ― 通知アプリへ INSERT
バッチが実 DML 実行中に失敗したら、kintone の通知アプリへレコードを INSERT します。exit code 別の一次切り分けヒントも一緒に書き込む。
- name: Notify kintone on batch failure
if: failure() && steps.batch.outputs.real_run == 'true' && steps.batch.conclusion == 'failure'
continue-on-error: true
env:
KSQL_TOKEN_PROD_NOTIFY_APP4206: ${{ secrets.KSQL_TOKEN_PROD_NOTIFY_APP4206 }}
KSQL_TOKEN_DEV_NOTIFY_APP4214: ${{ secrets.KSQL_TOKEN_DEV_NOTIFY_APP4214 }}
...
run: |
...
# フィールドコード ASSERT は kSQL の予約語のためバッククォート必須(bash の二重引用符内では \` にエスケープ)
node "$KSQL_BIN" -e "INSERT INTO LAPP_NOTIFY (ワークフロー, 失敗種別, バッチ名, exit_code, 切り分けヒント, \`ASSERT\`, RunURL, 所要時間秒) VALUES (...)" \
--config config/ksql.config.json --profile "notify-$KSQL_PROFILE" \
--allow-dml --yes --dml-max-rows 1 --no-color
profile "notify-$KSQL_PROFILE" で、dev 実行なら 4214・prod 実行なら 4206 に自動で振り分けられます。§8 の profile 分割がそのまま効いています。kintone アプリ側の条件通知で、担当者へメールやアプリ内通知が飛びます。
通知アプリ(バッチ実行アラート / LAPP_NOTIFY)の実レコード。exit code・切り分けヒント・Run URL などが記録され、kintone の条件通知で担当者へ届きます。下の例は、手動 run で confirm_dml を付け忘れて安全ゲートが働いた(exit 2)ときに INSERT されたものです。
11.2 成功時 ― 死活監視へ UPSERT(dead-man's switch)
成功したら、死活監視アプリのレコードを UPSERT して更新日時を前進させます。
- name: Record success to kintone (dead-man's switch)
if: success() && steps.batch.outputs.real_run == 'true'
continue-on-error: true
env:
KSQL_TOKEN_PROD_HEALTH_APP4207: ${{ secrets.KSQL_TOKEN_PROD_HEALTH_APP4207 }}
KSQL_TOKEN_DEV_HEALTH_APP4215: ${{ secrets.KSQL_TOKEN_DEV_HEALTH_APP4215 }}
run: |
node "$KSQL_BIN" -e "UPSERT INTO LAPP_HEALTH (ワークフローキー, 最終RunID) VALUES ('daily-batch', '${{ github.run_id }}') ON DUPLICATE (ワークフローキー)" \
--config config/ksql.config.json --profile "health-$KSQL_PROFILE" \
--allow-dml --yes --dml-max-rows 1 --no-color
仕掛けはこうです。kintone 側で、この死活監視アプリに 「更新日時から約 26 時間後」のリマインダー通知を設定しておく。バッチが毎日成功する限り更新日時が前進し、リマインダーの発火予定も先送りされ続ける。バッチが止まる(schedule 無効化・GitHub 障害・連続失敗)と更新が止まり、約 26 時間後に通知が発火する ― これが dead-man's switch です。
26 時間という猶予は、§3.1 の「schedule は数分〜数十分遅れる」を吸収するためのマージン。「時刻どおりに動かない」という GitHub Actions の性格が、ここで設計値として回収されます。
real_run == 'true' の条件にも意味があります。dry-run / test 実行では発火しない ― dev で計画確認しただけで死活監視が更新されたら、「動いている」の意味が薄れるからです。
死活監視アプリ(バッチ死活監視 / LAPP_HEALTH)の実レコード。最終RunID が毎回書き換わり、更新日時 が前進します。この更新日時がリマインダーの基準で、更新が止まると約 26 時間後に発火します。
11.3 最後の砦 ― GitHub Issue バックストップ
kintone 通知自体が失敗したら? そのときのために、kintone に依存しない通知経路として GitHub Issue の自動起票を持ちます。ただし prod の schedule 実行に限定。手動起動(試行錯誤)のたびに Issue が増えるノイズを避けるためです。
- name: Alert on batch failure (GitHub Issue)
if: failure() && github.event_name == 'schedule' && steps.batch.conclusion == 'failure'
さらに steps.batch.conclusion で「SQL バッチの失敗」と「checkout / npm ci などインフラ起因の失敗」を別ステップに分けています。後者は ksql 自体が動かないので kintone 通知はせず、Issue だけ起こす。失敗の種別で通知経路を変える、という細かさです。
12. 壊し方で確かめる ― PR 検証と失敗経路テスト
このプロジェクトの一番の教訓は、「成功が success になること」だけでなく「失敗が failure として顕在化すること」を証明しないと、偽陽性(§6.2)に気づけない、というものでした。そこで検証ワークフローを 2 本持ちます。
12.1 pr-check.yml ― Secret 不要の dry-run
PR ごとに、実トークンを使わず(ダミートークン + dry-run)SQL / config / ワークフローを静的検証します。Secret を使わないので fork PR でも安全に動く。
- name: SQL dry-run(ダミートークン・API 呼び出しなし・dev/prod 両 profile)
env:
KSQL_TOKEN_DEV_APP4216: dummy-token-for-dry-run
KSQL_TOKEN_PROD_APP4208: dummy-token-for-dry-run
...
run: |
# dev / prod 両 profile で検証(prod は allowPhysicalAppRefs:false — LAPP_ 参照必須)
for profile in dev prod; do
for f in "${daily_files[@]}"; do
node "$KSQL_BIN" -f "$f" \
--config config/ksql.config.json --profile "$profile" \
--allow-dml --yes --dml-max-rows 1000 --dry-run --no-color
done
done
dry-run は API を叩かず、tokenMap の解決に env が非空であることだけを要求します。だからダミートークンで論理参照の解決・prod の物理参照拒否まで検証できる。加えて actionlint でワークフロー YAML を、node --check でスクリプトを静的検査します。
12.2 validate.yml ― 意図的に失敗させる
もう 1 本は、わざと失敗させて exit code を厳密照合するワークフロー。
- 常時 false の read-only ASSERT → exit 1(stderr に
AssertError) -
--allow-dmlなしの DML → exit 2 - 無効トークン → exit 1(kintone 400)
- 存在しない
target→ exit 2 でジョブ failure
exit code を -ne 0 の緩い判定ではなく期待値で厳密判定するのがポイント。別要因の失敗を「成功扱い」にしないためです。
13. 教訓集 ― 素朴にやると踏む地雷
kSQL / kintone / GitHub Actions それぞれに、直感を裏切る細部があります。実測で確定したものを並べます。
| 事項 | 対策 |
|---|---|
| 1 バッチ最大 20 文 | kSQL のバッチは ; 区切りで最大 20 文。大量レコードは**多タプル VALUES(1 文で複数行)**や INSERT ... SELECT で入れる。文数とレコード数は別レイヤー(1 文の書き込みは 100 件ごとに API 自動分割) |
フィールドコード ASSERT は予約語 |
SQL では `ASSERT`、bash 二重引用符内では \`ASSERT\`
|
ラジオ / ドロップダウン型の WHERE は = 不可(kintone GAIA_IQ03) |
IN / NOT IN を使う。例: WHERE 状態 IN ('完了')
|
| UPSERT はキー句が必須 |
ON DUPLICATE (キーフィールド)(キーは重複禁止フィールド) |
| 0 件集計の返り方がバージョン依存 | 合計は COALESCE(SUM(x), 0) でバージョン非依存に。0 件ケースを必ずテスト |
| 終了コードが直感と違う | exit 1 = SQL・データ・認証(無効トークン=kintone 400)・接続断(範囲が広い)/ exit 2 = 引数・DML ガード / exit 3 = tokenMap 定義漏れのみ。切り分けは exit code より stderr のエラー種別で |
| schedule に required reviewers を付けない | 付けると schedule も承認待ちで止まる。誤操作防止は confirm_dml ゲートで(§9.2) |
concurrency 既定では pending は 1 件 |
queue: max で FIFO キューイング。DML の二重実行を防ぐ |
とくに 1 行目は、実際にテストデータ 100 件を投入する作業で踏んだ罠です。「100 件だから 100 文の INSERT」と考えて 20 文上限に弾かれ、慌てて 20 文 × 5 バッチに分割 ― が、正解は 100 タプルの単一 INSERT(1 文)でした。文数制限とレコード数制限を混同した、教科書どおりの罠です。
14. 実際に動かした記録
理屈だけでは信用できないので、dev 環境で 4 パターン実行し、挙動を実測しました(GitHub Actions の手動起動)。
GitHub Actions の daily-batch 実行履歴。手動起動(workflow_dispatch)での test / run が、成功・失敗込みで並んでいます。この履歴と、下の表・ログを突き合わせて読むと分かりやすいはずです。
| # | mode / confirm_dml | 結果 | 実証できたこと |
|---|---|---|---|
| 1 | test | ✅ success | dry-run + 疎通のみ。実 API read-only で SELECT success、通知/死活監視は発火せず
|
| 2 | run / false | ❌ failure (exit 2) | DML 安全ゲートが実書き込み前に停止。かつ dev 通知アプリ 4214 へ失敗レコードが INSERT された |
| 3 | test / true | ✅ success |
confirm_dml だけでは実行されない(mode: run との AND が必要)を確認 |
| 4 | run / true | ✅ success |
実 DML 完走。010 の ASSERT 実成立、020 が売上集計へ UPSERT、死活監視 4215 が更新 |
4 回目のログ抜粋:
INPUT_MODE: run / INPUT_CONFIRM_DML: true
[1] ASSERT success
[2] ASSERT success
UPSERT 1 0 ← 死活監視 4215 へ記録(insertedCount=1)
そして kintone 側の実データ:
-
死活監視 4215:
最終RunID = 29190584274 / 更新日時 = 2026-07-12T11:18:00Z← dead-man's switch の基準が前進 -
売上集計 4217:
集計キー=2026-07-12 / 件数=70 / 合計金額=11,295,445← 完了 70 件の SUM と一致。同キー再実行でも冪等に UPDATE
2 回目の「失敗」が、実は一番の収穫でした。安全ゲートが正しく働いて実書き込みを止め、同時に通知経路がエンドツーエンドで動いたことを一度に証明できた。§12 の「壊し方で確かめる」を、本番同型の経路でやった格好です。
15. まとめ ― サーバーを持たない安心感
作ったものを一言でいえば、「サーバーを一台も持たずに、壊れたら気づける日次バッチ」 です。設計の背骨は、GitHub Actions の性格に逆らわなかったことに尽きます。
- 時刻どおりに動かない → 「その日のうちに 1 回」で十分な設計にし、死活監視に 26 時間の猶予を持たせた。
- 状態を持たない → 状態は kintone に、冪等に書いた(集計キー = 実行日、死活監視レコード)。
- でもほぼ無料 → 常駐サーバーを捨て、運用コストごと消した。
そのうえで、kSQL の論理参照で 1 本の SQL を dev/prod に流し、tokenMap eager 解決という制約から profile を用途 × 環境で分け、手動操作には 二段の DML ゲートを張り、失敗と沈黙の両方を 通知 + dead-man's switch + Issue バックストップで拾う。
応用は素直に効きます。バッチを増やしたいなら sql/daily/ に番号付き SQL を足すだけ(ワークフロー変更不要)。通知先を Slack にしたいなら、通知ステップを 1 つ足す。他の kintone 運用にも、この骨格はそのまま移せます。本記事に載せた config・ワークフロー・SQL は実際に動作確認したファイルの主要部分なので、役割と接続関係をたどれば同じ構成を手元で組み立てられます。
手作業の集計は、消せます。そして「消えたことに気づける」ようにするまでが、自動化です。



