Go 服务内存持续上涨排查 SOP

SOP 目标:当 Linux 主机出现「Go 常驻服务内存持续上涨 / 触发 90% 内存告警 / 疑似 OOM」,且服务会调用 ffmpegffprobeimagemagick、压缩工具或其他外部命令时,用这套步骤快速判断:内存是不是被 Go 进程里的 os/exec 输出缓冲撑大的。

注:本文里的主机名、地域、项目名、服务名、路径、业务函数名和任务名都已脱敏。app-workermedia taskproject/command.Output192.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 或代码审查。

Go 服务内存上涨排查链路

一、确认告警是不是进程 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=1ffmpeg 父进程没有完整回收子进程或进程组
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,不要只改单个业务入口。根修复必须落在公共执行器。

Go os/exec + ffmpeg 输出进入 Go heap 的过程

五、临时止血

临时止血的目标是先保护机器,不是替代代码修复。

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 warningerror:减少正常路径输出。

如果确实需要进度,不要直接吃默认 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=1ffmpeg 数量 发现进程组清理问题
外部命令 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 只能兜底,不能替代代码修复。
posted @ 2026-07-07 11:40  Hello_worlds  阅读(3)  评论(0)    收藏  举报