文件充满了危险

2020-11-14 16:35:24

文件充满了危险,让我们来谈谈文件吧!大多数开发人员似乎认为文件很容易。例如,当Dropbox宣布他们只在Linux(最广泛使用的Linux文件系统)上支持ext4时,让我们来看看排名靠前的reddit r/编程评论。对于不熟悉reddit r/编程的人,我怀疑r/编程是世界上阅读量最大的英语编程论坛。

我有点困惑,为什么这些应用程序必须直接支持这些文件系统?难道内核本身不需要知道文件本身是如何存储的更低层次的细节吗?

我能看到的不同文件系统之间唯一的区别就是文件大小限制和权限,但大多数现代文件系统难道不能相互媲美吗?

#2:操作系统关注的普通应用程序不应该抽象吗?

回答:这是一个漏洞百出的抽象概念。我敢打赌,每个不同的文件系统都有自己的缺陷,在Dropbox代码库中有自己的文件系统特定修复程序。更多的文件系统意味着更多的测试,以确保一切正常运行。。。

二级回答:你在说什么?这是一个Dropbox,它到底需要从FS那里得到什么?有几十种fssync工具、数据传输工具、分布式存储软件,一切都与intify配合得很好。到底有什么该死的东西对Dropbox不起作用?

另一个二级回答是:当然,但是产生的任何错误都应该在各自的抽象层修复,而不是自己重新实现整个堆栈。除非您没有从抽象中获得所需的数据,否则您不应该重新实现。。。。Dropbox实现了特定于文件系统的解决办法和怪癖,这是一种矫枉过正的做法。这就像Vim提供特定于键盘的解决方案来避免错误的按键一样。所有的抽象都是漏洞百出的--但如果没有这些抽象,什么都做不了(我们将拥有数十亿个操作系统)。

在这次演讲中,我们将看看文件系统之间有何不同,以及在写入文件时可能遇到的其他问题。我们将从文件API开始,从顶部开始研究文件堆栈,我们将看到文件API几乎不可能正确使用,支持多个文件系统而不损坏数据要比支持单个文件系统困难得多;向下看文件系统,我们将看到文件系统存在严重的错误,会导致数据丢失和数据损坏;然后我们将查看磁盘,发现磁盘可以轻松地以500万倍的速度损坏数据,这一点我们将会看到,我们将从顶部开始讨论文件API,然后我们将看到,在不损坏数据的情况下支持多个文件系统要比支持单个文件系统困难得多;我们将看到文件系统存在严重的错误,会导致数据丢失和数据损坏;然后我们将查看磁盘,发现磁盘可以轻松地以500万倍的速度损坏数据

让我们假设我们想要安全地写入文件,这样我们就不会想要数据损坏。就本次演讲而言,这意味着我们希望我们的写入是原子的--我们的写入应该完全完成,或者我们应该能够撤销写入并回到开始的位置。让我们来看看Pillai等人的一个例子,OSDI‘14。

我们有一个包含文本a foo的文件,我们想用bar覆盖foo,因此我们最终得到了一个bar。我们将做一些简化。例如,您可能应该将我们正在写入的每个字符视为磁盘上的一个扇区(或者,如果您愿意,您可以想象我们正在使用一个假设的高级NVM驱动器)。如果你不知道这是什么意思,不要担心,我只是想指出这一点,我只是想指出,这次演讲将包含许多简化,我不会说出来,因为我们只有25分钟,而这个演讲的简化版本可能需要大约3个小时。

要编写代码,我们可以使用pwrite syscall。这是操作系统提供的让我们与文件系统交互的功能。我们对此系统调用的调用如下所示:

PWRITE([文件],“bar”,//要写入的数据为3,//写入3个字节为2)//偏移量为2。

PWRITE采用我们要写入的文件、我们要写入的数据、条形码、我们要写入的字节数、3和要开始写入的偏移量2。如果您习惯于使用高级语言(如Python),您可能会习惯于一个看起来不同的接口,但在幕后,当您写入一个文件时,它最终会产生一个类似这样的syscall,这就是为什么你会习惯于使用一个看起来不同的接口,但在幕后,当你写入一个文件时,它最终会产生一个类似这样的syscall,这就是为什么你会习惯于使用高级语言(如Python)的接口,但在幕后,当你写到一个文件时,它最终会产生一个类似这样的syscall,这就是为什么。

如果我们像这样调用pWRITE,我们可能会成功并在输出中得到一条线,或者我们可能什么都不做而得到foo,或者我们可能最终得到介于两者之间的东西,比如boo、bor等等。

这里发生的情况是,当我们写作时,我们可能会崩溃或断电。由于pwrite不能保证是原子的,如果我们崩溃,我们最终可能只完成写入的一小部分,从而导致数据损坏。避免此问题的一种方法是存储撤消日志,这样我们就可以恢复损坏的数据。在我们重新修改文件之前,我们会将要修改的数据复制一份(放入撤消日志中),然后我们会照常修改文件,如果没有问题,我们会删除撤消日志。

如果我们在写入撤消日志时崩溃,那也没问题--我们将看到撤消日志没有完成,我们知道我们不需要还原,因为我们还没有开始修改该文件。(##39;#**$$=“{##**$}”>=。如果我们在修改文件时崩溃,那也没问题。当我们尝试从崩溃中恢复时,我们将看到撤消日志已完成,我们可以使用它从数据损坏中恢复:

Creat(/d/log)//创建撤消日志写入(/d/log,";2,3,foo";,7)//要撤消,在偏移量2处,写入3个字节,";foo";pwrite(/d/orig,“bar";,3,2)//在取消链接(/d/log)之前修改原始文件(/d/log)//删除日志文件。

如果我们使用的是广泛使用的ext3或ext4 Linux文件系统,并且我们正在使用模式data=Journal(我们稍后将讨论这些模式的含义),以下是我们可能得到的一些结果:

在写入日志文件的过程中,我们可能会崩溃,并且日志文件将不完整。?在上面的第一种情况下,我们知道日志文件没有完成,因为文件说我们应该从偏移量2开始写入3个字节,但只指定了一个字节f,所以日志文件一定是不完整的。在上面的第二种情况下,我们可以看出日志文件是不完整的,因为撤消日志格式应该以偏移量和长度开头,但我们两者都没有。无论哪种方式,因为我们知道日志文件不完整,所以我们知道我们不需要恢复。

在第一种情况下,日志文件是完整的,我们在写入文件时崩溃了。这很好,因为日志文件告诉我们如何恢复到已知的良好状态。在第二种情况下,写入已完成,但由于日志文件尚未删除,我们将从日志文件恢复。

在DATA=ORDERED的情况下,不能保证对日志文件的写入和修改原始文件的写入将按程序顺序执行。Instesad,我们可以。

Creat(/d/log)//创建撤消日志写入(/d/orig,“bar";,3,2)//在写入撤消日志之前修改文件!WRITE(/d/log,";2,3,foo";,7)//写入撤消日志unlink(/d/log)//删除日志文件。

为了防止这种重新排序,我们可以使用另一个syscall,fsync。Fsync是一个障碍(防止重新排序),它会刷新缓存(这一点我们将在后面讨论)。

Creat(/d/log)WRITE(/d/log,“2,3,foo”,7)fsync(/d/log)//添加fsync以防止重新排序pwrite(/d/orig,“bar”,3,2)fsync(/d/orig)//添加fsync以防止重新排序取消链接(/d/log)

这适用于ext3或ext4,data=ordered,但如果我们使用data=Writeback,我们可能会看到类似以下内容:

不幸的是,使用DATA=WRITEBACK,不能保证对日志文件的写入是原子的,并且跟踪文件长度的文件系统元数据可以在我们完成日志文件写入之前更新,这将使日志文件看起来像是包含创建日志文件的磁盘上发生的任何位。由于日志文件存在,当我们尝试在崩溃后进行恢复时,我们可能会最终将随机垃圾恢复到原始文件中。为了防止出现这种情况,我们可以向日志文件添加校验和(一种确保文件实际有效的方法)。

创建(/d/LOG)写入(/d/LOG,“…。[✓∑],foo“,7)//向日志文件添加校验和以检测日志文件不完整同步(/d/log)pwrite(/d/orig,”bar“,3,2)fsync(/d/orig)unlink(/d/log)。

没有日志文件!尽管我们创建了一个文件,对其进行了写入,然后对其进行了fsync处理。不幸的是,不能保证如果我们崩溃,该目录是否真的会存储文件的位置。为了确保在从崩溃中恢复时可以轻松找到该文件,我们需要同步新创建的日志的父日志。

创建(/d/LOG)写入(/d/LOG,“…。[✓∑],foo“,7)fsync(/d/log)fsync(/d)/fsync父目录写入(/d/orig,”bar“,3,2)fsync(/d/orig)unlink(/d/log)。

我们还有几件事要做。我们还应该fsync后,我们做了(没有显示),我们还需要检查错误。这些syscall可能会返回错误,需要适当地处理这些错误。至少有一个文件系统问题使这一点变得非常困难,但由于这本身并不是API使用问题,我们将在文件系统一节中再次讨论这一问题。

我们现在已经了解了如何安全地编写文件。它可能比我们喜欢的复杂,但它似乎是可行的--如果有人要求您以一种独立的方式(如面试问题)编写文件,并且您知道适当的规则,那么您很可能可以正确地这样做。但是,如果我们必须将此作为日常工作的一部分,每次写入大型代码库中的文件时,我们都希望安全地写入文件,那么会发生什么呢?

Pillai等人(OSDI‘14)研究了一系列写入文件的软件,包括我们希望安全写入文件的软件,比如数据库和版本控制系统:Leveldb、LMDB、GDBM、HSQLDB、Sqlite、PostgreSQL、Git、Mercurial、HDFS、ZooKeeper。然后,他们编写了一个静态分析工具,可以发现文件API的错误用法,比如错误地假设非原子的操作实际上是原子的,错误地假设可以重新排序的操作将按程序顺序执行,等等。

当他们这样做的时候,他们发现除了SQLite在一种特定模式下测试的每个软件都有至少一个错误。这并不是要打击该软件或该软件的开发人员--从事Leveldb、LBDM等工作的程序员比绝大多数程序员更了解文件系统,而且该软件比大多数软件都有更严格的测试。但他们仍然不能每次都安全地使用文件!一个自然的后续问题是:为什么文件API如此难以使用,以至于连专家都会犯错?

造成这种情况的原因有很多。如果你问人们编程中的难题是什么?你会得到诸如分布式系统、并发编程、安全性、与CSS保持一致、日期等方面的答案。

如果我们看看当人们进行并发编程时是什么错误导致错误,我们会发现错误来自于错误地假设操作是原子的,以及错误地假设操作将按程序顺序执行。这些东西使并发编程变得困难,也使编写文件变得安全困难--我们在第一个例子中看到了这两种bug的例子。更广泛地说,使并发编程变得困难的许多事情与使安全地写入文件变得困难的事情是相同的,所以我们当然应该预料到写入文件是困难的!

对文件的写入与并发编程安全共享的另一个特性是,它很容易编写出现不频繁的、不确定的失败的代码。关于文件,人们有时会说这会让事情变得更容易(我从来没有注意到数据损坏,等等),但是如果你想安全地写文件,因为你正在使用不应该损坏数据的软件,这会让你更难判断你的代码是否真的正确,所以事情就变得更加困难了。(#34;我从来没有注意到你的数据损坏,你的数据大多数时候仍然在那里,等等),但是如果你想安全地写文件,因为你正在使用的软件不会损坏数据,这就会让事情变得更加困难,因为它让你的代码更难判断是否真的是正确的。

正如我们在第一个示例中看到的,即使使用一个文件系统,不同的模式也可能具有显著不同的行为。文件API的大部分如下所示,其中的行为在不同的文件系统或同一文件系统的不同模式之间有所不同。例如,如果我们查看主流文件系统,则附加是原子的,除非在任何模式下使用ext3或ext4和data=Writeback,或者ext2,并且目录操作不能重新排序。除btrf外的任何其他操作。理论上,我们都应该仔细阅读POSIX规范,并确保我们所有的代码都符合POSIX,但是如果他们检查文件系统的行为,人们倾向于根据他们的文件系统做什么来编码,而不是一些删节的规范。

如果我们看一下一个文件系统的一种特定模式(ext4,data=Journal),这似乎相对可以安全地处理,但在为各种文件系统编写代码时,特别是在处理与ext3和ext4(如btrf)有很大不同的文件系统时,人们很难编写正确的代码。

在我们的第一个示例中,我们看到使用不同的data=模式可以获得不同的行为。如果我们查看手册页(手册),了解这些模式在ext3或ext4中的含义,我们会得到:

日志:所有数据在写入主文件系统之前都会提交到日志中。

已订购:这是默认模式。在将所有数据的元数据提交到日志之前,所有数据都会被直接强制传出到主文件系统。

回写:数据顺序不会保留-数据可能会在其元数据提交到日志后被写入主文件系统。有传言称,这是吞吐量最高的选择。它可以保证内部文件系统的完整性,但它可以允许旧数据在崩溃和日志恢复后出现在文件中。

如果你想知道如何安全地使用你的文件系统,而你还不知道什么是日志文件系统,这肯定帮不了你。如果你知道什么是日志文件系统,这会给你一些提示,但这仍然是不够的。从理论上讲,通过阅读源代码就可以弄清楚所有事情,但对于大多数还不知道文件系统如何工作的人来说,这是非常不切实际的。

有关英文文档,请访问LWN.net和Linux内核邮件列表(LKML)。LWN很棒,但他们不能跟上所有的事情,所以如果你想要全面的东西,LKML是你去的地方。以下是LKML上关于文件系统的交流的一个例子:

Dev 1:就我个人而言,我关心元数据的一致性,ext3文档建议日志保护其完整性。不过,它不能在损坏的存储设备上运行,而且您仍然需要在那里运行fsck。Dev 2:正如ext3作者多年来多次声明的那样,无论如何,您仍然需要定期运行fsck。Dev 1:哪里有记录?Dev2:Linux内核邮件列表存档。财政司司长:大概是在6-8年前,在我发的电子邮件帖子里。

虽然文件系统开发人员往往乐于助人,他们会写出信息量很大的回复,但大多数人可能跟不上过去6-8年的LKML。

另一个问题是文件API在性能和正确性之间存在固有的冲突。我们在前面已经注意到,fsync是一个屏障(我们可以用它来强制排序),它会刷新缓存。如果您曾经从事过高性能缓存(如微处理器缓存)的设计工作,您可能会发现将这两样东西捆绑到一个原语中是不寻常的。这种情况不同寻常的一个原因是,刷新缓存有很大的性能成本,而且在很多情况下,我们希望在不支付此性能成本的情况下强制排序。当我们只关心排序时,将这两件事捆绑到一个原语中会迫使我们支付缓存刷新成本。

Chidambaram等人,SOSP‘13通过修改ext4以增加不刷新缓存的屏障机制来研究这一性能代价,他们发现,如果他们适当修改软件,并在不需要完全fsync的情况下使用屏障操作,他们能够在完全禁用缓存刷新的情况下获得与ext4大致相当的性能(这是不安全的,并且可能导致数据损坏)而不牺牲安全性。然而,对于大多数编写用户级软件的人来说,创建自己的文件系统并使其被采用是不切实际的。有些数据库将完全或几乎完全绕过文件系统,但这对大多数软件来说也是不切实际的。

这就是文件API。既然我们已经了解到它的使用非常困难,那么让我们来看看文件系统的用法吧。

如果我们想要确保文件系统正常工作,我们可以做的最基本的测试之一就是在文件系统下面的层注入错误,以查看文件系统是否正确地处理它们。例如,在写入时,我们可能会导致磁盘无法写入数据并返回相应的错误。如果文件系统丢弃了这个错误或者没有正确地处理这个错误,那就意味着我们有数据丢失或数据损坏。这类似于Kyle Kingsbury昨天在他的分布式系统测试演讲中谈到的分布式系统故障(尽管这些类型的错误更容易测试)。

Prabhakaran等人,SOSP‘05做到了这一点,并发现,对于大多数测试的文件系统,几乎所有的写入错误都被删除了。最大的例外是ReiserFS,它在测试所有类型的错误时都做得很好,但ReiserFS现在并没有真正使用,原因超出了本文的讨论范围。

我们(韦斯利·阿普特卡-卡塞尔和我)在2017年再次研究了这一点,发现情况有了显著改善。大多数文件系统(JFS除外)都可以通过这些非常基本的错误处理测试。

查找错误的另一种方法是查看文件系统代码,看看它是否正确处理内部错误。Gunwai等人,FAST‘08做了这件事,发现内部错误在很大程度上被丢弃了。他们使用的技术使得很难判断可能返回许多不同错误的函数是否正确地处理了每个错误,因此他们还查看了对只能返回单个错误的函数的调用。在这些情况下,根据功能的不同,错误下降的时间大约为2/3到3/4。

韦斯利和我在2017年也再次研究了这一点,发现了显著的改善--Gunawi等人的相同功能出现了错误。根据功能的不同,只有1/3到2/3的时间被忽略。

Gunwai等人。我还查看了这些删除的错误附近的评论,发现在这一点上只需忽略错误就可以了。我们无能为力,只能继续前进。犯错误,跳过障碍,抱最好的希望。

现在我们已经看到,尽管文件系统过去甚至可以删除最基本的错误,但它们现在可以正确地处理这些错误,但是有些代码路径可以删除错误。对于发生这种情况的一个具体例子,让我们回顾一下我们的第一个例子。如果我们在fsync上遇到错误,除非我们有一个较新的Linux内核(2018年第二季度),否则错误很有可能会被删除,甚至可能会被报告给错误的进程!

在最新的Linux内核上,很有可能会(向Corre)报告该错误。

.