エンジニアリング

ファイアウォールの内側の Dagu サーバーが Webhook を受け取るしくみ

公開 Webhook URL は、インターネットから接続できない Dagu サーバーに届きます。サーバーが接続をどう保つか、2 回届いたリクエストがなぜ実行を 1 つしか開始しないか、そして署名をなぜ Dagu Cloud が記録した時刻に照らしてサーバー上で検証するのかを説明します。

Yota Hamada
Share

TL;DR Dagu のスケジューラーは、Dagu Cloud への外向きの WebSocket を 1 本保ちます。Dagu Cloud は、サーバーが応答するまで各リクエストを保存し、サーバーは配信 ID を実行 ID に使います。そのため、2 回届いたリクエストが開始する実行は 1 つです。署名シークレットはサーバーにとどまり、サーバーは Dagu Cloud がリクエストを受け取った時刻に照らして署名を検証します。そのため、待っていたリクエストも検証を通ります。Dagu Cloud が保存するのは、各 URL のトークンのダイジェストだけです。

公開 Webhook URL を使うと、インターネットのどこからも到達できないサーバーの DAG を、GitHub、Stripe、Slack から起動できます。表から見える機能は小さなものです。スイッチ、URL、配信の一覧。この記事では、その下で何が成り立っている必要があったかを、主にトリガーの RFC の最初のリリースの節から説明します。

制約

最初に決まっていたのは 4 つです。

  • 内向きの接続はなし。 サーバーに届くものはすべて、サーバーが開いた接続に乗ります。
  • 新しく動かすものはなし。 Dagu の横にエージェントもサイドカーもトンネルも置きません。
  • サーバーはときどきいない。 再起動、デプロイ、スリープするノート PC。その間に届いたリクエストも実行を開始し、しかも 1 つだけでなければなりません。
  • Dagu Cloud が知ることは最小限に。 ワークフローも署名シークレットも渡さず、リクエストのボディも必要な間だけ預けます。

全体の形

GitHub, Stripe, Slack, ...
    │  POST https://hooks.dagu.sh/<server>/<token>
    ▼
Dagu Cloud relay            stores the request and answers 202,
one mailbox per server      keeps it for up to 7 days
    ▲
    │  WebSocket over HTTPS, opened by the server
    │  hello · hooks/hooked · delivery/delivered · missed · ping
    ▼
Dagu scheduler              verifies, filters, and queues the run,
holding the lock            then answers with an outcome

送信元には、Dagu Cloud がリクエストを保存した時点で 202 Accepted が返ります。実行が始まったかどうかはサーバー側の話で、送信元への応答ではなく、サーバー自身の配信一覧に表示されます。この分離があるから、サーバーはオフラインでいられます。送信元の側のやり取りが、サーバーを待つことはありません。

接続を持つのは 1 つのプロセス

Dagu の構成では、dagu server のプロセスもスケジューラーも複数動かせます。スケジューラーロックを持てるのは一度に 1 つのスケジューラーだけで、リレーへの接続もそのスケジューラーが持ちます。実行をキューに入れるのも、もともとこのプロセスです。そのため、配信がキューに入るまでにプロセスの境界をまたぐことはありません。同じサーバーから 2 本目の接続が現れた場合、リレーは新しいほうを残し、古いほうを理由 replaced で閉じます。

接続はチケットから始まります。スケジューラーは、チェックインに使うのと同じライセンスの認証情報で、Dagu コンソール にチケットを求めます。チケットの有効期間は 5 分で、接続するには十分です。チケットには、プランで使える URL の数が書かれています。無料モニタリングなら 1 個、トライアルや有料プランなら 1,000 個です。リレーはどの接続も 12 時間で終わらせ、スケジューラーは新しいチケットを求めます。そのため、リレーが適用する上限が半日より古くなることはありません。Dagu コンソール がサーバーとの接続を解除すると、リレーは理由 dropped でソケットを閉じ、次のチケットの要求は拒否されます。その後、スケジューラーが要求し直すのは 10 分ごとだけです。

ソケットが本当に生きているかは、両方向から確かめます。スケジューラーは 15 秒ごとに ping を送り、リレーは 45 秒間何も届かないソケットをオフラインとみなします。ノート PC のスリープやネットワークの切り替えの後には、サーバーの側でだけソケットが開いたままになることもあります。そのため、ping への応答が 30 秒なく、しかも 45 秒間何も届いていなければ、スケジューラーはソケットを閉じて接続し直します。再接続はジッター付きで 1 秒から 1 分まで間隔を空け、1 分以上続いた接続の後は、また 1 秒から始めます。

URL の集合を決めるのはサーバー

URL の末尾は、サーバーが作るトークンです。32 バイトの乱数で、ページで URL を再表示できるよう、Webhook の HMAC シークレットと同じく暗号化して保存します。リレーがトークンを受け取ることはありません。サーバーが送るのは各トークンの SHA-256 で、リレーは届いたリクエストのパスにあるトークンをハッシュして URL を探します。

サーバーは自分の URL を 1 つの集合としてまとめて送り、リレーが持っていた集合を丸ごと置き換えます。追加と削除の流れを送るより、集合のほうが正しく保ちやすいからです。再接続をまたいで順序を失う心配がなく、新しい接続で最初に送る集合が、前の接続で取りこぼしたものをすべて直します。

集合には、気をつけるべき点が 2 つあります。1 つ目は、ある URL を外した集合を送ると、その URL を待っていたリクエストが破棄されることです。一部しか読めなかった状態で集合を作ると、データを壊してしまいます。そのため、Webhook のレコードが 1 件でも読めないか復号できなければ、スケジューラーは小さくなった集合を送らず、前に送った集合をそのまま残します。カードには、Webhook を読めなかったことが表示されます。

2 つ目は空の集合です。スケジューラーが止まっている間に最後の URL をオフにすると、リレーはその URL を持ったままになります。一方で、公開 URL を 1 つも持たないスケジューラーには、接続する理由がありません。そこでスケジューラーは、リレーがまだ URL を持っているかもしれないことを自分のステータスに記録しておき、次に起動したときにもう一度だけ接続して、空の集合を送ります。本番環境では、スケジューラーが動いていない間にオフにした URL が、スケジューラーが戻った 2 秒後に消えました。

各 URL のキーは、DAG の名前ではなく Webhook の ID です。DAG の名前を変えても Webhook は残るので、URL も、その URL を待つリクエストも残ります。これで、もう 1 つの不変条件が重要になりました。プロセスをまたいでも、DAG 1 つにつき Webhook は 1 つです。そうでなければ、2 つの dagu server プロセスが同じ瞬間に同じ DAG の Webhook を作ると、1 つの名前の後ろに 2 つの Webhook と 2 つの URL が残りかねません。書き込みのたびに、まず Webhook のレコードを保存し、それから DAG 名をアトミックな作成で確保します。確保の記録が指す Webhook が存在しないか、別の DAG を指しているなら、その記録は 2 つの手順の間のクラッシュで残ったものでしかありえず、次に書き込む側が引き継ぎます。

1 リクエストにつき 1 実行

リレーは、ある配信の結果を受け取るまで、次の接続でその配信を送り直します。配信の途中で落ちたサーバーを乗り越える方法はこれしかなく、同じ配信が 2 回届くこともあるということです。スケジューラーが実行をキューに入れた後、応答を送る前にクラッシュすることもあるからです。

そこで、配信 ID をそのまま実行 ID にします。同じ ID の実行がすでにあれば、キューに入ったものとして扱い、2 回目の配信は何も変えません。送信元が同じイベントを 2 回送ることはあります。GitHub で Redeliver を押したときがそうです。これは Dagu Cloud にとっては新しい配信 ID を持つ新しいリクエストなので、新しい実行を開始します。Redeliver を押すとは、そういう意味だからです。

配信は、どれもリレーへの 4 つの応答のいずれかで終わります。

応答場面リレーの動作
started実行をキューに入れた配信を忘れる
ignoredWebhook がオフか存在しない、署名が正しくない、またはイベントが対象外忘れる
failedDAG を読み込めない、ボディがサーバーの上限を超えている、または指定どおりに実行を開始できない忘れる
retryDAG のキューですでに 100 件の実行が待っている、またはストレージでエラーが起きた60 秒後または 30 秒後に送り直す

サーバーが記録する結果は、リレーに送る応答より細かく分かれています。署名が正しくなければ理由付きで 拒否されました、対象外のイベントや GitHub の ping は スキップ済み と記録されるので、何も実行されなかった理由がカードでわかります。

Dagu の Public URL カードの最近の配信。push 2 件が実行を開始し、署名が署名シークレットと一致しなかった push は拒否され、issues.opened のイベントと GitHub の ping はスキップされた

retry は、その URL の後続の配信も、期限が来るまで止めます。そのため、キューがいっぱいのときは URL ごと一時停止し、新しいリクエストが先に進むことはありません。いっぱいのキューに実行を積み上げず、リレーで待たせることで、push が集中しても何百もの実行がキューに並ぶことはありません。それでも集中が続けば、サーバーごとに待機できるリクエストは 1,000 件までなので、リレーが 503 で送信元を押し戻します。

署名はサーバーで、時刻は Dagu Cloud から

GitHub や Stripe の署名を検証する場所として自然なのは、リクエストが着く場所です。ただしそれには、すべての署名シークレットを Dagu Cloud に渡す必要があります。そのため検証はサーバー上で行い、シークレットは Webhook の横に暗号化して保存します。Dagu Cloud は、ボディを 1 バイトも変えずに転送し、ヘッダーからは Authorization、Cookie、X-Forwarded-For などの認証情報とプロキシのヘッダーを取り除いて転送します。

難しかったのはタイムスタンプです。Stripe、Slack、Standard Webhooks は、リプレイを防ぐためにタイムスタンプに署名し、ふつうは 5 分以上ずれたものを拒否します。再起動中のサーバーを 3 時間待ったリクエストは、何も問題がないのに、この規則で落ちてしまいます。そこでリレーは各リクエストを受け取った時刻を記録して配信と一緒に送り、サーバーは署名されたタイムスタンプを、自分の時計ではなくその時刻と比べます。

これは何かと何かを引き換えにしており、何を引き換えたのかははっきりさせておく価値があります。署名は、今も送信元がそのボディを書いたことを証明します。一方で、新しさの確認は、リクエストがいつ届いたかについて Dagu Cloud を信頼するようになりました。Dagu Cloud は待っている間ボディを預かっているので、もともとこの確認で Dagu Cloud 自身のリプレイを防ぐことはできませんでした。防ぐのは、第三者が盗み見たリクエストを後から再送することで、その場合 Dagu Cloud が記録する受信時刻は再送した時刻になります。

Slack にはもう 1 つ癖があります。Request URL を保存すると、Slack はチャレンジを送り、それをすぐに返すよう求めます。オフラインかもしれないサーバーには約束できないことです。サーバーは集合の中で Slack の URL に印を付け、リレーがチャレンジに自分で応えます。実行は開始しません。

ボディは送られたとおりに

ローカルの Webhook リクエストは、ペイロードを {"payload": ...} で包みます。公開 URL ではそうできません。何を送るかは GitHub や Stripe が決めるからです。実行は、届いたとおりのボディを、WEBHOOK_PAYLOAD と、WEBHOOK_PAYLOAD_FILE が指すファイルで受け取ります。

このファイルがあるのは Linux のためです。Linux では 1 つの環境変数の文字列は 128 KiB までですが、GitHub の push はそれより大きくなることがあり、リレーは 512 KiB まで受け付けます。それより大きなボディを環境に持ったステップは、argument list too long で開始前に失敗していました。しかもボディは、WEBHOOK_PAYLOAD と DAG_PARAMS_JSON の中とで 2 回運ばれていました。今では、Webhook の実行はどれも、実行のユーザーだけが読める非公開の一時ディレクトリに置いたファイルでボディを受け取り、そのファイルは実行が終わると削除されます。1 つの変数に入らないボディは両方の変数から外され、ステップはファイルを読みます。Windows でも、32,767 文字という Windows の上限で同じように扱います。

リレーが受け付けるのは UTF-8 のボディだけです。配信は JSON のテキストとして運ばれ、最後は環境変数に入りますが、どちらも任意のバイト列をうまく運べません。このリリースが対象とする送信元は、どれも JSON かフォーム形式のテキストを送ります。そのため、バイナリのボディには、情報が欠ける変換をするのではなく 415 を返します。

リレーが保持するものと拒否するもの

制限値送信元に返るもの
ボディ512 KiB413
ヘッダー16 KiB431
URL ごとのリクエスト1 分間に 60 件Retry-After 付きの 429
サーバーごとの待機中のリクエスト1,000 件503、Retry-After は 10 分
リクエストが待てる期間7 日間破棄して件数を数える

黙って破棄されるものはありません。7 日を過ぎて期限切れになったリクエストと、1,000 件が待っている間に拒否したリクエストは URL ごとに数えられ、サーバーが接続したときに報告されます。カードは、管理者が通知を閉じるまでその件数を表示します。512 KiB で十分かどうかは、RFC でまだ未解決の問いです。大きな push のペイロードはこれを超えることがあります。

本番環境で確かめる

ここまでの動作はどれも、リリース前と v2.20.1 で改めて、ほかのサーバーと同じように Dagu Cloud に接続したビルドを使って、本番環境で確かめました。

  • 本物のシークレットで署名した GitHub の push が実行を開始し、そのペイロードファイルは送ったボディと 1 バイトも違いませんでした。
  • GitHub の ping と、イベントの一覧にない issues.opened はスキップされ、署名が不正な push は拒否されました。
  • 誤ったトークンには、サーバーに届く前に Dagu Cloud が 404 を返しました。
  • サーバーが止まっている間に送ったリクエストは、サーバーが戻ったときに実行されました。
  • DAG の名前を変えても URL は残りました。
  • 新しい URL で古い URL は約 5 秒で使えなくなり、URL をオフにすると約 10 秒以内に 404 が返るようになりました。

次に来るもの

このリリースは、既存の DAG ごとの Webhook に公開アドレスを与えます。RFC はその先を描いています。DAG の中で宣言するトリガーです。それぞれがソース、イベントの種類、条件、イベントから取り出すパラメーターを持ち、すべてが 1 つのイベントストアを通って、イベントとトリガーの組ごとに最大 1 つの実行を開始します。RFC には、トリガーのモデルは公開 URL が使われた場合にだけ続くとも書いてあります。何が作られるかは、公開 URL がどう使われるかで決まります。

公開 Webhook URL のドキュメントには、すべての送信元、結果、ステータスコードがまとめてあり、RFC には設計の全体があります。間違っていそうなところや、一覧にない送信元が必要なときは、Discord で教えてください。

· · ·

Written by

Yota Hamada

Daguプロジェクトのコントリビューター。

More from Yota Hamada