Skip to content

容器不是轻量虚拟机 ​

给容器限了 0.5 个 CPU、256MB 内存,进到容器里一看:nproc 是 6,/proc/meminfo 说有 15,970MB。三个不同的镜像里,uname -r 输出的是同一个内核版本。容器里的 root,在宿主机上也是 uid 0。容器只是宿主机上的一个进程,只不过被几种机制分别限制了「能看到什么」「能用多少」「文件放在哪」。

把容器当成轻量虚拟机,会在几个地方出错:以为镜像里带了操作系统内核,以为容器里的工具看到的就是限额,以为容器里的 root 是隔离的,以为容器停下来时应用一定会收到通知。这些都在上 Kubernetes 之后变成线上问题:Pod 被 OOM 杀掉、应用线程数按整台机器配置、滚动发布时请求被中断。

本文用 Docker(Docker Desktop,Linux 虚拟机内核 7.0.12,cgroup v2)逐项验证容器的几条边界,见文末配套实验。Kubernetes 里的 Pod 用的是同一套内核机制。

宿主机内核(所有容器共享,镜像里没有内核)容器进程在宿主机上就是一个普通进程namespace:看到什么pid、net、mnt、uts、ipc、cgroupcgroup v2:用多少memory.max、cpu.max根文件系统只读镜像层 + 可写层卷在容器删除之后仍然存在user namespace默认与宿主机相同:root 就是 uid 0
图 1 · 容器就是宿主机上的一个普通进程:namespace 决定它能看到什么,cgroup 决定它能用多少,镜像层加可写层组成它的根文件系统,卷的生命周期在容器之外。内核只有一个,默认也不隔离用户——容器里的 root 就是宿主机上的 uid 0

一、镜像里没有内核 ​

在 alpine、busybox、基于 Ubuntu 的 temurin 三个镜像里执行 uname -r,结果都是 7.0.12-linuxkit,也就是运行它们的那台 Linux 机器的内核。镜像里只有用户态的文件:库、工具、应用;系统调用最终都由宿主机内核处理。

这决定了两件事:

  • 内核相关的行为跟着宿主机走。 某个系统调用是否可用、内核参数怎么设、cgroup 是 v1 还是 v2,都取决于节点,而不是镜像。「镜像在本地能跑,到另一批节点上不行」,常见原因之一就是节点内核或内核参数不同。
  • 隔离强度不如虚拟机。 所有容器共享同一个内核,内核漏洞会影响同一节点上的所有容器。需要更强隔离的多租户场景,要用虚拟机级别的方案,而不是只靠容器。

二、namespace 隔离的是「看到什么」 ​

在宿主机上比较容器进程和宿主机 1 号进程的 namespace:

namespace容器与宿主机隔离的是
pid不同进程号:容器里的 sleep 是 1 号进程
net不同网卡、IP、端口、路由表
mnt不同挂载点,也就是看到的文件系统
uts不同主机名
ipc不同共享内存、信号量
cgroup不同看到的 cgroup 层级
user相同用户与权限

user namespace 默认没有隔离。实测容器进程在宿主机上的 uid 就是 0:容器里的 root 就是宿主机的 root。一旦进程通过挂载的目录、特权设置或内核漏洞碰到容器外面的东西,它拥有的是 root 权限。所以容器里的应用应该用普通用户运行:镜像里用 USER 指定,Kubernetes 里用 securityContext.runAsNonRoot: true 强制。

三、cgroup 限制的是「用多少」,但很多工具看不见 ​

--memory 256m --cpus 0.5 这类限额,写在容器所在 cgroup 的文件里:

text
/sys/fs/cgroup/memory.max = 268435456
/sys/fs/cgroup/cpu.max    = 50000 100000      # 每 100ms 最多用 50ms CPU

但容器里的进程看到的资源,取决于它从哪里读:

CPUnproc:6没有反映限额JVM:1 个按限额计算cpu.max:0.5 个限额本身内存/proc/meminfo:15,970MB没有反映限额JVM 最大堆:121MB按限额计算memory.max:256MB限额本身系统工具JVMcgroup 文件
图 2 · 限额 --memory 256m --cpus 0.5:限额写在 cgroup 文件里,nproc 和 /proc/meminfo 看到的仍是整台机器;JVM 会读 cgroup,算出 1 个 CPU,并在小内存时取一半作为最大堆
限额JVM 看到的 CPU 数JVM 默认最大堆
256MB、0.5 CPU1121MB
1GB、2 CPU2247MB
不限额63,994MB

nproc、/proc/meminfo 这类工具读的是内核的全局信息,看到的是整台机器。按它们来配置线程数、缓存大小的程序,会在容器里开出远超限额的线程和内存。

JVM 会读 cgroup,CPU 数和默认堆都按限额算。默认最大堆取内存限额的 1/4(MaxRAMPercentage=25),但内存较小时改用 MinRAMPercentage(默认 50%),所以 256MB 的限额下最大堆是 121MB,1GB 时是 247MB。生产环境不要依赖这两个默认值,用 -XX:MaxRAMPercentage 明确指定,给堆外内存、线程栈和元空间留出余量,见 JVM 调优与线上排查。

超过内存限额时,内核直接杀掉进程。实测 64MB 限额下不断申请内存,容器退出码 137(被 SIGKILL 杀掉),Docker 记录 OOMKilled=true;Kubernetes 里对应 Pod 状态里的 OOMKilled,它和被 kubelet 驱逐(Evicted)的区别见 requests 与 limits 各管什么。这和 Java 的 OutOfMemoryError 是两回事:后者是堆满了,JVM 还活着;前者是整个进程的内存超过了 cgroup 限额,JVM 连打印日志的机会都没有。

CPU 限额则不会杀进程,只会让进程在用完配额后被暂停到下一个周期,表现为延迟变长。

四、文件:可写层和卷 ​

容器的根文件系统由只读的镜像层加一层可写层组成。在容器里写 /tmp/in-layer.txt,docker diff 显示它落在可写层;删除容器后再起一个新容器,这个文件就不存在了。同时写进卷的文件,在容器删除后还在。

所以容器里需要保留的数据只能写到卷里。可写层适合放临时文件,而且它占用的是节点的磁盘:日志、临时文件无限增长,会把节点的磁盘写满。

五、PID 1 与优雅退出 ​

停止容器时,运行时先给容器里的 1 号进程发 SIGTERM,等一段时间还没退出再发 SIGKILL。Linux 对 1 号进程有一条特殊规则:它没有显式处理的信号会被忽略。实测 docker stop -t 5(等 5 秒):

1 号进程停止耗时退出码
sleep5.2 秒137(被 SIGKILL)
--init 启动,tini 作为 1 号进程转发信号0.1 秒143(响应 SIGTERM)
Java 程序0.1 秒143(响应 SIGTERM)

sleep 作为 1 号进程收不到 SIGTERM,只能等到超时被强杀。换成 Shell 脚本启动应用时也一样:如果脚本是 1 号进程而没有转发信号,应用就收不到 SIGTERM,关闭钩子不会执行,正在处理的请求被直接中断。

Java 程序直接作为 1 号进程没有这个问题,JVM 自己注册了 SIGTERM 的处理。要点是让 JVM 成为 1 号进程:Dockerfile 用 ENTRYPOINT ["java", ...] 这种 exec 形式,或者在启动脚本最后用 exec java ...;不得不用脚本或多个进程时,用 tini 这类 init 进程转发信号。Kubernetes 删除 Pod 时同样先发 SIGTERM,默认等 30 秒(terminationGracePeriodSeconds),超时后发 SIGKILL。滚动发布时,旧 Pod 从 Service 摘除与收到 SIGTERM 同时发生,这段时间的请求怎么处理,见 Running 不等于可用。

六、上线前检查 ​

  1. 应用用非 root 用户运行,Kubernetes 里加 runAsNonRoot: true。
  2. 按限额配置资源:Java 服务显式设置 -XX:MaxRAMPercentage,线程池、连接池不要按 nproc 或宿主机内存推算。
  3. 区分两种内存问题:OutOfMemoryError 是堆满了;退出码 137 并且 OOMKilled=true,是进程整体超过了限额。
  4. 需要保留的数据写到卷里;可写层只放临时文件,并限制日志大小。
  5. 让应用成为 1 号进程,或者用 init 进程转发信号;确认停止时关闭钩子会执行。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权