裸金属批量装机 500 台,40% 卡在 60 秒 DHCP 超时:dhcpd 的租约 fsync 阻塞 recv 循环,212KB UDP 缓冲被 500 次磁盘同步打爆

一、现象:iPXE 那个令人窒息的 040ee119

去年冬天,一个做 AI 训练平台的客户把 500 台裸金属托管在轻云互联的机柜里,走 iPXE + TFTP 全自动装机。第一次冷启动只上了 50 台,一切顺利。第二次一次性把 500 台 BMC 全部 set power on,问题来了——

iPXE 1.21.1+ (g4bd0)
net0: 3c:ec:ef:xx:xx:xx using undionly on PCI00:03.0
  [Link:up, TX:0 TXE:0 RX:0 RXE:0]
Configuring (net0 3c:ec:ef:xx:xx:xx)....................
No configuration methods succeeded (http://ipxe.org/040ee119)

其中约 40% 的机器报 040ee119,对应 iPXE 官方定义的 EADDRNOTAVAIL 家族里那个最讨厌的 DHCP 超时。而剩下能拿地址的机器,也会在 TFTP 拉 vmlinuz 时二次超时。整个装机窗口从 12 分钟被拖到 2 小时 40 分。

二、先排除三个假线索

  • 交换机广播风暴抑制:查了 access 口的 storm-control 计数,0 drop。排除。
  • DHCP 池子不够:range 10.20.10.1 10.20.10.1024,500 台机器绰绰有余,排除。
  • iPXE 编译问题:单台手测正常,prefix 与 make bin/undionly.kpxe EMBED=script.ipxe 都没问题。排除。

把怀疑锁定在 dhcpd 侧之后,第一件事不是改配置,是先让内核开口说话。

三、nstat 里的 12.8 万次 UdpRcvbufErrors

在 DHCP 服务器上(Debian 12,32C64G)跑:

$ nstat -az | grep -iE 'udp.*(error|drop|buf)'
UdpInDatagrams                 2849312            0.0
UdpNoPorts                     0                  0.0
UdpInErrors                    128742             0.0
UdpRcvbufErrors                128742             0.0

12.8 万次接收缓冲区溢出。这个计数器是内核 __udp_queue_rcv_skb() 里 sk_rmem_alloc > sk_rcvbuf 时 +1 的地方,非常明确——包到网卡了、到内核协议栈了,但 socket 层没人取,被丢掉。

再看 socket 分配了多大:

$ sysctl net.core.rmem_default net.core.rmem_max net.core.netdev_max_backlog
net.core.rmem_default = 212992
net.core.rmem_max = 212992
net.core.netdev_max_backlog = 1000

212992 字节,也就是 208KB。一个 DHCP DISCOVER 报文大约 300~500 字节(含 PXE 的 vendor-class identifier 会到 548 字节),208KB 顶多能缓存 400 个包。而 500 台机器同秒上电,DHCP DISCOVER 在二层泛洪再加上 iPXE 的重复重传,瞬时峰值轻松过千。缓冲区直接被打爆。

四、dhcpd 的 recv 循环到底在哪里断的

缓冲区被打爆只是结果。真正的问题是 dhcpd 消费不过来。用 strace -c -p $(pidof dhcpd) -f 挂了 10 秒:

% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 71.32    3.264100        3264      1000           fsync
 14.05    0.642900         642       1000           write
  8.21    0.375800         375       1000           pwrite64
  6.42    0.293800         293       1000           recvfrom

看到没,fsync 占了 71% 的 CPU 时间。ISC dhcpd 每次租约状态变更(DISCOVER → OFFER、REQUEST → ACK)都会触发 commit_leases(),而 commit_leases() 会对 lease 文件做一次原子写:写临时文件 + fsync + rename。默认 /var/lib/dhcp/dhcpd.leases 落在系统盘上。

单次 fsync 在机房常见的企业级 SATA SSD 上平均 3ms,500 台机器 × 2 次(OFFER/ACK)= 1000 次 fsync = 约 3.3 秒,socket 完全无人读取。

这就是根因。加上 iPXE 的重传策略——RFC 2131 建议 4 秒起点,指数退避,jitter 0~1s:4s → 8s → 16s → 32s,累计正好 60 秒——所以现象精确地卡在 60 秒这个数字上。缓冲区被冲爆的那几毫秒的窗口,恰好命中了第一轮 DISCOVER,第一次丢了就等 4 秒,后面每一轮又可能被新上电的机器继续冲击,最终整批收敛失败。

五、验证:把 lease 文件扔进 tmpfs

先做最小改动验证假设:

# 1. 停 dhcpd
systemctl stop isc-dhcp-server

# 2. 内存盘上放 lease
mkdir -p /dev/shm/dhcpd
cp /var/lib/dhcp/dhcpd.leases /dev/shm/dhcpd/
chown dhcpd:dhcpd /dev/shm/dhcpd/dhcpd.leases

# 3. 改 /etc/dhcp/dhcpd.conf
lease-file-name "/dev/shm/dhcpd/dhcpd.leases";

# 4. 内核侧把 UDP 缓冲拉大,重启后 socket 才会重新分配
cat >> /etc/sysctl.d/99-dhcp-tuning.conf <<'EOF'
net.core.rmem_default = 8388608
net.core.rmem_max     = 16777216
net.core.netdev_max_backlog = 65536
net.core.netdev_budget = 600
EOF
sysctl --system

systemctl start isc-dhcp-server

把 500 台机器重新 power cycle,超时率从 40% 掉到 0.3%。剩下 0.3% 是 BMC 本身没起来的问题。

这一步也顺带说个容易被忽略的点:net.core.rmem_default 是新建 socket 时继承的初值,已经存在的 dhcpd 进程不会因为你 sysctl 变了就自动调大,必须重启进程。很多人改完 sysctl 就以为生效,结果怀疑人生。

六、根治方案:Kea + HTTP Boot,别再吃 TFTP 的苦

ftmpfs 只是验证手段,生产环境不能这么干——服务器一掉电 lease 全丢,会引发更大规模的地址冲突。真正的三层改造:

1. 换 Kea,用 memfile 后端多线程

ISC 官方已经明确把 dhcpd 定为 EOL,Kea 从 1.9 起就有多线程支持:

{
  "Dhcp4": {
    "lease-database": {
      "type": "memfile",
      "persist": true,
      "name": "/var/lib/kea/kea-leases4.csv",
      "lfc-interval": 3600
    },
    "multi-threading": {
      "enable-multi-threading": true,
      "thread-pool-size": 4,
      "packet-queue-size": 512
    },
    "subnet4": [{
      "id": 1,
      "subnet": "10.20.0.0/16",
      "pools": [{ "pool": "10.20.10.1 - 10.20.10.2000" }],
      "next-server": "10.20.0.10",
      "option-data": [
        { "name": "boot-file-name", "data": "http://10.20.0.10/ipxe.efi" }
      ]
    }]
  }
}

关键点:lfc-interval 把全量 lease 文件重写降到每小时一次,日常操作走内存 + 追加日志,不会再有每租约一次 fsync 的问题。

2. UEFI 直接用 HTTP Boot,TFTP 只做兜底

裸金属这一代基本都是 UEFI 2.5+,option 60 = HTTPClient 直接认 HTTP 引导,绕开 TFTP 的 window-size 与 blksize 协商老化问题:

class "uefi-http" {
  match if substring(option vendor-class-identifier, 0, 10) = "HTTPClient";
  filename "http://10.20.0.10/ipxe.efi";
}

class "uefi-pxe" {
  match if option arch = 00:07 or option arch = 00:09;
  next-server 10.20.0.10;
  filename "ipxe.efi";
}

class "bios-legacy" {
  match if option arch = 00:00;
  filename "undionly.kpxe";
}

HTTP Boot 走标准 TCP,丢包有内核重传,比 TFTP 那个 lock-step 的 stop-and-wait 靠谱太多。500 台同时拉 3MB 的 ipxe.efi,nginx 配 20 个 worker,sendfile on,实测 8 秒全部完成。

3. 内核侧保留:

net.core.rmem_default = 4194304
net.core.rmem_max     = 16777216
net.core.netdev_max_backlog = 65536
net.ipv4.udp_rmem_min = 65536
net.ipv4.udp_mem     = 188883 251844 377766

七、落地清单

  • 别用 nstat 只看 tcp:UdpRcvbufErrors 才是这种"服务器看起来没负载但客户端就是超时"的第一现场。
  • 不要用系统盘存 lease:单机可以,批量上电场景下 fsync 会变成整个 DORA 流程的串行瓶颈。
  • iPXE 的 60 秒不是随机的:4/8/16/32 指数退避是设计使然,看到这个数字基本可以断定是首轮 DISCOVER 被丢。
  • 能上 HTTP Boot 就别折腾 TFTP:TFTP 的 window size 协商在 500 台并发下丢包率会非线性上升,不是调 tftp-hpa 的 -c 参数能救的。
  • 裸金属供应商的预装机能力要用起来:像轻云互联现在交付的裸金属可以走 Redfish API 直接下发 ISO 与 cloud-init,省掉整个 PXE 阶段——这种活儿在 500 台规模上能省你一个通宵。

说到底,裸金属自动化运维里最贵的一课不是"你不会调参数",而是"你不知道该看哪个参数"。内核计数器 nstat 和 strace -c 这两把刀,90% 的装机超时问题在两分钟内就能定位到层。