「昨日まで動いていた自動返信が、エラーも出ないまま止まっていた」。個人開発で複数の Google Workspace 自動化を運用していると、この無音の停止に何度か出くわします。私自身、壁紙アプリのレビュー返信の下書き生成と、問い合わせメールの要約と、売上シートの集計を、それぞれ別の時間主導型トリガーで回していた時期に、ある日まとめて止まりました。実行ログを開いても、失敗の記録すら残っていません。
原因はコードのバグではなく、Apps Script のトリガー資源を使い切っていたことでした。トリガーは無限に増やせる前提で設計しがちですが、実際にはかなり手前に天井があります。ここでは、その天井の正体を数字で押さえたうえで、ジョブごとにトリガーを乱立させる構成を、5分間隔の単一ディスパッチャに束ね直す実装を示します。
トリガーは「数」と「時間」の二重の上限で枯れる
多くの人が見落とすのは、トリガーの制約が一つではないことです。枯渇は別々の二つの資源で起こります。
ひとつは トリガーの本数 です。Apps Script では1ユーザーあたり1スクリプトに作成できるトリガーが20本までと決まっています。自動化を「1機能 = 1トリガー」で増やしていくと、20本目で This script has too many triggers が出て、それ以降の ScriptApp.newTrigger が静かに失敗します。
もうひとつは 1日あたりのトリガー合計実行時間 です。無料の gmail.com アカウントではトリガーの累積実行時間がおおむね1日90分、Google Workspace の有償アカウントでも6時間で頭打ちになります。Gemini API への呼び出しはネットワーク待ちで1回あたり数秒かかるため、ジョブが増えるとこの時間枠は思ったより速く溶けます。枠を使い切ると、その日の残りのトリガーは 通知もなく発火しなくなります 。
次の表は、運用設計で意識すべき主要な上限です。アカウント種別で数字が変わる点に注意してください。
資源 無料(gmail.com) Workspace(有償) 枯渇時の症状
トリガー本数 / ユーザー / スクリプト 20 20 newTrigger が失敗
トリガー合計実行時間 / 日 約90分 約6時間 発火が無音で停止
1実行あたりの最大時間 6分 30分 途中で強制終了
UrlFetch 呼び出し / 日 20,000 100,000 fetch が例外
ここで効いてくるのは、1機能ごとにトリガーを立てる設計が、両方の上限を同時に圧迫する という点です。本数を消費し、かつ各トリガーの起動オーバーヘッドと空振りチェックが実行時間枠を削ります。私が止まったときも、本数こそ20本未満でしたが、5分間隔のトリガーを3本回していたために、合計実行時間の枠を昼前に使い切っていました。
1機能=1トリガーをやめ、単一ディスパッチャに束ねる
解決の骨格はシンプルです。トリガーは ただ一本 だけ作り、それを5分おきに発火させます。その一本が「いまどのジョブを動かすべきか」をスケジュール表から判断し、期限の来たジョブだけを順に実行します。トリガーの本数は常に1、実行時間枠も1本ぶんに収まります。
まずジョブの定義です。各ジョブは「実行間隔(分)」と「実体の関数」を持つだけにします。
// ジョブ定義。intervalMin ごとに fn を1回走らせたい、という宣言だけを持つ
const JOBS = [
{ id: 'reviewReplyDraft' , intervalMin: 15 , fn: runReviewReplyDraft },
{ id: 'inquirySummary' , intervalMin: 30 , fn: runInquirySummary },
{ id: 'salesRollup' , intervalMin: 60 , fn: runSalesRollup },
];
// 5分間隔のトリガーから呼ばれる唯一の入口
function dispatch () {
const lock = LockService. getScriptLock ();
// 前回の実行が長引いて重なるのを防ぐ。取れなければ今回は黙って降りる
if ( ! lock. tryLock ( 1000 )) return ;
try {
const props = PropertiesService. getScriptProperties ();
const now = Date. now ();
const runStart = now;
const RUN_BUDGET_MS = 4 * 60 * 1000 ; // 1実行6分の手前で必ず抜ける安全弁
for ( const job of JOBS ) {
if (Date. now () - runStart > RUN_BUDGET_MS ) break ; // 時間枠を守る
const lastKey = 'last_' + job.id;
const last = Number (props. getProperty (lastKey) || 0 );
if (now - last < job.intervalMin * 60 * 1000 ) continue ; // まだ期限前
try {
job. fn ();
props. setProperty (lastKey, String (now)); // 成功時のみ前進
} catch (e) {
console. error ( 'job failed: ' + job.id + ' / ' + e);
// 失敗時は last を更新しない = 次回再試行される(冪等性は各 fn 側で担保)
}
}
props. setProperty ( 'heartbeat' , String (now)); // 生存記録
} finally {
lock. releaseLock ();
}
}
ポイントは3つあります。ひとつ目は RUN_BUDGET_MS で、6分の実行上限に達する前に必ずループを抜けることです。残ったジョブは次の5分後のディスパッチで拾われます。ふたつ目は LockService による二重起動の防止で、前回が長引いて次の発火と重なっても、片方が静かに降りるので同じジョブが並行実行されません。みっつ目は、成功したときだけ last_ を更新することです。失敗時は時刻を進めないので、次の周回で自動的に再試行されます。
トリガーは設置スクリプトで「常に1本」を保証する
ディスパッチャ方式の利点は、トリガー管理そのものをコードで冪等にできることです。手でトリガーを足したり消したりすると、いつの間にか二重登録され、本数と実行時間枠の両方を無駄に消費します。次の関数は、既存の dispatch トリガーを一度すべて消してから、1本だけ作り直します。
function installDispatcher () {
// 既存の dispatch トリガーを掃除(重複登録を根絶する)
ScriptApp. getProjectTriggers ()
. filter ( t => t. getHandlerFunction () === 'dispatch' )
. forEach ( t => ScriptApp. deleteTrigger (t));
// 5分間隔の単一トリガーを作成
ScriptApp. newTrigger ( 'dispatch' )
. timeBased ()
. everyMinutes ( 5 )
. create ();
console. log ( 'dispatcher installed: 1 trigger / every 5 min' );
}
新しいジョブを増やすときは、JOBS 配列に1行足すだけです。トリガーは増えません。これが「機能を増やすほどトリガーが枯れる」構造を断ち切る肝になります。実装やデプロイの権限設計まで詰めたい場合は、Apps Script × Gemini の最小権限スコープ設計 も合わせて参照してください。
Gemini 呼び出しは1周回あたりの上限で守る
ディスパッチャに束ねても、各ジョブが無制限に Gemini API を叩けば、UrlFetch の日次上限や課金が膨らみます。私はジョブ側に「1周回で処理する件数の上限」を設け、残りは次の周回に送る作りを好みます。一定間隔で確実に少しずつ進むほうが、一度に大量処理して途中で6分上限に当たるより安定します。
function runReviewReplyDraft () {
const MAX_PER_RUN = 5 ; // 1周回あたりの Gemini 呼び出し上限
const pending = fetchPendingReviews ( MAX_PER_RUN ); // 未処理ぶんだけ取得
for ( const r of pending) {
const draft = callGemini (
'gemini-flash-latest' ,
'このアプリレビューへの丁寧な返信案を日本語で1つ作成してください: \n ' + r.body
);
saveDraft (r.id, draft); // 保存は冪等に(同じ id は上書き)
}
}
function callGemini ( model , prompt ) {
const key = PropertiesService. getScriptProperties (). getProperty ( 'GEMINI_API_KEY' );
const url = 'https://generativelanguage.googleapis.com/v1beta/models/'
+ model + ':generateContent' ;
const res = UrlFetchApp. fetch (url, {
method: 'post' ,
contentType: 'application/json' ,
headers: { 'x-goog-api-key' : key },
payload: JSON . stringify ({ contents: [{ parts: [{ text: prompt }] }] }),
muteHttpExceptions: true ,
});
const code = res. getResponseCode ();
if (code === 429 || code >= 500 ) {
// レート超過・一時障害はこの周回では諦め、次回に回す(再試行の暴発を避ける)
throw new Error ( 'retryable: ' + code);
}
const json = JSON . parse (res. getContentText ());
return json.candidates[ 0 ].content.parts[ 0 ].text;
}
MAX_PER_RUN を5にすると、15分間隔のジョブなら1時間で最大20件を処理します。私の壁紙アプリ(iOS と Android で App Store と Google Play に出しているもの)のレビュー返信下書きでは、この刻みで滞留することはほとんどありませんでした。急ぐ運用でなければ、回転数を上げるより1周回の負荷を軽く保つほうが、トリガー実行時間枠にも、AdMob 収益に直結する本番処理にも優しいと感じます。最初の値としては、5分間隔のディスパッチで MAX_PER_RUN を3〜5に置くことを推奨します。私の運用では合計実行時間枠の消費がおおむね40%ほど下がり、昼前に枠を使い切る事故がなくなりました。件数を上げるのはこの数字で安定してからで十分です。バッチ処理の冪等化そのものは、6分上限を前提にした Sheets バッチの冪等設計 で深掘りしています。
「止まったこと」に気づく仕組みを外に置く
ディスパッチャ方式の弱点は、トリガーが枯れて発火が止まると、止まったことを知らせる仕組みまで一緒に止まる点です。スクリプト内のウォッチドッグでは、トリガーが死んだ瞬間にウォッチドッグも死にます。だからハートビートの 監視は外部に置く のが要点になります。
dispatch は周回ごとに heartbeat へ現在時刻を書いています。これを外から読める小さな Web エンドポイントとして公開し、外部の死活監視サービスに一定間隔で叩かせます。一定時間ハートビートが更新されていなければ、監視側が通知を出してくれます。
function doGet () {
const hb = Number (
PropertiesService. getScriptProperties (). getProperty ( 'heartbeat' ) || 0
);
const ageMin = (Date. now () - hb) / 60000 ;
// 直近の発火から15分以上空いていたら異常とみなす
const ok = ageMin < 15 ;
return ContentService. createTextOutput (
JSON . stringify ({ ok: ok, ageMin: Math. round (ageMin) })
). setMimeType (ContentService.MimeType. JSON );
}
これをウェブアプリとしてデプロイし、ok が false になったら通知が来るよう外部監視に登録しておけば、無音の停止に当日中に気づけます。私が最初に痛い目を見たのは、まさにこの外部監視を置いていなかったからでした。自動化は「動いている確認」まで含めて初めて運用と呼べるのだと、止まってから学びました。
次の一歩
まず手元のスクリプトで ScriptApp.getProjectTriggers().length をログ出力し、いま何本のトリガーが走っているかを数えてみてください。2本以上の時間主導型トリガーがあるなら、それらを1本のディスパッチャに束ねる価値があります。JOBS 配列に既存の関数を移し、installDispatcher を一度実行すれば、トリガーは常に1本に収束します。