运行 Playwright 自动化测试时,我们遇到过一种很难稳定复现的失败:页面正在正常加载,Chrome 却突然报错:

net::ERR_NETWORK_CHANGED

它看起来像目标服务断网,但实际检查后发现:

  • 被访问的服务没有重启
  • Pod 本身的网络仍然可用
  • 同一时刻,Pod 内的容器引擎正在创建或销毁其他容器
  • 重试通常又能成功

真正变化的不是 Chrome 正在使用的网络,而是同一网络命名空间中的虚拟网卡。

为什么启动容器会影响 Chrome

在服务器运行 Docker 时,每次创建或销毁容器,都可能伴随这些操作:

  • 创建或删除 veth 设备
  • 给虚拟网卡增加或移除 IP 地址
  • 更新链路状态和路由信息

这些变化会通过 Linux Netlink 机制通知用户空间。Chrome 恰好也是这些通知的订阅者。

Chromium 在 Linux 上使用 AddressTrackerLinux 跟踪网络状态。初始化时,它会创建一个路由 Netlink 套接字:

netlink_fd_.reset(socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE));

随后订阅多组网络事件:

addr.nl_groups =
    RTMGRP_IPV4_IFADDR |
    RTMGRP_IPV6_IFADDR |
    RTMGRP_NOTIFY |
    RTMGRP_LINK;

因此,同一网络命名空间里任何网卡的地址或链路发生变化,都可能被 Chrome 观察到。事件再经由 NetworkChangeNotifierLinux 向网络栈传播,最终让正在进行的请求以 ERR_NETWORK_CHANGED 结束。

整个链路可以简化成:

创建或销毁容器
虚拟网卡、IP 或链路状态变化
内核发送 NETLINK_ROUTE 消息
Chrome 的 AddressTrackerLinux 收到变化
NetworkChangeNotifier 通知网络栈
正在进行的请求被中断

Chrome 的行为对桌面用户是合理的。例如从 Wi-Fi 切换到有线网络后,旧连接确实可能已经失效。但在 CI 中,其他容器产生的虚拟网卡变化通常与浏览器访问的目标毫无关系。

为什么禁用 IPv6 只能缓解

网络上常见的建议是禁用 IPv6。它有时有效,是因为减少了 IPv6 地址变更事件,但 Chrome 监听的不只有 IPv6:

RTMGRP_IPV4_IFADDR
RTMGRP_IPV6_IFADDR
RTMGRP_NOTIFY
RTMGRP_LINK

容器创建时产生的 IPv4 地址和链路事件仍然存在。因此,禁用 IPv6 只能减少触发次数,不能从机制上切断这条通知链路。

问题的关键不是 Chrome 能否访问网络,而是它是否需要感知这个环境中的动态网络变化。

查看 Chromium 的 AddressTrackerLinux::Init() 可以发现,当 NETLINK_ROUTE 套接字创建失败时,它会执行下面的分支:

netlink_fd_.reset(socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE));
if (!netlink_fd_.is_valid()) {
    PLOG(ERROR) << "Could not create NETLINK socket";
    AbortAndForceOnline();
    return;
}

AbortAndForceOnline() 会停止 Netlink 监听,并把当前连接类型设置为 CONNECTION_UNKNOWN。Chrome 仍然可以创建普通的 TCP、UDP 和 Unix Domain Socket,只是不再接收这一路网络变化通知。

所以可以把目标收缩为:

只让 socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE) 失败,其他 socket() 调用保持原样。

用 LD_PRELOAD 替换 socket()

Linux 动态链接器支持通过 LD_PRELOAD 预先加载共享库。如果共享库导出了与 libc 相同的 socket() 符号,动态链接器会优先使用这个版本。

实现只需要 31 行 C 代码:

#define _GNU_SOURCE
#include <stdio.h>
#include <sys/socket.h>
#include <linux/netlink.h>
#include <dlfcn.h>
#include <errno.h>

static int (*real_socket)(int, int, int) = NULL;

int socket(int domain, int type, int protocol) {
    if (domain == AF_NETLINK &&
        (type & SOCK_RAW) == SOCK_RAW &&
        protocol == NETLINK_ROUTE) {
        fprintf(stderr,
                "Intercepted and blocked socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE) call.\n");
        errno = EPERM;
        return -1;
    }

    if (!real_socket) {
        real_socket = dlsym(RTLD_NEXT, "socket");
        if (!real_socket) {
            fprintf(stderr,
                    "LD_PRELOAD critical error: could not find real socket() function.\n");
            errno = EFAULT;
            return -1;
        }
    }

    return real_socket(domain, type, protocol);
}

这段代码分成两条路径。

第一条路径只匹配目标调用:

domain  AF_NETLINK
type 判断命中 SOCK_RAW
protocol  NETLINK_ROUTE

匹配后返回 -1,并把 errno 设置为 EPERM,让调用方得到一次正常的系统调用失败。

第二条路径处理所有未命中的套接字。dlsym(RTLD_NEXT, "socket") 会跳过当前共享库,继续寻找下一个 socket() 实现,也就是 libc 中的真实函数。这样浏览器的正常网络请求不会被代理或修改。不过,这个拦截条件对整个进程生效,不只针对 Chromium 的 AddressTrackerLinux

编译与接入

把代码编译成位置无关的共享库:

gcc -shared -fPIC -o fake_netlink.so fake_netlink.c -ldl

启动 Chrome 前设置环境变量:

export LD_PRELOAD=/opt/netlink/fake_netlink.so

如果使用 Playwright,可以只把变量传给浏览器进程:

import path from 'node:path';

const browser = await chromium.launch({
  env: {
    ...process.env,
    LD_PRELOAD: path.resolve('netlink/fake_netlink.so'),
  },
});

这比在整个测试容器中全局导出 LD_PRELOAD 更容易控制影响范围。Chrome 的子进程会继承环境变量,因此它的多进程架构不需要额外处理。

如何确认拦截生效

启动 Chrome 后,标准错误中应该出现:

Intercepted and blocked socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE) call.

也可以使用 strace 观察原始行为:

strace -f -e trace=socket,bind chromium 2>&1 | grep NETLINK_ROUTE

未注入共享库时,可以看到 Chrome 成功创建 NETLINK_ROUTE 套接字。注入后,日志应显示目标调用被拦截,同时普通页面仍然可以访问。

结语

沿着 ERR_NETWORK_CHANGED 追到 Chromium 的 Netlink 监听后,我们用 LD_PRELOAD 屏蔽无关的网络变化,让容器环境中的自动化测试恢复稳定。