容器原理(进阶) 一
容器原理(进阶) 一
在 Linux 容器领域,我们不只是在运行程序,而是在利用 Linux 内核的底层特性,通过复杂的封装机制来实现进程的“环境隔离”与“资源掌控”。
进阶维度:
| 维度 | 内容重点 |
|---|---|
| 底层机理 | 对应的内核参数、系统调用(如 unshare, clone)。 |
| 核心组件 | 涉及的二进制工具(如 runc, containerd)。 |
| 性能损耗 | 该技术对系统调用或 IO 的额外开销。 |
| 安全性 | 提权风险、逃逸路径。 |
容器的本质与“幻觉”的构建—内核级障眼法
我们要明确一点:容器不是虚拟机,它只是 Linux 操作系统中一个被“特殊对待”的进程。
这个“特殊对待”主要依靠 Linux 内核的三大基石:Namespace(命名空间)、Cgroups(控制组) 和 Layered File System(分层文件系统)。
专家级公式(所有容器引擎(Docker, runc, containerd)的底层逻辑):
真正的容器进程=Namespace(视图)+Cgroups(资源)+Pivotroot(文件系统根)+Re−mount/proc(状态隔离) 真正的容器进程 = Namespace (视图) + Cgroups (资源) + Pivot_root (文件系统根) + Re-mount /proc (状态隔离) 真正的容器进程=Namespace(视图)+Cgroups(资源)+Pivotroot(文件系统根)+Re−mount/proc(状态隔离)
1. Namespace:视图隔离(看到什么)
Namespace 负责给进程“洗脑”,让进程以为自己拥有独立的电脑。目前主要的 Namespace 包括:
- PID Namespace:让进程觉得自己是 1 号进程(init)。
- Net Namespace:让进程拥有独立的 IP、端口和路由表。
- Mount Namespace:让进程只能看到特定的目录挂载点。
- UTS/IPC/User Namespace:隔离主机名、信号量和用户权限。
2. Cgroups:资源限制(能用多少)
如果说 Namespace 是“障眼法”,那么 Control Groups (Cgroups) 就是“紧箍咒”。它限制了进程能消耗的物理资源,防止某个容器把主机的 CPU 或内存吃光。
- CPU:权重分配、核心绑定。
- Memory:硬限制(Limit)和软限制(Reservation)。
- I/O:磁盘读写速度限制。
3. Rootfs & Storage Driver:文件系统(脚踏何处)
容器切换了进程的根目录(通过 chroot 或 pivot_root),配合 Union FS(联合文件系统,如 Overlay2),实现了镜像的分层存储与写时复制(CoW)。
-
unshare容器实验
运行:
# 这条命令会创建一个拥有独立主机名、网络和进程空间的 Shell sudo unshare --uts --net --pid --fork /bin/bash # 进入后修改主机名 hostname my-fake-container # 检查主机名,你会发现它变了,但退出这个 Shell 后,宿主机的主机名依然没变 hostname
概念校准:系统调用与封装
在 Linux 内核层面,实现“隔离”的核心系统调用(System Call)主要有三个:
clone():创建一个新进程时,通过传入标志(如CLONE_NEWPID)直接让子进程进入新的 Namespace。这是 Docker/runc 创建容器最常用的方式。unshare():让当前进程脱离原有的 Namespace,进入新的 Namespace。这正是你刚才实验所用的方式。setns():让当前进程加入一个已经存在的 Namespace。这通常用于docker exec进入一个正在运行的容器。
结论: 容器的实现是基于这些内核系统调用,而 unshare 只是这些调用在用户态的一个命令行封装工具。
2. 从“孤立进程”到“工业级容器”:中间还差什么?
如果你只用 unshare,你只是得到了一个“视图隔离”的进程,要称之为真正的“容器”,还需要一套严密的生命周期管理。这就是为什么我们有了 OCI(Open Container Initiative)标准。
一个完整的容器实现(如 runc)在调用 unshare 之后,还会做以下极其关键的动作:
A. 设置 Rootfs (根文件系统)
unshare 之后,进程看到的还是宿主机的目录。专家级的做法是:
- 准备一个完整的 Linux 发行版目录(Rootfs)。
- 使用
pivot_root系统调用,将进程的根目录切换到这个新目录。 - 注意: 这比
chroot更安全,因为它会把旧的根目录卸载掉,防止“越狱”。
B. 挂载虚拟文件系统
没有 /proc, /sys, /dev 的容器是无法正常工作的。容器引擎会自动在隔离的 Mount Namespace 里挂载这些内核虚拟文件系统,让 top、ps 等命令能正确显示容器内的数据。
C. 配置网络隔离 (Veth Pair)
unshare --net 之后,容器里只有 lo 回环网卡。真正的容器实现会:
- 在宿主机创建一对 Veth Pair(虚拟网线)。
- 把一端“塞进”容器的 Net Namespace。
- 在宿主机端把另一端挂载到网桥(如
docker0)上。
D. 配置 Cgroups 限制
在进程启动后,将其 PID 写入到 /sys/fs/cgroup/.../cgroup.procs 文件中,确保它不会耗尽主机资源。
| 层级 | 实现手段 / 工具 | 解决的问题 |
|---|---|---|
| 内核层 (Kernel) | clone, unshare, cgroups, Overlay2 |
基础隔离与限额能力 |
| 低级运行时 (Low-level) | runc, crun | 真正调用内核接口,创建并管理容器进程 |
| 高级运行时 (High-level) | containerd, CRI-O | 管理镜像、网络、存储卷及容器生命周期 |
| 用户接口 (Interface) | Docker CLI, Kubernetes | 提供给用户的易用工具和编排平台 |
专家级思考题
既然你已经理解了 unshare 的原理,这里有一个进阶问题供你思考并记录在文档中:
实验发现: 当你运行 unshare --pid --fork /bin/bash 进入容器后,执行 ps -ef,你依然能看到宿主机的所有进程。
问: 既然 Namespace 已经隔离了 PID,为什么
ps命令失效了?提示:看看/proc目录。
-
解答
之所以在 PID Namespace 隔离后,执行
ps仍然能看到宿主机的所有进程,是因为ps命令并不是直接去内核询问进程信息的,它是/proc目录的“搬运工”。1. 深度解析:为什么隔离“失效”了?
在 Linux 中,
/proc是一个伪文件系统(Pseudo Filesystem)。它不是存在于磁盘上的数据,而是内核暴露出来的、关于系统运行状态的窗口。ps的原理:当你执行ps时,它会扫描/proc目录下的所有数字目录(每个数字代表一个 PID)。- 挂载点的继承:虽然你使用了
unshare --pid创建了新的 PID Namespace,但你的 Mount Namespace 默认还是继承自宿主机的。也就是说,容器内的/proc挂载点依然指向宿主机的内核信息。 - 信息错配:新进程虽然在内核里有了新的 PID 映射(例如在容器内是 1),但它看到的
/proc还是老家(宿主机)的那一套。
2. 专家级修复:重建进程视图
要让 PID 隔离真正“生效”(即让
ps只看到容器内的进程),你必须为容器重新挂载一个新的/proc实例。行这个进阶实验:
# 1. 同时开启 PID 和 Mount Namespace 隔离 # --mount-proc 会自动尝试重新挂载 /proc,但我们手动操作一遍加深理解 sudo unshare --pid --mount --fork /bin/bash # 2. 此时在容器内执行 ps -ef,依然能看到宿主机进程 ps -ef # 3. 关键动作:重新挂载容器专属的 /proc # 我们告诉内核,挂载一个 proc 类型的文件系统到 /proc 目录 mount -t proc proc /proc # 4. 再次执行 ps ps -ef输出结果只有两个进程:
bash(PID 1)和刚才运行的ps(PID x)。此时,容器的“隔离幻觉”才算在用户空间完美达成。
Namespace 技术原理:内核的“行政区划”
在 Linux 内核中,Namespace 的实现本质上是在内核资源对象上增加了一个标签(Tag)。
A. 核心数据结构
内核通过 nsproxy 结构体来管理一个进程所属的所有 Namespaces。
- 每个进程的
task_struct中都有一个指向nsproxy的指针。 - 当你创建一个新容器时,内核会复制父进程的
nsproxy,并为指定的类型(如 PID 或 NET)创建新的结构实例。
B. 核心系统调用(三剑客)
clone(): 创建新进程的同时开启隔离。这是 Docker 创建容器的入口。unshare(): 让当前进程“退群”,进入新的隔离空间。setns(): “瞬移”到另一个进程的隔离空间。这是docker exec的原理。
演进历程:长达 20 年的“拼图”
Namespace 并不是为了容器而突然诞生的,它经历了漫长的补完过程:
- 2002年 (Kernel 2.4.19): Mount Namespace 诞生。最初内核开发者甚至没想过会有其他 Namespace,所以它的标志位直接叫
CLONE_NEWNS。 - 2006年 (Kernel 2.6.19): UTS 和 IPC 隔离加入。
- 2008年 (Kernel 2.6.24): Network Namespace 出现,容器终于可以有自己的网卡了。
- 2008年 (Kernel 2.6.26): PID Namespace 完善。
- 2013年 (Kernel 3.8): User Namespace 终于合入主线。这是容器安全的分水岭,实现了“容器内 root,宿主机普通用户”。
- 2016-2020年: Cgroup 和 Time Namespace 加入,补齐了最后几块拼图。
容器使用:工业级的实现路径
目前顶尖的容器运行时(如 runc)在创建 Namespace 时遵循以下逻辑:
- 独立化:首先通过
clone()隔离 UTS(主机名)和 IPC。 - 网络初始化:在宿主机创建
veth-pair,通过ip link set netns将一端塞进容器,在容器内初始化lo和eth0。 - 根切换:在 Mount Namespace 内执行
pivot_root。 - 身份映射:如果开启了 User Namespace,会修改
/proc/self/uid_map进行权限映射。
注意事项与容易“踩坑”点(专家经验)
Namespace 的局限性:
A. Namespace 不是全能的隔离
并不是所有内核资源都被 Namespace 隔离了。
- 内核日志 (dmesg):容器内默认可以看到宿主机的内核日志。
- 内核模块 (Kernel Modules):容器无法加载独立的内核模块。
- 系统时间:虽然有了 Time Namespace,但在较旧的内核中,修改容器时间会直接修改宿主机时间。
B. 挂载传播(Mount Propagation)
这是最容易踩坑的地方。如果在宿主机挂载了一个磁盘,容器里可能看不到,除非你正确配置了 shared 或 slave 传播属性。
C. PID 1 的特殊性
在容器内,PID 1(你启动的程序)如果不具备初始化进程的功能(如处理僵尸进程),会导致容器内产生大量无法回收的进程。
-
namespace实验
我们通过
setns的逻辑,手动从宿主机进入一个正在运行的容器 Namespace,不使用docker exec。实验步骤:
- 运行一个容器:
docker run -d --name my-lab alpine sleep 3600 - 找到该容器进程的 PID:
PID=$(docker inspect -f '{{.State.Pid}}' my-lab) - 查看该进程的 Namespace 文件:
ls -l /proc/$PID/ns/看到一堆类似
net -> net:[4026532247]的链接,这些就是 Namespace 的唯一 ID。模拟 exec:
# 使用 nsenter 工具进入该进程的 network 和 uts 空间 sudo nsenter -t $PID -n -u /bin/sh # 验证:你会发现 IP 和主机名已经变成了容器的 ip addr hostname - 运行一个容器:
先进实现:无根容器(Rootless)
目前行业内最先进的实现是 Rootless Containers(如 Podman 或 Docker Rootless 模式)。
它极度依赖 User Namespace:
- 原理:在宿主机是一个普通用户(UID 1000),但在容器内通过 UID Mapping 表现为 root(UID 0)。
- 意义:即便容器被攻破并发生了“隔离逃逸”,攻击者拿到的也只是宿主机上的普通用户权限,无法破坏宿主机。
namespace 深度拆解
在 Linux 中,“万物皆文件”的思想在这里体现得淋漓尽致。每一个 Namespace 在这个目录下都对应一个特殊的符号链接文件。
A. 各个文件的作用清单
当你查看 /proc/[pid]/ns/ 时,你会看到以下项(基于 Linux Kernel 5.x+):
| 文件名 | Namespace 类型 | 隔离的内容 | 引入版本 |
|---|---|---|---|
| mnt | Mount | 挂载点、文件系统层级 | 2.4.19 |
| uts | UTS | 主机名 (Hostname) 和 NIS 域名 | 2.6.19 |
| ipc | IPC | 进程间通信资源 (信号量、消息队列、共享内存) | 2.6.19 |
| pid | PID | 进程编号层级 | 2.6.24 |
| net | Network | 网络设备、堆栈、端口、路由表 | 2.6.24 |
| user | User | 用户 ID (UID) 和组 ID (GID) 映射 | 3.8 |
| cgroup | Cgroup | Cgroup 根目录视图 | 4.6 |
| time | Time | 系统单调时间 (Monotonic Clock) 和启动时间 | 5.6 |
B. 文件名(Inode)的玄机
如果你运行 ls -l /proc/self/ns/,你会看到类似 net -> 'net:[4026532247]'。
- 数字部分:这是该 Namespace 的 Inode Number。
- 判定标准:如果两个进程的某个 Namespace Inode 号相同,说明它们共享同一个隔离空间;如果不同,则处于隔离状态。
C. 生成与销毁机制
- 生成:当调用
clone()或unshare()带有相关 Flag 时,内核会为该进程分配一个新的nsproxy结构体,并生成这些 Inode。 - 生命周期:Namespace 的生命周期与其内部最后一个进程相关。如果没有进程了,Namespace 就会销毁。
- 持久化(特例):如果你通过
mount --bind /proc/[pid]/ns/net /var/run/netns/my-ns挂载了该文件,即使原进程退出,该 Namespace 也会因为“引用计数不为0”而继续存在(这就是ip netns命令的原理)。
2. Namespace 的四大操作工具(专家级命令)
除了 unshare,专家必须掌握以下四个底层工具:
nsenter(进入):- 通过
setns()系统调用,让当前 Shell 加入到指定进程的命名空间。 - 场景:调试一个没有 Shell 的极简镜像容器。
- 通过
lsns(枚举):- 列出当前系统中所有的 Namespaces,以及它们所属的进程数和类型。
- 场景:快速排查系统中有多少个残留的隔离空间。
ip netns(网络专项):- 专门管理 Network Namespace 的工具。
- 场景:在不启动容器的情况下测试虚拟网络拓扑。
ioctl(fd, NS_GET_USERNS):- 这是一个系统调用操作,用于发现 Namespace 之间的层级关系(例如:哪个 User Namespace 是当前 PID Namespace 的父级)。
3. 容器运行时(Container Runtime)的工作工作机制
这是最关键的部分。以 runc(Docker 底层)为例,创建一个 Namespace 并非一步到位,而是经历了一个名为 “两阶段启动(Double Fork)” 的过程。
核心流程图解
- 父进程启动 (Parent):
runc启动,解析config.json(OCI 配置)。 - 第一阶段 Clone (Bootstrap):
- 调用
clone(),设置各种CLONE_NEW*标志位。 - 内核创建出拥有新 Namespace 的子进程。
- 调用
- 初始化容器进程 (Init):
- 这个子进程被称为
nsexec阶段。它会先停下来,等待父进程完成外部配置(比如在宿主机端配置好 Veth Pair 网络、设置 Cgroups)。
- 这个子进程被称为
- 配置 User ID Mapping:如果是无根容器,此时会写入
/proc/self/uid_map。 - Final Exec:
- 子进程执行
pivot_root切换根文件系统。 - 执行
execve()运行镜像中定义的真实业务命令(如nginx或sh)。
- 子进程执行
4. 易踩坑与注意事项(避坑指南)
A. 容器内的 PID 1 信号问题
- 现象:在容器内用
kill -9 1杀不死 PID 1。 - 原因:内核对 PID Namespace 的 1 号进程有保护机制。如果 PID 1 没有注册对应的信号处理函数,内核会丢弃该信号。
- 后果:这会导致容器难以优雅停止,或者产生僵尸进程。
B. /proc 挂载传播的陷阱
- 坑点:在多层嵌套容器中,如果挂载
proc时没有加上独立的 Mount Namespace,可能会导致宿主机的/proc被意外覆盖或修改。
更多推荐



所有评论(0)