Docker Compose 内存限制设置,避免容器 OOM
在 Docker Compose 中设置内存和 CPU 上限,防止单个容器拖垮 VPS。涵盖 deploy.resources、mem_limit、退出码 137、swap 与容量规划。
Docker Compose 内存限制的作用
Docker Compose 内存限制是 Linux 内核为单个容器的 cgroup(控制组,用于为一组进程计量资源的内核功能)设置的硬上限。为服务设置 deploy.resources.limits.memory 后,该容器使用的内存不会超过指定值。容器尝试超限时,内核会终止容器内的某个进程,容器通常会以代码 137 退出。
这在 VPS 上尤其重要,因为 VPS 的 RAM 是固定的,没有额外的主机内存可供借用。一个存在内存泄漏或执行错误查询的容器,可能耗尽一台 8GB 主机上的所有可用页面。然后,内核会终止它认为最应被终止的进程。这个进程通常是数据库或您的 SSH 会话,而不是导致问题的容器。设置限制后,整台服务器的中断可以变为单个服务重启。
services:
app:
image: ghcr.io/example/app:1.4
deploy:
resources:
limits:
cpus: "1.5"
memory: 1g
reservations:
memory: 256m应用设置并确认限制已生效:
docker compose up -d
docker stats --no-streamMEM USAGE / LIMIT 列应显示类似 142MiB / 1GiB 的值。如果限制列显示完整的主机 RAM,说明设置未生效。在设置生效前,本指南的其余内容都无法解决问题。如果您不熟悉 compose 文件,请参阅VPS 的 Docker Compose 基础知识,其中介绍了本文所依据的文件布局。
deploy.resources.limits 还是 mem_limit:哪个生效
同一概念有两种写法,因此容易混淆。
mem_limit、mem_reservation、memswap_limit、cpus 和 cpu_shares 是从旧版 Compose 文件格式继承而来的顶层服务键。deploy.resources 来自 Swarm schema,现在已成为 Compose Specification 的一部分,也是 docker compose 当前读取的格式。
两种写法在单主机上都有效。Compose V2,即 docker compose 插件,在运行 docker compose up 时会应用 deploy.resources.limits 和 deploy.resources.reservations,不需要任何 Swarm 集群。deploy 块中仅适用于 Swarm 的部分是其他键:mode、placement、update_config 和 endpoint_mode 对 docker stack deploy 有意义,但会被 docker compose up 忽略。因此,常见的“deploy 需要 Swarm”这一说法对于 resources 子部分并不正确。遵循这一说法会导致服务完全没有限制。
每个项目选择一种写法。同时在同一服务中写入 mem_limit: 512m 和 deploy.resources.limits.memory: 1g,会让人无法一眼看出最终使用了哪个值。不要猜测哪个数值生效,直接向守护进程查询:
docker inspect --format '{{.HostConfig.Memory}} {{.HostConfig.MemoryReservation}} {{.HostConfig.NanoCpus}}' app-1内存值的单位是字节,因此 1g 会显示为 1073741824。CPU 的单位是 nano CPU,因此 1.5 会显示为 1500000000。任何字段中的 0 都表示未设置限制。Docker 接受的最小内存限制是 6m,低于此值时容器将拒绝启动。
容器达到限制时会发生什么
容器不会变慢。它会终止。
当进程请求一个内存页,而 cgroup 已达到其 memory.max 时,内核首先会在该 cgroup 内回收可回收的内容:先回收干净的页缓存,然后回收可以交换出去的页面。如果回收的内存仍然不足,cgroup 的 OOM(内存不足)终止程序会选择容器内的一个进程,并向其发送 SIGKILL。终止容器的 PID 1 会结束容器。退出代码 137 只是 128 加信号 9,因此 137 是任何 SIGKILL 的特征,并不能单独证明发生了 OOM。
docker compose ps -a
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' app-1true 137 表示 OOM 终止。false 137 表示 SIGKILL 由其他原因发送,通常是 docker compose stop 达到其 10 秒宽限期,因为应用忽略了 SIGTERM。这个区别可以节省数小时,因为这两个问题完全无关。
还有两个位置会记录此事件。实时监控守护进程:
docker events --filter event=oom然后读取内核日志。重启后仍会保留该日志记录:
sudo dmesg -T | grep -i -E 'memory cgroup out of memory|killed process'cgroup 终止会输出以 Memory cgroup out of memory: Killed process 24713 (node) 开头的行。不带 Memory cgroup 前缀的行表示主机 OOM,即整台机器的 RAM 已耗尽。这正是限制要防止的故障,因此看到此类记录说明你的限制总和过高,或者某些服务根本没有设置限制。
使用 restart: unless-stopped 时,OOM 循环很容易被隐藏,因为服务会在终止一秒后显示为 docker compose ps。检查运行时间列和重启次数,并将该限制与报告应用不健康状态的 healthcheck结合使用。这样,即使你没有持续监控,反复终止的容器也能被发现。
预留值是提示,限制值才是规则
reservations.memory(较早的 mem_reservation)是一个软下限。Docker 将其描述为一种软限制:当守护进程检测到主机存在资源争用或内存不足时,该限制才会生效。它不会阻止容器使用超过该值的内存,也不会保证容器请求内存时一定有可用内存。它只会让内核优先从超过其预留值的容器回收内存。
因此,预留值本身不能提供保护。可以使用它标记希望在资源压力下得到优先照顾的服务,但安全性应依赖限制值。预留值必须低于限制值,否则容器将无法启动:Docker 会返回 Minimum memory limit can not be less than memory reservation limit,并拒绝该配置。
坦诚说明 swap 计费
大多数 VPS 镜像根本不提供 swap 文件。运行 swapon --show 和 free -h。如果 swap 总量为 0,下面所有与 swap 相关的设置都不起作用,您的内存限制就是纯 RAM 上限。
memswap_limit 不是 swap 的大小,而是内存加 swap 的总量。使用 mem_limit: 1g 和 memswap_limit: 2g 时,容器获得 1GB RAM 和 1GB swap。将两个值设为相同,容器将完全没有 swap。设置 mem_limit 并保持 memswap_limit 未设置,可让容器再次使用 swap,最多达到其内存限制的大小。
Ubuntu 24.04 和 Debian 13 默认使用 cgroup v2。此时 swap 使用独立的计数器(memory.swap.max),无需额外配置即可正常工作。旧消息 Your kernel does not support swap limit capabilities 来自使用 cgroup v1 且启动时未设置 swapaccount=1 的主机。在这些主机上,内存限制仍然生效,但 swap 部分会被忽略。
请准确理解 swap 的作用。它会让 OOM kill 发生得更慢,而不是降低发生概率,因为存在内存泄漏的进程填满 swap 的速度和填满 RAM 一样快。同时,在共享 VPS 存储上持续使用 swap 的容器会拖慢这台主机上的其他服务。对于任何对延迟敏感的服务,设置正确的限制并禁用 swap,可以更快、更可预测地失败。
为什么内存使用量看起来比实际更高
docker stats 中的 MEM USAGE 数值包含页缓存。因此,读取大文件的容器会逐渐接近其限制并保持在该水平。这是正常现象,不表示发生了内存泄漏,因为 OOM killer 被调用前,内核会先回收干净的缓存。自托管的 Jellyfin 媒体服务器 等服务正是因此看起来一直接近内存上限。
在容器内部将该数值拆分为缓存和实际工作集:
docker compose exec app grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat
docker compose exec app cat /sys/fs/cgroup/memory.eventsanon 是匿名内存,即无法丢弃的工作集。file 是可以回收的页缓存。应根据 anon 加上余量来设置限制,而不是根据总量设置。memory.events 文件可以直接确认结果:oom_kill 计数器大于 0,表示该容器启动后内核曾终止过其中的进程;max 计数器持续上升,表示容器当前正被限制在其上限。两条命令都需要镜像中包含 shell 和 coreutils,因此在 distroless 或 scratch 镜像中会失败。
8GB VPS 上的容量限制
从主机开始,而不是从应用开始。在 8GB VPS 上,为内核、Docker daemon、sshd、journald 和您自己的登录 shell 预留约 1GB。这样约剩 7GB 可分配,所有容器限制的总和应保持在该数值以下。超额分配通常一直有效,直到某天两个服务同时达到峰值。
在 8GB 主机上,可以采用以下分配方式:
- 反向代理:限制为 128m。它是一个小型进程。如此严格的限制可以立即发现失控的配置重载。
- PostgreSQL:限制为 2g,并在数据库配置中将
shared_buffers设置为约 512MB。 - 应用容器:限制为 1g。
- 后台工作进程:限制为 512m。
- 媒体或文件服务:限制为 2g,其中大部分将用于页面缓存。
不要将这些数值直接用于您自己的服务栈。在真实负载下运行这些服务一天,监控 docker stats,获取每个容器的峰值 anon 值,然后再增加约一半作为余量。限制设置得过低比不设置限制更糟,因为正常的流量峰值就可能导致健康的服务被终止。
有一个容易踩到的坑,需要单独说明。除非明确告知运行时,否则大多数运行时都看不到该限制。PostgreSQL 会继续将 shared_buffers 和 work_mem 设置到超过容器限制的数值,随后被终止。JVM(Java 虚拟机)需要使用 -XX:MaxRAMPercentage=75,才能根据 cgroup 限制而不是主机 RAM 设置堆大小。Node.js 需要将 --max-old-space-size 设置为低于容器限制的兆字节数,否则其垃圾回收器会让堆持续增长,直到内核介入。cgroup 不会协商。它会终止进程。
CPU 限制的行为完全不同
cpus: "1.5" 表示单个核心的 150%,通过 CFS(完全公平调度器)配额强制执行。容器在每个 100ms 周期内获得 150ms 的 CPU 时间,所有线程共享这段时间。用完后,内核会让容器等待下一个周期。
这就是关键区别。超过内存限制的容器会被终止。超过 CPU 限制的容器会受到节流,但仍会继续运行,只是速度更慢。因此,可以较为积极地设置 CPU 限制,而内存限制需要预留余量。
cpu_shares 是另一种工具:相对权重,仅在 CPU 实际饱和时才会产生影响。share 分别为 1024 和 512 的两个容器会大致按二比一的比例分配繁忙核心的处理能力;在空闲主机上,两者都不会受到限制。使用 share 按重要性为服务排序;需要设置实际上限时使用 cpus,例如防止夜间转码任务耗尽资源,导致 Web 服务器无法正常运行。
FAQ
deploy.resources.limits 在没有 Docker Swarm 时是否有效?
有效。在单个主机上运行 docker compose up 时,Compose V2 会应用 deploy.resources.limits 和 deploy.resources.reservations。使用 docker inspect --format '{{.HostConfig.Memory}}' <container> 进行确认。该命令会以字节为单位输出限制;未应用限制时会输出 0。deploy 中确实需要 Swarm 的键是 mode、placement、update_config 和 endpoint_mode。
Docker Compose 中的退出代码 137 表示什么?
这表示主进程收到了 SIGKILL,因为 137 等于 128 加信号 9。常见原因是内核 OOM killer,但应用忽略 SIGTERM 时,关闭超时也会产生相同的代码。运行 docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' <container> 以区分这两种情况。true 137 表示内存终止,false 137 则不是。
我应该设置 mem_limit 还是 deploy.resources.limits.memory?
使用 docker compose 时,两者都有效。deploy.resources.limits.memory 是当前 Compose Specification 的写法,也是新文件的更好默认选项。如果文件的其余部分已经使用旧的顶层键,请保留 mem_limit。在同一个服务中同时设置两者只会降低文件的可读性,因此请选择一种,并使用 docker inspect 验证结果。
为什么我的容器一直占用完整的内存限制,却没有被终止?
docker stats 中的使用量包括页缓存。内核会在内存压力下释放页缓存,而不是触发 OOM kill。运行 docker compose exec <service> grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat 并读取 anon 值。该值表示无法回收的工作集。file 值较高而 anon 值较低,表示容器正在进行磁盘输入输出,而不是即将终止。
在 8GB VPS 上,我应该保留多少未分配的 RAM?
为内核、Docker daemon、sshd、journald 和自己的 shell 保留约 1GB,然后将所有容器限制的总和控制在剩余的 7GB 以内。在确定具体数值前,在实际负载下运行一天,监控每个容器的峰值 anon 值,并将总量视为预算,而不是必须填满的目标。