技術約10分で読めます

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
作業用PCWindows、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-Poly1305NaClの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送信×2TLSの書き込みの中で、lwIPのロック待ち
DERP受信×2TLSのミューテックス待ち(送信側が握ったまま)

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回目の起動。

回取得時間速度
110.017秒42.3 KiB/s
23.215秒131.8 KiB/s
32.187秒193.8 KiB/s
448.346秒8.8 KiB/s
553.823秒7.9 KiB/s
648.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内のアドレスで直接つながった。

回取得時間速度
116.473秒25.7 KiB/s
22.399秒176.6 KiB/s
31.695秒250.0 KiB/s
42.752秒153.9 KiB/s
51.948秒217.5 KiB/s
61.754秒241.6 KiB/s
72.108秒201.0 KiB/s
83.609秒117.4 KiB/s
93.933秒107.7 KiB/s
102.561秒165.5 KiB/s
1148.978秒8.7 KiB/s
1250.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も申告できた。

回取得時間速度
111.548秒36.7 KiB/s
22.747秒154.3 KiB/s
32.414秒175.5 KiB/s
42.741秒154.6 KiB/s
51.852秒228.7 KiB/s
61.924秒220.2 KiB/s
71.967秒215.4 KiB/s
83.190秒132.8 KiB/s
93.565秒118.9 KiB/s
102.849秒148.7 KiB/s
111.958秒216.4 KiB/s
121.712秒247.5 KiB/s
132.173秒195.0 KiB/s
142.042秒207.5 KiB/s
153.164秒133.9 KiB/s
162.632秒161.0 KiB/s
173.315秒127.8 KiB/s
182.047秒207.0 KiB/s
191.942秒218.1 KiB/s
201.946秒217.7 KiB/s
211.854秒228.5 KiB/s
222.138秒198.2 KiB/s
232.444秒173.4 KiB/s
242.762秒153.4 KiB/s
253.246秒130.5 KiB/s
261.699秒249.3 KiB/s
272.034秒208.3 KiB/s
282.038秒207.9 KiB/s
291.882秒225.2 KiB/s
302.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回目では出ていない)。