Docker 容器监控、日志与排障实战:从半夜被叫起来到形成 SOP
写在前面:为什么可观测性比排障命令更重要
干过几年 SRE 的人都有一个共识:真正值钱的不是你会背多少 docker 命令,而是你在凌晨三点被叫起来的时候,能不能在五分钟内定位到根因。 命令谁都能 Google,但一套成体系的监控 + 日志 + 排障 SOP,是用无数次故障喂出来的。
我们团队管着一个大概 200 多个节点的容器化集群 ,跑着推荐、搜索、模型推理、定时任务等几十条业务线。容器化最早是图部署快、资源利用率高,但真正上生产之后,可观测性的债很快就还回来了:容器 OOM 把推理服务打挂、日志把磁盘写满导致整个节点容器全部起不来、CrashLoopBackoff 类的重启循环把 CPU 拖垮……
这篇文章把我这几年在 Docker 可观测性和排障上踩的坑、建的体系、沉淀的命令清单一次性写清楚。希望能帮你在出事之前就把该建的建好,而不是出事之后才去补。
一、监控:cAdvisor + Prometheus + Grafana 三件套
1.1 为什么是这套组合
Docker 本身的 docker stats 是给人看的,不是给系统用的——它是一个实时的 CLI 快照,没有存储、没有聚合、没有告警。要做真正的可观测性,你需要的是时序数据。
我们最早也试过 Docker 自带的 Google cAdvisor 单独跑(它的 Web UI 其实挺好看),但很快就发现单点的 cAdvisor 解决不了跨节点聚合、长期保存、告警联动这几个问题。所以标准答案就是:
- cAdvisor:每个节点跑一个(独立容器或 daemon),负责采集本机所有容器的 CPU、内存、网络、文件系统指标,以
/metrics端点暴露。 - Prometheus:中心化的时序数据库,按
cadvisorjob 拉取所有节点的指标,做存储和查询。 - Grafana:可视化看板,Prometheus 官方有现成的 cAdvisor dashboard(ID 193、14282 都不错),导入即用。
- Alertmanager:告警路由,接到 Prometheus 的告警规则触发后,按 severity 路由到企业微信、电话、邮件。
cAdvisor 的部署非常轻量,一段 docker-compose 就能拉起来:
1 | # docker-compose.yml 片段 |
这里有几个踩过的坑值得说一下:
privileged: true不是必须的,但某些 cgroup v2 的内核指标不加特权读不全,我们图省事直接开了。如果你对安全敏感,可以用--device精确挂载。--housekeeping_interval=15s控制采样间隔。默认是 1s,在节点上容器多的时候 Prometheus 拉取压力会很大,我们调到 15s 后指标精度仍然够用,存储量降到原来的 1/15。- cAdvisor 在 cgroup v2 上对某些指标(比如 OOM 计数)的采集有 bug,老版本甚至会 panic。强烈建议用 v0.47 以上版本 。
1.2 我们必装的几个关键告警
光有看板是不够的,告警才是让你半夜能睡着的保险丝。下面是我们线上 PromQL 告警规则里最关键的几条,每一条都是踩过坑之后才加上的:
CPU throttle 告警(最重要,没有之一)
容器限了 CPU(--cpus=2),不代表它真正能用满 2 核。CFS 的 throttle 机制会在容器每个周期(默认 100ms)内一旦配额用完就把它挂起,导致明显的延迟尖刺。我们有一次推理服务 P99 从 80ms 飙到 600ms,最后查出来就是 throttle。
1 | # 容器 CPU 被限流的时间占比,超过 5% 就告警 |
经验值:throttle ratio 超过 5% 就值得查,超过 20% 业务一定有体感 。解决思路要么提 CPU limit,要么调 cpu.cfs_period_us(这个在容器里改不了,得在 runtime 层面)。
内存接近 limit 告警
1 | # 容器内存使用率,超过 limit 的 85% 持续 5 分钟告警 |
注意要用 working_set_bytes 而不是 rss——内核 OOM Killer 看的就是 working set,包括 page cache 里不能被回收的部分。container_spec_memory_limit_bytes 为 0 表示没设 limit,要先过滤掉。
磁盘使用告警
Docker 最容易出事的就是磁盘。我们分两层监控:
- 节点文件系统使用率(
node_filesystem_avail_bytes/node_filesystem_size_bytes),常规 85% 告警。 - Docker 数据目录
/var/lib/docker的增长速率——这个更敏感。如果某个镜像构建或日志在狂写,半天就能把磁盘灌满:
1 | # /var/lib/docker 在 1 小时内增长超过 5GB 告警 |
1.3 看板要分三层
很多团队的 Grafana 看板就是一张大表,所有容器堆在一起,出了事根本看不出哪个有问题。我们后来分了三层:
- 集群总览:节点数、容器总数、异常容器数、CPU/内存总量与使用率。一眼看出集群健不健康。
- 节点详情:单节点的容器列表 + 资源占用排名(TopN)。定位到坏节点。
- 容器详情:单容器的 CPU throttle、内存趋势、网络 IO、重启次数。下钻到具体容器。
这个”总览 → 节点 → 容器”的下钻路径,是排障时最快的定位链路。
二、日志:stdout/stderr 是原则,集中化是刚需
2.1 容器日志的第一性原则
Kubernetes 和 Docker 的官方文档都反复强调一句话:应用日志只往 stdout/stderr 写。
为什么?因为 Docker daemon 会自动接管容器的 stdout/stderr,按你配置的 logging driver 落盘。这样你就能用 docker logs <container> 直接看,也能被统一的日志采集器抓走。如果应用坚持往容器内的文件写日志,那容器一删日志就没了,而且采集器得在容器里跑(sidecar 或者 daemonset 进容器),架构复杂度立刻翻倍。
我们在推荐服务改造的时候,遇到过 Java 应用习惯往 /data/logs/app.log 写的情况。改造方案是用一个轻量方案:日志框架(logback/log4j2)配一个 ConsoleAppender 把日志同步打到 stdout,文件 appender 保留兜底。改造完之后,docker logs 就能看全量日志,排障效率提升一大截。
2.2 json-file 驱动的轮转限制——这条配置能救命
Docker 默认的 logging driver 是 json-file,而且默认不轮转。这意味着一个长期跑的容器,它的日志文件 /var/lib/docker/containers/<id>/<id>-json.log 会无限增长。
这条坑我们吃过最大的亏:某天一个节点的所有容器突然全部变成 Exited 状态,新容器也起不来。登上节点一看,/var/lib/docker 所在分区使用率 100%。罪魁祸首是一个调试期开的 debug 日志容器,跑了三天写了 80 多 GB 的 json 日志 。
正确配置(在 /etc/docker/daemon.json 里全局设置):
1 | { |
max-size:单个日志文件的最大大小,超过就轮转。我们生产用 100m,测试环境可以放宽到 500m。max-file:保留的轮转文件数。5 个就是最多 500m 日志,够看了。
改完之后 systemctl restart docker 生效。注意:这个配置只对新建的容器生效,老容器要重建才行 。
也可以在 docker run 时针对单个容器覆盖:
1 | docker run -d \ |
2.3 集中化方案:Loki 还是 EFK?
一旦容器数量上来(几十个节点以上),单机看日志就不现实了。这时候要么上 EFK(Elasticsearch + Fluentd/Fluent Bit + Kibana),要么上 Loki + Promtail。
我们的选择是 Loki,理由很实在:
- EFK 要存全量索引,存储成本是 Loki 的 5-10 倍。Loki 只索引 label(容器名、namespace),日志正文不索引,存对象存储就行。
- Loki 的查询语言 LogQL 跟 PromQL 几乎一模一样,运维同学上手零成本。
- 跟 Grafana 原生集成,看监控下钻日志一气呵成。
架构上就是:每个节点跑一个 Promtail 容器,它订阅 Docker 的 /var/lib/docker/containers/*/ 目录,按容器 ID 给日志打上 label,发到中心 Loki。Loki 前面挂 Grafana,搜索时用 {container="recommend-service"} 这样的 selector 就能拿到该容器全量日志。
EFK 的优势在全文检索和复杂分析(比如按错误码聚合),如果你的业务对日志的查询复杂度很高、预算充足,EFK 仍然是好选择。但对大多数 SRE 场景,Loki 性价比更高。
三、排障命令清单:现场能背出来的才是真能力
监控和日志是平时的基建,真到了现场,命令行才是肌肉记忆。下面这套是我和团队反复演练过、闭着眼睛也能敲出来的排障命令。
3.1 状态排查层
1 | # 看所有容器状态(包括退出的) |
docker inspect 里最常看的是 .State 字段:Status、ExitCode、Error、OOMKilled、RestartCount。写个 alias 提取这些字段能省不少事:
1 | alias docker-state="docker inspect -f '状态={{.State.Status}} 退出码={{.State.ExitCode}} OOM={{.State.OOMKilled}} 错误={{.State.Error}} 重启次数={{.RestartCount}}'" |
3.2 进入容器层
1 | # 标准操作:进容器开 shell |
nsenter 是高级排障神器。比如容器里没装 ip、tcpdump、ss 这些工具(精简镜像很常见),但宿主机有,用 nsenter -t $PID -n 进入容器的网络命名空间,就能用宿主机的 tcpdump 抓容器里的包。
3.3 系统清理层
1 | # 看 Docker 占了多少磁盘(镜像/容器/卷/构建缓存) |
docker system df -v 加 -v 会列出每个镜像/卷的大小,定位”谁在吃磁盘”很有效。我们 CI 节点每周跑一次 docker builder prune -a -f,能省 30%+ 磁盘 。
四、典型故障复盘案例
光说命令是抽象的,下面两个案例是我们真实出过的事,复盘过程能说明整个排障链路。
案例一:推理服务容器 OOM,但应用日志没有 OutOfMemoryError
故障现象:某个 BERT 推理服务每天会随机 OOM 重启 2-3 次 ,业务方反馈推理偶尔超时。应用日志里没有 Java 那种 java.lang.OutOfMemoryError 堆栈,Python 进程也没打错误日志就直接没了。
排障过程:
docker inspect <container>看.State.OOMKilled: true——确认是内核 OOM Killer 杀的,不是应用自己挂的。docker inspect看HostConfig.Memory: 8589934592(8GB limit),看应用实际峰值内存——用 Prometheus 查container_memory_working_set_bytes最近一周的曲线,发现峰值稳定在 8.2GB 左右,刚好顶满 limit。- 为什么没 OutOfMemoryError?因为是 Python 的 PyTorch,模型加载占了大半内存,运行时内存增长是缓慢的,到 limit 时内核直接 SIGKILL,Python 进程没机会写日志。
- 根因:模型的 batch size 在某个流量峰值时会触发内存尖峰。应用层应该有 backpressure,但实际没做。
解决:
- 临时:把容器 memory limit 提到 12GB 。
- 长期:在 Prometheus 加了”内存使用率 > 80% 持续 3 分钟”的告警(见上文),并和应用团队约定 batch size 上限。
- 复盘要点:OOM 不一定有应用层日志,看
OOMKilled字段才是准的。Python、Go 这种带 GC 的语言,OOM 经常是静默发生的。
案例二:CrashLoopBackoff 类重启循环,把节点 CPU 拖垮
故障现象:周一早上 9 点高峰,某个节点 CPU 突然飙到 100%,所有容器响应变慢,业务大面积超时。登上节点发现一个新部署的容器在疯狂重启——docker ps 里它的 STATUS 一直闪 Restarting (1) 5 seconds ago。
排障过程:
docker logs --tail 100 <container>看日志——容器启动后 3 秒就退出,退出码 1,日志里只有应用框架的初始化报错:”Failed to connect to MySQL: Unknown database ‘recomend’”(注意拼写错了,应该是recommend)。- 这就是经典的”配置错了 → 启动失败 → 重启策略 restart=always → 再启动 → 再失败”循环。每次重启要拉起 JVM、加载 Spring 上下文,CPU 开销不小,频率一高就把节点拖垮。
docker inspect看.HostConfig.RestartPolicy.Name: always,没有任何退避。
解决:
- 立即
docker update --restart=no <container>然后停掉,止血。 - 修配置(数据库名拼写),重新部署。
- 长期改进:把所有业务容器的重启策略改成
restart=on-failure:5,失败 5 次后停止,避免无限循环;并加了一条告警——任何容器 5 分钟内重启次数 > 3 就告警。 - 复盘要点:
restart=always在生产是个危险的默认值,配合配置错误就是放大器。要么用on-failure带次数上限,要么有重启次数告警兜底。
五、其他高频故障的快速定位清单
把日常遇到的几类故障整理成清单,遇到直接对照:
5.1 容器启动失败
docker inspect看.State.Error和.State.ExitCode- ExitCode 127:命令或镜像里的二进制不存在(架构不对,比如 ARM 镜像跑在 x86 上)
- ExitCode 137:被 SIGKILL,多半是 OOM 或宿主机内存压力
- ExitCode 139:段错误,C/C++ 程序或带 native 的库崩了
- “no space left on device”:磁盘满了,先
docker system prune
5.2 容器内 DNS 失效
典型表现:容器里 curl 报 Could not resolve host,但宿主机能解析。排查链路:
- 看
/etc/docker/daemon.json里有没有dns字段配置。 docker run --dns 8.8.8.8 ...强制指定 DNS 测试。- 进容器看
/etc/resolv.conf的nameserver是不是宿主机的 docker0 网桥地址(一般127.0.0.11)。 - 如果是自定义网络,看
docker network inspect <network>的 IPAM 配置。 - 宿主机 systemd-resolved 抢占 53 端口也会导致 docker 内置 DNS 起不来,这个坑在 Ubuntu 18.04+ 很常见。
5.3 容器内进程僵死
应用线程死锁或 stuck,但进程还在、端口还通,健康检查过不了。这时候 docker exec 进去:
1 | # Java |
5.4 性能剖析:火焰图思路
如果容器 CPU 高但找不到热点,可以用 perf 画火焰图。容器里通常没装 perf,技巧是用宿主机的 perf 进入容器命名空间采集:
1 | PID=$(docker inspect -f '{{.State.Pid}}' <container>) |
Java 应用更推荐用 async-profiler,直接 ./profiler.sh -d 30 -f flame.html <pid> 出火焰图,开销小,能直接看到是 JIT、GC 还是业务方法吃 CPU。
六、写在最后:把 SOP 沉淀下来
写完上面这些,回头看其实没有一条是”奇技淫巧”。可观测性和排障的本质,就是把每一次故障的经验沉淀成三样东西:
- 监控指标——让故障在发生前就被发现(CPU throttle、内存告警、磁盘增长率)。
- 日志规范——让故障发生时有据可查(stdout 原则、json-file 轮转、集中化)。
- 排障 SOP——让故障发生后人能快速定位(命令清单、故障 case 库)。
我们团队现在维护着一个”故障 case 库”文档,每次出事之后强制写复盘,记录现象、定位过程、根因、改进项。半年下来,平均故障定位时间从 40 多分钟降到了 8 分钟以内 。这套东西比任何单条命令都值钱。
Docker 本身不难,难的是在它上面建起一套能让团队半夜安心睡觉的体系。希望这篇文章能帮你少走一些我们走过的弯路。
配置与命令基于 Docker 24.x / cAdvisor v0.47 / Prometheus v2.45 / Loki v2.9 环境 。文中所有指标阈值、集群规模、性能数字均为占位示意,请按你的真实经历核实替换后再发布。
