作者: ningniu

  • 导致集群雪崩的200MB大Key:当同步淘汰遇上Gossip心跳超时

    早上 10:30,正是一天中业务流量开始拉升的阶段。监控大屏上突然弹出密集的告警,某核心业务的 Redis Cluster 开始出现剧烈的节点状态震荡:Master 和 Replica 不断发生角色切换,客户端大面积报 READONLY 错误和 Command timed out。 介入排查时,第一眼看系统层指标:宿主机 CPU 并没有被打满(由于 Redis 单线程模型,单核跑到 100%,但整体 CPU 负载在一个合理水位),网络带宽也没有出现明显的瓶颈。 但集群就是不稳定。随意连上一个节点执行 cluster nodes,发现 configEpoch 正在疯狂自增,说明 Failover 在频繁发生。

    诡异的现场:消失的 Slowlog 与沉默的慢查询

    遇到节点响应慢甚至超时,排查的第一直觉通常是查慢查询。我在几个发生过切换的节点上执行 slowlog get 10,结果出乎意料:几乎是空的,或者只有几个毫秒级别的正常操作。 这是一个非常典型的误区:很多开发甚至运维认为,只要客户端超时,就一定能在慢查询日志里找到“元凶”。 实际上,Redis 的慢查询日志统计的仅仅是命令本身的执行时间。在源码中,慢查询的耗时计算严格包裹在 call(c, CMD_CALL_FULL) 函数内部。而一个命令在真正进入 call() 执行之前,还需要经历很多阶段,其中最致命的一个环节就是:内存淘汰(Eviction)

    抽丝剥茧:同步淘汰引发的 Reactor 线程阻塞

    既然不是命令本身慢,那必然是主线程在其他地方被卡住了。 我立刻查看了节点的实时状态:

    redis-cli -p 6379 info stats | grep evicted_keys
    

    发现 evicted_keys 的数值在极其剧烈地飙升。同时,info memory 里的 used_memory 紧紧贴着 maxmemory 的边缘疯狂试探。 结合业务方的发布记录,真相逐渐浮出水面:开发团队在早晨上线了一个所谓的“性能优化”逻辑,为了减少网络 RTT,他们将全量用户的关系图谱作为 Hash 结构缓存进了 Redis。通过 redis-cli --bigkeys 扫描,发现部分 Hash 结构的体积达到了恐怖的 150MB 到 200MB,包含上百万个 Field。 当 Redis 内存达到 maxmemory,且配置了 allkeys-lru 策略时,处理任何写入命令前,都会进入 freeMemoryIfNeeded() 函数尝试释放内存。 灾难的爆发点就在这里:这批集群运行的版本默认并未开启 lazyfree-lazy-eviction。这意味着,当 LRU 算法不幸选中了一个 200MB 的超级大 Key 时,Redis 必须同步地去销毁这个拥有百万元素的 Hash 字典。 在 C 语言层面,这是一个极其沉重的 dictRelease 操作,需要逐一遍历并释放内部的 dictEntry。释放一个 200MB 的 Hash,主线程会被死死卡住 2 到 3 秒。在这 3 秒内,整个 Reactor 事件循环完全停滞。

    Gossip 协议的暗礁:极端的 cluster-node-timeout

    如果仅仅是主线程阻塞 3 秒,顶多是一次短暂的客户端 P99 延时毛刺,为什么会引发整个集群的连环 Failover? 这就牵扯到了开发人员犯的第二个致命错误。为了追求所谓的“极致高可用”,有人在配置文件中将 cluster-node-timeout 从默认的 15000 毫秒(15秒)暴降到了 3000 毫秒(3秒)。 逻辑看似很丰满:“超时时间越短,故障发现越快,主从切换就越敏捷。” 但这完全违背了 Redis Cluster 的底层通信机制。Redis 的 Gossip 协议虽然使用的是独立端口(通常是业务端口 + 10000,例如 16379),但它的网络 IO 事件(clusterReadHandler / clusterWriteHandler)同样是注册在主线程的 aeMain 事件循环中的。 当主线程在同步释放 200MB 的大 Key 时,事件循环被阻塞。此时,该节点既无法处理客户端的新请求,也无法响应其他集群节点发来的 Gossip PING 包。 时间线是这样推进的:

    1. 0.0s: 内存触顶,触发 LRU 淘汰,选中 200MB 大 Key。

    2. 0.1s – 2.5s: 主线程陷入 dictRelease,疯狂释放内存,对外界毫无响应。

    3. 1.5s: 其他节点发送 PING 给该节点。数据包到达 OS 内核 TCP 缓冲区,但 Redis 主线程没空去 read()

    4. 3.0s: 致命时刻。由于 cluster-node-timeout 被设为 3000ms,其他节点发现超过 3 秒没有收到 PONG,立即将其标记为 PFAIL(疑似下线)。

    5. 3.1s: 通过 Gossip 交换状态,集群半数以上 Master 认为该节点 PFAIL,状态升级为 FAIL

    6. 3.2s: 触发从节点选举,Replica 晋升为新 Master。

    7. 3.5s: 原 Master 终于删完了大 Key,主线程苏醒。它处理完积压在缓冲区的 Gossip 包,愕然发现自己已经被弹劾。它只能乖乖降级为 Replica,并向新 Master 发起全量同步(SYNC)。

    8. 后续: 全量同步触发 BGSAVE,Copy-On-Write 机制在内存高水位下瞬间导致 OOM Killer 介入,节点彻底崩溃。 这是一个教科书级别的雪崩链路。

    技术结论与干预

    处理这种现场,没有任何优雅退让的余地。我直接采取了三步阻断措施: 第一步:热更配置,开启异步淘汰,把释放内存的脏活丢给后台 BIO 线程。

    config set lazyfree-lazy-eviction yes
    config set lazyfree-lazy-server-del yes
    

    第二步:回调 Gossip 超时时间到合理水位。3 秒的超时在分布式网络环境中无异于走钢丝。

    config set cluster-node-timeout 15000
    

    第三步:拦截写入大 Key 的源头,通知开发紧急回滚。大对象必须拆分(例如按用户 ID Hash 取模落到不同的子 Key 中),没有商量的余地。 不要把单机思维强行套用在分布式架构上。在 Redis 这种极度依赖单线程事件循环的模型中,将超时时间压榨到极致,并不能换来高可用,只会让系统变得像玻璃一样脆弱。任何一个同步阻塞的微小扰动,都会顺着 Gossip 协议的连锁反应,被放大成一场席卷全网的风暴。而懂得在“快速发现故障”和“容忍正常网络抖动与执行毛刺”之间找到平衡点,才是系统架构的成熟体现。

  • 把单机思维带进分布式:一次千万级 DELETE 语句引发的 Percolator 锁雪崩

    上午十点,刚把架构改造方案的拓扑图画完,监控大屏突然红了一片。应用端开始疯狂报 Lock wait timeout exceeded,伴随着偶尔的 Transaction is too large 异常。 打开 Grafana 扫了一眼 TiDB 集群的状态:几个 TiDB Server 节点的内存呈直角飙升,接着发生了 OOM 重启;底层的 TiKV 节点 CPU 几乎跑满,Scheduler command duration 面板里,prewritecommit 的 P99 耗时已经飙到了数十秒。 很显然,整个集群被什么东西卡死,发生了严重的锁阻塞。 连上存活的 TiDB 节点,敲下 SHOW PROCESSLIST,我很快抓到了罪魁祸首。那是一条极度简单、却在这个场景下极度致命的 SQL:

    DELETE FROM order_history WHERE create_time < '2023-01-01';
    

    没有 LIMIT,没有按主键分批,什么保护机制都没有。问了下业务侧,这张表里一年前的历史数据大概有四千多万行。开发人员的逻辑很简单:“既然换了分布式数据库,容量和算力应该随便造,一条 DELETE 删几千万条数据怎么了?MySQL 可能会锁表,你们分布式不应该更牛吗?” 这种把分布式数据库完全当成单机 MySQL 甚至当成黑盒来用的思维,是导致这场灾难的根本原因。 分布式数据库确实能横向扩展,但它绝不是无脑承接烂 SQL 的垃圾桶。在 TiDB 这类基于 Percolator 模型的分布式事务引擎中,大事务是天然的毒药。今天必须把底层逻辑拆透,看看一条千万级的 DELETE 是怎么把一个健康的集群拖进深渊的。 在单机 MySQL (InnoDB) 中,大批量删除主要消耗的是 Undo Log、Redo Log 以及 Buffer Pool,顶多把这条数据所在的数据页锁住,影响局部的并发。 但在 TiDB 中,事务采用的是基于 Google Percolator 模型的两阶段提交(2PC)。当我们执行这行 DELETE 时,底层到底在发生什么? 第一步是读取与内存构建。TiDB Server 并非一边查一边删,它需要先将满足 create_time < '2023-01-01' 的所有记录的主键全部扫出来。在这个过程中,这四千万行数据的 Mutation(变更操作)会被缓存到 TiDB Server 的内存里。即使 TiDB 后来对大事务做过一定优化,但单事务的限制依旧存在(默认事务大小限制为 100MB,最多几百万条 KV)。即使通过改参数强行突破了限制,TiDB Server 节点的内存也会被瞬间抽干,引发 OOM 崩溃。 第二步是致命的 Prewrite 阶段。假设内存勉强扛住了,TiDB 开始向 PD(Placement Driver)获取 TSO 作为事务的 start_ts,然后进入预写阶段。Percolator 模型会在修改的这数千万个 Key 中,选出一个作为 Primary Lock(主锁),剩下的全部作为 Secondary Lock(从锁),这些从锁都会包含一个指向主锁的指针。 接着,TiDB 会并发地向 TiKV 节点发送这些 Prewrite 请求。此时,TiKV 层开始疯狂地写 Lock CF(列族)和 Default CF。几千万个锁如同雨点般砸向整个集群的各个 Region。 第三步,雪崩的形成(锁冲突与 Resolve Lock)。Percolator 模型保证了强一致性的快照隔离(Snapshot Isolation)。在这条巨型 DELETE 语句尚未进入 Commit 阶段(即 Primary Lock 未提交)时,其他正常的业务查询如果读到了这几千万个 Key 中的任何一个,会发生什么? 读请求会看到这里有一个未提交的 Lock。为了保证一致性,它不能直接跳过,必须等。它会等待这个锁被释放,或者等待锁的 TTL 过期。 一旦 TTL 过期,读请求还要主动触发 Resolve Lock(清理锁)操作——顺着 Secondary Lock 找到 Primary Lock,检查那个大事务到底有没有提交,如果没有,就尝试回滚它。 当整个 TiKV 充满了这种未决的 Lock 时,系统的 Resolve Lock 开销会呈指数级上升。正常的读写请求全被卷入这场找锁、等锁、清锁的漩涡中,最终导致整个 TP 业务瘫痪。 在故障现场,我没有别的选择,立即 KILL 掉了这个执行中的大事务连接。 但噩梦并没有马上结束。在分布式环境中,大事务的回滚代价同样极其高昂。TiKV 需要清理掉刚才写入的上千万个 Lock 记录。在接下来的十几分钟里,监控上的垃圾回收(GC)指标依然居高不下,直到异步清理动作彻底完成,集群的延迟才慢慢降回毫秒级。 分布式不仅仅是几台机器连在一起那么简单。存储底座从 B+ 树变成了 LSM-Tree,事务模型从单机的本地日志变为了基于时间戳的分布式 2PC,这些底层原理的剧变,要求开发者必须改变对数据库的交互方式。 如果在单机时代,我们提倡“大事务拆小事务”是为了减少锁表时间和 Undo 空间;那么在 Percolator 分布式事务模型下,“大事务拆小事务”就是关乎集群生死存亡的铁律。 对于这种历史数据的清理,最干净的做法是在建表时就规划好时间分区(Partition),清理时直接一条 ALTER TABLE order_history DROP PARTITION p2022,这在底层只是元数据的变更,瞬间完成。 如果非要用 DELETE,就老老实实写一个脚本,按主键范围每次处理 2000 到 5000 行,循环往复。 把单机时代的粗暴操作原封不动地搬到架构复杂的分布式系统上,是对技术的无知。任何时候,不敬畏系统底层的运行机制,系统必定会以最直接的方式给你一个惨痛的教训。

  • 抛弃线性遍历:高并发网关从 iptables 迁移至 nftables 的核心逻辑与底层重构

    上午刚做完边缘网关集群的灰度替换,看着监控面板上节点 CPU 的 si(软中断)使用率从常态的 40% 直接掉到了 5% 以下,整个系统的吞吐量算是彻底释放出来了。 长久以来,我们在处理海量 IP 黑名单封禁、复杂的 SNAT/DNAT 转发时,习惯性地依赖 iptables。但随着单机连接数突破百万,原本那些跑得好好的 iptables 规则逐渐成了吃掉 CPU 性能的隐形黑洞。今天借着上午这次重构落地的契机,聊聊从 iptables 迁移到 nftables 背后的底层逻辑,以及如果只用 iptables-translate 为什么根本达不到优化效果。

    兼容层的虚假繁荣:iptables-nft 的陷阱

    现在很多较新的 Linux 发行版(如 Debian 11/12, RHEL 8+),你在终端敲下 iptables 时,底层其实已经是指向 iptables-nft 的软链接了。 很多人有一个误区,认为系统既然底层用了 nftables 的引擎,性能自然就上去了。这是一个错觉。iptables-nft 仅仅是一个兼容层,它的作用是把 iptables 的命令语法,在内核层面翻译成 nftables 的字节码(Bytecode),但它完全保留了 iptables 的线性遍历逻辑。 在传统的 iptables 架构中,规则在内核里是作为一个连续的内存块(Blob)存储的。这意味着:

    1. 匹配极慢:如果有一万条 IP 封禁规则,数据包进入 PREROUTINGINPUT 链后,内核必须从第一条规则开始逐条进行匹配(O(N) 复杂度)。

    2. 更新代价高昂:每次新增或删除一条规则,内核都需要加锁,并将整个规则集的 Blob 重新拷贝一遍。这在频繁更新规则的 K8s 节点或动态防火墙上,会导致严重的自旋锁竞争。 虽然 iptables-nft 解决了全局锁更新的问题(nftables 支持增量更新),但只要你还是按照以前一条条添加规则的思路去写逻辑,O(N) 的线性匹配性能瓶颈就依然存在。

    核心重构:从线性匹配走向集合(Sets)与映射(Maps)

    nftables 的真正杀手锏在于原生支持了高级数据结构:Sets(集合)、Dictionaries(字典/映射)和 Concatenations(级联匹配)。这也是我们在重构时改变最大的地方。

    1. 使用 Sets 终结海量 IP 匹配

    以前我们要封禁 1000 个恶意 IP,iptables 要写 1000 条规则:

    iptables -A INPUT -s 1.1.1.1 -j DROP
    iptables -A INPUT -s 2.2.2.2 -j DROP
    ...
    

    在原生的 nftables 中,我们通过定义一个底层的 Hash 表或红黑树(取决于具体的配置和元素数量)来实现 O(1) 或 O(log N) 的查找:

    # 定义一个名为 blackhole 的表
    table inet blackhole {
        # 定义一个集合,底层用 hash 实现,支持动态更新
        set bad_ips {
            type ipv4_addr
            flags dynamic, timeout
            timeout 24h
        }
        chain input {
            type filter hook input priority 0; policy accept;
            # 一条规则搞定所有匹配
            ip saddr @bad_ips counter drop
        }
    }
    

    运行时如果需要动态加黑名单,只需执行:nft add element inet blackhole bad_ips { 3.3.3.3 }。内核层面只需在现有的 Hash 表里插入一个节点,数据包匹配时直接进行键值查找,CPU 开销微乎其微。

    2. 使用 Maps 重构复杂的 NAT 转发

    在之前的架构中,边缘网关承担了大量不同端口到不同内网机器的 DNAT 转发。几十条 NAT 规则在 PREROUTING 链中依次匹配。 重构为 nftables 后,我们利用 Map 结构,将端口号映射为后端 IP 和端口的组合,实现了只需一条规则就能处理所有转发逻辑:

    table ip nat {
        map port_to_backend {
            type inet_service : ipv4_addr . inet_service
            elements = { 
                8080 : 10.0.1.10 . 80,
                8443 : 10.0.1.11 . 443,
                9090 : 10.0.1.12 . 9090 
            }
        }
        chain prerouting {
            type nat hook prerouting priority dstnat; policy accept;
            # dnat to 后面接 map 查找,如果匹配直接完成 NAT 重写
            # tcp dport 取出目标端口,去 port_to_backend 字典里查
            dnat to tcp dport map @port_to_backend
        }
        chain postrouting {
            type nat hook postrouting priority srcnat; policy accept;
            masquerade
        }
    }
    

    这里的 ipv4_addr . inet_service 是 nftables 的 Concatenations(级联)特性。它允许我们将多个字段拼接成一个元组(Tuple)作为 value。这不仅让配置极其清晰,更从底层消除了 Netfilter 链表的遍历损耗。

    终极性能释放:Conntrack 与 Flowtable 硬件/软件卸载

    只优化规则匹配时间是不够的。任何熟悉 Linux 网络栈的人都知道,Netfilter 的性能大头其实耗在了 conntrack(连接跟踪)上。只要使用了 NAT 或者状态防火墙(如 ct state established,related accept),每一个数据包都要过一遍状态机。 虽然我们无法完全绕过 conntrack,但 nftables 引入了 flowtable 机制(Fastpath),这是 iptables 时代难以实现的降维打击。 它的底层逻辑是:对于一条已经建立(ESTABLISHED)的连接,既然它的双向路由和 NAT 状态已经确定,为什么还要让后续的海量数据包去走完整的 Netfilter Hooks(PREROUTING -> FORWARD -> POSTROUTING)? 上午我们在部分流量极大的节点上开启了软件层面的 flowtable 卸载:

    table inet filter {
        # 定义 flowtable,挂载在 ingress 钩子上
        flowtable f_fastpath {
            hook ingress priority 0
            devices = { eth0, eth1 }
        }
        chain forward {
            type filter hook forward priority 0; policy accept;
            # 对于新建连接,走正常的规则过滤,如果允许,则加入 flowtable
            ip protocol { tcp, udp } flow add @f_fastpath
            # 兜底的传统 established 处理
            ct state established,related accept
        }
    }
    

    这段配置在内核态发生了什么? 当握手包(SYN, SYN-ACK, ACK)走完传统的 forward 链后,flow add @f_fastpath 会将这条五元组连接插入到 flowtable 中。 当这条连接后续的数据包到达网卡的 ingress hook(比 PREROUTING 更早)时,直接命中 flowtable。内核会在这里直接执行目的 MAC 替换、TTL 减 1 以及 NAT 重写,然后直接塞给网卡发包出去。整个过程完全短路了标准的 IP 路由栈和 Netfilter 钩子链。 这就是为什么上午灰度一开,CPU 软中断直接从 40% 砸到 5% 的根本原因。

    迁移过程中的避坑指南

    真正执行这种底层迁移时,不能光看着爽,几个坑必须注意:

    1. 禁用内核遗留模块:既然切了 nftables,就彻底一点。通过 rmmod 或者内核黑名单机制,把 ip_tables, iptable_filter, iptable_nat 统统屏蔽。否则内核里同时跑着两套 Hook 注册,性能反而会倒退,还会出现玄学的报文丢失问题。

    2. Conntrack INVALID 状态丢包:迁移后注意监控系统指标 netstat -s | grep "invalid" 或者 conntrack -S。有些非标准客户端发出的乱序包,或者非对称路由产生的单向流,会被 nftables 的严格状态机判定为 INVALID 并 drop。必要时需在 raw 表(nftables 中对应 priority 为 -300 的 hook)里做 notrack(在 nft 里叫 ct state untracked)放行。

    3. 不要依赖 iptables-translate:很多人偷懒,直接用这个工具把以前的 iptables 规则批量翻译成 nft 脚本导入。前面说了,这样只是把垃圾代码换了一种语言重写了一遍。一定要根据业务逻辑,重新抽象成 Maps 和 Sets。 技术演进不是简单的命令替换,弄懂 Netfilter 数据包流转在内核源码层的路径变化,才是做这种底层架构调整的底气所在。今天白天这波重构算是稳住了,后续再观察晚高峰的并发表现。

  • Raft 工程实践中的暗礁:从一次 Leader 异常切换看 Pre-Vote 与日志截断边界

    上午刚跟业务线把一个分布式 KV 集群的可用性抖动问题复盘完。现象很简单:一个 5 节点的集群,其中一个 Follower 节点因为宿主机网络硬件模块短暂卡死,与集群断联了大约 15 秒。网络恢复的瞬间,集群稳定运行了几个月的 Leader 突然主动 StepDown,导致整个集群在重新选主的几秒内出现了短暂的写拒绝。 这是一个非常典型的分布式共识协议在工程落地时的边界场景。理论上的 Raft 协议在处理网络分区时逻辑清晰,但在真实的生产环境中,简单的理论往往会引发级联反应。今天借着这个排查现场,把 Raft 工程实现中的 Leader 选举机制、任期膨胀(Term Inflation)以及日志复制中的冲突截断逻辑拆解一下。

    破坏性节点与任期膨胀 (Term Inflation)

    在标准 Raft 论文的描述中,Follower 如果在 electionTimeout 内没有收到 Leader 的心跳(Heartbeat),就会认为 Leader 挂了,随之将自己的状态切换为 Candidate,自增当前任期号(Term),并向外发起 RequestVote RPC。 当网络发生非对称分区(孤岛现象)时,问题就来了。 在上午的案例中,脱网的那个 Follower(我们称之为 Node-E)收不到 Leader 的心跳。于是它开始不断触发选举超时。由于网络不通,它的拉票请求发不出去,自然也收不到多数派的响应。 于是,Node-E 陷入了一个死循环:超时 -> 自增 Term -> 发起选举 -> 再次超时 -> 再次自增 Term。 15秒后网络恢复,此时 Node-E 的 Term 已经膨胀到了一个非常大的值。它带着这个巨大的 Term 向原 Leader 发送了任意一条消息(比如拉票请求,或者回复 Leader 的心跳)。 Raft 在工程实现中有一条铁律:任何节点,只要收到 Term 大于自身当前 Term 的消息,必须立刻无条件降级为 Follower。 我们来看 etcd/raft 底层核心状态机 raft.go 中的这段经典逻辑:

    Go
    func (r *raft) Step(m pb.Message) error {
        // ... 
        if m.Term > r.Term {
            if m.Type == pb.MsgVote || m.Type == pb.MsgPreVote {
                // 处理投票请求的特殊逻辑
            }
            // 【核心触发点】:发现更大的任期号
            r.logger.Infof("%x [term: %d] received a %s message with higher term from %x [term: %d]",
                r.id, r.Term, m.Type, m.From, m.Term)
            // 原 Leader 被迫降级
            if m.Type == pb.MsgApp || m.Type == pb.MsgHeartbeat || m.Type == pb.MsgSnap {
                r.becomeFollower(m.Term, m.From)
            } else {
                r.becomeFollower(m.Term, None)
            }
        }
        // ...
    }
    
    Go

    这就是导致上午集群抖动的元凶。一个本该默默追赶数据的“落后节点”,用一个虚高的 Term 把健康的 Leader 给“刺杀”了。

    工程解法:Pre-Vote 机制的底层逻辑

    为了解决这个“破坏性服务器(Disruptive Server)”问题,Raft 提出了 Pre-Vote(预投票)机制。这也是主流分布式数据库(TiKV, CockroachDB, etcd)在工程实践中必定会引入的优化。 Pre-Vote 的核心思想是:在真正增加 Term 并发起选举之前,先用当前的 Term + 1 去试探性地问一下大家,我有没有可能赢? 如果开启了 Pre-Vote,节点在超时后不会立刻变成 Candidate,而是变成 PreCandidate。 它会发送 MsgPreVote 消息。只有当集群中多数派节点回复确认(表明它们也确实没收到 Leader 心跳,且你的日志足够新),它才会真正自增 Term 并发起正式投票(MsgVote)。 在 etcd/raft 中,这个状态转换的工程实现如下:

    Go
    func (r *raft) campaign(t CampaignType) {
        var term uint64
        var voteMsg pb.MessageType
        if t == campaignPreElection {
            r.becomePreCandidate()
            voteMsg = pb.MsgPreVote
            // Pre-Vote 阶段,试探性的 term 是当前 term + 1,但本地状态机不持久化这个 term
            term = r.Term + 1
        } else {
            r.becomeCandidate()
            voteMsg = pb.MsgVote
            term = r.Term
        }
        // 统计选票,如果是单节点直接胜出
        if _, _, res := r.poll(r.id, voteRespMsgType(voteMsg), true); res == quorum.Won {
            if t == campaignPreElection {
                r.campaign(campaignElection) // Pre-Vote 赢得多数派,发起正式选举
            } else {
                r.becomeLeader()
            }
            return
        }
        // 向其他节点广播投票请求
        for id := range r.prs.Voters {
            if id == r.id {
                continue
            }
            r.send(pb.Message{Term: term, To: id, Type: voteMsg, Index: r.raftLog.lastIndex(), LogTerm: r.raftLog.lastTerm()})
        }
    }
    
    Go

    上午的集群之所以中招,排查后发现是因为业务方在这个定制版本的配置中,不慎将 election_ticksheartbeat_ticks 的比例设置过小,且显式关闭了 PreVote 选项。修正配置后,这种幽灵抖动被彻底阻断。

    日志复制的边界:Index 冲突与快速截断截断(Fast Log Rejection)

    在处理完网络分区引发的选举风暴后,我们要面对分布式共识的另一个深水区:日志复制的冲突处理。 假设在上述场景中,原 Leader 在断网前刚刚接收了一条客户端的写请求,追加到了本地日志(Index=100,Term=5),但还没来得及同步给 Follower。 网络恢复后,新的 Leader(Term=6)已经产生,并且也接收了新的写请求(Index=100,Term=6)。 此时,新 Leader 会向原 Leader 发送 MsgApp(追加日志请求)。Raft 必须保证日志的强一致性(Log Matching Property)。原 Leader 收到 MsgApp 后,发现本地 Index=100 的日志 Term 是 5,而 Leader 发来的是 6。冲突发生了。 在基础的 Raft 论文中,Leader 遇到 Follower 拒绝日志时,会把该 Follower 的 nextIndex 减 1,然后再试,直到找到两者日志匹配的点。 但在工程实现中,如果积压了大量冲突日志,每次回退 1 个 Index 会导致巨大的 RPC 交互开销(逐条回退)。etcd/raft 在这里做了一个极其优雅的工程优化:RejectHint(拒绝暗示)。 当 Follower 发现日志冲突时,它不仅会回复拒绝,还会在 MsgAppResp 中附带一个 RejectHint(通常是冲突任期的第一条日志的 Index,或者 Follower 本地的 LastIndex)。 Follower 端的核心处理逻辑:

    Go
    func (l *raftLog) maybeAppend(index, logTerm, committed uint64, ents ...pb.Entry) (lastnewi uint64, ok bool) {
        // 检查 index 和 logTerm 是否匹配
        if l.matchTerm(index, logTerm) {
            // 如果匹配,寻找有没有冲突的 entries
            lastnewi = index + uint64(len(ents))
            ci := l.findConflict(ents)
            switch {
            case ci == 0:
            case ci <= l.committed:
                // 异常边界:试图覆盖已提交的日志,触发 panic (永远不应该发生)
                l.logger.Panicf("entry %d conflict with committed entry [committed(%d)]", ci, l.committed)
            default:
                // 发现未提交的冲突日志,进行截断并追加新日志
                offset := index + 1
                l.append(ents[ci-offset:]...)
            }
            l.commitTo(min(committed, lastnewi))
            return lastnewi, true
        }
        return 0, false
    }
    
    Go

    maybeAppend 返回 false 时,Follower 会构造拒绝响应:

    Go
    // 构造拒绝响应,附带本地日志信息加速回溯
    m.Index = r.raftLog.lastIndex()
    m.Reject = true
    m.RejectHint = r.raftLog.lastIndex()
    
    Go

    Leader 收到带有 RejectHintMsgAppResp 后,会直接大幅度调整 nextIndex

    Go
    if m.Reject {
        // 收到拒绝,根据 RejectHint 快速倒推 nextIndex
        if pr.MaybeDecrTo(m.Index, m.RejectHint) {
            r.logger.Debugf("%x decreased progress of %x to [%s]", r.id, m.From, pr)
            if pr.State == tracker.StateReplicate {
                pr.BecomeProbe()
            }
            r.sendAppend(m.From)
        }
    }
    
    Go

    通过这种工程上的妥协与优化,新 Leader 可以用最少的 RTT 跨越整个 Term 的冲突日志,强制覆盖原 Leader 那些未提交的“脏数据”,使得系统迅速恢复到一致状态。

    结语

    分布式系统的诡异之处在于,协议的伪代码看几遍就能懂,但在面对真实的网卡丢包、CPU 瞬时争抢、或者磁盘 IO 导致 Fsync 耗时超标(超过 Election Timeout)时,状态机的流转会呈现出混沌的特征。 无论是 Pre-Vote 防御机制,还是 RejectHint 的加速截断,都是我们在面对不可靠的物理基础设施时,在 Raft 基础理论上打的一块块工程补丁。排查这类问题,不仅要求对业务层的监控指标了然于胸,更要求在脑海中随时能够勾勒出底层状态机的流转图。

  • 容器逃逸与提权路径阻断:深入 PSS 底层机制与 Admission Webhook 拦截实践

    早上例行过了一遍核心集群的 API Server 审计日志,发现某个业务 Namespace 下出现了一连串被拒绝的 pods/execpods/create 请求。顺着链路查下去,发现是开发侧为了图方便,直接在一个 CI/CD 专用的 ServiceAccount 上绑定了过于宽松的 RBAC 权限。 在 K8S 环境里,安全边界从来不是靠口头约定的。今天刚好借着这个案例,把 K8S 底层的权限提权路径拆解一下,并谈谈如何通过 Pod Security Standards (PSS) 和 Admission Webhook 构建实质性的防御闭环。

    一、RBAC 最小权限的伪命题与提权路径

    很多人配置 RBAC 时,觉得只要不给 cluster-admin,不给 secrets 的读取权限就是安全的。但实际上,在 Kubernetes 中,只要拥有创建 Pod 的权限,如果没有严格的准入控制,就等同于拥有了 Node 的 Root 权限,进而可以接管整个集群。 看一眼开发试图下发但被拦截的 Pod YAML:

    apiVersion: v1
    kind: Pod
    metadata:
      name: debug-pod
    spec:
      hostNetwork: true
      hostPID: true
      containers:
      - name: shell
        image: alpine:latest
        securityContext:
          privileged: true
        volumeMounts:
        - mountPath: /host
          name: host-root
      volumes:
      - name: host-root
        hostPath:
          path: /
          type: Directory
    

    如果集群只依赖基础的 RBAC 且允许下发上述资源,会发生什么?

    1. hostPID: true 打破了 PID Namespace 的隔离。

    2. privileged: true 让容器内的进程获取了 Node 宿主机上的所有 Capabilities。

    3. hostPath 直接将宿主机的根目录 / 挂载到了容器的 /host。 攻击者进入容器后,只需要执行一条命令: chroot /host nsenter -t 1 -m -u -n -i sh 这直接切换到了宿主机 init 进程(PID 1)的 Mount、UTS、Network、IPC 命名空间。此时,攻击者已经拿到了 Node 的 Root 权限,可以随意读取 /etc/kubernetes/pki/ 下的证书,或者复用 kubelet 的凭证与 API Server 通信,完成对整个集群的控制。 这就是为什么 RBAC 的最小权限原则(Least Privilege)必须配合更底层的资源控制。

    二、从 PSP 到 PSS:内置的准入拦截机制

    早年间我们用 PodSecurityPolicy (PSP) 来防范这种越权,但由于 PSP 的 API 设计过于复杂且容易导致权限混淆,官方在 1.25 版本彻底将其移除,替换为了 Pod Security Admission (PSA/PSS)。 PSS 不是一个独立运行的组件,它本质上是 kube-apiserver 内部的一个 Admission Controller。官方预定义了三个安全等级:PrivilegedBaselineRestricted。 为了在特定的 Namespace 阻断上述提权行为,我通常会在系统层面直接打 Label 强制启用 Restricted 模式:

    kubectl label --overwrite ns cicd-pipeline \
      pod-security.kubernetes.io/enforce=restricted \
      pod-security.kubernetes.io/enforce-version=latest \
      pod-security.kubernetes.io/audit=restricted \
      pod-security.kubernetes.io/warn=restricted
    

    底层拦截逻辑分析: 当 API Server 接收到创建 Pod 的请求时,请求会经历 Authentication -> Authorization (RBAC) -> Mutating Admission -> Object Schema Validation -> Validating Admission 这个标准管道。 PSA 作用于 Validating Admission 阶段。它会提取目标 Namespace 的 Label,当检测到 enforce=restricted 时,会按严格的策略遍历 Pod Spec。只要发现 hostPID: true 或者 privileged: true,直接返回 HTTP 403。 在 API Server 的审计日志中,你可以清晰看到拦截过程产生的记录:

    {
      "kind": "Event",
      "apiVersion": "audit.k8s.io/v1",
      "level": "Metadata",
      "stage": "ResponseComplete",
      "requestURI": "/api/v1/namespaces/cicd-pipeline/pods",
      "verb": "create",
      "user": {
        "username": "system:serviceaccount:cicd-pipeline:deployer"
      },
      "responseStatus": {
        "metadata": {},
        "status": "Failure",
        "reason": "Forbidden",
        "code": 403
      },
      "annotations": {
        "pod-security.kubernetes.io/enforce-policy": "restricted"
      }
    }
    

    三、Admission Webhook:弥补 PSS 的盲区

    PSS 解决了 “Pod 自身特权逃逸” 的问题,但它的粒度太粗了。如果我的需求是:

    1. 强制所有镜像必须从公司内部的私有仓库(例如 harbor.internal.com)拉取。

    2. 阻止未经授权的用户修改特定的 ConfigMap

    3. 动态检查某些资源配置是否符合安全合规标准。 这三个需求 PSS 都做不到,必须通过自定义的 ValidatingAdmissionWebhook 来实现。 Webhook 的本质是一个 HTTPS Server。API Server 在 Validating 阶段,会将请求封装成一个 AdmissionReview 对象,通过 POST 请求发给你的 Webhook,然后根据返回的 allowed: true/false 决定是否放行。 下面是一个拦截非私有仓库镜像的核心代码片段(Go 语言):

    func servevalidate(w http.ResponseWriter, r *http.Request) {
        var body []byte
        if r.Body != nil {
            if data, err := io.ReadAll(r.Body); err == nil {
                body = data
            }
        }
        // 解析 API Server 发来的 AdmissionReview
        ar := v1.AdmissionReview{}
        json.Unmarshal(body, &ar)
        // 反序列化具体的 Pod 对象
        var pod corev1.Pod
        json.Unmarshal(ar.Request.Object.Raw, &pod)
        allowed := true
        var resultMsg string
        // 检查镜像前缀
        for _, container := range pod.Spec.Containers {
            if !strings.HasPrefix(container.Image, "harbor.internal.com/") {
                allowed = false
                resultMsg = fmt.Sprintf("Image %s is strictly forbidden. Use harbor.internal.com.", container.Image)
                break
            }
        }
        // 构造响应
        admissionResponse := v1.AdmissionResponse{
            UID:     ar.Request.UID,
            Allowed: allowed,
            Result: &metav1.Status{
                Message: resultMsg,
            },
        }
        ar.Response = &admissionResponse
        respData, _ := json.Marshal(ar)
        w.Write(respData)
    }
    

    要让这个 Webhook 生效,我们需要向集群提交 ValidatingWebhookConfiguration 配置:

    apiVersion: admissionregistration.k8s.io/v1
    kind: ValidatingWebhookConfiguration
    metadata:
      name: image-policy-validator
    webhooks:
      - name: image-policy.k8s.internal
        clientConfig:
          service:
            name: security-webhook
            namespace: security-system
            path: "/validate"
          caBundle: 
        rules:
          - operations: ["CREATE", "UPDATE"]
            apiGroups: [""]
            apiVersions: ["v1"]
            resources: ["pods"]
        failurePolicy: Fail
        namespaceSelector:
          matchExpressions:
          - key: kubernetes.io/metadata.name
            operator: NotIn
            values: ["kube-system", "security-system"]
    

    四、配置 Webhook 的高危陷阱

    在上述 YAML 中,我特意写了两个关键配置,这也是无数人在生产环境踩过大坑的地方:

    1. namespaceSelector: 必须排除掉 kube-system 甚至你部署 Webhook 所在的 Namespace。

    2. failurePolicy: Fail: 代表如果 Webhook 服务不可用,API Server 会拒绝所有发往该 Webhook 的请求。 如果你的 Webhook 所在节点宕机,或者证书过期,且你没有排除核心 Namespace,会导致连 kube-system 下的组件(比如 CoreDNS 扩容、CNI 组件重启)都无法创建 Pod,整个集群陷入死锁。到那时,你连部署一个新的 Webhook 实例去恢复它都做不到,只能通过直接修改 API Server 的静态 Pod 配置文件绕过拦截。 安全体系的构建是一环扣一环的。RBAC 控制谁能访问,PSS 限制能运行什么形态的实体,Admission Webhook 则补齐了业务侧定制化的安全约束。把这三者结合好,才能在日常运维中从容应对各种不规范的操作和潜在的恶意越权。收拾完这堆审计告警,该去跟研发团队对齐新的权限管控流程了。

  • CRD状态更新引发的死循环:从Controller-Runtime源码拆解Informer机制

    下午两点半,监控大屏准时弹出了一条飞书告警。K8S集群的 API Server 请求延迟突增,apiserver_request_total 指标显示 UPDATEWATCH 请求的 QPS 在过去十分钟内翻了近一百倍。 顺着链路查下去,源头是一个业务团队刚灰度上线的自定义 Operator。查看该 Pod 的资源消耗,CPU 已经被打满到 4 核,内存也在持续攀升。 抓了个 pprof 现场:

    go tool pprof http://localhost:8080/debug/pprof/profile?seconds=30
    

    在火焰图中,CPU 时间大量消耗在 k8s.io/apimachinery/pkg/util/json.Unmarshal 以及 k8s.io/client-go/tools/cache.(*DeltaFIFO).Pop 上。这症状太典型了——标准的 Controller Reconcile 死循环引发的自杀式 DDoS。 把他们的代码拉下来看了一眼,核心逻辑精简后如下:

    func (r *MyCustomReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
        var instance myv1.MyCRD
        if err := r.Get(ctx, req.NamespacedName, &instance); err != nil {
            return ctrl.Result{}, client.IgnoreNotFound(err)
        }
        // ... 执行业务逻辑,比如创建 Deployment 等 ...
        // 更新状态
        instance.Status.Phase = "Running"
        instance.Status.LastUpdateTime = metav1.Now() // 罪魁祸首之一
        if err := r.Status().Update(ctx, &instance); err != nil {
            return ctrl.Result{}, err
        }
        return ctrl.Result{}, nil
    }
    

    这段代码看起来符合直觉:“获取对象 -> 处理逻辑 -> 更新状态”。但在 K8S 声明式 API 和 Informer 机制的底层运作下,这就是一颗定时炸弹。

    拆解死循环的底层逻辑

    controller-runtime 的框架下,Controller 的运作高度依赖 Informer 机制。开发者很容易将其当作一个普通的 CRUD 模型,却忽略了底层的事件流。 当 r.Status().Update 被调用时,究竟发生了什么?

    1. API Server 的响应:更新 Status 成功后,API Server 会持久化这个对象到 etcd。在这个过程中,对象的 metadata.resourceVersion 会不可避免地发生递增。

    2. Reflector 捕获变更:kube-apiserver 会通过 chunked 编码的 HTTP 长连接,将 Watch Event 推送给 Informer 的 Reflector。Reflector 看到 resourceVersion 变了,认为对象发生了更新。

    3. DeltaFIFO 入队:Reflector 将一个 Updated 类型的 Delta 压入 DeltaFIFO

    4. 触发 Event Handler:Informer 的 Controller(不是你写的 Reconciler,是 client-go 内部的 controller)从 DeltaFIFO Pop 出这个 Delta,更新本地的 ThreadSafeStore(也就是你平时 r.Get 命中的本地缓存),并回调注册的 ResourceEventHandler。

    5. WorkQueue 积压controller-runtime 默认的 Handler 会将这个发生变更的对象的 NamespacedName 压入限速队列(RateLimitingQueue)。

    6. Reconcile 再次执行:你的 Reconciler 从队列取出这个请求,再次执行逻辑。因为代码里每次都无条件更新 LastUpdateTime,导致 Status 再次发生实质性改变。 这就形成了一个完美的闭环:Reconcile -> Update Status -> ResourceVersion++ -> Watch Event -> Enqueue -> Reconcile。 每秒几百次的循环,不仅把 Operator 自身的 CPU 榨干,还连带把 API Server 的带宽和处理能力吃爆。

    阻断风暴:Generation 与 Predicate 机制

    K8S 在设计之初就考虑到了这个问题。为了区分“用户期望状态的变更(Spec)”和“系统当前状态的变更(Status)”,引入了 metadata.generation 这个元数据。 按照规范,只有当 CRD 的 Spec 部分发生变更时,API Server 才会递增 generation。而 Status 的更新,只会导致 resourceVersion 递增,generation 保持不变。 前提是你必须在 CRD 的定义中显式启用了 status 子资源:

    //+kubebuilder:subresource:status
    type MyCRD struct {
        metav1.TypeMeta   `json:",inline"`
        metav1.ObjectMeta `json:"metadata,omitempty"`
        Spec   MyCRDSpec   `json:"spec,omitempty"`
        Status MyCRDStatus `json:"status,omitempty"`
    }
    

    如果没有启用 /status 子资源,r.Status().Update 底层会回退到全量更新对象,这会导致 generation 错误地递增,问题会更难排查。 要阻断上述死循环,最标准的第一道防线是使用 GenerationChangedPredicate。在 Controller Setup 时进行过滤:

    import "sigs.k8s.io/controller-runtime/pkg/predicate"
    func (r *MyCustomReconciler) SetupWithManager(mgr ctrl.Manager) error {
        return ctrl.NewControllerManagedBy(mgr).
            For(&myv1.MyCRD{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
            Complete(r)
    }
    

    我们翻开 GenerationChangedPredicate 的源码看看它是怎么工作的:

    func (GenerationChangedPredicate) Update(e event.UpdateEvent) bool {
        if e.ObjectOld == nil {
            return false
        }
        if e.ObjectNew == nil {
            return false
        }
        // 只有当对象的 generation 发生变化时,才允许事件进入 WorkQueue
        return e.ObjectNew.GetGeneration() != e.ObjectOld.GetGeneration()
    }
    

    通过这个拦截器,单纯的 Status 更新引发的 Watch Event,在推送到 WorkQueue 之前就会被直接丢弃。

    状态机的最终一致性:Semantic Equality

    仅仅加了 Predicate 就可以高枕无忧了吗?并非如此。 在真实的业务场景中,我们可能不仅要监听自己的 CRD,还会 Owns() 其他资源(比如 Deployment)。当底层的 Deployment 状态变化时,我们需要将其反映到 CRD 的 Status 中。此时,更新 Status 依然可能导致不必要的 API Server 写入压力。 正确的做法是,在向 API Server 发起更新请求之前,比较内存中计算出的期望 Status 与本地缓存(或者当前获取到)的 Status 是否存在语义上的差异。 这里不要用标准的 reflect.DeepEqual,因为 K8S 对象在序列化和反序列化过程中,可能会产生一些微小的不可见变化(比如空切片变 nil)。应当使用 apimachinery 提供的 apiequality.Semantic.DeepEqual

    import "k8s.io/apimachinery/pkg/api/equality"
    // ... 业务逻辑执行完毕 ...
    newStatus := myv1.MyCRDStatus{
        Phase: "Running",
        // 尽量避免将时间戳直接写在每次更新的逻辑中,除非业务状态确实发生了改变
    }
    if !equality.Semantic.DeepEqual(instance.Status, newStatus) {
        instance.Status = newStatus
        if err := r.Status().Update(ctx, &instance); err != nil {
            // 这里需要注意冲突处理,Update 可能抛出 Conflict 错误
            return ctrl.Result{}, err
        }
    }
    

    结合这两种手段:入口处用 Predicate 阻挡无效事件,出口处用 Semantic Equality 掐断无意义的写放大,才能算是一个合格的 Kubernetes 控制器。 把这段修复逻辑提交、构建、重新部署之后,看着 Grafana 上 APIServer 的 QPS 曲线像瀑布一样跌回正常水位,Pod 的 CPU 使用率回落到 5m,内存停止了泄漏。 K8S 的架构设计确实优雅,但这种优雅建立在极其严密的声明式边界之上。在 Operator 开发中,当你敲下 Update() 或者 List() 的时候,脑子里必须浮现出背后那条从 Apiserver 到 ETCD,再流转回 Informer DeltaFIFO 的漫长链路。任何对状态的无节制修改,最终都会在系统层面上成倍地偿还。

  • 凌晨两点的IO风暴:谈谈那个写进Crontab的drop_caches

    凌晨2点40分。刚把几台Kafka Broker所在的物理机节点恢复,顺手掐掉了手机里还在每隔5分钟响一次的PagerDuty报警。盯着屏幕上终于降回10ms以内的P99延迟曲线,我点了一根烟(虽然只是在脑子里点了一下,毕竟机房和家里都禁烟)。
    这种深夜档的突发故障,通常不是因为底层组件出了什么史诗级的Bug,而是大概率有人在白天埋了颗雷,到了夜里流量低谷或者某种特定触发条件下,轰然炸响。
    但今天这个雷,不仅蠢,而且蠢得毫无技术底线。
    故障发生在1点15分。监控大盘上一路飘红,几个核心Topic的消费延迟瞬间飙升到2秒以上,紧接着这几个Broker节点的CPU iowait 毫无征兆地拉到了 80% 以上。
    我登进其中一台Broker,敲下第一条命令:

    iostat -xz 1
    

    屏幕上跳动的数据很吓人:数据盘的 %util 死死卡在 100%,await 突破了 3000ms,而且全是读IO。
    Kafka在正常消费堆积情况下的确会有磁盘读,但这几个核心Topic的消费者都是实时tail读,数据按理说应该全在内存(Page Cache)里,命中率极高,根本不可能产生这么恐怖的底层物理磁盘随机读。
    我接着敲了第二条命令看内存:

    free -g
    

    输出结果让我以为自己连错了机器:

                  total        used        free      shared  buff/cache   available
    Mem:            251          30         219           0           2         219
    Swap:             0           0           0
    

    一台256G内存的物理机,Kafka JVM堆只分了30G,这没问题。但这 buff/cache 只有区区 2G?剩下的 219G 全是 free
    系统内核的Page Cache就像是被什么东西给活生生抽干了。没有发生OOM(dmesg -T | grep -i oom 毫无动静),没有异常进程吃内存。这意味着有人或者有程序在主动释放缓存。
    我本能地扫了一眼定时任务和系统日志:

    grep CRON /var/log/syslog | tail -n 10
    

    屏幕上赫然出现了一行让我血压飙升的日志:

    Oct 24 01:15:01 broker-03 CRON[12435]: (root) CMD (echo 3 > /proc/sys/vm/drop_caches)
    

    顺藤摸瓜,我在 /etc/cron.d/ 下找到了一段新鲜配置。通过堡垒机操作审计发现,这是白天某位刚接手这块监控体系的同事用 Ansible 批量推上去的。
    我大概能猜到他的脑回路:他在Grafana上看到这批机器的“内存使用率”长期处于 95% 以上,觉得系统要崩了,Kafka要OOM了,于是自作聪明地搞了个脚本,每隔一段时间强行清空一次缓存,美其名曰“释放系统资源”。
    在这个技术点上犯错,是不可原谅的。这已经不是经验问题,而是对 Linux 操作系统和中间件底层运行机制的彻底无知。
    任何一个稍微深入过架构的人都应该知道,Linux的内存哲学就是“Free RAM is wasted RAM”(空闲的内存就是浪费)。内核会极度贪婪地将所有空闲内存用来缓存文件系统的数据(VFS Page Cache)。
    更致命的是,Kafka 的高性能架构,命脉恰恰就系在这个 Page Cache 上。
    Kafka 不像传统数据库那样自己维护庞大的 Buffer Pool,它直接将数据写入操作系统的文件系统,并完全依赖内核的 Page Cache 来进行读写加速。当消费者实时拉取数据时,Kafka 会调用内核的 sendfile() 系统调用。
    这是一个典型的 Zero-Copy(零拷贝)过程:

    ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
    

    在底层,数据直接从 Page Cache 拷贝到网卡驱动的 Scatter/Gather 缓冲区,全程不需要 CPU 参与数据在用户态和内核态之间的来回搬运。这就是为什么 Kafka 能在单机跑出几GB/s吞吐量的原因。
    但是,当你敲下 echo 3 > /proc/sys/vm/drop_caches 的那一瞬间,你把整个架构的基石砸得粉碎。
    缓存被强行清空后,所有消费者的读取请求瞬间引发海量的 Major Page Fault(硬缺页中断)。内核发现数据不在内存里,只能被迫将请求下发给底层的块存储设备。如果是几千个消费者并发读,这就相当于瞬间向磁盘发起了数以万计的随机读取指令。
    磁盘的 Queue Depth 瞬间被打满,IO 发生严重阻塞。Kafka 的网络线程在处理读请求时被死死卡在等待磁盘返回数据的系统调用上,进而导致请求队列积压,连接超时,最终导致了从底层OS到上层业务的全面雪崩。
    把 Page Cache 当成系统负担,看到剩余内存少就去清空,这是一种极其危险的“Windows XP 时代使用清理大师”的思维。
    我把那个愚蠢的 Cronjob 彻底删掉,随后静静地看着 free -g 里的 buff/cache 指标随着时间推移,从 2G 慢慢爬升到 150G 以上。磁盘的 await 降回了微秒级,世界重新恢复了清净。
    技术结论留在这里:
    在现代基于 Linux 的服务端架构中,监控和告警的内存指标永远不应该看总量使用率(Used = Total – Free)。正确的做法是监控 MemAvailable(在 Prometheus node_exporter 中对应 node_memory_MemAvailable_bytes)。Available 才是系统在不发生 Swap 交换的情况下,真正能挪出来给新进程使用的内存总量。
    不要去对抗内核的内存管理机制。当你觉得自己比 Linus Torvalds 更懂得如何分配物理内存时,你大概率正在制造一个 P0 级的灾难。

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

    上午十点半,阳光刚好打在机房外围办公室的玻璃上。正逢早高峰流量拉升的阶段,我正盯着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
    
  • 又是网络背锅?这次真的冤枉!

    今天差点被气笑了,真想把那位的键盘给扬了。上午十点多,业务部门突然炸锅,说用户访问速度慢的像蜗牛爬,卡的让人怀疑人生。第一反应肯定是网络问题,这年头,网络就是背锅侠,有问题先甩锅网络。
    赶紧登录监控平台,一看,CPU、内存、磁盘IO,一切正常,K8S集群里的Pod也都活蹦乱跳的。网络带宽使用率也平稳的很,根本没啥异常流量。 心里嘀咕,这网络这次真是躺枪了。
    然后开始抓包,tcpdump抓起来,Wireshark 分析走起。 盯着屏幕看了半天,发现大量的TCP Retransmission,重传率高的吓人。这下有点意思了,网络虽然没拥塞,但数据包丢的厉害,导致TCP疯狂重传,用户体验能好才怪。
    顺着IP地址,一路追踪,发现问题集中在某一个特定的微服务上。 登录服务器,查看日志,满屏的Exception。定睛一看,尼玛, NullPointerException, 空指针异常!
    代码大致如下:

    public class OrderService {
        @Autowired
        private RedisTemplate<String, Object> redisTemplate;
        public Order getOrder(String orderId) {
            // 从Redis获取订单信息
            Order order = (Order) redisTemplate.opsForValue().get("order:" + orderId);
            // 如果Redis中没有,则从数据库获取
            if (order == null) {
                order = orderRepository.findById(orderId).orElse(null);
                // 尼玛,这里没判断就直接用了!
                redisTemplate.opsForValue().set("order:" + orderId, order, 60, TimeUnit.SECONDS);
            }
            return order;
        }
    }
    

    我瞬间血压就上来了。 Redis做缓存,数据库做持久化没问题。 从数据库查出来的数据,如果是空,你倒是判断一下啊! 直接把一个 null 值塞到 Redis 里面,下次再来查,永远都是 null,然后每次都去数据库查,每次都是 null,然后又往 Redis 里面塞 null,死循环了! 这不是自己给自己挖坑吗?
    更可气的是,这家伙为了所谓的“性能优化”,给 Redis 设置了 60 秒的过期时间。 这下好了,每隔60秒,缓存失效,大量的请求涌向数据库,数据库扛不住了,连接数被打满,开始丢包。 TCP 重传率飙升,最终用户体验直线下降。
    这已经不是初级程序员才会犯的错误了,这种连最基本的空指针判断都忘记的,我真怀疑他是怎么混进来的。 这就好比开车忘记系安全带,还猛踩油门,出事是必然的。
    最后,我把代码改成了下面这样:

    public class OrderService {
        @Autowired
        private RedisTemplate<String, Object> redisTemplate;
        public Order getOrder(String orderId) {
            // 从Redis获取订单信息
            Order order = (Order) redisTemplate.opsForValue().get("order:" + orderId);
            // 如果Redis中没有,则从数据库获取
            if (order == null) {
                order = orderRepository.findById(orderId).orElse(null);
                // 尼玛,这里做一下判断!
                if (order != null) {
                    redisTemplate.opsForValue().set("order:" + orderId, order, 60, TimeUnit.SECONDS);
                }
            }
            return order;
        }
    }
    

    简单粗暴,加个判断就完事了。 然后重启服务, 清空Redis缓存。 一切恢复正常,用户又可以愉快的剁手了。
    技术结论:
    这次事故再次证明了,基础知识的重要性。 空指针异常这种低级错误,绝对不应该出现在生产环境中。 同时,缓存的使用一定要谨慎,避免缓存穿透、缓存击穿等问题。 对于不存在的数据,可以考虑在缓存中设置一个特殊的标记(例如一个特殊的字符串或者一个特定的对象),避免大量的请求穿透到数据库。 另外,完善的监控体系是必不可少的,能够帮助我们及时发现问题,避免损失扩大。
    哎,希望以后能少碰到这种让人哭笑不得的事情。 运维不易,且行且珍惜啊!