在第3章和第4章中,我们分别讨论了通过Linux命名空间实现的进程隔离和通过cgroups进行的个体进程资源控制。现在我们将探讨分层文件系统,这是Linux容器的第三个构建模块,仅次于命名空间和cgroups。本章描述了分层文件系统如何在主机上实现文件共享,以及这种机制如何有助于在主机上运行多个容器。
首先,让我们开始讨论什么是文件系统。
文件系统基础
Linux的哲学是将一切视为文件。例如,套接字、管道和块设备在Linux中都被表示为文件。
在Linux中,文件系统作为容器来抽象底层存储,特别是在块设备的情况下。对于像套接字和管道这样的非块设备,内存中存在文件系统,这些文件系统具有可以通过标准文件系统API调用的操作。
Linux通过一个称为虚拟文件系统(VFS)的层来抽象所有文件系统。所有文件系统都向VFS注册。VFS具有以下重要的数据结构:
- 文件(File) :这表示打开的文件并捕捉诸如偏移量等信息。用户空间通过一个称为文件描述符的结构来持有对打开文件的句柄。这是与文件系统接口交互的句柄。
- 索引节点(Inode) :这是与文件1:1映射的。索引节点是最关键的结构之一,保存有关文件的元数据。例如,它包括文件数据存储在哪些数据块中以及文件的访问权限等。这些信息是索引节点的一部分。索引节点也存储在特定的文件系统的磁盘上,但在内存中有一个VFS层的表示。文件系统负责列举VFS索引节点结构。
- 目录项(Dentry) :这是文件名和索引节点之间的映射。这是一个内存中的结构,不存储在磁盘上。它主要与查找和路径遍历相关。
- 超级块(Superblock) :这个结构包含有关文件系统的所有信息,包括有多少个块、设备名称等。这个结构在挂载操作期间被枚举并加载到内存中。
每个数据结构都持有指向其特定操作的指针。例如,文件具有用于读取和写入的file_ops,而超级块具有用于挂载、卸载等操作的super_ops。
挂载操作创建一个vfsmount数据结构,该结构持有对从文件系统中创建的新超级块结构的引用,并将其挂载到磁盘上。目录项(dentry)持有对vfsmount的引用。这是VFS区分目录和挂载点的地方。在遍历过程中,如果在目录项中找到vfsmount,则使用挂载设备上的索引节点号2(索引节点2保留用于根目录)。
那么,在块设备的情况下,这一切如何结合在一起呢?假设用户空间进程调用了读取文件的系统调用。系统调用会传递给内核。VFS检查路径并确定是否有来自根目录的目录项缓存。当它遍历并找到正确的目录项时,它定位到要打开的文件的索引节点。一旦找到索引节点,将检查权限,并将数据块从磁盘加载到操作系统页面缓存中。相同的数据被移动到进程的用户空间中。
页面缓存是操作系统中的一个有趣的优化。所有的读写操作(除了直接I/O)都是通过页面缓存进行的。页面缓存本身由一个称为address_space的数据结构表示。这个address_space保存了一棵内存页的树,而文件的索引节点持有对这个address_space数据结构的引用。
图5-1展示了文件如何映射到页面缓存中。这也是理解内存映射文件的mmap等操作如何工作的关键。
如果文件读取请求在页面缓存中(这是通过文件的索引节点的address_space结构确定的),则数据将从那里提供。
每当通过文件描述符对文件进行写操作时,写入的数据首先会被写入到页面缓存中。内存页面会被标记为脏页,Linux内核使用写回缓存机制,这意味着后台有线程(称为pdflush)会排空页面缓存并通过块驱动程序写入物理磁盘。标记页面为脏的机制并不是在页面级别进行的。页面可以是4KB大小,即使是最小的更改也会导致整个页面被写入。
为了避免这种情况,存在具有更细粒度的结构,代表内存中的磁盘块。这些结构称为缓冲头(buffer heads)。例如,如果块大小为512字节,那么页面缓存中会有八个缓冲头和一个页面。这样,单独的块可以被标记为脏,并成为写操作的一部分。
可以通过以下系统调用显式将缓冲区刷新到磁盘:
sync(): 刷新所有脏缓冲区到磁盘。fsync(fd): 仅刷新特定文件的脏缓冲区到磁盘,包括对索引节点的更改。fdatasync(fd): 仅刷新文件的脏数据缓冲区到磁盘,不刷新索引节点。
以下是同步过程的示例:
- 检查超级块是否为脏。
- 将超级块写回磁盘。
- 遍历索引节点列表中的每个索引节点: a. 如果索引节点是脏的,将其写回磁盘。 b. 如果索引节点的页面缓存是脏的,将其写回磁盘。 c. 清除脏标志。
图5-2展示了内核下文件系统的不同层级。
不同类型的文件系统示例如下:
- Ext4: 这个文件系统用于访问底层的块设备。
- ProcFS: 这是一个内存中的文件系统,用于提供系统特性,也称为伪文件系统。
伪文件系统简要概述
回顾一下,Linux 的基本理念是将一切视为文件。在这一理念下,伪文件系统通过文件接口暴露了内核的一些资源。其中之一就是 procfs。
procfs 文件系统挂载在根文件系统的 /proc 目录下。procfs 下的数据不会持久化,所有操作都在内存中进行。
以下表格介绍了通过 procfs 暴露的一些结构:
| 结构 | 描述 |
|---|---|
/proc/cpuinfo | CPU 详情,如核心数、CPU 大小、制造商等。 |
/proc/meminfo | 物理内存信息。 |
/proc/interrupts | 中断和处理程序的信息。 |
/proc/vmstat | 虚拟内存统计。 |
/proc/filesystems | 当前内核中的活动文件系统。 |
/proc/mounts | 当前挂载点和设备;这将特定于挂载命名空间。 |
/proc/uptime | 自内核启动以来的时间。 |
/proc/stat | 系统统计信息。 |
/proc/net | 网络相关的结构,如 TCP 套接字、文件等。 |
proc 还通过文件暴露了一些进程特定的信息:
| 结构 | 描述 |
|---|---|
/proc/pid/cmdline | 进程的命令行名称。 |
/proc/pid/environ | 进程的环境变量。 |
/proc/pid/mem | 进程的虚拟内存。 |
/proc/pid/maps | 虚拟内存的映射。 |
/proc/pid/fdinfo | 进程的打开文件描述符。 |
/proc/pid/task | 子进程的详细信息。 |
理解分层文件系统
现在你对 Linux 中的文件系统有了更好的理解,我们可以来看看 Linux 中的分层文件系统。
分层文件系统允许在磁盘上共享文件,从而节省空间。由于这些文件在内存中共享(加载在页面缓存中),分层文件系统实现了优化的空间利用以及更快的启动速度。
考虑一个例子:在同一主机上运行十个 Cassandra 数据库,每个数据库运行自己的命名空间。如果我们为每个数据库的不同 inode 使用单独的文件系统,我们将无法享受到以下优势:
- 内存共享
- 磁盘共享
相比之下,分层文件系统被分解为多个层,每层是一个只读文件系统。由于这些层在同一主机上的容器之间共享,它们往往会优化存储的使用。而且,由于 inode 是相同的,它们引用相同的操作系统页面缓存。这使得从各个方面来看都非常优化。
这与基于虚拟机的配置相比,虚拟机每个根文件系统都被配置为一个磁盘。也就是说,它们在主机上有不同的 inode 表示,与容器相比,没有优化的存储。
虚拟机管理程序也倾向于使用诸如 KSM(内核相同页面合并)之类的技术,以便在虚拟机之间进行页面去重。
接下来,我们讨论一下联合文件系统的概念,它是一种分层文件系统。
联合文件系统
根据 Wikipedia,联合文件系统是一种适用于 Linux、FreeBSD 和 NetBSD 的文件系统服务,它实现了对其他文件系统的联合挂载。它允许将多个文件系统(称为分支)中的文件和目录透明地叠加,形成一个单一的连贯文件系统。任何具有相同路径的目录内容将在合并的分支中一起显示在一个合并目录中,位于新的虚拟文件系统中。
基本上,联合文件系统允许你将不同的文件系统合并其内容,顶层提供所有底层文件的视图。如果发现重复的文件,顶层将覆盖下面的层。
OverlayFS
本节将以 OverlayFS 为例来探讨联合文件系统。OverlayFS 从 Linux 内核 3.18 版本开始就成为了其一部分。正如其名字所示,OverlayFS 将一个目录的内容覆盖到另一个目录上。源目录可以位于不同的磁盘或文件系统上。
在 OverlayFS v1 中,只有两个层级,这两个层级用于创建一个统一的层,如图 5-3 所示。
OverlayFS v2 有三个层级:
- Base:这是基础层,主要是只读的。
- Overlay:这个层级提供了基础层的可见性,并允许用户添加新的文件或目录。如果基础层中的文件发生变化,这些变化将被存储到下一个层级。
- Diff:在 Overlay 层做出的更改将存储在 Diff 层中。对基础层中文件的任何更改都会导致将文件从基础层复制到 Diff 层,然后在 Diff 层中进行更改。
让我们看一个 OverlayFS v2 工作的示例:
我们在 Overlay 目录中创建一个文件,并可以看到它出现在 Diff 层中。
我们现在修改 test1 文件:
如果我们检查 diff 目录中的文件,会看到已经更改的文件。然而,如果我们去 base 目录,我们仍然看到旧的文件。这意味着当我们修改 base 目录中的文件时,它首先被复制到 diff 目录,然后才进行了更改。
在执行这些操作后,如果用户希望清理资源,可以执行以下命令来卸载 OverlayFS:
root@instance-1: umount overlay
卸载完成后,如果需要,也可以删除这些目录。
现在,让我们考虑容器引擎如 Docker 如何实现这个过程。在 Docker 中,有一个名为 Overlay2 的存储驱动器,更多信息可以在 github.com/moby/moby/b… 上找到。
Docker 创建了多个只读层(基础层)和一个读写层,称为容器层(在我们的例子中是 overlay 层)。
多个只读层可以在同一主机上的不同容器之间共享,从而实现非常高的优化。如前所述,由于我们有相同的文件系统和相同的 inode,操作系统的页面缓存也在同一主机上的所有容器之间共享。
与此相对,如果我们查看 Docker 驱动器设备映射器,由于它为每一层提供虚拟磁盘,我们可能无法体验到 OverlayFS 提供的共享。但是,现在即使在 Docker 中使用设备映射器,我们也可以通过传递 --shared-rootfs 选项给守护进程来共享 rootfs。这基本上是通过为第一个容器基础镜像创建一个设备,然后对后续容器执行绑定挂载来实现的。绑定挂载允许我们保留相同的 inode,因此页面缓存被共享。
总结
本章提供了文件系统的全面概述,重点介绍了分层文件系统的概念,尤其是 OverlayFS。解释了 Linux 如何将一切视作文件,并利用虚拟文件系统(VFS)来抽象各种文件系统。VFS 使用关键的数据结构,如 File、Inode、Dentry 和 Superblock,以高效管理文件操作。详细描述了页面缓存如何通过将最近访问的数据缓存在内存中来优化读写操作。接着,本章探讨了分层文件系统的优势,通过将多个只读层结合起来,实现文件的共享和高效管理。同时讨论了容器引擎如 Docker 如何利用 OverlayFS 来优化存储和促进 inode 的共享,从而更有效地使用操作系统页面缓存。