分类: 运维架构与SRE

  • 深入 Netfilter 陷阱排查:高并发 SNAT 引发的 nf_conntrack 满载丢包与 nftables Flowtable 加速实战

    核心结论:高并发网关出现 nf_conntrack: table full, dropping packet 导致 502 和延迟雪崩时,盲目调大 nf_conntrack_max 只会加剧 CPU SoftIRQ 负载。治本之策是针对内网信任流量在 raw 表下发 NOTRACK 规则,并全面向 nftables 迁移,利用 Flowtable 实现 Fast-path 连接绕过,能将网络栈包转发的 CPU 消耗降低 40% 以上。

    案发现场:网关节点的 99 线雪崩

    排查过程中,某 Kubernetes 生产集群的 Ingress/NAT 网关节点(Ubuntu 22.04, Kernel 5.15)遭遇突发性高延迟。业务侧反馈 API 接口 99 线延迟从 20ms 飙升至 3s 以上,并伴随大量 502 错误。

    登录节点,top 显示部分 CPU 核心的 si(SoftIRQ)长期被打满至 100%。敲下 dmesg -T | tail,系统日志里被一行致命报错刷屏:

    [Fri ...] nf_conntrack: nf_conntrack: table full, dropping packet
    [Fri ...] nf_conntrack: nf_conntrack: table full, dropping packet
    

    通过 sysctl net.netfilter.nf_conntrack_count 确认,当前连接跟踪表中的条目数已经逼近配置的 262144 上限。这是一个极其典型的 Netfilter 性能瓶颈。

    抓取 conntrack 现场状态进行分析:

    conntrack -L -o extended | awk '{print $4}' | sort | uniq -c | sort -nr
    

    输出显示,绝大多数记录处于 TIME_WAIT 和 UDP 的 UNREPLIED 状态。由于业务采用短连接模式,并发极高,NAT 模块强依赖 conntrack 表进行 Tuple 映射。一旦表满,内核网络栈别无选择,只能将新到达的 SYN 包直接 Drop,引发应用层的重传与超时风暴。

    为什么单纯调大 nf_conntrack_max 无法根治丢包问题?

    遇到这个报错,很多人的第一反应是无脑修改 /etc/sysctl.conf,将 nf_conntrack_max 调到 100 万甚至 200 万。这是极其危险的做法,等于饮鸩止渴。

    Netfilter 在内核中维护连接状态使用的是哈希表。这个哈希表的大小由 nf_conntrack_buckets 决定。如果你只放大了 max(最大连接数),而不按比例放大 buckets(哈希桶数量),会导致单个哈希桶下的链表变得极长。

    对于每一个经过网络栈的包,内核都需要遍历这条链表来查找匹配的五元组。当链表过长时,查找算法的时间复杂度从 O(1) 退化为 O(N)。更致命的是,修改 conntrack 记录需要加自旋锁(Spinlock)。高并发下,漫长的遍历加上激烈的锁竞争,会直接导致内核消耗大量 CPU 在软中断(ksoftirqd)上,系统 Load Average 飙升,网卡丢包率进一步恶化。

    合理的应急配置比例应当是 nf_conntrack_max = nf_conntrack_buckets * 4。 应急修复命令如下:

    # 1. 调整哈希桶大小 (必须先调桶,再调 Max)
    echo 262144 > /sys/module/nf_conntrack/parameters/hashsize
    
    # 2. 调大最大连接数和降低超时时间
    sysctl -w net.netfilter.nf_conntrack_max=1048576
    sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=300
    sysctl -w net.netfilter.nf_conntrack_tcp_timeout_time_wait=30
    

    但这仅仅是续命,并不能解决 CPU 开销问题。

    防御性降级:Raw 表的 NOTRACK 机制

    网关节点并非所有流量都需要进行 NAT。比如同网段的 Pod 到 Pod 通信,或者内部 Consul/Prometheus 的探测流量,完全可以跳过连接跟踪。

    在 iptables 时代,我们在 raw 表下发 NOTRACK 规则,将这部分信任流量提前短路:

    # 对于源和目的都在内网 10.0.0.0/8 网段的包,不进入 conntrack
    iptables -t raw -I PREROUTING -s 10.0.0.0/8 -d 10.0.0.0/8 -j CT --notrack
    iptables -t raw -I OUTPUT -s 10.0.0.0/8 -d 10.0.0.0/8 -j CT --notrack
    

    避坑指南:一旦给数据包打了 NOTRACK 标签,它在后续的 filter 表中就不属于 ESTABLISHED 或 RELATED 状态,而是 UNTRACKED。如果你的 INPUT 链默认策略是 DROP,且只有 -m state --state ESTABLISHED,RELATED -j ACCEPT,那么这些内网包会被统统丢弃。必须补上一条:

    iptables -I INPUT -m state --state UNTRACKED -j ACCEPT
    

    架构演进:全面向 nftables 与 Flowtable Fast-path 迁移

    iptables 最大的架构缺陷在于其规则的线性遍历机制(Linear evaluation)。哪怕你有一万条匹配规则,数据包也要从第一条匹配到最后一条。而 nftables 原生支持 Set 和 Map(类似字典),能在 O(1) 复杂度内完成匹配。

    更重要的是,从 Linux Kernel 4.16 开始,nftables 引入了革命性的 Flowtable 机制(软件 Fast-path),并在 Kernel 5.3 引入了硬件卸载(Hardware Offload)。

    在传统的 Netfilter 路径中,每一个包都要经历完整的 PREROUTING -> FORWARD -> POSTROUTING 链条。 启用 Flowtable 后,对于建立好的双向连接(ESTABLISHED),nftables 会直接在 Ingress 钩子处截获数据包,将其直接推送到对应的出口网卡(Egress),完全绕过 Netfilter 核心网络栈和复杂的路由查找。

    落地配置:构建基于 nftables 的加速网关

    首先,清空老旧的 iptables 规则,确保模块未被深度占用(此处需极度谨慎,确保断网可恢复)。编写 /etc/nftables.conf:

    #!/usr/sbin/nft -f
    
    flush ruleset
    
    table inet filter {
        # 1. 定义 flowtable,挂载在 ingress 钩子,监听内网 eth0 和外网 eth1
        flowtable fastpath {
            hook ingress priority 0;
            devices = { eth0, eth1 };
        }
    
        chain forward {
            type filter hook forward priority 0; policy accept;
    
            # 2. 只有处于 established 状态的连接,才卸载到 flowtable 加速
            ip protocol { tcp, udp } flow add @fastpath
    
            # 基础的连接跟踪允许
            ct state established,related accept
            ct state invalid drop
        }
    }
    
    table ip nat {
        chain prerouting {
            type nat hook prerouting priority 0; policy accept;
        }
    
        chain postrouting {
            type nat hook postrouting priority 100; policy accept;
    
            # 3. 对出公网的流量执行伪装 (SNAT)
            oifname "eth1" masquerade
        }
    }
    

    应用规则:

    nft -f /etc/nftables.conf
    

    性能验证: 应用 nftables Flowtable 后,再次在晚高峰压测。

    1. 查看 CPU si 开销,从之前的单核满载 100% 断崖式下降至 15% 左右。

    2. 观察 conntrack_count,虽然总条目仍然在 10 万级别,但大量的 established 包走的是 Fast-path,不再反复冲击 Netfilter 锁。

    3. P99 延迟从数百毫秒稳定回落至 5ms 以内,丢包彻底消失。

    常见问题 (FAQ)

    Q1:如何平滑从 iptables 迁移到 nftables,不导致生产环境长连接中断? A: 生产环境切忌直接 apt-get purge iptables。Linux 提供了兼容层 iptables-nft。首先替换底层后端,让 iptables 命令实际生成 nftables 规则。跑通后,使用 iptables-save > rules.txt,再通过 iptables-restore-translate -f rules.txt > rules.nft 转换为原生 nftables 语法。最后,在维护窗口 nft -f rules.nft 覆盖并原子化应用新规则集,连接跟踪状态在内核态是共享的,ESTABLISHED 状态的连接不会因此断开。

    Q2:启用 nftables Flowtable 后,为什么用 tcpdump 抓包,部分数据包神秘消失了? A: 这是正常现象。Flowtable 在 Ingress 阶段(网卡驱动收到包后极早期的阶段)就将包转发出去了,直接绕过了协议栈的大部分拦截点(包括 AF_PACKET)。这意味着处于 Fast-path 的数据包可能无法被 tcpdump 捕获到。如果你必须抓包排障,需要临时在 nft 规则中注释掉 flow add @fastpath,让流量回退到慢速路径(Slow-path)。

    Q3:masquerade 和直接写 snat to 性能上有差别吗? A: 有明显差别。masquerade 是专门针对动态 IP 设计的,每次处理 NAT 映射时,它都会去动态查询出口网卡当前的 IP 地址。如果你的网关是静态 IP,务必在 nftables/iptables 中使用 snat to 192.168.x.x 这样的明确指令。静态 SNAT 免去了查路由表获取本机 IP 的开销,在高并发场景下性能表现更好。

    Q4:为什么 UDP 流量也会引发 conntrack 爆满,UDP 不是无连接的吗? A: UDP 在协议栈确实无连接,但 Netfilter 是状态防火墙,它必须为 UDP 维持一个伪连接状态(包含五元组),否则无法进行返回包的 NAT 映射。DNS 解析(短 UDP 请求)是最常见的导火索。如果业务侧不复用 DNS 连接,海量的源端口会导致 conntrack 表被大量状态为 UNREPLIED (默认超时 30 秒) 的 UDP 条目塞满。解决方案是优化业务代码,或在内核调低 net.netfilter.nf_conntrack_udp_timeout_stream 和 timeout 参数。

  • 深入混沌工程内核:从 TC/eBPF 故障注入到 SLO 自动化验证实战

    混沌工程绝不是毫无章法的“拔网线”。本文直接拆解基于 Chaos Mesh (v2.6.2) 的底层故障注入原理(Linux tc 与 eBPF 机制),并给出一套将故障注入与 Prometheus SLO 报警集成的自动化 GameDay 验证闭环方案。记住:没有可观测性度量和自动恢复兜底的故障注入,纯粹是在搞破坏。

    为什么你的故障注入总是不及预期?深入 TC 与 eBPF 机制

    很多研发拿着现成的 YAML 一把梭,看到 Pod 报错就以为混沌实验成功了。但在真实的排查场景中,如果不清楚底层到底“烂”在哪个系统调用或网络栈层级,你根本无法验证微服务的超时重试和熔断机制是否真正生效。

    1. 网络延迟注入:Netem 与 Namespace 的戏法

    当你下发一个针对某个 Pod 的网络延迟(NetworkChaos)时,控制面并不会去修改交换机配置。底层的 chaos-daemon 会通过 Kubelet 拿到目标容器的 PID,然后利用 nsenter 钻进该容器的网络命名空间(Network Namespace),利用 Linux 内核自带的 Traffic Control (tc) 和 netem 模块进行流量整形。

    某次验证超时熔断时,发现注入 200ms 延迟后应用依然秒回。直接登录 Node,钻入目标 Pod 命名空间查看真实流控规则:

    # 获取目标 Pod 容器的主进程 PID
    PID=$(crictl inspect <container_id> | jq .info.pid)
    
    # 进入容器的网络命名空间查看 tc 规则
    nsenter -t $PID -n tc -s qdisc show dev eth0
    

    正常被注入延迟的网卡,你能看到类似如下的输出:

    qdisc netem 1: root refcnt 2 limit 1000 delay 200.0ms  10.0ms 25%
     Sent 10234 bytes 81 pkt (dropped 0, overlimits 0 requeues 0)
     backlog 0b 0p requeues 0
    

    如果输出是 qdisc pfifo_fast 0:,说明 tc 规则根本没打上。通常是因为 CNI 插件(如 Cilium 的某些 BPF 模式)绕过了宿主机的 veth pair,或者内核没有加载 sch_netem 模块(modprobe sch_netem 可解)。

    2. 磁盘 IO 故障:eBPF 对 VFS 的精准拦截

    早期的 IO 故障注入靠在容器里跑 dd 把磁盘带宽打满,这种做法极度粗暴,且容易引发宿主机的 IO 风暴,波及同节点其他核心 Pod(典型的爆炸半径失控)。

    现代混沌工程(如 Chaos Mesh 的 IOChaos)在内核态使用 eBPF 实现精准注入。要求宿主机内核至少在 4.17+(推荐 5.4+ 以获得稳定的 BPF 特性)。其原理是将一段 BPF 字节码挂载到内核的 VFS(虚拟文件系统)层面上,例如通过 kprobe 拦截 vfs_read 和 vfs_write 函数。

    当目标进程发起读写请求时,BPF 程序会被触发,强制在内核态 bpf_ktime_get_ns() 循环等待(制造延迟),或者直接修改系统调用返回值,返回 -EIO (Input/output error)(制造读写失败)。这种方式只针对特定 PID 和特定目录生效,彻底切断了对宿主机全局的干扰。

    SLO 验证闭环:用数据说话,拒绝肉眼盯盘

    GameDay(故障演练日)的核心不是制造恐慌,而是验证系统的容错边界是否符合 SLO(服务级别目标)。我们通常以 Error Budget(错误预算)消耗率为核心判定标准。

    在演练前,必须确保 Prometheus 中有定义严谨的 SLO 监控指标。例如,核心交易链路的 P99 延迟 SLO 定义为 200ms。

    # 记录规则:计算订单服务 P99 延迟
    record: job:request_latency:p99
    expr: histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{service="order-svc"}[1m])) by (le))
    

    在 GameDay 流程中,自动化脚本的逻辑应该是:

    1. 持续轮询拉取当前 P99 延迟,确认 Baseline 正常(如 50ms)。

    2. 下发 NetworkChaos,注入 150ms 延迟。

    3. 观测 P99 延迟指标是否在 1 分钟内攀升至 200ms 左右。

    4. 核心断言:断言上游 API Gateway 的 5xx 错误率是否上升。如果上游配置了合理的 100ms 超时和重试熔断,上游应用应触发熔断策略,而不会被下游彻底拖死导致线程池耗尽(防止级联雪崩)。

    GameDay 实战剧本:千万别忘了防御性恢复

    这里给出一个验证数据库主备切换的真实网络隔离注入配置片段。注意其中的 duration 和 mode 参数,这是防御性编程在混沌工程中的体现。

    apiVersion: chaos-mesh.org/v1alpha1
    kind: NetworkChaos
    metadata:
      name: db-partition-gameday
      namespace: sre-chaos
    spec:
      action: partition
      mode: fixed
      value: "1" # 仅影响 1 个目标 Pod(爆炸半径控制)
      selector:
        namespaces:
          - production
        labelSelectors:
          "app": "mysql-cluster"
          "role": "master"
      direction: both
      target:
        selector:
          namespaces:
            - production
          labelSelectors:
            "app": "order-service"
      # 极其重要:强制 60 秒后自动恢复。严禁在没有自动恢复时间的配置下执行演练!
      duration: "60s" 
    

    排查心法:演练过程中如果发现系统挂了且无法自愈,第一反应是直接删除 Chaos 资源(kubectl delete networkchaos db-partition-gameday -n sre-chaos)。如果 chaos-controller-manager 组件本身在这个时候假死卡住了,立刻在宿主机执行兜底恢复脚本: find /proc -maxdepth 1 -regex '/proc/[0-9]+' -exec nsenter -t {} -n tc qdisc del dev eth0 root 2>/dev/null \; (强制清理节点上所有的 tc 限制,简单粗暴但救命)。

    常见问题

    Q1: 生产环境做混沌实验,如果控制面(Controller)挂了,故障一直存在怎么兜底? 控制面宕机会导致 duration 到期后无法自动清理。成熟的落地方案必须在 Node 层面部署一层“看门狗(Watchdog)”。可以写一个 DaemonSet,每 10 秒去 APIServer 检查特定 Chaos 对象是否存在,如果 APIServer 超时无响应,或者 Chaos 对象已被标记删除但底层规则还在,DaemonSet 直接在本地执行 tc qdisc del 或 bpf-loader unload 强制清理底层规则,确保业务绝对存活。

    Q2: 使用 PodChaos 注入了 CPU 满载(Stress)故障,为什么进容器敲 top 命令看到的 CPU 使用率并没有飙升? 这是容器隔离性带来的经典视图问题。top 命令读取的是 /proc/stat,默认情况下容器内挂载的是宿主机的 /proc 系统(除非你使用了 lxcfs 这类用户态文件系统)。因此 top 看到的是整个宿主机的 CPU 状态。要确认容器是否被压满,应该在宿主机查看目标容器对应的 cgroup 统计指标:cat /sys/fs/cgroup/cpu/kubepods.slice/kubepods-pod.slice/cpuacct.usage_percpu。

    Q3: 注入 IO 故障后,为什么 Node 节点内核直接发生 Panic 重启了? eBPF 的能力虽然强大,但拦截诸如 vfs_read/write 属于非常底层的内核操作。在特定的 Linux 内核版本(尤其是一些云厂商魔改的 4.19.x 分支)中,bpf 钩子与系统现有的某些内核模块(如特定的存储驱动)会产生竞态条件。遇到内核 Panic,首先通过 kdump 捕获 vmcore,用 crash 工具查看堆栈调用树(Backtrace),通常能看到 bpf_prog_XXX 导致了空指针解引用。解决办法是:升级内核至稳定版(如 5.4.x),或改用相对高层的应用级注入方案。