オンライン面接の最中に、家のネットが切れた。10 時から 11 時までの面接で、切れたのは 10:00 と 10:53 の 2 回、どちらも 3〜4 分。よりによって、という最悪のタイミングだった。

この家の回線が「ping は通るのに Web だけ開かない」形で落ちるのは初めてではない。この連載で何度も書いてきた症状で、10 月 8 日に IIJ へ問い合わせて、ようやく正体が分かっていた。IPv4 over IPv6(DS-Lite)では、IPv4 の通信に使えるポートが 1 回線あたり 1024 本まで。それを家の中の機械が使い切っていた。

今日やったことは 2 つある。家の中で一番ポートを使っていた巡回ツールの接続を半分以下に絞ったことと、UniFi Cloud Gateway Ultra の「単一ネットワーク」で LAN に IPv6 を配り、IPv6 で届く通信をトンネルの外に逃がしたことだ。後者は、最初は API の設定項目が 1 つ空のせいで配られず、管理画面を見てようやく原因が分かった。調べ物と設定の作業は Claude Code が行い、私はその途中で何度も質問を挟んだ。その質問が流れを変えたところもあるので、会話の順に書いていく。

結論から書く。

  • 原因: DS-Lite の出口(AFTR)で IPv4 のポートが 1024 本を超えた。面接の時間帯は、毎時の巡回ツールと開発機の作業が重なって、ルーターから見えるだけで 644〜1,157 本に達していた
  • 対策 1: 巡回ツールのページを開く間隔を 4 秒あけ、広告・計測の通信を止め、DNS をルーター経由にした。巡回中のピークは 511〜857 本 → 282 本
  • 対策 2: UCG Ultra の WAN で「単一ネットワーク」を選び、受け取った /64 を LAN に配った。IPv6 の通信は AFTR を通らないので、ポートを消費しない。開発機の外向きの IPv4 の接続は 0 本になり、同じ E2E テストが走っていても家全体の山は 1,000〜1,233 本 → 250〜470 本になった
  • 落とし穴: API で設定するときは ipv6_wan_delegation_type だけでなく single_network_lan(配る先のネットワーク)も入れる。管理画面は空でも「Default」と表示する
  • 守り: 各端末が外から届くアドレスを持つので、外から入れないことを Cloudflare Workers から実際に接続して確かめた

何が起きていたか: 1024 本の上限

まず仕組みを図にする。DS-Lite では、家の IPv4 の通信を IPv6 に包んで ISP の網まで運び、出口の AFTR で包みを開いて、多くの家で共有している IPv4 アドレスに変換して外に出す。家ごとに使えるポートの数は、この AFTR で決まっている。

transix の網

NTT 東の光回線網

家の中

IPv4 を IPv6 に包んだトンネル

IPv6 はそのまま

開発機・PC・スマホ

UCG Ultra

網のルーター

AFTR
IPv4 に変換して外へ
1 回線 1024 ポートまで

IPv6 の中継ルーター

IPv4 のサイト

IPv6 のサイト

1024 本を使い切ると、新しい接続だけが AFTR で拒否される。すでに張ってある接続は切れず、ping(ICMP)も通る。この連載で「TCP だけ死ぬ」と書いてきた症状はこれだった。以前の記事では「AFTR の装置が不調になる」と仮説を立てていたが、IIJ の回答で、装置の不調ではなく枠の使い切りだと分かった。AFTR を隣のアドレスに切り替えると即座に直るのは、割り当てが空の AFTR から始め直せるからだ。

ここで大事なのは、閉じた接続もすぐには枠を返さないことだ。通信を終えた接続も、しばらくは TIME_WAIT としてポートを持ったままになる。短い接続を大量に張って閉じると、本数はすぐに積み上がる。

面接の時間帯に、誰が使っていたか

10 月 9 日には、宅内の監視が自分で張る接続を 1 分あたり約 224 本から約 32 本に減らしていた。それでも切れたので、ルーターの接続の一覧(conntrack)を 30 秒ごとに記録する仕組みを 12 日に入れてあった。今日の記録を、断の時刻で引いた。

時刻断家全体の IPv4 接続一番使っていた機械
09:53〜09:563 分869 本Raspberry Pi 511 本(巡回ツール)
10:00〜10:033 分644〜729 本開発機 約 250 本・Raspberry Pi 約 210 本・Mac 105〜210 本
10:53〜10:574 分1,051〜1,157 本Raspberry Pi 757〜857 本(巡回ツール)

毎時 53 分の断は、Raspberry Pi で動かしている自作の巡回ツールが主因だった。懸賞の情報を集めて応募ページを読むツールで、1 時間に 1 回、ヘッドレスブラウザで数十のページを開く。10:00 の断は、その巡回の残りと、開発機の作業(Google 宛てに 208 本)と、面接中の Mac が重なっていた。本数は 53 分より少なく、1024 本に届く前に切れている。閉じた接続がルーターの一覧から消えたあとも、AFTR 側ではまだ枠を押さえている、と考えると説明がつくが、AFTR の中は見えないので確かめようがない。

この巡回ツールが主な消費元だということは、前日の分析で分かっていた。「並列度を下げる」と結論を出しておきながら、手を入れないまま今朝を迎えた。

対策 1: 巡回ツールの接続を絞る

Claude がコードを開いて最初に分かったのは、巡回ツールはもともと 1 ページずつ順番に開いていて、並列ではなかったことだ。並列度を下げる余地は無い。本数が膨らむ理由は別にあった。

接続の一覧を、巡回中の Raspberry Pi の分だけ取り出して数えると、こうなっていた。

  • 1 ページ開くたびに、広告・計測・SNS 埋め込みのサーバーにも接続していた(断の最中、Facebook の配信網だけで 20〜31 本)
  • DNS の問い合わせが全部 8.8.8.8 に直接出ていた。巡回中は UDP だけで 90〜150 本
  • 2〜3 分に数十ページを詰めて開くので、閉じた接続の TIME_WAIT がその間に積み上がる

DNS の件は Docker の挙動が原因だった。Raspberry Pi の /etc/resolv.conf は 127.0.0.1(自分の中の DNS)を向いている。コンテナの中から 127.0.0.1 は使えないので、Docker は既定の 8.8.8.8 に切り替える。名前を 1 回引くたびに、ポートを 1 本使っていたわけだ。

直したのは 3 か所で、どれも小さい。

// ページを開く間隔を最低 4 秒あける(テストでは 0)
let lastOpen = 0;
async function pace() {
  const gap = Number(process.env.PAGE_GAP_MS ?? 4_000);
  const wait = lastOpen + gap - Date.now();
  if (wait > 0) await new Promise((r) => setTimeout(r, wait));
  lastOpen = Date.now();
}

export async function newPage(browser) {
  await pace();
  const page = await browser.newPage();
  // 画像・動画・フォントと、広告・計測・SNS 埋め込みは読まない
  await page.route("**/*", (route) => {
    const req = route.request();
    return SKIP.has(req.resourceType()) || isTracker(req.url()) ? route.abort() : route.continue();
  });
  return page;
}

広告・計測の一覧には、Facebook・Google タグマネージャー・DoubleClick などのホストを並べた。ただし reCAPTCHA の通信は止めていない。応募フォームに CAPTCHA があるかどうかの判定に使うからだ。3 つ目の DNS は、コンテナの定義に dns: [ルーターの IP] を足して、ルーターに問い合わせるようにした。ルーターがキャッシュするので、外への問い合わせが減る。

同じページを、止める前と後で開いて接続先を数えた。

ページ止める前止めた後
PR TIMES の記事13 ホスト3 ホスト
関西の情報サイト48 ホスト10 ホスト

反映した直後の巡回では、Raspberry Pi のピークが 282 本(対策前は 511〜857 本)、家全体では 574 本に収まり、外形監視の断も無かった。巡回にかかる時間は 2〜3 分から約 6 分に延びたが、1 時間に 1 回の作業なので問題はない。

1.1.1.1 はどこから来たのか

接続の一覧には、ルーター自身が 1.1.1.1 に DNS を問い合わせる UDP も常に数十本あった。私は「なんでそんな設定になっているのか、何のためか」と聞いた。

調べると、誰かが選んだ設定ではなかった。WAN の DNS は「自動」のままで、DS-Lite では回線側が IPv4 の DNS サーバーを配らないので、欄が空になる。そこで UniFi が予備の 1.1.1.1 を使っていた。この問い合わせもトンネルを通る。

WAN の IPv6 側の DNS に Cloudflare と Google の IPv6 アドレスを手で入れると、IPv6 での問い合わせが流れ始めた。ところが 1.1.1.1 宛ての IPv4 も残った。IPv4 側の欄を空にする変更は、UniFi に api.err.MissingDnsServer で拒否された。効果は半分ほどにとどまっている。

ここで私は、「1.1.1.1 は IPv4 だから、IPv4 にしか対応していないサイトを引くのに残っているのは自然では?」と聞き返した。Claude の答えは、DNS の問い合わせを IPv4 で送るか IPv6 で送るかと、答えに IPv4 のアドレス(A レコード)をもらうか IPv6 のアドレス(AAAA レコード)をもらうかは別の話、というものだった。IPv6 の窓口に問い合わせても、IPv4 だけのサイトの A レコードはきちんと返ってくる。1.1.1.1 が残るのは、ルーターの中の DNS が登録された窓口に振り分けて送っているからで、問い合わせの中身で送り分けているわけではない。

IPv6 で直接つなげば、トンネルを通らないのでは

ここで私は、根本的な質問をした。「1024 ポートを通れないのはトンネルの話だよね。IPv6 で直接つなげば、このトンネルを通らないという理屈だよね」

その理屈で合っていた。IPv6 の通信は AFTR で変換されないので、ポートの枠を使わない。Google・YouTube・Cloudflare・Facebook・Apple など、家の通信の多くの行き先は IPv6 を持っている。

問題は、家の中に IPv6 を配っていなかったことだ。ONU に UCG Ultra を直結してから、LAN の端末は IPv4 しか持っていない。実は 12 日にも同じ案を私が出していて、そのとき Claude は「回線側からプレフィックス委任(PD)が来ないので配れない」と書いて止めていた。NTT 東の光回線では、ひかり電話を契約してホームゲートウェイを置かないと、PD で家の中用の範囲がもらえない。ひかり電話の無いうちでは、WAN に /64 が 1 つ来るだけだ。

今回改めて調べると、12 日の結論は調べ不足だった。UniFi には 「単一ネットワーク」(Single Network) という設定がある。WAN で受け取った /64 を、1 つの LAN にそのまま使う機能だ。ひかり電話なしで /64 が 1 つだけ、という状況にちょうど合う。

「1 つしか割り当てられていない」のに、なぜ配れるのか

私は「本来 1 つしか割り当てられていないものを、どうやって家中で使うのか」と聞いた。

/64 はアドレス 1 個ではなく、アドレスの範囲だ。中には 2 の 64 乗、約 1,800 京個のアドレスがある。数は問題にならない。問題は、回線側がその範囲を「WAN の線にぶら下がっている」と思っていることだ。NTT 側のルーターは、その範囲宛ての通信が来ると、WAN の線に向かって「このアドレスの人はいますか?」と尋ねる(近隣探索、ND)。

単一ネットワークでは、ルーターがこの問いに代わりに答える。

家の端末UCG UltraNTT 側のルーター外のサイト家の端末UCG UltraNTT 側のルーター外のサイト/64 の中から自分のアドレスを作る(SLAAC)RA「この /64 を使って」IPv6 で外へ(既定の経路は UCG)そのまま中継(変換なし)転送返事(宛先は端末のアドレス)近隣探索「このアドレスの人は?」「私です」(端末の代わりに答える)返事を UCG に渡す端末に渡す

NTT 側から見ると、家の端末が全部 WAN の線に直接つながっているように見える。日本の家庭用ルーター(NEC の Aterm やバッファローなど)は、この「IPv6 ブリッジ」「パススルー」にあたる機能をたいてい最初から持っている。/64 が 1 つだけの状況を想定していない海外製の UniFi が、珍しく対応していた、というのが実際のところだ。

デメリットは何か

私はデメリットも聞いた。Claude が挙げたのは次の 5 つだった。

  1. 外からの入口の守りが、ファイアウォールだけになる。IPv4 では NAT のおかげで外から端末に直接届かなかったが、IPv6 では各端末が外から届くアドレスを持つ
  2. IPv6 を使える LAN は 1 つだけ(範囲が 1 つなので)。うちは LAN が 1 つなので影響なし
  3. 「IPv4 では通るが IPv6 では通らない」という新しい種類の不調が起こりうる
  4. 回線をつなぎ直して /64 が変わると、家中の IPv6 アドレスが一斉に変わる
  5. 古い家電は MAC アドレスから IPv6 アドレスを作るので、識別されやすい

私は「それって UCG にファイアウォールがあれば大丈夫ってことでは」と聞いた。そのとおりで、UniFi の既定は「家の中から始めた通信の返事だけ通し、外から始まる通信は止める」だ。ただ、LAN に IPv6 を配ったことが一度も無いので、その規則は一度も試されていない。だから「はずだ」ではなく、外から実際に試すことにした。

試行錯誤: API で入れても配られない

UCG Ultra の操作は、これまでも管理画面ではなく API で行ってきた。設定値の名前が分からないので、最初に使っていない WAN(Internet 2)で試した。でたらめな値を入れると api.err.InvalidValue で拒否され、single_network は受け付けられた。値の検査があるので、受け付けられたら有効な値だと読める。

# 使っていない WAN2 で、値の名前が正しいかだけ確かめる
curl -sk -X PUT -H "X-API-KEY: $KEY" -H 'Content-Type: application/json' \
  "$BASE/api/s/default/rest/networkconf/$WAN2_ID" \
  -d '{"ipv6_wan_delegation_type":"zzz_invalid"}'      # → api.err.InvalidValue
curl ... -d '{"ipv6_wan_delegation_type":"single_network"}'  # → rc: ok

本番の WAN に single_network を入れ、IPv4 が切れないことを 10 秒ごとに見張った。切れなかった。ところが、家の中の端末に IPv6 が付かない。開発機で 70 秒待ち受けても、ルーターからの案内(RA)が 1 つも流れてこない。LAN 側にも ipv6_interface_type に single_network を入れ、設定の再適用(force-provision)をかけ、WAN の IPv6 を一度オフにしてオンに戻した。どれも変わらなかった。

API が受け付ける値を当て推量で入れ続けても先が無いので、ここで手を止めた。管理画面に何が出ているかを、Cowork(ブラウザを操作する Claude)に書き写してもらった。設定を変えないこと、保存を押さないことを依頼書に書いて渡した。

返ってきた報告で、2 つのことが分かった。

  • WAN 側には「プレフィックス委任 / 単一ネットワーク」の選択肢があり、単一ネットワークの下に 「ネットワーク: Default」という配る先の欄がある
  • LAN 側の選択肢は「なし / プレフィックス委任(灰色で選べない)/ スタティック」で、単一ネットワークという選択肢は無い。API で入れた LAN 側の値は、画面では「なし」と表示されていた

配る先は、LAN 側ではなく WAN 側で指定する作りだった。WAN の設定を API で読み直すと、single_network_lan という項目があり、中身が空だった。管理画面は、空でも先頭の「Default」を表示していたことになる。

WAN(Internet 1)の設定

ここに Default の ID を入れる

LAN(Default)の設定

ipv6_interface_type = none のまま

wan_type_v6 = slaac

ipv6_wan_delegation_type = single_network

single_network_lan = (空)

ここに Default の ID を入れ、LAN 側に入れていた値は画面の想定どおり「なし」に戻した。入れた次の 10 秒で、ルーターの LAN に /64 が割り当てられ、開発機に IPv6 のアドレスと既定の経路が付いた。設定した瞬間に IPv4 が 10 秒ほど 1 回だけ切れたが、すぐ戻った。

curl -sk -X PUT -H "X-API-KEY: $KEY" -H 'Content-Type: application/json' \
  "$BASE/api/s/default/rest/networkconf/$WAN1_ID" \
  -d '{"single_network_lan":"<Default の _id>"}'

API だけで設定するなら、ipv6_wan_delegation_type と single_network_lan を必ず一緒に入れる。

外から入れないことを確かめる

IPv6 で外に出られることは、すぐに確かめられた。curl -6 で Google に 204 が返り、Cloudflare から見た送信元も開発機の IPv6 アドレスだった。

外から入れないことの確認には手間がかかった。私が使えるサーバーは IPv6 を持っていない。外部の検査サービスは IPv6 アドレスの直接指定を受け付けなかった。そこで Cloudflare Workers の connect()(TCP ソケット)で、Cloudflare の外側から開発機につないでみることにした。wrangler dev --remote で一時的に動かすだけで、本番には何も足していない。

ここでも 2 回つまずいた。

  1. 対照として Cloudflare の DNS につなぐと、即座に失敗した。Workers は Cloudflare 自身のアドレスにはつなげない。対照を Google の DNS に替えた
  2. IPv6 アドレスを直接書くと、どこにつないでも 0 ミリ秒で失敗した。Workers は IPv6 アドレスの直書きを受け付けない。ホスト名ならつながる

2 つ目は、IPv6 アドレスをそのままホスト名に変える公開の DNS サービス、sslip.io で回避した(2001-4860-4860--8888.sslip.io のように、コロンをハイフンに替える)。

つなぎ先(外から IPv6 で)結果
Google の DNS(対照・開いている)接続できた(93 ミリ秒)
開発機の ssh届かない(6 秒で時間切れ)
開発機のもう 1 つの待ち受け届かない(6 秒で時間切れ)

対照にはつながり、開発機には届かない。UCG Ultra のファイアウォールが、外から始まる通信を止めている。

効果: 山が逃げた

設定した直後の開発機では、外向きの IPv4 の接続が 0 本になり、確立済みの 60 本がすべて IPv6 に移っていた(Anthropic の API・Cloudflare・Google など)。

今日の午後には、ちょうど比べやすい材料があった。別の作業で開発機が Playwright の E2E テストを流していて、テストのたびに新しいブラウザで Google Fonts を読み込み直していた。その Google 宛ての接続が 700 本ほどあり、家全体の IPv4 の接続は 14:52〜15:44 に 1,000〜1,233 本まで上がっていた。面接中と同じ、断の一歩手前だ。

IPv6 を配ったのは 15:55。同じ E2E テストが動いたまま、家全体は 250〜470 本に下がった。

私は「E2E って Google Fonts だったよね。これは IPv4 なのかな」と聞いた。Claude が E2E と同じ Chromium で Google Fonts を読み込み、つながった先を記録した。

fonts.googleapis.com -> [2404:6800:400b:c00c::5f]
fonts.gstatic.com    -> [2404:6800:400b:c00c::5e]

どちらも IPv6 だった。Chromium は IPv6 が使えるときは IPv6 を優先する。テストが何度 Google Fonts を取り直しても、もうトンネルの枠は使わない。

一方で、平常時の本数はあまり減っていない。夕方の家全体は 370 本で、昨日の同じ時刻(196 本)より多いくらいだ。平常時の大半は Raspberry Pi の監視と巡回ツールで、どれも Docker のコンテナから出ている。コンテナは IPv6 を持っていないので、相手が IPv6 を持っていてもトンネルを通る。回線を落とすのは 1,000 本を超える山なので、今回の効果は「山が逃げた」と表すのが正確だ。

IPv6 の通信は、どこを通っているのか

最後に私は、「IPv6 なら transix を経由せずに直接アクセスできるのか」と聞いた。経路を測ると、こうなっていた。

段何か
1家の UCG Ultra
2NTT 東の光回線網のルーター
3〜4(応答なし)
52404:8e00:feed:ff1c::2(transix の網)
6〜(応答なし)。その先が Google

2404:8e00:: は、transix を運営する Internet Multifeed のアドレス帯だ。IPv6 の通信も transix の網は通る。ただし、5 段目は普通の中継ルーターで、変換もポートの割り当てもしない。AFTR と同じアドレス帯でも、AFTR そのものではない。

transix には役目が 2 つある。IPv6 をそのまま中継する道(IPoE)と、IPv4 を包んで運ぶトンネルの出口(AFTR)だ。1024 本の枠があるのは後者だけで、IPv6 の通信は前者だけを通る。同じ高速道路を走るけれど、混んでいる料金所のレーンには入らない、という形になる。

まとめ

  • DS-Lite(transix など)では、IPv4 の通信に使えるポートが 1 回線 1024 本。閉じた接続も TIME_WAIT の間は枠を持ったままなので、短い接続を大量に張るツールがあると、すぐに使い切る
  • 使い切ると、新しい TCP 接続だけが拒否される。ping は通り、確立済みの接続も切れない
  • ヘッドレスブラウザの巡回は、1 ページごとの広告・計測の接続が本数の大半を占める。止めるだけで、接続先のホストが 4 分の 1 になったページもあった
  • Docker のコンテナは、ホストの resolv.conf が 127.0.0.1 だと 8.8.8.8 に直接問い合わせる。DNS の問い合わせもポートを使う
  • UniFi で、ひかり電話なし(/64 が 1 つ)の回線の LAN に IPv6 を配るなら、WAN の IPv6 を SLAAC にして「単一ネットワーク」を選ぶ。API で設定するときは single_network_lan を必ず入れる(管理画面は空でも Default と表示する)
  • IPv6 を配ったら、外から入れないことを実際に試す。外に IPv6 の足場が無くても、Cloudflare Workers の connect() と sslip.io で確かめられる
  • 平常時の本数を占める Docker のコンテナには、まだ IPv6 が無い。コンテナに IPv6 を持たせるのは、次の宿題になった