1. 引言
很多开发者把容器当作"轻量虚拟机",但这是一个常见的误解。虚拟机里跑的是完整的操作系统内核,而容器里跑的只是一个加了隔离的进程------它和宿主机共享同一个 Linux 内核,只是通过 namespace 和 cgroup 这两大机制,让这个进程"看起来"像一台独立的小机器。
本文不依赖 Docker,而是直接用 Linux 原生的 unshare、nsenter、lsns 等命令,从零手搓一个"容器",把 namespace、cgroup、镜像分层这些概念逐一拆开讲清楚。读完你会明白:容器不是魔法,它只是 Linux 内核早已提供的进程隔离能力,被 Docker 等工具包装成了易用的产品形态。
本文是系列的第 14 篇,前置依赖为第 01 篇(Linux 基础)与第 08 篇(进程与信号)。建议先掌握进程、PID、挂载等基础概念再阅读本文。
2. 容器本质:加了隔离的进程
2.1 先破除"迷你虚拟机"的迷思
虚拟机(VM)通过 Hypervisor 虚拟化硬件,每个 VM 内部运行一个完整的 Guest OS,包括独立的内核。这意味着:
- 每个 VM 都要占用大量内存和磁盘(内核 + 系统库 + 应用);
- 启动一个 VM 通常需要几十秒;
- VM 之间的隔离是硬件级别的,安全性较高。
而容器完全不同:
- 容器与宿主机共享同一个内核;
- 容器只是一个普通的用户态进程,只是被 namespace 和 cgroup 包裹了一层;
- 启动一个容器只需毫秒级,资源开销极小。
一句话总结:VM 是"虚拟的机器",容器是"加了隔离的进程"。
2.2 namespace:让进程"看不见"外面的世界
namespace 是 Linux 内核提供的一种机制,它把系统资源(如 PID、网络、挂载点、主机名等)做了隔离,让 namespace 内的进程只能看到属于自己的那部分资源,仿佛自己运行在一台独立的机器上。
Linux 目前支持 8 种 namespace:
| namespace | 隔离的资源 | 关键作用 |
|---|---|---|
| PID | 进程号 | 容器内进程 PID 从 1 开始,看不到宿主机其他进程 |
| Network | 网络栈 | 独立的网卡、IP、路由、防火墙规则 |
| Mount | 挂载点 | 容器内看到的文件系统与宿主机不同 |
| UTS | 主机名与域名 | 容器内 hostname 独立 |
| IPC | 进程间通信 | 隔离 System V IPC 和 POSIX 消息队列 |
| User | 用户 ID 与组 ID | 容器内 root 映射为宿主机普通用户 |
| Cgroup | cgroup 根目录 | 容器内看到独立的 cgroup 层级 |
| Time | 系统时间 | 容器内可拥有独立的时钟偏移(较新内核) |
其中前 6 种是 Docker 默认启用的核心隔离,也是本文重点。
2.3 cgroup:限制进程"能用多少资源"
namespace 解决的是"看得见什么"的问题,cgroup 解决的是"能用多少"的问题。
cgroup(Control Groups)是 Linux 内核提供的资源限制机制,可以对进程组做:
- CPU 使用上限与权重分配;
- 内存使用上限;
- 磁盘 I/O 带宽限制;
- 网络带宽限制;
- 进程数量限制(pids)。
Docker 的 --cpus、--memory、--pids-limit 等参数,底层都是通过 cgroup 实现的。
3. 手搓一个"容器":unshare 实战
3.1 准备工作
本文所有命令都在 Linux 环境执行(推荐 Ubuntu 22.04+ 或 CentOS 7+,内核 4.x 以上)。先确认环境:
bash
# 查看内核版本
uname -r
# 确认 unshare 命令可用
which unshare nsenter lsns
如果 lsns 不存在,先安装 util-linux:
bash
# Ubuntu / Debian
sudo apt install -y util-linux
# CentOS / RHEL
sudo yum install -y util-linux
3.2 用 unshare 创建隔离的 PID namespace
unshare 命令可以创建一个新的 namespace,并在其中运行指定命令。先看最简单的例子------隔离 PID namespace:
bash
sudo unshare --pid --fork --mount-proc bash
进入新 shell 后,执行:
bash
# 查看当前进程 PID
echo $$
# 查看进程列表
ps aux
你会惊讶地发现:$$ 输出的是 1,ps aux 只能看到少数几个进程,而宿主机上成百上千的进程全部"消失"了。
这是因为:
--pid创建了新的 PID namespace,新 shell 成为该 namespace 的 PID 1;--fork让 unshare 先 fork 一个子进程再进入新 namespace(否则 PID 1 语义不完整);--mount-proc重新挂载/proc,让ps只能看到当前 namespace 内的进程。
退出后回到宿主机,再执行 ps aux,一切恢复原样。
3.3 隔离主机名:UTS namespace
再叠加 UTS namespace,让容器拥有独立的主机名:
bash
sudo unshare --pid --fork --mount-proc --uts bash
在新 shell 中修改主机名:
bash
hostname my-container
hostname
此时 hostname 输出 my-container,而宿主机的主机名完全不受影响。这就是 Docker 里 --hostname 参数的底层原理。
3.4 隔离网络:Network namespace
网络隔离是容器最常用的能力之一。创建独立的网络 namespace:
bash
sudo unshare --net bash
在新 shell 中查看网络:
bash
ip addr
你会发现只有 lo 回环接口,且默认是 down 状态,没有任何物理网卡。这就是"容器有独立网络栈"的直观体现。
启动回环接口:
bash
ip link set lo up
ping 127.0.0.1
3.5 组合起来:一个"伪容器"
把上面几种 namespace 组合起来,就得到了一个最简"容器":
bash
sudo unshare --pid --fork --mount-proc --uts --net --ipc bash
在这个 shell 里:
- PID 从 1 开始;
- 主机名可独立修改;
- 网络栈独立;
- IPC 隔离;
/proc只显示本 namespace 的进程。
这就是 Docker 容器最核心的运行时骨架。当然,真正的容器还差最后一块拼图------文件系统隔离(Mount namespace + 镜像分层),我们下一节讲。
3.6 用 nsenter 进入已有 namespace
nsenter 可以进入一个已存在的 namespace,这正是 docker exec 的底层原理。
先在一个终端启动一个"容器":
bash
sudo unshare --pid --fork --mount-proc --uts bash
echo $$ # 记下这个 PID,比如 12345
然后在另一个终端:
bash
# 进入该进程的 PID namespace
sudo nsenter --target 12345 --pid --mount --uts bash
进入后执行 ps aux,你会看到和容器内一致的进程列表。这就是 docker exec 的工作方式------它并不是进入容器的 PID 1,而是创建一个新进程并把它加入目标 namespace。
4. 镜像分层:overlayfs 与写时复制
4.1 为什么容器镜像能"共享"
Docker 镜像由多层只读层组成,底层机制是 overlayfs(Overlay Filesystem)。overlayfs 把多个目录层叠加成一个统一视图:
- lowerdir:只读的镜像层,可被多个容器共享;
- upperdir:容器自己的可写层;
- merged:容器内看到的最终文件系统。
diff
+-----------------------+
| merged(容器视角) |
+-----------------------+
| upperdir(可写层) |
+-----------------------+
| lowerdir 层 3 |
+-----------------------+
| lowerdir 层 2 |
+-----------------------+
| lowerdir 层 1 |
+-----------------------+
4.2 写时复制(CoW)
写时复制(Copy-on-Write)是 overlayfs 的核心优化:当容器要修改一个只读层中的文件时,不会直接改底层文件,而是先把该文件复制到 upperdir,再在副本上修改。这样:
- 多个容器共享同一份只读镜像层,互不影响;
- 每个容器只保存自己修改的部分,磁盘占用极小;
- 镜像层可以被大量容器并发共享,启动速度极快。
4.3 手动体验 overlayfs
不依赖 Docker,直接用 mount 命令体验 overlayfs:
bash
# 准备目录
mkdir -p /tmp/overlay/{lower1,lower2,upper,work,merged}
# 在只读层放一些文件
echo "from lower1" > /tmp/overlay/lower1/file1.txt
echo "from lower2" > /tmp/overlay/lower2/file2.txt
# 挂载 overlayfs
sudo mount -t overlay overlay \
-o lowerdir=/tmp/overlay/lower1:/tmp/overlay/lower2,\
upperdir=/tmp/overlay/upper,workdir=/tmp/overlay/work \
/tmp/overlay/merged
# 查看合并视图
ls /tmp/overlay/merged
cat /tmp/overlay/merged/file1.txt
cat /tmp/overlay/merged/file2.txt
现在修改 merged 中的文件,观察写时复制:
bash
echo "modified" > /tmp/overlay/merged/file1.txt
# 底层只读层不变
cat /tmp/overlay/lower1/file1.txt # 仍是 "from lower1"
# 修改被复制到 upperdir
cat /tmp/overlay/upper/file1.txt # 输出 "modified"
这就是 Docker 镜像分层的全部秘密:镜像层只读共享,容器层写时复制。
4.4 层共享与缓存
Docker 构建镜像时,每一层都会生成一个内容哈希。如果某层的内容与已有层完全一致,Docker 会直接复用缓存,不会重新构建。这就是为什么:
- 多个镜像共享基础层时,磁盘占用很小;
- 修改 Dockerfile 时,只有变更层之后的层会重建;
- 拉取镜像时,已存在的层会被跳过。
5. 用 strace 和 lsns 验证"容器不是迷你虚拟机"
5.1 lsns:查看 namespace 关系
lsns 可以列出系统上所有的 namespace 及其关联进程:
bash
sudo lsns
输出示例:
bash
NS TYPE NPROCS PID USER COMMAND
4026531835 pid 123 1 root /sbin/init
4026531836 net 123 1 root /sbin/init
4026531837 mnt 123 1 root /sbin/init
4026531838 uts 123 1 root /sbin/init
启动一个容器后再次执行 lsns,你会看到新增的 namespace 条目,且其 PID 指向容器内的进程。这直观地证明:容器进程与宿主机进程共享同一个内核,只是被 namespace 隔离。
5.2 strace:观察系统调用
strace 可以跟踪进程的系统调用。用 strace 观察容器内进程,会发现它调用的都是普通的 Linux 系统调用(clone、execve、open、read 等),与宿主机进程没有本质区别:
bash
# 在容器内运行一个简单命令,并跟踪其系统调用
sudo strace -f -e trace=clone,execve unshare --pid --fork --mount-proc bash -c "echo hello"
输出中可以看到 clone 调用携带了 namespace 相关的 flag(CLONE_NEWPID、CLONE_NEWNS 等),这正是容器创建的底层机制。如果容器是虚拟机,这里应该出现的是硬件虚拟化相关的调用(如 KVM_CREATE_VM),而实际并没有------这从系统调用层面证明了容器只是加了隔离的进程。
5.3 对比:VM 与容器的系统调用差异
| 维度 | 虚拟机 | 容器 |
|---|---|---|
| 内核 | 独立 Guest OS 内核 | 共享宿主机内核 |
| 系统调用 | 经过 Hypervisor 虚拟化 | 直接调用宿主机内核 |
| 启动时间 | 秒级到分钟级 | 毫秒级 |
| 资源开销 | 每个 VM 一套完整 OS | 仅进程级开销 |
| 隔离级别 | 硬件级 | 内核 namespace/cgroup 级 |
6. 坑位总结
6.1 PID 1 与僵尸进程
容器内的第一个进程是 PID 1,它承担着"收养孤儿进程、回收僵尸进程"的职责。如果 PID 1 进程没有正确处理 SIGCHLD 信号,容器内就会出现大量僵尸进程无法回收。
bash
# 在容器内观察僵尸进程
ps aux | grep defunct
解决方案:让 PID 1 进程(如应用主进程)正确实现信号处理,或使用 tini 等 init 系统作为容器入口。
6.2 docker exec 是新进程而非 PID 1
docker exec 并不是"进入"容器的 PID 1,而是在目标 namespace 中创建一个全新的进程。这意味着:
docker exec启动的进程 PID 不是 1;- 它不继承 PID 1 的环境变量(除非显式传递);
- 它退出后不会影响容器主进程。
bash
# 在容器内查看 exec 进程的 PID
docker exec <container> echo $$
# 输出通常不是 1
6.3 容器内 systemd 不可用
由于容器与宿主机共享内核,且 PID namespace 隔离,容器内无法运行 systemd 作为 PID 1(systemd 需要完整的系统初始化能力)。常见表现:
systemctl start xxx报错;- 无法管理宿主机级服务。
解决方案:容器内使用轻量 init(如 tini、s6),或直接以应用进程作为 PID 1。
7. 总结
本文从零开始,用 unshare 手搓了一个"容器",并拆解了容器底层的三大核心机制:
- namespace:隔离进程的"视野"(PID、网络、挂载、UTS、IPC、User);
- cgroup:限制进程的"食量"(CPU、内存、I/O);
- overlayfs + CoW:实现镜像分层共享与写时复制。
通过 lsns 和 strace 的验证,我们从系统调用层面确认:容器不是迷你虚拟机,而是加了隔离的进程。理解这些底层原理,能帮助你在生产环境中更好地排查容器问题、优化镜像构建、设计更合理的资源限制策略。
8. 思考与练习
- 用
unshare组合出包含 PID、UTS、Network、Mount 四种隔离的"容器",并验证各自效果。 - 在容器内启动一个后台进程,观察它退出后是否变成僵尸进程,思考 PID 1 的职责。
- 手动挂载 overlayfs,验证写时复制行为,并对比 Docker 镜像层的共享机制。
- 用
strace对比容器进程与普通进程的系统调用差异,总结两者的本质区别。 - 思考:为什么容器内不能运行 systemd?如果必须运行,有哪些替代方案?