首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >深入剖析:一次由僵尸进程引发的容器内存异常增长排查实录

深入剖析:一次由僵尸进程引发的容器内存异常增长排查实录

原创
作者头像
粲然
修改2025-08-18 14:05:18
修改2025-08-18 14:05:18
4850
举报
文章被收录于专栏:工程师的分享工程师的分享

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

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

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

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

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

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

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

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

排查工具箱:pprof 实战三连

(一)启用 pprof 监控

代码语言:go
复制
import (
    _ "net/http/pprof"
    "net/http"
)

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

(二)收集内存数据

代码语言:bash
复制
# 进入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

(三)使用对比分析

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

代码语言:bash
复制
go tool pprof -http=:9998 -base /heap.pprof /heap_after.pprof

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

代码语言:bash
复制
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脚本:

代码语言:dockerfile
复制
ENTRYPOINT ["start.sh"]
代码语言:bash
复制
# 启动 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脚本:

代码语言:bash
复制
#!/usr/bin/env bash

top

执行bash test.sh

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

修改脚本:

代码语言:bash
复制
#!/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时发现矛盾点:

代码语言:dockerfile
复制
...
...
ENTRYPOINT ["./start.sh"]

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

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

代码语言:bash
复制
...
sh /usr/Atta-init.sh
...
....
exec ./main_app  # 进程替换!

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

Exec 的关键副作用

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

  • 当前 Shell 进程被直接覆盖
  • 新进程继承原 PID(包括 PID 1 身份)
  • 原进程的所有子进程由新进程接管

做个实验

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

代码语言:bash
复制
#!/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?历史问题浮出水面:

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

代码语言:md
复制
# 历史工单记录:
"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 原生的生命周期管理方案:

代码语言:javascript
复制
lifecycle:
  preStop:
    exec:
      command: ["sh", "/graceful_shutdown.sh"]

lifecycle有两种回调函数: PostStart:容器创建成功后,运行前的任务,用于资源部署、环境准备等。 PreStop:在容器被终止前的任务,用于优雅关闭应用程序、通知其他系统等等。

代码语言:bash
复制
#!/bin/#!/bin/sh
ps -ef|grep app|grep -v grep|awk '{print $1}'|xargs kill -15

即:

该方案的三大致命伤

  1. 强耦合性:需为每个服务定制graceful_shutdown.sh
代码语言:bash
复制
# 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 的致命缺陷

代码语言:go
复制
// 伪代码: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内容如下:

代码语言: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如下:

代码语言:bash
复制
sh /usr/Atta-init.sh
exec ./main

文件main.go如下:

代码语言: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")
}

执行以下命令:

代码语言:bash
复制
# 编译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管理僵尸进程,后一个可以传递信号量

运维监控

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

代码语言:bash
复制
# 实时检测僵尸进程
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

选型公式:

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

容器内PID 1号进程职责

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

🧟 僵尸进程收割机

📡 信号广播中转站

🚦 进程生命周期协调员

进程树扁平化原则

立体进程监控

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

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

-fork 速率

-僵尸进程计数

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

附录:

本文涉及工具链

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

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

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、问题突现:诡异的内存曲线
  • 二、初阶排查:Golang 程序的“清白证明”
    • 排查工具箱:pprof 实战三连
    • (一)启用 pprof 监控
    • (二)收集内存数据
    • (三)使用对比分析
    • 反直觉的分析结果
  • 三、深入容器:僵尸进程的幽灵
    • 发现问题真相
    • 僵尸进程溯源
    • 修复方案:
    • Linux 进程管理核心机制
    • 再进一步观察进程树
    • 做个实验
  • 四、信号传递困境:PID 1 的两难抉择
    • 新的线索
    • Exec 的关键副作用
    • 做个实验
    • 信号传递的真相
    • Plan B:K8s Lifecycle 方案的得与失
    • 两难困境的具象化
  • 五、终极方案:tini 的完美与局限
    • 理想的需求目标:
    • 容器 init进程管理系统选型
    • tini的局限
      • 但tini也依然有所不足:
    • 测试方案
    • 运维监控
    • 最终决策矩阵
  • 六、架构启示录
    • 容器≠虚拟机
    • 基础组件选型原则
    • 容器内PID 1号进程职责
    • 进程树扁平化原则
    • 立体进程监控
  • 附录:
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档