Skip to content
Published on

容器与 Docker 内部完全攻略 — Namespace、cgroups、OverlayFS、seccomp、Capabilities 直到 Kubernetes(2025)

分享
Authors

0. “容器不是 VM”意味着什么

很多开发者把容器理解成“更轻的 VM”。实际上二者完全不同:

项目VMContainer
隔离单位硬件OS namespace
内核每个 VM 独立与宿主机共享
启动时间数秒 ~ 数分钟数十 ms
内存开销数百 MB数 MB
磁盘开销GBMB
隔离强度

容器 = 进程 + 内核功能(namespace、cgroup、rootfs)的组合。并不存在一个独立的 OS。

本文剖析 docker run nginx 这一行在 Linux 内核里究竟如何运作,以及它背后长达十余年的工程史。

1. 容器并不短暂的历史

1.1 1979 — chroot

Unix v7 的 chroot() 系统调用。“改变这个进程的文件系统根目录” → 只能看见受限的目录。

但仅靠 chroot 还不够:

  • 进程列表、网络、用户依旧照常可见。
  • 逃逸手法早已广为人知(chdir、mount 等)。

1.2 2000 — FreeBSD Jails

chroot 的扩展版。把网络、进程、用户也一并隔离。真正的“轻量级虚拟化”由此开始。

1.3 2004 — Solaris Zones

完整的隔离技术。容器概念在商业上的第一次成功。

1.4 2006 — cgroups,2007 — LXC

Google 的工程师为了内部的资源隔离开发了“process containers” → 以 cgroups 的形式进入 Linux。namespace 也在分阶段补齐。

LXC(Linux Containers)把它们组合起来,提供了第一个实用的容器运行时。

1.5 2013 — Docker

Solomon Hykes 的 dotCloud 把 LXC 包装成易用的 API。docker run 一行命令的魔法。

杀手级功能:镜像层(OverlayFS)+ 镜像仓库(Docker Hub)。开发者终于可以“把我的环境原样共享出去”。

1.6 2015 — OCI,2016 — containerd,2017 — Kubernetes 崛起

Open Container Initiative(Docker + CoreOS + Linux Foundation)确立了镜像/运行时的标准规范(OCI)。

Docker 把运行时拆分成 containerd,演化为更小、更可复用的组件。

Kubernetes 成为编排标准,“docker 引擎”逐渐被抽象掉。

1.7 2024 — 现在

Docker Desktop、Podman(daemonless)、Kubernetes + containerd + runc、ECS、Cloud Run、Fly.io。容器如今已是所有云端部署的基本单位。

2. Linux Namespace — 隔离的 7 个维度

namespace 限制“这个进程能看到哪些 OS 资源”。共有 7 种(2024 年为准):

2.1 PID Namespace

进程 ID 隔离。在容器里执行 ps 会看到:

PID TTY      TIME CMD
  1 ?     00:00:00 nginx
 10 ?     00:00:00 worker
 11 ?     00:00:00 worker

容器里的 nginx 是 PID 1。从宿主机上看,它却是 PID 12345。这是两个彼此不同的编号空间。

PID 1 的特殊之处:

  • 内核期待它承担僵尸进程回收(init 角色)。
  • 它退出时所有子进程也一并退出。
  • 大多数应用并不是按 init 设计的 → 于是有了 tinidumb-init 这类小型 init。

2.2 Mount Namespace

文件系统挂载点隔离。每个容器都拥有自己的 / 树。

chroot 的进化形态。可以只共享宿主机的特定挂载。

2.3 Network Namespace

每个容器都有自己的网络接口、路由表和 iptables 规则。

# 从宿主机进入容器网络
sudo nsenter -t <container_pid> -n ip addr

Docker 的 bridge 模式:用虚拟网桥 docker0 + veth 对连接。

2.4 UTS Namespace

Unix Timesharing System。主机名 + 域名的隔离。容器可以设置自己的主机名。

2.5 IPC Namespace

System V IPC、POSIX 消息队列的隔离。容器之间的共享内存被分开。

2.6 User Namespace

UID/GID 映射。容器里的 root(UID 0)在宿主机上是非特权用户 UID 100000。

对安全至关重要,但配置复杂 → Docker 默认不启用。Podman rootless 用到了它。

2.7 Cgroup Namespace

隔离 cgroup 层级。容器把自己的 cgroup 路径看成 /。2016 年引入。

2.8 Time Namespace

Linux 5.6(2020)新增。每个容器可以拥有不同的启动时间(CLOCK_BOOTTIME)。用于检查点/恢复(CRIU)。

2.9 操作 namespace

unshare --pid --mount --net --fork bash   # 用新的 namespace 启动 bash
nsenter -t PID -n -p                       # 进入已有的 namespace

Docker 的 docker exec 其实就是把目标容器的那些 namespace 组合起来,创建一个新进程。

3. cgroups — 资源限制

3.1 cgroups v1(2007-)

每种资源(CPU、内存、网络等)都有各自独立的层级:

/sys/fs/cgroup/
├── cpu/
│   ├── docker/
│   │   └── <container_id>/
│   │       ├── cpu.cfs_quota_us
│   │       └── cpu.cfs_period_us
├── memory/
├── blkio/
└── ...

各层级彼此独立 → 复杂。

3.2 cgroups v2(2016-,systemd 232+)

单一的统一层级:

/sys/fs/cgroup/
├── user.slice/
├── system.slice/
│   └── docker-<id>.scope/
│       ├── cpu.max         # "100000 100000" = 1 CPU
│       ├── memory.max      # "536870912" = 512MB
│       ├── io.max
│       └── pids.max
  • 一棵树里容纳所有资源。
  • 层级化的资源分配。
  • 更简单的模型。

3.3 CPU 限制的实际含义

cpu.max = "50000 100000" 的意思是:“每 100ms 周期最多使用 50ms CPU”,也就是 0.5 CPU。

是否允许突发 取决于配置。JVM、Node 只要短时间内 CPU 冲高就会被 throttle → 响应延迟。

Docker 的 --cpus=1 = cpu.max = "100000 100000"

3.4 内存限制

  • memory.max:上限。超过就 OOM。
  • memory.high:soft limit。越过后会有 reclaim 压力。
  • memory.minmemory.low:受保护的内存。

容器达到 memory.max 时,会触发 容器内部的 OOM killer。宿主机安然无恙。

3.5 JVM、Node、Go 对 cgroup 的感知

JVM 10+(-XX:+UseContainerSupport 默认开启):读取 cgroup 限额来决定堆大小。 Go 1.19+ 的 GOMEMLIMIT:手动反映 cgroup limit。 Node 的 --max-old-space-size:直接设置。

不做这件事,就会误以为“宿主机内存有 64GB”而把堆开得很大 → OOM。

4. OverlayFS — 镜像层的秘密

4.1 为什么要分层

Docker 镜像是 只读层的堆叠

Layer 4 (10KB): 应用代码
Layer 3 (50MB): npm install 结果
Layer 2 (80MB): apt packages
Layer 1 (70MB): ubuntu base
  • 每一层都 独立且不可变
  • 多个镜像共享 base 层。
  • 即使有 100 GB 的镜像,也只需 push/pull 变化过的 top layer。

4.2 OverlayFS 的结构

┌─────────────────────┐
upperdir (RW)      │  ← 容器写入
├─────────────────────┤
│  merged view        │  ← 应用看到的文件系统
├─────────────────────┤
lowerdir (RO) ×N   │  ← 镜像层
└─────────────────────┘
  • 读取:upperdir 里没有就去 lowerdir 找。
  • 写入:一律写到 upperdir。
  • 修改:Copy-up — 先把原文件复制到 upperdir 再修改。

4.3 Copy-up 的代价

修改大文件时:

  • 把原文件 整份复制 到 upperdir。
  • 之后才轮到修改。

对 100MB 的文件改 1 字节 = 复制 100MB。这就是不该把大体积 DB 文件放在容器内部的原因。

解决办法:Volume mount。DB 数据交给宿主机卷。

4.4 Whiteout — 文件删除

执行 rm 并不会把文件从 lowerdir 里抹掉。取而代之的是:

  • 在 upperdir 生成特殊的“whiteout”文件。
  • merged view 里看不到该文件。
  • 真实文件依然留在下面。

结果就是:“即便在 Dockerfile 里用 RUN rm 想减小体积”,层的大小也不会缩小。必须在同一个 RUN 里完成生成+删除,才不会残留在层中。

4.5 StorageDriver 的演进

Docker 的 storage driver 变迁史:

  • aufs(2013-):早期方案。没有进入内核 upstream。
  • devicemapper(2014-):thin provisioning。慢。
  • btrfs、zfs:依赖文件系统。
  • overlay(2014):upstream 内核,快。
  • overlay2(2016-):当前默认。支持同时使用多个 lowerdir。

Podman 还可以选用 fuse-overlayfs(支持 rootless)。

5. 容器运行时 — runc 和它的伙伴们

5.1 Docker 的内部层次

docker CLI
dockerd (daemon)
containerd (容器 lifecycle)
containerd-shim (每个容器一个)
runc (OCI runtime,实际创建容器)
Linux kernel (namespace, cgroup, ...)

5.2 runc 的职责

实现 OCI Runtime Specification。输入:rootfs + config.json。输出:运行中的容器。

用 C / Go 实现,纯粹地调用系统调用:

  • clone() + namespace flags。
  • setns() 进入 namespace。
  • 设置 cgroup。
  • 挂载 rootfs。
  • execve() 启动用户进程。

5.3 替代运行时

  • crun:用 C 编写,比 runc 快(没有 Go 运行时开销)。
  • youki:Rust 实现。
  • Kata Containers:把每个容器跑在超轻量 VM 里 → VM 级别的隔离。
  • gVisor:Google 出品。用用户态内核拦截系统调用 → 内核级隔离。
  • Firecracker:AWS Lambda 的基础。microVM。

安全要求高的多租户环境(Lambda、Fargate)会采用 VM 隔离的运行时。

5.4 shim 的职责

containerd-shim 对每个容器都存在一份:

  • containerd 守护进程挂掉,容器依然存活。
  • 收集 stdout/stderr。
  • 记录 exit code。

这也是 Docker 没有直接接到 kubelet 上的原因。结构是 kubelet → CRI → containerd → shim → runc。

6. 安全 — 容器为什么不是“真正的隔离”

6.1 共享内核的风险

容器们共享同一个内核 → 内核漏洞 = 所有容器逃逸。实际案例:

  • Dirty COW(CVE-2016-5195):权限提升。可以逃逸出容器。
  • runc CVE-2019-5736:覆写 runc 二进制以夺取宿主机 root。
  • Leaky Vessels(2024):runc、containerd 的多个漏洞。

应对:勤更新内核、AppArmor/SELinux、seccomp profile。

6.2 Linux Capabilities

传统 Unix:root(UID 0)= 全部权限。对容器来说太危险了。

Capabilities 把 root 权限 细分

  • CAP_NET_ADMIN:网络配置。
  • CAP_SYS_ADMIN:大部分危险操作。
  • CAP_NET_BIND_SERVICE:绑定 1024 以下端口。
  • 其余还有数十种。

Docker 默认:受限的 capability set(最小权限)。用 --cap-add--cap-drop 调整。--privileged 等于全部 cap,几乎等同于宿主机 root。

6.3 seccomp — 系统调用过滤器

“这个容器只允许特定的系统调用”:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "syscalls": [
    { "names": ["read", "write", "open", ...], "action": "SCMP_ACT_ALLOW" }
  ]
}

Docker 的默认 seccomp profile 只允许约 300 个 syscall。keyctlptracemount 等会被拦截。

6.4 AppArmor / SELinux

文件系统层面的 MAC(Mandatory Access Control)。用策略限制容器可以尝试的文件访问。

Ubuntu 用 AppArmor,RHEL 用 SELinux。

6.5 Rootless Container

容器守护进程也以非特权用户运行。用 User namespace 做 UID 映射:

  • Podman 的默认方式。
  • Docker 也支持 rootless 模式。
  • 安全性大幅增强,部分功能受限(1024 以下端口等)。

6.6 安全检查清单

  • 最新内核
  • 保持 默认 seccomp profile(禁止 --security-opt seccomp=unconfined)。
  • 最小化 capability--cap-drop ALL + 只 add 必要的)。
  • 非 root 用户 运行应用(USER 指令)。
  • 只读 rootfs--read-only + tmpfs 卷)。
  • Image scanning:Trivy、Grype、Docker Scout。
  • Runtime monitoring:Falco。

7. 制作镜像的技术

7.1 层的优化

# 不好:每个 RUN 都是新的一层
FROM node:20
COPY package.json .
RUN npm install             # 层 1
COPY . .
RUN npm run build           # 层 2
RUN rm -rf /tmp/cache       # 层 3(第 1 层的 cache 原封不动地留着)
# 好:临时文件在同一个 RUN 里处理掉
FROM node:20
COPY package.json .
RUN npm install && rm -rf /tmp/* /var/cache/*
COPY . .
RUN npm run build

7.2 Multi-stage Build

# Stage 1: 构建
FROM node:20 AS builder
WORKDIR /app
COPY . .
RUN npm install && npm run build

# Stage 2: 运行时(小体积)
FROM node:20-alpine
COPY --from=builder /app/dist /app
COPY --from=builder /app/node_modules /app/node_modules
CMD ["node", "/app/index.js"]

构建工具(gcc、webpack 等)不会出现在最终镜像里 → 体积缩小 5 倍以上。

7.3 Distroless 镜像

Google 打造的最小镜像(连 shell 都没有):

FROM gcr.io/distroless/nodejs20
COPY --from=builder /app /app
CMD ["/app/index.js"]
  • 体积:数十 MB。
  • 攻击面:没有 shell,RCE 更困难。
  • 缺点:调试困难(无法 exec 进去)。

7.4 Alpine vs Debian

  • Alpinenode:20-alpine):5MB base。musl libc。
  • Debian slimnode:20-slim):80MB。glibc。

Alpine 的注意点:musl 兼容性问题(DNS resolver、pthread 行为的细微差异)。构建 Python 的 C extensions 时需要额外工具。

7.5 BuildKit 与缓存

DOCKER_BUILDKIT=1 docker build .
  • 并行 stage 构建
  • 缓存的导出/导入--cache-to=type=registry,ref=...
  • 安全:构建期 secrets(--secret)。
  • BuildKit 是 containerd 原生的

8. 网络 — 从 docker0 到 CNI

8.1 Docker 默认网桥

docker0 (bridge, 172.17.0.1)
  ├── veth0 → container1 eth0 (172.17.0.2)
  ├── veth1 → container2 eth0 (172.17.0.3)
  └── veth2 → container3 eth0 (172.17.0.4)
  • veth 对:一端在宿主机,另一端在容器。
  • NAT:用 iptables MASQUERADE 连到外部。
  • 端口转发-p 80:8080 就是 iptables DNAT。

8.2 网络模式

  • bridge(默认):如上所述。
  • host:直接用宿主机网络(没有隔离,最快)。
  • none:没有网络。
  • container:与其他容器共享(类似 pod)。

8.3 Kubernetes CNI

Kubernetes 的理念是“所有 pod 都拥有扁平的 IP”。只靠 Docker bridge 不够。

  • Flannel:VXLAN 隧道,简单。
  • Calico:基于 BGP,支持 eBPF,网络策略。
  • Cilium:eBPF 原生,可观测性/安全能力丰富。
  • AWS VPC CNI:把 ENI 直接分配给 pod。

CNI = Container Network Interface。让编排器可以替换网络插件的标准。

9. 与 Kubernetes 的整合

9.1 Kubernetes 为什么抛弃了 Docker(2020)

Kubernetes 1.20 公布了“dockershim deprecation” → 在 1.24(2022)中移除。

理由:

  • Docker 引擎包含大量 kubelet 用不到的功能(镜像构建、swarm 等)。
  • 为了对齐 CRI(Container Runtime Interface)标准,需要额外的一层 shim。
  • containerd 本来就是 Docker 的核心。

结果:kubelet → CRI → containerd 直连。更简单、更快。从用户视角看,OCI 镜像照常工作。

9.2 Pod = 共享 namespace 的一组容器

Pod 是多个容器 共享 network、IPC、UTS namespace。Mount 和 PID(默认)保持独立。

pause container (namespace 所有者)
  ├── app container
  └── sidecar container

pause 是一个什么都不做的迷你进程。只负责维持 namespace。

9.3 Init container 与 sidecar

  • Init container:Pod 启动前顺序执行。
  • Sidecar(native 2023+):与应用并行运行,可以独立重启。

Envoy(Istio)、Fluent Bit 这类辅助进程都以 sidecar 形式存在。

9.4 编排的意义

  • Scheduling:放到哪个节点上运行?
  • Health check、自动重启。
  • Rolling update、canary。
  • Service discovery、负载均衡。
  • Storage、Config、Secret。
  • 水平自动伸缩。

Kubernetes 用声明式 YAML 完成这一切。

10. 实战贴士

10.1 调试工具箱

docker ps -a                    # 所有容器
docker logs -f <container>      # 日志 stream
docker exec -it <container> sh  # 进入
docker inspect <container>      # 详细信息(JSON)
docker stats                    # 实时资源使用
docker events                   # 事件 stream

Kubernetes:

kubectl logs -f <pod>
kubectl exec -it <pod> -- sh
kubectl describe pod <pod>
kubectl get events --sort-by='.lastTimestamp'

10.2 在 Distroless 里调试

没有 shell,无法用 exec 进入。替代方案:

kubectl debug <pod> --image=busybox --target=app
# 或者
docker run --rm -it --pid=container:<id> --net=container:<id> busybox

共享 pid/net namespace,用调试容器接入。

10.3 镜像体积分析

  • docker history <image>:按层查看大小。
  • dive tool:浏览层的内容。
  • docker image ls --format="..." + 格式化。

10.4 性能观测

# 容器资源使用
docker stats --no-stream

# 直接查看 cgroup
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/cpu.stat

# 用 perf 做剖析
docker run --cap-add SYS_ADMIN --pid host ...

11. 结语 — docker run 一行的深度

docker run nginx 这一行实际做的事:

  1. 从 Docker Hub 下载 nginx 镜像层。
  2. 用 OverlayFS 把层堆叠组装起来。
  3. containerd 生成 containerd-shim。
  4. shim 启动 runc。
  5. runc 设置 7 个 namespace + cgroup + rootfs。
  6. 应用 seccomp/AppArmor profile。
  7. execve("nginx") 启动进程。
  8. 配置容器网络(veth + bridge)。
  9. 添加 iptables 端口转发。

50ms 之内就结束了。和 2000 年代启动 VM 要花上几分钟相比,这简直是奇迹。它的背后是 2006 cgroups、2008 namespaces、2013 Docker、2015 OCI、2016 overlay2、2020 CRI 等十余年的工程积累。

与此同时,这份便利的代价是 共享内核 = 安全弱点。这正是 Lambda、Fargate 这类商用平台使用 microVM 的原因。如果是多租户场景,不要全然相信“容器 = 隔离”。

下一篇打算深入 Kubernetes 内部 — etcd 的 Raft 共识、调度器的过滤/打分、控制器模式、自定义资源、CRI/CNI/CSI 的插件体系。一张 YAML 在数百台节点上跑起来的全过程。

参考资料

  • Jérôme Petazzoni — “Anatomy of a Container: Namespaces, cgroups & some filesystem magic”(LinuxCon 2015)。
  • Michael Kerrisk — “Understanding Linux Namespaces”(LWN 系列)。
  • OCI Image/Runtime Specifications(GitHub)。
  • Julia Evans — “Container Networking” 系列。
  • Liz Rice — “Container Security”(O'Reilly, 2020)。
  • Kubernetes 官方文档 — Pods、CNI、CRI。
  • Red Hat crun 博客。
  • Firecracker 论文(NSDI 2020)。
  • Google gVisor 论文。
  • “The Kubernetes Book” — Nigel Poulton。