日曜日、自宅の回線が同じ型の障害で 2 回落ちた。朝は2 分ほど落ちては 10 秒だけ戻る、を 1 時間半近く繰り返し、夕方は1 時間のあいだ静かに止まっていた。どちらも ping は通る。落ちているのは新規の TCP 接続だけで、確立済みの ssh は切れない。

この日は自宅の開発機で Claude Code を自律稼働させていて、朝の異変に最初に気づいたのはその Claude だった。夕方は家族の「Gmail が開かない」から始まり、Claude に伝えて 10 分ほどで切り分けと復旧が終わった。ただし監視の記録をあとから見ると、夕方の障害は 18:47 から続いていて、その間 14 サイトの外形監視は全滅を記録していたのに、通知は 1 本も届いていなかった

前回の記事で UniFi Cloud Gateway Ultra を transix (DS-Lite) に直結した構成(ONU → UCG Ultra → U7 Pro)の、その後の話になる。結論から書く。

  • 症状: ping(ICMP)と DNS(UDP)は通るのに、新規の TCP 接続だけが拒否される。上流は TCP の SYN にだけ ICMP admin-prohibited を返していた。確立済みの接続は切れないので、ssh は生きたまま Web だけ開かない
  • 直し方: ルーターや ONU の再起動は効いたり効かなかったりする(朝は ONU の電源入れ直しが効き、夕方は効かなかった)。DS-Lite の AFTR(トンネルの宛先)を隣のアドレスに変えると必ず 10 秒で直る
  • 根本原因: 特定できていない。固定していた AFTR(ISP 側の CGN 装置)が新規の TCP セッションを受け付けなくなる、というのが最有力の仮説
  • 予防策: 「TCP だけ死んだ」を毎分検知して AFTR を自動で回す watchdog を常設した。障害中は通知が届かないので、復旧後にまとめて Slack へ報告する

症状: 「つながらない」の形が普通ではなかった

家族から見た症状は「Gmail が開かない」「YouTube が見られない」で、ごく普通のネット障害に見える。ところが開発機から見ると、様子がおかしかった。

$ ping -c 2 1.1.1.1            # 通る(4ms)
$ dig +short gmail.com         # 引ける(DNS は UDP)
$ curl https://mail.google.com # 失敗。しかも 25ms で即座に
curl: (7) Failed to connect to mail.google.com port 443: Could not connect to server

タイムアウトではなく即座に失敗するのがポイントで、これは経路のどこかが「届かない」と積極的に返してきている。tracepath を打つと、その発信元が分かる。

$ tracepath -n -m 6 1.1.1.1
 1:  192.168.1.1        0.4ms
 2:  192.0.0.2          3.7ms !H

192.0.0.2 は DS-Lite のルーター側(B4)に付く決まりのアドレスで、要するに UCG 自身の WAN 側だ。UCG が、TCP の SYN に対してだけ ICMP host-unreachable を返している。ping は通り、UDP の DNS も通り、TCP の接続確立だけが拒否される。

しかも、すでに確立している TCP 接続は影響を受けない。だから開発機への ssh は切れないし、Claude Code の API 通信も生きているコネクションの上では動く。この「壊れているのに動いているように見える」性質が、夕方に 1 時間気づけなかった理由の半分だった。

朝: 「2 分落ちて 10 秒だけ戻る」を 1 時間半

最初の兆候は「Slack に投稿できない」だった。09:27、Claude が作業の区切りで Slack に投稿しようとして失敗した。当時の通知スクリプトは curl -s で失敗を黙って捨てる作りだったので、Claude から見えたのは「出力が無い」だけ。ここで Claude はまず通知スクリプトのほうを直した(リトライ 3 回、失敗時は stderr に本文の先頭を出して非 0 で終わる)。回線障害の副産物として、以後「送ったつもりで届いていない」が起きなくなったのは良かった。

そのあと 09:45、09:48、09:51 と 1 分前後の断が続き、09:53 から 10 秒おきの計測を始めた。「どこが落ちているか」を 3 段で絞る。

1 段目、自分の機械かどうか。 開発機の NIC のリンクは落ちていない。ゲートウェイ(UCG)への ping は通る。つまり LAN は生きている。

2 段目、宅内の別の機械でも同じか。 監視用の Raspberry Pi 5 からも同時に計測したところ、秒単位で同期して落ちる。1 台の問題なら同期しない。この時点で「WAN 側(DS-Lite のトンネル、ONU、ISP のどれか)」に確定した。

09:48:11 - 09:49:04  53 秒(forge・home とも不達、GW は疎通)
09:51:14 - 09:54:04  2 分 50 秒

3 段目、何が落ちているか。 ここが今回の肝で、別のセッションで動いていた Claude が tcpdump を取っていた。

  • ping(ICMP)は通る
  • DNS(UDP)も引ける
  • TCP は 80 も 443 も 22 も、全宛先で「no route to host」
  • パケットを見ると、上流の 14.0.9.198(transix 側のアドレス)が TCP の SYN に対してだけ ICMP admin-prohibited を返している
  • LAN 内のセッション数は開発機 19・監視機 3 と少なく、こちらが張りすぎているわけではない

「経路が無い」のではなく、「ISP 側の装置が TCP を意図的に落としている」。DS-Lite は IPv4 を IPv6 トンネルで ISP 側の AFTR まで運び、そこで NAT(CGN)される。その CGN が、うちのトンネルからの新しい TCP セッションを受け付けていない、という読みになる。

10 時を過ぎると悪化した。10:14 以降は落ちている時間が 9 割で、しかも周期がきれいに揃っている。

10:14:06 - 10:16:07
10:16:17 - 10:18:07
10:18:17 - 10:20:08
10:20:18 - 10:22:09
10:22:19 - 10:29:11   ← 7 分
10:29:21 - 10:33:12
10:33:22 - 10:35:12
10:35:22 - 10:37:13
10:37:23 - 10:39:14

ほぼ 2 分落ちて、10 秒だけ戻る。何かが 2 分のタイムアウトで張り直しては、すぐまた拒否される、という形に見える。この間、公開しているサイトは Cloudflare 上にあるので閲覧者には影響が無い。困るのは自宅からの作業と、自宅に置いてある監視の外向き通信だけだった。Claude はオンラインの作業を全部保留して、オフラインでできる検査(ソースの点検など)を先に片づけていた。

Claude からの推しは「UniFi アプリで WAN の状態を見て、赤ならゲートウェイを再起動」だった。10:45 頃に UCG Ultra を再起動したが、復帰後もすぐ同じ周期で落ち始めた

10:47:57 - 10:48:31
10:48:51 - 10:49:11
10:49:41 - 10:50:12

次に 10:50、ONU の電源を抜き差しした。10:51:13 に復帰し、以後は 10 秒おきの計測で一度も落ちなかった。30 分安定を確認して障害収束と判断、監視は通常に戻した。

なぜ UCG の再起動は効かず、ONU の電源入れ直しは効いたのか。正直なところ分からない。ONU を落とすと ISP 側から見て回線ごと消えるので、AFTR(CGN)側に残っていた状態が掃除された、というのが一番素直な説明だが、証明はできない。実際、この日の夕方に同じ型の障害が再発したときは、ONU の再起動は効かなかった。

夕方: 1 時間、静かに止まっていた

18:47 から、今度はフラッピングではなく落ちっぱなしになった。ただし ping は通り、確立済みの接続は切れないので、誰も気づかない。Mac から Gmail が開かないことに家族が気づき、ONU を再起動しても直らず、19:50 頃に Claude Code へ「Gmail につながらない」と伝えたところから切り分けが始まった。冒頭に書いた curltracepath の結果はこのときのもので、朝と同じ「TCP だけ拒否」の型だとすぐ分かった。

このあと試したのが AFTR の変更で、適用から約 10 秒で復旧した(後述)。先に、なぜ 1 時間も気づけなかったかを書く。

なぜ気づけなかったか: 回線が落ちると、監視も通知も一緒に沈黙する

障害そのものより、こちらのほうが教訓として大きい。

自宅の Raspberry Pi には Prometheus と Grafana があり、14 サイトの外形監視をしている。朝の障害の間、監視は 26 ターゲット × 合計 4,066 回の失敗をきちんと記録していた。夕方も 1 時間ずっと全サイトが「落ちている」と記録されていた。それでも Slack には 1 件も届いていない。

  • 疎通の監視は ping ベースのものが多く、ping は通っているので「正常」に見える
  • サイトの死活監視(HTTP)は正しく全滅を検知していたが、アラートを Slack に送る経路そのものが TCPなので、外に出られない
  • ssh は切れないので、ターミナルで作業していても違和感がない

朝、Claude が Slack に送ろうとした「悪化しています」の続報も、30 分以上送れなかった。復帰したときには内容が古くなっていたので取り消して、「ONU 再起動で復帰、以後安定」に差し替えている。結局朝の障害が私に伝わったのは、たまたま Claude のセッションが動いていて、ターミナルで直接報告できたからだった。

つまり、自宅回線の障害は、自宅の監視には原理的に見えない。「通知経路が障害と同じ経路に乗っている」という構造的な穴で、復帰した瞬間に「何時から何分落ちていたか」を事後報告する仕組みが要る。この宿題は後述の watchdog で塞いだ。

切り分けの手順

再発したときに同じ順で確認できるよう、手順として残しておく。

# 1. TCP と ICMP を並べて測る。ICMP だけ通るなら経路ではなくセッション確立の問題
for i in $(seq 1 10); do
  printf "%s tcp=%s icmp=%s\n" "$(date +%T)" \
    "$(timeout 3 bash -c 'exec 3<>/dev/tcp/1.1.1.1/443' 2>/dev/null && echo OK || echo FAIL)" \
    "$(ping -c1 -W2 1.1.1.1 >/dev/null 2>&1 && echo OK || echo FAIL)"
  sleep 1
done

# 2. UDP も確認する。DNS を UDP と TCP で引き比べると一発で分かる
dig +short @8.8.8.8 example.com        # UDP: 引ける
dig +short +tcp @8.8.8.8 example.com   # TCP: host unreachable

# 3. 誰が「届かない」と言っているか
tracepath -n -m 6 1.1.1.1              # 2: 192.0.0.2 !H なら UCG の WAN 側

# 4. 192.0.0.2 の応答時間。0.3ms 台なら宅内のルーター自身
ping -c 3 192.0.0.2

「落ちたり戻ったり」の型なら、10 秒おきの計測を 2 拠点で同時に取る。同期して落ちていれば端末側ではなく上流の問題だと確定できる。朝は開発機と Raspberry Pi、夕方は Mac と開発機で、どちらも同期していた。

直し方: 再起動ではなく、AFTR を隣に変える

前回の記事に書いたとおり、transix の AFTR(トンネルの終端)は東日本エリアでは 3 つある。

$ dig +short AAAA gw.transix.jp
2404:8e00::feed:100
2404:8e00::feed:101
2404:8e00::feed:102

うちの UCG は :100 にリテラルで固定していた。これを :101 に変えたら、適用から約 10 秒で TCP が 100% 通るようになった。Gmail も GitHub も Slack も、その場で開けた。

UniFi の管理画面なら「設定 → インターネット → 対象の WAN → IPv6 ゲートウェイアドレス」を書き換えるだけだ。うちは Claude に API で叩いてもらった(ID とキーの取り方は前回の記事)。

K=$(cat ~/.config/ucg-api-key); B=https://192.168.1.1/proxy/network; ID=<WAN設定のID>
curl -sk -X PUT -H "X-API-KEY: $K" -H "Content-Type: application/json" \
  $B/api/s/default/rest/networkconf/$ID \
  -d '{"wan_dslite_remote_host":"2404:8e00::feed:101","wan_dslite_remote_host_auto":false}'

再起動について整理しておく。朝のフラッピングは UCG を再起動しても収まらず、ONU を電源から入れ直したら収束した。夕方の障害では ONU の再起動は効かなかった。2 日前の同じ症状では UCG を再起動して 10 分後に戻ったが、再起動が効いたのか偶然かは分からない。再起動は「効くこともある」程度で、AFTR の変更は「必ず即座に効く」。この差が原因を考える手がかりになった。

原因は何か

正直に書くと、根本原因は特定できていない。分かっている事実を並べる。

事実意味するところ
ICMP と UDP は通り、TCP の接続確立だけ拒否される経路(IPv6 トンネル)自体は生きている。セッション単位の何かが弾いている
朝の tcpdump では上流ホップ(14.0.9.198)が TCP の SYN にだけ ICMP admin-prohibited を返していたISP 側の装置が TCP を能動的に拒否している
夕方の host-unreachable の発信元は UCG の WAN 側(192.0.0.2)UCG が自分で諦めている。上流からの admin-prohibited を UCG が翻訳して返している可能性がある
AFTR を隣のアドレスに変えると 10 秒で直る別の装置に張り直すと解消する。ISP 側の広域障害ではない
3 回とも同じ AFTR(:100)のときに起きた。うち 2 回は夕方その AFTR 固有の問題か、混雑時間帯の負荷が疑わしい
宅内のセッション数は障害中も平常(開発機 19・監視機 3)こちらがポートを張りすぎているわけではない
再起動(ONU / UCG)は効いたり効かなかったりするトンネルの張り直しで向こう側の状態がリセットされることはあるが、確実ではない

ここから立つ仮説は、**「固定していた AFTR(の CGN 装置)が、うちのトンネルからの新規 TCP セッションを受け付けなくなることがある」**というものだ。DS-Lite では NAT を ISP 側の AFTR がやる。そこでセッションやポートの割り当てが上限に当たるか、装置側に不調があれば、ICMP と UDP は素通りで TCP だけ拒否、という今回の形になる。夕方に起きやすいのは混雑時間帯だからで、AFTR を変えると別の装置に張り直すので即座に直る。朝の「2 分落ちて 10 秒戻る」も、トンネルが張り直されるたびに一瞬だけ通り、すぐまた同じ装置に拒否される、と読めば辻褄が合う。この仮説は上の事実を全部説明できるが、ISP 側の中身は見えないので証明はできない。

transix には有償のポート拡張オプションがあるので、AFTR を回しても頻発するようなら、そのときに検討する。

運用上の教訓は「再起動より先に AFTR を変える」。再起動は家中の接続を巻き込むうえに効く保証がなく、AFTR の変更は 10 秒で結果が出る。

予防策: 「TCP だけ死んだら AFTR を回す」watchdog

対処が API 1 発・10 秒で済むと分かったので、その判断と操作を人から外した。常時稼働している Raspberry Pi 5(監視スタックが載っている)に、毎分走る systemd のタイマーを置いた。中身はシェルスクリプト 1 本で、Claude Code に書いてもらった。

動き

  1. 毎分、外部の 3 ホスト(1.1.1.1 / 8.8.8.8 / 9.9.9.9)へ TCP 443 の接続と ping を試す。DNS には依存させない
  2. **「TCP が全滅、かつ ICMP は 2 つ以上通る」**が直近 5 分のうち 3 分以上あったら発動。UCG の API で AFTR を次の候補(:100 → :101 → :102 → :100)に切り替える。連続ではなく回数で見るのは、朝のようなフラッピング(10 秒だけ戻る)でも拾うため
  3. 切替は 10 分に 1 回・1 日 6 回まで。切替後に ICMP まで落ちたら(2 回連続)、切替前の AFTR に戻す
  4. TCP が 3 分続けて戻った時点で、Slack に時系列つきで 1 本だけ報告する
  5. 毎回の計測値と切替回数を Prometheus に書き、Grafana のパネルとアラートで見る

設計で気をつけたこと

発動条件を「TCP だけ」に絞る。 回線が丸ごと落ちたときに AFTR を回しても意味がなく、むしろ設定を荒らすだけになる。ICMP が通っているという条件を付けることで、この障害の型だけに反応する。

通知は復旧後に送る。 障害中は Slack に届かないのだから、無理に送らない。切替のたびに文面をためておき、TCP が通った最初の実行でまとめて流す。「必ず届く」ことのほうが「即時に届く」ことより大事だった。届いた通知はこんな形になる。

✅ home/wan-tcp-watchdog: 自宅回線の TCP 不通を AFTR 切替で自動復旧(不通 4 分)
症状: ping・DNS は通るのに新規 TCP だけ不通(DS-Lite の AFTR 側)
時系列:
10:46 TCP 不通を検知(ICMP は 3/3 通る)
10:49 AFTR を 2404:8e00::feed:100 → 2404:8e00::feed:101 に切替(直近 5 分で 3 回不通)
10:52 TCP 復旧(3 分続けて疎通)
現在の AFTR: 2404:8e00::feed:101

暴走を止める。 上限を付けないと、AFTR が全部駄目な広域障害のときに 1 分ごとに設定を書き換え続ける。10 分に 1 回、1 日 6 回で止まる。

watchdog 自身が止まったことも検知する。 最後に走った時刻を Prometheus に書き、10 分止まったら Grafana が鳴らす。「監視が沈黙しているのを正常と読む」のが今回の教訓なので、値の監視には必ず鮮度の監視を添える。

中核の部分

スクリプトは 150 行ほどだが、判断の中核は次の数行に収まる。

# 1. 疎通の計測(TCP と ICMP を別々に数える)
tcp_ok=0; icmp_ok=0
for t in 1.1.1.1 8.8.8.8 9.9.9.9; do
  timeout 3 bash -c "exec 3<>/dev/tcp/$t/443" 2>/dev/null && tcp_ok=$((tcp_ok+1))
  ping -c1 -W2 "$t" >/dev/null 2>&1 && icmp_ok=$((icmp_ok+1))
done
outage=0; [ "$tcp_ok" -eq 0 ] && [ "$icmp_ok" -ge 2 ] && outage=1

# 2. 直近 5 分で 3 回以上なら、現在の AFTR の「次」に切り替える
AFTRS=(2404:8e00::feed:100 2404:8e00::feed:101 2404:8e00::feed:102)
cur=$(curl -sk -H "X-API-KEY: $K" $B/api/s/default/rest/networkconf/$ID | jq -r '.data[0].wan_dslite_remote_host')
next=${AFTRS[0]}
for i in "${!AFTRS[@]}"; do
  [ "${AFTRS[$i]}" = "$cur" ] && next=${AFTRS[$(( (i+1) % 3 ))]}
done
curl -sk -X PUT -H "X-API-KEY: $K" -H "Content-Type: application/json" \
  $B/api/s/default/rest/networkconf/$ID \
  -d "{\"wan_dslite_remote_host\":\"$next\",\"wan_dslite_remote_host_auto\":false}"

導入したその場で、検知を強制するフラグを立てて本番の経路を通した。実際に AFTR が :101 から :102 に切り替わり、TCP は途切れず、次の実行で「復旧」と判定して Slack に届いた。今は :102 で動いている。

まとめ

  • transix (DS-Lite) で「ping は通るのに Web だけ開かない」なら、新規 TCP だけが拒否される型を疑う。DNS を UDP と TCP で引き比べると一発で分かり、tracepath192.0.0.2 !H が出れば当たり
  • 「落ちたり戻ったり」の切り分けは、10 秒おきの計測を 2 拠点で同時に取る。同期して落ちれば上流、しなければ端末側
  • 直すのは再起動ではなく AFTR の変更。東日本なら :100 :101 :102 の隣に変えるだけで、10 秒で戻る。再起動は効くこともあるが保証がない
  • 根本原因は未特定。同じ AFTR で 3 回、うち 2 回は夕方に起きたこと、上流が TCP にだけ admin-prohibited を返していたこと、AFTR を変えると必ず即直ることから、固定先の AFTR(CGN)が新規 TCP を受け付けなくなるのが最有力の仮説
  • 確立済みの接続は切れないので気づきにくく、通知経路が TCP なら障害中はアラートも届かない。自宅回線の障害は自宅の監視から原理的に見えないので、復旧後にまとめて報告する設計にする。通知スクリプトは失敗を黙らせない
  • 対処が API 1 発なら、判断ごと自動化できる。発動条件を障害の型に絞る・上限を付ける・自分の鮮度も監視するの 3 点を守れば、常設しても怖くない