Linux 上的容器
Linux 上的容器往往被认为是轻量级的虚拟化技术,并且随着 Docker/LXC 之类的普及, 容器在成为开发和部署应用的常见方式的同时, 也给初学者带来了黑魔法的感觉
可惜感觉这方面的资料并不算太多, 很多关于容器的文章都直接跳过了容器的底层原理, 直接介绍了如何使用 Docker/LXC 等工具来创建和管理容器
于是我决定大概讲一下创建一个容器背后使用的 Linux 内核特性, 让大家进一步了解容器知道有哪些方向可以深入学习, 因此我也会贴出相关资料的来源, 如 Linux 源码路径, man page 等等, 以便大家进一步研究
BTW, 我希望先让大家从行为入手, 所以整篇文章都是从命令行先感受的
我认为主要有这四大核心 Linux 内核特性:
- Namespaces
- Cgroups
- UnionFS/OverlayFS
- pivot_root
Namespaces
Namespaces 是 Linux 内核提供的一种机制, 用于隔离不同进程的资源, 使得每个进程都认为自己拥有独立的系统资源, 包括进程 ID、网络接口、文件系统等, 并且对命名空间的资源的更改只对同一命名空间内的进程可见, 从而实现了容器的隔离性
下表显示了 Linux 上可用的命名空间类型
具体内容可查看 man 7 namespaces 的文档
| Namespace Type | Description |
|---|---|
| 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 有一些特殊的地方:
- 该 ns 内所有孤儿进程都会被 reparent 到它, 因此它会成为该 ns 内所有孤儿进程的父进程
- 如果它退出,整个 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/mntunshare(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 Type | Description |
|---|---|
| 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_blocksuper_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, ...)实现步骤:
- 从旧 net 的 dev_base_head 删除
- 插入新 net 的 dev_base_head
- 重新注册 sysfs/proc
- 更新 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 上, 这样它们就能互相通信了
# 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;
};他们大概的结构就是这样的
其中: 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 | 内存、swap | memory.max memory.current memory.high |
| cpu | CPU 时间分配 | cpu.max cpu.weight cpu.stat |
| cpuset | 绑定到指定 CPU 核/NUMA 节点 | cpuset.cpus cpuset.mems |
| io | 磁盘 IO 带宽、IOPS | io.max io.weight io.stat |
| pids | 进程数量上限 | pids.max pids.current |
| freezer | 暂停/恢复整个 cgroup | cgroup.freeze |
- 创建 cgroup -> 内核创建一个 cgroup 对象, 挂到父 cgroup 的 children 链表上
- 写入 cgroup.procs -> 内核通过 css_set 将进程加入各子系统的 cgroup
- 进程操作资源 -> 通过 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 | 内部事务目录 |
| merged | overlayfs 挂载点(新文件系统) |
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
- 创建一个临时容器
- 挂载 overlayfs
- 等价于
mount -t overlay overlay \
-o lowerdir=layer0,\
upperdir=tmp_layer1_diff,\
workdir=tmp_layer1_work \
tmp_layer1_merged然后进行修改:
- 先通过 pivot_root 切换到 tmp_layer1_merged
- 执行 apt update, apt update 会修改 upper 里的文件, 但 lower 完全不动
- 临时 merged/work 删除.
- 此时这个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...