
本地起一个开发服务,日志里打印 Listening on http://localhost:3000,浏览器打开正常。同一台机器上用 127.0.0.1 去探活,连接被拒。这一步卡住过不少人的排查:服务明明在跑,端口也没被占。
把六种写法挨个跑一遍,每次都用 curl 从三个地址各探一次。下面所有输出是在本机跑出来的。
localhost 解析出来两个地址
解析结果决定了 bind 到哪里,先看这一步:
== 1. localhost 解析成什么 ==
1. ::1 family=6
2. 127.0.0.1 family=4
不带 all 时返回第一个:::1 family=6
/etc/hosts 里提到 localhost 的行:
# localhost is used to configure the loopback interface
127.0.0.1 localhost
::1 localhost
dns.lookup 返回的第一个是 ::1,第二个才是 127.0.0.1,不带 all 的时候只给第一个。/etc/hosts 里 localhost 有两条记录,一条 IPv4 一条 IPv6,两边的写法不同:IPv4 那条写在 127.0.0.1 后面,IPv6 那条写在 ::1 后面。
绑定地址决定谁能连上
六种写法的 server.address() 和三次探测的结果:
| 代码里的写法 | server.address() | curl localhost | curl 127.0.0.1 | curl [::1] |
|---|---|---|---|---|
listen({ host: 'localhost' }) |
::1 | HTTP 200 | 连不上 | HTTP 200 |
listen(0) |
:: | HTTP 200 | HTTP 200 | HTTP 200 |
listen({ host: '127.0.0.1' }) |
127.0.0.1 | HTTP 200 | HTTP 200 | 连不上 |
listen({ host: '::1' }) |
::1 | HTTP 200 | 连不上 | HTTP 200 |
先 setDefaultResultOrder('ipv4first') 再绑 localhost |
127.0.0.1 | HTTP 200 | HTTP 200 | 连不上 |
第一行就是开头那个现象。写 localhost 的代码,实际监听地址是 ::1,同一台机器的 IPv4 回环地址上没有进程在监听,curl 127.0.0.1 报的是连接失败而不是超时。原文这样:
== 2. 绑 localhost ==
代码里写的是:listen({ host: 'localhost' })
server.address() 报的是:::1 family=IPv6 port=63257
curl localhost -> HTTP 200
curl 127.0.0.1 -> 连不上(退出码 7)(7) Failed to connect to 127.0.0.1 port 63257 after 0 ms: Couldn't connect to server
curl [::1] -> HTTP 200
不给 host 的时候监听的是通配地址
listen(0) 里 server.address().address 是 ::,这是同时接受 IPv4 和 IPv6 的通配地址,三个探测全通:
== 3. 不给 host ==
代码里写的是:listen(0)
server.address() 报的是::: family=IPv6 port=63261
curl localhost -> HTTP 200
curl 127.0.0.1 -> HTTP 200
curl [::1] -> HTTP 200
开发环境里这是最省事的一种写法:不写 host,本机所有回环地址都能连。代价是它也接受来自本机以外网卡的连接,放到生产上要配合防火墙或者反向代理限制来源。
写死一个地址就只服务那一半
把地址写死成 127.0.0.1,IPv6 那侧连不上;写死成 ::1,IPv4 那侧连不上。两个方向都试了:
== 4. 只绑 127.0.0.1 ==
代码里写的是:listen({ host: '127.0.0.1' })
server.address() 报的是:127.0.0.1 family=IPv4 port=63265
curl localhost -> HTTP 200
curl 127.0.0.1 -> HTTP 200
curl [::1] -> 连不上(退出码 7)(7) Failed to connect to ::1 port 63265 after 0 ms: Couldn't connect to server
server.address() 报什么,就只有什么能连。这一条对写探活配置的人尤其重要:容器里的 healthcheck 常写成 curl 127.0.0.1:PORT,应用里绑的却是 localhost,两边各说各的地址,探活就一直失败,服务本身没问题。
想继续写 localhost,就改解析顺序
最后一组是先调用 dns.setDefaultResultOrder('ipv4first'),再照原样绑 localhost:
== 6. 先 setDefaultResultOrder(ipv4first) 再绑 localhost ==
代码里写的是:listen({ host: 'localhost' })
server.address() 报的是:127.0.0.1 family=IPv4 port=63274
curl localhost -> HTTP 200
curl 127.0.0.1 -> HTTP 200
curl [::1] -> 连不上(退出码 7)(7) Failed to connect to ::1 port 63274 after 0 ms: Couldn't connect to server
server.address() 这时变成 127.0.0.1,curl 127.0.0.1 通了,[::1] 换成连不上。同一个函数,换了解析顺序,绑定的地址就翻了个面。
顺带说一句版本:dns.lookup 按解析器返回的原样顺序给结果,这件事在较新的 Node 上是默认行为,设置 ipv4first 属于把旧行为要回来。本机只装了 Node 26,旧版本上的表现我没有实测,不猜。
三条改法各自的代价
最省事的是不写 host,让服务监听通配地址,本机怎么连都通,适合开发机。
想收紧一点就写死 127.0.0.1,只服务本机 IPv4 回环,别人连不上,前提是探活脚本和调用方也用这个地址。
还要保留 localhost 这个写法,就在进程启动最前面设一次解析顺序,改动最小,但影响的是整个进程里所有 dns.lookup 的调用,其他依赖解析地址的代码会跟着变。
倒过来看这件事,日志里那句 Listening on http://localhost:3000 说的是「我绑在了 localhost 解析出来的那个地址」,不是「本机所有地址都能连」。把日志打成实际监听地址更有用:
srv.listen({ host: 'localhost', port: 3000 }, () => {
const a = srv.address();
console.log(`listening on ${a.address}:${a.port} (${a.family})`);
});