--- id: devops.linux.scenario.incident-troubleshooting title: 线上故障排查:接口变慢、磁盘写满、进程被 OOM 杀掉 type: scenario domain: devops stack: [linux, go, nginx] tags: [故障排查, OOM, 磁盘满, pprof, 复盘] level: intermediate prerequisites: [devops.linux.troubleshooting] est_hours: 2 related: [devops.linux.interview, devops.nginx.interview] week: [14] --- # 线上故障排查:接口变慢、磁盘写满、进程被 OOM 杀掉 ## 业务背景与问题 一个小团队维护的 SaaS 系统,部署在两台云服务器上(Nginx + Go API + PostgreSQL),没有专职运维。某天上午:用户反馈页面很慢,部分请求 502;中午服务自己恢复了;下午又出现。你被要求“查清楚原因并保证不再发生”。 ## 约束 - 业务优先:排查不能让故障时间更长。 - 只有 SSH 权限和基本的云监控(CPU、内存、磁盘曲线),没有完整的监控平台。 - 需要给出书面复盘。 ## 方案设计 按“止血 → 采集现场 → 定位 → 修复 → 预防”推进: 1. **止血**:确认影响范围(Nginx 日志里 5xx 比例),如果最近有发布先回滚;必要时重启服务恢复,但**重启前先采集现场**。 2. **采集现场**:运行 `diag.sh` 保存系统快照;保存最近的 Nginx 错误日志、应用日志;对 Go 服务抓 pprof(heap、goroutine)。 3. **定位**(本例的实际线索): - Nginx 错误日志:`connect() failed (111: Connection refused) while connecting to upstream` → 502 是因为 API 进程不在了; - `journalctl -k`:`Out of memory: Killed process 2345 (api)` → API 被 OOM 杀掉,systemd 自动拉起,所以“自己恢复了”; - 内存曲线:API 内存从启动开始持续上涨; - pprof heap:大部分内存在一个“导出任务结果”的 map 里——导出结果放在内存里等用户下载,从不清理。 - 同时发现 `df -h` 磁盘使用 92%:应用日志没有切割。 4. **修复**:导出结果改为写入对象存储 / 临时文件并设置过期清理;本地缓存加容量上限;配置 logrotate;systemd 设置 `MemoryMax` 让问题早暴露、不拖垮整机。 5. **预防**:增加内存、磁盘、5xx 比例的告警;发布后观察内存曲线;复盘文档记录时间线和改进项。 ## 关键代码 现场采集(只读): ```bash bash diag.sh api > /tmp/diag-$(date +%F-%H%M).txt journalctl -k --since "today" | grep -i -E "oom|killed process" tail -n 200 /var/log/nginx/error.log | grep -E "upstream|connect\(\)" curl -s -o /tmp/heap.pb.gz http://127.0.0.1:6060/debug/pprof/heap go tool pprof -top -inuse_space /tmp/heap.pb.gz | head -n 15 ``` logrotate 配置 `/etc/logrotate.d/api`: ```text /srv/api/logs/*.log { daily rotate 14 compress missingok notifempty copytruncate } ``` 带上限的本地缓存(修复思路,替代“无限增长的 map”): ```go // 最多保留 max 个条目,超过时淘汰最早写入的(简单的 FIFO,按需换成 LRU) type BoundedCache[K comparable, V any] struct { mu sync.Mutex max int order []K data map[K]V } func NewBoundedCache[K comparable, V any](max int) *BoundedCache[K, V] { return &BoundedCache[K, V]{max: max, data: make(map[K]V)} } func (c *BoundedCache[K, V]) Put(k K, v V) { c.mu.Lock() defer c.mu.Unlock() if _, ok := c.data[k]; !ok { c.order = append(c.order, k) if len(c.order) > c.max { delete(c.data, c.order[0]) c.order = c.order[1:] } } c.data[k] = v } ``` 系统快照脚本和日志分析脚本见 [examples/log-analysis](../examples/log-analysis/)。 ## 取舍 | 做法 | 优点 | 缺点 | | --- | --- | --- | | 直接重启 | 恢复最快 | 丢失现场,问题会再次发生 | | **先采集现场再重启(本方案)** | 能定位根因 | 多花一两分钟 | | 加内存 | 立即缓解 | 掩盖泄漏,只是延后 | | systemd `MemoryMax` 限制 | 防止拖垮整机、问题早暴露 | 进程会更早被杀,需要配合告警 | ## 易错点 - 只看应用日志,没看内核日志:OOM 在应用日志里没有任何痕迹。 - 把“自己恢复了”当成没问题:其实是 systemd 自动重启掩盖了崩溃。 - 删除正在写的日志文件释放空间:空间不会释放,用 `copytruncate` 或让程序重新打开文件。 - 复盘只写“已修复”:要写时间线、根因、为什么没有提前发现、改进项和负责人。 ## 面试怎么讲 > “有一次服务间歇性 502,自己又恢复。我先看 Nginx 错误日志,是连接后端被拒绝,说明进程挂了;再查内核日志发现是 OOM 被杀,systemd 自动重启所以看起来自己恢复了。用 pprof 看 heap,发现导出结果一直放在内存 map 里没清理。修复后又加了缓存上限、日志切割、内存和 5xx 告警,并写了复盘。” 如果你在前端岗位做过类似的线上排查(比如内存泄漏导致页面卡死、静态资源 404),也可以用同样的结构讲:现象 → 证据 → 根因 → 修复 → 预防。