我们如何在Linux内核中寻找两周的NFS错误

GitLab任务中错误搜索的详细描述,该错误导致了Linux内核补丁


9月14日,GitLab支持人员报告了一个客户发生的关键问题:首先,GitLab运行良好,然后用户收到错误消息。 他们试图通过Git克隆一些存储库,突然出现了一个关于过时文件的令人费解的消息: Stale file error 。 该错误持续了很长时间,直到系统管理员在目录本身中手动启动ls时,该错误才起作用。


我必须研究Git和NFS网络文件系统的内部机制。 结果,我们在Linux v4.0 NFS客户端中发现了一个错误, Trond Myklebust为该内核编写了一个补丁程序 ,并且自10月26日以来, 该补丁程序已包含在主要的Linux内核中


在这篇文章中,我将告诉您我们如何研究该问题,我们以什么方向思考以及使用什么工具来跟踪该错误。 Oleg Dashevsky出色的侦探工作使我们受到启发,该文章在“我如何在Ruby中寻找内存泄漏两周的方法”一文中描述



这也是一个很好的例子,说明开源调试是一项由许多人,公司和国家参与的团队运动。 GitLab的座右铭“ 每个人都可以贡献力量 ”不仅对GitLab本身适用,对其他开源项目(例如Linux内核)也是如此。


错误再现


我们将NFS保留在GitLab.com上已有多年,但随后停止使用它来访问带有应用程序的计算机上的存储库数据。 我们已将所有Git呼叫移至Gitaly 。 我们为在GitLab上管理安装但从未遇到过与上述客户端相同的问题的客户端提供NFS支持。


客户给出了一些有用的提示


  1. 错误消息全文: fatal: Couldn't read ./packed-refs: Stale file handle
  2. 显然,当客户端使用git gc命令在Git中手动启动垃圾收集时,出现了问题。
  3. 当系统管理员在目录中启动ls实用程序时,该错误消失了。
  4. git gc进程git gc时,错误消失git gc

显然,前两点已连接。 当您向Git分支提交更改时,Git会创建一个弱链接-一个长文件名,指示提交的分支名。 例如,当发送给master ,将在存储库中创建一个名为refs/heads/master的文件:


 $ cat refs/heads/master 2e33a554576d06d9e71bfd6814ee9ba3a7838963 

git gc命令执行一些任务。 例如,它将收集这些弱链接(引用)并将其打包到一个名为packed-refs文件中。 这会稍微加快工作速度,因为读取一个大文件比许多小文件要容易。 例如,运行git gc命令后, packed-refs文件可能如下所示:


 # pack-refs with: peeled fully-peeled sorted 564c3424d6f9175cf5f2d522e10d20d781511bf1 refs/heads/10-8-stable edb037cbc85225261e8ede5455be4aad771ba3bb refs/heads/11-0-stable 94b9323033693af247128c8648023fe5b53e80f9 refs/heads/11-1-stable 2e33a554576d06d9e71bfd6814ee9ba3a7838963 refs/heads/master 

packed-refs文件是如何创建的? 为了找出strace git gc ,我们在链接薄弱的地方运行了strace git gc命令。 以下是相关的行:


 28705 open("/tmp/libgit2/.git/packed-refs.lock", O_RDWR|O_CREAT|O_EXCL|O_CLOEXEC, 0666) = 3 28705 open(".git/packed-refs", O_RDONLY) = 3 28705 open("/tmp/libgit2/.git/packed-refs.new", O_RDWR|O_CREAT|O_EXCL|O_CLOEXEC, 0666) = 4 28705 rename("/tmp/libgit2/.git/packed-refs.new", "/tmp/libgit2/.git/packed-refs") = 0 28705 unlink("/tmp/libgit2/.git/packed-refs.lock") = 0 

系统调用显示git gc命令:


  1. 打开packed-refs.lock 。 这告诉其他进程, packed-refs文件已锁定,无法更改。
  2. 打开packed-refs.new
  3. 我在packed-refs.new薄弱的链接。
  4. 重命名packed-refs.newpacked-refs
  5. 删除了packed-refs.lock
  6. 删除了薄弱环节。

这里的关键是第四点,即重命名,Git引入了packed-refs文件。 git gc不仅收集弱链接,而且执行更多的资源密集型任务-搜索并删除未使用的对象。 在大型存储库中,此过程可能持续一个多小时。


然后我们问自己:在大型存储库中, git gc在清理过程git gc文件保持打开状态? 我们研究了strace日志,启动了lsof实用程序,这是我们从git gc进程中学到的知识:


图片


如您所见,在可能需要很长时间进行Garbage collect objects过程之后, packed-refs文件最终关闭。


因此,出现了以下问题:当在一个节点上打开packed-refs文件时,NFS表现如何?另一个节点在那时将其重命名?


“出于科学目的,”我们要求客户在两种不同的机器(Alice和Bob)上进行一个实验:
1)在NFS共享卷中,创建两个具有不同内容的文件: test1.txttest2.txt ,以便于区分它们:


 alice $ echo "1 - Old file" > /path/to/nfs/test1.txt alice $ echo "2 - New file" > /path/to/nfs/test2.txt 

2)在爱丽丝的机器上,文件test1.txt应该打开:


 alice $ irb irb(main):001:0> File.open('/path/to/nfs/test1.txt') 

3)在爱丽丝的机器上,连续显示test1.txt的内容:


 alice $ while true; do cat test1.txt; done 

4)然后,在Bob的机器上,运行以下命令:


 bob $ mv -f test2.txt test1.txt 

最后一步重现了git gc在覆盖现有文件时对packed-refs文件所做的工作。
在客户的计算机上,结果如下所示:


 1 - Old file 1 - Old file 1 - Old file cat: test1.txt: Stale file handle 

有! 我们似乎已经以受控的方式控制了这个问题。 但是在Linux NFS服务器上的同一实验中,没有发生此问题。 预期结果-重命名新内容后,该结果被接受:


 1 - Old file 1 - Old file 1 - Old file 2 - New file <--- RENAME HAPPENED 2 - New file 2 - New file 

这种行为差异来自何处? 事实证明,客户端使用了Isilon NFS存储,该存储仅支持NFS v4.0。 当我们使用/etc/fstabvers=4.0参数将连接设置更改为v4.0时,测试显示Linux NFS服务器的结果不同:


 1 - Old file 1 - Old file 1 - Old file 1 - Old file <--- RENAME HAPPENED 1 - Old file 1 - Old file 

Linux NFS v4.0服务器显示了过时的内容 Stale file handle而不是过时的Stale file handle 。 事实证明,行为差异可以通过NFS规范来解释。 从RFC 3010开始


重命名时,文件描述符可能会过时或过期,但并非总是如此。 建议服务器实施者采取措施,以确保文件描述符不会到期,也不会以这种方式到期。

换句话说,NFS服务器可以选择重命名文件时的行为方式,在这种情况下,NFS服务器会相当合理地返回Stale file error 。 我们建议问题的原因是相同的,尽管结果有所不同。 我们怀疑这是缓存检查,因为该目录中的ls实用程序消除了该错误。 现在,我们有了一个可重现的测试方案,我们求助于专家-Linux NFS维护者。


错误的跟踪:在NFS服务器上的委派


当我们设法逐步重现错误时,我向Linux NFS联系人写了有关我们所学的知识。 我与Linux NFS服务器维护者Bruce Fields进行了为期一周的交流,他建议该错误存在于NFS中,因此我需要研究网络流量。 他认为问题在于委派NFS服务器上的任务。


NFS服务器上的委派是什么?


简而言之,NFS v4版本具有委派功能以加快文件访问速度。 服务器可以将对客户端的读取或写入访问委派给客户端,这样客户端就不必不断询问服务器文件是否已被另一个客户端更改。 简而言之,委托记录就像给某人借笔记本,然后说:“您在这里写,等我准备好后再取。” 一个人不必每次都要写东西时都要求笔记本电脑-他拥有完全的行动自由,直到笔记本电脑被拿走为止。 在NFS中,返回笔记本的请求称为委托吊销。


NFS委派吊销中的错误可以解释Stale file handle问题。 记住在爱丽丝的test1.txt如何打开test1.txt ,然后用test2.txt代替test2.txt 。 也许服务器无法撤消test1.txt的委托,这导致无效状态。 为了验证这一理论,我们使用tcpdump实用程序记录了NFC流量,并使用Wireshark对其进行了可视化处理。


Wireshark是一款出色的开源工具,可用于分析网络流量,尤其是在实际操作中探索NFS。 我们在NFS服务器上使用以下命令记录了跟踪:


 tcpdump -s 0 -w /tmp/nfs.pcap port 2049 

此命令记录通常通过TCP端口2049的所有NFS流量。由于我们的实验在NFS v4.1上取得了成功,但在NFS v4.0上却没有成功,因此我们可以比较NFS在正常和非正常情况下的行为。 使用Wireshark,我们看到了以下行为:


NFS v4.0(不建议使用的文件)


图片


该图显示,在步骤1中,Alice打开test1.txt并接收标识符为stateid 0x3000的NFS文件描述符。 当Bob尝试重命名该文件时,NFS服务器通过发送消息NFS4ERR_DELAY来请求重试,并且他通过消息CB_RECALL从Alice调用委托(步骤3)。 爱丽丝返回委托(步骤4中的DELEGRETURN),鲍勃尝试再次发送RENAME消息(步骤5)。 在两种情况下都执行RENAME ,但是Alice继续通过相同的描述符读取文件。


NFS v4.1(工作案例)


图片


在这里,差异在步骤6中可见。在NFS v4.0(带有过时的文件)中,Alice尝试stateid使用相同的stateid 。 在NFS v4.1(工作情况)中,Alice执行其他LOOKUPOPEN操作,因此服务器返回另一个stateid 。 在v4.0中,它不会发送任何其他消息。 这解释了为什么爱丽丝看到过时的内容-她使用了旧的描述符。


为什么爱丽丝突然决定增加额外的“ LOOKUP ? 代表团的召回显然是成功的,但显然仍然存在一些问题。 例如,跳过残疾步骤。 为了验证这一点,我们使用以下命令排除了NFS服务器本身上的NFS委托:


 echo 0 > /proc/sys/fs/leases-enable 

我们重复了实验,但是问题并没有消失。 我们确保问题不在NFS服务器或委派中,并决定查看内核中的NFS客户端。


深入探讨:Linux NFS客户端


我们必须向NFS维护者回答的第一个问题是:


这个问题在最新的内核版本中仍然存在吗?


该问题分别发生在CentOS 7.2和Ubuntu 16.04内核中,版本分别为3.10.0-862.11.6和4.4.0-130。 但是两个内核都落后于最新版本,当时的最新版本是4.19-rc2。


我们在Google Cloud Platform(GCP)上部署了新的Ubuntu 16.04虚拟机,克隆了最新的Linux内核,并设置了内核开发环境。 我们使用menuconfig创建了.config文件,并验证了以下内容:


  1. NFS驱动程序被编译为模块( CONFIG_NFSD=m )。
  2. 正确指定了正确的GCP内核参数

遗传学通过果蝇实时跟踪进化过程,并且通过第一个项目,我们无需重新启动内核即可快速对NFS客户端进行更正。 第二点保证内核将在安装后启动。 幸运的是,我们对默认的内核参数感到满意。


我们确保在最新的内核版本中旧文件的问题不会消失。 我们问自己:


  1. 问题到底在哪里出现?
  2. 为什么在NFS v4.0中会发生这种情况,而在v4.1中却不会发生?

为了回答这些问题,我们深入研究了NFS源代码。 我们没有内核调试器,因此我们向源代码发送了两种类型的调用:


  1. pr_info() (以前 printk )。
  2. dump_stack() :显示当前函数调用的堆栈跟踪。

例如,我们做的第一件事是连接到fs/nfs/nfs4file.cnfs4_file_open()函数:


 static int nfs4_file_open(struct inode *inode, struct file *filp) { ... pr_info("nfs4_file_open start\n"); dump_stack(); 

当然,我们可以使用Linux动态调试 dprintk ,也可以使用rpcdebug ,但是我们想添加自己的消息来检查更改。


每次更改后,我们重新编译模块,并使用以下命令将其重新安装在内核中:


 make modules sudo umount /mnt/nfs-test sudo rmmod nfsv4 sudo rmmod nfs sudo insmod fs/nfs/nfs.ko sudo mount -a 

使用NFS模块,我们能够重复实验并接收消息以了解NFS代码。 例如,您可以立即查看应用程序调用open()时发生的情况:


 Sep 24 20:20:38 test-kernel kernel: [ 1145.233460] Call Trace: Sep 24 20:20:38 test-kernel kernel: [ 1145.233462] dump_stack+0x8e/0xd5 Sep 24 20:20:38 test-kernel kernel: [ 1145.233480] nfs4_file_open+0x56/0x2a0 [nfsv4] Sep 24 20:20:38 test-kernel kernel: [ 1145.233488] ? nfs42_clone_file_range+0x1c0/0x1c0 [nfsv4] Sep 24 20:20:38 test-kernel kernel: [ 1145.233490] do_dentry_open+0x1f6/0x360 Sep 24 20:20:38 test-kernel kernel: [ 1145.233492] vfs_open+0x2f/0x40 Sep 24 20:20:38 test-kernel kernel: [ 1145.233493] path_openat+0x2e8/0x1690 Sep 24 20:20:38 test-kernel kernel: [ 1145.233496] ? mem_cgroup_try_charge+0x8b/0x190 Sep 24 20:20:38 test-kernel kernel: [ 1145.233497] do_filp_open+0x9b/0x110 Sep 24 20:20:38 test-kernel kernel: [ 1145.233499] ? __check_object_size+0xb8/0x1b0 Sep 24 20:20:38 test-kernel kernel: [ 1145.233501] ? __alloc_fd+0x46/0x170 Sep 24 20:20:38 test-kernel kernel: [ 1145.233503] do_sys_open+0x1ba/0x250 Sep 24 20:20:38 test-kernel kernel: [ 1145.233505] ? do_sys_open+0x1ba/0x250 Sep 24 20:20:38 test-kernel kernel: [ 1145.233507] __x64_sys_openat+0x20/0x30 Sep 24 20:20:38 test-kernel kernel: [ 1145.233508] do_syscall_64+0x65/0x130 

这些do_dentry_openvfs_open什么? Linux具有虚拟文件系统( VFS ),它是为所有文件系统提供通用接口的抽象层。 VFS文档说:


VFS实现open(2),stat(2),chmod(2)和其他系统调用。 VFS系统使用传递给他们的路径名参数在高速缓存中搜索目录条目(dentry高速缓存或dcache)。 这提供了一个非常快速的搜索引擎,可以将路径名(或文件名)转换为特定的dentry。 Dentry驻留在RAM中,并且从不保存到磁盘-它们存在只是为了提高性能。

它突然出现在我们面前-如果问题出在Dentry缓存中怎么办?


我们注意到,通常在fs/nfs/dir.c检查了dentry缓存。 我们对nfs4_lookup_revalidate()函数特别感兴趣,并且作为实验,我们使其更早工作:


 diff --git a/fs/nfs/dir.cb/fs/nfs/dir.c index 8bfaa658b2c1..ad479bfeb669 100644 --- a/fs/nfs/dir.c +++ b/fs/nfs/dir.c @@ -1159,6 +1159,7 @@ static int nfs_lookup_revalidate(struct dentry *dentry, unsigned int flags) trace_nfs_lookup_revalidate_enter(dir, dentry, flags); error = NFS_PROTO(dir)->lookup(dir, &dentry->d_name, fhandle, fattr, label); trace_nfs_lookup_revalidate_exit(dir, dentry, flags, error); + goto out_bad; if (error == -ESTALE || error == -ENOENT) goto out_bad; if (error) 

并且在此实验中,没有发生过时的文件问题! 最后,我们进攻了那条小径。


为了找出为什么在NFS v4.1中没有发生此问题,我们在此函数中向每个if块添加了pr_info()调用。 我们对NFS v4.0和v4.1进行了实验,发现v4.1版本存在特殊情况:


 if (NFS_SB(dentry->d_sb)->caps & NFS_CAP_ATOMIC_OPEN_V1) { goto no_open; } 

什么是NFS_CAP_ATOMIC_OPEN_V1 ? 此内核补丁说这是NFS v4.1的功能, fs/nfs/nfs4proc.c的代码确认此参数在v4.1中,但不在v4.0中:


 static const struct nfs4_minor_version_ops nfs_v4_1_minor_ops = { .minor_version = 1, .init_caps = NFS_CAP_READDIRPLUS | NFS_CAP_ATOMIC_OPEN | NFS_CAP_POSIX_LOCK | NFS_CAP_STATEID_NFSV41 | NFS_CAP_ATOMIC_OPEN_V1 

因此,版本的行为有所不同-在v4.1中, goto no_opennfs_lookup_revalidate()函数中调用更多检查,而在v4.0中, nfs4_lookup_revalidate()函数更早返回。 我们如何解决这个问题?


解决方案


我谈到了我们在NFS邮件列表中的发现,并提出了一个原始补丁 。 一周后,Trond Myklebust 向邮件列表发送了一系列带有错误修复的补丁程序,并在NFS v4.1中发现了另一个相关问题


事实证明,NFS v4.0错误的修复在代码库中的深度比我们想象的要深。 Trond在补丁中对此进行了很好的描述:


打开已经打开的文件时,必须确保正确检查了inode和dentry。 现在,我们不会再次检查NFSv4.0,因为已缓存打开的文件。 让我们解决此问题,仅在特殊情况下缓存打开的文件-恢复打开的文件并返回委托。

我们确保此修复程序解决了过时的文件问题,并将错误报告发送给了UbuntuRedHat团队。


我们非常了解更改不会在稳定的内核版本中进行,因此我们在Gitaly中为该问题添加了临时解决方案 。 我们进行了实验,并验证了在packed-refs文件中调用stat()会导致内核再次检查dentry缓存中的重命名文件。 为简单起见,我们在Gitaly中针对所有文件系统(不仅仅是NFS)实现了此功能。 验证仅在Gitaly打开存储库之前完成一次,对于其他文件,已经存在其他stat()调用。


我们学到了什么


Bug可能隐藏在软件堆栈的任何角落,有时您需要在应用程序外部查找它。 如果您在开源世界中拥有有用的联系,这将使您的工作更加轻松。


非常感谢Trond Myuklebust解决了该问题,并感谢Bruce Fields回答了我们的问题并帮助弄清了NFS。 正是由于这种响应能力和专业精神,我们才重视开源开发人员社区。

Source: https://habr.com/ru/post/zh-CN432922/


All Articles