Linux内核分析之文件系统-05

38.1 OverlayFS 原理与层次结构

OverlayFS 的挂载参数只有三个:lowerdir=(可多个逗号分隔)、upperdir=(恰好一个)、workdir=(与 upper 同文件系统的工作目录)。合并视图的语义由这套参数严格推导:多层只读底本 + 单层可写覆盖 + 事务辅助目录。本节拆解层模型与合并规则。


38.1.1 层模型与数据结构

// fs/overlayfs/ovl_entry.h:33-56(节选)
struct ovl_layer {
    /* ovl_free_fs() relies on @mnt being the first member! */
    struct vfsmount *mnt;       /* 本层挂载 (直接复用 VFS 挂载!) */
    /* Trap in ovl inode cache */
    struct inode *trap;     /* 防"层下文件被换"的陷阱 inode */
    struct ovl_sb *fs;
    /* Index of this layer in fs root (upper idx == 0) */
    int idx;            /* 层序号 (upper=0, 从下往上) */
    /* One fsid per unique underlying sb (upper fsid == 0) */
    int fsid;           /* 底层文件系统去重 id */
    /* xwhiteouts were found on this layer */
    bool has_xwhiteouts;
};

struct ovl_path {
    const struct ovl_layer *layer;
    struct dentry *dentry;
};

struct ovl_entry {
    unsigned int __numlower;
    struct ovl_path __lowerstack[] __counted_by(__numlower);    /* 逐层命中路径 */
};

层就是 vfsmount——OverlayFS 不理解磁盘布局,它把每个 lower/upper 目录当一次挂载持有,合并逻辑完全工作在 VFS dentry 之上(33 章抽象的又一次胜利)。fsid 对相同底层文件系统的多层去重(同一镜像的多个层通常同盘),让"跨层 copy-up 是否可省/是否可重定向"有依据。ovl_entry 记录一个文件的"逐层命中栈"——合并查找的结果物。

38.1.2 合并语义速查

读 (lookup): 从最上层往下找第一个命中者
   目录例外: 所有层的同名目录"合并"显示 (目录可叠)
写 (普通文件):
   在 lower → copy-up: 复制到 upper 再改 (38.2.2)
   在 upper → 直接改
删除:
   upper 有 → 删之
   仅 lower 有 → upper 建"白障" (whiteout) 遮挡
新建: 直接 upper
rename: 跨层需要 copy-up + 白障 组合 (目录还有 redirect)
元数据 (chmod/chown/时间戳): lower 文件需 copy-up
   (metacopy 特性可只拷元数据, 数据留在 lower — 38.2.2)

目录的合并例外是 OverlayFS 与"简单覆盖"的本质差异:/usr/lib 在多个镜像层都有内容,合并视图必须并集显示、逐文件再判定归属——这使目录查找必须扫描全部层(38.2.1 节),而文件查找在第一命中即止。

38.1.2 workdir 与索引

// fs/overlayfs/super.c:311(workdir 建立)
static struct dentry *ovl_workdir_create(struct ovl_fs *ofs, ...)
// :455
static bool ovl_workdir_ok(struct dentry *workdir, struct dentry *upperdir)

workdir 是 OverlayFS 的事务工作区:copy-up 的临时文件、目录 rename 的中间状态、"索引"(index,记录 lower 文件的身份映射,支持 NFS 导出与移动后重连)都经它落地。ovl_workdir_ok 的检查(workdir 与 upper 同文件系统、非上层嵌套)保证操作可原子——workdir 与 upper 跨设备会让"临时名→最终名"的 rename 无法原子完成,这是挂载参数最常报错的地方。

// fs/overlayfs/ovl_entry.h:58-80(挂载级状态, 节选)
struct ovl_fs {
    unsigned int numlayer;
    unsigned int numfs;
    unsigned int numdatalayer;  /* "data-only"层 (推送镜像新特性) */
    struct ovl_layer *layers;
    struct ovl_sb *fs;
    struct dentry *workbasedir;
    struct dentry *workdir;
    long namelen;
    ...
};

numdatalayer 支持data-only lower 层(只含文件数据、无目录结构的层)——镜像分发新格式的内核对接点,配合 redirect xattr(ovl_entry.h:162-164)让大文件数据留在专用层。


小结

OverlayFS 的模型是"多层 vfsmount 的查找合并 + 单层 upper 承写 + workdir 事务区":层即挂载、fsid 去重、ovl_entry 保存逐层命中栈;合并语义按类型分流(文件覆盖、目录并集、删除白障、修改 copy-up);workdir 与 upper 必须同文件系统以保 rename 原子性。下一节进入实现:合并查找、copy-up 的完整流程与扩展属性协议。

38.2 OverlayFS 实现 —— upperdir/lowerdir/workdir

OverlayFS 的实现复杂度集中在三处:合并查找(ovl_lookup 在多层间裁决归属与重定向)、copy-up(写穿透的完整协议)、白障与扩展属性(删除/重命名的一致性编码)。本节逐一解剖。


38.2.1 合并查找:ovl_lookup

lookup("/usr/lib/x") 的多层裁决:

 [1] 自上而下逐层 lookup:
     upper:  有? → 命中, 记录 (upper, dentry_upper)
     lower3/2/1: 同样记录, 目录则继续收全部命中
     (38.1.2 节: 文件首中即止, 目录收集并集)
 [2] 白障检查: 命中前若遇 whiteout → 该层以下失效
 [3] redirect xattr (namei.c:28-31 解析结构):
     目录被 rename 过的层会带 "trusted.overlay.redirect"
     → 后续层按重定向后的名字继续找 (合并目录的
     移动一致性)
 [4] 建 merged dentry/inode (ovl_inode, ovl_entry.h:159):
     缓存三层路径 (ovl_entry), inode 的 i_op 全部换成
     ovl_* 实现 — 用户进程看到的是"普通文件"

 origin xattr: upper 文件记录"我来自 lower 的哪个
 inode" — NFS 文件句柄/export (export.c 的
 ovl_lookup_real 系列) 与"去重升级"的依据

查找的性能策略:merged dentry/inode 全量缓存(33.1.3 节 dcache 语义),稳态读写零重查;d_revalidate 钩子在 lower 层是 NFS 时做服务器端确认——OverlayFS 把"多层缓存一致性"问题转化回了"单层缓存一致性"的叠加。

38.2.2 copy-up:写穿透协议

// fs/overlayfs/copy_up.c:1280(入口)与 :1123(单层推进)
int ovl_copy_up(struct dentry *dentry)
static int ovl_copy_up_one(struct dentry *parent, struct dentry *dentry, ...)
// fs/overlayfs/dir.c:347
static int ovl_create_upper(struct dentry *dentry, struct inode *inode, ...)
copy-up 的步骤 (echo > /etc/hosts 触发):

 [0] 沿父链向上: 父目录也全在 lower → 先逐层
     copy-up 父目录 (需要能挂白障/建同名目录)
 [1] 在 upper 建目标 (ovl_create_upper, dir.c:347):
     workdir 建临时文件 → 复制数据+元数据+扩展属性
     → rename 到最终名 (workdir 同 fs 的原子性!,
       38.1.2 节检查的原因)
 [2] metacopy 变体 (特性开启):
     只拷元数据 inode, 数据留在 lower —
     upper 文件带 "trusted.overlay.metacopy" xattr
     读数据时按 origin 回 lower 取
     → 大文件 chmod/chown 的成本从 GB 级降到 0
 [3] 数据一致性: 复制期间原 lower 文件只读语义不变;
     完成后 merged 指向 upper

 失败恢复: workdir 临时名残留 → 下次挂载清理
 (jbd2 式的"先写后改名"事务感, 但无日志兜底 —
 依赖 rename 原子性)

38.2.3 白障与扩展属性协议

OverlayFS 的 xattr 语汇 (trusted.overlay.*):

 whiteout:      字符设备 0:0 (旧) 或 xattr 白障 (新,
                has_xwhiteouts, ovl_entry.h:46)
 opaque:        目录不透明 — 中断下层同名目录的并集
                (目录整体替换的语义)
 redirect:      目录移动后的跨层指路 (38.2.1)
 origin/index:  身份追溯 (数据回源/句柄导出)
 metacopy:      元数据-only 上层文件的标记

 rename 的两难: 把 /lower 的文件改名, 下层旧名必须
 同时"消失" — 实现为 upper 新名 + upper 旧名白障,
 目录则叠加 redirect; 跨层目录 rename 需 workdir
 多步事务 + 失败回滚 (dir.c 的 ovl_rename_start, :1120)

权限模型:所有 xattr 用 trusted. 前缀——仅 root/CAP_SYS_ADMIN 可读写,用户态容器内不可伪造合并语义;userxattr 挂载选项(非特权容器)降级为 user.overlay.*,配合 33.2 节 FS_USERNS_MOUNT 让无 root 的容器也能挂 overlay。


小结

OverlayFS 的实现三面:合并查找以"逐层命中栈 + 白障剪枝 + redirect 指路"维护并集语义,merged inode 全量缓存复用 dcache;copy-up 以"workdir 临时件 + rename 落定"实现原子穿透,metacopy 变体把元数据修改的成本降到零拷贝;trusted.overlay.* 扩展属性族(whiteout/opaque/redirect/origin/metacopy)是全部删除/移动/溯源语义的编码载体。下一节看这套机制在容器镜像上的工程化。

38.3 OverlayFS 与容器镜像

容器镜像的分层分发(Docker/OCI image manifest 的层 tar 包)与 OverlayFS 的多层只读底本是天然的对应:每个镜像层解包成一个 lower 目录,容器启动即一次 overlay 挂载。本节从内核视角解释这套工程:层如何摆放、运行层如何隔离、页缓存在共享基础镜像时的红利,以及已知的坑。


38.3.1 镜像层到挂载参数的映射

docker run 的内核动作 (概化):

 镜像 alpine (3 层) + 容器运行层:

 lowerdir=/var/lib/docker/overlay2/l/C1/diff,\
         /var/lib/docker/overlay2/l/C2/diff,\
         /var/lib/docker/overlay2/l/C3/diff
 upperdir=/var/lib/docker/overlay2/<id>/diff
 workdir =/var/lib/docker/overlay2/<id>/work

 层的获取:
   拉取: 每层 tar 解包到独立目录 (按 manifest 顺序)
   共享: 相同层的 digest 相同 → hardlink/overlay 挂载
   去重: 同盘多层 fsid 相同 (38.1.1 节) → 优化生效
 容器 rootfs: 挂载点即容器内 "/" (mount namespace,
   9.3 节) — 容器内一切路径穿越这层 overlay

写时复制的量化:容器启动零拷贝(无 copy-up,全部只读命中);echo > /etc/hostname 触发一次 30KB 级 copy-up;yum install 的大量写全落 upper 层——运行层与镜像层的物理隔离让"删除容器=删目录"成为完整卸载。

38.3.2 共享与隔离的红利

页缓存共享 (36.1 节的红利):

 同一基础镜像跑 100 个容器:
   libc.so 的页缓存在 lower 层文件上 —
   页缓存按 (inode, offset) 唯一 → 100 容器
   **共享同一份 libc 页缓存页**
   → 内存占用 ≈ 1 份库代码 + N 份容器私有数据

 对照: 每容器独立解包完整镜像 (无 overlay) →
   libc 页缓存 ×100 (与 Shared_Clean 消失)

 隔离面:
   容器间 upper 层互不可见 (各自挂载参数)
   lower 只读挂载在宿主侧管控
   idmapped mount (33.2 节 FS_ALLOW_IDMAP) +
   userns 挂载 (FS_USERNS_MOUNT, 38.2.3 userxattr)
   → 无特权守护进程也能安全挂 overlay

38.3.3 已知的坑与观测

运维必知的坑:

 [1] copy-up 打破 fd 语义: 进程以 fd 持有 lower 文件,
     另一进程写触发 copy-up → fd 仍指向 lower 旧 inode!
     (fd 是 inode 的引用 — 33.1.4 节语义的边界案例)
     容器内 "truncate 后老 fd 读到旧数据" 皆此因
 [2] rename 目录 + 并发查找: redirect xattr 的窗口
     (38.2.3) — 高频目录改名负载慎用
 [3] 稀疏/特殊文件: 设备文件白障化、套接字 copy-up
     的历史缺陷各版本不一
 [4] upper 所在文件系统的 d_type 必须 supported
     (ext4/xfs ok; 某些旧 xfs 配置拒挂)

 观测:
 mount | grep overlay          参数全览
 /proc/self/mountinfo          层序与挂载点
 du lower/upper 各层           空间归因
 xattr 检查 trusted.overlay.*  语义标注现场

与 12 章 cgroup 的联动:容器内 overlay 的写落在 upper 所在文件系统,io/内存限速经 12.5 节 io 控制器按容器 cgroup 生效——回写(36.3 节)与节流在块层汇合(35.2.3 节 iocost)。第六部分的终局视角:一个容器进程的每次文件访问,穿过 VFS 对象(33 章)→ overlay 合并(本卷)→ lower 的页缓存(36 章)→ 底层 ext4(34 章)→ JBD2 事务与块层(35 章)——全书第六部分正好是这条完整栈的逐层解剖。


小结

容器镜像把 OverlayFS 从内核特性推成基础设施:镜像层=lower(digest 去重、只读共享)、容器层=upper(零拷贝启动、删容器即卸载)、页缓存按 inode 共享让百容器共享基础库;工程边界在"fd 指向 lower 的旧 inode"(copy-up 语义陷阱)、目录 rename 窗口与 upper 文件系统要求。OverlayFS 与 mount namespace、cgroup 限速的组合正是"容器 = 内核原语组合"的缩影——第六部分至此完成。