- Fly.io は、
flyctl と Fly Machines 間の直接通信を維持しつつ WireGuard ゲートウェイの状態管理の負担を減らすため、ピアを事前にインストールせず、接続時にカーネルへ追加する方式に変更
- 従来のフローは、GraphQL API が NATS RPC でピア設定を渡し、
wggwd が SQLite と Linux カーネル WireGuard に登録した後、flyctl が接続する構造だった
- NATS メッセージの消失と CI ジョブによる一回限りのピア生成が重なり、ゲートウェイには再利用されないピアが数十万個も蓄積し、カーネル操作や再起動時のロードが遅くなっていた
- 新方式では、
handshake initiation パケットを BPF フィルタまたは WebSockets の受信経路で捕捉し、Noise ハンドシェイクの一部を復号して公開鍵を識別したうえで、内部 HTTP API から必要なピアだけを取得する
- 本番適用後の数週間で古いピアの数はほぼ消え、ゲートウェイはより少ない状態で、より高速なピア設定と再起動を処理できるようになった
Fly.io が WireGuard を使う方法
- Fly.io はコンテナを Firecracker ベースの VM として実行しており、顧客向け API の一部のように WireGuard を複数の場所で活用している
flyctl は実行時に独自の IPv6 アドレスを持つ TCP/IP スタックを作成し、Fly.io ネットワーク上の Fly Machines と直接通信する
- このアプローチにより、リモート Docker ビルダーのような機能を同じ LAN 上にあるかのように表現しやすくなるが、安定して運用し続けるのはより難しい
- Fly.io は最終的にデフォルト経路を WireGuard-over-WebSockets に変更した
従来のゲートウェイプロビジョニングフロー
- Fly.io は、世界各地の複数の ゲートウェイ サーバーに入ってくる WireGuard 接続を、適切なプライベートネットワークに接続する
flyctl がコンテナビルド、SSH コンソール、ファイルコピー、サービスプロキシのために Fly Machine と通信する必要がある場合、バックグラウンドのエージェントプロセスを実行または接続する
- エージェントは初回実行時に GraphQL API で新しい WireGuard ピア設定を作成する
- API はその設定を NATS メッセージングシステムの RPC として適切なゲートウェイに渡す
- ゲートウェイ上の
wggwd は設定を受け取り、SQLite に保存し、WireGuard Go ライブラリでカーネルに追加した後、API にインストール完了を応答する
- API が GraphQL リクエストに設定を返すと、
flyctl はすでにゲートウェイにインストール済みの WireGuard ピアとして接続する
従来構造が遅くなった理由
- NATS は高速だが配信を保証しないため、信頼性のある API 基盤として使うのは難しかった
- Fly.io は内部での NATS 利用を減らし、たとえば内部
flyd API は NATS ベースから HTTP ベースへ変更された
- NATS 利用を減らすことで WireGuard ゲートウェイは改善されたが、十分ではなかった
flyctl 終了後も、作成された WireGuard ピアはゲートウェイに残り続け、古いピアを整理するプロセスはなかった
- 翌日に再度デプロイしたり、
fly ssh console でデバッグしたりする可能性があるため、ピアを削除しないという選択があった
- しかしほとんどのピアは永続ストレージのない CI ジョブで作成され、次回実行時に同じピアで再接続できないため、毎回新しいピアが作成された
- その結果、ゲートウェイは再利用されない可能性のある数十万個のピアを抱えることになった
- 古いピア数が増えるにつれて、カーネル WireGuard の操作が非常に遅くなった
- ゲートウェイサーバーの再起動後、すべてのピアを再びカーネルにロードする過程が特に遅かった
- 一部ではカーネルパニックも発生した
必要なときだけピアをカーネルにインストールする設計
- すべての WireGuard ピア履歴を 1 つの SQLite に保存するのは難しくないが、すべてのピアを Linux カーネルに保持することがボトルネックになる
- Fly.io はゲートウェイへ設定をプッシュする代わりに、ゲートウェイが API から必要なピアを オンデマンドで取得する方式を採用した
- クライアントが接続しようとするときだけピアをカーネルに追加すれば、古いピアはいつでもカーネルから削除できる
- 削除されたピアも次回接続時に再取得してインストールすればよいため、ゲートウェイが長期的な状態を持ち続ける必要が減る
- ただし Linux カーネル WireGuard には、「incoming connection attempt」イベントを購読する API がない
JIT WireGuard ピアの実装方法
- Linux カーネルの WireGuard 設定インターフェイスは Netlink であり、WireGuard Go 制御ライブラリは wgctrl-go を使用している
- Fly.io は、WireGuard 接続リクエストが識別可能なパケットである点を利用し、BPF フィルタと packet socket で直接イベントを作成した
- WebSockets WireGuard 経路では、生の WireGuard パケットをより簡単に取得できる
- この経路は、認証なしの WebSockets 接続でフレーミングされた UDP パケットをゲートウェイインターフェイスとの間で送受信する
- Fly.io がそのデーモンコードを保有しているため、パケット受信関数にフックを掛けられる
- WireGuard には「クライアント」と「サーバー」の概念がなく、トラフィックを送るときにピア同士が接続する ポイントツーポイントプロトコルである
- 先に接続する側が initiator、相手側が responder である
- Fly.io では通常
flyctl が initiator、ゲートウェイが responder である
- 最初の UDP パケットは WireGuard 論文上の
handshake initiation であり、パケットタイプは平文の 1 バイトに記録される
- Fly.io は
udp and dst port 51820 and udp[8] = 1 BPF フィルタで入ってくる接続を捕捉する
Noise ハンドシェイクでピアを識別する
- WireGuard は Noise Protocol Framework ベースであり、Noise はハンドシェイク中に identity hiding のため識別子を隠す
- そのため、パケットからユーザー名のような値を読み取ってすぐに設定を探す方法は使えない
- Fly.io は入ってくるリクエストを識別するため、Noise 暗号化の一部を実行してアイデンティティを復号する
- このコードは扱いが難しいが、およそ 200 行ほどである
- カーネル Netlink インターフェイスは、権限を持つプロセスにインターフェイスの秘密鍵を提供できるため、必要なシークレットを取得できる
- 関連コードは gist で公開されている
- この処理により、ゲートウェイに WireGuard 接続を試みるユーザーの公開鍵イベントフィードを得られる
インストール、キャッシュ、再試行の最適化
- ゲートウェイは SQLite に レート制限キャッシュを保持し、新しいピアを発見すると内部 HTTP API リクエストで対応するピア情報を取得してインストールする
- このロジックは、従来ゲートウェイで WireGuard を管理していた小さなデーモンにうまく収まった
- 古いピアは
cron ジョブで積極的に削除できるようになった
- 新しいピアに対する API 参照は、最初の
handshake initiation メッセージに即座に応答できるほど速くない場合がある
- WireGuard はすばやく再試行するため、動作自体には問題ない
- Jason Donenfeld が教えてくれた Linux WireGuard Netlink 機能を利用し、より高速に接続を成立させた
- 入ってくる initiation メッセージから、
flyctl の一時的なソースポートを含む 4 タプルアドレスを取得する
- ゲートウェイは自分が initiator、
flyctl が responder であるかのようにピアをインストールする
- Linux カーネルが
flyctl 側へ WireGuard 接続を開始し、このプロトコルはサーバーとクライアントの役割に大きく依存しない
- 新しい接続は、インストール可能な速度に近い速さで成立する
本番適用の結果
- この方式は数週間にわたり本番環境で実行された
- ゲートウェイごとに数千個から数十万個に達していた古い WireGuard ピア数が、ほぼ 0 に近づいた
- ゲートウェイが保持しなければならない状態が減った
- ピア設定がより速くなった
- 再起動時に、使用されていないピアをカーネルへ再ロードする必要が減った
1件のコメント
Hacker News の意見
Linux カーネル版 WireGuard に、必要になったときにピアをインストールする機能がないという話がよく理解できません。ランタイムでもピアを追加できるように見えます: https://serverfault.com/questions/1101002/wireguard-client-a...
私の理解が正しければ、その段階ではすでに遅く、インターフェースに古いエントリが残らないように、ピアを追加する前に認証しようとしているようです。
そこでインターフェースの前に eBPF フィルターを置き、暗号鍵ルーティングに基づいて承認済みの相手かどうかを直接接続して確認し、通ればピアをインターフェースに追加してタイムアウト後に削除する構造に見えます。
ピアはすべて SQLite のような場所に置いておき、クライアントが接続を試みたときに必要に応じてインストールすればよいのです。
VPN プロバイダーの立場では、現在の API は少し不格好です。実際にはある時点で使われているピアは一部だけという点もありますが、ピア数が数十万から数千万に増えると、カーネルの 1 インスタンスにすべて保存すること自体が不可能になります。
ピアを事前にインストールしなければならないなら、結局は特定のサーバーマシンに縛られてしまいます。
記事にあるように、今でも簡単なパケットキャプチャで必要なインターフェースに近いものを作れますし、Jason が API をうまく設計してくれているおかげで、サーバーとクライアントの開始方向を非常に簡単に反転できます。カーネルが最初の開始メッセージを捨てたとしても、ユーザーにはシームレスにつながったように感じられます。
Jann Horn はさらに一歩進めて、キャプチャした開始パケットを保持しておき、ピアのインストール後にカーネルへ再注入することもできたと言っていて、それもかなり良いアイデアです。
この記事は人生を変えるほどのものではなく、人々が知っておくと喜びそうな、きれいなトリックがいくつかある、という程度だと思います。
次のステップは、これを土台に floating peers を作り、ピアを完全にリージョンから切り離すことです。そうすればユーザーはピアがどのリージョンに設定されているかを気にしなくてよくなり、これは単なるマニア向けの面白さを超えて、実際のプロダクト上の利点になりそうです。
JIT WireGuard という表現は少し変に感じます。最初に思ったのは「なぜ? 性能のボトルネックは暗号化で、クライアントごとの JIT はそこには役立たないはずなのに」でした。
私なら単にユーザー空間に行ったと思います。tokio-uring や glommio のようなものを使って性能を出せばよいでしょう。
カーネル内で押し通し続けると、Linux は数百万のアクティブトンネルを処理するように作られているわけではないので、ずっと限界にぶつかるはずです。1 つのカーネルで TCP 接続を数百万本扱うだけでも、ときどき厄介です。
限界ごとにハックが必要になり、ハックごとに適用・管理しなければならないシステム設定が生まれます。Linux 物理サーバーのプロビジョニング用ツールチェーンは、アプリやサービスの開発・設定管理ツールに比べてはるかに遅れています。
それとも私が愚かで、何かを誤解しているのでしょうか?
Go アプリでユーザー空間の WireGuard ピアを作りたいなら、最近の実験的プロジェクトである https://github.com/dpeckett/noisysockets を見るとよいかもしれません。
wireguard-go の素晴らしい仕事を土台にしていますが、ライブラリとして使いやすく、より Go らしくしようとしたものです。
これでサービスメッシュを作ると面白そうです。複数言語対応は難しいでしょうが、ソケット API を実装することもできそうです。
ただし WireGuard 暗号化に対するハードウェアアクセラレーションはまだ見かけていないので、性能面では mTLS と競うのは難しいかもしれません。
ちなみに今フリーランスの仕事を探しているので、高速・セキュアなネットワーキング分野の Golang フリーランサーが必要なら連絡してもらって構いません。
任意トンネル向けの Magic Wormhole のようなもので、長距離・高帯域幅ネットワークでファイル転送が 20〜30 MB/s あたりで崩れる問題も大きく改善できることを期待しています。
0 - https://github.com/slackhq/nebula
単一のポイント間メッセージでは、メッセージキューを経由するより直接 HTTP リクエストの方が信頼性が高い場合がある、という点にはおおむね同意しますが、NATS でメッセージがそれほど多く失われ、サービスに大きな影響が出たという点には少し驚きました。
メッセージが失われたら、NATS が成功するまで再送するのではないのでしょうか? なぜ体感できるほどの不安定さを経験したのか、知っている人がいるのか気になります。
NATS の構造は直感的で魅力的ですが、どこでずれたのか気になります。JetStream には調整可能なパラメータがたくさんあります。
たとえば時間ベースの重複検出ウィンドウを持つメモリストリーム、push/pull 方式、再送と確認ポリシーの設定などが可能です。
ただし、一回限りの単一メッセージ接続とは相性が悪いかもしれません。いずれにしても、より具体的な詳細があれば非常に有用だと思います。
しかし結局、私たちには不要でした。メッセージ層は表現力を増すというより、テストとモニタリングを難しくするだけでした。
「自分たちが initiator であるかのようにピアを設定し、flyctl を responder にする。Linux カーネルが flyctl 側へ WireGuard 接続を再開する」という部分は、実質的にハンドシェイクに半往復分の遅延を追加しているのだろうか?
例えば 1) flyctl が Initiation を送信、2) netlink でピアが追加され、新しい Initiation が送信される、3) flyctl から Response が送信される、という流れなのか気になる
つまり 3 ステップ目は存在しないか、待つ必要がなく、2 ステップ目の新しい開始を妨げなければ、確かにそうならないのではないかと思う
1.a) Bob は電話に出ないが、発信者番号の番号を電話帳に追加する
「flyctl を実行するたびに、私たちの愛すべき巨大な CLI が虚空から TCP/IP スタックを作り出し、独自の IPv6 アドレスを持ち、私たちのネットワークで実行中の Fly Machines と直接通信する」というのがどういう意味なのか分からない
「虚空から TCP/IP スタックを作る」と表現した理由は、通常 OS がカーネルの一部として TCP/IP スタックを提供するから。
wireguard-go では TCP/IP スタックがユーザー空間で実行されるため、flyctl コマンドラインインターフェースのような通常のユーザー空間プロセス内で作れる。
昔からシステムを扱ってきた人には、かなり魔法のように見えるはず。実際、実用に足るプロセス内ユーザー空間 TCP/IP スタックは比較的新しく、目新しいものだ
最初のハンドシェイクパケットをネットワークスタックに再注入できないようにしているものは何なのか気になる。そうすればパケットロスはなさそう
それと、eBPF フィルタで
udp[8] = 1を確認する目的も気になる隣のコメントで言われているように、BPF フィルタは開始パケットだけを捕捉し、それが望む動作。TCP 接続開始を見るために SYN をスニッフすることの WireGuard 版だ
udp[8] = 1はハンドシェイクパケットだけをフィルタリングする。これがないとデータパケットもユーザー空間デーモンに送られる。最初のハンドシェイクを再生できるかは確かではないが、WireGuard は未知のクライアントを無視するので、可能かもしれない
デフォルトで WireGuard をWebSocket 上でトンネリングしている点が興味深い。性能には良くないが、flyctl が使われる DevOps 的な作業には問題なさそう。
QUIC/HTTP3 の将来について考えるときも、こういう点が気になっていた。ネットワーク運用者が UDP 443 ポートを適切に扱うのではなく、完全にブロックしてしまう可能性もゼロではない
flyctlにも設定オプションがある。UDP が使えないとまったく動かず、デバッグも難しいため、デフォルトは確実に動くと分かっている方にした。
どのデフォルトを選ぶかという議論で負けたことは、苦々しく思っている
私のスタートアップは Fly をほぼ 1 年使った。コードを 1 分以内にデプロイ済みコードにする中核機能は本当に美しい。
バックフィル用の新しいノードを立ち上げたり落としたりするのも数秒で済む。
ただ、会社自体は少し未成熟に感じた。一度、API サーバーが Fly 上で 48 時間アクセス不能になったが、自分の設定ミスだったのか、別の「静かな」障害だったのか確信が持てなかった。
「db」製品はあるが「マネージド Postgres ではない」というような扱いで、そこでも切断が継続的に発生した。
CLI に Postgres をトップレベル名詞として追加しておきながら、サポートする機能範囲を制限しているのは奇妙に感じた。
中核サービスの API アクセスもしばしば落ち、新しいサービス修正のデプロイを待たなければならなかった。
デプロイ体験は恋しいが、正直、今は GCP の Cloud Run の方が満足している。「驚き」がはるかに少なく、ドキュメントもずっと完成度が高い
VPS プロバイダーが、ユーザーに対するバックエンドサービスのレイテンシを下げる支援をほとんどしていないのは不思議だ。Anycast をサポートするところはなく、GeoDNS の選択肢も非常に少ない。
ただし GeoDNS は別の複雑さを加える。
Fly.io のデータ転送料金がもっと安ければいいのにと思う。今は作業中の ngrok 風サービスで、Fly.io の機能のかなりの部分を不器用に再実装しなければならない状況だ。
[0]: https://lastlogin.io
[1]: LastLogin を世界分散方式で実行するために必要な Fly 専用コードはこの程度: https://github.com/lastlogin-io/obligator/blob/37f75cc861f1b...
うまく動くときは本当に滑らかだったが、その頻度が十分ではなかった
この機会に Netmaker[0] を紹介したい
関係者ではなく、複数アカウントにまたがる非公開の AWS VPC アクセスが必要で、満足して使っているだけの人間。もっと広く採用されるといいと思う
[0] https://www.netmaker.io/
前職では Ansible で Windows と Linux 数台に wg を設定・管理していたが、悪くはなかったものの、最後のほうは少しごちゃごちゃしてしまった
サイトがあまりにも曖昧
「数十万のピアを持つゲートウェイ、その中には二度と使われないピアもある」という部分は、最初の数段落を読みながらまさに思い浮かべていたことだった
「着信接続試行イベントを購読する API 呼び出しはない。問題ない。自分たちでイベントを作ればいい。WireGuard の接続要求はパケットであり簡単に識別できるので、BPF フィルタとパケットソケットで効率よく捕まえられる」というアイデアも良い
着信する開始メッセージを受け取ると、flyctl が使う一時的な送信元ポートまで含めた、目的の接続の4タプルアドレスが得られ、自分たちが initiator で flyctl が responder であるかのようにピアをインストールするとのことだが、これが NAT 配下でも動作するのか気になる
{wggwd.fly.io, 12345, clientIP, 23456}のような形新しい “initiator” UDP パケットであれ、送信した開始メッセージへの応答であれ、経路上の UDP NAT にはまったく同じに見える
判断材料は 4タプルだけで、その 4タプルが同じだから