深度解析:基于 Netfilter 源码剖析 K8S 环境下 DNS 5秒超时的底层逻辑

凌晨两点半,办公室安静得只剩下机房排风扇隐约的低频共振。刚把一个长期潜伏在核心链路里的幽灵问题揪出来,趁着思路还清晰,把排查过程和底层逻辑沉淀下来。
问题表现很简单但极其折磨人:高并发的微服务集群中,业务监控偶尔会出现长尾请求,耗时精准地落在 5 秒出头。应用日志里没有任何异常报错,只是单纯的慢。
遇到这种特征极为明显的“5秒超时”,我的第一反应通常是网络协议栈底层的机制问题。由于微服务大量依赖 Kubernetes 内部的 CoreDNS 进行服务发现,这个 5 秒,大概率与 Linux 系统的 DNS 解析机制和 K8S 的网络模型脱不了干系。

现场抓包与症状剥离

在业务 Pod 所在的 Node 节点上,直接用 tcpdump 抓取对应的 DNS 报文:

tcpdump -i any udp port 53 -nn -w dns_trace.pcap

分析抓包文件,看到了经典的一幕:
1. 00.000000 业务 Pod 向 CoreDNS 发起 A 记录和 AAAA 记录解析请求。
2. 00.000000 两个 UDP 报文使用了相同的源端口(Ephemeral Port)。
3. 05.000512 业务 Pod 重新发起请求。
4. 05.000831 CoreDNS 正常返回响应。
为什么是 5 秒?这是 Linux 下 glibc 库中 resolv 模块的默认超时时间。
为什么丢包?这就得潜入到 Linux Kernel 的 netfilter 机制里去寻找答案。

潜入深水区:Conntrack 的并发竞争

在 K8S 环境中,Pod 访问 CoreDNS 的 ClusterIP,必然要经过 kube-proxy 维护的 iptablesIPVS 规则,进行 DNAT/SNAT 转换。而无论是哪种模式,底层都严重依赖 Linux 内核的连接跟踪模块:nf_conntrack
UDP 是无连接的,但 netfilter 为了实现 NAT,依然会为 UDP 维护一个虚拟的连接状态表(Conntrack Table)。
问题就出在 glibc 并发发送 A 和 AAAA 记录请求的设计上。这两个 UDP 报文在毫秒级的时间内从同一个 Socket(同源 IP、同源端口)发出。当它们到达宿主机内核的网络栈时,会触发如下流程:
1. 报文进入 nf_conntrack_in:内核发现这是一个新的 UDP “连接”,为其创建一个非确认状态的 conntrack 表项(unconfirmed tuple)。
2. 经过 NAT 模块:如果涉及到跨节点或者特定网络插件,报文会进行源地址或目的地址转换。
3. 调用 __nf_conntrack_confirm:在报文离开本机(POSTROUTING 链之后),内核尝试将刚才创建的表项正式插入到全局的 Conntrack Hash 表中。
此时,内核源码 net/netfilter/nf_conntrack_core.c 中的隐患被触发了:

/* net/netfilter/nf_conntrack_core.c (部分逻辑简化) */
int __nf_conntrack_confirm(struct sk_buff *skb)
{
    struct nf_conn *ct;
    struct nf_conntrack_tuple_hash *h;
    // ...
    // 计算 Hash 桶并加锁
    spin_lock_bh(&nf_conntrack_locks[hash]);
    // 核心竞争点:检查全局表中是否已经存在相同的 Tuple
    if (unlikely(nf_conntrack_tuple_taken(&ct->tuplehash[IP_CT_DIR_REPLY].tuple, net))) {
        // 如果冲突,直接跳转到丢包逻辑
        goto out;
    }
    // 正常插入 Hash 表
    __nf_conntrack_hash_insert(ct, hash, reply_hash);
    // ...
out:
    spin_unlock_bh(&nf_conntrack_locks[hash]);
    // 隐式丢包,不返回 ICMP,应用层毫无察觉
    nf_ct_drop_unconfirmed(ct);
    return NF_DROP;
}

当 A 和 AAAA 两个报文在多个 CPU 核心上并行处理时,第一个报文成功 confirm 并插入了全局 Hash 表。第二个报文在执行 nf_conntrack_tuple_taken 检查时,发现期望的五元组(Tuple)已经被第一个报文占用了(因为它们源端口相同,经过 NAT 后的端口也极大可能冲突)。
结果就是:内核判定状态冲突,默默将第二个报文 NF_DROP。应用层的 glibc 傻傻地等不到 AAAA 记录的响应,直到 5 秒后触发重试。
可以通过查看节点的 conntrack 统计数据来验证这个推论:

cat /proc/net/stat/nf_conntrack | awk '{print $8}' | grep -v insert_failed

如果发现 insert_failed 的计数在不断增加,基本就能坐实这个问题。

架构与配置级的规避方案

解决这个问题,不能指望去改内核的 conntrack 锁机制,那会引发更严重的性能退化。我们需要从架构和配置层面进行规避。
方案一:应用层规避(治标)
在 Pod 的部署配置中,修改 DNS 解析的行为。强制 glibc 顺序发送 A 和 AAAA 请求,或者强制使用不同的源端口。
在 Kubernetes 的 Deployment 中注入 dnsConfig

apiVersion: apps/v1
kind: Deployment
metadata:
  name: core-service
spec:
  template:
    spec:
      containers:
      # ...
      dnsConfig:
        options:
          - name: single-request-reopen

single-request-reopen 会让 glibc 在发送 A 和 AAAA 请求时,使用不同的 Socket(即不同的源端口),从而完美避开 conntrack 的 Tuple 冲突。
方案二:架构层规避(治本)
如果集群规模庞大,修改所有业务的 Yaml 显然不现实且容易遗漏。根本的解决之道是减少或彻底消除 DNS 请求在宿主机上的 NAT 行为。
引入 NodeLocal DNSCache。这已经是当前大规模 K8S 集群的标配架构。
通过在每个 Node 上运行一个 DaemonSet 级别的 DNS 缓存组件,并监听一个本地的 Link-local IP(如 169.254.20.10)。Pod 的 DNS 请求会直接发往这个本地 IP。
1. 本地请求不经过 kube-proxy 的全局 NAT 规则,避免了跨节点的 SNAT/DNAT 转换。
2. 即使发生 A 和 AAAA 的并发,由于不再经过复杂的 NAT 引擎,conntrack 冲突的概率被降到最低(甚至可以通过 iptables NOTRACK 直接绕过连接跟踪)。
对应的 iptables 规则通常会被 NodeLocal DNS 自动配置如下:

iptables -t raw -I PREROUTING -d 169.254.20.10/32 -p udp -m udp --dport 53 -j CT --notrack
iptables -t raw -I OUTPUT -d 169.254.20.10/32 -p udp -m udp --dport 53 -j CT --notrack

绕过 conntrack--notrack)是处理高并发无状态 UDP 报文最优雅的底层手段。

结语

很多时候,排查高并发系统下的偶发问题,就像在迷宫里摸黑拼图。监控面板上的一个毛刺,背后往往隐藏着内核机制、网络协议栈与容器架构的复杂碰撞。
不要畏惧底层代码,当所有表象都无法解释时,源码往往是最后也是最诚实的答案。
该合上笔记本了。重启了几个核心节点的组件,看着监控大盘上的 P99 延迟曲线平滑地降到了 10ms 以内,今晚的活儿算干完了。