【云上那些事】深入剖析:一次由僵尸进程引发的容器内存异常增长排查实录

作者按:作为在云原生领域多年的技术负责人,我常遇到各类“诡异”的线上问题。本次分享的案例,表面是内存告警,实则涉及 Linux 进程管理、容器信号传递等底层机制。相信对各位架构师和研发负责人理解容器运行时行为具有启发意义。

一、问题突现:诡异的内存曲线

某日凌晨,监控系统突现刺耳告警——某核心服务内存使用率突破阈值:

    查看详情时,内存增长趋势更令人心惊:

    短短72小时内,容器内存增长超7GB,且毫无收敛迹象:

    技术负责人视角:在云原生环境中,此类“阶梯式”内存增长往往指向两类问题:应用层内存泄漏,或运行时资源管理异常,需立即启动深度排查。

    二、初阶排查:Golang 程序的“清白证明”

    面对突发故障,我遵循经典排查路径——首先聚焦应用层,作为 Golang 服务,pprof自然是首选武器。

    排查工具箱:pprof 实战三连

    (一)启用 pprof 监控

    import (
        _ "net/http/pprof"
        "net/http"
    )
    
    func main() {
        // 启动 pprof 监听
        go func() {
            http.ListenAndServe("localhost:6060", nil)
        }()
        
        // 你的业务代码
    }

    (二)收集内存数据

    # 进入Pod
    #
    # 实时查看内存情况
    go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap
    # 生成内存快照
    curl -o heap.pprof http://localhost:6060/debug/pprof/heap
    # 间隔30秒后再生成一个快照
    sleep 30 && curl -o heap_after.pprof http://localhost:6060/debug/pprof/heap
    
    
    # 退出Pod,在K8S集群环境
    #
    # 把快照从pod里面拿出来
    kubectl cp Demo-56c8475fb-tff7p:/data/service/heap.pprof ./heap.pprof
    kubectl cp Demo-56c8475fb-tff7p:/data/service/heap_after.pprof ./heap_after.pprof

    (三)使用对比分析

    一般来说用页面来看比较直观,但需要下载到本地,用页面看的话,用如下命令:

    go tool pprof -http=:9998 -base /heap.pprof /heap_after.pprof

    如果情况不太复杂,可以直接用如下命令在Pod里面查看:

    go tool pprof -base /heap.pprof /heap_after.pprof Top 10

    反直觉的分析结果

    通过对比快照分析,结果令人诧异:

    关键结论:

    1. 无显著内存泄漏点

    2. 堆内存占用仅百兆级

    3. 与7GB内存消耗严重不符

    架构师思考:当应用层证据链断裂时,需将视线转向运行时环境,容器内是否存在“看不见”的资源占用?

    三、深入容器:僵尸进程的幽灵

    发现问题真相

    登陆问题容器,通过 top命令揭晓真相:

    这里也可以ps -auxps -aux --forest来查看

    关键发现:

    • 主进程(Go服务)内存占用正常(RES < 220MB)

    • 存在大量 zombie进程(状态为 Z)

    • 僵尸进程数量与内存增长正相关

    僵尸进程溯源

    查阅组件文档发现关键线索:

    📌 官方警示:直接通过 Dockerfile 启动 sh /usr/Atta-init.sh可能引发僵尸进程堆积

    修复方案:

    start.sh作为dockerfile文件里面ENTRYPOINT入口,

    然后在start.sh启动Atta-init.sh脚本:

    ENTRYPOINT ["start.sh"]
    # 启动 atta_agent 
    sh /usr/Atta-init.sh ...

    如图所示:

    Linux 进程管理核心机制

    为何简单包装脚本即可解决问题?这涉及 Linux 进程管理的两大知识点:

    1. 僵尸进程回收责任链: 父进程负责通过 wait()系统调用回收子进程 孤儿进程由 PID 1(init 进程)接管

    2. Bash 的进程管理能力: 前台进程:实时阻塞等待 + 自动回收 后台进程:通过 SIGCHLD 信号触发 waitpid()

    后台进程会短暂产生僵尸进程(通常仅几毫秒))

    例外:disown脱离管理的进程可能滞留

    所以,原则上讲,Bash具备管理僵尸进程的能力

    再进一步观察进程树

    上面的内容基本可以涵盖绝大部分的场景,但在这个场景,其实更加复杂一点

    细心的同学可能观察到,这里官方文档推荐了ENTRYPOINT的方式运行Bash脚本,这个操作会让Bash进程变成PID 1号进程

    那如果Bash脚本不是PID 1号进程能不能管理atta僵尸进程呢?

    答案是不可以,因为在这个场景不适用

    首先Bash脚本作为父进程,可以管理所有的子进程状态,包括子进程中僵尸进程

    但是,这里有个前提,僵尸进程是Bash进程的子进程才行,但在这个案例里面,Atta-init.sh作为初始脚本,里面会嵌套多个程序,最终使用了setsid命令,导致Atta-init.sh生成的atta_agent进程Bash进程同级,即非Bash进程的子进程

    如果Bash进程同时为PID 1号进程时,那atta_agent变成僵尸进程时,则还是会回到PID 1号进程来管理

    所以要用ENTRYPOINT的方式运行Bash脚本才可以

    做个实验

    这里可以做个简单实现写一个test.sh脚本:

    #!/usr/bin/env bash 
    top

    执行bash test.sh

    使用命令pstree -p查看进程树的结果:

    修改脚本:

    #!/usr/bin/env bash
    
    # 模拟一个新的bash脚本
    (setsid sleep 100s &)
    
    top

    执行bash test.sh

    查看进程树的结果:

    可以看到:sleeptop命令在同一级

    回到问题本身,Bash脚本作为PID 1号进程的话,可以同时管理sleeptop进程的状态,防止僵尸进程“无人管理”的情况出现

    所以看起来:这个问题根因是没有间接使用bash脚本,同时没有把bash脚本作为PID 1号进程问题!

    技术负责人视角:实际情况基于不同linux版本和当时运转的进程,可能结果会稍有不同,这里做了一些简单的归一和抽象

    四、信号传递困境:PID 1 的两难抉择

    新的线索

    检查问题服务的Dockerfile时发现矛盾点:

    ...
    ...
    ENTRYPOINT ["./start.sh"]

    可以看到,问题服务的Dockerfile正确使用了ENTRYPOINT和bash脚本运行方式!

    但进一步审查 start.sh发现关键操作:

    ...
    sh /usr/Atta-init.sh
    ...
    ....
    exec ./main_app  # 进程替换!

    这里用到了一个关键的命令:exec

    Exec 的关键副作用

    Exec命令的实质是进程替换:

    • 当前 Shell 进程被直接覆盖

    • 新进程继承原 PID(包括 PID 1 身份)

    • 原进程的所有子进程由新进程接管

    做个实验

    写一个简单的test.sh脚本:

    #!/bin/bash
    # test.sh
    
    # 启动一个后台睡眠命令
    sleep 60 &
    
    echo "启动后台sleep"
    echo "当前子进程: $(jobs -p)"
    
    # 替换进程
    exec top

    运行之后的进程状态为:

    如上图所示,可以看到top进程完全替换了bash进程,接管了sleep子进程

    回到这个问题

    1. 因为exec这个命令把这个本来后台运行的main_app子进程强制替换了Bash进程这个父进程,变成了父进程,

    2. 同时原来的Bash进程消失了,main_app的这个进程同时接收了Bash进程本身的其他子进程

    3. 因为Bash进程原本是PID 1号进程,所以这个业务进程(main_app进程)也成为了PID 1号进程,但这个业务没有处理僵尸进程的能力,所以导致僵尸进程泛滥了!!!

    信号传递的真相

    为何需要 exec?历史问题浮出水面:

    询问修改的同学发现,原来是正确处理系统信号的工单要求导致的

    # 历史工单记录:
    "K8s 滚动更新时,部分请求被强制中断"
    ...

    根因分析:

    K8s 发送的 SIGTERM 仅到达 PID 1(Bash进程

    在这个服务中Bash进程 不会自动向子进程(main_app)转发信号

    业务进程main_app虽然实现了信号处理逻辑,但没有收到信号,导致服务没有做好准备就被终止了

    所以为了能够接收到K8s的信号,开发同学做了上述修改,变成了:

    代码修改以前的进程树:

    Bash进程(父进程)--PID 1号进程

    |

    |---业务进程(子进程)-- PID X号进程

    修改代码以后的进程树

    Bash进程(父进程)--被替换销毁❎

    业务进程(子进程)-- PID 1号进程

    Plan B:K8s Lifecycle 方案的得与失

    当研发同学拒绝使用包装脚本时,我们曾考虑 K8s 原生的生命周期管理方案:

    lifecycle:
      preStop:
        exec:
          command: ["sh", "/graceful_shutdown.sh"]

    复制

    lifecycle有两种回调函数:

    PostStart:容器创建成功后,运行前的任务,用于资源部署、环境准备等。

    PreStop:在容器被终止前的任务,用于优雅关闭应用程序、通知其他系统等等。

    #!/bin/#!/bin/sh
    ps -ef|grep app|grep -v grep|awk '{print $1}'|xargs kill -15

    即:

    该方案的三大致命伤:

    1. 强耦合性:需为每个服务定制graceful_shutdown.sh

    # stop.sh 强依赖进程命名规范
    # 当进程名变更或存在同名程序时,将导致误杀
    ps -ef | grep "my_app" | awk '{print $2}' | xargs kill

    1. 2.信号黑盒:无法确保进程真正优雅退出

    2. 3.运维负担:需维护多版本停止脚本

    服务数量

    定制脚本量

    配置管理复杂度

    10

    10

    ⭐⭐

    100

    100

    ⭐⭐⭐⭐⭐

    技术负责人洞察:架构师洞察:正如《Designing Data-Intensive Applications》所指出的,基础设施应提供通用能力而非定制路径。Lifecycle 方案本质是将进程管理责任转嫁给开发者,违背云原生理念。 架构师思考:在微服务架构中,此类定制化脚本将成为版本管理的噩梦。正如《UNIX编程艺术》所言:“机制应优于策略”,我们需要更通用的信号传递机制。

    两难困境的具象化

    此时我们面临 PID 1 的三重悖论:

    架构启示:容器环境下,PID 1 进程需兼具“进程管家”与“信号路由器”双重角色。标准 Linux 工具链中,谁堪此任?

    五、终极方案:tini 的完美与局限

    理想的需求目标:

    那么:PID 1号进程

    理想工作能力:

    ✅ 信号广播至整个进程组

    ✅ 等待所有子进程退出

    ✅ 无僵尸进程残留

    容器 init进程管理系统选型

    竞品对比:tini vs dumb-init

    特性

    tini

    dumb-init

    子进程监控

    全进程组

    仅直接子进程

    SIGTERM 传递机制

    进程组广播

    逐进程通知

    等待策略

    所有子进程退出

    首个直接子进程退出

    容器集成难度

    ⭐⭐

    ⭐⭐⭐

    经对比测试,tini(Tiny Init)胜出:

    同时还有以下几个特点:

    ⚡ 轻量级(仅 20KB)

    🧟 自动回收僵尸进程

    📶 支持信号转发(-g参数)

    🔄 与 K8s 生命周期无缝集成

    github.com/krallin/t...

    具体表述:

    tini的局限

    tini 的深水区:进程组信号传递的本质

    之所以选择tini,其中一个核心原因是tini的广播机制比dumb-init好:

    dumb-init 的致命缺陷

    // 伪代码:dumb-init 的信号传递逻辑
    func handleSignal(sig os.Signal) {
        for child := range directChildren { // 仅遍历直接子进程
            child.SendSignal(sig)
        }
    }

    // 伪代码:dumb-init 的信号传递逻辑 func handleSignal(sig os.Signal) { for child := range directChildren { // 仅遍历直接子进程 child.SendSignal(sig) } }

    复制

    因为仅遍历直接子进程,导致孙子进程成为"信号孤岛"

    但tini也依然有所不足

    tini 的信号广播机制

    通过 kill(-pid)向整个进程组发送信号,确保子孙进程均被通知

    然而,tini进程自己结束的时间为:所有子进程和孤儿进程返回确认信号后则关闭tini自己进程

    并不会在乎孙子进程的状态

    tini和dumb-init的详细资料参考:https://github.com/Zheaoli/weekly-share/issues/10

    理论上来讲,使用tini工具后,进程树结构如下:

    这就导致tini进程虽然会把关闭的信号传递给CMD ./main进程,但如果atta_agent进程和bash进程相比CMD ./main进程先收到且反馈给tini进程已处理信号的话,那tini进程会认为所有子进程或孤儿进程都返回了,tini进程就会主动终止,导致CMD ./main进程突然中止

    所以优化的方案是:

    • Dockerfile文件中ENTRYPOINT形式启动bash脚本,来初始化各类系统

    • 各类系统用fork形式创建独立进程,脱离与Bash进程的关系

    • 最后一个主业务服务用exec的方式替换Bash进程`

    即:

    测试方案

    文件dockerfile内容如下:

    FROM alpine:3.18
    
    # 1. 安装 tini
    RUN apk add --no-cache tini
    
    COPY test.sh test.sh
    
    COPY main.go main.go
    
    RUN go build -o ./test ./main.go
    
    # 2. 配置入口点
    ENTRYPOINT ["tini", "-g", "--"]
    
    # 3. 启动业务脚本
    CMD ["sh", "-c", "./test.sh"]

    文件test.sh如下:

    sh /usr/Atta-init.sh
    exec ./main

    sh /usr/Atta-init.sh exec ./main

    文件main.go如下:

    packagepackage main
    
    import (
    	"fmt"
    	"os"
    	"os/signal"
    	"syscall"
    )
    
    func main() {
    	sigs := make(chan os.Signal, 1)
    	done := make(chan bool, 1)
    	// registers the channel
    	signal.Notify(sigs, syscall.SIGTERM)
    
    	go func() {
    		sig := <-sigs
    		fmt.Println("Caught SIGTERM, shutting down")
    		fmt.Println(sig)
    		// Finish any outstanding requests, then...
    		done <- true
    	}()
    
    	fmt.Println("Starting application")
    	// Main logic goes here
    	<-done
    	fmt.Println("exiting")
    }

    执行以下命令:

    # 编译docker镜像
    docker build -t my-app .
    # 运行docker镜像
    docker run -d -p 8081:80 --name my-app my-app
    # 查看docker日志(在另一个控制台)
    docker logs -f my-app
    # 发送关闭docker容器的信号
    docker stop my-app

    结果如下:

    可以看到,golang服务收到了信号量,且正确的关闭了服务

    进程状态:

    tini服务是init进程,golang服务是子进程服务,符合预期

    再看下进程树:

    可以看到,三个进程关系平行,被tini管理,前两个可以用tini管理僵尸进程,后一个可以传递信号量

    运维监控

    架构启示:容器初始化系统不是银弹。建议结合以下监控手段:

    # 实时检测僵尸进程
    watch -n 5 'kubectl exec $POD -- ps aux | awk \'$8=="Z"{print "Zombie:", $11}\''
    
    # 进程树深度告警(超过3层需预警)
    if [ $(kubectl exec $POD -- pstree -p | grep -o '(' | wc -l) -gt 50 ]; then
      send_alert "PROCESS_TREE_DEPTH_ALERT"
    fi

    最终决策矩阵

    维度

    tini+exec方案

    Bash方案

    K8s Lifecycle

    信号可靠性

    ✅✅ 进程组广播

    ❌ 无传递机制

    ✅ 单次触发

    僵尸处理

    ✅✅ 全局回收

    ✅ 仅直接子进程

    ❌ 需定制开发

    多进程支持

    ✅✅ 无限层级

    ⚠️ 受限于进程树

    ❌ 需定制开发

    运维成本

    ⭐ 统一基础镜像

    ⭐⭐ 需定制脚本

    ⭐⭐⭐⭐ 每服务定制

    K8s兼容性

    ✅ 全版本支持

    ✅ 全版本支持

    ⚠️ 依赖kubelet版本

    六、架构启示录

    本次排查之旅带来几点核心认知:

    容器≠虚拟机

    传统init系统职责在容器中由 PID 1 进程承担,需特别关注进程管理边界

    基础组件选型原则

    场景

    推荐方案

    单一进程容器

    直接运行业务进程

    多进程/复杂初始化

    tini + 启动脚本 + exec

    需要完整 init 功能

    systemd in container

    选型公式:

    是否需要管理子进程? 
    ├─ 否 → 直接运行业务进程
    └─ 是 → 是否有进程间依赖?
        ├─ 否 → tini
        └─ 是 → systemd(仅特权容器)

    容器内PID 1号进程职责

    对容器内**PID 1 进程**必须承担:

    🧟 僵尸进程收割机

    📡 信号广播中转站

    🚦 进程生命周期协调员

    进程树扁平化原则

    立体进程监控

    除应用层指标外,需监控容器内:

    -进程状态分布(R/S/D/Z)

    -fork 速率

    -僵尸进程计数

    致技术决策者:在云原生架构中,看似简单的“进程启动方式”,实则是稳定性设计的胜负手。建议将 init 进程选型纳入技术考虑范畴,防患于未然。

    附录:

    本文涉及工具链

    tini 项目地址:https://github.com/krallin/tini

    pprof 官方文档:https://github.com/google/pprof

    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

    1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
    2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

    余额充值