0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

n8nのWebhookが二重実行されていた — テストURLと本番URLの違いを知らずに深夜アラートが11通飛んだ話

0
Posted at

「テストだと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点です。

  1. 冪等キーを受け取っているのに使っていない — 送信側が key を付けて再送してきても、こちらは黙って毎回新規登録する
  2. 副作用が全部「成功前提」の直列処理 — 途中でタイムアウトすると、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読み放題対象)(葉山悠希 著)

著者: 葉山悠希 — 書籍シリーズは Zenn / Amazon で公開中

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?