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 种:
| Namespace | clone flag | 隔离了什么 | 容器里的效果 |
|---|---|---|---|
| Mount | CLONE_NEWNS | 文件系统挂载点 | 只能看到自己的 rootfs,看不到宿主机的 mount |
| PID | CLONE_NEWPID | 进程编号空间 | ps aux 只有自己的进程,PID 从 1 开始 |
| Network | CLONE_NEWNET | 网络栈 | 独立的网卡、IP、路由表、iptables |
| IPC | CLONE_NEWIPC | 进程间通信 | 独立的信号量、消息队列、共享内存 |
| UTS | CLONE_NEWUTS | 主机名和域名 | 独立的 hostname |
| User | CLONE_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_RAW | raw socket(ping) | 偶尔要 |
CAP_NET_ADMIN | 配网卡、改路由、iptables | 网络组件要 |
CAP_SYS_PTRACE | strace 调试别的进程 | CI 要,线上不给 |
CAP_SYS_TIME | 改系统时间 | 不给 |
CAP_SYS_ADMIN | mount / 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 技术全用上了:
- namespaces → 创建 PID/net/mount/IPC/UTS 隔离
- resources(memory) → 限制 512M,落到 cgroup 写
memory.max - capabilities → 只给
NET_BIND_SERVICE和CHOWN,砍掉 root 的其他能力 - seccomp → 系统调用白名单,mount/ptrace/reboot 直接拒绝
- 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 namespace | ps aux 只有自己,PID 从 1 开始 |
| 网络 | net namespace | 独立网卡、IP、路由表 |
| 进程间通信 | IPC namespace | 独立信号量、消息队列 |
| 主机名 | UTS namespace | 独立 hostname |
| 资源限制 | cgroup v1/v2 | CPU 和内存被精确限死 |
| 特权操作 | capabilities | root 被拆成碎片,需要的才给 |
| 系统调用 | seccomp BPF | mount/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/xfs | mount 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:
- user namespace 映射:容器内 root(uid=0) → 宿主机 uid=100000。mount 操作只影响容器的 user namespace 视角,穿透不到宿主机。
- 虚拟化 /proc 和 /sys:容器内
cat /proc/uptime看到的是从容器启动算起的时间,不是宿主机。 - 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 库 | glibc | musl |
| 包管理 | apt / dpkg | apk |
| 基础工具 | GNU coreutils | BusyBox |
| 镜像体积 | ~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 虚拟机。