文章摘要
飞宇 GPT

写在前面:为什么可观测性比排障命令更重要

干过几年 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:中心化的时序数据库,按 cadvisor job 拉取所有节点的指标,做存储和查询。
  • Grafana:可视化看板,Prometheus 官方有现成的 cAdvisor dashboard(ID 193、14282 都不错),导入即用。
  • Alertmanager:告警路由,接到 Prometheus 的告警规则触发后,按 severity 路由到企业微信、电话、邮件。

cAdvisor 的部署非常轻量,一段 docker-compose 就能拉起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# docker-compose.yml 片段
services:
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.47.2 <!-- 校准:请按真实经历核实/替换 -->
container_name: cadvisor
restart: unless-stopped
privileged: true
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
devices:
- /dev/kmsg
ports:
- "8080:8080"
command:
- "--housekeeping_interval=15s"
- "--docker_only=true"

这里有几个踩过的坑值得说一下:

  • 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
2
3
4
# 容器 CPU 被限流的时间占比,超过 5% 就告警
sum by(container, pod, namespace) (
rate(container_cpu_cfs_throttled_seconds_total[5m])
) > 0.05

经验值:throttle ratio 超过 5% 就值得查,超过 20% 业务一定有体感 。解决思路要么提 CPU limit,要么调 cpu.cfs_period_us(这个在容器里改不了,得在 runtime 层面)。

内存接近 limit 告警

1
2
3
4
5
6
# 容器内存使用率,超过 limit 的 85% 持续 5 分钟告警
sum by(container) (container_memory_working_set_bytes)
/ sum by(container) (container_spec_memory_limit_bytes) > 0
and
sum by(container) (container_memory_working_set_bytes)
/ sum by(container) (container_spec_memory_limit_bytes) > 0.85

注意要用 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
2
3
# /var/lib/docker 在 1 小时内增长超过 5GB 告警
predict_linear(node_filesystem_size_bytes{mountpoint="/var/lib/docker"}[1h], 3600)
- node_filesystem_size_bytes{mountpoint="/var/lib/docker"} > 5 * 1024 * 1024 * 1024

1.3 看板要分三层

很多团队的 Grafana 看板就是一张大表,所有容器堆在一起,出了事根本看不出哪个有问题。我们后来分了三层:

  1. 集群总览:节点数、容器总数、异常容器数、CPU/内存总量与使用率。一眼看出集群健不健康。
  2. 节点详情:单节点的容器列表 + 资源占用排名(TopN)。定位到坏节点。
  3. 容器详情:单容器的 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
2
3
4
5
6
7
{
"log-driver": "json-file",
"log-opts": {
"max-size": "100m",
"max-file": "5"
}
}
  • max-size:单个日志文件的最大大小,超过就轮转。我们生产用 100m,测试环境可以放宽到 500m。
  • max-file:保留的轮转文件数。5 个就是最多 500m 日志,够看了。

改完之后 systemctl restart docker 生效。注意:这个配置只对新建的容器生效,老容器要重建才行

也可以在 docker run 时针对单个容器覆盖:

1
2
3
4
5
docker run -d \
--log-driver json-file \
--log-opt max-size=50m \
--log-opt max-file=3 \
my-image:tag

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
2
3
4
5
6
7
8
9
10
11
12
13
14
# 看所有容器状态(包括退出的)
docker ps -a

# 看容器详细配置、状态、退出码、重启次数
docker inspect <container>

# 实时资源占用(CPU/内存/网络/IO)
docker stats --no-stream

# 看容器内进程树(排查僵尸进程、谁占的内存)
docker top <container>

# 监听 docker daemon 的实时事件(容器起停、健康检查)
docker events --filter type=container

docker inspect 里最常看的是 .State 字段:StatusExitCodeErrorOOMKilledRestartCount。写个 alias 提取这些字段能省不少事:

1
alias docker-state="docker inspect -f '状态={{.State.Status}} 退出码={{.State.ExitCode}} OOM={{.State.OOMKilled}} 错误={{.State.Error}} 重启次数={{.RestartCount}}'"

3.2 进入容器层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 标准操作:进容器开 shell
docker exec -it <container> bash
# 容器没装 bash 的话(alpine)
docker exec -it <container> sh

# 但如果容器已经 Crash 了,exec 进不去怎么办?
# 方案一:用 docker commit 把现场保存成镜像,再 run 进去
docker commit <container> debug-img
docker run -it --entrypoint sh debug-img

# 方案二:用 nsenter 进入容器的命名空间(更高级)
# 先拿到容器主进程 PID
PID=$(docker inspect -f '{{.State.Pid}}' <container>)
# 用 nsenter 进入它的 mount/net/pid 命名空间
nsenter -t $PID -m -u -i -n -p

nsenter 是高级排障神器。比如容器里没装 iptcpdumpss 这些工具(精简镜像很常见),但宿主机有,用 nsenter -t $PID -n 进入容器的网络命名空间,就能用宿主机的 tcpdump 抓容器里的包。

3.3 系统清理层

1
2
3
4
5
6
7
8
9
10
11
# 看 Docker 占了多少磁盘(镜像/容器/卷/构建缓存)
docker system df -v

# 一键清理:停止的容器、悬空镜像、未使用网络、构建缓存
docker system prune -f

# 更激进:连同未使用的卷一起删(小心,卷里可能有数据)
docker system prune -a --volumes -f

# 单独清构建缓存(CI 节点经常需要)
docker builder prune -a -f

docker system df -v-v 会列出每个镜像/卷的大小,定位”谁在吃磁盘”很有效。我们 CI 节点每周跑一次 docker builder prune -a -f,能省 30%+ 磁盘


四、典型故障复盘案例

光说命令是抽象的,下面两个案例是我们真实出过的事,复盘过程能说明整个排障链路。

案例一:推理服务容器 OOM,但应用日志没有 OutOfMemoryError

故障现象:某个 BERT 推理服务每天会随机 OOM 重启 2-3 次 ,业务方反馈推理偶尔超时。应用日志里没有 Java 那种 java.lang.OutOfMemoryError 堆栈,Python 进程也没打错误日志就直接没了。

排障过程

  1. docker inspect <container>.State.OOMKilled: true——确认是内核 OOM Killer 杀的,不是应用自己挂的。
  2. docker inspectHostConfig.Memory: 8589934592(8GB limit),看应用实际峰值内存——用 Prometheus 查 container_memory_working_set_bytes 最近一周的曲线,发现峰值稳定在 8.2GB 左右,刚好顶满 limit。
  3. 为什么没 OutOfMemoryError?因为是 Python 的 PyTorch,模型加载占了大半内存,运行时内存增长是缓慢的,到 limit 时内核直接 SIGKILL,Python 进程没机会写日志。
  4. 根因:模型的 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

排障过程

  1. docker logs --tail 100 <container> 看日志——容器启动后 3 秒就退出,退出码 1,日志里只有应用框架的初始化报错:”Failed to connect to MySQL: Unknown database ‘recomend’”(注意拼写错了,应该是 recommend)。
  2. 这就是经典的”配置错了 → 启动失败 → 重启策略 restart=always → 再启动 → 再失败”循环。每次重启要拉起 JVM、加载 Spring 上下文,CPU 开销不小,频率一高就把节点拖垮。
  3. 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 失效

典型表现:容器里 curlCould not resolve host,但宿主机能解析。排查链路:

  1. /etc/docker/daemon.json 里有没有 dns 字段配置。
  2. docker run --dns 8.8.8.8 ... 强制指定 DNS 测试。
  3. 进容器看 /etc/resolv.confnameserver 是不是宿主机的 docker0 网桥地址(一般 127.0.0.11)。
  4. 如果是自定义网络,看 docker network inspect <network> 的 IPAM 配置。
  5. 宿主机 systemd-resolved 抢占 53 端口也会导致 docker 内置 DNS 起不来,这个坑在 Ubuntu 18.04+ 很常见。

5.3 容器内进程僵死

应用线程死锁或 stuck,但进程还在、端口还通,健康检查过不了。这时候 docker exec 进去:

1
2
3
4
5
6
7
# Java
docker exec <container> jstack 1 > thread-dump.txt
# Python(带 faulthandler)
docker exec <container> kill -SIGUSR1 1
# 通用:看进程在干嘛
docker exec <container> cat /proc/1/status
docker exec <container> ls -l /proc/1/task/ | wc -l # 线程数

5.4 性能剖析:火焰图思路

如果容器 CPU 高但找不到热点,可以用 perf 画火焰图。容器里通常没装 perf,技巧是用宿主机的 perf 进入容器命名空间采集:

1
2
3
4
5
PID=$(docker inspect -f '{{.State.Pid}}' <container>)
# 用 nsenter 在容器命名空间里跑 perf
nsenter -t $PID -p -- perf record -F 99 -p $PID -g -- sleep 30
# 生成火焰图(用 FlameGraph 脚本)
perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg

Java 应用更推荐用 async-profiler,直接 ./profiler.sh -d 30 -f flame.html <pid> 出火焰图,开销小,能直接看到是 JIT、GC 还是业务方法吃 CPU。


六、写在最后:把 SOP 沉淀下来

写完上面这些,回头看其实没有一条是”奇技淫巧”。可观测性和排障的本质,就是把每一次故障的经验沉淀成三样东西:

  1. 监控指标——让故障在发生前就被发现(CPU throttle、内存告警、磁盘增长率)。
  2. 日志规范——让故障发生时有据可查(stdout 原则、json-file 轮转、集中化)。
  3. 排障 SOP——让故障发生后人能快速定位(命令清单、故障 case 库)。

我们团队现在维护着一个”故障 case 库”文档,每次出事之后强制写复盘,记录现象、定位过程、根因、改进项。半年下来,平均故障定位时间从 40 多分钟降到了 8 分钟以内 。这套东西比任何单条命令都值钱。

Docker 本身不难,难的是在它上面建起一套能让团队半夜安心睡觉的体系。希望这篇文章能帮你少走一些我们走过的弯路。


配置与命令基于 Docker 24.x / cAdvisor v0.47 / Prometheus v2.45 / Loki v2.9 环境 。文中所有指标阈值、集群规模、性能数字均为占位示意,请按你的真实经历核实替换后再发布。