案例 rootless Docker 的 slirp4netns 被 OOM 杀掉,6 小时后 json-file 日志缓冲把 daemon 冻死

2026-09-26 21:06:20

rootless Docker 的 slirp4netns 被 OOM 杀掉,6 小时后 json-file 日志缓冲把 daemon 冻死

来源:sumguy.com/unused-container-oom-postmortem

周一早上,Slack 里是一整面 02:48 AM 触发的告警。站点范围 502。docker compose ps 挂住,systemctl restart 没有任何反应。机器还活着,SSH 能进,但 Docker 和一半 systemd 完全僵死。

第一幕:慢速渗漏(周日 04:23 AM)

生产服务器跑的是 rootless Docker。rootless Docker 需要用户态网络层 slirp4netns。slirp4netns 是单个进程,负责主机上所有 rootless 容器的出站网络。一个进程,没有冗余,单点故障。

周日 04:23 AM 它开始泄漏内存。等内核 OOM killer 注意到时,RSS 已经涨到 3.54 GB:

Jun 21 04:23:52 prod-web-01 kernel: systemd invoked oom-killer
Jun 21 04:23:52 prod-web-01 kernel: Out of memory: Killed process 837 (slirp4netns) total-vm:9073940kB, anon-rss:3538292kB

slirp4netns 一死,所有 rootless 容器同时失去出站网络和 DNS。前端仍在接收入站请求,nginx listener 没有被杀。但每个需要访问外部的后端服务都失联了。外部支付网关 API 开始超时。原本 200ms 完成的请求,现在挂 30 秒才失败。

第二幕:日志洪水(周一 02:48 AM)

容器网络坏掉之后,每个尝试访问外部端点的服务每次调用都在失败。DNS 超时、connection refused、重试循环,每次失败都产生一行日志。支付流程每隔几秒在多个容器里重试,日志量从正常水平变成消防水管。

Docker 的 json-file 日志驱动在默认的 blocking 模式下,写不及的时候会把日志输出缓冲在内存里。内核处于内存压力下,写入变慢;写入变慢让缓冲变大;缓冲变大带来更大内存压力。到 02:48 AM,Docker logging 进程已经吃掉 6.25 GB 内存:

Jun 22 02:48:01 prod-web-01 kernel: Out of memory: Killed process 1244 (docker-logging-) total-vm:12911116kB, anon-rss:6257804kB

OOM killer 把它打掉了。logging daemon 一死,Docker 无法从任何容器收集日志,也无法干净地关闭那些仍在尝试写日志的容器。整个 Docker daemon 进入死锁状态。docker compose 命令无限挂起,systemctl restart docker 没有任何作用。唯一的出路是硬重启那台 AWS 实例。

第三幕:难堪的部分

重启之后 nginx 起不来,有一个 location block 是坏的。六个月前起过一个 Formbricks(开源问卷工具),既没真正投入用起来,也没删掉:

location /surveys {
  proxy_pass http://${FORMBRICKS_CONTAINER_HOST}:${FORMBRICKS_CONTAINER_PORT};
  proxy_set_header Host $host;
  proxy_set_header X-Real-IP $remote_addr;
}

nginx 在 proxy_pass 里使用变量时,会从启动时的静态解析切换到每次请求的运行时 DNS 解析。运行时 DNS 解析需要显式的 resolver 指令,他们没有配。

这里的区别在于:这个坏掉的 proxy block 并没有拖垮整站的入站流量。/surveys 这个 location 里上游解析失败,只让发往 /surveys 的请求返回 502。全站 502 是上游网络对所有服务都死了造成的。教训是:没有清理掉的死配置,会在最糟的时刻变成障碍。

实际修了什么

1. Docker 日志限制:两组不同的旋钮

max-size / max-file 控制的是磁盘占用——保留几个文件、多大轮转。但内存问题来自 json-file 驱动默认的 blocking 模式。更关键的修法是 non-blocking 模式:

daemon.json:

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "50m",
    "max-file": "3",
    "mode": "non-blocking",
    "max-buffer-size": "10m"
  }
}

然后 sudo systemctl restart docker,用 docker info | grep -A 3 "Logging Driver" 验证。

2. slirp4netns 的问题

不要退回 rootful Docker。真正的问题在 slirp4netns 本身:单进程、没有失败重启行为、默认不受资源上限约束。

方案 A:监控它,让 rootless 栈自愈。 在 rootless Docker 里没法给 slirp4netns 单独「节食」——它不是独立的 systemd unit,而是由 rootlesskit 作为用户级 docker.service 的子进程拉起来的,不存在可以塞 MemoryMax 的 slirp4netns.service。能做的是通过用户级 drop-in 给 rootless docker.service 加重启策略,再加一个粗暴的 cgroup 内存上限:

~/.config/systemd/user/docker.service.d/override.conf

[Service]
Restart=on-failure
RestartSec=5
# Optional and blunt: caps the WHOLE rootless Docker cgroup
# MemoryMax=6G

然后 systemctl --user daemon-reload 并重启。真正的收益在监控:对 slirp4netns 的 RSS 做告警,这样你能在 OOM killer 之前发现。

方案 B:迁移到 pasta/passt。 pasta(或其库形式 passt)是 slirp4netns 的替代品,内存占用更小,性能更好(大约 2.4x 吞吐,CPU 开销更低)。pasta 需要 Docker Engine 25.0 或更新版本。切换方式是设置:

Environment=DOCKERD_ROOTLESS_ROOTLESSKIT_NET=pasta

先确认 passt/pasta 已安装;没装的话 daemon 会静默回退。

方案 C:bypass4netns。 对吞吐敏感的部署,可以让容器到主机的流量完全跳过用户态网络层。

3. 清理配置要像清理容器一样

把某个东西下线时,做完整清理:停掉容器、从 compose 里移除、删掉它对应的 nginx block、删掉它的数据卷。

要点

  1. 闲置服务是负债。
  2. 给日志加限制,两个维度都要:max-size / max-file 管磁盘,mode: non-blocking + max-buffer-size 管内存。
  3. slirp4netns 是 rootless Docker 的薄弱环节——没有失败重启、没有内存上限、单点故障。要么加重启策略并限制内存,要么迁移到 pasta。
  4. 搞清楚反向代理怎么解析上游 DNS:静态的 proxy_pass http://hostname:port 在启动时解析,失败则 nginx 起不来;变量形式的 proxy_pass http://${VAR}:port 每次请求运行时解析,需要 resolver 指令,上游挂掉只会让那个 location 返回 502。
复制全文 生成海报 运维 Docker 容器 故障复盘 日志

推荐文章

程序员茄子在线接单