容器的本质
约 3632 字大约 12 分钟
2025-07-19
一、先破后立:容器不是什么
在理解容器本质之前,有必要澄清两个最常见的误解。
容器 ≠ 虚拟机。
虚拟机(VM)通过 Hypervisor 在物理硬件之上虚拟出一整套硬件,每个 VM 运行自己独立的 Guest OS 内核。而容器直接运行在宿主机的内核之上,没有自己的内核,本质上只是一个被 "隔离" 的普通进程。
┌─────────────────────────────────┐ ┌─────────────────────────────────┐
│ 虚拟机架构 │ │ 容器架构 │
│ │ │ │
│ App A │ App B │ App C │ │ App A │ App B │ App C │
│ Bins/Libs │ Bins/Libs │ ... │ │ Bins/ │ Bins/ │ Bins/ │
│ Guest OS │ Guest OS │ │ │ Libs │ Libs │ Libs │
│─────────────────────────────────│ │─────────────────────────────────│
│ Hypervisor │ │ Container Runtime │
│─────────────────────────────────│ │─────────────────────────────────│
│ Host OS Kernel │ │ Host OS Kernel │
│─────────────────────────────────│ │─────────────────────────────────│
│ Physical Hardware │ │ Physical Hardware │
└─────────────────────────────────┘ └─────────────────────────────────┘这也是为什么:
- 容器只能运行与宿主机相同内核的操作系统(Linux 上跑不了 Windows 容器,反之亦然)
- 容器启动是毫秒级的(只是启动一个进程),而虚拟机启动是秒级的
- 容器的资源开销极小,一台物理机可以运行成千上万个容器
二、容器的本质:三个 Linux 内核特性的组合
容器不是一种 "技术",而是三种 Linux 内核特性的有机组合:
Container=Namespace(视图隔离)+Cgroups(资源限制)+UnionFS(文件系统分层)
2.1 Namespace —— "我能看到什么"
Linux Namespace 是内核提供的资源视图隔离机制。每个进程可以拥有自己独立的 "世界视图"。目前 Linux 支持 8 种 Namespace:
| Namespace 类型 | 隔离的资源 | 效果 |
|---|---|---|
| Mount (mnt) | 文件系统挂载点 | 容器有自己的根文件系统 |
| PID | 进程 ID 编号空间 | 容器内进程看到 PID 从 1 开始 |
| Network (net) | 网络设备、IP、端口 | 容器有自己独立的网卡和 IP |
| IPC | 进程间通信资源 | 隔离信号量、消息队列、共享内存 |
| UTS | 主机名和域名 | 容器可以有自己的 hostname |
| User | 用户和组 ID | 容器内 root 可以映射为宿主机普通用户 |
| Cgroup | Cgroup 根目录视图 | 容器内看到的 cgroup 路径不同 |
| Time | 系统时间 | 容器可以有独立的系统时钟 |
核心理解:Namespace 解决的是 "能看到什么" 的问题。一个进程被放入某个 Namespace 后,它就只能看到这个 Namespace 范围内的资源。
2.2 Cgroups —— "我能用多少"
Control Groups (Cgroups) 是内核提供的资源限制与审计机制。它可以限制、审计和隔离一组进程的物理资源使用。
Cgroups v2 中常见的控制器:
| 控制器 | 限制的资源 |
|---|---|
| cpu | CPU 使用时间配额 |
| memory | 内存使用上限,含 OOM 行为控制 |
| io | 块设备 I/O 带宽 / IOPS |
| pids | 进程数量上限 |
| cpuset | 绑定到特定 CPU 核心或 NUMA 节点 |
| hugetlb | 大页内存使用量 |
核心理解:Cgroups 解决的是 "能用多少" 的问题。它可以防止一个容器耗尽整台机器的资源。
2.3 UnionFS —— "我的文件在哪"
UnionFS(联合文件系统)如 OverlayFS,允许将多个目录(层)合并为一个统一的视图。容器镜像的分层构建和快速分发正是依赖这个特性。
┌────────────────────────────┐
│ Container Layer (RW) │ ← 容器运行时写入层(可读写)
├────────────────────────────┤
│ Image Layer 3 (RO) │ ← RUN apt-get install nginx
├────────────────────────────┤
│ Image Layer 2 (RO) │ ← COPY app binary
├────────────────────────────┤
│ Image Layer 1 (RO) │ ← FROM ubuntu:22.04
└────────────────────────────┘核心理解:UnionFS 解决的是 "文件在哪、怎么复用" 的问题。镜像层是只读的,所有修改都写入最上层的可读写层(容器层)。
三、实战:在 Linux 上手动模拟一个容器
理解了原理,我们来亲自动手。下面每一步都在 Linux 物理机或虚拟机上执行(推荐 Ubuntu 22.04)。
3.1 准备根文件系统
首先需要一个 Linux 根文件系统作为容器的 "镜像"。最简单的方式是用 debootstrap 或直接导出 Docker 镜像:
# 方式一:用 Docker 导出一个 rootfs
docker export $(docker create ubuntu:22.04) -o ubuntu.tar
mkdir rootfs && tar -xf ubuntu.tar -C rootfs
# 方式二:用 debootstrap(需要先 apt install debootstrap)
# sudo debootstrap jammy rootfs http://archive.ubuntu.com/ubuntu3.2 第一步:隔离文件系统 (Mount Namespace)
让进程只能看到我们准备的 rootfs:
# unshare 创建新的 mount namespace,然后 chroot 切换根目录
sudo unshare --mount --fork /bin/bash -c "
# 在新 namespace 中重新挂载 /proc
mount -t proc proc rootfs/proc
# 切换根文件系统
chroot rootfs /bin/bash
"此时执行 ls /,你看到的是 rootfs 里的文件,不再是宿主机的根目录。
3.3 第二步:隔离进程视图 (PID Namespace)
加上 --pid,让容器内的进程以为自己是 PID 1:
sudo unshare --mount --pid --fork /bin/bash -c "
mount -t proc proc rootfs/proc
chroot rootfs /bin/bash
# 现在 ps aux 会报错,因为 /proc 还没正确挂载
mount -t proc proc /proc
ps aux
"你会看到容器内只有少量进程,且 bash 自身的 PID 是 1。
3.4 第三步:隔离网络 (Network Namespace)
加上 --net,容器拥有独立的网络栈:
sudo unshare --mount --pid --net --fork /bin/bash -c "
# 初始化 lo 回环接口
ip link set lo up
# 此时 ifconfig 只能看到 lo,没有任何 eth0
mount -t proc proc rootfs/proc
chroot rootfs /bin/bash
"要让容器访问外网,还需要创建 veth pair 把容器的 network namespace 连到宿主机网桥上——这正是 Docker/CNI 做的事情。
3.5 第四步:隔离主机名 (UTS Namespace)
加上 --uts,容器可以有自己的 hostname:
sudo unshare --mount --pid --net --uts --fork /bin/bash -c "
hostname my-container
mount -t proc proc rootfs/proc
chroot rootfs /bin/bash
hostname # 输出: my-container
"3.6 第五步:限制资源 (Cgroups)
unshare 不能直接创建 cgroup 限制。需要手动操作 cgroup 文件系统:
# 创建新的 cgroup(以 cgroups v2 为例)
sudo mkdir /sys/fs/cgroup/mycontainer
# 限制内存为 128MB
echo "134217728" | sudo tee /sys/fs/cgroup/mycontainer/memory.max
# 限制 CPU 使用为 0.5 核(50000 表示 50%)
echo "50000 100000" | sudo tee /sys/fs/cgroup/mycontainer/cpu.max
# 启动容器进程并加入 cgroup
sudo unshare --mount --pid --net --uts --fork /bin/bash -c "
echo \$\$ | sudo tee /sys/fs/cgroup/mycontainer/cgroup.procs
mount -t proc proc rootfs/proc
chroot rootfs /bin/bash
"3.7 完整的容器启动脚本
将以上步骤整合为一个脚本 my-container.sh:
#!/bin/bash
set -e
ROOTFS="./rootfs"
CGROUP_NAME="mycontainer"
HOSTNAME="my-container"
# 1. 创建 cgroup 并设置资源限制
sudo mkdir -p /sys/fs/cgroup/$CGROUP_NAME
echo "134217728" | sudo tee /sys/fs/cgroup/$CGROUP_NAME/memory.max > /dev/null
echo "50000 100000" | sudo tee /sys/fs/cgroup/$CGROUP_NAME/cpu.max > /dev/null
echo "256" | sudo tee /sys/fs/cgroup/$CGROUP_NAME/pids.max > /dev/null
# 2. 启动隔离进程
sudo unshare \
--mount \
--pid \
--net \
--uts \
--ipc \
--cgroup \
--fork \
/bin/bash -c "
# 将自己加入 cgroup
echo \$\$ > /sys/fs/cgroup/$CGROUP_NAME/cgroup.procs
# 设置主机名
hostname $HOSTNAME
# 挂载关键文件系统
mount -t proc proc ${ROOTFS}/proc
mount -t sysfs sysfs ${ROOTFS}/sys
mount -t tmpfs tmpfs ${ROOTFS}/tmp
mount -t tmpfs tmpfs ${ROOTFS}/run
mkdir -p ${ROOTFS}/dev/pts
mount -t devpts devpts ${ROOTFS}/dev/pts
# 初始化 lo 接口
ip link set lo up
# 切换根文件系统
chroot ${ROOTFS} /bin/bash
"执行 bash my-container.sh,你就获得了一个手工打造的 "容器"——它拥有独立文件系统、独立进程空间、独立网络栈、独立主机名,且受资源限制。
关键认知:Docker、containerd 等容器运行时,本质上就是在自动化地执行上述步骤——解析镜像、准备 rootfs、创建 namespace、设置 cgroup、配置网络。它们没有用到任何黑魔法。
3.8 延伸:Namespace 操作的底层系统调用
你可能好奇:Linux 有专门的 "创建 Namespace" 命令吗?答案是没有。Namespace 不是独立存在的内核对象,它依附于进程——由进程创建,随最后一个进程退出而自动销毁。
内核提供了三个系统调用来操作 Namespace,unshare 命令只是其中之一的命令行封装:
| 系统调用 | 作用 | 语义 |
|---|---|---|
clone(flags) | 创建子进程时,指定 CLONE_NEW* 标志,子进程获得独立的 Namespace | "生个孩子,让他住新房子" |
unshare(flags) | 当前进程脱离现有 Namespace,获得新的独立 Namespace | "我自己搬出去住新房子" |
setns(fd, nstype) | 将当前进程加入一个已存在的 Namespace | "我搬去别人已有的房子住" |
三者的关系如下图:
clone() ──→ 创建子进程 + 放入新 Namespace
┌──────────┐
│ Parent │ (老 NS)
└────┬─────┘
│ clone(CLONE_NEWNET)
┌────▼─────┐
│ Child │ (新 NS)
└──────────┘
unshare() ──→ 自身脱离老 NS,获得新 NS
┌──────────┐ ┌──────────┐
│ 自身(老NS)│ ──→ │ 自身(新NS)│
└──────────┘ └──────────┘
setns() ──→ 自身加入一个已存在的 NS
┌──────────┐ ┌──────────┐
│ 自身(NS A)│ ──→ │ 自身(NS B)│
└──────────┘ └──────────┘在日常使用中,与之对应的三个命令行工具分别是:
| 命令 | 封装的系统调用 | 典型用途 |
|---|---|---|
unshare | unshare() | 手动模拟容器,让 shell 进入新的 Namespace |
nsenter | setns() | 进入正在运行的容器/Pod 内部进行调试 |
lsns | 读取 /proc | 列出系统中所有 Namespace |
几个实用的例子:
# unshare: 上文一直在用,为当前 bash 创建新的 net + pid namespace
sudo unshare --net --pid --fork /bin/bash
# nsenter: 进入一个正在运行的容器的 network namespace 调试网络
# 先拿到容器进程的 PID
PID=$(docker inspect <container_id> --format '{{.State.Pid}}')
sudo nsenter --net --target $PID
# 此时 ifconfig 看到的是容器内的网卡,可以排查网络问题
# lsns: 查看系统中所有 namespace
lsns
# NS TYPE NPROCS PID USER COMMAND
# 4026531834 time 4 1234 root /sbin/init
# 4026531835 cgroup 4 1234 root /sbin/init
# 4026531992 net 2 5678 root /pause ← 这是某个 Pod 的 Pause 容器这解释了为什么 Pod 必须有 Pause 容器:Namespace 的生命周期由引用它的进程决定。一旦 Namespace 中最后一个进程退出,该 Namespace 就会被内核回收。Pause 容器永远不退出 —— 它作为 Network Namespace 的"锚点"保持其存活,让其他业务容器可以通过 setns() 加入。如果 Pause 容器挂了,整个 Pod 的网络命名空间就消失了。
四、Kubernetes 中的 Pod:一组共享 Namespace 的容器
4.1 Pod = 最小调度单元
在 Kubernetes 中,Pod 是最小的调度和部署单元。你不能直接调度一个容器,K8s 调度的永远是 Pod。
apiVersion: v1
kind: Pod
metadata:
name: my-pod
spec:
containers:
- name: app
image: nginx:latest
- name: sidecar
image: busybox:latest
command: ["sh", "-c", "while true; do echo hello; sleep 5; done"]4.2 Pod 的本质:共享 Namespace 的容器组
回到我们的 Namespace 理论:Pod = 一组共享特定 Namespace 的容器。
┌─────────────────────────────────────────┐
│ Pod │
│ │
│ 共享 Network Namespace (相同 IP 和端口) │
│ 共享 IPC Namespace (进程间通信) │
│ 共享 UTS Namespace (相同 hostname) │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │Container│ │Container│ │Container│ │
│ │ A │ │ B │ │ C │ │
│ │ │ │ │ │ │ │
│ │独立 │ │独立 │ │独立 │ │
│ │Mount NS │ │Mount NS │ │Mount NS │ │
│ │PID NS │ │PID NS │ │PID NS │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ 共享 Volume (通过挂载) │
└─────────────────────────────────────────┘具体来说,Pod 内的容器共享以下 Namespace:
| Namespace | 共享? | 说明 |
|---|---|---|
| Network | ✅ 共享 | 同一 Pod 内所有容器共享同一个 IP 和端口空间,通过 localhost 互访 |
| IPC | ✅ 共享 | 可以使用 System V 信号量或 POSIX 消息队列通信 |
| UTS | ✅ 共享 | 共享 hostname(默认同 Pod 名) |
| Mount | ❌ 独立 | 各自有自己的文件系统(镜像),但可以通过 Volume 共享目录 |
| PID | 可选 | 可通过 shareProcessNamespace: true 开启共享,默认不共享 |
4.3 Pod 的实现原理:Infra Container(Pause 容器)
K8s 如何实现 "共享 Network Namespace"?答案是 Infra Container(也叫 Pause/Sandbox 容器)。
Pod 启动时:
- 先启动 Pause 容器——一个极简容器(
registry.k8s.io/pause:3.9),只做一件事:pause()系统调用,永远睡眠 - Pause 容器创建了 Network Namespace、IPC Namespace
- 其他业务容器加入 Pause 容器的这些 Namespace
启动顺序:
Step 1: 创建 Pause 容器(持有 Network NS + IPC NS)
Step 2: Container A → 加入 Pause 的 Network NS, IPC NS
Step 3: Container B → 加入 Pause 的 Network NS, IPC NS
Step 4: Container C → 加入 Pause 的 Network NS, IPC NS这就是为什么即使是单容器的 Pod,也会有一个 Pause 容器在运行——它是整个 Pod 命名空间的 "锚点"。
验证一下:
# 在任意 K8s 节点上
crictl ps | grep pause
# 你会看到每个 Pod 都有对应的 Pause 容器五、Pod 中多个容器的设计模式
Pod 不只是 "一个容器" 的包装,它支持在同一 Pod 中运行多个紧密协作的容器。这些容器共享网络和 IPC,形成逻辑上的 "单机多进程" 模型。
5.1 Sidecar 模式(最常用)
Sidecar 容器增强或辅助主容器完成功能,与主容器生命周期绑定。
经典案例:日志收集
apiVersion: v1
kind: Pod
metadata:
name: app-with-log-sidecar
spec:
containers:
- name: app # 主容器:写日志
image: my-app:latest
volumeMounts:
- name: logs
mountPath: /var/log/app
- name: log-shipper # Sidecar:收集并转发日志
image: fluentd:latest
volumeMounts:
- name: logs
mountPath: /var/log/app
volumes:
- name: logs
emptyDir: {}这里 sidecar 通过共享 Volume 读取主容器的日志,然后转发到日志平台。它们通过 localhost 就能通信。
5.2 Init 容器
Init 容器在应用容器启动之前运行,用于执行初始化任务。它们按顺序执行,且必须全部成功退出后,应用容器才会启动。
apiVersion: v1
kind: Pod
metadata:
name: app-with-init
spec:
initContainers:
- name: init-db
image: busybox:latest
command: ["sh", "-c", "until nslookup mysql; do sleep 2; done"]
- name: init-config
image: busybox:latest
command: ["sh", "-c", "cp /config-template/* /config/"]
containers:
- name: app
image: my-app:latest关键区别:
| Init Container | Sidecar Container | |
|---|---|---|
| 运行时机 | 应用容器之前 | 与应用容器并行 |
| 运行顺序 | 严格按顺序执行 | 同时启动 |
| 失败处理 | 必须成功 Pod 才启动 | 可重启,不影响其他容器 |
| 用途 | 预处理、等待依赖就绪 | 持续辅助主容器 |
5.3 Ambassador 模式
Ambassador 容器作为代理,处理主容器与外部服务的通信(如连接池管理、重试、TLS 终止)。
┌───────────────────────────┐
│ Pod │
│ │
│ ┌──────────┐ ┌────────┐ │ ┌──────────────┐
│ │ App │─▶│Ambassador│─┼──TLS────▶│ External │
│ │Container │ │Container│ │ │ Service │
│ │localhost │ │ (Envoy) │ │ └──────────────┘
│ └──────────┘ └────────┘ │
└───────────────────────────┘App 容器只需访问 localhost:port,Ambassador 负责处理与外部服务的复杂通信。
5.4 Adapter 模式
Adapter 容器转换主容器输出的数据格式,使其匹配外部系统期望的接口。典型场景是 Prometheus exporter sidecar。
┌───────────────────────────────┐
│ Pod │
│ │
│ ┌──────────┐ ┌────────────┐ │ ┌──────────────┐
│ │ Redis │─▶│ Adapter │──┼────────▶│ Prometheus │
│ │Container │ │ (exporter) │ │ /metrics│ Server │
│ │ :6379 │ │ :9121 │ │ └──────────────┘
│ └──────────┘ └────────────┘ │
└───────────────────────────────┘5.5 Pod 内多容器通信方式总结
| 通信方式 | 适用场景 | 示例 |
|---|---|---|
| localhost 网络 | 高性能、低延迟 | App → sidecar proxy 走 localhost:8080 |
| 共享 Volume | 文件交换 | App 写日志,log-shipper 读日志 |
| 共享 IPC | 进程间信号量/消息队列 | 少数高性能场景 |
| Unix Domain Socket | 同 Pod 内进程通信 | abstract:///my_socket |
六、总结
回顾全文,我们建立了这样的认知链条:
容器PodK8s=Namespace+Cgroups+UnionFS=一组共享 Network/IPC/UTS Namespace 的容器=编排 Pod 的系统
- 容器的本质是进程隔离——不是轻量级虚拟化,而是带有资源限制和文件系统分层的普通 Linux 进程
- 手动模拟容器并不神秘——
unshare+chroot+cgroup就能搭出一个 "土制容器" - Pod 是对 Namespace 共享关系的声明——它定义了一组容器如何共享网络、IPC 等命名空间
- Pod 内多容器是紧密协作单元——通过 Sidecar、Init Container、Ambassador、Adapter 等模式实现功能组合
理解这些底层原理后,你会发现 Docker 的 "神奇" 不过是 Linux 内核特性的优雅封装,而 Kubernetes 的 Pod 设计也不过是对 Namespace 共享关系的声明式管理。