标签: 故障排查

  • TiDB 集群 P99 暴涨至 5000ms:一个 2 亿行大事务 DELETE 引发的 Percolator 惨案

    某次排查过程中,核心交易集群的 TiDB 节点发生大面积 OOM,集群 P99 延迟从日常的 10ms 直接飙升到 5000ms 以上,TiKV 节点接连抛出 Server is busy 拒绝服务。先说最终结论: 某位研发在后台归档任务中,执行了一条没有任何 LIMIT 和分批的 DELETE 语句,企图在一个事务内删掉 2 亿行历史数据。由于对底层 Percolator 分布式事务模型一无所知,这个超级大事务不仅瞬间抽干了 TiDB Server 的内存,残留在 TiKV 的海量锁和 MVCC 墓碑(Tombstone)更是直接引发了读写雪崩。

    案发现场:从 OOM 到全局雪崩

    监控面板上,故障的爆发几乎是垂直的:

    1. tidb_server_memory_usage 指标在 3 分钟内从 4GB 飙升到 64GB(容器 Limit),随后节点被内核 OOMKilled

    2. TiKV 的 Raft apply duration P99 飙到秒级,Coprocessor CPU 打满。

    3. 应用端出现大量 java.sql.SQLException: Lock wait timeout exceeded; try restarting transactionRegion is unavailable

    切到机器上抓一下 dmesg,典型得不能再典型的 OOM:

    [123456.789] Memory cgroup out of memory: Kill process 5678 (tidb-server) score 1980 or sacrifice child
    [123456.790] Killed process 5678 (tidb-server) total-vm:85934028kB, anon-vm:67108864kB, file-vm:0kB, shmem-vm:0kB
    

    翻看 INFORMATION_SCHEMA.SLOW_QUERY 和存活节点的 TiDB 日志,抓到了罪魁祸首:

    DELETE FROM trade_orders WHERE create_time < '2023-01-01 00:00:00';
    

    就是这么一句平平无奇的 SQL,命中了近 2 亿条数据。

    当我拿着这条 SQL 去找对应业务线的开发时,得到的答复是:“我们用的是分布式数据库啊,底层不是无限水平扩展的吗?删个历史数据怎么就挂了?”

    这种把分布式数据库当成魔法、完全无视底层物理定律的想法,是导致大多数生产灾难的根源。分布式 != 无底洞。

    刨根问底:为什么分布式数据库最怕“大事务”?

    在单机 MySQL (InnoDB) 中,大事务会撑爆 Undo Log,导致长事务阻塞和主从延迟。而在 TiDB 这类基于 Percolator 模型的分布式 HTAP 数据库中,大事务的杀伤力是指数级的。

    1. OOM 的元凶:两阶段提交(2PC)的内存缓冲

    TiDB 处理事务使用的是 Percolator 模型的变种。在事务提交(Commit)之前,客户端(即 TiDB Server)会把所有修改的数据缓存在自己的内存中。 当执行这句 2 亿行的 DELETE 时,TiDB Server 需要将这 2 亿个 Key 的修改操作(在底层,DELETE 也是一种写入,即写入包含 Tombstone 标记的 KV)装进内存。 算一笔最简单的账:单行数据的 Key + Value 加上事务元数据假设为 200 Byte。 200,000,000 * 200 Byte ≈ 40 GB。 更要命的是,Go 语言在处理如此庞大的对象分配时,GC 会产生巨大的开销,内存碎片加上堆栈扩展,轻轻松松就能把 64GB 的容器内存干爆。

    2. “掩耳盗铃”的配置修改

    其实 TiDB 为了防止这种惨案,出厂设置是有保护机制的:txn-total-size-limit 默认通常为 100MB。 理论上,这个事务早就该报 Transaction too large 失败了。但我查阅配置变更历史时发现,前段时间该业务线抱怨过几次批量更新报错,某位缺乏敬畏之心的运维,直接将全网的 txn-total-size-limit 改成了 10GB! 放开这种硬性防御阈值,等于拆掉了保险丝。TiDB 成功绕过了配置限制,然后死在了物理内存耗尽上。

    3. 锁残留与 Resolve Lock 风暴

    TiDB Server OOM 崩溃后,灾难并没有结束。 在 Percolator 2PC 的 Prewrite 阶段,TiDB 会在 TiKV 端写入大量的 Primary Lock 和 Secondary Lock。TiDB Server 进程猝死,导致这些锁变成了“孤儿锁”。 此时,正常的业务请求如果读取到了这些被锁住的 Key,就会发现事务处于 Pending 状态。为了保证 ACID,读请求必须触发锁清理机制(Resolve Lock)。 几十万个并发查询撞上几千万个残留锁,瞬间引发了海量的 RPC 交互:

    [WARN] [endpoint.go:612] ["error response"] [err="Key is locked (primary)"] 
    [WARN] [resolve.go:128] ["resolve lock timeout"] [txn=43981293847123984]
    

    TiKV 的 RPC 线程池直接打满,Raftstore 处理缓慢,最终导致大面积的 Region unavailable,连正常的小事务也无法提交。

    终极解法与避坑指南

    对于分布式数据库的批量数据清理,绝对不能用传统的“大事务一波流”。如果你需要删几亿条数据,请把“防御性编程”刻在脑子里。

    正规的落地姿势有三种:

    方案 A:非事务 DML(Non-transactional DML) 新版 TiDB 提供了原生的批处理语法,直接在内部完成分批提交,不保证事务的原子性(反正删历史数据也不需要原子性),彻底绕过大事务限制:

    BATCH ON id LIMIT 10000 
    DELETE FROM trade_orders WHERE create_time < '2023-01-01 00:00:00';
    

    方案 B:按时间分区的 Drop Partition 对于日志流、流水表,建表时就应该规划好时间分区(Partition By Range)。清理历史数据只需一条 ALTER TABLE trade_orders DROP PARTITION p2022;。这在底层仅仅是元数据的解绑,瞬间完成,没有 MVCC,没有锁冲突。

    方案 C:TiDB TTL (Time to Live) 机制 如果业务特性允许,直接在表结构上加上 TTL 属性:

    ALTER TABLE trade_orders TTL = `create_time` + INTERVAL 1 YEAR;
    

    交由 TiDB 后台按 Region 慢慢清理,对前台业务透明。

    排查清单:同类大事务问题速查 (Troubleshooting Checklist)

    1. 核对 OOM 与系统日志 立刻在 TiDB 节点执行 dmesg -T | grep -i oom,如果命中 tidb-server,说明发生过严重的内存挤兑,大概率是大事务或者无索引的巨型 JOIN。

    2. 定位元凶 SQL 检索 INFORMATION_SCHEMA.SLOW_QUERY,重点关注 Mem_maxTxn_start_tsQuery_time 极大的语句: SELECT query, mem_max, process_time FROM information_schema.slow_query ORDER BY mem_max DESC LIMIT 5;

    3. 检查全局限制配置 不要盲目调大保护参数。检查 tidb_mem_quota_query(单条 SQL 内存限制)和 txn-total-size-limit(总事务大小限制),恢复到合理阈值(推荐单事务不要超过 1GB)。

    4. 清理遗留的悲观锁/乐观锁 如果 OOM 后集群持续卡顿,观察 Grafana 中的 TiKV-Details -> Locks 面板。必要时可通过临时调低 resolve-lock 的 backoff 时间来加速孤儿锁清理,或联系官方辅助清理陈旧的 MVCC tombstone 触发手动 Compaction。

    分布式架构给了你海量存储的错觉,但底层的内存、网络 IO 和锁机制依然遵循着严密的物理约束。在生产环境敲下回车之前,想想底层要付出多大的代价。

  • Exit Code 159 连环暴雷:一份“原汁原味”的 Seccomp 配置是如何干碎生产集群的

    排查某核心计费链路故障时,处理了一起令人血压飙升的 P0 事故。现象很简单:核心服务在一次例行发布后陷入无限 CrashLoopBackOff,容器退出码清一色是 159。而真正引发雪崩的,是研发为了绕过报错,随手加上的一句 privileged: true,直接触发了节点级 Falco 规则引擎的“死亡螺旋”,导致整台宿主机 Load Average 飙升至 80+,最终 OOM。

    结论先行:Exit Code 159 意味着进程收到了 SIGSYS (128 + 31) 信号,触发了 Seccomp 机制的系统调用拦截。 事故的根本原因是业务团队为了应付安全合规扫描,从几年前的博客上盲目抄了一份 Seccomp 白名单配置,漏掉了新版 glibc 强依赖的 clone3 系统调用。更不可原谅的是,面对拦截,他们没有去审计日志补齐规则,而是选择直接裸奔,进而引爆了底层的安全监控器。

    防御性编程的底线在于:不要用更大的错误,去掩盖一个你没看懂的报错。 接下来,我们把事故现场扒开,看看底层到底发生了什么。

    现场复原:神秘的 159 退出码与“消失的线程”

    服务起不来,查看 Pod 状态:

    $ kubectl get pods -n billing
    NAME                              READY   STATUS             RESTARTS   AGE
    billing-svc-7f8b9d4c-x9j2k        0/1     CrashLoopBackOff   12         3m
    

    看一眼容器退出日志,没有任何 Java 异常栈,只有一句冰冷的提示:Pod the container terminated with exit code 159.

    遇到 159,老鸟的直觉应该立刻指向 Seccomp(Secure Computing Mode)。登录所在 Node,直接翻内核审计日志:

    $ dmesg -T | grep audit | grep "sig=31"
    [Mon ...] audit: type=1326 audit(1690000000.123:45): auid=4294967295 uid=1000 gid=1000 ses=4294967295 pid=14321 comm="java" exe="/opt/java/bin/java" sig=31 arch=c000003e syscall=435 compat=0 ip=0x7f8a9b8c2d4e code=0x80000000
    

    这是一条标准的 Seccomp 拦截日志。拆解一下核心字段:

    • sig=31:触发了 SIGSYS 信号,内核直接 Kill 了该线程。

    • arch=c000003e:代表 x86_64 架构。

    • syscall=435:重点来了,在 x86_64 下,系统调用号 435 对应的是 clone3

    • code=0x80000000:对应 SECCOMP_RET_KILL_THREAD

    为什么会突然拦截 clone3?排查后发现,业务基础镜像最近升级到了基于 Ubuntu 22.04(内置 glibc 2.34+),而新版 glibc 在创建线程时默认优先使用 clone3。但业务提交的那份陈年 Seccomp 白名单(Default Profile)里,压根没有 435 这个系统调用!

    灾难升级:当“掩耳盗铃”遇上 Falco 规则引擎

    按照正常的逻辑,拿到 syscall=435,去 Seccomp Profile 的 syscalls 列表里加上 clone3 就完事了。但研发团队为了快速恢复,做了一个极其愚蠢的操作:直接在 YAML 里移除了 Seccomp 限制,甚至为了“保险起见”,加了特权模式:

    securityContext:
      privileged: true # 罪恶之源
      # seccompProfile:
      #   type: Localhost
      #   localhostProfile: "strict-profile.json"
    

    Pod 确实跑起来了,但集群的噩梦才刚刚开始。监控大屏上,该 Node 的 CPU 使用率瞬间打满,Falco(容器安全监控系统)的 Pod 疯狂重启。

    抓取 Node 的 top 和 eBPF 性能指标,发现 Falco 正在被按在地上摩擦。为什么?

    因为集群的安全团队在 Falco 中配置了这样一条规则,用于监控特权容器内的可疑命令执行:

    - rule: Privileged Container Exec
      desc: Detect any execve in a privileged container
      condition: >
        evt.type = execve and container
        and container.privileged = true
        and proc.cmdline pmatch ( "sh", "bash", "curl", "wget" )
      output: "Privileged execve (user=%user.name container_id=%container.id command=%proc.cmdline)"
      priority: WARNING
    

    注意那个 pmatch(正则前缀匹配)。业务 Pod 配置了 livenessProbe,每 5 秒执行一次 sh -c "curl -s http://localhost:8080/health"。 由于改成了特权容器,探针的每一次执行都会命中这条 Falco 规则。更要命的是,正则表达式是非常消耗 CPU 的操作。在高并发场景下,海量的 sys_enter_execve 事件涌入 Falco 的 eBPF Ring Buffer,导致 Falco 陷入重度计算,大量事件 Drop:

    # 查看 Falco drop 统计
    $ curl -s http://localhost:8765/metrics | grep falco_stats_drop_count
    falco_stats_drop_count 4589212
    

    最终,Falco 因处理不过来吃光了内存,被宿主机的 OOM Killer 无情干掉,整个节点短暂处于监控盲区。

    技术结论与正规军玩法

    解决这类问题,靠的不是拍脑袋加权限,而是建立正确的安全配置基线和调试方法。

    1. 永远不要用 SECCOMP_RET_KILL 作为默认动作调试 在生产环境引入自定义 Seccomp 前,正确的做法是先将 default action 设置为 SCMP_ACT_LOG。这样内核只会记录审计日志,而不会杀死进程:

    {
      "defaultAction": "SCMP_ACT_LOG",
      "syscalls": [
        {
          "names": ["clone", "clone3", "epoll_pwait", "futex"],
          "action": "SCMP_ACT_ALLOW"
        }
      ]
    }
    

    跑几天后,提取 /var/log/audit/audit.log 里的记录,分析出业务实际需要的 syscall 集合,再切回 SCMP_ACT_ERRNOSCMP_ACT_KILL

    2. 使用 SPO(Security Profiles Operator)自动化录制 不要手工猜系统调用。K8s 官方提供的 Security Profiles Operator 支持 LogEnricher 机制,可以在 Staging 环境跑一遍完整的回归测试,SPO 会自动帮你生成精确到业务级别的 Seccomp/AppArmor Profile。

    3. Falco 规则的防御性优化 Falco 规则引擎极度依赖条件短路(Short-circuit evaluation)。

    • 将高频过滤条件(如 evt.type = execve)放在最前面。

    • 尽量用 in= 替代正则 pmatchregex

    • 必须对 K8s 探针做白名单豁免,绝不能让健康检查触发报警逻辑。

    排查清单:容器运行时安全拦截速查

    遇到容器莫名其妙死亡、无日志退出、或权限拒绝时,请直接核对以下三步:

    1. 核对 Exit Code 159 (Seccomp拦截)

      • 现象:容器 CrashLoopBackOff,退出码 159
      • 命令:dmesg | grep -i seccompjournalctl -k | grep "sig=31"
      • 动作:提取 syscall= 后面的数字,去查阅 ausyscall x86_64 ,确认被拦截的调用(常见如 clone3=435, rseq=334)。
    2. 核对 AppArmor 拦截 (EPERM / Permission Denied)

      • 现象:代码里抛出 EPERM,或者 open/mkdir 报错,但文件权限明明是 777。
      • 命令:dmesg | grep -i apparmor | grep DENIED
      • 动作:检查 profile= 字段,确认是否使用了过于严苛的 AppArmor 模板限制了特定目录的写权限。
    3. 核对 Falco 性能瓶颈 (节点 Load 飙升 / 事件丢弃)

      • 现象:部署 Falco 后宿主机 CPU 升高,应用延迟抖动。
      • 命令:检查监控指标 falco_stats_drop_count
      • 动作:排查是否有规则使用了高昂的 regex,或者审计了太高频的 open / read 等系统调用,务必加上 container.name 的白名单豁免。
  • 深入排查 Go 业务 CPU 尖峰:从 pprof 盲区到 Linux perf 揭秘 futex 锁竞争实战

    仅靠 pprof 无法彻底看清 Go 程序的性能瓶颈。在某次高并发网关的 CPU 突发抖动排查中,pprof 仅显示微小的 GC 耗时,而通过 Linux perf 结合火焰图,最终定位到底层元凶是 sync.RWMutex 导致的系统调用 futex 激烈竞争。本文将还原从应用层到内核层的持续性能剖析过程。

    现场还原:幽灵般的 CPU 尖峰

    某次核心网关业务进行压测时,系统 p99 延迟从稳定的 20ms 突增至 800ms 以上。此时监控面板上出现了诡异的现象:

    • 节点 Load Average 狂飙,远超 CPU 核心数。

    • top 命令显示该 Go 进程(基于 Go 1.20.4 编译,运行于 Linux 5.10 内核)CPU 占用率达到 700%(8核机器)。

    • 但通过 go tool pprof 抓取 30 秒的 CPU Profile,看到的消耗却非常平缓。

    执行标准 pprof 采样:

    go tool pprof -text http://localhost:6060/debug/pprof/profile?seconds=30
    

    输出结果显示,没有任何一个业务函数占用超过 5% 的 CPU 时间,排在前面的全是 runtime 调度和网络 epoll 等底层函数:

    Showing nodes accounting for 1.20s, 35.10% of 3.42s total
    Dropped 214 nodes (cum <= 0.02s)
          flat  flat%   sum%        cum   cum%
         0.45s 13.16% 13.16%      0.45s 13.16%  runtime.epollwait
         0.30s  8.77% 21.93%      0.30s  8.77%  runtime.futex
         0.25s  7.31% 29.24%      0.40s 11.70%  runtime.findrunnable
         ...
    

    pprof 统计的总耗时只有区区 3.42s,这与 top 看到的进程 700% 满负荷运行(30秒内理应消耗接近 210秒的 CPU 时间)存在巨大的鸿沟。

    为什么 pprof 的采样数据与 top 看到的 CPU 负载严重不符?

    这涉及 Go pprof 的底层采样机制盲区。

    Go 原生的 CPU Profiler 默认通过 setitimer 系统调用触发 SIGPROF 信号进行采样(频率默认 100Hz)。当程序大量时间消耗在 系统调用(Syscalls) 阻塞、不可中断睡眠状态,或者发生极高频的内核态上下文切换时,基于用户态信号的 Profiler 往往会发生“漏采”。

    简单来说:pprof 擅长看 User Space 的纯计算逻辑(如序列化、复杂算法),但对于 Kernel Space 的阻塞和抢占,它是个高度近视眼。当你的 CPU 时间被内核态吃干抹净时,pprof 交出的报告自然是一笔糊涂账。

    穿透内核:使用 perf 与 FlameGraph 还原真相

    既然用户态工具失明,必须动用 Linux 系统级性能调优核武器:perf。通过记录 CPU 硬件计数器,我们能同时捕获 User 和 Kernel 栈。

    1. 抓取全局性能事件

    在问题机器上直接对该进程进行 30 秒的全栈采样(采样频率设为 99Hz 以避免与特定周期事件共振):

    # -F 99: 99次/秒采样频率
    # -p: 进程号
    # -g: 记录调用栈 (call graph)
    perf record -F 99 -p 18374 -g -- sleep 30
    

    2. 生成火焰图

    原始的 perf.data 不可读,通过 Brendan Gregg 的火焰图工具链进行可视化转换:

    # 解析 perf.data 输出明文
    perf script > out.perf
    
    # 折叠调用栈
    ./stackcollapse-perf.pl out.perf > out.folded
    
    # 生成 SVG 火焰图
    ./flamegraph.pl out.folded > cpu_flamegraph.svg
    

    3. 火焰图解析

    打开 cpu_flamegraph.svg 后,真相大白。火焰图的 X 轴表示 CPU 耗时比例。 在生成的火焰图中,有一座极为宽阔的“平顶山”(占总 CPU 宽度的 60% 以上),调用链明确指向: 业务函数 getFromCache -> sync.(*RWMutex).RLock -> runtime.gopark -> runtime.futex -> [kernel.kallsyms] -> sys_futex -> do_futex

    这意味着:CPU 的计算资源根本没有用来处理业务逻辑,而是全耗在了内核锁原语 futex(Fast Userspace Mutex)的自旋、挂起和唤醒操作上。

    根因剖析:读写锁降级与 sys_futex 风暴

    切回业务代码,排查 getFromCache 所在的逻辑:

    var cacheLock sync.RWMutex
    var globalCache = make(map[string]string)
    
    func getFromCache(key string) string {
        cacheLock.RLock()
        defer cacheLock.RUnlock()
        return globalCache[key]
    }
    

    这段看似极度常规的读缓存代码,在超高并发(十万级 QPS)下是个致命的性能毒药。

    Go 的 sync.RWMutex 在设计上偏向写公平。当有一个写锁请求(Lock())到达时,后续所有的读锁请求(RLock())都会被阻塞排队,以防止写饥饿。 排查过程中发现,有个后台 Goroutine 每 10 秒会全量刷新一次该 globalCache 并加写锁。

    在这个极短的写锁持有窗口期内:

    1. 海量的读请求涌入,全部在 RLock() 处被拦截。

    2. Go 的 P(Processor)发现 Goroutine 阻塞,触发 runtime.gopark 让出执行权。

    3. 底层 M(OS 线程)调用内核 futex 将线程挂起等待。

    4. 写锁释放时,使用 futex 唤醒数以千计堆积的 Goroutines。

    5. 爆发 惊群效应(Thundering Herd),大量线程瞬间从休眠态转为就绪态,疯狂抢占 CPU,产生极其惨烈的 Context Switch。

    极客排查与改造方案

    明确了是全局单点锁在多核架构下的竞争问题,解决方案必须走向“无锁化”或“锁粒度细化”。

    方案一:锁分片(Lock Sharding)

    最典型的防御性编程思路,参考 ConcurrentHashMap 的分段锁。

    const shardCount = 256
    
    type ShardedCache struct {
        shards [shardCount]struct {
            sync.RWMutex
            data map[string]string
        }
    }
    
    // 散列函数,规避单点竞争
    func (c *ShardedCache) getShard(key string) int {
        hash := fnv.New32a()
        hash.Write([]byte(key))
        return int(hash.Sum32()) % shardCount
    }
    
    func (c *ShardedCache) Get(key string) string {
        shard := &c.shards[c.getShard(key)]
        shard.RLock()
        defer shard.RUnlock()
        return shard.data[key]
    }
    

    通过 256 个分片,将锁竞争的碰撞概率降到了原来的 1/256,彻底消除了单点 futex 风暴。

    方案二:写时复制(Copy-on-Write) + atomic.Value

    既然是读多写少的缓存场景,使用原子操作直接替换底层指针是性能最高的方式,达到读操作 0 阻塞。

    var cache atomic.Value
    
    // 初始化
    cache.Store(make(map[string]string))
    
    func getFromCache(key string) string {
        // 无锁读取
        m := cache.Load().(map[string]string)
        return m[key]
    }
    
    func updateCache(newData map[string]string) {
        // 整个替换 map 指针
        cache.Store(newData)
    }
    

    改造上线后,再次抓取 perf 火焰图,sys_futex 的高塔完全消失,节点 Load Average 从 30 回落到 2 左右,p99 延迟稳定在 15ms。

    常见问题 (FAQ)

    Q1:线上运行 perf record 收集数据,会对生产环境业务造成明显的性能损耗吗? 只要不使用过高的采样频率,开销是完全可控的。文章中推荐使用 -F 99(每秒 99 次)而不是默认的 -F 4000 或直接不加限制。对于生产环境,99Hz 产生的额外 CPU 开销通常不到 1%,完全可以安全进行数分钟的常规采样。

    Q2:如果程序的内存一直缓慢上涨,但 pprofheap 视图看到的 inuse_space 很小,该用什么思路排查? 大概率发生了非 Go 堆内存泄漏(即 CGO 调用、mmap 显式分配、或者 glibc/jemalloc 底层的碎片化)。此时 pprof 无能为力。建议通过 cat /proc//smaps 查看具体的内存段映射,结合 bcc/eBPFmemleak 工具,或者使用 perf record -e page-faults 追踪哪些底层 C 函数在频繁触发缺页中断。

    Q3:除了手敲命令生成 SVG,现在业界有哪些主流的持续性能分析(Continuous Profiling)落地架构? 现代云原生架构多采用基于 eBPF 的持续 Profiling 平台。主流开源方案包括 Pyroscope 和 Parca。它们通过 DaemonSet 在每个 Kubernetes 节点部署 Agent,利用 eBPF 的低开销特性全天候抓取所有 Pod 的 CPU/内存/锁信息,并存储在专门的时序数据库中,支持随时回溯任意时间点的火焰图,是排查偶发性能毛刺的最佳实践。

  • Jenkins K8S 动态 Agent 疯狂重启劫难:被隐式降级击穿的 JNLP 通信防线

    某次排查过程中,核心业务线的 CI/CD 流水线彻底瘫痪,Jenkins 任务队列(Queue)积压突破 500。与此同时,底层 Kubernetes 集群告警群炸锅,API Server 出现严重的请求限流(Throttling),P99 延迟飙升至 3 秒以上。

    最终排查结论:架构团队在做 Jenkins 迁移与高可用改造时,仅配置了 Layer 7 的 Ingress 规则,却遗漏了 Jenkins Remoting 通信依赖的 Layer 4 TCP(50000)端口。导致 K8S 动态 Agent Pod 启动后无法与 Master 建立 JNLP 连接。Jenkins Kubernetes 插件因此陷入了致命的“申请 Pod -> Agent 注册超时 -> 销毁 Pod -> 无限重试”死循环,硬生生把集群 API Server 给打穿了。

    把 Jenkins 当成一个普通的无状态 Web 服务去搞云原生改造,而不去深究其底层 Master-Agent 的心跳与通信模型,这种粗暴的操作在生产环境中是极其致命的。

    案发现场:失控的调度器与死亡循环

    接到报障后,第一时间登录集群查看资源状态。终端里的现象令人窒息:

    $ kubectl get pods -n jenkins | grep jnlp-agent | wc -l
    842
    
    $ kubectl get pods -n jenkins | grep jnlp-agent | head -n 5
    jnlp-agent-8f73b-5x9qp   0/1     ContainerCreating   0          12s
    jnlp-agent-8f73b-9m2kx   1/1     Terminating         0          1m45s
    jnlp-agent-8f73b-p2v1l   0/1     ContainerCreating   0          8s
    jnlp-agent-8f73b-x8c4d   1/1     Terminating         0          1m45s
    

    数百个 Agent Pod 处于 ContainerCreatingTerminating 状态。再去查看 Jenkins Master 的系统日志,满屏都是类似下面的报错:

    INFO: Kubernetes pod jnlp-agent-8f73b-9m2kx started
    WARNING: Failed to connect to agent jnlp-agent-8f73b-9m2kx within 100 seconds. 
    INFO: Terminating node jnlp-agent-8f73b-9m2kx
    INFO: Queue task #4023 still pending, provisioning a new agent...
    

    转头查看其中一个 Agent Pod 的内部日志,终于抓到了真凶:

    INFO: Locating server among [https://jenkins.company.com/]
    WARNING: Failed to connect to https://jenkins.company.com/tcpSlaveAgentListener/: Connection refused
    java.net.ConnectException: Connection refused
        at sun.nio.ch.SocketChannelImpl.checkConnect(Native Method)
    ...
    INFO: Retrying in 10 seconds
    

    深度剖析:为什么缺少一个端口会导致雪崩?

    要理解这个故障,必须理清 Jenkins Kubernetes Plugin 的工作状态机。这绝不只是一个“网络不通”的简单 Bug,而是一个典型的分布式状态机不同步导致的雪崩。

    1. Remoting 协议的固执:Jenkins Master 与 Agent 之间的通信基于 Jenkins Remoting 协议,这是一个重度依赖序列化与长连接的 Java 二进制协议。默认情况下,Agent 启动后,会先通过 HTTP(S) 请求 Master 的主入口,获取 X-Jenkins-CLI-Port 或相关 TCP 端口信息(通常是 50000),随后尝试建立直连 TCP 通道。

    2. L7 Ingress 的拦截:改造期间,Jenkins Master 被放到了 Nginx Ingress 后端。Ingress 默认只处理 HTTP/HTTPS 协议(L7)。当 Agent 尝试向 jenkins.company.com:50000 建立 TCP 握手时,流量直接在网关层被丢弃或拒绝。

    3. 致命的机制错位(State Mismatch)

    4. K8S 视角:Pod 已经成功拉起,容器状态是 Running,K8S 认为任务完成。
    5. Jenkins 视角:向 K8S 发送了 Pod 创建请求,且等待 Agent 进程发起 JNLP 注册回调。
    6. 死循环触发:等待 100 秒后(默认超时时间),Jenkins Master 依然没收到 Agent 的 JNLP 注册心跳。它不仅不会认为是自己的网络配置问题,反而会固执地判定:“这个 Pod 死掉了,为了满足队列里等待的构建任务,我必须销毁它,并向 K8S 申请一个新的 Pod。”

    当并发构建任务达到 50 个,每个任务都在触发这种“申请 -> 等待 -> 销毁 -> 再申请”的循环时,K8S 的 kube-apiserver 就成了重灾区。大量的 POST /api/v1/namespaces/jenkins/podsDELETE 请求瞬间填满了 API Server 的队列,触发限流,进而影响整个集群内其他核心业务 Pod 的调度与扩缩容。

    解决方案与防御性配置

    针对此类问题,修复网络通信只是第一步,更重要的是在架构层面加上防御性兜底限制。

    1. 拥抱 WebSocket,抛弃底层 TCP 直连

    既然 L4 暴露配置繁琐且容易在各种负载均衡器上踩坑,最优雅的做法是直接让 JNLP 流量复用 HTTP(S) 的 L7 通道。从 Jenkins 2.217 开始,Remoting 已经原生支持 WebSocket。

    在 JCasC (Jenkins Configuration as Code) 的配置中,必须在 K8S Cloud 配置项里显式开启 webSocket: true

    jenkins:
      clouds:
        - kubernetes:
            name: "kubernetes"
            # 直接走集群内部 DNS 通信,绕过外部 Ingress,降低网络开销与故障点
            serverUrl: "https://kubernetes.default"
            namespace: "jenkins-agents"
            jenkinsUrl: "http://jenkins-master.jenkins.svc.cluster.local:8080"
            # 开启 WebSocket,彻底解决 TCP 50000 端口穿透问题
            webSocket: true
            # 【防御性编程核心】设置全局容量上限,哪怕死循环也不会打穿 API Server
            containerCapStr: "100" 
    

    2. 配置 Kubernetes Plugin 的防雪崩限制

    永远不要假设外部系统会乖乖按预期工作。必须给 Jenkins 向 K8S 索要资源的行为加上硬性枷锁:

    • containerCapStr: 限制整个 K8S Cloud 并发存活的 Agent 总数。

    • 在每个 podTemplate 级别设置 instanceCap:防止单一异常的 Pipeline 把所有集群资源耗尽。

    3. 剥离通信链路(Cluster Internal Routing)

    如果你只是在同一个 K8S 集群内部署 Jenkins Master 和调度 Agent,Agent 连接 Master 绝对不应该 绕一圈跑到外网 Ingress 再进来。不仅增加延迟,还多引入了一层网络设备的故障风险。 强制在 jenkinsUrl 中使用 K8S 内部的 FQDN:http://..svc.cluster.local:

    排查清单与同类问题速查

    如果你也遇到了 Jenkins Agent 疯狂重启或一直在 Pending/Terminating 之间横跳,请核对以下清单:

    1. 排查 JNLP 握手阻断:查看 Agent Pod 的日志。如果出现 Connection refusedConnection timed out,且指向 Master 的 50000 端口,立刻检查安全组、网络策略 (NetworkPolicy) 或 LoadBalancer 的 L4 暴露情况,或者直接开启 WebSocket。

    2. 检查 Jenkins Master URL 配置:如果 Manage Jenkins -> System -> Jenkins URL 配置错误,Agent 会拿到一个无法解析的地址。在 K8S 环境下,尽量在 Cloud 配置的 jenkinsUrl 中覆盖并强制指定 ClusterIP 或内部 DNS。

    3. 监控 ContainerCap 触顶情况:如果在 Jenkins 侧看到任务一直卡在 ‘Jenkins’ doesn’t have label ‘xxx’ 或者 Waiting for next available executor,但没有看到新 Pod 创建,检查系统日志确认是否触发了 containerCap 上限。

    4. 防御性兜底检查:确认有没有恶意的 Groovy 脚本在无限触发重试。检查 Pipeline 里的 retry() 块逻辑是否包含了环境构建阶段,避免因业务代码逻辑错误引发基础设施级别的 Ddos 攻击。