容器原理(进阶) 一


在 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(文件系统根)+Remount/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:文件系统(脚踏何处)

容器切换了进程的根目录(通过 chrootpivot_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 之后,进程看到的还是宿主机的目录。专家级的做法是:

  1. 准备一个完整的 Linux 发行版目录(Rootfs)。
  2. 使用 pivot_root 系统调用,将进程的根目录切换到这个新目录。
  3. 注意: 这比 chroot 更安全,因为它会把旧的根目录卸载掉,防止“越狱”。

B. 挂载虚拟文件系统

没有 /proc, /sys, /dev 的容器是无法正常工作的。容器引擎会自动在隔离的 Mount Namespace 里挂载这些内核虚拟文件系统,让 topps 等命令能正确显示容器内的数据。

C. 配置网络隔离 (Veth Pair)

unshare --net 之后,容器里只有 lo 回环网卡。真正的容器实现会:

  1. 在宿主机创建一对 Veth Pair(虚拟网线)。
  2. 把一端“塞进”容器的 Net Namespace。
  3. 在宿主机端把另一端挂载到网桥(如 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. 核心系统调用(三剑客)

  1. clone(): 创建新进程的同时开启隔离。这是 Docker 创建容器的入口。
  2. unshare(): 让当前进程“退群”,进入新的隔离空间。
  3. setns(): “瞬移”到另一个进程的隔离空间。这是 docker exec 的原理。

演进历程:长达 20 年的“拼图”

Namespace 并不是为了容器而突然诞生的,它经历了漫长的补完过程:

  • 2002年 (Kernel 2.4.19): Mount Namespace 诞生。最初内核开发者甚至没想过会有其他 Namespace,所以它的标志位直接叫 CLONE_NEWNS
  • 2006年 (Kernel 2.6.19): UTSIPC 隔离加入。
  • 2008年 (Kernel 2.6.24): Network Namespace 出现,容器终于可以有自己的网卡了。
  • 2008年 (Kernel 2.6.26): PID Namespace 完善。
  • 2013年 (Kernel 3.8): User Namespace 终于合入主线。这是容器安全的分水岭,实现了“容器内 root,宿主机普通用户”。
  • 2016-2020年: CgroupTime Namespace 加入,补齐了最后几块拼图。

容器使用:工业级的实现路径

目前顶尖的容器运行时(如 runc)在创建 Namespace 时遵循以下逻辑:

  1. 独立化:首先通过 clone() 隔离 UTS(主机名)和 IPC。
  2. 网络初始化:在宿主机创建 veth-pair,通过 ip link set netns 将一端塞进容器,在容器内初始化 loeth0
  3. 根切换:在 Mount Namespace 内执行 pivot_root
  4. 身份映射:如果开启了 User Namespace,会修改 /proc/self/uid_map 进行权限映射。

注意事项与容易“踩坑”点(专家经验)

Namespace 的局限性:

A. Namespace 不是全能的隔离

并不是所有内核资源都被 Namespace 隔离了。

  • 内核日志 (dmesg):容器内默认可以看到宿主机的内核日志。
  • 内核模块 (Kernel Modules):容器无法加载独立的内核模块。
  • 系统时间:虽然有了 Time Namespace,但在较旧的内核中,修改容器时间会直接修改宿主机时间。

B. 挂载传播(Mount Propagation)

这是最容易踩坑的地方。如果在宿主机挂载了一个磁盘,容器里可能看不到,除非你正确配置了 sharedslave 传播属性。

C. PID 1 的特殊性

在容器内,PID 1(你启动的程序)如果不具备初始化进程的功能(如处理僵尸进程),会导致容器内产生大量无法回收的进程。

  • namespace实验

    我们通过 setns 的逻辑,手动从宿主机进入一个正在运行的容器 Namespace,不使用 docker exec

    实验步骤:

    1. 运行一个容器docker run -d --name my-lab alpine sleep 3600
    2. 找到该容器进程的 PIDPID=$(docker inspect -f '{{.State.Pid}}' my-lab)
    3. 查看该进程的 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,专家必须掌握以下四个底层工具:

  1. nsenter (进入)
    • 通过 setns() 系统调用,让当前 Shell 加入到指定进程的命名空间。
    • 场景:调试一个没有 Shell 的极简镜像容器。
  2. lsns (枚举)
    • 列出当前系统中所有的 Namespaces,以及它们所属的进程数和类型。
    • 场景:快速排查系统中有多少个残留的隔离空间。
  3. ip netns (网络专项)
    • 专门管理 Network Namespace 的工具。
    • 场景:在不启动容器的情况下测试虚拟网络拓扑。
  4. ioctl(fd, NS_GET_USERNS):
    • 这是一个系统调用操作,用于发现 Namespace 之间的层级关系(例如:哪个 User Namespace 是当前 PID Namespace 的父级)。

3. 容器运行时(Container Runtime)的工作工作机制

这是最关键的部分。以 runc(Docker 底层)为例,创建一个 Namespace 并非一步到位,而是经历了一个名为 “两阶段启动(Double Fork)” 的过程。

核心流程图解

  1. 父进程启动 (Parent)runc 启动,解析 config.json(OCI 配置)。
  2. 第一阶段 Clone (Bootstrap)
    • 调用 clone(),设置各种 CLONE_NEW* 标志位。
    • 内核创建出拥有新 Namespace 的子进程。
  3. 初始化容器进程 (Init)
    • 这个子进程被称为 nsexec 阶段。它会先停下来,等待父进程完成外部配置(比如在宿主机端配置好 Veth Pair 网络、设置 Cgroups)。
  4. 配置 User ID Mapping:如果是无根容器,此时会写入 /proc/self/uid_map
  5. Final Exec
    • 子进程执行 pivot_root 切换根文件系统。
    • 执行 execve() 运行镜像中定义的真实业务命令(如 nginxsh)。

4. 易踩坑与注意事项(避坑指南)

A. 容器内的 PID 1 信号问题

  • 现象:在容器内用 kill -9 1 杀不死 PID 1。
  • 原因:内核对 PID Namespace 的 1 号进程有保护机制。如果 PID 1 没有注册对应的信号处理函数,内核会丢弃该信号。
  • 后果:这会导致容器难以优雅停止,或者产生僵尸进程。

B. /proc 挂载传播的陷阱

  • 坑点:在多层嵌套容器中,如果挂载 proc 时没有加上独立的 Mount Namespace,可能会导致宿主机的 /proc 被意外覆盖或修改。
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐