Linux 上的容器

容器LinuxDocker

Linux 上的容器往往被认为是轻量级的虚拟化技术,并且随着 Docker/LXC 之类的普及, 容器在成为开发和部署应用的常见方式的同时, 也给初学者带来了黑魔法的感觉

可惜感觉这方面的资料并不算太多, 很多关于容器的文章都直接跳过了容器的底层原理, 直接介绍了如何使用 Docker/LXC 等工具来创建和管理容器

于是我决定大概讲一下创建一个容器背后使用的 Linux 内核特性, 让大家进一步了解容器知道有哪些方向可以深入学习, 因此我也会贴出相关资料的来源, 如 Linux 源码路径, man page 等等, 以便大家进一步研究

BTW, 我希望先让大家从行为入手, 所以整篇文章都是从命令行先感受的

我认为主要有这四大核心 Linux 内核特性:

  1. Namespaces
  2. Cgroups
  3. UnionFS/OverlayFS
  4. pivot_root

Namespaces

Namespaces 是 Linux 内核提供的一种机制, 用于隔离不同进程的资源, 使得每个进程都认为自己拥有独立的系统资源, 包括进程 ID、网络接口、文件系统等, 并且对命名空间的资源的更改只对同一命名空间内的进程可见, 从而实现了容器的隔离性

下表显示了 Linux 上可用的命名空间类型

具体内容可查看 man 7 namespaces 的文档

Namespace TypeDescription
PID隔离进程 ID, 这个进程会认为自己是系统中唯一的进程, PID 从1开始
Net隔离网络接口, 包括网络设备, IP, 端口, 路由表等等
Mount隔离文件系统挂载点, 不是隔离文件系统(但是他是 pivot_root 发挥作用的前提, 和 pivot_root 共同实现文件系统的隔离)
IPC隔离进程间通信, 包括 System V IPC 和 POSIX 消息队列
UTS隔离主机名和域名,使得容器内的进程只能看到容器内的主机名和域名
User隔离用户和组 ID,映射容器内的 root 到宿主机的普通用户
Cgroup隔离控制组,使得容器内的进程只能看到容器内的控制组
Time隔离系统时间,使得容器内的进程只能看到容器内的系统时间

Linux 内核通过 nsproxy 结构体来管理不同类型的命名空间, 每个进程都有一个指向 nsproxy 结构体的指针, 通过这个指针可以访问到该进程所属的命名空间, 从而实现资源的隔离:

// include/linux/sched.h
struct task_struct {
    ...
    struct nsproxy *nsproxy; // 指向 nsproxy 结构体的指针, nsproxy 结构体包含了所有命名空间的指针
    const struct cred *cred;
    ...
};
 
// include/linux/nsproxy.h
struct nsproxy {
    atomic_t count;              // 引用计数(多个进程可共享同一组 ns)
    struct uts_namespace  *uts_ns;   // hostname, domainname
    struct ipc_namespace  *ipc_ns;   // IPC: semaphores, msg queues, shm
    struct mnt_namespace  *mnt_ns;   // mount points
    struct pid_namespace  *pid_ns_for_children;
    struct net            *net_ns;   // network stack
    struct time_namespace *time_ns;
    struct cgroup_namespace *cgroup_ns;
};
 
// User namespace 的实现比较特殊, 需要在用户空间进行映射, 因此在 nsproxy 结构体中没有直接指向 user namespace 的指针, 而是通过 task_struct 中的 cred 结构体来实现用户命名空间的隔离
// include/linux/cred.h
struct cred {
    ...
    struct user_namespace *user_ns;
    ...
}

PID Namespace

用命令先尝试一下 PID namespace

我们可以这样创建一个新的 PID namespace, 同时启动一个新的进程, 这个进程在新的 PID namespace 中的 PID 是 1, 但是在 Host 上看到的 PID 是一个随机分配的 PID

~/Downloads/linux-6.19
 sudo unshare --pid --fork --mount-proc bash
  # --pid        创建新的 PID namespace
  # --fork       先 fork, 让子进程成为新 namespace 的 PID 1
  # --mount-proc 挂载新的 /proc, 否则看到的还是宿主机的进程视图

进入后:

# 在新的 PID namespace 中, 该进程的 PID 是 1
root@yiki:/home/ayi/Downloads/linux-6.19# echo $$ 1
1 1

此时这个 bash 在新 namespace 里是 PID 1. 但在宿主机和容器分别执行:

# 查看当前系统中的 PID namespace
root@yiki:/home/ayi/Downloads/linux-6.19# lsns -t pid 
        NS TYPE NPROCS PID USER COMMAND
4026533262 pid       2   1 root bash
 
# 在 Host 上看到的 PID 是 371573, 但是在新的 PID namespace 中的 PID 是 1
 sudo lsns -t pid
        NS TYPE NPROCS PID USER COMMAND
4026533262 pid       1  371573 root bash
 

也可以进入已有的 PID namespace, 通过 nsenter 命令来进入一个已有的 PID namespace, 这样在新的 PID namespace 中启动的进程就会有一个新的 PID, 从而实现了进程的隔离

 nsenter --target 371573 --pid bash
# 本质: 打开 /proc/371573/ns/pid
#       调用 setns() 进入该 namespace
#       再 fork 出新的 shell

系统调用接口

#include <sched.h>
clone(CLONE_NEWPID, ...);   // 创建新的 pid namespace, 子进程进入新 namespace, 并成为 PID 1
unshare(CLONE_NEWPID);      // 仅影响之后 fork 的子进程
setns(fd, CLONE_NEWPID);    // 进入已有 namespace, fd 通常来自 /proc/<pid>/ns/pid

需要注意 CLONE_NEWPID 永远只对子进程生效~

内核实现 pid_namespace

Linux 内核通过 pid_namespace 结构体来管理 PID namespace, 该结构体包含了该 PID namespace 中的所有进程的 PID 分配信息, 包括一个 idr 结构体用于管理该 PID namespace 中的 PID 分配, 一个指向该 PID namespace 的 init 进程 (PID 1) 的指针, 以及一个指向父 PID namespace 的指针, 从而实现了 PID namespace 的嵌套和隔离

// include/linux/pid_namespace.h
struct pid_namespace {
    struct idr      idr;           // 管理该 ns 内的 pid 分配 (radix tree)
    struct rcu_head rcu;
    unsigned int    pid_allocated;
    struct task_struct *child_reaper; // 该 ns 的 init 进程 (PID 1)
    struct kmem_cache  *pid_cachep;
    unsigned int    level;         // 嵌套深度, root namespace = 0
    struct pid_namespace *parent;  // 父 ns
    ...
};

每个 PID namespace 都有自己的 PID 分配器和自己的 PID 1 通过 parent 指针形成嵌套关系

一个进程如何拥有多个 PID

// include/linux/pid.h
struct pid {
    ...
    unsigned int level;          // 该 pid 所在的最深层 namespace
    struct upid numbers[1];      // 变长数组, 每层一个 upid
    ...
};
 
struct upid {
    int nr;                      // 在该 namespace 中的 PID 数值
    struct pid_namespace *ns;    // 对应的 namespace
};

一个 task_struct 只有一个 struct pid, 但 struct pid 内部有多个 upid, 每层 namespace 一个

因此:

  • 宿主机看到 PID = 371573
  • 子 namespace 看到 PID = 1

两者对应的是同一个 task_struct, 只是 upid.nr 不同

当进程调用:

  • getppid()
  • waitpid()
  • 读取 /proc
  • 发送信号

内核会根据当前任务所在 namespace 选择对应的 upid, PID 隔离本质是“查哪个 upid”

child_reaper

child_reaper 有一些特殊的地方:

  1. 该 ns 内所有孤儿进程都会被 reparent 到它, 因此它会成为该 ns 内所有孤儿进程的父进程
  2. 如果它退出,整个 ns 内所有进程都会被 SIGKILL
Docker(runc) 中的 PID namespace
dockerd
  └── containerd
        └── containerd-shim
              └── runc  ←── 真正调用 clone(CLONE_NEWPID|...) 
                    └── container init (PID 1 in ns)
                          └── 你的进程
  • runc 通过 clone() 创建容器进程,同时传入多个 CLONE_NEW* flag
  • 容器的 PID 1 通常是你在 CMD/ENTRYPOINT 指定的进程(或 tini/dumb-init)

docker exec:

...
setns(...);  // 进入已有 namespace
fork();      // 创建新进程
...

Mount Namespace

理解 mount namespace 之前, 需要明确 Linux 的路径解析模型。进程在调用 open("/a/b/c") 时, 内核并不是简单地在某个“目录结构”里查找, 而是从当前进程的 fs_struct->root 或 fs_struct->pwd(根据是绝对路径还是相对路径) 出发, 沿着挂载树进行遍历. 如果某个路径分量是一个挂载点, 就会跳转到对应 super_block 的根 dentry, 再继续向下. 也就是说, 路径解析本质是"在当前进程可见的挂载树上做遍历"

mount namespace 隔离的, 正是这棵“挂载树”

先从行为观察一下 mount namespace

创建 Mount namespace 的方式和 PID namespace 类似, 通过 unshare 命令来创建一个新的 Mount namespace

sudo unshare --mount bash
# --mount  创建新的 Mount namespace

进入后让我们来挂载一个 tmpfs:

root@yiki:/home/ayi/Downloads/linux-6.19# mount -t tmpfs tmpfs /tmp/test
root@yiki:/home/ayi/Downloads/linux-6.19# mount | grep test
# tmpfs on /tmp/test type tmpfs (rw,relatime)

回到宿主机:

 mount | grep test
# 没有任何输出, 说明宿主机上没有挂载 /tmp/test

内核创建了 struct mount 并插入了挂载树, 只是这个 struct mount 只存在于当前 namespace 的挂载树中. 不同 namespace 拥有不同的 mnt_namespace 对象, 因此看到的挂载集合不同

系统调用接口

#include <sched.h>
 
clone(CLONE_NEWNS, ...);
// 创建新 Mount namespace
// 有趣的是 flag 名字是 CLONE_NEWNS, 不是 CLONE_NEWMOUNT
// 因为它是第一个被实现的 namespace (Linux 2.4.19, 2002年)
// 当时还没有"namespace"这个统一概念, 所以命名比较特殊
 
unshare(CLONE_NEWNS);
// 当前进程立刻进入新 namespace
// 和 PID namespace 不同: 这里对当前进程立刻生效
 
setns(fd, CLONE_NEWNS);
// 进入已有 Mount namespace
// fd 来自 /proc/<pid>/ns/mnt

unshare(CLONE_NEWNS) 对当前进程立刻生效, 这和 CLONE_NEWPID 只影响子进程不同

默认情况下, fork() 只是共享同一个 mnt_namespace, 引用计数加一. 只有在 clone(CLONE_NEWNS) 或 unshare(CLONE_NEWNS) 时, 才会调用 dup_mnt_ns() 深拷贝整棵挂载树

传播类型: 不是简单隔离

Mount namespace 还有一个特殊的概念叫做传播类型, 隔离是全有或全无太粗糙, 传播类型允许精细控制哪些挂载事件跨 namespace 同步

# 查看当前所有挂载点的传播类型
cat /proc/self/mountinfo
# 第6列会出现: shared, private, slave, unbindable

不同的传播类型满足了不同的使用场景:

内核中每个 struct mount 都维护 peer/slave 关系链表

Propagation TypeDescription
shared双向传播, 任一 peer 的挂载事件都会同步
slave单向传播,主能传给从,从的操作不传回主
private不传播,完全隔离
unbindable不传播,不能被 bind mount(禁止被其他 namespace 传播)
# 设置传播类型
mount --make-shared /mnt
mount --make-slave /mnt
mount --make-private /mnt
mount --make-unbindable /mnt
 
# 递归设置(对子挂载点也生效)
mount --make-rprivate /

有趣的是,直接 unshare --mount 之后, 挂载树默认还是 shared。如果你在里面 mount, 事件可能会传播回宿主机. 真正完全隔离, 需要

sudo unshare --mount --propagation private bash
# 或者在进入后:
mount --make-rprivate /
# --make-rprivate / 会递归地把整棵挂载树改成 private, 这是容器运行时的标准做法

内核实现

mnt_namespace

// fs/mount.h
struct mnt_namespace {
    struct mount *root;         // 根挂载点
    struct list_head list;      // 该 ns 内所有 mount 的链表
    struct user_namespace *user_ns; // 由于 mount namespace 需要操作文件系统, 因此它必须关联一个 user namespace, 以便在需要权限检查时使用
    ...
};

每个 mount namespace 有一棵独立的挂载树. 树节点类型是 struct mount:

struct mount {
    struct mount *mnt_parent;       // 父挂载点task_struct
    struct dentry *mnt_mountpoint;  // 挂载在父的哪个 dentry
    struct vfsmount mnt;            // VFS 层对象
    struct mnt_namespace *mnt_ns;   // 所属 namespace
 
    // 传播相关链表
    struct list_head mnt_share;
    struct list_head mnt_slave_list;
    struct mount *mnt_master;
};

struct vfsmount 内部指向 super_block

struct vfsmount {
    struct dentry *mnt_root;     
    struct super_block *mnt_sb;     
};
// dentry 是运行时存储在内核的目录项, 可以简化看作是 (parent dentry, name) -> inode 的内核数据结构
// 比如 路径 /a/b/c 在内核 里会被解析成一系列 dentry:
root_dentry ("/")
    └── a_dentry ("a")
            └── b_dentry ("b")
                    └── c_dentry ("c")

关系是:

mnt_namespace
    └── 多个 mount
            └── 内含 vfsmount
                    └── 指向 super_block

super_block 是文件系统实例, 全局唯一。多个 namespace 可以各自拥有自己的 mount 对象, 但这些对象可以指向同一个 super_block。因此 namespace 隔离的是“挂载关系”, 不是文件系统实例

Fork

// kernel/fork.c
copy_namespaces()
    └── copy_mnt_ns()
            └── dup_mnt_ns()

dup_mnt_ns() 会遍历父 namespace 的所有 mount, 为每个创建新的 struct mount, 重建父子关系和传播关系. 但内部的 vfsmount->mnt_sb 仍然指向同一个 super_block. 这是一种结构级别的深拷贝, 但底层文件系统共享

路径解析如何感知 namespace

// fs/namei.c
path_lookupat()
    └── set_root()        // 使用 task_struct->fs->root
    └── link_path_walk()
            └── follow_mount()

follow_mount() 会在当前进程所属的 mnt_namespace 的挂载树中查找匹配的 mount. 整个路径解析过程完全依赖当前进程的 namespace. 因此

两个不同 mount namespace 的进程, 即使执行 open("/etc/passwd"), 也可能得到完全不同的文件

Bind Mount

Mount namespace 的一个核心能力是 bind mount, 把目录树的一部分"重新挂载"到另一个位置, 类似于复制一个挂载点:

mount --bind /src /dst
# /dst 现在和 /src 看到同样的内容
# 本质是创建一个新的 struct mount, 指向同一个 super_block
# inode 完全共享, 一方修改另一方立刻可见
 
mount --rbind /src /dst
# 递归 bind, 连 /src 下的子挂载点也一起复制过来

容器里大量使用 bind mount:

# Docker 把宿主机目录挂进容器
docker run -v /host/data:/container/data nginx
# 等价于在容器的 mount namespace 里执行
# mount --bind /host/data /container/data

相关命令

# 查看挂载树(比 mount 更详细)
findmnt
findmnt --all
findmnt -o TARGET,PROPAGATION   # 查看传播类型
 
# 查看原始 mountinfo(内核直接导出)
cat /proc/self/mountinfo
cat /proc/<pid>/mountinfo        # 某个进程视角的挂载树
 
# 查看所有 mount namespace
lsns -t mnt
# 判断两个进程是否在同一个 ns: 看 inode 是否相同
ls -la /proc/1/ns/mnt /proc/<pid>/ns/mnt
 
# 进入某个进程的 mount namespace
nsenter --target <pid> --mount bash
nsenter -t <pid> -m bash

容器的实现

容器启动时,runc 用 mount namespace 构造出一个完全独立的文件系统视图:

clone(CLONE_NEWNS | ...)

  ├── mount --make-rprivate /        ← 先把整棵挂载树变成 private
  │                                     防止容器内的挂载传播到宿主机

  ├── mount --bind <overlay_root> /  ← 挂载 overlayfs 作为容器根文件系统 /

  ├── mount --bind /host/data /data  ← 处理 -v 指定的 volume

  ├── mount -t proc proc /proc       ← 挂载新的 /proc (配合 PID ns)
  ├── mount -t sysfs sysfs /sys
  ├── mount -t tmpfs tmpfs /dev

  └── pivot_root new_root put_old    ← 切换根目录
      (或 chroot)                       之后 / 就是容器的文件系统

pivot_root 会在下文有具体解释: chroot 只改变路径解析的起点,原来的根文件系统还挂着;pivot_root 真正替换 /,把旧根移到 put_old,之后可以 umount 掉它,让容器完全看不到宿主机的文件系统

OverlayFS 和 Mount Namespace 的关系

Docker 镜像的分层存储依赖 overlayfs, OverlayFS 是一种联合文件系统, 允许把多个目录叠加在一起形成一个新的目录视图, 常用于容器的根文件系统构建, 它本身也是 mount namespace 的一个挂载

mount -t overlay overlay \
  -o lowerdir=/image/layer2:/image/layer1:/image/layer0,\
     upperdir=/container/rw,\
     workdir=/container/work \
  /merged
 
# lowerdir: 只读的镜像层 (可以多层叠加)
# upperdir: 容器的读写层
# merged:   最终呈现给容器的视图
  • 读文件:从上往下找, upperdir 优先
  • 写文件:copy-on-write,先把文件从 lowerdir 复制到 upperdir, 再修改
  • 删文件:在 upperdir 创建 whiteout 文件(特殊标记),遮盖 lowerdir 里的原文件

整个机制没有复制文件系统, 所有容器共享同一份镜像层的数据, 只是 mount namespace 给每个容器呈现了不同的视图

隔离文件系统?

看到这里, 你可能还不知道 mount namespace 在容器中到底有什么用, 下面是一些具体的例子, 他只是隔离了 mount 的传播, 并没有达到原本想象中的“每个容器都有一个独立的文件系统”那样的程度, 但是对于后面真正实现文件系统隔离的 pivot_root 来说, mount namespace 是一个必要的前提条件


Net namespace

行为观察

命令行试试
# 创建一个新的 net namespace(命名为 myns)
sudo ip netns add myns
 
# 在新 ns 里执行命令
sudo ip netns exec myns bash
 
# 查看网络接口
ip link
# 1: lo: <LOOPBACK> mtu 65536 ...   ← 只有 lo,且是 DOWN 状态
# 宿主机的 eth0、iptables 规则、路由表全都不见了

在宿主机上

ip link
# 1: lo: ...
# 2: eth0: ...          ← 宿主机正常的网卡
# 完全看不到 myns 里发生了什么

Net namespace 隔离的不只是网卡, 而是整个网络栈:网卡、IP 地址、路由表、iptables 规则、socket、甚至 /proc/net 下的所有文件

系统调用接口
clone(CLONE_NEWNET, ...);
// 创建新的 Net namespace
// 新 ns 只有一个 lo,且默认 DOWN
 
unshare(CLONE_NEWNET);
// 当前进程立刻进入新的 Net namespace
// 和 Mount namespace 一样,对当前进程立刻生效
 
setns(fd, CLONE_NEWNET);
// 进入已有 Net namespace
// fd 来自 /proc/<pid>/ns/net

新建的 net namespace 是完全空的: 只有一个 loopback 设备, 没有路由, 没有 iptables 规则, 所有 socket 操作都在这个隔离的栈里进行

网络设备的归属

每个网络设备(net_device)只能属于一个 net namespace, 可以在 namespace 之间移动:

# 把宿主机的 eth1 移入 myns
sudo ip link set eth1 netns myns
 
# 此后宿主机上 eth1 消失,myns 里才能看到它
# 物理网卡是真实硬件,所以这种移动要谨慎
 
# veth 才是容器场景的标准做法(见下文)

物理网卡可以移入 namespace, 但通常不这么做. 容器场景几乎全部使用 veth pair

veth pair:连接两个 namespace

隔离之后需要通信怎么办?

内核提供 veth(virtual ethernet), 成对创建, 两端各在一个 namespace, 数据从一端进, 从另一端出, 像一根虚拟网线:

# 创建 veth pair(veth0 和 veth1 是一对)
sudo ip link add veth0 type veth peer name veth1
 
# 把 veth1 移入 myns
sudo ip link set veth1 netns myns
 
# 宿主机配置 veth0
sudo ip addr add 10.0.0.1/24 dev veth0
sudo ip link set veth0 up
 
# myns 里配置 veth1
sudo ip netns exec myns ip addr add 10.0.0.2/24 dev veth1
sudo ip netns exec myns ip link set veth1 up
sudo ip netns exec myns ip link set lo up
 
# 现在可以 ping 通
sudo ip netns exec myns ping 10.0.0.1

内核实现

Linux 内核通过 net_namespace 结构体来管理 Net namespace, 该结构体包含了该 Net namespace 中的所有网络设备、IP 地址、路由表、iptables 规则等网络资源的管理信息, 以及一个指向父 Net namespace 的指针,从而实现了 Net namespace 的嵌套和隔离

该结构是所有 namespace 中最复杂的一个, 包含了大量网络协议栈相关的数据结构, 以及网络设备的管理数据结构

// include/net/net_namespace.h
struct net {
    refcount_t          passive;      
    // 引用计数
    // 引用来源: 1. task_struct → nsproxy → net_ns
    // 2. /proc/<pid>/ns/net 打开的 fd
    // 3. bind mount
    struct list_head    list;         // 全局 netns 链表
 
    struct list_head    dev_base_head; // 本 ns 下所有 net_device
 
    struct netns_ipv4   ipv4;
    struct netns_ipv6   ipv6;
    struct netns_nf     nf;           // netfilter
    struct netns_xt     xt;
 
    struct sock         *rtnl;        // rtnetlink socket
    struct net_device   *loopback_dev;
};

init_net 是根 namespace,全局唯一,内核启动时初始化,所有"宿主机"的网络操作都在这里

但重点不在字段, 而在 per-net 机制

per-net 机制: 协议栈按 namespace 实例化 内核里每个网络子系统在初始化时都会注册:

// include/net/net_namespace.h
struct pernet_operations {
    int  (*init)(struct net *net);
    void (*exit)(struct net *net);
};
// 注册方式
register_pernet_subsys(...)

每创建一个新的 net namespace, 内核就会调用所有注册的 pernet_operations->init() 来为该 namespace 初始化协议栈相关的数据结构, 这样每个 namespace 就拥有了独立的网络资源

例如:

  • IPv4 路由表
  • TCP/UDP 哈希表
  • conntrack 表
  • nf_tables
  • /proc/net 视图
  • sysctl(/proc/sys/net)

net namespace 是如何隔离 socket 的? socket 结构体里有一个指向所属 net namespace 的指针, 内核在处理 socket 相关的系统调用时, 会根据当前进程所属的 net namespace 来选择对应的协议栈数据结构, 从而实现 socket 的隔离.

// include/net/sock.h
struct sock {
    struct net *sk_net;
};
// socket 创建时:
sk->sk_net = current->nsproxy->net_ns;

之后:

  • bind()
  • listen()
  • connect()
  • 路由查找
  • netfilter hook
  • conntrack 全部通过: sk->sk_net 查找对应 namespace 的数据结构

网络设备的 namespace 归属

  • 每个网络设备(net_device)都有一个指向所属 net namespace 的指针, 内核在处理网络设备相关的系统调用时, 会根据当前进程所属的 net namespace 来选择对应的网络设备, 从而实现网络设备的隔离.
  • 当一个网络设备被移动到一个新的 net namespace 时, 内核会更新该设备的 sk_net 指针, 使其指向新的 namespace 的数据结构, 从而实现网络设备的隔离
// include/linux/netdevice.h
struct net_device {
    ...
    struct net *nd_net; // 该设备所属的 net namespace
    ...
};
// 移动设备
dev_change_net_namespace(dev, new_net, ...)

实现步骤:

  1. 从旧 net 的 dev_base_head 删除
  2. 插入新 net 的 dev_base_head
  3. 重新注册 sysfs/proc
  4. 更新 ifindex

内核里 veth 的实现 veth 仍然只是两个普通的 net_device

// drivers/net/veth.c
struct veth_priv {
    struct net_device __rcu *peer;
};
// 创建后
// veth0 → net A
// veth1 → net B
// 他们的 peer 指针互相指向对方, 这样发送数据时就可以直接把 skb 从一端放到另一端

发送数据的流程

tcp_sendmsg
  → ip_queue_xmit
      → dev_queue_xmit(veth0)
           → veth_xmit

其中在 veth_xmit 中, 会有这样的代码:

// drivers/net/veth.c
rcv = priv->peer;   // 找到对端
skb->dev = rcv;     // 切换设备!!!, 

发送阶段仍在 namespace A, 一旦 skb->dev 被替换, 后续接收路径完全在 namespace B 中执行

Docker 中的 Net namespace

单个 veth pair 只能点对点, 因此 Docker 还会创建一个 Linux bridge(软件模拟的二层交换机), 把所有容器的 veth 都连接到这个 bridge 上, 这样它们就能互相通信了

Rendering diagram...
# Docker 启动时自动创建 docker0
# 让我们手动还原这个过程:
 
# 创建 bridge
ip link add docker0 type bridge
ip addr add 172.17.0.1/16 dev docker0
ip link set docker0 up
 
# 为每个容器创建 veth pair
ip link add veth_a type veth peer name eth0 netns ns_A
ip link set veth_a master docker0    # 把宿主机端的 veth 插入 bridge
ip link set veth_a up
 
# ns_A 内配置
ip netns exec ns_A ip addr add 172.17.0.2/16 dev eth0
ip netns exec ns_A ip route add default via 172.17.0.1 # 设置默认路由指向 bridge, 这样网络包会先从 veth pair 发送到 bridge, 再由 bridge 转发到目标容器

Bridge 工作在 L2,做 MAC 地址学习和转发. 容器之间走 bridge 直接转发,容器访问外网走 NAT

runc 创建容器时网络部分的流程(CNI 介入之前):

runc
  └── clone(CLONE_NEWNET | ...)

        ├── 容器进程进入新的 net namespace
        │   此时只有 lo (DOWN)

        └── 返回给 runtime,通知网络插件介入(网络插件的作用主要是配置网络设备、IP 地址、路由表等, 让容器能够联网)
 
CNI plugin(比如 bridge)
  ├── 在宿主机 ns 创建 veth pair (vethXXXX, eth0)
  ├── ip link set eth0 netns <container-pid>
  ├── 把 vethXXXX 插入 cni0 bridge
  ├── 进入容器 ns 配置 eth0 的 IP、路由、lo UP
  └── 在宿主机 ns 配置必要的 iptables 规则

CNI(Container Network Interface) 是一套标准接口,runc 不关心网络细节, 只负责创建空的 net namespace, 具体的连线工作全部委托给 CNI plugin

CNI pluhins 都是建立在这一套 namespace + veth + bridge 的基础设施上的, 只是配置不同:

  • 路由怎么配
  • NAT 怎么做
  • 是否使用 overlay
  • 是否使用 eBPF

Kubernetes 里每个 Pod 内的容器共享同一个 net namespace(但各自有独立的 mount/pid namespace):

在 Kubernetes 中, 创建一个 Pod 在启动我们需要的容器之前, 会先建立一个 pause 容器, 这个 pause 容器的作用就是持有 net namespace(net namespace 的引用计数不会为 0 被销毁), 业务容器重启不会导致网络配置丢失


User Namespace

User namespace 是 Linux 内核提供的一种机制, 用于隔离用户和组 ID, 使得容器内的进程只能看到容器内的用户和组 ID

行为观察

命令行

unshare --user --map-root-user bash
# --user 创建新的 user namespace
# 查看当前 uid
id
# uid=0(root) gid=0(root)
 
# 但在宿主机上
id
# 记下 pid
 
# 宿主机查看
cat /proc/<pid>/status
# 会看到真实 uid 仍然是普通用户

新 namespace 里的 root 只是 namespace 内的 root

背后是有一层 uid 映射:

# 在新的 user namespace 中执行
cat /proc/self/uid_map
#         inside  outside  length
#         0       1000     1
# 表示 namespace 里的 0 → 宿主机的 1000

系统调用接口

clone(CLONE_NEWUSER, ...);
// 创建新的 user namespace
 
unshare(CLONE_NEWUSER);
// 当前进程进入新的 user namespace
 
setns(fd, CLONE_NEWUSER);
// 进入已有 user namespace

net namespace 不同的是:

  • user namespace 不继承父 namespace 的权限
  • 必须手动写 uid_map / gid_map 才能使用

内核结构

// include/linux/user_namespace.h
struct user_namespace {
    struct uid_gid_map  uid_map;
    struct uid_gid_map  gid_map;
 
    struct user_namespace *parent;
 
    kuid_t              owner;
    kgid_t              group;
 
    struct ucounts      *ucounts;
    atomic_t            count;
};
// 真正的关键在:
struct uid_gid_map {
    unsigned int nr_extents; // 有多少映射规则, 0 表示没有任何映射
    struct uid_gid_extent extent[UID_GID_MAP_MAX_EXTENTS];
};
 
// 每一条 extent:
struct uid_gid_extent {
    u32 first;        // namespace 内起始 UID
    u32 lower_first;  // 父 namespace 中起始 UID
    u32 count;        
    // 映射数量, 0 表示该 extent 无效
    // example: first=0, lower_first=1000, count=1 表示 namespace 内的 0 映射到父 namespace 的 1000
    // 若 conut = 5, 则 namespace 内的 0-4 映射到父 namespace 的 1000-1004
};

当内核做权限判断时: namespace uid → 转换为 kuid_t(是一个包装类型, 表示 namespace 内的 uid, 只有一个字段 uid_t uid 的结构体) → 再映射到真实 uid 核心转换函数:

kuid_t make_kuid(struct user_namespace *ns, uid_t uid);

创建流程

copy_namespaces
  → create_user_ns

关键函数:

// include/linux/user_namespace.h
struct user_namespace *create_user_ns(...)

流程:

  • 分配 struct user_namespace
  • 设置 parent 指针
  • 初始化 uid/gid 映射为空
  • 设置 owner = 当前用户
  • 增加引用计数

新 user namespace 创建后: 进程在 namespace 里 uid=0, 但没有任何 capability, 必须配置映射后才能获得有效权限

User namespace 的嵌套

user namespace 可以嵌套, 子 namespace 的 root 权限不能超过父 namespace 的权限, init_user_ns 是最顶层, 宿主机的 root 属于 init_user_ns

User namespace 和其他 namespace 的关系

  • user namespace 是其他 namespace 的基础, 其他 namespace 都需要 user namespace 来管理权限
  • 每个 namespace 结构体里都有一个指向所属 user namespace 的指针, 内核在处理 namespace 相关的系统调用时, 会根据当前进程所属的 user namespace 来进行权限检查, 从而实现 namespace 的隔离, 比如 mount/net namespace 中会有管理设备的操作, 内核会检查当前进程在 user namespace 中是否有 CAP_SYS_ADMIN 权限, 从而决定是否允许该操作

通过调用:

// include/linux/capability.h
ns_capable(current_user_ns(), CAP_XXX) // 检查当前进程在所属 user namespace 中是否有 CAP_XXX 权限

例如: 在新 user namespace 中:

mount -t proc proc /mnt # 可以成功
mount /dev/sda1 /mnt # 会失败, 因为挂载块设备需要 CAP_SYS_ADMIN 权限, 但新 namespace 的 root 没有这个权限, block device 属于 init_user_ns, 除非你把容器中的 root 映射到宿主机的 root, 否则是没有任何权限的

容器中 User namespace 的作用 -> rootless containers

User namespace 的一个重要应用场景是实现 rootless containers, 也就是容器内的进程以 root 身份运行, 但在宿主机上却是普通用户, 这样即使容器被攻破, 攻击者也无法获得宿主机的 root 权限, 大大提升了安全性


CGroups

行为观察

# 挂载点通常在 /sys/fs/cgroup
ls /sys/fs/cgroup
# cgroup.controllers  cgroup.procs  memory.current  cpu.stat ...
 
# 创建一个新的 cgroup(就是创建一个目录)
mkdir /sys/fs/cgroup/mygroup
 
# 内核自动填充控制文件
ls /sys/fs/cgroup/mygroup
# cgroup.procs  cgroup.controllers  memory.max  cpu.max ...
 
# 限制内存为 100MB
echo $((100 * 1024 * 1024)) > /sys/fs/cgroup/mygroup/memory.max
 
# 把当前 shell 加入这个 cgroup
echo $$ > /sys/fs/cgroup/mygroup/cgroup.procs
 
# 验证
cat /sys/fs/cgroup/mygroup/memory.current
# 实时显示该 cgroup 当前内存用量

Cgroups 的所有操作都通过文件系统完成, 没有专用系统调用. 内核实现了一个文件系统 cgroupfs, 读写文件就是在操作内核数据结构

内核实现

// include/linux/cgroup-defs.h
 
// 整个 cgroup 子系统的根
struct cgroup_root {
    struct kernfs_node  *kf_root;   // 对应文件系统VFS中 /sys/fs/cgroup
    struct cgroup       cgrp;       // 根 cgroup
    unsigned int        subsys_mask; // 挂载了哪些子系统, cgroups 可以同时支持多个子系统
    char                name[MAX_CGROUP_ROOT_NAMELEN];
};
 
// 一个 cgroup 节点(对应一个目录)
struct cgroup {
    struct cgroup_subsys_state __rcu *subsys[CGROUP_SUBSYS_COUNT];
    // 每个子系统(memory、cpu...)在这个 cgroup 里的状态, 但是这个只是基础的一些状态
 
    struct cgroup_root  *root;      // 所属的 root
    struct kernfs_node  *kn;        // 对应的文件系统中的目录项
 
    struct list_head    self;       // 链入父节点 children
    struct list_head    children;   // 子 cgroup 链表
    struct cgroup       *parent;    // 父 cgroup
 
    u64                 id;         // cgroup ID,全局唯一
    int                 level;      // 层级深度
 
    struct cgroup_file  events_file; // cgroup.events
    struct cgroup_file  procs_file;  // cgroup.procs
};
  • cgroup_root 是整个 cgroup 子系统的入口, 类似一个 cgroupfs 挂载点
  • cgroup 对象对应每个目录,也就是一个具体的控制组
  • children / parent 链表维护层级关系
  • 每个子系统在这个 cgroup 下都有自己的 cgroup_subsys_state, 表示当前 cgroup 在该子系统里的状态, 例如 memory 子系统会在这里记录当前内存用量和限制
 
// 每个子系统在某个 cgroup 里的状态基类
struct cgroup_subsys_state {
    struct cgroup       *cgroup;    // 所属 cgroup
    struct cgroup_subsys *ss;       // 所属子系统
    refcount_t          refcnt;
    struct cgroup_subsys_state *parent;
    unsigned long       flags;
};

每个子系统(memory、cpu、io...)都会嵌入 cgroup_subsys_state 作为基类, 扩展自己的数据:

例如 memory 子系统会额外保存内存用量、回收状态、LRU 链表

// mm/memcontrol.c
struct mem_cgroup {
    struct cgroup_subsys_state css;  // 基类,必须是第一个字段
 
    struct page_counter memory;      // 当前用量和上限
    struct page_counter swap;
    struct page_counter memsw;
 
    MEMCG_PADDING(_pad1_);
    struct memcg_vmstats_percpu __percpu *vmstats_percpu;
 
    struct mem_cgroup_reclaim_iter iter;  // 内存回收迭代器
    struct lruvec       lruvec;      // 该 cgroup 的 LRU 链表
    ...
};
  • 内核在进程申请内存时, 会通过 mem_cgroup_charge() 对应 cgroup 的 page_counter 扣配额
  • 超过 memory.high 会触发同步回收; 超过 memory.max 会触发 OOM

进程与 cgroup 的关联

// include/linux/sched.h
struct task_struct {
    ...
    struct css_set __rcu *cgroups;  // 指向进程在各子系统里的状态集合
    struct list_head cg_list;       // 链入 css_set 的进程列表, 等等... 什么是 css_set?
    ...
};
 
struct css_set {
    struct cgroup_subsys_state *subsys[CGROUP_SUBSYS_COUNT];
    // subsys[memory_cgrp_id] → 该进程所在 cgroup 的 mem_cgroup
    // subsys[cpu_cgrp_id]    → 该进程所在 cgroup 的 task_group
    // ...
 
    refcount_t      refcount;       // 多个进程可共享同一个 css_set
    struct list_head tasks;         // 属于这个 css_set 的所有进程
    struct hlist_node hlist;        // 全局哈希表
};
  • 为什么不直接在 task_struct 指向 cgroup? 因为如果多个进程在所有子系统的 cgroup 归属都一样, 它们共享一个 css_set, 节省内存
  • css_set 就是“进程在各个子系统中的 cgroup 状态集合”, 一个对象代表多个进程

子系统(controllers) 每个子系统是一个独立模块, 实现统一接口:

struct cgroup_subsys {
    struct cgroup_subsys_state *(*css_alloc)(struct cgroup_subsys_state *);
    // 分配子系统私有数据(创建新 cgroup 时)
 
    void (*css_free)(struct cgroup_subsys_state *);
 
    int  (*can_attach)(struct cgroup_taskset *);
    void (*attach)(struct cgroup_taskset *);
    // 进程加入 cgroup 时的回调
 
    void (*fork)(struct task_struct *);
    // fork 时子进程自动继承 cgroup
 
    void (*exit)(struct task_struct *);
    void (*release)(struct task_struct *);
 
    struct cftype *dfl_cftypes;     // 该子系统暴露的文件列表
    const char *name;
    int id;
};

他们大概的结构就是这样的

Rendering diagram...

其中: cgroup_subsys_state -> 每个子系统在该 cgroup 的状态, 你可能会迷惑不是说是有各自的子系统对象吗? 其实 cgroup_subsys_state 是一个基类, 每个子系统在这个基础上扩展自己的状态数据, 他们在 cgroup 里是以数组的形式存储的, 通过子系统 ID 来索引到对应的状态对象

比如说在 struct cgroup 里有一个 subsys[CGROUP_SUBSYS_COUNT] 数组, 其中 subsys[memory_cgrp_id] 就是该 cgroup 在 memory 子系统里的状态对象, 这个对象的实际类型是 mem_cgroup, 但在 cgroup 里以 cgroup_subsys_state 的形式存储, 通过 css->subsys[memory_cgrp_id] 来访问, 但是会被强制转换为 mem_cgroup 来使用

子系统控制对象文件
memory内存、swapmemory.max memory.current memory.high
cpuCPU 时间分配cpu.max cpu.weight cpu.stat
cpuset绑定到指定 CPU 核/NUMA 节点cpuset.cpus cpuset.mems
io磁盘 IO 带宽、IOPSio.max io.weight io.stat
pids进程数量上限pids.max pids.current
freezer暂停/恢复整个 cgroupcgroup.freeze
  1. 创建 cgroup -> 内核创建一个 cgroup 对象, 挂到父 cgroup 的 children 链表上
  2. 写入 cgroup.procs -> 内核通过 css_set 将进程加入各子系统的 cgroup
  3. 进程操作资源 -> 通过 css_set 找到对应子系统的状态对象执行限制或计量

以 Memory cgroup 为例

当进程申请内存时, 内核在 mem_cgroup 里计费:

// mm/memcontrol.c
int mem_cgroup_charge(struct folio *folio, struct mm_struct *mm, gfp_t gfp)
{
    // 找到当前进程所在的 mem_cgroup
    // 调用 page_counter_try_charge() 尝试扣减配额
    // 如果超过 memory.high → 触发同步回收
    // 如果超过 memory.max → 触发 OOM
    //   → oom_kill_process() 选一个进程杀掉
}

OOM 发生在 cgroup 内部:内核在该 cgroup 里选 oom_score 最高的进程杀掉,不影响其他 cgroup

# 查看 OOM 事件
cat /sys/fs/cgroup/mygroup/memory.events
# oom 3          ← 发生了 3 次 OOM
# oom_kill 3     ← 杀掉了 3 个进程

容器中的实现

Docker 启动容器时(docker run --memory=512m --cpus=1.5):

dockerd
  └── containerd
        └── runc
              ├── clone(CLONE_NEWPID | CLONE_NEWNET | ...)  ← namespace

              └── 配置 cgroup(在容器进程启动前):
                    mkdir /sys/fs/cgroup/system.slice/docker-<id>.scope
                    echo 536870912 > memory.max          # 512MB
                    echo "150000 100000" > cpu.max       # 1.5 核
                    echo <container-pid> > cgroup.procs  # 加入

Kubernetes 里 cgroup 层级更深:

/sys/fs/cgroup/
  └── kubepods/
        └── burstable/                    # QoS 类型
              └── pod<uid>/               # Pod 级别
                    ├── memory.max        # Pod 总内存限制
                    └── <container-id>/   # 容器级别
                          └── memory.max  # 单容器内存限制

pivot_root

感谢你看到这里! 但是你可能还有最后的一点疑惑, 在使用 Container 时, 进入一个容器全新的文件系统往往是最先让人感觉隔离的, 究竟是怎么做到的?

你用 clone(CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | ...) 创建了一个新进程,namespace 都隔离好了, 但是

# 容器进程启动后,ls /
bin   boot  dev  etc  home  lib  ...  var
# 看到的还是宿主机的根文件系统

namespace 隔离了进程视图、网络栈、挂载点,但没有给容器一个独立的文件系统内容. 我们希望容器在自己的文件系统中工作, 用自己的/usr, /etc, 自己的库文件, 这就是 pivot_root 解决的问题

让我们先从最初的方案讲起...

chroot

最朴素的解法是 chroot:

直接手动试一下:

# 准备一个最小根文件系统
mkdir /tmp/myroot
# 把 busybox 静态二进制放进去
mkdir -p /tmp/myroot/{bin,proc,sys,dev}
cp /bin/busybox /tmp/myroot/bin/sh
 
# 切换根目录
sudo chroot /tmp/myroot /bin/sh
 
# 现在 ls /
# bin  dev  proc  sys   ← 只看到 myroot 里的内容

chroot 修改的是进程的 fs->root,路径解析从这里开始,看不到上层目录

你可能忘了 fs->root 是什么了, 他是位于 task_struct 的一个字段, 用于路径解析的

struct task_struct {
    struct fs_struct		*fs; // 用于路径解析的字段
    ...
}
 
struct fs_struct {
	struct path root, pwd; // 用于路径解析
    ...
} __randomize_layout;

这就足够实现文件系统隔离了吗? 未必, chroot 只是修改了路径解析的起点, 但原来的根文件系统仍然挂着, 容器内的进程仍然可以通过一些特殊手段访问到宿主机的文件系统, 例如:

  • 通过 /proc/self/mounts 找到宿主机的挂载点
  • 通过 /proc/self/fd 打开宿主机的文件描述符
  • 通过 chroot(".") 切换回宿主机的根目录
  • 如果有 CAP_SYS_ADMIN 权限, 还可以重新挂载宿主机的文件系统到容器内

本质原因还是: chroot 只是修改了路径解析的起点, 但没有真正替换根文件系统, 容器内的进程仍然可以通过一些特殊手段访问到宿主机的文件系统

pivot_root: 真正替换根文件系统

真正的解决方案是 pivot_root,它会把当前根文件系统替换成一个新的目录, 旧的根文件系统被移动到另一个位置, 之后容器内的进程只能看到新的根文件系统, 完全看不到宿主机的文件系统了

# 完整流程
NEW_ROOT=/tmp/myroot
mkdir -p $NEW_ROOT/oldrootfs
 
# 关键:new_root 必须是一个挂载点
# 最简单的办法是 bind mount 到自身
# mount --bind A B 的语义是, 在 VFS 层创建一个新的 mount point, 让路径 B 指向和 A 相同的 inode 树
# 但如果目标和源是同一个目录, 就相当于给这个目录创建了一个新的挂载点
sudo mount --bind $NEW_ROOT $NEW_ROOT
 
# 切换
cd $NEW_ROOT
sudo pivot_root . ./oldrootfs
 
# 现在 / 是 myroot
# 旧根在 /oldrootfs, 你可以在这里访问宿主机的文件系统, 但正常情况下你应该禁止访问
ls /oldrootfs
# bin  boot  dev  etc  home  lib  ...  var
 
# 卸载旧根
sudo umount /oldrootfs
sudo rmdir /oldrootfs
 
# 现在完全看不到宿主机文件系统了
ls /

为什么 new_root 必须是挂载点

这是 pivot_root 最让人困惑的要求; 原因在于内核的挂载树结构

pivot_root 操作的对象是 struct mount(挂载点), 不是目录, 它要做的是把两个 struct mount 在树里换位置:

操作前:
  mount(/) → 宿主机 ext4
    └── mount(/tmp/myroot) → 如果是挂载点的话
 
操作后:
  mount(/) → 原来的 /tmp/myroot
    └── mount(/oldrootfs) → 原来的宿主机 ext4
主要是改变了两个 mount的 mnt->root 和 mnt->mnt_parent 指针, 让它们在挂载树里交换位置

如果 new_root 不是一个挂载点, 那么它就不是一个 struct mount, 也就无法在内核的挂载树里进行位置交换, 这就是为什么必须先 bind mount 一下, 给它创建一个新的挂载点, 这样它才有了 struct mount 对象, 才能被 pivot_root 操作

为什么需要 Mount Namespace

到这里可能会问:pivot_root 不是已经切换根了吗, 为什么还需要 Mount Namespace?

让我们先看看如果没有 Mount Namespace 会发生什么:

没有 Mount Namespace 时:
 
  宿主机进程 A         容器进程 B
       │                    │
       └──── 共享同一个 ────┘
             挂载命名空间(mount 树是共享的)
 
 
容器进程 B 执行 pivot_root 之后,
宿主机进程 A 的根也变了

Mount Namespace 给每个容器一份独立的挂载树拷贝,pivot_root 在这份拷贝上操作,完全不影响宿主机


OverlayFS

OverlayFS 是 Linux 内核提供的一种联合文件系统, 它将多个不同的底层挂载点合并为一个,从而形成一个包含所有来源的底层文件和子目录的单一目录结构

行为观察

# 准备目录结构
mkdir -p /tmp/overlay/{lower,upper,work,merged}
 
# lower: 只读层,放一些基础文件
echo "from lower" > /tmp/overlay/lower/base.txt
echo "original" > /tmp/overlay/lower/shared.txt
 
# 挂载 overlayfs
mount -t overlay overlay \
  -o lowerdir=/tmp/overlay/lower,\
     upperdir=/tmp/overlay/upper,\
     workdir=/tmp/overlay/work \
  /tmp/overlay/merged
 
# 查看 merged(同时看到两层的内容)
ls /tmp/overlay/merged
# base.txt  shared.txt

此时这几个目录的结构如下:

目录作用
lower只读层, 基础数据
upper可写层, 存放修改结果
work内部事务目录
mergedoverlayfs 挂载点(新文件系统)

merged 是一个独立文件系统实例, upper/lower 只是它的后端存储, 也就是说, 通过操作 merged 目录, OverlayFS 会对读写操作的处理进行特殊处理, 读操作会先在 upper 查找, 没有才去 lower; 写操作会把文件 copy-up 到 upper, 然后修改; 删除操作会在 upper 创建一个 whiteout 文件来遮盖 lower 的文件, 具体读写 lower 和 upper 是通过调用具体的文件系统的接口来实现的, 这就是为什么 lower 可以是 ext4, upper 可以是 tmpfs, merged 就是 overlayfs 的原因

stat -f /tmp/overlay/merged 
# Type: overlayfs

现在我们在 merged 里修改 shared.txt

echo "modified in merged" > /tmp/overlay/merged/shared.txt
cat /tmp/overlay/lower/shared.txt
# original          ← lower 完全没动
 
cat /tmp/overlay/upper/shared.txt
# modified          ← 修改出现在 upper 里
 
# 删除一个文件
rm /tmp/overlay/merged/base.txt
ls /tmp/overlay/lower/
# base.txt          ← lower 还在
 
ls -la /tmp/overlay/upper/
# c--------- base.txt   ← whiteout 文件(字符设备,major:minor=0:0)

lower 层永远不会被修改, 所有写操作都发生在 upper 层. 这就是 Docker 镜像层共享的基础

merged(用户看到的视图)

   ├── 读文件:upper 优先,upper 没有才去 lower 找
   ├── 写文件:copy-up 到 upper,再修改
   └── 删文件:在 upper 创建 whiteout,遮盖 lower
 
upper(可写层,容器的读写层)
   │   存放:新建的文件、修改过的文件副本、whiteout

lower(只读层,镜像层,可以多个)
       存放:基础文件系统内容

具体的实现细节可以参考内核源码 fs/overlayfs/ 目录下的代码, 以及相关的 VFS 代码, 主要涉及到文件系统的挂载、路径解析、文件读写、删除等操作在 overlayfs 中是如何处理的

Docker 中的应用

在 Docker 中:

  • 每个镜像层 = 一个 lowerdir
  • 容器看到的根目录 = merged(读写都是操作 merged, 由 overlayfs 负责把读写分发到对应的 lower/upper)

示例

FROM ubuntu:22.04
RUN apt update
RUN apt install -y curl
COPY app /app

构建时内部大致流程如下:

先构建镜像层

layer0: ubuntu:22.04 镜像层, 作为 lowerdir

RUN apt update
  1. 创建一个临时容器
  2. 挂载 overlayfs
  3. 等价于
mount -t overlay overlay \
-o lowerdir=layer0,\
 upperdir=tmp_layer1_diff,\
 workdir=tmp_layer1_work \
tmp_layer1_merged

然后进行修改:

  1. 先通过 pivot_root 切换到 tmp_layer1_merged
  2. 执行 apt update, apt update 会修改 upper 里的文件, 但 lower 完全不动
  3. 临时 merged/work 删除.
  4. 此时这个tmp_layer1_merged 就是 layer1 镜像层的内容了, 之后会被提交成一个新的镜像层, 作为 layer2 的 lowerdir
RUN apt install -y curl

同样的流程, 但这次的 lowerdir 是 layer0 + layer1 一直执行同样的操作, 只要文件系统发生变化, 就会产生新层.....

内核中的实现

在内核里, overlayfs 实现挂载的关键数据结构在 fs/overlayfs/:

struct ovl_fs {
    struct path upperpath;          // upper 目录
    struct path workdir;            // workdir
    struct path *lowerstack;        // 下层目录数组 (支持多 lower)
    int numlower;                   // lower 层数量
    struct super_block *sb;         // overlayfs superblock
};

以及 overlay inode、dentry

// 每一个 overlay 中的文件对应的 inode 结构体
struct ovl_inode {
    struct inode vfs_inode;         // overlay 层 inode
    struct inode *lower;            // 指向 lower inode, 可能为 NULL
    struct inode *upper;            // 指向 upper inode, 可能为 NULL
};
  • overlay_inode 封装了 upper/lower inode
  • VFS 调用 overlayfs 的 inode_operations / file_operations 时, overlay 内核会转发到 upper 或 lower

Comments

Loading comments...

    Please complete the verification challenge.