作者按:作为在云原生领域多年的技术负责人,我常遇到各类“诡异”的线上问题。本次分享的案例,表面是内存告警,实则涉及 Linux 进程管理、容器信号传递等底层机制。相信对各位架构师和研发负责人理解容器运行时行为具有启发意义。
某日凌晨,监控系统突现刺耳告警——某核心服务内存使用率突破阈值:

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

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

技术负责人视角:在云原生环境中,此类“阶梯式”内存增长往往指向两类问题:应用层内存泄漏,或运行时资源管理异常,需立即启动深度排查。
面对突发故障,我遵循经典排查路径——首先聚焦应用层,作为 Golang 服务,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通过对比快照分析,结果令人诧异:

关键结论:

架构师思考:当应用层证据链断裂时,需将视线转向运行时环境,容器内是否存在“看不见”的资源占用?
登陆问题容器,通过 top命令揭晓真相:

这里也可以
ps -aux和ps -aux --forest来查看
关键发现:

查阅组件文档发现关键线索:
📌 官方警示:直接通过 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 进程管理的两大知识点:
后台进程会短暂产生僵尸进程(通常仅几毫秒))
例外: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
查看进程树的结果:

可以看到:sleep和top命令在同一级
回到问题本身,Bash脚本作为PID 1号进程的话,可以同时管理sleep和top进程的状态,防止僵尸进程“无人管理”的情况出现
所以看起来:这个问题根因是没有间接使用bash脚本,同时没有把bash脚本作为PID 1号进程问题!

技术负责人视角:实际情况基于不同linux版本和当时运转的进程,可能结果会稍有不同,这里做了一些简单的归一和抽象
检查问题服务的Dockerfile时发现矛盾点:
...
...
ENTRYPOINT ["./start.sh"]可以看到,问题服务的Dockerfile正确使用了ENTRYPOINT和bash脚本运行方式!

但进一步审查 start.sh发现关键操作:
...
sh /usr/Atta-init.sh
...
....
exec ./main_app # 进程替换!这里用到了一个关键的命令:exec
Exec命令的实质是进程替换:
写一个简单的test.sh脚本:
#!/bin/bash
# test.sh
# 启动一个后台睡眠命令
sleep 60 &
echo "启动后台sleep"
echo "当前子进程: $(jobs -p)"
# 替换进程
exec top运行之后的进程状态为:

如上图所示,可以看到top进程完全替换了bash进程,接管了sleep子进程
回到这个问题
exec这个命令把这个本来后台运行的main_app子进程强制替换了Bash进程这个父进程,变成了父进程,Bash进程消失了,main_app的这个进程同时接收了Bash进程本身的其他子进程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号进程
当研发同学拒绝使用包装脚本时,我们曾考虑 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即:

该方案的三大致命伤:
graceful_shutdown.sh# stop.sh 强依赖进程命名规范
# 当进程名变更或存在同名程序时,将导致误杀
ps -ef | grep "my_app" | awk '{print $2}' | xargs kill服务数量 | 定制脚本量 | 配置管理复杂度 |
|---|---|---|
10 | 10 | ⭐⭐ |
100 | 100 | ⭐⭐⭐⭐⭐ |
技术负责人洞察:架构师洞察:正如《Designing Data-Intensive Applications》所指出的,基础设施应提供通用能力而非定制路径。Lifecycle 方案本质是将进程管理责任转嫁给开发者,违背云原生理念。 架构师思考:在微服务架构中,此类定制化脚本将成为版本管理的噩梦。正如《UNIX编程艺术》所言:“机制应优于策略”,我们需要更通用的信号传递机制。

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

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

那么:PID 1号进程的
理想工作能力:
✅ 信号广播至整个进程组
✅ 等待所有子进程退出
✅ 无僵尸进程残留
竞品对比:tini vs dumb-init
特性 | tini | dumb-init |
|---|---|---|
子进程监控 | 全进程组 | 仅直接子进程 |
SIGTERM 传递机制 | 进程组广播 | 逐进程通知 |
等待策略 | 所有子进程退出 | 首个直接子进程退出 |
容器集成难度 | ⭐⭐ | ⭐⭐⭐ |
经对比测试,tini(Tiny Init)胜出:
同时还有以下几个特点:
⚡ 轻量级(仅 20KB)
🧟 自动回收僵尸进程
📶 支持信号转发(-g参数)
🔄 与 K8s 生命周期无缝集成
github.com/krallin/t...
具体表述:


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

之所以选择tini,其中一个核心原因是tini的广播机制比dumb-init好:
dumb-init 的致命缺陷
// 伪代码:dumb-init 的信号传递逻辑
func handleSignal(sig os.Signal) {
for child := range directChildren { // 仅遍历直接子进程
child.SendSignal(sig)
}
}因为仅遍历直接子进程,导致孙子进程成为"信号孤岛"
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进程突然中止
所以优化的方案是:
ENTRYPOINT形式启动bash脚本,来初始化各类系统fork形式创建独立进程,脱离与Bash进程的关系的方式替换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文件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 进程**必须承担:
🧟 僵尸进程收割机
📡 信号广播中转站
🚦 进程生命周期协调员

除应用层指标外,需监控容器内:
-进程状态分布(R/S/D/Z)
-fork 速率
-僵尸进程计数
致技术决策者:在云原生架构中,看似简单的“进程启动方式”,实则是稳定性设计的胜负手。建议将 init 进程选型纳入技术考虑范畴,防患于未然。
本文涉及工具链
tini 项目地址:https://github.com/krallin/tini
pprof 官方文档:https://github.com/google/pprof
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。