登录
  • 人们都希望被别人需要 却往往事与愿违
  • 软件设计就像做爱, 一次犯错, 你要用余下的一生来维护@Michael Sinz

Node.js 18升级:双栈协议踩坑

编程 Benny小土豆 3831次浏览 6786字 1个评论
文章目录[显示]

很久之前我就发现了一个现象,有些时候我在使用 nc去探测本地的某些服务是否开启时,会有两个输出,第一次拒绝第二次成功,输出如下所示:

$ nc -v localhost 12345

nc: connectx to localhost port 12345 (tcp) failed: Connection refused
Connection to localhost port 12345 [tcp/italk] succeeded!

很奇怪,但是又不是不能用,就一直没去深究,甚至连一点点思考都没有。

前几天在把项目的 Node.js 从16升级到18时,突然发现能用 redbird 连不上后端的一个服务了。

于是我就继续 nc 了一下,使用 nc 是作为网络工程师的基本技能。结果输出和上面一样,第一次拒绝第二次成功。

想了几分钟突然懂了,我的服务只监听在了 IPv4的地址,并没有监听 IPv6的地址,而localhost 通常会同时解析为 IPv6的本地环回地址 ::1 和 IPv4的127.0.0.1 。同时对于大部分应用程序而言,在双栈的情况下会优先尝试 IPv6,如果失败那么再回落到 IPv4.

$ ping localhost

PING localhost (127.0.0.1): 56 data bytes
64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.115 ms
^C
--- localhost ping statistics ---
1 packets transmitted, 1 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 0.115/0.115/0.115/nan ms


benny@Yogurt ~
$ ping6 localhost
PING6(56=40+8+8 bytes) ::1 --> ::1
16 bytes from ::1, icmp_seq=0 hlim=64 time=0.053 ms

当时也没多想,猜测就是 redbird 由于什么原因只尝试了 IPv6 的地址。那就简单点改 hosts 让 localhost 只解析到 127.0.0.1就好了。

可是我没有 IPv6地址呀?
没关系,即便你没有被分配IPv6地址,只要没有明确禁用IPv6,如 net.ipv6.conf.all.disable_ipv6 = 1 那么 ::1 等就是有的!

这样改 hosts 确实管用,但是知其然,更要知其所以然……

在开始之前,我们需要了解几个概念

127.0.0.1 与 ::1

为表示本地计算机所保留的IP地址,对于 IPv4而言,其范围是 127.0.0.0/8,其地址范围为 127.0.0.0127.255.255.255,除去开头的0表示网络位和结尾255表示广播地址,其余所有地址都可以表示本机。

~: ping 127.255.255.254

PING 127.255.255.254 (127.255.255.254) 56(84) bytes of data.
64 bytes from 127.255.255.254: icmp_seq=1 ttl=64 time=0.028 ms
^C
--- 127.255.255.254 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.028/0.028/0.028/0.000 ms


~: ping 127.8.9.2
PING 127.8.9.2 (127.8.9.2) 56(84) bytes of data.
64 bytes from 127.8.9.2: icmp_seq=1 ttl=64 time=0.045 ms
64 bytes from 127.8.9.2: icmp_seq=2 ttl=64 time=0.039 ms

至少在 Linux下是这样的!127.0.0.1是常用的标准环回地址,其他地址并不是所有操作系统都支持。

对于 IPv6 来说,事情就简单了,一个 ::1 没有其他的花样。

如果我们的应用程序监听在 127.0.0.1,那么意味着只有本机,更准确的说来自于 127.0.0.1 的请求才会被接受;类似地,监听在 ::1 也是一样,意味着只有来自 ::1 的请求才会被接受。

我们可以使用 nc来做这样一个实验,k表示连接后不断开,l表示listen

nc -klv 127.0.0.1 12345

然后另开一个窗口 nc -v 127.0.0.1 12345

$ nc -v 127.0.0.1 12345
Connection to 127.0.0.1 port 12345 [tcp/italk] succeeded!
^C


benny@Yogurt: ~
$ nc -v ::1 12345
nc: connectx to ::1 port 12345 (tcp) failed: Connection refused

你看,虽然都是一台电脑,但是 ::1 却不行,如果你有 docker,docker0网桥的地址是172.17.0.1,开个容器试试

~ # ping 172.17.0.1
PING 172.17.0.1 (172.17.0.1): 56 data bytes
64 bytes from 172.17.0.1: seq=0 ttl=64 time=0.213 ms
^C
--- 172.17.0.1 ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max = 0.213/0.213/0.213 ms


~ # nc -vv 172.17.0.1 12345
nc: 172.17.0.1 (172.17.0.1:12345): Connection refused
sent 0, rcvd 0

也不行!因为请求不是从 127 来的而是172!如果想要从容器内可以访问,那么nc就要监听在网桥的地址,如 nc -klv 172.17.0.1

咱也不知道为啥要给loopback分配那么多地址,能把这些拿出来做其他的用途可能会更有趣。

0.0.0.0与 ::

这里的 0.0.0.0 不是在指默认路由,指的是本机所有的 IPv4地址,:: 类似地指的是本机所有的IPv6 地址。

当你的应用监听在 0.0.0.0时,所有的可达的IPv4地址都可以访问到。比如说,本机,docker 容器,你的路由器等等都可以。我们可以试试看nc -klv 0.0.0.0 12345

从MacBook连到 PVE 的虚拟机

[18:17:02] benny:~ $ nc -v 192.168.7.55 12345
Connection to 192.168.7.55 port 12345 [tcp/italk] succeeded!

从PVE的虚拟机的容器连网桥

~ # cat /etc/issue
Welcome to Alpine Linux 3.18
Kernel \r on an \m (\l)

~ # nc -v 172.17.0.1 12345
172.17.0.1 (172.17.0.1:12345) open

噢!有一点需要注意,如果你是 macOS或者Windows的话,由于这两者并不原生支持 docker,所以他们的docker实际上是跑在虚拟机里的。此时你 nc -v 172.17.0.1 也是不行的,因为这里172.17.0.1 代表的是虚拟机的网桥,而不是你的电脑的网桥。此时你需要 host.docker.internal这个内部域名。

Windows 的话,具体情况我没测试,因为有 Hyper V和WSL两种情况。

如果此时我们在本机通过 ::1 尝试连接

~ ➤ nc -v ::1 12345
nc: connectx to ::1 port 12345 (tcp) failed: Connection refused

当然不行了,nc都明确指明只监听 IPv4的,那么通过v6自然连不上

 

那么我们如果指定 nc 只监听 IPv6,那么是不是 nc -v 127.0.0.1 12345 就不行了呢?

nc -klv :: 12345

再开一个新窗口

~ ➤ nc -v 127.0.0.1 12345
Connection to 127.0.0.1 port 12345 [tcp/italk] succeeded!
^C

~ ➤ nc -v ::1 12345
Connection to ::1 port 12345 [tcp/italk] succeeded!

啊,竟然都可以,意味着无论是IPv4还是IPv6都可以连接!

nc -klv localhost 12345 呢?这取决于 localhost 的解析结果

这是为什么呢?

net.ipv6.bindv6only

这是 Linux 内核的一个参数,用于定义监听IPv6地址时的行为。默认情况下,这个值是0 禁用,也就意味着,如果你监听了:: 那么也相当于监听了 0.0.0.0

netstat 中我们可以看出

如果是 :: 的话

tcp6 0 0 :::12345 :::* LISTEN 1925847/nc

如果是 0.0.0.0的话

tcp 0 0 0.0.0.0:12345 0.0.0.0:* LISTEN 1926110/nc

如果我们通过 sysctl 启用这个参数

sysctl net.ipv6.bindv6only=1

然后再次监听 nc -klv :: 12345

再次通过 nc去连接 127.0.0.1

nc -v 127.0.0.1 12345
nc: connect to 127.0.0.1 port 12345 (tcp) failed: Connection refused

就会发现连不上了,因为此时只监听 IPv6的地址,对于IPv4的对应端口并没有程序在监听。

在绝大多数 Linux 发行版中, net.ipv6.bindv6only 的默认值都是0,也就意味着监听在 :: 也同样意味着监听在 0.0.0.0 ;换句话说,在这种情况下,看到 tcp6 :::port 也就意味着通过 IPv4 也可以连接上。

macOS也同样有这样的行为,至于Windows嘛……可能也是,我没有Windows的机器来做测试。

特殊情况

对于大多数应用程序,localhost 无非就是对应 ::1 和或 127.0.0.1 .但是MySQL是个例外,当你用 localhost 时,MySQL会通过 UNIX套接字(/var/run/mysqld/mysqld.sock) 去连接

小结

  • 监听 127.0.0.1 意味着只有从 127来的请求可以接受,::1 同理
  • 在大多数情况下 net.ipv6.bindv6only=0为默认值。 监听 :: 同时意味着监听0.0.0.0
  • 在未明确禁用 IPv6的情况下,即便你没有IPv6 地址,那么大多数情况下 localhost 也会被 hosts定义到 ::1127.0.0.1;并且你也能监听在 ::
  • MySQL的localhost是套接字
  • 如果同时解析到了IPv6和IPv4,那么应该优先 IPv6;如果IPv6连接失败,那么理想情况是应该回落到 IPv4,所谓“快乐眼球(Happy eyeballs)”算法

Node.js ???

你以为事情到这里就结束了吗?并没有。

明明应该支持快乐眼球算法的,但是后端只监听了 0.0.0.0 为什么 redbird的配置中写 localhost还是失败了呢?并且在使用 Node.js 16的时候没问题,18就有问题了。

重新使用 nc 监听到 0.0.0.0

nc -klv 0.0.0.0 12345

并且已经确认只有 IPv4可达,在 nc -v localhost 12345 时会先失败再成功

使用如下简单的 Node.js 代码来测试

const net = require("net");
const client = new net.Socket();
client.connect(12345, "localhost", function () {
    client.write(`node js ${process.version}\n`);
    console.log("Connected");
    process.exit(0);
});

Node.js 18升级:双栈协议踩坑

这是什么情况?

Node.js 18升级:双栈协议踩坑

$ for i in {12..21}; do nvm use $i && node test.js; done
Now using node v12.22.12 (npm v6.14.16)
Connected
Now using node v13.14.0 (npm v6.14.4)
Connected
Now using node v14.21.3 (npm v6.14.18)
Connected
Now using node v15.14.0 (npm v7.7.6)
Connected
Now using node v16.20.2 (npm v8.19.4)
Connected
Now using node v17.9.1 (npm v8.11.0)
node:events:505
      throw er; // Unhandled 'error' event
      ^

Error: connect ECONNREFUSED ::1:12345
    at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1195:16)
Emitted 'error' event on Socket instance at:
    at emitErrorNT (node:internal/streams/destroy:164:8)
    at emitErrorCloseNT (node:internal/streams/destroy:129:3)
    at processTicksAndRejections (node:internal/process/task_queues:83:21) {
  errno: -61,
  code: 'ECONNREFUSED',
  syscall: 'connect',
  address: '::1',
  port: 12345
}

Node.js v17.9.1
Now using node v18.18.2 (npm v9.8.1)
node:events:495
      throw er; // Unhandled 'error' event
      ^

Error: connect ECONNREFUSED ::1:12345
    at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1555:16)
Emitted 'error' event on Socket instance at:
    at emitErrorNT (node:internal/streams/destroy:151:8)
    at emitErrorCloseNT (node:internal/streams/destroy:116:3)
    at process.processTicksAndRejections (node:internal/process/task_queues:82:21) {
  errno: -61,
  code: 'ECONNREFUSED',
  syscall: 'connect',
  address: '::1',
  port: 12345
}

Node.js v18.18.2
Now using node v19.9.0 (npm v9.6.3)
node:events:491
      throw er; // Unhandled 'error' event
      ^

Error: connect ECONNREFUSED ::1:12345
    at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1532:16)
Emitted 'error' event on Socket instance at:
    at emitErrorNT (node:internal/streams/destroy:151:8)
    at emitErrorCloseNT (node:internal/streams/destroy:116:3)
    at process.processTicksAndRejections (node:internal/process/task_queues:82:21) {
  errno: -61,
  code: 'ECONNREFUSED',
  syscall: 'connect',
  address: '::1',
  port: 12345
}

Node.js v19.9.0
Now using node v20.10.0 (npm v10.2.3)
Connected
Now using node v21.2.0 (npm v10.2.3)
Connected

咳,不过是Node.js自从17就弄坏了快乐眼球算法,18、19也是坏着的,直到20才修好。16之前的版本全是好的。

18是LTS啊,哎,这难免有点太草台班子了吧。

参考资料

https://github.com/nodejs/node/issues/41625
https://github.com/nodejs/node/issues/40702


文章版权归原作者所有丨本站默认采用CC-BY-NC-SA 4.0协议进行授权|
转载必须包含本声明,并以超链接形式注明原作者和本文原始地址:
https://dmesg.app/nodejs-dual-stack.html
喜欢 (3)
分享:-)
关于作者:
If you have any further questions, feel free to contact me in English or Chinese.
发表我的评论(代码和日志请使用Pastebin或Gist)
取消评论

                     

去你妹的实名制!

  • 昵称 (必填)
  • 邮箱 (必填,不要邮件提醒可以随便写)
  • 网址 (选填)
(1)个小伙伴在吐槽
  1. 太棒了,学到好多!那个关于ip的部分写得好棒!
    萌新2023-11-26 21:38 回复