一、问题从哪来:容器退出了,但没完全退出

在平时的运维和开发中,我们经常使用 CRI-O 来跑容器。容器退出后,按理说它的一切都应该被清理干净,就像住客退房后,酒店会把房间打扫干净一样。但实际情况是,有时候房间根本没打扫干净,留下了“垃圾”和“钉子”。这些垃圾在系统里就是僵尸进程和残留挂载点。

僵尸进程听起来吓人,其实就是一个已经死掉的进程,它的任务已经完成,但是没有任何机制把它从进程表里摘掉。它不占CPU,也不占内存,但占着一个进程号(PID),而且你没法用 kill 命令杀死它。残留挂载点呢,就是容器曾经挂载过的存储目录,卸载的时候没卸干净,还挂在系统里。这些看似小问题,积累多了就会让系统变得卡顿,甚至新容器都起不来。

CRI-O 本身的设计是好的,它会调用 conmon 这样的组件来监控容器状态,容器退出后,conmon 会负责把退出的状态告诉上层,然后层层清理。但是,总有一些特殊情况会导致清理链条断掉。比如容器进程被某个父进程一直持有,或者存储驱动出了问题,再或者系统崩溃后重启,遗留了之前的挂载信息。

下面我们用一个具体的场景来说明。假设你在一台装有 CRI-O 的服务器上,用 Kubernetes 跑了一个 Pod,后来这个 Pod 被删除了。正常情况下,容器里的所有进程都应该消失,挂载点也应该被卸载。但你在执行 ps 命令后,却发现了一些状态是 Z 的进程。你会看到类似这样的输出:


# 查看系统中全部的僵尸进程,状态显示为Z
ps -eo stat,pid,ppid,cmd | awk '$1 ~ /^Z/ {print}'

如果输出里有一堆记录,说明僵尸进程已经存在了。同时你还可以看看挂载点:


# 查看与容器存储有关的挂载点
mount | grep -E "overlay|containers" 

如果看到很多 /var/lib/containers/overlay 开头的记录,说明残留挂载点也不少。这时候,你如果直接去创建新的容器,很可能会收到类似的报错:mountpoint already in use 或者 device or resource busy。

那怎么办呢?别急,我们有手动根治的办法。

二、隐患有多大:别小看“不大”的问题

很多开发者觉得,僵尸进程不占CPU不占内存,挂载点就挂在那儿不动,能有多大危害?这里我告诉你,小问题也能酿成大事故。

先说僵尸进程。它虽然不干活,但占着进程表条目。Linux 内核维护一个进程表,进程表大小是有上限的。如果僵尸进程涨到一万个,新的进程就没法创建了。到时候你想敲一个 ls 命令,可能都会报错 fork: cannot allocate memory。另外,僵尸进程的父进程一旦被反复触发,还会增加系统的调度负担。更麻烦的是,僵尸进程的存在往往说明有程序逻辑没处理干净,后面可能还有更严重的问题。

再说残留挂载点。挂载点不卸载,意味着那个目录被“焊死”在系统上了。你去删目录,删不掉;你去挂载新设备,挂不上;你去查看磁盘空间,会被它占用;如果你要重新创建容器,Kubelet 会检查存储目录是否干净,发现不干净就直接拒绝启动。一个挂载点可能没什么,如果每个失败的容器都残留一个挂载点,时间长了,根目录的空间被这些“看不见的”目录占满,系统直接宕机。

我见过一个真实的例子:一个生产集群里,某天所有新 Pod 都调度失败,错误提示是挂载点冲突。运维排查了一上午,最后发现是之前被强制删除的容器留下了上百个 overlay 挂载点。后来手动清掉这些挂载点,集群才恢复。所以说,清理不彻底,就是在给自己埋雷。

三、手动根治方案:动手清理现场

既然 CRI-O 没清理干净,我们就得自己动手。注意,手动清理有风险,一定要先看清楚,再操作。下面我们按步骤来,用 Shell 命令一步步解决。

3.1 清理僵尸进程

要清理僵尸进程,先得明白它的原理。僵尸进程的父进程没有调用 wait 或者 waitpid 来回收它。所以,只要让父进程去“收尸”,或者让容器运行时重新初始化,就能清理掉。如果父进程还在运行,可以给它发送一个 SIGCHLD 信号,提示它去处理子进程的退出状态。如果父进程已经挂了,那僵尸进程会被系统 init 进程收养,而 init 进程会定期回收,一般不会长期存在。真正难缠的,是父进程仍然活着但拒绝回收的情况。

第一步,找到僵尸进程以及它的父进程:


# 打印僵尸进程的PID、PPID以及命令行
ps -eo stat,pid,ppid,cmd | awk '/^Z/ {print "僵尸进程PID:", $2, "父进程PID:", $3, "命令行:", $4}'

这个命令的输出里,第一列是状态 Z 的是僵尸进程,第二列是它的 PID,第三列是父进程 PID。拿到父进程 PID 后,我们用 pstree 或者 ps 再看看这个父进程是谁:


# 根据PID查看父进程的详细信息
ps -p 12345 -o pid,stat,cmd

如果这个父进程是容器里的一个进程,而且容器也已经退出了,那这个父进程可能本身就是个孤儿。这时候,我们可以试着让系统的 init 进程来接管。但大多数情况下,我们只能选择让父进程退出,或者重启 CRI-O 服务(如果父进程是 CRI-O 的组件)。

下面是一个完整的清理脚本,它会自动找出所有僵尸进程,并尝试触发父进程回收:


#!/bin/bash
# 清理僵尸进程的脚本,用于CRI-O环境
# 使用方法:在root用户下执行

echo "=== 扫描僵尸进程开始 ==="

# 用find命令找出所有僵尸进程,记录它们的PID和PPID
# 注意:这里用read循环读取ps的输出,每行用:分割,防止空格错位
ps -eo state,pid,ppid,cmd |
while read state pid ppid cmd; do
    # 只处理状态为Z的进程
    if [[ "$state" == "Z" ]]; then
        echo "发现僵尸进程: 状态=$state PID=$pid 父进程=$ppid 命令=$cmd"
        # 尝试向父进程发送SIGCHLD信号,促使它回收子进程状态
        # SIGCHLD的号码是17,可以写成 kill -CHLD 或 kill -17
        if kill -17 "$ppid" 2>/dev/null; then
            echo "已向父进程 $ppid 发送SIGCHLD信号"
        else
            echo "向父进程 $ppid 发送信号失败,只能重启CRI-O"
        fi
    fi
done

# 再次检查是否还有僵尸进程
sleep 2
remaining=$(ps -eo state,pid | awk '/^Z/ {count++} END {print count+0}')
echo "剩余僵尸进程数量: $remaining"

if [ "$remaining" -gt 0 ]; then
    echo "有僵尸进程残留,需要进一步处理父进程"
fi

echo "=== 扫描僵尸进程结束 ==="

这个脚本会先打印出所有僵尸进程,然后尝试给它们的父进程发送 SIGCHLD 信号。大部分情况下,只要能正常发送信号,父进程就会自动把僵尸进程回收掉。如果发送信号之后僵尸进程还在,那可能就得重启 CRI-O 服务。重启 CRI-O 的时候要小心,最好选在业务低谷期,并且先通过 Kubernetes 暂停调度。


# 重启CRI-O服务,注意这会影响当前节点上所有容器
systemctl restart crio

重启后,由于 init 系统重新接管,很多遗留的僵尸进程会被清掉。如果个别的父亲进程是用户态程序,那你就需要去排查那个程序为什么不回收子进程了。

3.2 清理残留挂载点

挂载点的清理相对直接,但动手前一定要确认这个挂载点还有没有进程在占用。千万不要一上来就 umount,否则可能导致正在运行的容器数据损坏。

先查看所有的容器相关挂载点:


# 查看所有与容器存储相关的挂载点,显示挂载源和目标
findmnt -t overlay,ext4,xfs | grep containers

假设你看到了一个挂载点是 /var/lib/containers/storage/overlay/abc123/merged,你要先检查有没有进程在使用它:


# 用lsof查看该挂载点下被占用的文件
lsof +f -- /var/lib/containers/storage/overlay/abc123/merged

如果没有输出,说明没有进程占用,这时就可以安全卸载:


# 卸载挂载点
umount /var/lib/containers/storage/overlay/abc123/merged

但如果 lsof 显示有进程在访问,你就不能用强硬的 umount,而应该先解决那些进程。如果确定那些进程确实是残留的僵尸容器进程,可以把它们先结束掉。如果实在无法结束,可以尝试延迟卸载:


# 延迟卸载,等没有进程使用时会自动卸载
umount -l /var/lib/containers/storage/overlay/abc123/merged

注意:-l 是懒卸载,它立刻从挂载表中移除,但底层设备要等所有访问关闭后才真正卸载。如果那些进程永远不退出,那这个挂载点也永远不会真正消失,所以懒卸载算是一个折中办法。

为了更高效地清理,我们可以写一个脚本,自动找出所有已经没有进程占用的挂载点并卸载。脚本如下:


#!/bin/bash
# 清理残留挂载点的脚本,适用于CRI-O环境
# 前提:必须用root用户执行

echo "=== 开始扫描残留挂载点 ==="

# 找出所有与overlay相关的挂载点,并记录到临时文件
findmnt -t overlay -o TARGET -n > /tmp/crio_related_mounts.txt

while read mountpoint; do
    # 跳过空行
    [ -z "$mountpoint" ] && continue
    echo "检查挂载点: $mountpoint"

    # 使用lsof判断是否有进程占用
    # 如果lsof退出码非0,表示没有进程占用,可以安全卸载
    if lsof +f -- "$mountpoint" >/dev/null 2>&1; then
        echo "  有进程占用,跳过卸载 (先处理进程)"
    else
        echo "  没有进程占用,尝试卸载"
        umount "$mountpoint" 2>/dev/null
        if [ $? -eq 0 ]; then
            echo "  [成功] 已卸载 $mountpoint"
        else
            echo "  [失败] 卸载 $mountpoint 失败,尝试懒卸载"
            umount -l "$mountpoint" 2>/dev/null
        fi
    fi
done < /tmp/crio_related_mounts.txt

# 清理临时文件
rm -f /tmp/crio_related_mounts.txt

echo "=== 清理挂载点结束 ==="

这个脚本会自动遍历所有 overlay 挂载点,如果发现没有进程占用就立即卸载;如果占用就跳过,你可以根据提示去查那些占用进程。用过之后,你再执行 findmnt -t overlay 发现挂载点少了很多,心里就舒服多了。

四、长效预防措施:让打扫变成自动的

手动清理只能解决一时的问题,如果 CRI-O 的清理机制依然有缺陷,过阵子还是会积累垃圾。所以我们要从源头做起,让系统自己打扫,这样我们才能安心。

4.1 配置 CRI-O 的自动清理参数

CRI-O 本身提供了一些清理相关的配置,我们可以把它们的周期调短一点。打开 CRI-O 的配置文件 /etc/crio/crio.conf,在里面找到 [crio.image] 段,这里有一个参数叫 cleanup_interval,它控制着后台定期清理未使用镜像和容器日志的时间间隔。我们把它设置成每 5 分钟一次:


[crio.image]
# 设置清理周期为5分钟,单位是秒
cleanup_interval = 300

修改完配置后,重启 CRI-O 让它生效:


# 重启CRI-O服务,让配置生效
systemctl restart crio

另外,[crio.runtime] 里还有一些与删除容器相关的选项,比如 ctr_stop_timeout 和 runtimes,这些不是直接关系到挂载点,但可以确保容器删除动作有足够的超时时间。具体配置可以这样写:


[crio.runtime]
# 设置容器停止的超时时间,避免因为业务关闭慢而出现清理不完全
ctr_stop_timeout = 60

通过这些配置,至少可以降低僵尸进程和残留挂载点的出现概率。

4.2 使用 systemd 定时器定期清理

就算 CRI-O 再完善,也有漏网之鱼。我们可以写一个 systemd timer,定期执行我们刚才写的那两个清理脚本。这样既不需要人工干预,也能在垃圾堆积之前就把它请走。

第一步,把我们清理僵尸进程和挂载点的脚本合并成一个总的清理脚本,放到 /usr/local/bin/cleanup-crio.sh:


#!/bin/bash
# 综合清理脚本,每隔一段时间执行一次
# 包含僵尸进程清理和残留挂载点清理

echo "[cleanup] 开始清理"

# 清理僵尸进程
ps -eo state,pid,ppid,cmd |
while read state pid ppid cmd; do
    if [[ "$state" == "Z" ]]; then
        echo "僵尸进程 PID=$pid 父进程=$ppid"
        kill -17 "$ppid" 2>/dev/null || true
    fi
done

# 清理残留挂载点
findmnt -t overlay -o TARGET -n | while read mountpoint; do
    if [ -z "$mountpoint" ]; then
        continue
    fi
    if ! lsof +f -- "$mountpoint" >/dev/null 2>&1; then
        echo "卸载挂载点: $mountpoint"
        umount "$mountpoint" 2>/dev/null || umount -l "$mountpoint" 2>/dev/null
    fi
done

echo "[cleanup] 清理完成"

注意,这个脚本只是简单演示,实际生产环境建议加上日志和注释。

然后创建 systemd service 文件 /etc/systemd/system/cleanup-crio.service:


[Unit]
Description=Cleanup CRI-O leftover processes and mounts
After=network.target
# 注意:这里使用oneshot类型,表示任务完成后立即退出

[Service]
Type=oneshot
ExecStart=/usr/local/bin/cleanup-crio.sh
User=root

再创建 timer 文件 /etc/systemd/system/cleanup-crio.timer:


[Unit]
Description=Run cleanup-crio every hour

[Timer]
# 设置从开机后10分钟开始第一次执行
OnBootSec=10min
# 之后每1小时执行一次
OnCalendar=hourly
# 为了防止上一轮没跑完就启动下一轮,使用单调时钟
Unit=cleanup-crio.service

[Install]
WantedBy=timers.target

最后,启用 timer:


# 重新加载systemd配置
systemctl daemon-reload

# 启用定时器
systemctl enable --now cleanup-crio.timer

这样,系统每个小时就会自动打扫一遍。如果你觉得每小时有点频繁,可以改 timer 里的 OnCalendar,比如每天凌晨 3 点执行一次。

4.3 引入监控与告警

定时清理是“治”,我们还需要“防”。怎么防?就是监控。我们可以写一个监控脚本,统计僵尸进程数量以及残留挂载点数量,一旦超过阈值,就调用告警工具。比如用 curl 发一个 webhook 到钉钉或者 Slack。

下面是一个简单的监控脚本:


#!/bin/bash
# 监控CRI-O环境中的僵尸进程和残留挂载点数量,超过阈值时发告警
# 需要设置环境变量 WEBHOOK_URL 用于告警

THRESHOLD_ZOMBIE=50
THRESHOLD_MOUNT=30
WEBHOOK_URL="${WEBHOOK_URL:-https://your-notification-webhook.example.com/}"

# 统计僵尸进程数量
zombie_count=$(ps -eo state | awk 'BEGIN{c=0} /^Z/{c++} END{print c}')

# 统计overlay挂载点数量
mount_count=$(findmnt -t overlay -o TARGET -n | wc -l)

# 输出当前状态
echo "当前僵尸进程数: $zombie_count, 残留挂载点数: $mount_count"

# 阈值检查
if [ "$zombie_count" -gt "$THRESHOLD_ZOMBIE" ] || [ "$mount_count" -gt "$THRESHOLD_MOUNT" ]; then
    message="CRI-O清理隐患告警:僵尸进程 ${zombie_count} 个,残留挂载点 ${mount_count} 个"
    # 发送webhook告警
    curl -s -X POST -H "Content-Type: application/json" \
        -d "{\"text\":\"${message}\"}" \
        "$WEBHOOK_URL"
fi

你可以把这个监控脚本也放进 systemd timer 里,或者用 cron 每天执行几次。这样即便出现大规模清理不彻底的情况,你也能第一时间收到通知,而不是等到用户报障。

五、应用场景、技术优缺点、注意事项

到这里,我们介绍了手动根治和长效预防的具体方法。下面我们再理一理这些方法的使用场景、优缺点以及需要注意的坑。

从应用场景来看,手动清理适合两种情况:一种是线上已经出了故障,你需要立刻恢复服务,比如新容器调度失败;另一种是开发环境机器很少,偶尔手动跑一跑也无妨。而自动定时清理和监控,更适合生产环境。生产环境讲究稳定,不能总是深夜爬起来敲命令。

从技术优点来说,手动清理的优点是精准可控,你能看到每一个被清理对象,确定无误后再动手;自动清理的优点是省心,能长期运行,跟垃圾赛跑。但从缺点来看,手动清理容易操作失误,比如误杀还在运行的进程,或者卸载了正在使用的挂载点;自动清理如果脚本写得不严谨,也可能把正在使用的挂载点强制卸载,造成数据丢失。

因此,有几点注意事项必须牢记:

第一,所有的清理操作都建议在维护窗口执行,尤其是手动清理,别在业务高峰期动手。

第二,在执行 umount 之前,一定要用 lsof 确认没有进程占用。就算没有进程占用,也建议看一下这个挂载点是不是系统还在使用的系统盘或关键目录。

第三,不要随便 kill 僵尸进程的父进程。如果父进程是 CRI-O 或者 kubelet,直接 kill 重启它们,可比你找个配套的强。但在生产环境,kill 容器运行时前要先通过 Kubernetes 排空节点。

第四,清理脚本一定要加上日志。万一出了问题,你还可以回溯历史,看看是哪一步操作导致的。

第五,如果你使用的是 cgroup v2 或者存储驱动有所调整,挂载点的类型和路径可能会有差异,脚本里的正则记得同步修改。

六、文章总结

我们来简单收个尾。容器退出后,CRI-O 偶尔会留下僵尸进程和残留挂载点,这就像退房后的房间没有打扫干净。僵尸进程占着 PID 让新进程起不来,残留挂载点占着目录让新容器挂不上,积累多了,系统就会慢慢“呼吸不畅”。

好消息是,这些问题不是无解的。我们可以通过手动脚本,先找出僵尸进程和残留挂载点,再有针对性地让父进程回收、让挂载点卸载。同时,我们还可以修改 CRI-O 的配置,缩短自动清理周期,再用 systemd 定时器定期执行清理脚本,最后加一个监控告警,让系统自己发现隐患并通知我们。

其实,容器运行时清理不彻底是个老话题了。它和运维人常说的“雪崩”很像,很多小问题叠加起来,最后变成不可收拾的大事件。我们做这套手动加自动的治理方案,目的就是在小问题变成大事件之前,把它偷偷解决掉。希望这篇文章能帮你在遇到类似情况时,心里更有底,手上有活,不慌不忙地清除那些不请自来的“僵尸”和“钉子”。