你 ext4 文件系统中文件的顺序并不重要?也许吧
你 ext4 文件系统中文件的顺序并不重要
2025-04-06
- tl;dr
- 1. 转移视线 -
buildah
默认 squash layers - 2. 转移视线 -
overlayfs
layer 顺序 - 3. 提取 layer 过程中的岔路
- 4. "f-(sync)" 时刻
- 5. 十六进制编辑 block image 文件
- 6. 结束的想法?
标题是对今天橙色网站首页上某事的一个俏皮引用1。
我不想在这里误导人;bash
中的 glob
顺序是“字母数字”的。
这更多的是关于记录最近在节点镜像补丁更新后遇到的一个奇怪的 bug,它反过来导致了数小时的中断,因为我们无法及时解决它。
我们在生产环境中有 JVM 工作负载,其 dockerfile
看起来像这样。
CMD ["java", "-cp", "/jars/*", "-server", ..., "com.acmecorp.app.Application"]
这里的通配符不是 glob
,因为它不是在 bash
shell 中运行的。JVM 接收到的实际参数值是 "/jars/*"
,然后 JVM 决定提供帮助,并无论如何都展开通配符2。
在 posix
系统中,这恰好使用了 readdir
系统调用3。
tl;dr
overlayfs
将readdir
委托给底层文件系统- 底层文件系统恰好是
ext4
ext4
readdir 通过使用特定的“目录哈希种子”在“哈希 b-tree”中缓存目录条目来进行优化- 目录哈希种子随着节点镜像补丁更新而改变,导致 classpath 中的 jar 顺序发生变化,导致未捕获的 throwable,从而导致应用程序初始化“卡住”
博客的其余部分是关于我如何试图弄清楚我们是如何走到这一步的。
1. 转移视线 - buildah
默认 squash layers
随着几年前对我们的 CI/CD 设置(从 Jenkins 到 GHA)的最新改进,以及其他一些原因,我们从 docker
切换到 buildah
来构建容器镜像,并且我们注意到一些由 buildah
构建的镜像无法启动。
我们以特定的顺序将文件复制到容器镜像中,以便可能通过“共享”层来节省带宽。 这涉及按照“易变性”(从下到上)的特定顺序复制 jar 包;
- 特定项目的 jar 包
- 内部依赖项的 jar 包
- Kotlin stdlib 和依赖项的 jar 包
- 其他依赖项的 jar 包
目的是 layer 3 和 4 “很少”会改变,并且考虑到 SHA 会保持一致,这肯定会有助于带宽。
经过调查,我们发现一个关键的配置文件是从一个不是“特定项目 jar 包”的 jar 包中读取的,这反过来导致应用程序启动出现异常。
作为立即修复,我们进行了相应的修复,在 buildah bud
步骤中添加了 --layers
。
“
buildah
默认情况下不会在构建镜像期间缓存 layer,因此最终会 squash layer。当使用通配符设置 java 的 classpath 时,jar 的列出顺序会发生变化,因此导致项目中 jar 之外的其他 jar 在 class path 中具有更高的优先级。 此 PR 实现了--layers
标志的使用,该标志重新启用了 layer 的缓存,并修复了 classpath jar 优先级的问题。” 我们并没有意识到这并没有太大的作用;但它似乎解决了从错误的 jar 包中提取配置文件的这个问题;然后我们快乐地离开了。
2. 转移视线 - overlayfs
layer 顺序
这是一个理解上的差距,我一直认为在 overlayfs
上执行 readdir
时,迭代顺序将遵循 overlayfs
目录堆叠顺序。
旁注:容器使用 overlayfs
并基本上将镜像 layer “堆叠”在一起;他们使用一种叫做“white-outs”的东西来处理删除。其中一个原因是,如果有很多 white-out,从性能的角度来看会很糟糕,并且 squash layer 据说对性能更好,或者别的什么,我也不知道。
执行最基本的测试,让我意识到情况并非如此。唯一的保证是“上层”layer 的文件将覆盖“下层”layer 的文件,但没有关于 overlayfs
layer 的迭代顺序的内容。
可以通过以下示例轻松证明这一点
[](https://thewisenerd.com/blog/ext4-readdir/<#cb2-1>)uname -r
[](https://thewisenerd.com/blog/ext4-readdir/<#cb2-2>)# 6.1.0-18-amd64
[](https://thewisenerd.com/blog/ext4-readdir/<#cb2-3>)
[](https://thewisenerd.com/blog/ext4-readdir/<#cb2-4>)mkdir l0 l1 l2 work merged
[](https://thewisenerd.com/blog/ext4-readdir/<#cb2-5>)for d in l0 l1 l2; do for file in $(seq 10 12); do touch $d/$d-$file; done; done
[](https://thewisenerd.com/blog/ext4-readdir/<#cb2-6>)sudo mount -t overlay overlay -o lowerdir=./l0:./l1,upperdir=./l2,workdir=./work ./merged/
[](https://thewisenerd.com/blog/ext4-readdir/<#cb2-7>)ls -1U ./merged/ # list unsorted, basically think readdir
我之前的理解,或者至少是按照我们在上一节中提出的“修复”的理解,是这里期望的顺序是 l2,l1,l0
,
但 ls -1U ./merged/
的实际输出是 l1,l2,l0
。
l1-11
l1-10
l1-12
l2-10
l2-12
l2-11
l0-10
l0-12
l0-11
可以肯定地说,虽然 overlayfs
保证 upper
目录的文件将覆盖 lower
目录的文件,但它不保证目录遍历顺序相同。
在这一点附近,我验证了合并后的“overlayfs”文件夹上的 ls -1U
的一部分与底层“ext4”上的“lower”目录上的 ls -1U
顺序匹配,并决定将精力集中在弄清楚那里发生了什么。
3. 提取 layer 过程中的岔路
考虑到 layer 提取逻辑可能已更改(为什么??),我开始尝试从镜像中获取确切的 tar blob。 这涉及,
- 获取 Azure Container Registry 的身份验证令牌,并将其交换为特定容器镜像的令牌
- 获取镜像的 manifest
/v2/{repo}/manifests/{version}
- 迭代 layer
fsLayers[].blobSum
并获取 tar blob
获取 tar blob,并复制 containerd layer 提取逻辑4(仅限 golang 原生 "archive/tar"
位),当在同一 nodepool 上运行时,随后的 ls -1U
输出基本上是相同的。
inode 顺序是否不同?没有。按 tar 存档中的顺序排列的顺序 inode。
4. "f-(sync)" 时刻
考虑到 fsync 在将未写入的 block 刷新到磁盘时可能会重新排序 block,我启用了跟踪,但日志太多了。
[](https://thewisenerd.com/blog/ext4-readdir/<#cb4-1>)echo 1 > /sys/kernel/debug/tracing/events/ext4/enable
[](https://thewisenerd.com/blog/ext4-readdir/<#cb4-2>)cat /sys/kernel/debug/tracing/trace # too noisy,
我所有的只是我无法理解的日志行,并且我无法有效地为我的特定操作过滤日志行(因为共享根磁盘)。没有心情坐下来弄清楚 ebpf,我决定创建一个 loopback 设备并改为 | grep
该特定 loopback 设备。
[](https://thewisenerd.com/blog/ext4-readdir/<#cb5-1>)cat /sys/kernel/debug/tracing/trace | grep -v 'dev 8,1'
在 loopback 设备内部运行相同的 tar
提取 golang 程序,ls -1U
顺序变得不同。wtf。在另一个文件夹中重新运行提取,顺序相同;
创建另一个 loopback 设备,ls -1U
顺序再次更改。
因此,在文件系统中,提取后 ls -1U
顺序是一致的。
5. 十六进制编辑 block image 文件
使用 debugfs disk.img
,并运行 stats
,只有两个可能更改的参数,“Filesystem UUID”和“Directory Hash Seed”。
文件系统 UUID 可以在 mkfs.ext4
中使用 -U
参数轻松指定,
[](https://thewisenerd.com/blog/ext4-readdir/<#cb6-1>)mkfs.ext4 -U {uuid} disk2.img
但是,在具有相同 UUID 的两个 ext4 分区上运行 tar 提取测试仍然具有不同的 ls -1U
顺序。
因此,决定接下来处理“Directory Hash Seed”,我意识到没有简单的方法可以使用 mkfs.ext4
设置此参数,因此在 block 文件中找到目录哈希种子的偏移量并对其进行十六进制编辑是“唯一的出路”。
这是通过 xxd
、grep -ob
和 printf | dd
的一个相当愚蠢的组合“完成”的。
ext4
标头 block 也有一个 crc 校验和,debugfs
会抱怨;但它也为您提供“预期”值,因此消除该“错误”只是另一种十六进制编辑。
挂载新修改的磁盘镜像,并重新运行 tar 提取测试,顺序匹配!
6. 结束的想法?
在处理 overlayfs
将 readdir
委托给底层文件系统时,我曾在某个地方仔细阅读了 ext4 readdir
实现5,但阅读就是阅读,而阅读是有损的。
ext4
有一个叫做“h-tree indexing”的东西,需要专门启用,而且据我所知,并没有启用它们。
我假设 is_dx_dir
会立即退出,但在仔细检查后(当然是在十六进制编辑 block image 文件之后),我意识到 is_dx_dir
和 ext4_dx_dir
几乎都是 happy-path,因为 is_dx_dir
impl 是“排除特定的”,而不是“包含特定的”。
[](https://thewisenerd.com/blog/ext4-readdir/<#cb7-1>)static int ext4_readdir(struct file *file, struct dir_context *ctx)
[](https://thewisenerd.com/blog/ext4-readdir/<#cb7-2>){
[](https://thewisenerd.com/blog/ext4-readdir/<#cb7-3>) // ...
[](https://thewisenerd.com/blog/ext4-readdir/<#cb7-4>) if (is_dx_dir(inode)) {
[](https://thewisenerd.com/blog/ext4-readdir/<#cb7-5>) err = ext4_dx_readdir(file, ctx);
[](https://thewisenerd.com/blog/ext4-readdir/<#cb7-6>) if (err != ERR_BAD_DX_DIR)
[](https://thewisenerd.com/blog/ext4-readdir/<#cb7-7>) return err;
[](https://thewisenerd.com/blog/ext4-readdir/<#cb7-8>)
[](https://thewisenerd.com/blog/ext4-readdir/<#cb7-9>) // ...
[](https://thewisenerd.com/blog/ext4-readdir/<#cb7-10>) }
[](https://thewisenerd.com/blog/ext4-readdir/<#cb7-11>) // ...
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-1>)/**
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-2>) * is_dx_dir() - 检查目录是否正在使用 htree 索引
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-3>) * @inode: 目录 inode
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-4>) *
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-5>) * 检查给定的 dir-inode 是否引用 htree 索引的目录
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-6>) * (或可能被转换为使用 htree 目录的目录
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-7>) * 索引)。
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-8>) *
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-9>) * 如果它是 dx 目录,则返回 1,否则返回 0
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-10>) */
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-11>)static int is_dx_dir(struct inode *inode)
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-12>){
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-13>) struct super_block *sb = inode->i_sb;
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-14>)
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-15>) if (ext4_has_feature_dir_index(inode->i_sb) &&
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-16>) ((ext4_test_inode_flag(inode, EXT4_INODE_INDEX)) ||
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-17>) ((inode->i_size >> sb->s_blocksize_bits) == 1) ||
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-18>) ext4_has_inline_data(inode)))
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-19>) return 1;
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-20>)
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-21>) return 0;
[](https://thewisenerd.com/blog/ext4-readdir/<#cb8-22>)}
实际上拥有一个调试器来单步执行内核函数会很有帮助,但那是另一天的冒险。
6.1. 等等,到底哪里出错了?
我们有三个 Bouncy Castle “provider” 依赖项,它们位于单个 overlayfs
layer 上。
bcprov-jdk14-1.38.jar
bcprov-jdk15on-1.55.jar
bcprov-jdk18on-1.75.jar
有一个客户端库需要一个版本为 "jdk15"+ 的 Bouncy Castle “provider”,因为客户端初始化使用了类中的特定属性,而这些属性仅在 "jdk15"+ 中可用。
直到节点镜像更新之前,我们“幸运地”拥有节点镜像,其目录哈希种子将 "jdk15" 或 "jdk18" 排序在 "jdk14" 之前。
在节点镜像补丁更新之后,目录种子导致 "jdk14" 被哈希为一个值,导致它在 readdir
中早于 "jdk15" 或 "jdk18" 出现。
这导致了初始化线程中未捕获的 “NoSuchFieldError”,导致客户端初始化“卡住”。因此,较新的 pod 无法初始化。
再见。
- The order of files in /etc/ssh/sshd_config.d/ matters (and may surprise you)↩︎
JLI_WildcardExpandClasspath
↩︎WildcardIterator_next
>readdir
↩︎- containerd/pkg/archive/tar.go↩︎
- fs/ext4/dir.c↩︎