Skip to content

필사 모드: 容器与 VM 究竟差在哪里 — namespace、cgroup,以及共享内核的账单

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — "轻量级 VM"这种说法留下的错误直觉

刚开始学容器时,几乎所有人看到的都是同一张图。左边是 hypervisor 上叠着三个 Guest OS,右边是 Docker 引擎上叠着三个容器。解释就这样收尾:"容器没有 Guest OS,所以更轻。"

这话不算错,但从这张图得来的直觉,在实务中会反复背叛你。把容器理解成小型 VM 的人,会在下面这些场景里卡住。在容器里敲 uname -r,出来的是宿主机的内核版本;在容器里执行 sysctl -w vm.max_map_count,要么影响整台宿主机,要么干脆被拒绝;free -m 显示的不是容器的内存上限而是宿主机的全部内存;JVM 按宿主机的标准取得 CPU 核数,把线程池建得过大。

准确的说法是这样。容器就是跑在宿主机内核之上的普通进程,只不过能看到什么、能用什么受到了限制。上面所有现象都从这一句里推导得出。

容器不过是被隔离的进程

最快的确认方式,是在宿主机上看进程列表。

docker run -d --name web nginx:1.27
ps -eo pid,user,comm | grep nginx
  48213 root     nginx
  48261 systemd+ nginx
  48262 systemd+ nginx

在宿主机的 ps 里原样可见。如果是 VM,Guest 里面的进程绝不可能出现在宿主机的进程表里。容器的进程从一开始就是宿主机的进程。

从容器内部看,同一批进程的编号却不一样。

docker exec web ps -eo pid,comm
    PID COMMAND
      1 nginx
     29 nginx
     30 nginx

在宿主机上是 48213 的进程,在容器里是 1 号。不是有两个进程,而是同一个进程从不同的命名空间里看到的样子。

内核版本藏不住。

uname -r
docker run --rm alpine:3.20 uname -r
docker run --rm ubuntu:24.04 uname -r
6.8.0-52-generic
6.8.0-52-generic
6.8.0-52-generic

alpine 容器也好,ubuntu 容器也好,用的都是宿主机的内核。镜像提供的不是内核,而是用户空间的文件树。如果在 Mac 或 Windows 的 Docker Desktop 上这个值看着眼生,那是因为 Docker 起了一台 Linux VM,容器是在那里面跑的。在那种环境下,你用的既是容器,同时也是在 VM 里面。

namespace — 什么变得看不见了

创造容器的内核功能不是魔法,只是几个系统调用。cloneunsharesetns 就是全部。每个进程属于哪些命名空间,是以文件形式暴露出来的。

ls -l /proc/self/ns
lrwxrwxrwx 1 root root 0 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 ipc    -> 'ipc:[4026531839]'
lrwxrwxrwx 1 root root 0 mnt    -> 'mnt:[4026531841]'
lrwxrwxrwx 1 root root 0 net    -> 'net:[4026531840]'
lrwxrwxrwx 1 root root 0 pid    -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 time   -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0 user   -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 uts    -> 'uts:[4026531838]'

方括号里的数字就是命名空间的标识符。起一个容器再看同样的文件,大部分数字都变了。

docker run --rm alpine:3.20 ls -l /proc/self/ns | awk '{print $9, $11}'
cgroup cgroup:[4026532561]
ipc ipc:[4026532499]
mnt mnt:[4026532497]
net net:[4026532562]
pid pid:[4026532500]
time time:[4026531834]
user user:[4026531837]
uts uts:[4026532496]

只有 timeuser 与宿主机相同。因为 Docker 默认不使用用户命名空间。就这一行,决定了下一节安全讨论的全部走向。

各个命名空间负责的内容如下。

pid 分离进程编号空间。容器的第一个进程成为 1 号,在容器里看不到其他容器或宿主机的进程。而成为 1 号这件事本身,也一并赋予了信号处理和僵尸进程回收的责任。

mnt 分离挂载表。镜像的根文件系统之所以呈现为容器的 /,正是这个命名空间加上 pivot_root 的结果。

net 复制整个网络栈。接口、路由表、iptables 规则、套接字、端口号空间全都各自独立。所以两个容器可以各自打开 80 端口。

uts 分离主机名和域名。这就是在容器里改 hostname 不会影响宿主机的原因。

ipc 分离 System V IPC 和 POSIX 消息队列。把使用共享内存的进程拆到两个容器里,它们就互相看不见了,说的就是这一点。

user 映射 UID 和 GID。它是让容器里的 0 号不等于宿主机 0 号的唯一手段。

cgroup 让进程在 cgroup 层级中自己所处的位置显示为根。

亲手做一次就有感觉了。

sudo unshare --pid --fork --mount-proc --uts --net --mount \
  /bin/bash -c 'hostname mini; hostname; ps -ef; ip link'
mini
UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 14:22 pts/0    00:00:00 /bin/bash -c hostname mini; ...
root           4       1  0 14:22 pts/0    00:00:00 ps -ef
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default

没有镜像也没有 Docker,容器的一半就做出来了。剩下的一半是 cgroup。

cgroup — 什么变得不能用了

命名空间只是遮住视野,并不分配资源。内存限制和 CPU 分配全都是 cgroup 的职责。近来的发行版默认使用 cgroup v2,控制器挂在一个统一的层级上。

docker run -d --name limited --memory=256m --cpus=0.5 nginx:1.27
CID=$(docker inspect -f '{{.Id}}' limited)
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/memory.max
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/cpu.max
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/pids.max
268435456
50000 100000
max

memory.max 是 256MiB,cpu.max 是每 100ms 周期 50ms。超过这些值会怎样才是重点。CPU 会被限流,内存则会被杀死。

docker run --rm --memory=64m python:3.12-slim \
  python -c "x = bytearray(200 * 1024 * 1024)"
echo "exit=$?"
exit=137

137 是 128 加 9,也就是 SIGKILL。内核的 OOM killer 以超出 cgroup 上限为由终止了进程。应用程序收不到任何异常。所以当容器在悄无声息地反复重启时,该看的不是应用日志而是内核日志。

dmesg -T | grep -i 'memory cgroup out of memory' | tail -2
[Sun Jul 26 14:31:07 2026] Memory cgroup out of memory: Killed process 51882 (python) total-vm:412508kB, anon-rss:66120kB

这里出现了那个著名的陷阱。cgroup 会施加限制,但不会遮住 /proc/proc/meminfo/proc/cpuinfo 依然显示宿主机的值。

docker run --rm --memory=256m --cpus=0.5 debian:bookworm-slim \
  sh -c 'free -m | head -2; nproc'
               total        used        free      shared  buff/cache   available
Mem:           64228        9112       41003         512       14113       54216
32

被限制在 256MB 的容器看到的是 64GB,被限制在 0.5 核的容器看到的是 32 核。原样相信这些数字的运行时,会按宿主机的规格设定线程池和堆,然后立刻死于 OOM。JVM 用 UseContainerSupport 直接读取 cgroup 解决了这个问题,而 Node 的 os.cpus() 和 Go 的 runtime.NumCPU() 依然返回宿主机的值,所以必须显式设置 GOMAXPROCS 或者使用 automaxprocs 这类库。也可以挂上 lxcfs 来伪造 /proc,但让应用自己去读 cgroup 要稳健得多。

共享内核的真实账单

到这里是结构,从这里开始是后果。

第一,会依赖内核版本。镜像里的 glibc 再新,内核不支持的系统调用也用不了。在老内核上跑最新的基础镜像,使用 io_uring 或新 seccomp 过滤器的代码就会失败。反方向也有。在 Docker 20.10 以下和 CentOS 7 系上跑最新 glibc 镜像时,默认 seccomp 配置拦掉 clone3 导致容器立刻死掉的事故,长年反复出现。

第二,内核参数是共享的。sysctl 分为已命名空间化的和没有命名空间化的两类。

# net.* 大多属于 net 命名空间,因此可以按容器分别设置
docker run --rm --sysctl net.ipv4.tcp_syncookies=0 alpine:3.20 \
  sysctl net.ipv4.tcp_syncookies
net.ipv4.tcp_syncookies = 0
# vm.* 没有被命名空间化
docker run --rm --sysctl vm.max_map_count=262144 alpine:3.20 true
docker: Error response from daemon: failed to create task for container:
sysctl "vm.max_map_count" is not in a separate kernel namespace: unknown.

这就是用容器起 Elasticsearch 时会碰到的那个错误。解法不在容器配置,而是在宿主机上执行 sysctl -w vm.max_map_count=262144。也就是说,这个值由该节点上的所有容器共享。要是 VM,这本该是每个 Guest 独立设置的值。

第三,安全边界的性质不同。在 VM 上,Guest 要掌控宿主机,必须攻破 hypervisor 或虚拟设备模型的漏洞。在容器上,整个内核系统调用接口都是攻击面。Linux 内核的一个本地提权漏洞,就会直接通向容器逃逸。

真实案例还在不断出现。CVE-2019-5736 是从容器内部通过 /proc/self/exe 覆写宿主机 runc 二进制的攻击,CVE-2024-21626 则利用 runc 在未关闭文件描述符的情况下启动容器的问题,仅靠操纵 WORKDIR 就访问到了宿主机文件系统。两者都是运行时而非内核的 bug,但正因为容器和宿主机处在同一个内核之下,这些攻击才成立。

所以容器的隔离不是靠命名空间就能完成的,而要层层叠加。Docker 的默认设置已经做了相当一部分。

docker run --rm alpine:3.20 sh -c 'grep CapEff /proc/self/status'
CapEff:	00000000a80425fb
capsh --decode=00000000a80425fb
0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,
cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,
cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap

四十多项 capability 里只留下 14 项。在此之上还要加上拦截约 44 个系统调用的默认 seccomp 配置,以及 AppArmor 或 SELinux 的配置。生产环境里,从这里继续往下削才是常规做法。

docker run -d \
  --cap-drop=ALL --cap-add=NET_BIND_SERVICE \
  --security-opt no-new-privileges:true \
  --read-only --tmpfs /tmp \
  --user 10001:10001 \
  api:v312

反过来,也有一次性拆掉这些防线的选项。--privileged 会给出全部 capability、关掉 seccomp 和 AppArmor,并暴露宿主机设备。把 Docker socket 挂进容器实际上是一回事。只要能访问那个 socket,就能新起一个挂载了宿主机根文件系统的特权容器。

用户命名空间是唯一能从根本上改变这幅图景的开关。

// /etc/docker/daemon.json
{
  "userns-remap": "default"
}

这样一来,容器里的 UID 0 会映射到宿主机上的非特权 UID,即便逃逸成功也不会立刻变成 root。代价是卷的属主和部分网络功能上会出现约束。

VM 多给了什么,又多收了什么

VM 的隔离边界不是系统调用,而是硬件虚拟化。Guest 有自己的内核,由 Guest 内核处理的系统调用不会抵达宿主机内核。要从 Guest 越到宿主机,必须攻破 VM exit 路径和虚拟设备模拟,而那个接口比三百多个系统调用窄得多。

代价也很明确。每个 Guest 都需要内核和初始化过程,所以启动要花几十秒,内存光是 Guest 内核和页缓存就先去掉几百 MB。分配给 Guest 的内存即使实际没用也大体被占住,IO 还要多穿过一层虚拟设备。

按密度比较,差距很明显。32GB 的节点上 512MB 的容器能起 50 个以上,而同一个节点上的 VM,把 Guest 开销算进去连一半都很难。

项目容器VM微型 VM(Firecracker)gVisor
内核与宿主机共享Guest 专用Guest 专用(轻量)由用户空间内核代办
启动时间几十 ms几十秒约 125ms几百 ms
内存开销几乎没有几百 MB约 5MB几十 MB
隔离边界系统调用硬件虚拟化硬件虚拟化拦截系统调用
运行不同内核不可可以可以不可
系统调用性能原生接近原生接近原生
适合的边界同一信任域不同组织/租户无服务器多租户运行不可信代码

中间地带 — gVisor、Kata Containers、Firecracker

如果要在多租户环境里执行客户提交的代码,就需要落在两极之间的某处。于是三种方案站稳了脚跟。

gVisor 在用户空间重新实现了一套 Linux 系统调用接口。名为 Sentry 的进程拦下容器的系统调用自行处理,只把很窄的一个子集转交给宿主机内核。宿主机内核的攻击面急剧缩小,但系统调用频繁的负载会明显变慢,而且有些系统调用根本不被支持。

docker run --rm --runtime=runsc alpine:3.20 dmesg | head -3
[    0.000000] Starting gVisor...
[    0.310495] Preparing for the zombie uprising...
[    0.582901] Setting up VFS2...

Kata Containers 为每个容器起一台轻量 VM,在它里面运行 OCI 容器。用 kata-runtime 替代 runc 即可,在 Kubernetes 上则可以用 RuntimeClass 按工作负载选择。

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: kata
handler: kata
---
apiVersion: v1
kind: Pod
metadata:
  name: untrusted-job
spec:
  runtimeClassName: kata
  containers:
    - name: job
      image: registry.example.com/untrusted:latest

Firecracker 是 AWS 为 Lambda 和 Fargate 打造的 VMM。它把虚拟设备削减到网络、块设备、串口、键盘这个程度,把启动时间压到约 125ms,每台 VM 的内存开销做到 5MB 级别。这是同时瞄准容器密度与 VM 隔离的设计,实际上也成了无服务器平台的标准答案。

选择标准归根结底是信任边界。如果只是把同一个团队做的服务集中到一个节点上,命名空间级别的隔离就够了,把时间花在收紧 seccomp 和 capability 上反而更值。如果要在一个节点上运行不同客户的代码或任意用户提交的代码,共享内核就是不可承受的风险,那就得叠上微型 VM 或 gVisor,或者干脆按租户拆分节点。如果需要不同的内核版本或不同的操作系统,那毫无争议就是 VM。

结语 — 差的不是隔离的强度,而是隔离的种类

容器和 VM 不是同一根轴上一重一轻的两个点。容器共享内核,以系统调用为边界;VM 划分内核,以硬件为边界。它们种类不同。

把这一点当作基准,实务判断就变得简单。内核版本不同、或者需要隔离不可信代码,就用 VM 一系;目的是把部署单元跑得又轻又快,就用容器;两者都需要,就用 Kata 或 Firecracker。而从选择容器的那一刻起,就不该指望内核会自动帮你做好隔离,而要亲手收紧 capability、seccomp、只读根文件系统和非特权用户。

현재 단락 (1/157)

刚开始学容器时,几乎所有人看到的都是同一张图。左边是 hypervisor 上叠着三个 Guest OS,右边是 Docker 引擎上叠着三个容器。解释就这样收尾:"容器没有 Guest OS,所以更...

작성 글자: 0원문 글자: 7,661작성 단락: 0/157