目標網域有 AAAA 紀錄、容器沒有 IPv6 出口,Node 的 Happy Eyeballs 會卡死在 IPv6。curl 會自己退回 IPv4,所以用 curl 排查一定得到錯誤結論。
發布於 2026-09-06・約 3 分鐘
我的抓取排程跑在容器裡,每天半夜跑完會推一則 Telegram 通知。有一天我發現:它從來沒有成功推播過任何一則。
不是最近壞的。是從第一天起就沒成功過。因為發送失敗只 console.warn 不拋例外——這是我自己寫的,通知不該讓排程掛掉——所以沒有任何一個地方會告訴我。
console.warn
錯誤訊息一直都在日誌裡:
⚠️ Telegram 推播失敗:connect ETIMEDOUT
第一個反應是網路不通。進容器測:
$ docker exec gather curl -s -o /dev/null -w '%{http_code}\n' https://api.telegram.org 302
通的。而且同一個容器打別的 API(我另一組 AI 服務)一直都好好的。
於是我往程式碼查,懷疑是 token 錯、chat id 錯、axios 設定錯。全部查完都沒問題。
curl 是錯的測法。 它跟 Node 對「連不上」的處理方式不一樣。
api.telegram.org 有 AAAA 紀錄,也就是有 IPv6 位址。而我這台機器上的容器沒有可用的 IPv6 出口。
api.telegram.org
Node 從 v18 開始預設啟用 Happy Eyeballs(autoSelectFamily)。理論上它應該同時試 IPv4 跟 IPv6、誰先通用誰。實務上在「IPv6 位址解析得出來、封包送得出去、但永遠沒有回應」這種環境裡,它會卡在 IPv6 那條上等到 timeout,而不是快速退回 IPv4。
autoSelectFamily
curl 遇到同樣的狀況會自己重試 IPv4,所以你在同一個容器、對同一個網域,用兩個工具測會得到相反的結論。
實測數字(2026-08-29,同一個容器同一分鐘內):
預設(不指定 family) 3 次全部 ETIMEDOUT family: 4 3 次都在 0.3~0.9 秒回 302
強制 IPv4。用 axios 的話是傳一個指定了 family 的 agent:
family
const https = require('https'); const axios = require('axios'); // 這個容器沒有可用的 IPv6 出口,而 api.telegram.org 有 AAAA 紀錄。 // Node 預設的 Happy Eyeballs 會卡在 IPv6 上,實測 3/3 全部 ETIMEDOUT。 const ipv4Agent = new https.Agent({ family: 4, keepAlive: false }); await axios.post(url, body, { timeout: 10000, httpsAgent: ipv4Agent });
用內建 fetch 的話沒有 httpsAgent 這個口,改成在行程層級調 DNS 解析順序:
fetch
httpsAgent
const dns = require('node:dns'); dns.setDefaultResultOrder('ipv4first');
這個是全域的,會影響整個行程的所有連線。只想影響一支請求的話還是用 agent 比較乾淨。
也可以在容器層級關掉 IPv6(sysctl 或啟動參數),但那會影響到同一個容器裡所有東西,而且我不想為了一支通知去動網路設定。
sysctl
一、別的 API 正常,不能拿來排除網路因素。 問題只發生在有 AAAA 紀錄的目標上。我另一組服務的網域沒有 IPv6,所以它從頭到尾都好好的,這個「對照組」把我騙了很久。
要確認目標有沒有 AAAA:
$ getent ahostsv6 api.telegram.org $ dig +short AAAA api.telegram.org
二、用 curl 測「網路通不通」會得到錯誤結論。 要測 Node 的行為就用 Node 測:
docker exec gather node -e " require('https').get('https://api.telegram.org',{family:4},r=>console.log('v4',r.statusCode)) .on('error',e=>console.log('v4 err',e.code)); require('https').get('https://api.telegram.org',{family:6},r=>console.log('v6',r.statusCode)) .on('error',e=>console.log('v6 err',e.code)); "
兩條一起跑,答案就出來了。
三、「失敗不拋例外」的設計要配一個出口。 我當初讓通知失敗只印 console 是對的決定,通知確實不該讓主流程掛掉。錯的是沒有任何地方會回頭看那個 console。
現在的做法是讓發送函式回傳布林,呼叫端把「今天推了幾則、成功幾則」寫進排程的結果摘要裡。一個從來沒成功過的通知管道,在摘要上會是很顯眼的 0/1。
0/1
會靜默吞掉錯誤的東西,就是最需要被主動量測的東西。
標籤:Node.js、Docker、IPv6、網路、除錯