CoreS3のTailscaleを直接UDPでつないでみた
目次
2026-09-25 追記: この直接UDP接続をStackChanの音声チャット本体(ESP-IDFプロジェクト化)へ組み込み、自宅サーバーと直結した実機検証 → StackChanの音声チャットをESP-IDFでビルドし直してTailscaleで直結した
前回はCoreS3自身をTailscaleに参加させて、VPSの中継なしで自宅の音声サーバーからWAVを取った。
ただし経路は東京のDERP(Tailscaleの中継サーバー)固定で、433,964バイトのWAVに約9秒、速度は約47 KiB/sだった。
同じサーバーからPCで取ると0.154秒で終わる。
TailscaleはふつうDERPで相手を見つけたあと、端末どうしを直接UDPでつなぎ直す。
前回はこの直接接続がうまくいかず、DERPだけを使う設定にしていた。
ならCoreS3でも中継なしの直接UDPにできるのか、実装を調べながら試してみた。
検証環境
| 項目 | 使用した環境 |
|---|---|
| 実機 | M5Stack CoreS3、ESP32-S3、16MiBフラッシュ、8MiB PSRAM |
| 作業用PC | Windows、Tailscale 1.102.4 |
| ビルド | ESP-IDF 6.0、Dockerイメージ espressif/idf:v6.0 |
| 書き込み | esptool 5.3.0 |
| 接続先 | 自宅の音声サーバー。作業用PC・CoreS3とは別の回線 |
| 取得するファイル | 前回と同じ待ち時間用のWAV、433,964バイト |
PC同士の経路確認
先に、作業用PCから音声サーバーへの経路を確かめた。
tailscale ping -c 5 laptop-0e6caiut
pong from laptop-0e6caiut (...) via <音声サーバー側の公開IP>:<ポート> in 56ms
via のあとがDERPではなく相手の公開IPなので、1回目から直接UDPでつながっている。
PCと音声サーバーは公開IPの違う別の回線にある。
tailscale netcheck では、UDPが通り、NATの対応付けが宛先ごとに変わらない型(MappingVariesByDestIP: false)だった。
いちばん近いDERPは東京で10.1ms。
自宅のネットワークなら、ふつうのTailscaleクライアントどうしは直接つながる。
CoreS3だけ中継になるなら、CoreS3側の実装に原因を絞り込める。
取り込んだDISCOの実装の確認
Tailscaleで直接経路を探すのはDISCOという仕組みで、候補のアドレスへPingを送り、Pongが返ったアドレスを直接経路として使う。
前回取り込んだserial_wifi_loggerのコミット c537f4b にも、DISCOの実装は入っていた。
同じ作者のportable_terminalには、このTailscale実装を拡張した版と、移植メモが入っている。
移植メモとコードを突き合わせると、c537f4bのDISCOは公式のTailscaleとパケットの形式が合っていなかった。
| 項目 | c537f4b | 公式のTailscale(移植メモの記述) |
|---|---|---|
| 暗号化 | XChaCha20-Poly1305 | NaClのbox(XSalsa20-Poly1305) |
| パケットの先頭 | 識別子6バイト、nonce 24バイト | 識別子6バイト、送信者のDISCO公開鍵32バイト、nonce 24バイト |
| Pingの中身 | 種別、8バイトのID | 種別、バージョン、12バイトのID、送信者のノード鍵 |
| UDPソケット | DISCO専用 | WireGuardと同じソケット |
| 相手からのPing | 処理しない | Pongを返す |
| 自分のアドレスの申告 | 空の一覧を送る | 公開IPとポートを申告する |
これでは相手のtailscaledがPingを処理できない。
前回、直接接続でタイムアウトしたのは、Pongを待たずに最初の候補へWireGuardの宛先を切り替えていたからだと思う。
portable_terminalの実装に差し替える
portable_terminalのコミット 44a616e から、components/tailscale と components/wireguard を持ってきた。
移植メモには、DISCOの書き直しのほかに、DERPでつないでから直接経路へ切り替える段取り、全候補へのPing、HTTPSでの公開IP取得と申告、CallMeMaybe(相手に「こちらへPingをくれ」と頼むDISCOのメッセージ)の送受信が並んでいる。
前回の修正のうち、次の2つはそのまま足した。
| 修正 | 内容 |
|---|---|
| 承認待ちの再登録 | ブラウザ承認を待つ間、前回のURLを Followup で送り、承認前はnetmapを要求しない |
| DERPのTLS | 接続ごとに、TLSの読み書き1回ずつを同じミューテックス(排他制御)で囲む。接続後は非ブロッキングI/O |
portable_terminalのDERPも、送信だけロックして受信は素で読んでいた。
前回、送受信がぶつかってTLSが異常終了したのと同じ形なので、こちらも囲んだ。
portable_terminalはnetmapなどのバッファをPSRAMに置く前提なので、前回は切っていたPSRAMを有効にした。
DERPだけを使う設定と、東京のDERPを優先する設定は外した。
プローブは起動後、同じWAVを15秒おきに取り続けて、1回ごとの時間を出すようにした。
DERPから直接UDPへ切り替わる前後の差を確かめるため。
1回目の起動でWAV取得が停止
保存済みの鍵で、ブラウザの再承認なしにTailscaleへ戻った。
起動から約32〜35秒で、CoreS3が送ったPingにPongが返り始めた。
I (34667) ts_disco: Pong recv from <音声サーバー側の公開IP>:<ポート> → peer 12 direct
I (44857) wireguard: [WireGuard] HANDSHAKE_RESPONSE: e8c68499:<ポート>
peer 12が音声サーバーで、宛先はPCから tailscale ping したときと同じ公開IPとポートだった。
WireGuardのハンドシェイクも同じ公開IPから返ってきた。
DERP経由なら、ここは疑似アドレス 127.3.3.40(ログでは 2803037f)になる。
ところが、WAVの取得が終わらなかった。
HTTPのタイムアウトを15秒にしているのに、エラーも出ないまま5分以上止まった。
DERPの送信タスクも2本とも止まっていて、約310秒から送信キューがあふれ始めた。
キューは16件、キープアライブ(接続維持パケット)は15秒おきなので、逆算すると46秒ごろから止まっている。
一方、音声サーバーから直接UDPで届くPingにはPongを返し続けていたので、全体が止まったわけでもなかった。
lwIPの二重ロックによる停止
1回の取得が45秒を超えたら全タスクのバックトレースを出す見張りを足して、もう一度起動した。
同じ現象が出たので、アドレスを addr2line で関数名に戻した。
| タスク | 止まっていた場所 |
|---|---|
| tcpip(lwIPの処理スレッド) | udp_input から呼ばれた wireguardif_network_rx の中の LOCK_TCPIP_CORE() |
| main(WAV取得) | HTTPの接続中、lwip_select の中 |
| DERP送信×2 | TLSの書き込みの中で、lwIPのロック待ち |
| DERP受信×2 | TLSのミューテックス待ち(送信側が握ったまま) |
lwIPはネットワークスタックで、Wi-Fiから届いたパケットは処理スレッドの上で処理される。
このスレッドは処理の間ずっとlwIPのコアロックを持っている。
直接UDPで届いたWireGuardのデータを処理するとき、wireguardif_network_rx がもう一度 LOCK_TCPIP_CORE() を取っていた。
再帰できないロックなので、ロック解放待ちで止まる。
あとは処理スレッドを待つほかのタスクが、順に止まっていった。
DISCOのPing/Pongはこの手前で分岐するので通る。
直接UDPでデータが初めて届いた時点で固まる、というのがログと合う。
portable_terminalで起きなかった理由ははっきりしない。
そちらの設定にはlwIPの項目がなく、コアロックが既定の無効なら、このロックは何もしないはずだ。
CoreS3では前回からの設定で CONFIG_LWIP_TCPIP_CORE_LOCKING を有効にしていた。
DERPから届いたデータも処理スレッドへ渡してから同じ関数を呼ぶので、どちらの経路でもこのロックは要らない。
wireguardif.c のロックを外した。
3回目の起動と速度低下
ロックを外して3回目の起動。
| 回 | 取得時間 | 速度 |
|---|---|---|
| 1 | 10.017秒 | 42.3 KiB/s |
| 2 | 3.215秒 | 131.8 KiB/s |
| 3 | 2.187秒 | 193.8 KiB/s |
| 4 | 48.346秒 | 8.8 KiB/s |
| 5 | 53.823秒 | 7.9 KiB/s |
| 6 | 48.801秒 | 8.7 KiB/s |
1回目は、ヘッダーが返るまでに約8秒かかった。
音声サーバーとのWireGuardのハンドシェイクを待っていた時間で、本体の受信は約2秒。
3回目は前回のDERP経由の約4倍になった。
4回目からは、ヘッダーは1秒以内に返るのに本体が8 KiB/s前後に落ちた。
経路が切り替わったログは出ていない。
ログを見直すと、ほかの端末のtailscaledから届いていたPingが、起動から約90秒で止まっていた。
30秒ごとの件数は49、54、4、0。
もう1つ、公開IPの申告が一度も出ていなかった。
CoreS3はPingを送った相手には直接の送信元として見えているが、netmapに載る自分のアドレスは空のままになる。
相手のtailscaled側でCoreS3への直接経路が無効になり、音声サーバーからCoreS3への通信だけDERPに戻ったと考えている。
公開IPの取得は、制御サーバーへの2本目のnetmap要求のあとで始まる。
2回目のバックトレースを確認すると、この2本目の要求は :status 200 のあと、本体を待ったまま止まっていた。
制御サーバーとのやり取りはHTTP/2で、受信側はウィンドウ更新(WINDOW_UPDATE)で「ここまで受け取ったので続きを送ってよい」と伝える。
HTTP/2の仕様では、ウィンドウの初期値は65,535バイト。
CoreS3側はウィンドウ更新を一度も送っていなかった。
このtailnetには16台の端末があり、1本目のnetmapでウィンドウを使い切ると、サーバーは2本目の応答を送れない。
受け取ったDATAフレームの分だけ、ウィンドウ更新を返すようにした。
200秒前後での再度の速度低下
4回目の起動では、2本目のnetmap要求の応答が届き、公開IPの取得が始まった。
1つ目の取得先はHTTPの接続に失敗し、2つ目の checkip.amazonaws.com で公開IPを取って申告した。
86秒ごろには、直接経路がまだない10台へCallMeMaybeを送っている。
作業用PCから tailscale ping すると、CoreS3へは同じLAN内のアドレスで直接つながった。
| 回 | 取得時間 | 速度 |
|---|---|---|
| 1 | 16.473秒 | 25.7 KiB/s |
| 2 | 2.399秒 | 176.6 KiB/s |
| 3 | 1.695秒 | 250.0 KiB/s |
| 4 | 2.752秒 | 153.9 KiB/s |
| 5 | 1.948秒 | 217.5 KiB/s |
| 6 | 1.754秒 | 241.6 KiB/s |
| 7 | 2.108秒 | 201.0 KiB/s |
| 8 | 3.609秒 | 117.4 KiB/s |
| 9 | 3.933秒 | 107.7 KiB/s |
| 10 | 2.561秒 | 165.5 KiB/s |
| 11 | 48.978秒 | 8.7 KiB/s |
| 12 | 50.800秒 | 8.3 KiB/s |
90秒で止まる問題は解消したが、11回目(起動から212秒〜)でまた8 KiB/s台に落ちた。
205秒ごろ、音声サーバーからの直接のPingが止まった。
212秒からは、音声サーバーがDERP経由でCallMeMaybeを5秒おきに送ってくるようになった。
直接経路をつなぎ直したいので、そちらからPingをくれ、という意味だ。
ところがCoreS3は、CallMeMaybeを受け取っても音声サーバーへPingを1本も送っていなかった。
DISCOには、Pongを待っているPingを記録しておく表が64件分あり、この枠はPongが返ったときにしか空かない。
このtailnetの端末は、Dockerのブリッジのような到達できないアドレスも候補に出してくるし、オフラインの端末もある。
応答のないPingが溜まり、送信83本の時点で表が満杯になっていた。
それ以降のPingは、DEBUGレベルのログを出すだけで全部捨てられていた。
送ってから5秒たった枠は回収するようにし、満杯のときは警告を出すようにした。
もう1つ、CoreS3が名乗るホームDERP(CoreS3宛ての中継を受け取るDERP)が、最初からリージョン1のニューヨークだった。
DERPの一覧からリージョン番号がいちばん小さいものを選んでいたためで、前回東京を優先するようにしたのと同じ箇所だ。
直接経路が切れると、音声サーバーからの戻りはニューヨーク経由になる。
8 KiB/s前後という遅さはこれのせいだと思う。
直接UDPは有効のまま、ホームDERPだけ東京(リージョン7)を優先するようにした。
内部RAMの空きは、起動直後の約220KBから1回目の取得後に約128KBへ減り、その後は横ばいで、減り続けてはいない。
30回の連続取得テスト
5回目の起動は、30回取るようにした。
ホームDERPは東京になり、公開IPも申告できた。
| 回 | 取得時間 | 速度 |
|---|---|---|
| 1 | 11.548秒 | 36.7 KiB/s |
| 2 | 2.747秒 | 154.3 KiB/s |
| 3 | 2.414秒 | 175.5 KiB/s |
| 4 | 2.741秒 | 154.6 KiB/s |
| 5 | 1.852秒 | 228.7 KiB/s |
| 6 | 1.924秒 | 220.2 KiB/s |
| 7 | 1.967秒 | 215.4 KiB/s |
| 8 | 3.190秒 | 132.8 KiB/s |
| 9 | 3.565秒 | 118.9 KiB/s |
| 10 | 2.849秒 | 148.7 KiB/s |
| 11 | 1.958秒 | 216.4 KiB/s |
| 12 | 1.712秒 | 247.5 KiB/s |
| 13 | 2.173秒 | 195.0 KiB/s |
| 14 | 2.042秒 | 207.5 KiB/s |
| 15 | 3.164秒 | 133.9 KiB/s |
| 16 | 2.632秒 | 161.0 KiB/s |
| 17 | 3.315秒 | 127.8 KiB/s |
| 18 | 2.047秒 | 207.0 KiB/s |
| 19 | 1.942秒 | 218.1 KiB/s |
| 20 | 1.946秒 | 217.7 KiB/s |
| 21 | 1.854秒 | 228.5 KiB/s |
| 22 | 2.138秒 | 198.2 KiB/s |
| 23 | 2.444秒 | 173.4 KiB/s |
| 24 | 2.762秒 | 153.4 KiB/s |
| 25 | 3.246秒 | 130.5 KiB/s |
| 26 | 1.699秒 | 249.3 KiB/s |
| 27 | 2.034秒 | 208.3 KiB/s |
| 28 | 2.038秒 | 207.9 KiB/s |
| 29 | 1.882秒 | 225.2 KiB/s |
| 30 | 2.551秒 | 166.1 KiB/s |
30回とも取得でき、8 KiB/sまで落ちる回はなくなった。
Pingの表が満杯になることも一度もなかった。
音声サーバーからの直接のPingは、9分間ずっと1分あたり約20回届き続けた。
音声サーバーとのWireGuardのハンドシェイク8回も、すべて音声サーバーの公開IPからだった。
DERP経由のハンドシェイクも40回出ているが、これはほかの端末とのもの。
| 経路 | 同じWAVの取得時間 | 速度 |
|---|---|---|
| 前回(東京DERP固定、CoreS3) | 8.816〜9.025秒 | 約47 KiB/s |
| 今回(直接UDP、CoreS3、2〜30回目) | 平均2.373秒、中央値2.138秒(1.699〜3.565秒) | 平均187.0 KiB/s |
| 作業用PC | 約0.154秒 | ― |
前回のDERP経由に比べて約4倍になったが、PCにはまだ遠い。
1回目だけは11.548秒かかっている。
起動直後で、音声サーバーとのWireGuardのハンドシェイクを待つ時間が入るからだ。
1〜3回目の起動では、起動から14〜17秒の間にWi-Fiが2回切れてつなぎ直してもいた(4・5回目では出ていない)。