一次由Jenkins流水线中不当Docker清理引发的服务器超时事件复盘

1. 事件背景

jenkins-worker-01 是我司核心的CI/CD构建节点,负责执行多个项目的Jenkins Pipeline任务。为了保持节点环境整洁,多数流水线在构建结束后都包含一个标准的“清理工作区”阶段,该阶段会自动删除本次构建产生的悬空Docker镜像(dangling images)。

某日下午,开发团队在一个长期未成功构建的项目 project-zeus 上触发了一次新的构建。流水线在执行到最后的清理阶段时,Jenkins的Web界面显示该阶段长时间处于“运行中”状态,无任何日志输出。

该清理阶段的核心逻辑如下 (摘自 Jenkinsfile):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
stage('Cleanup Workspace') {
steps {
script {
// 查找悬空镜像ID并拼接成一个字符串
def danglingImages = sh(
script: 'docker images -f "dangling=true" -q',
returnStdout: true
).replaceAll('\n', ' ')

// 如果存在,则一次性全部删除
if (danglingImages) {
echo "Cleaning up dangling images..."
sh "docker rmi ${danglingImages}"
}
}
}
}

由于该项目及其他项目在 jenkins-worker-01 节点上累积了数月的构建失败产物,本次清理任务需要处理的悬空镜像数量预估超过了500个。

2. 故障现象

  1. Jenkins任务卡死project-zeus 流水线的清理阶段长时间运行不结束。

  2. 节点响应迟钝:尝试通过SSH登录 jenkins-worker-01 节点排查问题,发现终端响应极其缓慢。

  3. 核心服务无响应:在节点上执行任何 systemctl 命令(如 systemctl status jenkins-agent),均返回超时错误:

    1
    Failed to activate service 'org.freedesktop.systemd1': timed out
  4. 监控系统告警:监控平台显示该节点的 磁盘I/O Wait 指标(%wa)一度超过90%,多个关键进程处于 D 状态(不可中断睡眠)。

3. 排查与处理过程

3.1 初步诊断

通过 topiotop 命令艰难地观察到,dockerd 进程正在进行疯狂的磁盘读写,I/O负载极高。确认问题是由于Docker的大规模删除操作引发了I/O性能瓶颈。

3.2 尝试优雅重启

判断系统已无法通过常规手段恢复,首先尝试了标准的 reboot 命令。

  • 现象:终端返回了关机广播信息后,SSH连接挂起,服务器无任何后续响应。
  • 结果:10分钟后,节点依然存活(可ping通),但Jenkins Agent已断开,所有服务均不可用。reboot 的优雅关机流程被阻塞。

3.3 实施强制重启

确定 systemd 自身已被I/O问题“冻结”,无法完成关机任务。作为最终手段:

  1. 通过云服务商的VNC控制台登录服务器。
  2. 执行强制重启命令 reboot -f
  3. 系统立即重启,并在启动后执行了文件系统检查(fsck)。随后,节点恢复正常,并重新与Jenkins主节点建立连接。

4. 根本原因分析 (RCA)

本次故障的根源在于自动化脚本中一个看似无害却极其危险的设计。

4.1 直接原因

systemctl 命令超时是因为它无法与 systemd (PID 1) 进程正常通信。systemd 因等待磁盘I/O完成而被内核挂起,无法响应任何D-Bus请求。

4.2 根本原因

Jenkins流水线中的 replaceAll('\n', ' ') 代码,将所有悬空镜像的ID拼接成一个巨大的、由空格分隔的字符串。最终执行的 docker rmi ${danglingImages} 命令等同于在shell中执行 docker rmi ID1 ID2 ID3 ... ID500+。这个单次命令要求Docker守护进程在短时间内完成数百个镜像的删除操作,瞬间产生的海量I/O请求压垮了服务器的磁盘子系统。

4.3 连锁反应详解

  1. 批量指令:Jenkins脚本构造并执行了一个超长的 docker rmi 命令。
  2. I/O风暴dockerd 接收指令后,开始并行或快速串行地删除大量镜像层,引发I/O风暴。
  3. systemd 阻塞systemd 自身的常规I/O操作(如写日志)被阻塞在拥堵的队列中,导致进程进入 D 状态。
  4. 优雅重启失败reboot 命令依赖 systemd 来协调关机,而已被阻塞的 systemd 无法执行此任务,导致关机流程死锁。
  5. 强制重启成功reboot -f 命令绕过了 systemd,直接向内核发送重启指令,因此能够成功执行。

5. 解决方案与改进措施

为了根除此类隐患,必须从优化自动化脚本和规范流程入手。

5.1 短期措施:修正存在问题的Pipeline脚本

立即审计所有 Jenkinsfile,将上述危险的清理逻辑替换为安全、可控的迭代式清理。

【修正后的安全清理阶段 (Groovy)】

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
stage('Safe Cleanup Workspace') {
steps {
script {
echo "正在查找悬空镜像..."
// 获取ID列表并分割成数组,为逐个处理做准备
def danglingImagesList = sh(
script: 'docker images -f "dangling=true" -q',
returnStdout: true
).trim().split('\n')

if (danglingImagesList && danglingImagesList[0]) {
echo "发现 ${danglingImagesList.size()} 个悬空镜像,将逐个安全清理..."
// 循环删除,避免单次命令造成I/O风暴
for (imageID in danglingImagesList) {
echo "--> 正在删除镜像: ${imageID}"
try {
sh "docker rmi ${imageID}"
// 关键:每次删除后暂停2秒,平滑I/O负载
sh "sleep 2"
} catch (e) {
echo "删除镜像 ${imageID} 失败: ${e.getMessage()}"
}
}
} else {
echo "没有发现悬空镜像。"
}
}
}
}

核心改进:通过将ID列表分割成数组,并使用 for 循环和 sleep,将集中的I/O压力有效分散,从根本上避免了I/O风暴。

5.2 长期建议:架构与流程优化

  1. 代码审查流程:将 CI/CD 流水线脚本 (Jenkinsfile 等) 纳入代码审查 (Code Review) 范围,重点关注其中与系统资源交互(如文件、网络、进程操作)相关的部分。
  2. 节点存储升级:评估并推动将核心构建节点的Docker工作目录迁移至SSD,以大幅提升I/O承载能力。
  3. 专用清理任务:创建一个独立的、专用的Jenkins维护任务,使用上述安全脚本,在所有节点的业务低峰期(如每日凌晨)统一执行清理,而不是在每个业务流水线后执行。

6. 总结

本次事件是一次深刻的教训,它表明自动化脚本中的“效率陷阱” 同样致命。一个为了便捷而设计的“一句话命令”,在特定条件下会成为导致系统崩溃的元凶。所有运维和开发人员必须认识到,自动化工具在放大效率的同时,也在放大错误的破坏力。对底层系统(尤其是I/O)的敬畏之心,是构建稳定可靠自动化体系的基石。