「テストだと1回なのに、本番にしたら同じ処理が2回走る」——検証が終わってWebhook URLを本番に差し替えた夜、同じ内容の通知がSlackに11連発しました。原因は、n8nのWebhookノードが持つテストURLと本番URLの2系統を、リトライ制御と一緒に正しく理解していなかったことです。テストでは問題が潜んだまま通過し、本番で事故が起きる。典型的な失敗の型でした。この記事では、二重実行の実測と、コピペで動くリトライ安全化の実装を載せます。
何が起きたか: 11通の重複通知
社内ツールのフォーム送信をトリガーに、外部APIへ登録してSlackへ通知するワークフローを組んだときのことです。テストURL(/webhook-test/)では完璧に動く。本番URL(/webhook/)に差し替えてActivateした途端、同じ通知が立て続けに飛びました。
| # | 発生時刻 | 通知の内容 | 実行ID |
|---|---|---|---|
| 1 | 23:41:02 | 正常に登録完了 | exec_a1 |
| 2 | 23:41:14 | 正常に登録完了(重複) | exec_a1 |
| 3 | 23:41:14 | 正常に登録完了(重複) | exec_a2 |
| 4以降 | 23:41〜23:52 | 同様の重複が続き、計11通 | — |
犯人探し: Executionsログで実行IDを突き合わせた
n8nの Executions 画面で全11回の実行履歴を開き、同じ入力ペイロードで集計しました。すると次の事実が分かります。
-
exec_a1が2回記録されていた(同一実行の再実行 = ワークフローレベルのリトライ) -
exec_a1とexec_a2が同一ペイロードで別実行として並んでいた(送信側の再送 = HTTPレベルのリトライ)
つまり二重実行は2階建てでした。表にするとこうです。
| 階層 | 誰が再送しているか | n8n側の設定箇所 | 症状 |
|---|---|---|---|
| HTTP層 | フォーム送信側(Webhookクライアント)の再送タイムアウト | 受け手側では制御不能・冪等キーで吸収 | 別実効IDで同内容が並ぶ |
| ワークフロー層 | n8n自体のエラーワークフロー・手動「Execute again」 | Workflow Settings / ノードのRetry On Fail | 同一実行IDが2回記録される |
失敗した設計: 「Webhookは1回きり呼ばれる」と決めつけていた
まず私が最初に組んでいた駄目な構成です。これは失敗パターンなのでそのまま再現しないでください(正しい形は次章に完全形があります)。
// ❌ 失敗設計: 「Webhookは必ず1回」という前提で副作用を直列に全実行する
// Code ノード (Run Once for Each Item)
const items = $input.all();
const results = [];
for (const item of items) {
// 外部APIへの登録がここに直書きされている
// 同一冪等キーの再送が来ても、そのまままた登録してしまう
const resp = await this.helpers.httpRequest({
method: 'POST',
url: 'https://api.example.com/v1/registrations',
body: {
key: item.json.key, // ←冪等キーとして使えるのに使っていない
name: item.json.name,
email: item.json.email,
},
});
results.push({ json: { registered: true, api_id: resp.id } });
}
return results;
このコードの決定的な欠陥は次の2点です。
-
冪等キーを受け取っているのに使っていない — 送信側が
keyを付けて再送してきても、こちらは黙って毎回新規登録する - 副作用が全部「成功前提」の直列処理 — 途中でタイムアウトすると、n8nのRetry On Failがワークフロー全体をやり直し、前回成功した分も含めて全登録し直す
直し方1: 冪等キーで「2回目の副作用」を止める
外部APIが同じ key の再登録を弾く仕様でない場合でも、n8nの手前で受け止める側に冪等ゲートを設けられます。静的データ(getWorkflowStaticData)に処理済みキーを記録し、2回目の到着を黙って成功扱いにします。
// ✅ 完全形: 冪等ゲート付き Code ノード (Run Once for All Items)
// 静的データはワークフロー保存時に永続化される(アクティブな本番ワークフロー向け)
const staticData = $getWorkflowStaticData('global');
const seen = staticData.seenKeys || {};
const results = [];
const MAX_KEYS = 5000; // 肥大化防止: 古いキーを丸ごと捨てる閾値
const keys = Object.keys(seen);
if (keys.length > MAX_KEYS) {
staticData.seenKeys = {};
seenKeys2 = {}; // reset後に再構築するためローカルを初期化
} else {
seenKeys2 = seen;
}
for (const item of $input.all()) {
const key = item.json.key;
const payload = item.json;
if (seenKeys2[key]) {
// 2回目以降の到着: 副作用は起こさず「既に処理済み」として正常応答
results.push({ json: { key, deduped: true, registered: false } });
continue;
}
// 本体処理: 外部APIへ登録
const resp = await this.helpers.httpRequest({
method: 'POST',
url: 'https://api.example.com/v1/registrations',
body: {
key,
name: payload.name,
email: payload.email,
},
});
seenKeys2[key] = new Date().toISOString();
results.push({ json: { key, deduped: false, registered: true, api_id: resp.id } });
}
staticData.seenKeys = seenKeys2;
return results;
この1本で HTTP層の重複は構造的に消えます。同じ key の再送・再実行が何回届いても、副作用は初回の1回だけです。
直し方2: テストURLと本番URLを「混ぜない」
もう一つの実害が、テストと本番のURL取り違えです。n8nのWebhookノードには2つのURLがあり、挙動が違います。
| 項目 | テストURL(/webhook-test/) | 本番URL(/webhook/) |
|---|---|---|
| 受付条件 | 「Listen for test event」実行中のみ | ワークフローがActiveの間ずっと |
| Executions記録 | テスト実行として記録 | 本番実行として記録 |
| 想定用途 | 1回の検証 | 常時運用 |
| 放置時の事故 | リッスン停止後、送信側だけが「200 OK」のつもりで再送を続ける | 二重実行が静的に蓄積 |
混ぜて使うと、検証が終わった後にテストURLへ送り続けるクライアントが残っていても、こちらからは気付けません。切り替え手順は**「テストで検証 → 本番URLへ差し替え → クライアント側のURL設定を変更 → テストURLへの送信停止を確認」**と1方向に固定し、戻る操作は入れないのが安全です。
直し方3: リトライは「副作用があるノードだけ」無効にする
ワークフロー層のリトライと冪等ゲートは併用が正解ですが、設定は慎重にやります。Retry On Failをワークフロー全体に付けても、冪等ゲートが2回目を吸収するので二重実行は起きません。ただし、外部APIの登録が「タイムアウトしたが実際は成功していた」ケースでは、冪等キーがAPI側で効くかを先に確認します。
| リトライ設計 | 二重実行リスク | 向いているケース |
|---|---|---|
| ワークフロー全体にRetry On Fail | 冪等ゲートなし=高い / あり=低い | 全体が読み取り中心のとき |
| 副作用ノードだけ個別にRetry On Fail | 冪等ゲートと併用で最小 | 登録・作成系の副作用が混ざるとき |
| リトライなし・失敗時に通知だけ | リトライ事故はゼロ、ただし取りこぼし | 1回きりで許容できる処理 |
私の選択は「副作用ノードだけ個別リトライ+冪等ゲート」です。失敗した実行はSlackへ飛ばし、手動で再実行させても冪等ゲートが守ってくれるため、人が復旧作業に入っても壊れない状態を作れました。
まとめ
- Webhookは1回きり呼ばれると決めつけない。HTTP層(クライアント再送)とワークフロー層(n8nリトライ)の2階建てで重複は来る
- 冪等キーは「受け取ったら使う」。静的データに処理済み記録を持ち、2回目は副作用なしで成功応答を返す
- テストURLと本番URLは1方向で切り替える。戻る操作を入れると、リッスン終了後の幽霊送信が静かに蓄積する
深夜に11連発したSlack通知は、どれも「壊れた結果」ではなく「正しい処理が重複した結果」でした。自動化の事故は失敗ではなく重複から起きることが多く、その防御はコードではなく設計(冪等キーの存在)で決まります。
次のアクションは、n8nワークフローのWebhook後続ノードに本記事の冪等ゲートを貼り、HTTP層の再送が「deduped: true」で吸収されるかを確認することです。
読者特典(無料)
本記事のような実装パターンを横断的にまとめたチートシートと、Obsidian・Notionのテンプレート集を無料配布しています。8冊の内容から「何をどのツールでやるか」の判断チャートも含みます。
参考書籍: n8n × AI自動化実践入門 — ノーコードで作るAIワークフロー(Kindle・Kindle Unlimited読み放題対象)(葉山悠希 著)