LabHub

博客

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

한국어English日本語中文

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 还不够:

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 的特殊之处:

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 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

4.2 OverlayFS 的结构

┌─────────────────────┐
upperdir (RW)      │  ← 容器写入
├─────────────────────┤
│  merged view        │  ← 应用看到的文件系统
├─────────────────────┤
lowerdir (RO) ×N   │  ← 镜像层
└─────────────────────┘

4.3 Copy-up 的代价

修改大文件时:

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

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

4.4 Whiteout — 文件删除

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

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

4.5 StorageDriver 的演进

Docker 的 storage driver 变迁史:

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 实现,纯粹地调用系统调用:

5.3 替代运行时

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

5.4 shim 的职责

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

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

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

6.1 共享内核的风险

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

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

6.2 Linux Capabilities

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

Capabilities 把 root 权限 细分

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 映射:

6.6 安全检查清单

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"]

7.4 Alpine vs Debian

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

7.5 BuildKit 与缓存

DOCKER_BUILDKIT=1 docker build .

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)

8.2 网络模式

8.3 Kubernetes CNI

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

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

9. 与 Kubernetes 的整合

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

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

理由:

结果: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

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

9.4 编排的意义

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 镜像体积分析

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 在数百台节点上跑起来的全过程。

参考资料

评论

还没有评论。

登录后即可发表评论