Go 服务内存持续上涨排查 SOP
SOP 目标:当 Linux 主机出现「Go 常驻服务内存持续上涨 / 触发 90% 内存告警 / 疑似 OOM」,且服务会调用
ffmpeg、ffprobe、imagemagick、压缩工具或其他外部命令时,用这套步骤快速判断:内存是不是被 Go 进程里的os/exec输出缓冲撑大的。注:本文里的主机名、地域、项目名、服务名、路径、业务函数名和任务名都已脱敏。
app-worker、media task、project/command.Output、192.168.1.1都是占位,替换成你自己的即可。
适用范围
适用:
- Go 常驻服务 RSS/PSS 持续增长;
- 机器上同时存在
ffmpeg等外部子进程; - pprof heap 可访问,或至少能登录机器查看进程;
- 告警表现为主机内存超过 90%、服务 OOM、服务重启或节点内存高水位。
不适用:
- 单个
ffmpeg子进程自身 RSS 已经非常大; - 主要内存来自 page cache、数据库 buffer pool 或容器 cgroup 限制;
- Go 服务没有调用外部命令;
- 只能看到业务错误,没有主机或进程级观测权限。
前置条件
| 条件 | 怎么确认 |
|---|---|
| 能登录告警机器 | hostname; date; uptime |
| 能查看进程 | `ps -eo pid,ppid,stat,rss,etime,cmd --sort=-rss |
| 能访问 Go pprof | curl -s http://127.0.0.1:6060/debug/pprof/heap -o /tmp/heap.pb.gz |
| 知道目标服务 PID | `pgrep -af 'app-worker |
| 知道服务是否调用外部命令 | 代码搜索 exec.Command / CommandContext / 公共 Output 封装 |
如果 pprof 暂时不可用,仍可先做「步骤一」到「步骤三」。但要确认 bytes.Buffer / os/exec 这类根因,最终需要 pprof 或代码审查。

一、确认告警是不是进程 RSS 拉高
在告警机器执行:
date
hostname
uptime
free -m
ps -eo pid,ppid,user,stat,%cpu,%mem,rss,vsz,etime,cmd --sort=-rss | head -25
判定标准:
| 结果 | 下一步 |
|---|---|
| Go 服务排在 RSS 前几名,且 RSS 明显异常 | 继续步骤二 |
单个 ffmpeg / 其他子进程 RSS 最大 |
优先排查子进程参数、输入文件和编码任务 |
free 显示主要是 cache,进程 RSS 不高 |
转向 page cache / IO / 文件系统排查 |
| 容器环境中 cgroup 内存接近上限 | 同时检查容器 limit 和宿主机内存 |
如果 Go 服务是内存大户,再看更准确的 smaps 汇总:
cat /proc/<GO_PID>/smaps_rollup
重点看:
Rss:进程实际驻留内存;Pss:按共享页折算后的内存,更接近服务真实占用;Anonymous:匿名内存,Go heap 通常主要体现在这里。
二、确认不是单个 ffmpeg 自己吃爆
在同一台机器执行:
ps -eo pid,ppid,stat,rss,etime,cmd | grep '[f]fmpeg'
建议顺手统计三类信息:
# ffmpeg 总数
ps -eo cmd | grep -c '[f]fmpeg'
# 是否有父进程已经变成 1 的孤儿进程
ps -eo pid,ppid,stat,etime,rss,cmd | awk '$2 == 1 && $0 ~ /ffmpeg/'
# 按运行时间肉眼看是否有明显长跑任务
ps -eo pid,ppid,stat,etime,rss,cmd | grep '[f]fmpeg' | sort -k4
判定标准:
| 现象 | 判断 |
|---|---|
Go 服务几十 GB,单个 ffmpeg 只有几十到几百 MB |
大概率不是子进程 RSS 本身吃爆 |
ffmpeg 数量很多,且有长跑任务 |
说明任务堆积或命令生命周期异常 |
存在 PPID=1 的 ffmpeg |
父进程没有完整回收子进程或进程组 |
ffmpeg 单进程 RSS 已到几十 GB |
另走 ffmpeg 参数 / 输入文件 / codec 排查 |
这一步的目的不是证明根因,而是排除一个常见误判:看到 ffmpeg 多,就直接认为「ffmpeg 自己吃了 60GB」。如果 RSS 大头在 Go 父进程,后面要看父进程怎么接子进程输出。
三、抓 Go heap,确认是否命中 bytes.Buffer / os/exec
优先在机器本地抓:
go tool pprof -top http://127.0.0.1:6060/debug/pprof/heap
如果只能先保存文件:
curl -s http://127.0.0.1:6060/debug/pprof/heap -o /tmp/heap.pb.gz
go tool pprof -top /path/to/app-worker /tmp/heap.pb.gz
重点看 top 栈里是否出现:
bytes.growSlice
bytes.(*Buffer).ReadFrom
io.Copy
os/exec.(*Cmd).writerDescriptor.func1
project/command.Output
判定标准:
| pprof 结果 | 判断 |
|---|---|
大部分 in-use heap 落在 bytes.Buffer + os/exec 链路 |
命中本 SOP 的核心问题 |
| heap 在业务 map/slice/cache | 转向业务对象泄漏或缓存淘汰策略 |
| heap 不高,但 RSS 高 | 看 cgo、mmap、线程栈、page cache 或 allocator 归还问题 |
| pprof 抓不到 | 检查 pprof 是否开启,或临时增加进程级内存/heap 观测 |
命中时,可以得出一个明确结论:
直接吃内存的是 Go 父进程 heap。外部命令输出通过
os/exec管道被读入bytes.Buffer,由于没有大小上限,输出越多,Go heap 越大。
四、检查代码里的公共命令执行器
在代码仓库里搜索:
rg -n 'exec\\.Command|CommandContext|bytes\\.Buffer|CombinedOutput|Output\\(' .
高风险写法通常长这样:
func Output(name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
return stderr.String(), err
}
return stdout.String(), nil
}
风险点:
bytes.Buffer没有大小上限;cmd.Run()没有超时;- 失败时可能把完整 stderr 返回或打印;
- cancel 时没有 kill 进程组;
- 外部命令如果派生子进程,可能留下孤儿进程。
如果业务里大量调用公共 Output,不要只改单个业务入口。根修复必须落在公共执行器。

五、临时止血
临时止血的目标是先保护机器,不是替代代码修复。
1. 清理长跑和孤儿外部命令
先列出来,不要盲杀:
ps -eo pid,ppid,stat,etime,rss,cmd | grep '[f]fmpeg'
ps -eo pid,ppid,stat,etime,rss,cmd | awk '$2 == 1 && $0 ~ /ffmpeg/'
确认业务影响后,再按任务窗口清理异常长跑进程。清理前建议保留:
- 进程列表;
- 运行时间;
- 命令行参数;
- 父进程 PID;
- 对应任务 ID 或输入文件占位信息。
2. 滚动重启 Go 服务释放 heap
如果 Go heap 已经涨到几十 GB,单独 kill 子进程不一定能立刻释放父进程 RSS。紧急情况下,需要按业务窗口滚动重启服务:
systemctl status app-worker
systemctl restart app-worker
systemctl status app-worker
重启前确认:
- 是否有任务会中断;
- 是否有队列重放或幂等保障;
- 是否需要先摘流量;
- 是否有多实例可滚动。
3. 限制新任务进入
如果任务还在继续涌入,只重启会反复复发。需要临时降低入口并发、暂停重试或关闭高风险任务类型。
六、长期修复
长期修复分四层:公共执行器、外部命令参数、任务并发、服务兜底。
1. 公共执行器:输出限长
只保留尾部日志,不要保留完整 stdout/stderr。
type tailBuffer struct {
buf []byte
max int
}
func newTailBuffer(max int) *tailBuffer {
return &tailBuffer{max: max}
}
func (b *tailBuffer) Write(p []byte) (int, error) {
n := len(p)
if b.max <= 0 {
return n, nil
}
if len(p) >= b.max {
b.buf = append(b.buf[:0], p[len(p)-b.max:]...)
return n, nil
}
b.buf = append(b.buf, p...)
if len(b.buf) > b.max {
copy(b.buf, b.buf[len(b.buf)-b.max:])
b.buf = b.buf[:b.max]
}
return n, nil
}
func (b *tailBuffer) String() string {
return string(b.buf)
}
建议默认值:
| 场景 | stdout/stderr 保留大小 |
|---|---|
| 普通命令 | 64KiB |
| ffmpeg / 转码类 | 256KiB 到 1MiB |
| 需要完整产物的命令 | 不走 stdout,改写文件或对象存储 |
2. 公共执行器:超时和进程组清理
type OutputOptions struct {
Timeout time.Duration
MaxTail int
}
func Output(ctx context.Context, name string, args []string, opt OutputOptions) (string, error) {
if opt.Timeout <= 0 {
opt.Timeout = 10 * time.Minute
}
if opt.MaxTail <= 0 {
opt.MaxTail = 1 << 20 // 1 MiB
}
ctx, cancel := context.WithTimeout(ctx, opt.Timeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
// Linux: put the child into a new process group, so timeout can kill all descendants.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
stdout := newTailBuffer(opt.MaxTail)
stderr := newTailBuffer(opt.MaxTail)
cmd.Stdout = stdout
cmd.Stderr = stderr
if err := cmd.Start(); err != nil {
return "", err
}
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
var err error
select {
case err = <-done:
case <-ctx.Done():
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
err = <-done
return stderr.String(), fmt.Errorf("command timeout: %w, stderr_tail=%s", ctx.Err(), stderr.String())
}
if err != nil {
return stderr.String(), fmt.Errorf("command failed: %w, stderr_tail=%s", err, stderr.String())
}
return stdout.String(), nil
}
注意:
- Linux 服务端可以使用
Setpgid+Kill(-pid)清理进程组; - macOS/Windows 需要拆平台文件;
- 不要只依赖
CommandContext,它通常只能保证直接子进程收到 kill,不能保证子孙进程都被清掉; - 返回错误时只带 tail,不要拼完整输出。
3. ffmpeg 默认参数
ffmpeg 类任务建议统一加:
ffmpeg -nostdin -hide_banner -loglevel warning ...
含义:
-nostdin:后台任务不等待标准输入;-hide_banner:减少启动横幅;-loglevel warning或error:减少正常路径输出。
如果确实需要进度,不要直接吃默认 stderr。可以使用受控进度文件、单独 pipe 或业务事件,并限制大小和采样频率。
4. 任务并发限制
公共执行器修好后,仍要限制重任务并发:
var mediaSem = make(chan struct{}, 4)
func runMediaTask(ctx context.Context, fn func(context.Context) error) error {
select {
case mediaSem <- struct{}{}:
defer func() { <-mediaSem }()
case <-ctx.Done():
return ctx.Err()
}
return fn(ctx)
}
并发值不要写死在代码里,建议配置化:
- 按机器规格设置;
- 按任务类型设置;
- 按用户或租户设置;
- 失败重试加退避;
- 明显短任务设置更短 timeout。
5. systemd 兜底
[Service]
KillMode=control-group
MemoryMax=48G
Restart=on-failure
RestartSec=5s
MemoryMax 只是兜底,不能替代代码修复。它的作用是防止单服务拖垮整机。
七、修复后验证
上线后至少观察一个完整任务周期。
执行:
# Go 进程 RSS 是否稳定
ps -eo pid,rss,etime,cmd --sort=-rss | head -20
# heap top 是否还以 bytes.Buffer/os/exec 为主
go tool pprof -top http://127.0.0.1:6060/debug/pprof/heap
# 是否还有长时间运行的 ffmpeg
ps -eo pid,ppid,stat,etime,rss,cmd | grep '[f]fmpeg'
# 是否还有孤儿 ffmpeg
ps -eo pid,ppid,stat,etime,cmd | awk '$2 == 1 && $0 ~ /ffmpeg/'
通过标准:
- Go heap 不再随任务持续线性上涨;
bytes.growSlice -> bytes.Buffer.ReadFrom -> os/exec不再是 heap 主体;- 长时间
ffmpeg数量下降; PPID=1的孤儿ffmpeg消失;- 任务失败时能看到尾部 stderr,但日志大小受控;
- 主机内存曲线不再出现同类持续爬升。
八、监控补齐
建议把下面指标加到服务或主机监控里:
| 指标 | 用途 |
|---|---|
| Go 进程 RSS / PSS | 看服务真实内存趋势 |
| Go heap in-use | 区分 Go heap 和系统缓存 |
| 外部命令并发数 | 判断任务堆积 |
| 最长外部命令运行时间 | 发现卡死任务 |
ffmpeg 进程数 |
发现异常堆积 |
PPID=1 的 ffmpeg 数量 |
发现进程组清理问题 |
| 外部命令 stdout/stderr tail 截断次数 | 发现日志异常放大 |
| 外部命令 timeout 次数 | 发现输入异常或资源瓶颈 |
告警建议:
Go RSS > 基线 * 2持续 10 分钟;Go heap in-use连续增长且不回落;ffmpeg数量超过并发上限;- 最长
ffmpeg运行时间超过任务 SLA; - 孤儿
ffmpeg数量大于 0。
九、升级条件
出现下面任一情况,不要继续只做本地止血,需要升级到开发或服务 owner:
- pprof 已确认
command.Output/os/exec是 heap 主体; - 同一类告警在多台机器复现;
- 清理子进程和重启服务后仍快速复发;
- 外部命令存在大量孤儿进程;
- 任务重试导致同类命令持续堆积;
- 需要修改公共命令执行器或任务调度模型。
升级时带上这几类证据:
1. 告警时间和主机内存曲线
2. Go 服务 PID、RSS/PSS、smaps_rollup
3. pprof top 输出
4. ffmpeg 数量、最长运行时间、孤儿进程列表
5. 命中的代码路径: exec.Command / command.Output
6. 临时止血动作和效果
快速参考
排查命令:
date
hostname
uptime
free -m
ps -eo pid,ppid,user,stat,%cpu,%mem,rss,vsz,etime,cmd --sort=-rss | head -25
cat /proc/<GO_PID>/smaps_rollup
ps -eo pid,ppid,stat,etime,rss,cmd | grep '[f]fmpeg'
ps -eo pid,ppid,stat,etime,cmd | awk '$2 == 1 && $0 ~ /ffmpeg/'
go tool pprof -top http://127.0.0.1:6060/debug/pprof/heap
命中栈:
bytes.growSlice
bytes.(*Buffer).ReadFrom
io.Copy
os/exec.(*Cmd).writerDescriptor.func1
project/command.Output
修复清单:
- 不用无上限
bytes.Buffer接外部命令 stdout/stderr。 - 只保留尾部日志,例如 64KiB 到 1MiB。
- 所有外部命令必须有 timeout。
- timeout 时 kill 进程组,不是只 cancel goroutine。
ffmpeg默认加-nostdin -hide_banner -loglevel warning。- 重任务入口必须限并发。
- systemd
MemoryMax只能兜底,不能替代代码修复。

浙公网安备 33010602011771号