Skip to main content

1. 容器简史与Docker原理

从 chroot 说起​

容器不是什么新发明。1979 年 Unix V7 就有 chroot 了——把一个进程的根目录"骗"到另一个路径:

chroot /tmp/my-jail /bin/sh

这个 sh 跑起来后 ls / 看到的是 /tmp/my-jail 下面的东西。它出不去。

问题也很明显——只能隔离文件系统。进程照样能 ps aux 看到宿主机的所有进程,照样能占满 CPU 和内存。墙太矮。

Linux 后来陆续往内核里塞了几个机制,一个解决一个层面的隔离问题。摊开来看就五个东西:

技术解决的问题进内核时间
Namespace进程能看到什么2002
Cgroup进程能用多少资源2006
Capabilities进程能做哪些特权操作1999
seccomp进程能调哪些系统调用2005
OverlayFS文件系统怎么分层复用2014

先一个一个看,最后看 Docker 怎么把它们拼成一个 docker run。


Namespace:你能看到什么​

Namespace 做的是视角隔离。同一个内核,不同进程看到的"世界"可以完全不同。

Linux 目前有 8 种 namespace(man 7 namespaces),容器最常用这 6 种:

Namespaceclone flag隔离了什么容器里的效果
MountCLONE_NEWNS文件系统挂载点只能看到自己的 rootfs,看不到宿主机的 mount
PIDCLONE_NEWPID进程编号空间ps aux 只有自己的进程,PID 从 1 开始
NetworkCLONE_NEWNET网络栈独立的网卡、IP、路由表、iptables
IPCCLONE_NEWIPC进程间通信独立的信号量、消息队列、共享内存
UTSCLONE_NEWUTS主机名和域名独立的 hostname
UserCLONE_NEWUSER用户和组 ID容器内 root(uid=0) 可映射到宿主机 uid=100000

创建一个带独立 namespace 的进程,底层用 clone() 系统调用:

// 创建 PID + Mount namespace 隔离的子进程
pid_t pid = clone(child_fn, child_stack,
SIGCHLD | CLONE_NEWPID | CLONE_NEWNS, NULL);

clone 返回后,子进程跑在自己独立的 PID 空间里——它的 getpid() 返回 1,宿主机上它可能是 PID 38742。网络方面同理,子进程 ip link 只看到一个 lo,看不到宿主机的 eth0。

把 6 种 namespace 全开,这个进程就被装进了一个"几乎独立"的世界——它看不到别人的进程、碰不到别人的网络、改不了系统时间、读不到其他用户的文件。跟虚拟机不一样的是,这一切都是同一个内核在做——内核只是给不同进程展示了不同的"视角"。


Cgroup:你能用多少​

Namespace 解决了"看到什么",下一个问题是谁来控制"用多少"。

没有 cgroup 的时候,一个进程的 while(1) { fork(); } 能把整台机器吃崩。cgroup 把一个或一组进程的资源用量框起来,超出限制就触发 throttle 或 OOM。

cgroup v1:每种子系统各管各的​

v1 按资源维度分成独立子树,各自在 /sys/fs/cgroup 下维护:

/sys/fs/cgroup/
cpu/ → CPU 限制(cfs_quota_us / cfs_period_us)
memory/ → 内存限制(limit_in_bytes)
blkio/ → 磁盘 I/O 限速
devices/ → 设备访问控制
freezer/ → 暂停/恢复进程组
pids/ → 限制进程数

一个容器的限制分散在小十几个文件里:

# 你要 limit=0.5 核 + 512M 内存,就写这些文件
echo 50000 > /sys/fs/cgroup/cpu/docker/abc/cpu.cfs_quota_us
echo 512M > /sys/fs/cgroup/memory/docker/abc/memory.limit_in_bytes

v1 的问题是各个子树独立,没有一个地方能一眼看到"这个容器总共用多少资源"。而且在容器内运行 systemd 或 Docker daemon 时,它们需要同时管多个子树的 cgroup,权限管理复杂。

cgroup v2:单一层级​

v2 把所有资源控制合并到一个目录,一个 docker-abc.scope 下面包含一切:

/sys/fs/cgroup/system.slice/docker-abc.scope/
cpu.max → "50000 100000" 一行搞定 CPU 限制(配额 周期)
memory.max → 512M
memory.current → 当前用了多少
io.max → IO 限速
cpu.pressure → CPU 压力指标(连续信号,不是二分阈值)
memory.pressure → 内存压力指标

几个关键改进:

  • CPU 限制一行搞定。v1 两个文件配,v2 一个 cpu.max。
  • 内存压力变成连续信号。v1 的 OOM 是"够/不够"二分,v2 的 memory.pressure 跟 CPU load average 一样有 avg10/avg60/avg300。kubelet 驱逐 Pod 就拿这个做决策,而不是等 OOM Killer 动手了再反应过来。
  • rootless 容器支持。v2 允许非 root 用户委托管理自己的子 cgroup。sysbox 依赖这一点——容器内的 systemd 能管理子 cgroup,但不需要宿主机 root。

Capabilities:你能做什么(root 的拆分术)​

传统 Unix 权限是"全有或全无"——你是 root 就什么都能干,不是 root 就很多事干不了。

但在容器里,nginx 经常以 root 身份跑(要绑 80 端口),它完全不需要 mount 文件系统、加载内核模块、改系统时间。capabilities 的答案就是把 root 的"通天大权"拆成 40 多块,只给容器真正需要的。

Capability控制什么容器里要不要给
CAP_NET_BIND_SERVICE绑定 1024 以下端口要(nginx 绑 80)
CAP_CHOWN改文件所属者偶尔要
CAP_DAC_OVERRIDE无视文件权限检查要
CAP_NET_RAWraw socket(ping)偶尔要
CAP_NET_ADMIN配网卡、改路由、iptables网络组件要
CAP_SYS_PTRACEstrace 调试别的进程CI 要,线上不给
CAP_SYS_TIME改系统时间不给
CAP_SYS_ADMINmount / umount / swapon / clone user ns / 加载内核模块 / ...尽量不给——这是"新 root"

CAP_SYS_ADMIN 最危险。内核社区戏称它是"专门放各种没归类的 root-only 操作的垃圾桶"。include/uapi/linux/capability.h 里它覆盖了几十种互不相关的操作——拿了它等于拿半个 root。现代容器安全的核心命题就是:不靠 CAP_SYS_ADMIN,容器能不能正常跑?


seccomp:你能调哪些系统调用​

capabilities 有一个粒度盲区——它管"哪类操作",不管具体是哪个 syscall。CAP_SYS_ADMIN 同时放行 clone()(创建进程)和 mount()(挂载文件系统),capabilities 无法区分"clone 可以,mount 不行"。

seccomp(Secure Computing)在系统调用入口做更细的拦截。它最精妙的设计是用位掩码匹配 syscall,跟路由器子网掩码逻辑一模一样。

子网掩码类比​

路由器收到一个数据包,目标 IP 192.168.1.100。它不搜列表,一条规则搞定:

IP: 192.168.1.100
Mask: 255.255.255.0
AND: 192.168.1.0 → 匹配 → 放行

一次 AND + 一次比较,几个 CPU 周期。seccomp 把每条规则编译成 BPF 指令,在 syscall 入口做同样的位运算:

(syscall_nr & 0xFFFFFFFF) == 165 → 拒绝 (mount)
(syscall_nr & 0xFFFF0000) == 0 → 放行 (x86_64 标准 syscall)
其他所有 → 拒绝

第二条规则用掩码 0xFFFF0000 排掉 32 位兼容模式的 syscall(高 16 位非 0),只放行 x86_64 的标准调用。

位运算快是关键——每次 syscall 都要过 seccomp,列表比对扛不住。

不只管 syscall 号,参数也用掩码匹配​

containerd 默认 seccomp 里对 clone() 的处理最能说明问题。clone 不能全禁(进程需要 fork),也不能全放(clone(CLONE_NEWUSER) 能提权):

{
"names": ["clone", "clone3"],
"action": "SCMP_ACT_ALLOW",
"args": [{
"index": 0,
"value": 2114060288,
"op": "SCMP_CMP_MASKED_EQ"
}]
}

翻译:clone_flags & 2114060288 == 2114060288。这串数字是一组允许的 clone flag 的位掩码。带了 CLONE_NEWUSER 的调用 AND 下来对不上 → 不匹配 → 拒绝。

五种动作​

动作效果
SCMP_ACT_ALLOW放行
SCMP_ACT_ERRNO返回 errno(如 EPERM),进程自己处理
SCMP_ACT_KILL直接杀进程
SCMP_ACT_LOG放行并记录日志
SCMP_ACT_TRAP发 SIGSYS,进程可设 handler

Docker/containerd 默认对禁掉的 syscall 用 ERRNO(返回"没权限"),而不是 KILL——给应用一个正常报错和降级的机会。


OverlayFS:镜像怎么分层​

镜像拉下来不是一个大文件,是一堆 layer 的 tar 包。Docker 把它们叠成一个文件系统,靠的就是 OverlayFS(联合文件系统):

layer3: COPY nginx 二进制 (5MB)
layer2: RUN apt install 依赖 (200MB)
layer1: FROM ubuntu:22.04 (78MB)

这三个 layer 不拷贝合并,而是叠起来。OverlayFS 有 lowerdir(只读层)和 upperdir(可写层):

mount -t overlay overlay \
-o lowerdir=layer1:layer2:layer3,upperdir=writable,workdir=work /container/rootfs

lowerdir 可以多个目录,冒号隔开,越靠右优先级越高(layer3 覆盖 layer2)。upperdir 是容器运行时写东西的地方——删文件、写日志都在这里,不会污染镜像层。

思考题:FROM ubuntu:22.04 这个 base 镜像——78MB 的 tar 包里面到底装了什么东西?没有内核、没有 bootloader,它凭什么说自己是"Ubuntu"?读一下 Docker Hub 上 ubuntu:22.04 的 Dockerfile,答案就在那几行里。


Docker:把上面五样东西编成一个 JSON​

以上就是"容器"的全部零件了。Docker 2013 年做的事不是什么新发明——把这些散落在内核接口里的技术,包装成了一个 JSON 规范和一个命令行。

docker run -d -m 512m --name nginx nginx

这一行命令背后,Docker 调 runc(OCI 标准运行时),传了一份 JSON(OCI Runtime Spec):

{
"ociVersion": "1.1.0",
"root": { "path": "rootfs", "readonly": true },
"process": {
"args": ["nginx", "-g", "daemon off;"],
"user": { "uid": 101, "gid": 101 },
"capabilities": {
"bounding": ["CAP_NET_BIND_SERVICE", "CAP_CHOWN"],
"effective": ["CAP_NET_BIND_SERVICE", "CAP_CHOWN"],
"permitted": ["CAP_NET_BIND_SERVICE", "CAP_CHOWN"]
}
},
"linux": {
"namespaces": [
{ "type": "pid" },
{ "type": "network" },
{ "type": "mount" },
{ "type": "ipc" },
{ "type": "uts" }
],
"resources": {
"memory": { "limit": 536870912 }
},
"seccomp": {
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{ "names": ["write","read","mmap","futex" /* ... 300+ more syscalls */], "action": "SCMP_ACT_ALLOW" },
{ "names": ["mount","ptrace","reboot"], "action": "SCMP_ACT_ERRNO" }
]
}
}
}

这份 JSON 把五个 Linux 技术全用上了:

  1. namespaces → 创建 PID/net/mount/IPC/UTS 隔离
  2. resources(memory) → 限制 512M,落到 cgroup 写 memory.max
  3. capabilities → 只给 NET_BIND_SERVICE 和 CHOWN,砍掉 root 的其他能力
  4. seccomp → 系统调用白名单,mount/ptrace/reboot 直接拒绝
  5. root(readonly) + overlay → 镜像层叠 + 可写层的 rootfs

runc 拿到这份 JSON,调用 clone() 创建 namespace、写 cgroup 文件、裁剪 capabilities、加载 seccomp BPF 程序、pivot_root 切到 rootfs——整个"容器"就成型了。

这份 JSON 叫做 OCI Runtime Spec。任何实现了这个 spec 的运行时(runc、crun、youki、sysbox),都能跑任何符合 OCI 镜像规范的容器。接口定好了,具体实现百花齐放——这跟后面 K8S 的 CRI/CNI/CSI 是一个思路。


一张表回顾五层隔离​

隔离层Linux 机制容器里的表现
文件系统mount namespace + OverlayFS只能看到镜像层叠出来的 rootfs
进程空间PID namespaceps aux 只有自己,PID 从 1 开始
网络net namespace独立网卡、IP、路由表
进程间通信IPC namespace独立信号量、消息队列
主机名UTS namespace独立 hostname
资源限制cgroup v1/v2CPU 和内存被精确限死
特权操作capabilitiesroot 被拆成碎片,需要的才给
系统调用seccomp BPFmount/ptrace/reboot 等 40+ syscall 禁掉

容器本质是被这五个内核机制框住的一组普通 Linux 进程。VM 跑了一个完整内核,容器跟宿主机共享内核。这也解释了为什么 Linux 容器跑不了 Windows 程序——跨不了内核。

思考题:你在一台 Linux 宿主机上跑 docker run ubuntu 和 docker run alpine,这两个容器用的是各自镜像里"装好"的内核吗?还是共用宿主机内核?如果共用,那 ubuntu 和 alpine 的"发行版差异"到底体现在哪?


容器 vs 虚拟机​

讲完五层隔离机制,现在可以把容器和传统虚拟机的区别讲清楚了。

先看架构差异:

# 虚拟机架构 # 容器架构
┌──────────────────┐ ┌──────────────────┐
│ App A │ App B │ ← 应用 │ App A │ App B │
├─────────┴────────┤ │ 共享内核系统调用 │
│ Guest OS (Linux) │ ← 完整 OS ├──────────────────┤
│ Guest OS (Win) │ │ 容器运行时 │
├──────────────────┤ │ (runc/containerd) │
│ Hypervisor │ ← 虚拟化层 ├──────────────────┤
├──────────────────┤ │ Host OS │
│ Host Kernel │ │ (Linux Kernel) │
├──────────────────┤ ├──────────────────┤
│ 物理硬件 │ │ 物理硬件 │
└──────────────────┘ └──────────────────┘

虚拟机的隔离靠 Hypervisor 模拟硬件。 每个 VM 有自己的内核、自己的驱动、自己的 systemd——它是从 BIOS 开始完整启动的,跟物理机一模一样,只是 CPU/内存/IO 是虚拟化出来的。

容器的隔离靠内核的五个机制。 没有 Hypervisor 层,没有独立内核,容器跟宿主机共享同一个 Linux 内核,只是内核给每个容器展示了不同的"视角"(namespace)并限制了它的用量(cgroup)。

这导致了几个直接的区别:

维度虚拟机容器
启动速度几十秒到几分钟(BIOS → 内核 → init)几百毫秒到几秒(只起进程,不加载内核)
内存开销Guest OS 自带 128MB-512MB几乎为 0(只消耗应用本身的内存)
CPU 开销虚拟化损耗 2-5%(硬件辅助后降低了但还存在)无虚拟化损耗——直接调宿主机内核的 syscall
隔离强度强——不同 VM 跑不同内核,内核漏洞不互穿弱于 VM——共享内核,内核漏洞会波及所有容器
内核版本每个 VM 可以跑不同版本的内核必须跟宿主机用同一个内核
跨 OS 能力能——宿主机 Linux 上跑 Windows VM,反之亦然不能——Linux 容器只能跑 Linux 内核
镜像体积以 GB 计(包含完整 OS)以 MB 计(只有应用+依赖,不需要内核)
密度一台物理机跑 10-50 个 VM一台物理机跑几百个容器

什么时候用 VM,什么时候用容器?

  • 需要强隔离、跑不同内核版本、跨操作系统的场景 → VM(比如多租户云的底层)
  • 跑同一个 Linux 内核的应用、追求密度和速度 → 容器(绝大多数微服务)
  • 两者不是互斥的——生产上经常是"VM 里跑 K8S,K8S 上跑容器"

容器和虚拟机不是同一根赛道的对手。VM 解决的是"怎么把一台物理机切成多台虚拟的",容器解决的是"怎么高效管理和隔离同一台机上的进程"。两者可以在不同层级共存。

在五层隔离的基础上再回头看这张表:

隔离层虚拟机怎么做的容器怎么做的
文件Hypervisor 分配虚拟磁盘 + Guest OS 的 ext4/xfsmount namespace + OverlayFS
进程Guest OS 内核自己管理进程表PID namespace
网络虚拟网卡(virtio)+ 虚拟交换机net namespace
内存Hypervisor 分配固定内存 + Guest OS 管理cgroup memory.max
CPU虚拟 CPU(vCPU)时间片cgroup cpu.max
权限Guest OS 自己的 root 体系capabilities + seccomp

容器的每一个隔离维度,虚拟机都有对应的实现——但实现层不一样。虚拟机在硬件模拟层做,容器在内核系统调用层做。


containerd 的接口设计​

Docker 后来把运行时部分抽出来独立成了 containerd,负责镜像拉取和容器生命周期。containerd 暴露 gRPC 接口:

// containerd/api/services/containers/v1/containers.proto
service Containers {
rpc Create(CreateContainerRequest) returns (CreateContainerResponse);
rpc Start(StartRequest) returns (google.protobuf.Empty);
rpc Delete(DeleteContainerRequest) returns (google.protobuf.Empty);
}

K3S 把 containerd 内置了——pkg/agent/containerd/containerd.go 在 K3S 进程里直接 import containerd 的库启动,不需要额外安装:

// k3s/pkg/agent/containerd/config_linux.go 简化
func Client(ctx context.Context) (*containerd.Client, error) {
return containerd.New("/run/k3s/containerd/containerd.sock")
}

K8S 的标准路径是 deploy 独立的 containerd systemd 服务。两者调用的是完全一样的 gRPC 接口——区别只在部署方式,接口契约是同一份。


sysbox:不用 privileged 也能跑 systemd 和 DinD​

上面五个机制全开之后,容器已经很安全了,但代价是你没法在里面跑 systemd 或 Docker daemon(DinD)。systemd 需要写 /sys/fs/cgroup 和 /proc,需要 mount 操作。Docker daemon 需要 clone() 创建新 namespace。这些都属于上面禁掉的"危险操作"。

传统解法是 --privileged 一把梭——capability 全开 + seccomp 关掉 + /dev 全暴露:

docker run --privileged -d docker:dind

现在容器里的 Docker daemon 跟宿主机 root 没区别——它能 mount 宿主机文件系统、加载内核模块。这是定时炸弹。

sysbox 是一个增强版 OCI 运行时,用三层设计解决这个问题,不给 --privileged:

  1. user namespace 映射:容器内 root(uid=0) → 宿主机 uid=100000。mount 操作只影响容器的 user namespace 视角,穿透不到宿主机。
  2. 虚拟化 /proc 和 /sys:容器内 cat /proc/uptime 看到的是从容器启动算起的时间,不是宿主机。
  3. cgroup 代理层:容器内的 systemd/Docker daemon 以为自己能写 /sys/fs/cgroup,实际写入了宿主机上一个被隔离的子目录。cgroup v2 的单层级设计让这一步比 v1 简单得多——一个 docker-abc.scope 目录就包含了一切,而 v1 要做十几棵子树的代理。
宿主机 /sys/fs/cgroup/system.slice/
sysbox-abc.service/ ← sysbox 容器
/ns-sandbox/ ← 容器内 systemd 的"虚拟 cgroup 根"
docker-def.scope/ ← DinD 内部起的容器的真实 cgroup

跑起来的效果:

docker run --runtime=sysbox-runc -d --name=k3s-test rancher/k3s:v1.33.1-k3s1 server

一个完整 K3S 集群跑在 sysbox 容器里,不用 --privileged,不用虚拟机。


单机的尽头​

到这里你应该看清了——Linux 提供了五个独立的内核机制:

chroot/OverlayFS → 文件隔离
Namespace → 视角隔离
Cgroup → 资源限制
Capabilities → 权限拆分
seccomp → 系统调用过滤

Docker 用一份 OCI spec JSON 把它们全部组织起来,runc/containerd 负责执行。这就是容器的完整能力栈。

但所有能力都绑在一台机器的一个内核上——机器挂了,上面的容器全没。单机的尽头,就是集群。

这也是为什么 Google 十年 Borg 经验沉淀出了 Kubernetes。下一章看看到底集群多了哪些新问题。

思考题:你用的是 macOS,docker run nginx 一样能跑。但 macOS 是 XNU 内核(Mach + BSD),不是 Linux。Docker 容器是需要 Linux 内核的——那 macOS 上的 Docker 是怎么让你觉得"原生"的?它到底跑在谁的 Linux 内核上?


解答​

1. base 镜像是怎么来的?

FROM ubuntu:22.04 的镜像里面没有 Linux 内核,没有 bootloader,没有 systemd init 系统。它本质上是一套从 ubuntu 发行版里提取出来的用户态文件——/bin、/usr、/lib 这些目录的 tar 包。

你可以去 Docker Hub 或 GitHub 看 ubuntu 官方镜像的构建脚本。它在 GitHub Actions 里做的事情大概就是:从 ubuntu 的 rootfs tar 包(发行版官方提供的 minimal root filesystem)中提取 /bin、/lib、/usr、/etc 等用户态目录,加入一个 /etc/apt/sources.list 设好包管理源,封装成 Docker 镜像。

这个 tar 包里装的是 ubuntu 编译的 libc、ubuntu 的包管理工具(apt)、ubuntu 修改过配置的各类工具(比如 ubuntu 的 /etc/os-release 写的是 ID=ubuntu)。所以容器里 cat /etc/os-release 显示 ubuntu,是因为镜像里确实放了这个文件。

2. ubuntu、centos、alpine 的容器内核是独立的吗?

不是。三者的容器都在共用宿主机同一个 Linux 内核。docker run ubuntu 和 docker run alpine 跑出来的容器里,内核是同一份——都是宿主机的。

差异在用户态。所谓"发行版"的差异,完全是用户态文件的差异:

差异点ubuntu 镜像alpine 镜像
C 库glibcmusl
包管理apt / dpkgapk
基础工具GNU coreutilsBusyBox
镜像体积~78MB~5MB

对比最显性的差异就是 libc——glibc 和 musl 编译出来的二进制不通用。你在 ubuntu 上编译的 C 程序,放到 alpine 容器里自己跑不了,因为动态链接的 libc 不兼容。

这也解释了为什么 alpine 能压到 5MB——BusyBox 把 ls、cat、cp、sh 等几十个最常用的工具包进一个不到 1MB 的二进制,而且 musl 比 glibc 小了不止一个体量。

3. macOS 能运行原生的 Docker 吗?

不能。macOS 内核是 XNU(Mach 微内核 + FreeBSD 内核组件混搭),不支持 Linux 的 namespace/cgroup/capabilities/seccomp。Docker 容器需要 Linux 内核的系统调用,在 macOS 上无法直接运行。

Docker Desktop for Mac 的背后跑着一个轻量级 Linux 虚拟机。Docker Desktop 用 Apple 的 Virtualization.framework(或旧版用的 HyperKit)悄悄起了一个极小的 Linux 内核。你跑的所有 Linux 容器,实际上都在这个虚拟机里,这个虚拟机共享 Linux 内核,容器进程在宿主机(macOS)上不可见。

可以用 ps aux | grep docker 验证——在 macOS 上你看不到容器进程,只能看到一个 com.docker.virtualization 进程。ssh 进 Docker Desktop 的虚拟机(nc -U ~/Library/Containers/com.docker.docker/Data/debug-shell.sock)后 ps aux,容器进程全出来了。

这也顺带解释了 Docker Desktop 需要 2G+ 内存的原因——它不光要跑容器,还要跑一整个 Linux 虚拟机。