你 ext4 文件系统中文件的顺序并不重要

2025-04-06

标题是对今天橙色网站首页上某事的一个俏皮引用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

博客的其余部分是关于我如何试图弄清楚我们是如何走到这一步的。

1. 转移视线 - buildah 默认 squash layers

随着几年前对我们的 CI/CD 设置(从 Jenkins 到 GHA)的最新改进,以及其他一些原因,我们从 docker 切换到 buildah 来构建容器镜像,并且我们注意到一些由 buildah 构建的镜像无法启动。 我们以特定的顺序将文件复制到容器镜像中,以便可能通过“共享”层来节省带宽。 这涉及按照“易变性”(从下到上)的特定顺序复制 jar 包;

  1. 特定项目的 jar 包
  2. 内部依赖项的 jar 包
  3. Kotlin stdlib 和依赖项的 jar 包
  4. 其他依赖项的 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。 这涉及,

  1. 获取 Azure Container Registry 的身份验证令牌,并将其交换为特定容器镜像的令牌
  2. 获取镜像的 manifest /v2/{repo}/manifests/{version}
  3. 迭代 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 文件中找到目录哈希种子的偏移量并对其进行十六进制编辑是“唯一的出路”。 这是通过 xxdgrep -obprintf | dd 的一个相当愚蠢的组合“完成”的。 ext4 标头 block 也有一个 crc 校验和,debugfs 会抱怨;但它也为您提供“预期”值,因此消除该“错误”只是另一种十六进制编辑。 挂载新修改的磁盘镜像,并重新运行 tar 提取测试,顺序匹配!

6. 结束的想法?

在处理 overlayfsreaddir 委托给底层文件系统时,我曾在某个地方仔细阅读了 ext4 readdir 实现5,但阅读就是阅读,而阅读是有损的。 ext4 有一个叫做“h-tree indexing”的东西,需要专门启用,而且据我所知,并没有启用它们。 我假设 is_dx_dir 会立即退出,但在仔细检查后(当然是在十六进制编辑 block image 文件之后),我意识到 is_dx_dirext4_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 无法初始化。 再见。

  1. The order of files in /etc/ssh/sshd_config.d/ matters (and may surprise you)↩︎
  2. JLI_WildcardExpandClasspath↩︎
  3. WildcardIterator_next > readdir↩︎
  4. containerd/pkg/archive/tar.go↩︎
  5. fs/ext4/dir.c↩︎

home