• 别拿十年前的博客调优现在的容器:记一次由 tcp_tw_recycle 引发的血案

    看着窗外上午十点半的阳光,我原本打算喝完手里的这杯黑咖,顺便把上周搁置的底层存储架构图画完。结果监控大屏上突然跳出的刺眼红光,硬生生把我拉回了现实。
    核心交易链路的 API Gateway 突然报出大面积的 502 Bad Gateway,监控曲线上 Ingress 到后端业务 Pod 的建连超时率呈指数级上升。
    遇到这种突发流量下跌,第一直觉是后端服务被打挂了。但我切到业务容器的监控面板一看,CPU、内存水位平稳得像一潭死水,甚至连 Load 都没有明显波动。服务没挂,但流量进不去。
    我直接登上一台发生报错的 Kubernetes Worker 节点,挑了一个业务 Pod 的 IP,挂上 tcpdump 抓包:

    tcpdump -i any host 10.244.3.15 -nn -c 100
    

    屏幕上滚动的报文印证了我的猜想:大量的 SYN 包发向了 Pod 的 8080 端口,但后端业务容器就像个黑洞,一个 SYN-ACK 都没有回。网络层丢包了。
    是宿主机网卡满了?还是 conntrack 表爆了?我顺手敲了条命令检查系统网络统计:

    netstat -s | grep timestamp
    

    输出结果直接让我瞳孔地震:
    234561 passive connections rejected because of time stamp
    并且这个数字还在以每秒几千的速度疯狂飙升。
    看到这个提示,我已经知道问题出在哪了。我冷着脸敲下 sysctl -a | grep tcp_tw,果不其然,屏幕上赫然写着:

    net.ipv4.tcp_tw_recycle = 1
    net.ipv4.tcp_tw_reuse = 1
    

    我立刻在部门群里问了一句:“今天早上谁在生产环境的 Worker 节点上跑了内核参数修改的剧本?”
    过了一会儿,一个研发同事弱弱地冒泡,说他早上看压测报告,发现有大量的 TIME_WAIT 状态连接,觉得这会消耗系统资源,于是去网上搜了一篇“高并发内核优化指南”,顺手用 Ansible 把所有节点的 tcp_tw_recycle 给打开了,希望能“快速回收”这些连接。
    我深吸了一口气,把刚冲好的咖啡放在桌上。这种操作,放在十年前的物理机直连时代或许还能混过去,但在如今的云原生和容器化网络环境下,这简直是在给系统投毒。
    为什么说在 Kubernetes 环境下开启 tcp_tw_recycle 是一个极其愚蠢且不可原谅的错误?
    这里必须要把底层的技术逻辑扯清楚。tcp_tw_recycle 这个参数的设计初衷,是为了在开启 tcp_timestamps 的前提下,利用 PAWS(Protect Against Wrapped Sequence numbers)机制来快速回收 TIME_WAIT 状态的 socket。它的核心判断逻辑是:如果同一个源 IP 发来的新连接请求,其 TCP SYN 包里的 timestamp 值小于该源 IP 上一次通信的 timestamp,内核就会认为这是一个迟到的、过期的乱序包,并直接在底层静默丢弃(Drop)掉。
    听起来很完美对吧?但这位同事完全忽略了我们所处的网络拓扑。
    在 Kubernetes 中,无论是 Ingress 转发、Service 的 kube-proxy (iptables/IPVS),还是跨 Node 的 Pod 通信,都充斥着大量的 NAT(网络地址转换)和 SNAT(源地址转换)。
    当外部用户的海量请求经过 Gateway 或者 NAT 网关打到后端 Pod 时,在后端 Pod 看来,这些请求的源 IP 全部被替换成了同一个 IP(比如 NAT 网关的 IP 或 Node IP)。
    但是!这些请求实际上来自于无数个不同的真实客户端,它们各自机器上的系统时钟和 TCP timestamp 根本不可能是一致的!
    结果就是:后端 Pod 所在的宿主机内核,看到来自“同一个源 IP”的 SYN 包,其 timestamp 忽大忽小、疯狂乱跳。一旦某个请求的 timestamp 小于前一个请求的 timestamp,内核直接触发 PAWS 机制,将 SYN 包丢弃。前端 Gateway 收不到 ACK,只能不断重传,最终引发大面积的建连超时和 502 错误。
    更讽刺的是,稍微关注点 Linux Kernel 演进的人都会知道,由于 NAT 环境的普及,tcp_tw_recycle 这个参数引发的血案实在太多,Linus Torvalds 已经在 Linux Kernel 4.12 版本(2017年)中,直接把这个参数从内核代码里彻底删除了。
    我们生产环境之所以还能执行成功,仅仅是因为这批机器还在使用 CentOS 7 时代的 3.10 老内核。拿着 2017 年就被废弃的机制,去“优化” 2024 年的微服务架构,这种不加甄别直接复制粘贴技术博客的行为,是运维和架构领域的大忌。
    我没有在群里继续长篇大论,只是扔了一行命令让运维小哥立刻执行回滚:

    ansible worker_nodes -m shell -a "sysctl -w net.ipv4.tcp_tw_recycle=0"
    

    不到一分钟,监控大屏上的 502 报错断崖式下跌,业务全面恢复。
    最后的技术结论:
    面对高并发下的 TIME_WAIT 飙升,正确的处理思路永远是往上看,而不是往下瞎搞内核。
    1. TIME_WAIT 是 TCP 协议的正常状态,不是 Bug。 它的存在是为了保证全双工连接的可靠关闭和防止延迟报文污染新连接。
    2. 只要系统内存充裕,几万个 TIME_WAIT 对现代操作系统的消耗微乎其微。
    3. 如果真的觉得 TIME_WAIT 太多,去查应用层的连接池配置!检查 HTTP 客户端和服务器是否正确开启了 Keep-Alive,复用长连接才是解决短连接导致 TIME_WAIT 过多的根本途径。
    4. 在任何涉及 NAT / 负载均衡 / 容器网络的架构下,绝对、永远、不要开启 tcp_tw_recycle。开启 tcp_tw_reuse 配合 tcp_timestamps 已经足够应付绝大多数需要复用本地端口的场景。
    收拾完这个烂摊子,咖啡已经凉了。我重新敲开终端,把清理老旧系统参数的检测脚本加到了下周的巡检计划里。系统可以容忍高负载,但架构的稳定性,绝对不能建立在对底层原理一知半解的“优化”上。

  • 深度解析:高并发下 K8s Ingress 节点 `nf_conntrack` 锁竞争导致的 TCP 丢包排查实录

    凌晨三点半。刚把一个极其难缠的 P1 级别故障单关掉。这会儿机房外边漆黑一片,只有工位上的显示器还在亮着。咖啡早就凉透了,但我现在毫无睡意。
    今晚处理的这个问题,非常有代表性。很多在 K8s 环境下做高并发微服务架构的人,迟早会踩到这个坑里。不是应用代码的问题,也不是简单的 CPU、内存打满,而是 Linux 内核网络协议栈底层机制与现代容器网络(基于 netfilter/iptables 或 IPVS)碰撞出的暗礁。

    现场还原:幽灵般的 P99 延迟毛刺

    晚上十点左右,监控系统告警。核心交易链路的 API Gateway(部署在 K8s Ingress Controller 上)出现间歇性的 P99 延迟突刺。平时的 P99 在 20ms 左右,毛刺发生时直接飙到 3000ms 以上,甚至伴随少量的 502 和 504。
    排查初期的现象非常诡异:
    1. 查看 Node 的 CPU 和 Load,整体都在 40% 以下。
    2. 内存充裕,没有 OOM 迹象。
    3. 网卡带宽远未达到瓶颈(万兆网卡,跑了不到 2Gbps)。
    4. 应用层日志干干净净,没有任何 Error 或者 Full GC 停顿。

    抽丝剥茧:深入内核协议栈

    常规指标看不出问题,那只能向下钻,去查内核网络栈。这种偶发性的长尾延迟,大概率是发生了 TCP 重传。
    我直接登录到发生告警的 Ingress 节点,抓了一段包,同时用 nstat 查网络计数器:

    # 观察 TCP 异常丢包与重传指标的变化增量
    watch -n 1 'nstat -a | grep -iE "TcpExtTCPRetransFail|TcpExtListenDrops|TcpExtTCPReqQFullDrop|TCPSynRetrans"'
    

    果然,在延迟突刺的瞬间,TCPSynRetransTcpExtListenDrops 出现了明显的跳变。这说明三次握手阶段就出了问题,包被丢了。
    是半连接队列(SYN Queue)或者全连接队列(Accept Queue)满了吗?
    查了一下 ss -lnt,发现 Send-Q(应用的 backlog 设置)高达 4096,而当时的 Recv-Q 根本没跑满。系统级别的 net.core.somaxconnnet.ipv4.tcp_max_syn_backlog 早就调优过,不可能在这里卡住。
    如果不是 Socket 队列满,那包在进入 TCP 层之前就被干掉了。K8s 环境下,最可疑的就是 Netfilter 框架。
    我调出了 dmesg,想看看有没有经典的 nf_conntrack: table full, dropping packet 报错。结果出乎意料,系统日志里连个屁都没有。
    既然 table 没满,为什么会丢包?
    为了抓现行,我写了一段 bpftrace 脚本,直接挂载到内核的 kfree_skb tracepoint 上,看看是谁在扔我的包:

    // skb_drop_trace.bt
    #include 
    tracepoint:skb:kfree_skb
    {
        $skb = (struct sk_buff *)args->skbaddr;
        // 过滤掉正常的释放,只看异常丢包
        if (args->reason != 0) {
            printf("Time: %s, Comm: %s, CPU: %d, Location: %s, Reason: %d\n",
                   strftime("%H:%M:%S", nsecs),
                   comm, cpu, kstack, args->reason);
        }
    }
    

    跑了几分钟,抓到了致命线索。大量的丢包栈指向了同一个地方:__nf_conntrack_find_getnf_conntrack_in
    随后,我用 perf top -C <网卡中断绑定的CPU号> 看了一下当时的 CPU 消耗,发现 queued_spin_lock_slowpath 占据了该核近乎 60% 的 CPU 周期,而调用方正是 nf_conntrack 相关的函数。

    根因剖析:Hash 碰撞与自旋锁地狱

    到这里,真相已经大白了。这不是容量问题,而是并发锁竞争问题
    在 Linux 内核中,nf_conntrack 用于跟踪每个网络连接的状态,这是 K8s Service (无论是 iptables 还是 IPVS 模式) 实现 NAT 的基础。
    它在内核中维护了一个全局的 Hash 表。一条连接由一个 tuple(源 IP、源端口、目的 IP、目的端口、协议)通过 Hash 算法映射到具体的 Bucket 中。
    问题在于:
    1. Hash 冲突严重:如果 hashsize(Bucket 数量)太小,而并发连接数很高,大量的连接会挤在同一个 Bucket 的链表中。
    2. 锁的粒度太粗:在查询、插入或更新连接状态时,内核必须对该 Bucket 加自旋锁(spin_lock)。
    3. 软中断风暴:网络包的处理是在 NET_RX_SOFTIRQ 软中断上下文中进行的。当大批量的并发短连接(比如 HTTP/1.1 突发流量)涌入时,多个 CPU 核心同时在软中断里尝试获取同一个 Hash Bucket 的自旋锁。获取不到的 CPU 只能原地自旋(Spin),导致该核的 %si(软中断 CPU 使用率)瞬间飙升至 100%。
    在这个自旋的过程中,网卡送上来的新包堆积在 Ring Buffer 中来不及处理,引发 NAPI 轮询超时,最终导致底层的网卡驱动或者上层的 TCP/IP 协议栈主动丢包(Drop)。这就完美解释了为什么 TCP 会重传,以及为什么没有 table full 的日志。

    现场手术:调优与 Bypass

    解决这个问题,不能靠盲目扩容节点。需要对症下药。
    第一步:扩容 Hash 表规模,稀释锁竞争冲突率
    很多文档只教你调大 nf_conntrack_max,但这治标不治本。真正决定 Hash 冲突率的是 hashsize
    内核默认的 hashsize 通常较小(早期版本是 max 的 1/4,后来有些变成了 1/8)。我直接将其拉大。

    # 查看当前的 hashsize
    cat /sys/module/nf_conntrack/parameters/hashsize
    # 动态修改 hashsize (注意:这个值必须是 2 的 n 次方)
    # 我将 Bucket 数调大到了 4194304
    echo 4194304 > /sys/module/nf_conntrack/parameters/hashsize
    # 同步调整 max 的值,保持 max = hashsize * 2 (或者 *4)
    sysctl -w net.netfilter.nf_conntrack_max=8388608
    

    注:不要把这个值设得无限大,每个 Bucket 会占用少量不可交换的内核内存(slab 分配器)。8M 的 max 大约消耗几十 MB 内存,这对于现在的物理机来说九牛一毛。
    第二步:对于纯内网的高并发透传流量,直接 Bypass Conntrack
    即便调大了 Hash 表,对于 Ingress 这种千万级 PPS 的纯转发节点,Netfilter 依然是极大的开销。
    我们的业务特点是,部分流量是集群内互访的已知流量,不需要做复杂的 NAT 转换。
    利用 iptables 的 raw 表,我们可以让特定的流量提前打上 NOTRACK 标记,彻底绕开 nf_conntrack 机制:

    # 针对特定高并发的已知后端 IP 段,免除连接跟踪
    iptables -t raw -I PREROUTING -s 10.244.0.0/16 -d 10.244.0.0/16 -j NOTRACK
    iptables -t raw -I OUTPUT -s 10.244.0.0/16 -d 10.244.0.0/16 -j NOTRACK
    

    应用这两步之后,通过监控观察,P99 延迟立刻稳如死狗,死死压在了 25ms 以内,TCPSynRetrans 增量归零。

    总结

    干了二十年的系统底层,看了无数的技术演进。从裸机到虚机,再到如今号称“基础设施即代码”的 K8s,抽象的层级越来越高。很多年轻人以为只要 YAML 写得溜,就能把架构玩转。
    但现实往往是残酷的。当流量达到一定量级,那些被精美封装起来的底层机制——软中断、自旋锁、Hash 冲突、CPU 缓存失效,会像幽灵一样从内核深处爬出来,给你致命一击。不懂底层原理,面对这种毫无明显报错的丢包,只能是两眼一抹黑。
    四点钟了,监控曲线上再也没出现那个该死的尖刺。合上电脑,抽根烟,该睡觉了。明天还有明天的事。

  • Linux内核内存规整(Memory Compaction)引发的微服务P99毛刺排查剖析

    凌晨两点半,机房的白噪音在此刻显得格外清晰,刚切断了视频会议,端起已经冷掉的浓茶。
    过去这四个小时,我一直在追踪一个幽灵般的生产问题。核心链路上的一个高频Redis集群,每隔几小时就会出现一次毫无规律的P99延迟毛刺。正常的响应时间在2ms以内,但毛刺发生时,延迟会瞬间飙升到300ms甚至500ms。业务侧告警如暴雨般涌入,但当你去查监控大盘时,网络带宽没跑满,IOPS在水位线下,CPU使用率平均不到40%,DBA查了Redis的Slowlog,干干净净。
    这种现象,新手通常会归咎于“网络抖动”然后草草结案。但在我的经验里,一切无法复现的偶发性系统停顿(Stall),大概率都藏在内核的内存管理子系统或者调度器里。

    现场捕获:跳出指标监控的盲区

    Prometheus这种秒级采集频率的监控,对于毫秒级的毛刺是无能为力的。毛刺发生的时间窗口极短,平均值会将这些尖刺完全抹平。
    我直接挂载了eBPF工具集。既然Redis的请求被拖慢了,那必然是Redis的工作线程在某个地方被挂起(Block)了。排查这类问题,offcputime 是最直接的手术刀,它能精准剖析线程不在CPU上运行的时长和内核调用栈。
    我写了一个简单的BCC脚本挂在宿主机上,专门抓取Redis进程离开CPU超过50ms的堆栈:

    /usr/share/bcc/tools/offcputime -p $(pgrep -f redis-server) -K -m 50
    

    蹲守了将近一个小时,屏幕上终于吐出了一段让我豁然开朗的内核栈:

        __schedule
        schedule
        schedule_timeout
        wait_iff_congested
        shrink_inactive_list
        shrink_lruvec
        shrink_node
        do_try_to_free_pages
        try_to_free_pages
        __alloc_pages_slowpath
        __alloc_pages_nodemask
        alloc_pages_vma
        do_huge_pmd_wp_page
        __handle_mm_fault
        handle_mm_fault
        __do_page_fault
        do_page_fault
        page_fault
        -                redis-server (pid 18233)
            312.450 ms
    

    堆栈清晰地表明,Redis单线程在处理请求时,触发了缺页中断(page_fault),进而进入了 __alloc_pages_slowpath(内存分配慢速路径),最终被阻塞在了 try_to_free_pages(直接内存回收)上,整整卡顿了312毫秒。

    深度剖析:THP与直接内存回收的绞杀

    为什么一个内存充足(Free + Cache 还有几十GB)的宿主机,会触发直接内存回收(Direct Reclaim)?
    这就不得不提 Linux 的 Transparent Huge Pages (THP, 透明大页) 机制。为了减少 TLB Miss,内核默认尝试为匿名内存分配 2MB 的大页,而不是标准的 4KB 小页。
    在这个场景中,Redis 在进行 BGSAVE 或者大规模写入时,触发了写时复制(COW)或新的内存分配,内核的 do_huge_pmd_wp_page 尝试分配一个 2MB 的连续物理内存页。
    但由于这台 Kubernetes Node 已经连续运行了半年,容器频繁的创建销毁,加上大量的文件读写,导致物理内存极度碎片化。系统里虽然有几十GB的空闲内存,但找不到连续的 2MB 物理页框
    此时,内核的内存分配策略由 /sys/kernel/mm/transparent_hugepage/defrag 决定。如果是 always(某些老发行版的默认值),内核为了凑齐这 2MB,会毫不犹豫地挂起当前的用户态线程,同步执行直接内存规整(Direct Compaction)直接内存回收(Direct Reclaim)
    内核就像一个在杂乱无章的仓库里整理出完整货架的搬运工,需要不断地迁移现有的活动页面(Page Migration),驱逐文件缓存(Page Cache)。而在这个漫长的过程中,Redis 的主线程只能绝望地等待(wait_iff_congested),导致这期间到达的所有网络请求全部超时。

    破局与根治:重塑内存分配规则

    查明了机制,解决起来不过是几行配置的事。但作为运维架构,不能头痛医头。
    第一步:线上止血,禁用THP与规整
    对于 Redis、数据库这类极其注重长尾延迟的中间件,THP 带来的 TLB 性能提升,远不足以抵消它引发内存规整造成的灾难性毛刺。
    直接在宿主机层面关闭 THP,并将碎片整理策略改为 madvise,仅允许明确声明需要大页的进程使用:

    # 立即生效
    echo never > /sys/kernel/mm/transparent_hugepage/enabled
    echo madvise > /sys/kernel/mm/transparent_hugepage/defrag
    # 持久化到 GRUB_CMDLINE_LINUX
    # transparent_hugepage=never
    

    第二步:调整水位线,防御突发内存分配
    即便关了 THP,常规的小页分配在极端情况下依然可能触发 Direct Reclaim。内核中 kswapd(异步回收线程)的唤醒阈值由 vm.min_free_kbytes 控制。如果这个值太小,突发的大量内存申请会瞬间击穿水位线,导致 kswapd 来不及回收,应用线程被迫下场同步回收。
    我检查了这台 256GB 内存的宿主机,min_free_kbytes 竟然是默认的 90MB。这极其不合理。我将其调整至系统总内存的 1%~2%(约 3GB),给 kswapd 留出足够的异步作业缓冲带。

    sysctl -w vm.min_free_kbytes=3145728
    # 同时优化 watermark_scale_factor,让 kswapd 尽早介入
    sysctl -w vm.watermark_scale_factor=50
    

    第三步:K8s QoS 与 Cgroup 限制
    在 K8s 环境下,Pod 级别的 Memory Limit 只能限制 Cgroup 内的内存使用,但内核的页面碎片化是全局(Node-level)的。这意味着,即使你的 Redis Pod 内存远未达到 Limit,同宿主机的其他日志采集组件(如 Filebeat/Fluentd)刷 Page Cache 造成的碎片,依然会通过底层系统调用拖死你的 Redis。
    因此,对于核心 DB 类 Pod,必须使用 Guaranteed 的 QoS 类别,绑定独占的 NUMA 节点,同时在 kubelet 配置中开启 --topology-manager-policy=single-numa-node,从硬件拓扑层面隔离内存访问延迟。
    问题解决,图表上的P99线条重新变成了一条平滑的直线。
    排查系统底层的偶发性问题,从来就没有什么玄学。它靠的不是盲目的重启和扩容,而是对 Kernel 源码机制的敬畏,以及能否在枯燥的十六进制调用栈中,敏锐地抓住那个正在阻塞业务的自旋锁。
    合上电脑,天快亮了。

  • 凌晨三点的OOM惨案:论为什么不要在容器里动 oom_score_adj

    凌晨3点15分,桌上的咖啡早就凉透了。Prometheus的报警把一块屏幕映得通红,K8s集群里一台核心Node节点状态直接变成了 NotReady
    尝试直接SSH连上去,终端卡死在 ssh_exchange_identification。毫无响应。这种级别的挂死,连句柄都分配不出来,没办法,只能切进带外管理(IPMI),强行发送重置指令给服务器硬重启。
    机器重新起飞后,我第一时间切进去捞 /var/log/messagesdmesg 的尸体。满屏的 Out of memory: Killed process。这在K8s环境里本是家常便饭,无非是哪个业务内存泄露,或者突发流量打爆了堆内存,触发了Cgroup的 memory.limit_in_bytes
    但越往下看,我的眉头皱得越紧。
    被OOM Killer干掉的并不是业务进程。先倒下的是 systemd-journald,紧接着是 sshd,最后连 kubeletcontainerd 都未能幸免。整个宿主机的基础设施被屠戮殆尽。而那个跑着祖传Java单体应用、堆外内存泄露把整机内存吃光的一个Pod,竟然稳如泰山地活到了宿主机彻底失联的前一秒。
    这违背了最基本的操作系统常识和K8s资源管理逻辑。我顺藤摸瓜去查这个Deployment的YAML,看到了一段让我血压飙升的配置:

    securityContext:
      privileged: true
    lifecycle:
      postStart:
        exec:
          command: ["/bin/sh", "-c", "echo -1000 > /proc/1/oom_score_adj"]
    

    不知道是哪个自作聪明的开发(或者半吊子运维),因为解决不了这个Java应用频繁的内存泄露,又不想总是半夜被Pod Restart的报警吵醒,居然想出了这种“绝妙”的偏门手段。申请了特权模式(privileged: true),然后在 postStart 钩子里,硬生生把容器主进程的 oom_score_adj 改成了 -1000
    只要稍微翻过Linux内核 mm/oom_kill.c 的源码,就该知道 -1000 这个魔术数字意味着什么。
    内核在发生全局内存耗尽(Global OOM)时,会调用 oom_badness() 函数给每个进程打分(oom_score)。基础分数基于进程的驻留集大小(RSS)、页表和交换缓存的使用量。占得越多,分数越高,越容易被杀。但在此基础上,内核提供了一个决定性的调节值:oom_score_adj
    这个调节值的有效范围是 -10001000。当你把它设为 -1000 时,等于触碰了内核硬编码的底线宏 OOM_SCORE_ADJ_MIN。你就是在告诉内核的OOM Killer:“这块拥有免死金牌,哪怕系统崩溃也绝对不要杀它”。
    在K8s的QoS(服务质量)体系里,kubelet 原本会精细地管理这些分数:
    kubeletcontainerd 等核心系统守护进程,为了保命,分数通常被设定在 -999
    Guaranteed 级别的Pod(Requests == Limits),分数是 -997
    BestEffort 级别的Pod,分数直接拉满到 1000,一旦资源紧张,最先被献祭。
    而这家伙,直接给一个破绽百出的业务进程赋了 -1000。它的优先级居然比宿主机底层的 kubelet 还要高。
    最终的现场就是:当这个Java应用无休止地吞噬宿主机内存,耗尽了所有物理内存,触发系统级OOM时,内核的OOM Killer苏醒了。它提着刀巡视了一圈,发现罪魁祸首挂着 -1000 的免死金牌,动不了。于是只能含泪把旁边无辜的 kubeletsshd 等核心进程一个个砍死,以求释放哪怕几兆的内存。核心进程一死,节点处于假死状态,控制面将Node标记为 NotReady,进而引发网络分片和路由黑洞。
    用破坏内核保护机制的代价去掩盖应用层拙劣的代码缺陷,这是架构演进中最愚蠢的妥协。
    我已经把这个Pod的特权模式强行剥夺,剔除了所有 lifecycle 钩子,并加上了极其严格的 resources.limits。至于那个接下来必然会不断被 OOMKilled 状态重启的应用,让业务研发自己去挂载卷抓 Heap Dump 分析吧。
    技术结论:永远不要在用户态的业务进程中使用 oom_score_adj = -1000。在云原生架构下,资源隔离的底线在于Cgroup机制和K8s QoS策略。特权容器的滥用加上对内核OOM打分机制的篡改,等同于将整台宿主机的生命周期交托给了一个随时会引爆的内存炸弹。系统运维的核心是控制爆炸半径,而不是给炸弹穿上防弹衣。

  • 记一次 K8s 环境下高频偶发丢包的底层排查:基于 Netfilter 源码与 eBPF 的追踪分析

    凌晨三点半,刚把一杯已经彻底冷掉的黑咖灌下去。就在十分钟前,我切断了线上故障排查会议的语音。问题解决了,但排查过程值得复盘。
    这是一个潜伏得很深的连环雷。业务团队反馈,在晚高峰期间,K8s 集群内的微服务调用偶尔会出现 1 秒以上的延迟,部分请求直接抛出 502 或 Timeout。总体报错率不高,大概在万分之五左右,但由于是核心交易链路,上游重试导致了不小的雪崩隐患。
    很多人遇到这种偶发网络抖动,第一反应是去查宿主机的 CPU 负载,或者是看网卡的 Ring Buffer 有没有溢出。我看过监控,CPU Steal Time 正常,netstat -s | grep -i listen 也没有增加的 ListenOverflowsListenDrops。不是应用层的 backlog 打满,问题出在更底层的网络栈里。
    我登录到其中一台报错最频繁的 Node 节点,直接抓包:

    tcpdump -i any -nn -S host 10.244.3.15 and port 8080
    

    抓包结果显示了一个典型的现象:客户端发送了 SYN,但服务端没有回 SYN-ACK,大约 1 秒后客户端触发了超时重传(Retransmission),第二次 SYN 过去后,连接瞬间建立。
    首包静默丢弃。在 Linux Kernel 网络栈中,这种情况如果排除了上层应用的 Accept 队列满,大概率是 Netfilter 搞的鬼。
    按照常规思路,查一下内核日志:

    dmesg -T | grep -i conntrack
    

    干干净净。没有那句经典的 nf_conntrack: table full, dropping packet。说明当前连接数并没有触及 net.netfilter.nf_conntrack_max 的上限。
    既然不是容量问题,那就是逻辑丢包。Netfilter 的全景图中,丢包最严重的地方往往在 NAT 和 Conntrack 的交互处。K8s 默认的 kube-proxy(无论是 iptables 还是 ipvs 模式),以及 CNI(如 Flannel/Calico)大量使用了 SNAT 和 DNAT。
    为了精准定位到底是内核哪个函数把包 Drop 掉了,我写了一个简单的 bpftrace 脚本,直接 Hook 内核的 kfree_skb(内核释放网络数据包的核心函数),并过滤掉正常的释放逻辑,只抓取异常丢包:

    #!/usr/bin/env bpftrace
    #include 
    #include 
    #include 
    kprobe:kfree_skb
    {
        $skb = (struct sk_buff *)arg0;
        $iph = (struct iphdr *)($skb->head + $skb->network_header);
        $tcph = (struct tcphdr *)($skb->head + $skb->transport_header);
        // 过滤 IPv4 且为 TCP SYN 包
        if ($iph->version == 4 && $iph->protocol == 6 && $tcph->syn == 1 && $tcph->ack == 0) {
            printf("Time: %s, SYN dropped at %s\n", strftime("%H:%M:%S", nsecs), kstack);
        }
    }
    

    跑了大约十分钟,屏幕上终于弹出了几条堆栈信息。关键的调用链如下:

    Time: 02:14:32, SYN dropped at
        kfree_skb+1
        nf_conntrack_confirm+0x9c
        ipv4_confirm+0x4a
        nf_hook_slow+0x44
        ip_local_deliver+0x8b
        ...
    

    看到 nf_conntrack_confirm,破案了一半。
    在 Linux 内核源码 net/netfilter/nf_conntrack_core.c 中,__nf_conntrack_confirm 函数负责在数据包即将离开 Netfilter 系统(比如 POSTROUTING 或 LOCAL_IN 阶段)时,将之前在 PREROUTING 阶段建立的、处于 unconfirmed 状态的连接记录,正式插入到系统的 Conntrack Hash 表中。
    它为什么会失败并返回 NF_DROP
    在 K8s 高并发场景下,同一个 Node 上的多个 Pod 如果同时通过 SNAT(例如访问集群外的服务,或者经过 Service 的 NodePort 转发)发起连接,Netfilter 需要为它们分配源端口(Source Port)。分配端口发生在上半部的 NAT 阶段,但确认插入 Hash 表发生在下半部的 Confirm 阶段。
    这里存在一个经典的 Race Condition(竞态条件):
    1. 包 A 和包 B 几乎同时到达,目标 IP 和端口相同。
    2. 在 NAT 阶段,系统为包 A 寻找了一个未被使用的五元组(假设分配了源端口 30001),为包 B 也进行了分配。由于此时包 A 的五元组还没进 Hash 表,系统认为 30001 还是空闲的,于是也可能把 30001 分配给包 B。
    3. 当到达 nf_conntrack_confirm 阶段时,包 A 抢先一步插入 Hash 表。
    4. 包 B 尝试插入时,内核调用 nf_conntrack_hash_check_insert,发现五元组冲突(insert_failed),于是直接将包 B 标记为 NF_DROP
    这就是为什么连接没有满,但依然会发生偶发性 SYN 丢包的原因。并发越高,冲突概率越大。
    既然找到了根因,解决思路就明确了。打破这个竞态条件。
    最直接且成本最低的改法,是强制 NAT 在分配端口时使用完全随机的算法,而不是默认的顺序查找。在 iptables 中,这对应的是 --random-fully 参数。这个参数会让内核调用 prandom_u32() 去选取端口,极大地降低了多个并发包在未确认期间撞击同一个源端口的概率。
    我检查了当前集群 Calico 生成的 NAT 规则:

    iptables -t nat -S | grep MASQUERADE
    

    输出显示:
    -A cali-nat-outgoing -j MASQUERADE
    果然没有带随机化参数。
    我当即修改了 Calico 的配置,开启了 SNAT 的完全随机化(在 Calico 中可以通过修改 FelixConfiguration 的 NatOutgoingAddress 相关行为,或直接在较新的 kube-proxy 配置中开启 InsertSNATRandomFully)。
    对应的底层规则变更实际上是让规则变成了这样:

    -A cali-nat-outgoing -j MASQUERADE --random-fully
    

    为了稳妥,我同时微调了这台机器的内核参数,加速 TIME_WAIT 状态的回收,减少 Conntrack 表里的无效堆积,缩小碰撞域:

    # sysctl.conf
    net.netfilter.nf_conntrack_tcp_timeout_time_wait = 30
    net.netfilter.nf_conntrack_tcp_timeout_close_wait = 15
    net.ipv4.tcp_tw_reuse = 1
    

    执行 sysctl -p 生效。
    再次打开 bpftrace 脚本盯着,观察了半个小时,再也没有抓到 nf_conntrack_confirm 抛出的 kfree_skb。业务群里的超时告警也随之消失。
    做了二十年技术,看着系统架构从单机演进到微服务,再到现在的 Cloud Native 和 Service Mesh,抽象层叠得越来越高。很多人习惯于在 K8s 的 Yaml 里打转,遇到性能问题就去改 Request/Limit,或者横向扩容。但流量的本质并没有变,底层依然是那个兢兢业业但也充满脾气的 Linux Kernel。
    高并发不是玄学,丢包必有其因。理解从 Socket 到 eBPF,从 VFS 到 Netfilter 的完整链路,才是运维架构师面对这类 P0 故障时,能保持拔剑即斩的底气。
    合上电脑,外面天已经快亮了。这杯冷咖啡,足够让我清醒地迎接几个小时后的早会。

  • 深入解析:Netfilter Conntrack SNAT 端口冲突导致的 TCP 握手 1 秒延迟跳变

    凌晨三点一刻。机房的冷风通过空调出风口灌进值班室,屏幕上的监控大盘终于恢复了一片幽绿。我揉了揉发酸的眉心,刚合上了一个挂了一周的 P0 级故障单。
    这几天,基础架构部和业务研发团队因为一个诡异的问题几乎要吵翻:核心交易链路在晚高峰高并发时,偶尔会出现精确到 1000ms(1秒)的接口延迟跳变。研发坚持认为是容器网络丢包,而网络组看了交换机和物理机的网卡监控,甚至甩出了零丢包的报表,反咬研发代码有阻塞。
    扯皮解决不了问题。凭我这些年摸爬滚打的经验,当你在监控上看到精确的 1 秒、3 秒延迟跳变时,第一直觉就不该是去查应用层的锁,而是要立刻下沉到协议栈,盯死 TCP 的超时重传机制(RTO)。

    现场还原与抓包确诊

    业务场景很简单:一个基于 K8S 的微服务,通过 NodePort 频繁调用集群外的一个 Redis 集群。并发极高,短连接为主(历史遗留代码,暂不吐槽)。
    我登上一台出问题的 Node 节点,直接用 tcpdump 抓取特定 Pod 的出向流量:

    # 在宿主机抓取对应 Pod veth pair 的网络包,只看 SYN
    tcpdump -i cali123456 -n -e -ttt 'tcp[tcpflags] & tcp-syn != 0'
    

    经过半小时的蹲守,抓到了关键线索。在 Wireshark 中分析抓包文件,看到了经典的画面:
    1. 00.000000 Pod 发出了一个 SYN 包。
    2. 宿主机的物理网卡上,没有抓到这个 SYN 包出村。它在宿主机内部神秘消失了。
    3. 01.001024 Pod 触发了 TCP 超时重传,再次发出了相同的 SYN 包。
    4. 这一次,SYN 包顺利通过物理网卡发出,并在几毫秒后收到了 SYN+ACK。
    在较新的 Linux Kernel 中,TCP 的初始重传超时时间(TCP_TIMEOUT_INIT)默认正好是 1 秒(早期内核是 3 秒)。这就完美解释了业务端监控到的 1000ms 延迟毛刺。
    问题收敛了:数据包在宿主机内部被默默丢弃了,且没有触发网卡的 drop 计数。

    潜入内核:Netfilter 的隐秘角落

    在 K8S 环境下,Pod 访问外部资源,包从 veth pair 出来后,必然要经过主机的网络栈,其中最厚重、最容易暗藏杀机的一层,就是 Netfilter / iptables。为了实现伪装(Masquerade),包在 POSTROUTING 链必须做 SNAT。
    怀疑是 Netfilter 丢包,我没有盲目去查 iptables 规则,而是直接去翻了 conntrack(连接跟踪)的内核统计:

    # 查看 conntrack 的详细统计
    cat /proc/net/stat/nf_conntrack
    

    这个文件返回的是十六进制数据,很难看。可以直接用 conntrack 工具:

    conntrack -S | grep insert_failed
    

    结果印证了我的猜想:在业务延迟报警的同一时刻,insert_failed 计数器出现了明显的飙升。
    为什么会 insert failed?这就必须扒开 Kernel 的源码来看看了。在 net/netfilter/nf_conntrack_core.c 中,核心逻辑在 __nf_conntrack_confirm 函数。
    在 SNAT 场景下,Netfilter 处理连接的逻辑分为两步:
    1. 预分配(NAT 阶段):数据包经过 NAT 链,内核为其分配一个源端口(Ephemeral Port),并初始化一个 conntrack entry。
    2. 确认(Confirm 阶段):数据包走到网络栈的最后,准备发给网卡前,调用 __nf_conntrack_confirm,将这个 entry 真正插入到全局的 Hash 表中。
    在高并发短连接场景下,致命的 Race Condition(竞态条件) 出现了:
    假设有两个并发的 SYN 包(属于不同的本地端口发往同一个目标 IP:Port)穿过网络栈。
    – 线程 A 为包 1 选择了一个未被使用的 SNAT 端口 60001
    – 此时,包 1 还没有走到 Confirm 阶段,没有写入全局 Hash 表。
    – 线程 B 处理包 2 时,去查找可用端口,发现 60001 依然是“空闲”的,于是也选择了 60001
    – 线程 A 走到 Confirm 阶段,顺利将 (源IP, 60001, 目标IP, 目标Port) 插入 Hash 表。
    – 线程 B 走到 Confirm 阶段,尝试插入相同的 tuple,Hash 冲突爆发
    内核对于这种冲突的处理极其冷酷:

    /* net/netfilter/nf_conntrack_core.c */
    int
    __nf_conntrack_confirm(struct sk_buff *skb)
    {
        // ... 省略部分代码 ...
        /* 检查是否已经被其他线程抢先插入了相同的 tuple */
        if (unlikely(nf_conntrack_hash_cmp(hash, ct))) {
            /* 冲突了,直接丢弃数据包 */
            NF_CT_STAT_INC(net, insert_failed);
            goto out;
        }
        // ...
    out:
        nf_conntrack_double_unlock(hash, reply_hash);
        return NF_DROP; /* 默默返回 DROP */
    }
    

    这就是真相:Netfilter 默默丢弃了其中一个 SYN 包,不产生任何 ICMP 报错,应用层毫不知情,只能傻傻等待 1 秒后的 TCP RTO 重传。

    架构级解法:从妥协到彻底抛弃

    知道了底层原理,解决起来就有了方向。我给出了两个层面的处理方案。
    方案一:快速止血(打补丁)
    为了降低多个线程分配到同一个 SNAT 端口的概率,我们需要强制内核在分配端口时增加随机性。
    在原生 iptables 中,可以通过加上 --random-fully 参数来解决。这会触发内核使用 prandom_u32() 来选择端口,而不是线性累加。
    对于 K8S 集群,如果是 kube-proxy 的 iptables 模式,从 K8S 1.16 开始,可以通过开启 KubeProxyWinkernel 特性门控或者直接修改配置,让 SNAT 带有随机性。如果流量是通过 Calico 出去的,可以修改 Calico 的 NAT 出站规则:

    # 修改 iptables 规则示例(针对 SNAT/MASQUERADE)
    iptables -t nat -R POSTROUTING 1 -m comment --comment "kubernetes service traffic requiring SNAT" -j MASQUERADE --random-fully
    

    实施这个变更后,监控曲线瞬间被削平了,1000ms 的跳变几乎绝迹。
    方案二:彻底治本(架构演进)
    但作为一个架构师,我深知 --random-fully 只是在掩盖 Netfilter 自身架构在高并发下的疲态。引入 PRNG(伪随机数生成器)本身也会带来额外的 CPU 周期消耗。
    在当今 100G 网卡和千万级 PPS 的时代,iptables/Netfilter 这套三十年前设计的包过滤框架,其臃肿的链表遍历和复杂的全局锁,已经成为了云原生网络的毒瘤。
    我已经在架构技术委员会上提了 Q3 的改造提案:全面引入 eBPF,使用 Cilium 替换现有的 Kube-Proxy + Calico 组合。
    在 eBPF 的世界里,我们可以直接在 XDP (eXpress Data Path) 或 TC (Traffic Control) 钩子处完成数据包的改写和转发,彻底绕过 Netfilter 的 conntrack 机制。Cilium 维护了自己的高效 BPF Map 来做连接跟踪,完全没有传统 nf_conntrack_confirm 的竞态丢包问题,同时还能把网络延迟再压榨下几个毫秒。

    结语

    时钟指向了快四点。我喝掉杯子里最后一口早就冷透的咖啡。
    现在的年轻人喜欢讨论各种高大上的云原生架构、Service Mesh、Serverless。但不管概念怎么飞,流量最终还是要化成一个个由 0 和 1 组成的数据帧,走过网卡,穿过中断,流经那几百万行古老的 Kernel C 代码。
    在这个行业干了二十年,我始终相信一点:不懂底层的架构师,永远只能在别人搭好的积木上打转。当系统被压榨到极限时,唯有深入内核,才能看见真相。
    关灯,下班。希望明天白天没人来找我重启服务器。

  • 凌晨两点半的TCP灾难:被无脑短连接打爆的nf_conntrack

    凌晨两点半,机房的VPN还没挂断,手里这杯美式已经彻底凉透了。
    刚才过去的两个小时,我一直在盯着监控大盘上那条像心电图一样剧烈抖动的502报错曲线。业务群里还在嗡嗡作响,几个前端和产品经理在艾特后端开发,后端开发又在群里艾特我,嚷嚷着“K8S网络又抽风了”、“容器丢包了”。
    我没有理会群里的喧哗,直接连上了出问题的Node节点。多年的直觉告诉我,系统层面不会无缘无故丢包,如果在CPU、内存、IO吞吐都平稳的情况下出现大面积的网络中断,去查查内核底层的网络栈绝对没错。
    敲下第一条命令:

    dmesg -T | grep -i conntrack
    

    屏幕上立刻滚出了一大片刺眼的红字:
    [Fri Oct 27 01:15:32 2023] nf_conntrack: table full, dropping packet
    很好,案子破了一半。Netfilter的连接跟踪表被打满了,内核为了自保,正在无差别地丢弃新来的网络包。
    但这只是表象,是什么东西在疯狂消耗连接跟踪表?我紧接着看了一眼当前的TCP状态分布:

    ss -s
    

    输出结果简直让人脑溢血:

    Total: 310524
    TCP:   298512 (estab 120, closed 295000, orphaned 0, timewait 294800)
    

    单台机器上,竟然堆积了接近30万个TIME_WAIT状态的连接。
    我切进那个引发报警的业务Pod,调出对应的业务日志和代码仓库。看了不到三分钟,我感到一种难以名状的荒谬感。
    我们的某位“资深”微服务开发同事,写了一个定时同步下游配置的后台任务。这本没什么,但他用的是原生的HTTP Client,没有开启Keep-Alive,没有连接池限制,以每秒钟将近4000次的频率,向同集群的另一个Service发起短连接轮询
    在这个技术点上犯错,是极其业余且不可原谅的。
    任何一个对TCP/IP协议栈有基本敬畏心的人都应该知道,TCP四次挥手后,主动关闭连接的一方会进入TIME_WAIT状态,这个状态必须等待2 * MSL(Maximum Segment Lifetime,Linux默认通常是60秒)才会彻底释放。
    在K8S环境下,情况会变得更糟。Pod之间的跨Service通信,不论是走iptables还是IPVS,都会经过NAT(网络地址转换)。每一次新建短连接,Netfilter都要在nf_conntrack表里为这个连接分配一个条目(Entry)来记录状态。
    你每秒钟打出4000个短连接,60秒内就会产生24万个无法回收的TIME_WAIT套接字。而这台宿主机上分配的临时端口范围(net.ipv4.ip_local_port_range)默认只有大约28000个。端口被耗尽,连接跟踪表被塞满,宿主机上的所有Pod——包括正常处理用户请求的网关——全部跟着一起陪葬,连DNS解析的UDP包都被内核直接Drop掉。
    开发把操作系统当成了一个拥有无限资源的黑盒,以为只要代码不报Exception,底层就会永远默默兜底。
    我叹了口气,先解决眼下的可用性问题。在Node节点上热加载了内核参数,强行抬高conntrack上限,并加速复用:

    # 临时调大连接跟踪表大小
    sysctl -w net.netfilter.nf_conntrack_max=1048576
    # 允许将TIME-WAIT sockets重新用于新的TCP连接
    sysctl -w net.ipv4.tcp_tw_reuse=1
    # 缩短TIME_WAIT在conntrack中的保留时间(注意:仅限特定内核版本或已合入相关patch的系统)
    sysctl -w net.netfilter.nf_conntrack_tcp_timeout_time_wait=30
    

    配置刷下去,监控曲线在30秒内拉平,502报错归零。群里安静了,开发甚至还发了个“赞”的表情,仿佛危机已经完美解除。
    但我很清楚,调优内核参数永远只是在给糟糕的架构擦屁股。
    我关掉终端,在群里甩下了一段复盘结论作为今晚的结束语,然后合上电脑。
    技术结论:
    1. 永远不要在核心链路或高频调用中使用短连接。 HTTP/1.1必须显式复用TCP连接(开启Keep-Alive),或者直接升级到HTTP/2、gRPC进行多路复用。连接池(Connection Pool)是分布式系统的保命底线。
    2. 不要盲目迷信网上的“优化大全”去开启net.ipv4.tcp_tw_recycle。在Linux Kernel 4.12版本之后,这个参数已经被彻底废弃。在NAT环境下(如K8S),开启它会导致时间戳错乱,直接造成正常的TCP握手被静默丢弃(PAWS机制引发)。
    3. 当你遇到nf_conntrack: table full时,调大nf_conntrack_max只是续命。根本解法只有两个:要么优化业务逻辑消除不合理的并发短连接;要么在网络层对高频流量执行NOTRACK规则(-j NOTRACK),让其绕过连接跟踪机制。
    底层不相信魔法,代码的每一丝愚蠢,最终都会在内核的计数器里精准变现。

  • 记一次“经典”的早高峰雪崩:当你把微服务写成TCP炸弹

    上午十点半,正是核心业务的早高峰期。我刚把显示器切到监控大盘,Prometheus的告警就如期而至:几个核心交易链路的接口延迟P99直接飙到了5秒以上,网关层开始大面积报502和504。
    登录终端,随便挑了一台承载核心交易Pod的K8s Node进去。系统负载(Load Average)看起来不高,CPU和内存都还有富裕,但网络包的丢弃率在急剧攀升。
    条件反射般地敲下第一条命令:

    dmesg -T | tail -n 20
    

    屏幕上满屏的红色:
    [Tue Oct 24 10:33:12 2023] nf_conntrack: nf_conntrack: table full, dropping packet
    这就很有意思了。连接跟踪表被打满,说明这台节点上产生了海量的连接状态。继续往下看连接分布:

    ss -s
    

    输出结果极为离谱:

    Total: 312045
    TCP:   320112 (estab 1024, closed 318050, orphaned 0, timewait 315000)
    

    单台节点上竟然堆积了超过31万个 TIME_WAIT 状态的TCP连接。
    我顺着 ss -ntp 的输出,找出了占据这些连接的罪魁祸首——一个早上九点半刚由某位“高级开发”灰度上线的促销微服务。
    我直接去拉了那个微服务的代码仓库。在看了一眼他们调用下游库存服务的逻辑后,我大概理解了什么叫做“用最现代的语言,写最复古的Bug”。
    这位同事在Golang的HTTP调用逻辑里,写出了这样的代码:

    func CheckInventory(ctx context.Context, req Request) error {
        // 为了每次请求都能均匀打到下游Pod,这里禁用KeepAlive
        tr := &http.Transport{
            DisableKeepAlives: true, 
        }
        client := &http.Client{Transport: tr}
        resp, err := client.Post(...)
        // ...
    }
    

    注释写得很“贴心”:“为了每次请求都能均匀打到下游Pod,这里禁用KeepAlive”。
    在这一刻,我确实感受到了一种架构认知上的断层。
    在K8s环境中,通过ClusterIP调用下游服务,流量会经过 kube-proxy 维护的 iptables 或 IPVS 规则进行负载均衡。一旦涉及到 NAT(网络地址转换),内核就必须依赖 netfilterconntrack 模块来跟踪这个连接的状态,以此保证请求包和响应包能正确映射。
    这位同事为了解决所谓的“长连接负载不均”问题(这本该通过客户端侧的软负载均衡、Service Mesh或者gRPC的LB策略来解决),硬核地选择了最愚蠢的方式:在高达数千QPS的早高峰并发下,对每一个请求发起完整的TCP三次握手,并在请求结束后由客户端主动关闭连接。
    TCP协议的常识是什么?主动关闭连接的一方,其端口状态必然会进入 TIME_WAIT,并且会在内核中保留 2 * MSL(Linux下默认通常是60秒)。
    这就意味着,如果这个接口的QPS是5000,那么一分钟内就会产生 300,000 个 TIME_WAIT 连接。Linux默认的本地临时端口范围(net.ipv4.ip_local_port_range)通常只有 32768 到 60999,不到三万个端口。即使端口能复用,节点的 nf_conntrack_max(此集群配置为262144)也会瞬间被击穿。
    一旦 conntrack 表满,内核的网络栈就会毫不留情地丢弃所有新建连接的SYN包。不仅是这个促销微服务,这台Node上调度的其他无辜Pod(包括 CoreDNS 的本地缓存请求)也全部因为无法建立网络连接而跟着陪葬。
    这就叫一颗老鼠屎,炸掉整个宿主机的网络栈。
    为了先让业务恢复,我在几台受影响的Node上执行了紧急止血操作,动态拉高 conntrack 上限,并允许 TIME_WAIT 快速回收:

    # 紧急扩大连接跟踪表
    sysctl -w net.netfilter.nf_conntrack_max=1048576
    sysctl -w net.netfilter.nf_conntrack_buckets=262144
    # 开启 TIME_WAIT 状态复用 (仅对出站连接有效,刚好符合此场景)
    sysctl -w net.ipv4.tcp_tw_reuse=1
    

    随后,我直接在运维群里通知回滚了早上九点半的所有发布。五分钟后,监控上的错误率悬崖式下跌,P99延迟回落到20毫秒的正常水位。
    很多写业务代码的人,对底层基础设施有一种莫名其妙的傲慢,认为有框架和容器兜底,自己只需要关注业务逻辑。
    但在高并发的分布式系统里,所有的抽象都是有漏水的。你不懂TCP状态机,不懂网络栈的连接跟踪机制,不懂四层负载均衡的原理,盲目地在代码里挥舞“优化”的大棒,最终的结果就是把基础组件变成业务的殉葬品。
    记住:不要用破坏内核协议栈行为预期的方式,去弥补你在架构设计上的无知。 连接池(Connection Pool)被发明出来,就是为了不让你干这种蠢事。

  • 记一次令人窒息的“性能优化”:别把无知当极客

    上午十点半,阳光刚好打在机房外围办公室的玻璃上。正逢早高峰流量拉升的阶段,我正盯着Grafana上的大盘,手里这杯美式还没喝到一半,监控告警群直接炸了。
    核心交易链路的Redis集群,P99延迟曲线像一根突然勃起的中指,直插云霄。接着就是铺天盖地的 5xx 报错,网关层的Timeout日志刷得终端根本看不清。
    第一反应:Redis挂了?
    切到终端,kubectl get pods -n data-layer,所有Redis Pod状态全是 RunningREADY 也是 1/1。
    查看 CPU 和内存,风平浪静,连个OOM的影子都没有。
    我顺手找了个应用Pod进去,直接 ping Redis的Pod IP,通的。
    再用 nc -vz 6379,秒连。
    但只要用K8s的 Service (ClusterIP) 去连,nc -vz 6379,直接卡死,直到超时。
    Pod网络正常,Service网络瘫痪。而且只针对这一个Redis Service瘫痪。
    排查到这里,我脑子里大概有底了。Kube-proxy的规则出问题了,或者底层的网络栈被动了手脚。就在我准备拉取宿主机的 iptables 规则时,群里一个刚入职没半年的“资深”DevOps同事冒泡了:
    “我刚才通过Ansible推了一个内核网络优化脚本,会不会跟这个有关系?我看网上说这样能大幅降低高并发下的网络延迟。”
    听到这句话,我眼皮跳了一下。直接让他把推的脚本发来看看。
    不看不知道,一看血压直接飙到180。脚本里赫然躺着这么两行:

    iptables -t raw -A PREROUTING -p tcp --dport 6379 -j NOTRACK
    iptables -t raw -A OUTPUT -p tcp --sport 6379 -j NOTRACK
    

    我深吸了一口气,强压着顺着网线过去砸他键盘的冲动。
    这是一个极其经典的“只知其一不知其二”的愚蠢操作。
    这位同事大概是看了某篇不知哪年哪月的“高并发调优指南”,知道Linux内核的 nf_conntrack(连接跟踪)表在高并发下容易被打满,导致 nf_conntrack: table full, dropping packet 的丢包报错,并且连接跟踪确实会消耗一点点CPU。所以他觉得,既然Redis是高频调用的内网服务,直接在 raw 表把它 Bypass 掉(NOTRACK),不就能榨干最后一点性能了吗?
    听起来很极客,对吧?但他根本没搞懂Kubernetes的网络基石是什么。
    让我用最底层的逻辑来拆解一下,为什么在这个技术点上犯错是不可原谅的。
    在Linux的Netfilter数据包处理流水线中,生命周期是这样的:
    raw 表 -> Connection Tracking -> mangle 表 -> nat 表 -> 路由决策 -> filter
    Kubernetes的Service(这里指ClusterIP)是基于DNAT(目标地址转换)实现的。不管你底层是 iptables 模式还是 IPVS 模式,Kube-proxy都需要拦截发往ClusterIP的数据包,并将其目标IP修改为后端真实的Pod IP。
    这个动作,发生在哪里?发生在 nat 表。
    重点来了:Linux内核的 nat 表是强依赖于连接跟踪(conntrack)的。
    nat 表只处理一条连接的第一个数据包(状态为 NEW)。一旦第一个包完成了地址转换,内核会在 conntrack 表里记录下这层映射关系。后续属于这条连接的数据包,甚至回包,都会直接根据 conntrack 里的记录进行自动转换,根本不会再去走一遍 nat 表的规则。
    当你自作聪明地在第一关 raw 表里加上了 -j NOTRACK
    数据包带着免检金牌大摇大摆地绕过了连接跟踪机制。
    紧接着,它来到了 nat 表。nat 表一看:这包没有被跟踪?那对不起,我没法处理。
    于是,这个数据包直接跳过了Service IP到Pod IP的DNAT转换环节。
    结果是什么?
    应用端发往 10.96.x.x (ClusterIP) 的SYN包,带着原始的目的地址进入了底层的网络插件(Calico/Flannel),路由器一看,这个IP根本不在我的Pod网段路由表里,直接黑洞丢弃。
    应用端永远等不到ACK,直到连接超时,业务全盘崩溃。
    你在K8s环境里,把Kube-proxy赖以生存的底座给抽了,还美其名曰“性能优化”。这就好比嫌汽车发动机发热,所以把冷却液全抽干一样荒谬。
    我切到生产机,两行命令把这该死的规则删了:

    iptables -t raw -D PREROUTING -p tcp --dport 6379 -j NOTRACK
    iptables -t raw -D OUTPUT -p tcp --sport 6379 -j NOTRACK
    

    回车敲下的瞬间,监控大盘上的延迟曲线断崖式下跌,5xx报错清零,早高峰的流量重新平稳涌入。
    在这个行业干了二十年,我见过无数华丽的故障,大多源于对底层原理的无知和对网文盲目的崇拜。
    技术结论:
    在Kubernetes集群或任何重度依赖NAT/SNAT/DNAT的网络架构中,严禁对业务端口使用 NOTRACK
    如果你真的遇到了 conntrack 瓶颈:
    1. 请去调大 net.netfilter.nf_conntrack_max 并配合增加相应的哈希表大小 (hashsize)。
    2. 缩短TIME_WAIT的跟踪超时时间 (net.netfilter.nf_conntrack_tcp_timeout_time_wait)。
    3. 如果真的到了千万级并发连接,不要试图通过魔改内核网络栈来硬抗,去重构你的应用架构,引入本地缓存或者连接池复用。
    永远记住:架构是一个精密的齿轮组,任何一行底层的参数修改,都必须建立在对整个数据流转路径的绝对掌控之上。没有这种敬畏心,你敲下的每一个回车,都是一颗定时炸弹。

  • 运维事故:消失的Pod,和那个错得离谱的YAML

    今天差点因为一个YAML文件把我的血压送走。你说这年头,K8S用了这么久,居然还能有人犯这种低级错误,真让人怀疑他是不是把YAML当成了薛定谔的猫,写之前都不知道里面是个什么东西。
    事情是这样的,早上接到监控告警,一个关键业务的Pod时不时地消失,然后又自动拉起来,就像得了间歇性失忆症。开始我还以为是节点资源不足,或者OOM Killer又出来搞事情了。结果登上去一看,CPU、内存都稳得很,日志里也没有OOM的痕迹。
    这就有点意思了。Pod自己反复重启,那肯定得查探一下Deployment或者StatefulSet的配置。结果不看不知道,一看吓一跳。这位“大神”写的YAML文件,简直就是行为艺术。
    我先不说他缩进乱得像狗啃的一样,光是那个livenessProbereadinessProbe的配置,就能让人原地去世。他大概是觉得健康检查不重要,直接把initialDelaySeconds设置成了3600秒,periodSeconds设置成了7200秒。
    好家伙,这是要让Pod启动一个小时之后再开始检查,而且每两个小时才检查一次? 这Pod要是真出了问题,估计早就被用户骂上天了,还等着你来检查?更离谱的是,他还把failureThreshold设置成了9999。这是什么概念?意思是这个Pod得挂掉近一万次,才会被认为是不健康的?
    我当时就想问问他,是不是觉得Pod有九条命?
    我毫不客气地把他的YAML文件扔进了垃圾桶,然后自己重新写了一份。initialDelaySeconds改成了5秒,periodSeconds改成了10秒,failureThreshold设置成3。这样一来,Pod启动后5秒就开始健康检查,每10秒检查一次,如果连续三次检查失败,就会被认为是不健康的,然后K8S就会自动重启Pod。
    改完之后,问题立刻解决了。Pod再也没有无故消失,业务也恢复了正常。
    这件事告诉我一个深刻的道理:K8S的配置,特别是健康检查这种关键配置,绝对不能想当然。livenessProbereadinessProbe的配置,直接关系到应用的可用性和稳定性。如果配置不合理,不仅不能及时发现问题,反而会掩盖问题,甚至引发更严重的故障。
    记住,健康检查的目的是为了在问题发生时,能够快速定位和解决问题,而不是为了让问题变得更加隐蔽。对于这类配置,一定要深思熟虑,结合应用的实际情况进行设置,不能随便拍脑袋决定。
    技术结论:
    livenessProbereadinessProbe的配置直接影响Pod的可用性。initialDelaySeconds应该根据应用的启动时间设置,periodSeconds应该根据应用的健康状况变化频率设置,failureThreshold应该根据应用的容错能力设置。永远不要想当然,要根据实际情况进行调整。YAML文件不是艺术品,它是生产力工具,写之前先搞清楚每个参数的含义。

    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: my-deployment
    spec:
      replicas: 3
      selector:
        matchLabels:
          app: my-app
      template:
        metadata:
          labels:
            app: my-app
        spec:
          containers:
          - name: my-container
            image: my-image:latest
            ports:
            - containerPort: 8080
            livenessProbe:
              httpGet:
                path: /healthz
                port: 8080
              initialDelaySeconds: 5
              periodSeconds: 10
              timeoutSeconds: 5
              failureThreshold: 3
              successThreshold: 1
            readinessProbe:
              httpGet:
                path: /readyz
                port: 8080
              initialDelaySeconds: 5
              periodSeconds: 10
              timeoutSeconds: 5
              failureThreshold: 3
              successThreshold: 1