容器原理揭秘:namespace、cgroup 与镜像分层

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 手搓了一个"容器",并拆解了容器底层的三大核心机制:

  1. namespace:隔离进程的"视野"(PID、网络、挂载、UTS、IPC、User);
  2. cgroup:限制进程的"食量"(CPU、内存、I/O);
  3. overlayfs + CoW:实现镜像分层共享与写时复制。

通过 lsns 和 strace 的验证,我们从系统调用层面确认:容器不是迷你虚拟机,而是加了隔离的进程。理解这些底层原理,能帮助你在生产环境中更好地排查容器问题、优化镜像构建、设计更合理的资源限制策略。

8. 思考与练习

  1. 用 unshare 组合出包含 PID、UTS、Network、Mount 四种隔离的"容器",并验证各自效果。
  2. 在容器内启动一个后台进程,观察它退出后是否变成僵尸进程,思考 PID 1 的职责。
  3. 手动挂载 overlayfs,验证写时复制行为,并对比 Docker 镜像层的共享机制。
  4. 用 strace 对比容器进程与普通进程的系统调用差异,总结两者的本质区别。
  5. 思考:为什么容器内不能运行 systemd?如果必须运行,有哪些替代方案?
相关推荐
wzq11_6663 小时前
Kubernetes集群——基础篇(基础知识与搭建步骤一遍过!!!)
java·容器·kubernetes
java_logo4 小时前
Docker 部署 dockurr/windows:轻松搭建浏览器可控 Windows 虚拟机
运维·windows·docker·容器·虚拟机·kvm·轩辕镜像
谢亮_vipxieliang5 小时前
私有镜像仓库:Registry、Harbor 与镜像同步实战
网络·docker·容器
努力努力再努力wz7 小时前
【边缘计算入门系列】从“在哪里算”到“怎么算”:一文建立边缘计算、算子、计算图与 Tensor 的底层心智模型
人工智能·docker·边缘计算
江湖有缘7 小时前
3款开源IT工具箱整理合集,可Docker一键部署!
docker·容器·开源
旋生万物7 小时前
用螺旋数重写 Transformer Attention:让大模型自带“相位记忆“的 PyTorch 实现
docker·云原生·kubernetes·螺旋生成论·螺旋相位
编码如写诗8 小时前
【k8s】全新Ubuntu 26.04 使用kt 超简单安装 k8s 最新1.37.1+KubeSphere4.1.3
ubuntu·容器·kubernetes
小匠石钧知8 小时前
05_在k8s集群中安装NFS实现ReadWriteMany存储
java·容器·kubernetes·nfs·readwritemany·rwx
余槐i8 小时前
-m 2g 反而更早 OOM:Docker 内存计数与宿主机空闲口径差异
java·linux·docker·性能优化·cgroup