前回、クォータが復旧した瞬間にジョブをどれだけ再実行すべきかを書いた話を書きました。今回はその数日前に起きた、もっと地味で厄介な話です ―― 自宅Wi-Fiが5時間死んでいたのに、ブラウザだけは平然と繋がっていた朝の記録です。
困りごと:ブラウザは繋がってるのにジョブだけ落ちる
2026-09-12の朝、無人ジョブが4本立て続けに落ちました。
| ジョブ | 時刻 |
|---|---|
com.lily.es-daily-rows |
05:01 |
com.lily.paid-note-pin-guard |
07:20 |
com.lily.line-column-gen |
08:40 |
com.lily.line-pdca |
08:41 |
エラーは Node fetch failed や python getaddrinfo の失敗。ところが同じ時間にChromeでブラウジングは普通にできていました。「ネットは繋がってるのに一部のプロセスだけ死ぬ」という、一番厄介なやつです。
unified logを掘ると、airportd が吐いている SlowWiFiDnsFailure が異常な件数出ていました。直近24時間で4,960件、05:43頃から本格化して06〜11時台は毎時1,000〜1,700件超、ピークは10時台の1,720件。12:38を最後にぴたりと止まっています。原因側の人間の操作は13:09、自宅Wi-Fiからスマホのテザリングへ手動で切り替えた瞬間でした。切替後にプローブを37回打つとfailed: 0、IPv4/IPv6とも lookup 60ms未満・connect 100ms未満に復帰しています。
犯人は「DNSの経路だけ」死んでいたことでした。Chromeは独自のDNS解決(DoH+キャッシュ)を持つため自宅ルータのDNSが腐っていても平然と動き、Node/pythonはシステムリゾルバ(=自宅ルータのDNS)を直撃するので刺さる。「ブラウザが繋がってる=ネットは正常」という直感が、そのまま診断のトラップになっていました。
踏んだ落とし穴(診断編)
-
symlinkのmtimeを見て「3週間前からテザリングだった」と誤読しかけた →
/etc/resolv.confはsymlinkで、mtimeがAug 15のまま。実体の/var/run/resolv.confのmtimeを見ると切替時刻(13:09:39)とぴったり一致し、誤読と判明。symlinkと実体、どっちのmtimeを見ているか常に意識する必要がある -
unified logのfacilityごとに保持ウィンドウが違う →
configdは05:54以降しか残っておらず、airportdは前日19時から残存。同じ時間軸で2つのfacilityを比較しているつもりが、片方だけ取りこぼしていた -
SlowWiFiDnsFailureという名前がミスリード → 「遅い」であって「死んでいる」ではない体でログに出るため、grepで異常検知するにも閾値判断が要る - Node fetch failed は同じ24時間で343件。ジョブの失敗時刻(05:01〜08:41)と
airportdのバースト開始(05:43〜)がズレて見えるのも、facilityの保持期間差が理由でした
この日は後から unified log を掘って「06:00-12:38に約5,000件」までは特定できました。でもそれができたのは、たまたまインシデントが目立つ規模だったからです。次に同じ現象がもっと短時間・低頻度で起きたら、unified logの保持期間を過ぎて何も残らない可能性があります。原因追跡を「後からlogを掘る」に依存しない仕組みが要る、というのがこの記事の本題です。
二度と後追いにしないために:net-probe.sh
作ったのは、5分ごとに「回線状態のスナップショット」を1行JSONLへ残し続けるだけのスクリプトです。Chromeもclaudeも使わず、20秒以内に収まることを目標にしています。
#!/bin/bash
# 5分毎の軽量ネットワークプローブ(Chrome/claude 不使用・目標20秒以内)。
# gateway / SSID / nameserver / dig 3ホスト / curl -4,-6 2ホスト / python getaddrinfo を
# 1行JSONで ~/.claude/logs/net-probe.jsonl へ追記する。
# 目的: Node fetch failed / python getaddrinfo 失敗が「どの回線・どのDNSで」起きたかを後から突合する。
肝は全部並列で撃つことです。system_profiler によるSSID取得だけで5秒かかるので、他の収集と同時にバックグラウンドへ逃がします。
# --- SSID は system_profiler が5秒かかるので並列 ---
( system_profiler SPAirPortDataType 2>/dev/null | awk '/Current Network Information:/{getline; gsub(/^ +| *:$/,""); print; exit}' > "$TMP/ssid" ) &
# --- DNS: システム順序で3ホスト + 各NSで oauth2 (すべて並列, 2秒×1回) ---
for h in oauth2.googleapis.com discord.com note.com; do
( dig +time=2 +tries=1 "$h" A > "$TMP/dig.$h" 2>&1 ) &
done
i=0
for ns in ${NS//,/ }; do
( dig +time=2 +tries=1 @"$ns" oauth2.googleapis.com A > "$TMP/digns.$i" 2>&1; echo "$ns" > "$TMP/digns.$i.ns" ) &
i=$((i+1))
done
dig は +time=2 +tries=1 で必ず2秒で切り上げ、curl は --max-time 6、pythonの getaddrinfo はスレッド化して join(5) で強制的に5秒で見切ります。全部タイムアウトを明示しないと、DNSが本当に死んでいるときにプローブ自体が刺さって次の5分枠に間に合わなくなるからです。
def f():
t = time.time()
try:
r = socket.getaddrinfo('oauth2.googleapis.com', 443)
res.update(ok=True, n=len(r), ms=round((time.time()-t)*1000))
except Exception as e:
res.update(ok=False, err=type(e).__name__+': '+str(e), ms=round((time.time()-t)*1000))
th = threading.Thread(target=f, daemon=True); th.start(); th.join(5)
if not res: res.update(ok=False, err='timeout>5s', ms=5000)
集めた断片は wait で全部揃うのを待ってから、bashではなくpythonで1行JSONへ組み立てます。文字列連結でJSONを手組みすると、SSIDに空白や特殊文字が入った瞬間に壊れるので、ここだけは json.dumps に任せています。
gw = os.environ['GW']
fail = sum(1 for d in dns.values() if d['status'] != 'NOERROR') + sum(1 for h in http.values() if h['http'] == 0) + (0 if py.get('ok') else 1)
rec = {
'ts': os.environ['TS'], 'gateway': gw, 'gateway6': os.environ['GW6'], 'iface': os.environ['IFACE'],
'ip4': os.environ['IP4'], 'ssid': rd(f'{T}/ssid').strip(), 'security': os.environ['SEC'],
'isIphoneHotspot': gw == '172.20.10.1', 'resolvers': [x for x in os.environ['NS'].split(',') if x],
'utun': int(os.environ['UTUN'] or 0), 'dns': dns, 'dnsPerResolver': per_ns, 'http': http,
'pyGetaddrinfo': py, 'failures': fail,
}
isIphoneHotspot はゲートウェイIPが 172.20.10.1(iPhoneテザリングの既定アドレス)かどうかで判定しています。ゲートウェイのIPを毎回目で見比べなくても、この1フィールドで「今どっちの回線か」が一発で分かるようにしています。failures は dns / http / pyGetaddrinfo の失敗数を足した集計値で、jsonlを grep '"failures":[1-9]' するだけで異常行を抜けます。
launchd:5分毎に静かに走らせる
StartInterval で固定間隔起動にし、優先度を下げてバックグラウンドに徹させます。
<key>StartInterval</key>
<integer>300</integer>
<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>
Nice 10 + LowPriorityIO で、他の自動化ジョブのCPU/IOを絶対に奪わないようにしています。ネットワークの生死を見張るジョブ自身がリソース競合の原因になったら本末転倒です。
4日間回してみた実測
導入から今日までで 662行溜まりました。
$ wc -l ~/.claude/logs/net-probe.jsonl
662
ゲートウェイ別の内訳は、自宅Wi-Fi(192.168.3.1)が594件、iPhoneテザリング(172.20.10.1)が68件。"failures":[1-9] にマッチする行は50件、662件中約7.6%でした。ゼロ件だった日もあれば、2026-09-13T09 だけで5件失敗が固まっている時間帯もあり、5分粒度で見ないと潰れていたであろうムラです。
1行はこういう形で残ります(実際の記録から抜粋)。
{"ts":"2026-09-16T20:28:45+0900","gateway":"192.168.3.1","iface":"en0",
"security":"WPA2_PSK","isIphoneHotspot":false,
"dns":{"oauth2.googleapis.com":{"status":"NOERROR","ms":17,"answers":5,"err":null},
"discord.com":{"status":"NOERROR","ms":17,"answers":11,"err":null},
"note.com":{"status":"NOERROR","ms":15,"answers":8,"err":null}},
"http":{"v4:discord.com":{"http":200,"lookup":0.004285,"connect":0.013422,"total":0.115357}, ...},
"pyGetaddrinfo":{"ok":true,"n":4,"ms":107},"failures":0}
突合せ:いつ・どの回線・どのDNSでを後から復元する
インシデント直後にまとめた network-flap-probe-2026-09-12.json には verdict: "dns-resolver-flap" と、原因の説明がそのまま残っています。
"verdictDetail": "症状の発生源は自宅Wi-Fiの DNS 経路。airportd が 06:00-12:38 に
SlowWiFiDnsFailure を約5,000件(毎時1,000-1,700件)記録しており、failing 4 ジョブと
fetch failed 343件はこの回線上で発生。13:09に人間が iPhone テザリングへ手動切替した後は
fault 0・全プローブ成功。Chrome 経由ジョブが通っていたのは Chrome が独自 DNS
(DoH/非同期リゾルバ+キャッシュ)を使い、Node/pythonはシステムリゾルバ
(自宅ルータ DNS)を直撃するため"
この日は手作業でここまで掘りましたが、net-probe.jsonl があれば同じ結論に5分粒度で機械的に辿り着けます。gateway フィールドで回線を、dnsPerResolver フィールドでどのDNSサーバーへの問い合わせがどう応答したかを個別に記録しているので、「いつ・どの回線・どのDNSサーバーで」失敗したかをフィールドの絞り込みだけで再現できます。手動での再現手順(router再起動、DNSを1.1.1.1/8.8.8.8に固定)が必要かどうかも、この記録を見れば都度判断できます。
まとめ
- Wi-Fiは繋がっていてもDNS経路だけが死ぬことがある。Chromeが平然と動くのは独自DNSキャッシュを持っているからで、正常性の証拠にはならない
- 原因追跡を「インシデント後にunified logを掘る」に依存すると、facilityごとの保持期間差やsymlinkのmtime誤読といった罠にはまる
-
net-probe.shは gateway / SSID / DNS / HTTP(v4,v6) / getaddrinfo を全部並列・タイムアウト付きで撃ち、20秒以内に1行JSONLへ追記するだけの薄い仕組み -
launchdのStartInterval=300+Nice 10+LowPriorityIOで、5分毎に他ジョブと競合せず静かに回る - 4日で662行、failures>0が50行(7.6%)。この蓄積があれば、次に同じ現象が起きても「後から掘る」ではなく「フィールドを絞る」だけで原因に到達できる
このjsonlを使って、実際に異常を検知したら自動でDiscordに通知し、必要なら回線を自動で切り替えるところまで育てたいと思っています。それはまた別の記事で。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- 制作物・記事は bokuwalily.com にまとめています🖥️
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
- AI導入・自動化の相談と実装テンプレ7本の配布は 公式LINE から💬
皆さんの ❤️ やシェアが励みになります!