This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,226 @@
<audio id="audio" title="11 | 容器文件系统:我在容器中读写文件怎么变慢了?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/76/fd/7645ede689f3f086c97c6cc50c919dfd.mp3"></audio>
你好,我是程远。从这一讲开始,我们进入容器存储这个模块。
这一模块我们所讲的内容,都和容器里的文件读写密切相关。因为所有的容器的运行都需要一个容器文件系统,那么我们就从容器文件系统先开始讲起。
那我们还是和以前一样,先来看看我之前碰到了什么问题。
这个问题具体是我们在宿主机上把Linux从ubuntu18.04升级到ubuntu20.04之后发现的。
在我们做了宿主机的升级后启动了一个容器在容器里用fio这个磁盘性能测试工具想看一下容器里文件的读写性能。结果我们很惊讶地发现在ubuntu 20.04宿主机上的容器中文件读写的性能只有ubuntu18.04宿主机上的1/8左右了那这是怎么回事呢
## 问题再现
这里我提醒一下你因为涉及到两个Linux的虚拟机问题再现这里我为你列出了关键的结果输出截图不方便操作的同学可以重点看其中的思路。
我们可以先启动一个ubuntu18.04的虚拟机它的Linux内核版本是4.15的,然后在虚拟机上用命令 `docker run -it ubuntu:18.04 bash` 启动一个容器接着在容器里运行fio这条命令看一下在容器中读取文件的性能。
```
# fio -direct=1 -iodepth=64 -rw=read -ioengine=libaio -bs=4k -size=10G -numjobs=1 -name=./fio.test
```
这里我给你解释一下fio命令中的几个主要参数
第一个参数是"-direct=1"代表采用非buffered I/O文件读写的方式避免文件读写过程中内存缓冲对性能的影响。
接着我们来看这"-iodepth=64"和"-ioengine=libaio"这两个参数这里指文件读写采用异步I/OAsync I/O的方式也就是进程可以发起多个I/O请求并且不用阻塞地等待I/O的完成。稍后等I/O完成之后进程会收到通知。
这种异步I/O很重要因为它可以极大地提高文件读写的性能。在这里我们设置了同时发出64个I/O请求。
然后是"-rw=read-bs=4k-size=10G"这几个参数指这个测试是个读文件测试每次读4KB大小数块总共读10GB的数据。
最后一个参数是"-numjobs=1",指只有一个进程/线程在运行。
所以这条fio命令表示我们通过异步方式读取了10GB的磁盘文件用来计算文件的读取性能。
那我们看到在ubuntu 18.04内核4.15上的容器I/O性能是584MB/s的带宽IOPSI/O per second是150K左右。
<img src="https://static001.geekbang.org/resource/image/5d/5e/5df788a1c7fb9f5677557d6cb15c995e.png" alt="">
同样我们再启动一个ubuntu 20.04内核5.4的虚拟机,然后在它的上面也启动一个容器。
我们运行 `docker run -it ubuntu:20.04 bash` 接着在容器中使用同样的fio命令可以看到它的I/O性能是70MB带宽IOPS是18K左右。实践证明这的确比老版本的ubuntu 18.04差了很多。
<img src="https://static001.geekbang.org/resource/image/90/f1/90aff20c885286d4b6b5aed7b017a9f1.png" alt="">
## 知识详解
### 如何理解容器文件系统?
刚才我们对比了升级前后的容器读写性能差异,那想要分析刚刚说的这个性能的差异,我们需要先理解容器的文件系统。
我们在容器里,运行 `df` 命令,你可以看到在容器中根目录(/)的文件系统类型是"overlay"它不是我们在普通Linux节点上看到的Ext4或者XFS之类常见的文件系统。
那么看到这里你肯定想问Overlay是一个什么样的文件系统呢容器为什么要用这种文件系统别急我会一步一步带你分析。
<img src="https://static001.geekbang.org/resource/image/2f/b0/2fed851ba2df3232efbdca1d1cce19b0.png" alt="">
在说容器文件系统前我们先来想象一下如果没有文件系统管理的话会怎样。假设有这么一个场景在一个宿主机上需要运行100个容器。
在我们这个课程的[第一讲](https://time.geekbang.org/column/article/308108)里,我们就说过每个容器都需要一个镜像,这个镜像就把容器中程序需要运行的二进制文件,库文件,配置文件,其他的依赖文件等全部都打包成一个镜像文件。
如果没有特别的容器文件系统只是普通的Ext4或者XFS文件系统那么每次启动一个容器就需要把一个镜像文件下载并且存储在宿主机上。
我举个例子帮你理解比如说假设一个镜像文件的大小是500MB那么100个容器的话就需要下载500MB*100= 50GB的文件并且占用50GB的磁盘空间。
如果你再分析一下这50GB里的内容你会发现在绝大部分的操作系统里库文件都是差不多的。而且在容器运行的时候这类文件也不会被改动基本上都是只读的。
特别是这样的情况假如这100个容器镜像都是基于"ubuntu:18.04"的每个容器镜像只是额外复制了50MB左右自己的应用程序到"ubuntu: 18.04"里那么就是说在总共50GB的数据里有90%的数据是冗余的。
讲到这里,你不难推测出理想的情况应该是什么样的?
没错,当然是在一个宿主机上只要下载并且存储存一份"ubuntu:18.04",所有基于"ubuntu:18.04"镜像的容器都可以共享这一份通用的部分。这样设置的话,不同容器启动的时候,只需要下载自己独特的程序部分就可以。就像下面这张图展示的这样。
<img src="https://static001.geekbang.org/resource/image/c0/3f/c0119d9d2af9cf7386db13467027003f.jpg" alt="">
**正是为了有效地减少磁盘上冗余的镜像数据同时减少冗余的镜像数据在网络上的传输选择一种针对于容器的文件系统是很有必要的而这类的文件系统被称为UnionFS。**
UnionFS这类文件系统实现的主要功能是把多个目录处于不同的分区一起挂载mount在一个目录下。这种多目录挂载的方式正好可以解决我们刚才说的容器镜像的问题。
比如我们可以把ubuntu18.04这个基础镜像的文件放在一个目录ubuntu18.04/下容器自己额外的程序文件app_1_bin放在app_1/目录下。
然后我们把这两个目录挂载到container_1/这个目录下作为容器1看到的文件系统对于容器2就可以把ubuntu18.04/和app_2/两个目录一起挂载到container_2的目录下。
这样在节点上我们只要保留一份ubuntu18.04的文件就可以了。
<img src="https://static001.geekbang.org/resource/image/44/27/449669a1aaa8c631d7768369b275ed27.jpg" alt="">
### OverlayFS
UnionFS类似的有很多种实现包括在Docker里最早使用的AUFS还有目前我们使用的OverlayFS。前面我们在运行`df`的时候,看到的文件系统类型"overlay"指的就是OverlayFS。
在Linux内核3.18版本中OverlayFS代码正式合入Linux内核的主分支。在这之后OverlayFS也就逐渐成为各个主流Linux发行版本里缺省使用的容器文件系统了。
网上Julia Evans有个[blog](https://jvns.ca/blog/2019/11/18/how-containers-work--overlayfs/)里面有个的OverlayFS使用的例子很简单我们也拿这个例子来理解一下OverlayFS的一些基本概念。
你可以先执行一下这一组命令。
```
#!/bin/bash
umount ./merged
rm upper lower merged work -r
mkdir upper lower merged work
echo "I'm from lower!" &gt; lower/in_lower.txt
echo "I'm from upper!" &gt; upper/in_upper.txt
# `in_both` is in both directories
echo "I'm from lower!" &gt; lower/in_both.txt
echo "I'm from upper!" &gt; upper/in_both.txt
sudo mount -t overlay overlay \
-o lowerdir=./lower,upperdir=./upper,workdir=./work \
./merged
```
我们可以看到OverlayFS的一个mount命令牵涉到四类目录分别是loweruppermerged和work那它们是什么关系呢
我们看下面这张图这和前面UnionFS的工作示意图很像也不奇怪OverlayFS就是UnionFS的一种实现。接下来我们从下往上依次看看每一层的功能。
首先,最下面的"lower/"也就是被mount两层目录中底下的这层lowerdir
在OverlayFS中最底下这一层里的文件是不会被修改的你可以认为它是只读的。我还想提醒你一点在这个例子里我们只有一个lower/目录不过OverlayFS是支持多个lowerdir的。
然后我们看"uppder/"它是被mount两层目录中上面的这层 upperdir。在OverlayFS中如果有文件的创建修改删除操作那么都会在这一层反映出来它是可读写的。
接着是最上面的"merged" 它是挂载点mount point目录也是用户看到的目录用户的实际文件操作在这里进行。
其实还有一个"work/"这个目录没有在这个图里它只是一个存放临时文件的目录OverlayFS中如果有文件修改就会在中间过程中临时存放文件到这里。
<img src="https://static001.geekbang.org/resource/image/ca/5d/ca894a91e0171a027ba0ded6cdf2a95d.jpg" alt="">
从这个例子我们可以看到OverlayFS会mount两层目录分别是lower层和upper层这两层目录中的文件都会映射到挂载点上。
从挂载点的视角看upper层的文件会覆盖lower层的文件比如"in_both.txt"这个文件在lower层和upper层都有但是挂载点merged/里看到的只是upper层里的in_both.txt.
如果我们在merged/目录里做文件操作,具体包括这三种。
第一种新建文件这个文件会出现在upper/ 目录中。
第二种是删除文件,如果我们删除"in_upper.txt"那么这个文件会在upper/目录中消失。如果删除"in_lower.txt", 在 lower/目录里的"in_lower.txt"文件不会有变化,只是在 upper/目录中增加了一个特殊文件来告诉OverlayFS"in_lower.txt'这个文件不能出现在merged/里了,这就表示它已经被删除了。
<img src="https://static001.geekbang.org/resource/image/f3/2a/f3813b984193e3aebebe1b5104f75e2a.png" alt="">
还有一种操作是修改文件,类似如果修改"in_lower.txt"那么就会在upper/目录中新建一个"in_lower.txt"文件包含更新的内容而在lower/中的原来的实际文件"in_lower.txt"不会改变。
通过这个例子我们知道了OverlayFS是怎么工作了。那么我们可以再想一想怎么把它运用到容器的镜像文件上
其实也不难从系统的mounts信息中我们可以看到Docker是怎么用OverlayFS来挂载镜像文件的。容器镜像文件可以分成多个层layer每层可以对应OverlayFS里lowerdir的一个目录lowerdir支持多个目录也就可以支持多层的镜像文件。
在容器启动后对镜像文件中修改就会被保存在upperdir里了。
<img src="https://static001.geekbang.org/resource/image/55/26/55a7059809afdd3d51e5a6b3f5c83626.png" alt="">
## 解决问题
在理解了容器使用的OverlayFS文件系统后我们再回到开始的问题为什么在宿主机升级之后在容器里读写文件的性能降低了现在我们至少应该知道在容器中读写文件性能降低了那么应该是OverlayFS的性能在新的ubuntu20.04中降低了。
要找到问题的根因我们还需要进一步的debug。对于性能问题我们需要使用Linux下的perf工具来查看一下具体怎么使用perf来解决问题我们会在后面讲解。
这里你只要看一下结果就可以了自下而上是函数的一个调用顺序。通过perf工具我们可以比较在容器中运行fio的时候ubuntu 18.04和ubuntu 20.04在内核函数调用上的不同。
<img src="https://static001.geekbang.org/resource/image/6d/7a/6d970f9cf76bd0875ff3e505900b1b7a.png" alt="" title="ubuntu 18.04 (Linux内核4.15)环境下使用perf输出的函数调用结果">
<img src="https://static001.geekbang.org/resource/image/46/1f/466cd0da98f4170111c5ce2436f2ed1f.png" alt="" title="ubuntu 20.04 (Linux内核 5.4)环境下使用perf输出的函数调用结果">
我们从系统调用框架之后的函数aio_read()开始比较Linux内核4.15里aio_read()之后调用的是xfs_file_read_iter()而在Linux 内核5.4里aio_read()之后调用的是ovl_read_iter()这个函数之后再调用xfs_file_read_iter()。
这样我们就可以去查看一下在内核4.15之后新加入的这个函数ovl_read_iter()的代码。
查看[代码](https://lwn.net/Articles/755889/)后我们就能明白Linux为了完善OverlayFS增加了OverlayFS自己的read/write函数接口从而不再直接调用OverlayFS后端文件系统比如XFSExt4的读写接口。但是它只实现了同步I/Osync I/O并没有实现异步I/O。
而在fio做文件系统性能测试的时候使用的是异步I/O这样才可以得到文件系统的性能最大值。所以在内核5.4上就无法对OverlayFS测出最高的性能指标了。
在Linux内核5.6版本中,这个问题已经通过下面的这个补丁给解决了,有兴趣的同学可以看一下。
```
commit 2406a307ac7ddfd7effeeaff6947149ec6a95b4e
Author: Jiufei Xue &lt;jiufei.xue@linux.alibaba.com&gt;
Date: Wed Nov 20 17:45:26 2019 +0800
ovl: implement async IO routines
A performance regression was observed since linux v4.19 with aio test using
fio with iodepth 128 on overlayfs. The queue depth of the device was
always 1 which is unexpected.
After investigation, it was found that commit 16914e6fc7e1 ("ovl: add
ovl_read_iter()") and commit 2a92e07edc5e ("ovl: add ovl_write_iter()")
resulted in vfs_iter_{read,write} being called on underlying filesystem,
which always results in syncronous IO.
Implement async IO for stacked reading and writing. This resolves the
performance regresion.
This is implemented by allocating a new kiocb for submitting the AIO
request on the underlying filesystem. When the request is completed, the
new kiocb is freed and the completion callback is called on the original
iocb.
Signed-off-by: Jiufei Xue &lt;jiufei.xue@linux.alibaba.com&gt;
Signed-off-by: Miklos Szeredi &lt;mszeredi@redhat.com&gt;
```
## 重点总结
这一讲,我们最主要的内容是理解容器文件系统。为什么要有容器自己的文件系统?很重要的一点是**减少相同镜像文件在同一个节点上的数据冗余,可以节省磁盘空间,也可以减少镜像文件下载占用的网络资源。**
作为容器文件系统UnionFS通过多个目录挂载的方式工作。OverlayFS就是UnionFS的一种实现是目前主流Linux发行版本中缺省使用的容器文件系统。
OverlayFS也是把多个目录合并挂载被挂载的目录分为两大类lowerdir和upperdir。
lowerdir允许有多个目录在被挂载后这些目录里的文件都是不会被修改或者删除的也就是只读的upperdir只有一个不过这个目录是可读写的挂载点目录中的所有文件修改都会在upperdir中反映出来。
容器的镜像文件中各层正好作为OverlayFS的lowerdir的目录然后加上一个空的upperdir一起挂载好后就组成了容器的文件系统。
OverlayFS在Linux内核中还在不断的完善比如我们在这一讲看到的在kenel 5.4中对异步I/O操作的缺失这也是我们在使用容器文件系统的时候需要注意的。
## 思考题
在这一讲OverlayFS的[例子](https://github.com/chengyli/training/blob/main/filesystem/overlayfs/test_overlayfs.sh)的基础上建立2个lowerdir的目录并且在目录中建立相同文件名的文件然后一起做一个overlay mount看看会发生什么
欢迎在留言区和我分享你的思考和疑问。如果这篇文章让你有所收获,也欢迎分享给你的同事、朋友,一起学习探讨。

View File

@@ -0,0 +1,234 @@
<audio id="audio" title="12 | 容器文件Quota容器为什么把宿主机的磁盘写满了" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fc/c4/fc13b1771af810738b26c6005dc214c4.mp3"></audio>
你好我是程远。今天我们聊一聊容器文件Quota。
上一讲我们学习了容器文件系统OverlayFS这个OverlayFS有两层分别是lowerdir和upperdir。lowerdir里是容器镜像中的文件对于容器来说是只读的upperdir存放的是容器对文件系统里的所有改动它是可读写的。
从宿主机的角度看upperdir就是一个目录如果容器不断往容器文件系统中写入数据实际上就是往宿主机的磁盘上写数据这些数据也就存在于宿主机的磁盘目录中。
当然对于容器来说如果有大量的写操作是不建议写入容器文件系统的一般是需要给容器挂载一个volume用来满足大量的文件读写。
但是不能避免的是,用户在容器中运行的程序有错误,或者进行了错误的配置。
比如说我们把log写在了容器文件系统上并且没有做log rotation那么时间一久就会导致宿主机上的磁盘被写满。这样影响的就不止是容器本身了而是整个宿主机了。
那对于这样的问题,我们该怎么解决呢?
## 问题再现
我们可以自己先启动一个容器,一起试试不断地往容器文件系统中写入数据,看看是一个什么样的情况。
用Docker启动一个容器后我们看到容器的根目录(/)也就是容器文件系统OverlayFS它的大小是160G已经使用了100G。其实这个大小也是宿主机上的磁盘空间和使用情况。
<img src="https://static001.geekbang.org/resource/image/d3/3e/d32c83e404a81b301fbf8bdfd7a9c23e.png" alt="">
这时候,我们可以回到宿主机上验证一下,就会发现宿主机的根目录(/)的大小也是160G同样是使用了100G。
<img src="https://static001.geekbang.org/resource/image/b5/0b/b54deb08e46ff1155303581e9d1c8f0b.png" alt="">
那现在我们再往容器的根目录里写入10GB的数据。
这里我们可以看到容器的根目录使用的大小增加了从刚才的100G变成现在的110G。而多写入的10G大小的数据对应的是test.log这个文件。
<img src="https://static001.geekbang.org/resource/image/c0/46/c04dcff3aa4773302495113dd2a8d546.png" alt="">
接下来,我们再回到宿主机上,可以看到宿主机上的根目录(/)里使用的大小也是110G了。
<img src="https://static001.geekbang.org/resource/image/15/9d/155d30bc20b72c0678d1948f25cbe29d.png" alt="">
我们还是继续看宿主机看看OverlayFS里upperdir目录中有什么文件
这里我们仍然可以通过/proc/mounts这个路径找到容器OverlayFS对应的lowerdir和upperdir。因为写入的数据都在upperdir里我们就只要看upperdir对应的那个目录就行了。果然里面存放着容器写入的文件test.log它的大小是10GB。
<img src="https://static001.geekbang.org/resource/image/4d/7d/4d32334dc0f1ba69881c686037f6577d.png" alt="">
通过这个例子我们已经验证了在容器中对于OverlayFS中写入数据**其实就是往宿主机的一个目录upperdir里写数据。**我们现在已经写了10GB的数据如果继续在容器中写入数据结果估计你也知道了就是会写满宿主机的磁盘。
那遇到这种情况,我们该怎么办呢?
## 知识详解
容器写自己的OverlayFS根目录结果把宿主机的磁盘写满了。发生这个问题我们首先就会想到需要对容器做限制限制它写入自己OverlayFS的数据量比如只允许一个容器写100MB的数据。
不过我们实际查看OverlayFS文件系统的特性就会发现没有直接限制文件写入量的特性。别担心在没有现成工具的情况下我们只要搞懂了原理就能想出解决办法。
所以我们再来分析一下OverlayFS它是通过lowerdir和upperdir两层目录联合挂载来实现的lowerdir是只读的数据只会写在upperdir中。
那我们是不是可以通过限制upperdir目录容量的方式来限制一个容器OverlayFS根目录的写入数据量呢
沿着这个思路继续往下想因为upperdir在宿主机上也是一个普通的目录这样就要看**宿主机上的文件系统是否可以支持对一个目录限制容量了。**
对于Linux上最常用的两个文件系统XFS和ext4它们有一个特性Quota那我们就以XFS文件系统为例学习一下这个Quota概念然后看看这个特性能不能限制一个目录的使用量。
### XFS Quota
在Linux系统里的XFS文件系统缺省都有Quota的特性这个特性可以为Linux系统里的一个用户user一个用户组group或者一个项目project来限制它们使用文件系统的额度quota也就是限制它们可以写入文件系统的文件总量。
因为我们的目标是要限制一个目录中总体的写入文件数据量,那么显然给用户和用户组限制文件系统的写入数据量的模式,并不适合我们的这个需求。
因为同一个用户或者用户组可以操作多个目录,多个用户或者用户组也可以操作同一个目录,这样对一个用户或者用户组的限制,就很难用来限制一个目录。
那排除了限制用户或用户组的模式我们再来看看Project模式。Project模式是怎么工作的呢
我举一个例子你会更好理解对Linux熟悉的同学可以一边操作一边体会一下它的工作方式。不熟悉的同学也没关系可以重点关注我后面的讲解思路。
首先我们要使用XFS Quota特性必须在文件系统挂载的时候加上对应的Quota选项比如我们目前需要配置Project Quota那么这个挂载参数就是"pquota"。
对于根目录来说,**这个参数必须作为一个内核启动的参数"rootflags=pquota"这样设置就可以保证根目录在启动挂载的时候带上XFS Quota的特性并且支持Project模式。**
我们可以从/proc/mounts信息里看看根目录是不是带"prjquota"字段。如果里面有这个字段就可以确保文件系统已经带上了支持project模式的XFS quota特性。
<img src="https://static001.geekbang.org/resource/image/72/3d/72d653f67717fe047c98fce37156da3d.png" alt="">
下一步我们还需要给一个指定的目录打上一个Project ID。这个步骤我们可以使用XFS文件系统自带的工具 [xfs_quota](https://linux.die.net/man/8/xfs_quota) 来完成,然后执行下面的这个命令就可以了。
执行命令之前,我先对下面的命令和输出做两点解释,让你理解这个命令的含义。
第一点,新建的目录/tmp/xfs_prjquota我们想对它做Quota限制。所以在这里要对它打上一个Project ID。
第二点通过xfs_quota这条命令我们给/tmp/xfs_prjquota打上Project ID值101这个101是我随便选的一个数字就是个ID标识你先有个印象。在后面针对Project进行Quota限制的时候我们还会用到这个ID。
```
# mkdir -p /tmp/xfs_prjquota
# xfs_quota -x -c 'project -s -p /tmp/xfs_prjquota 101' /
Setting up project 101 (path /tmp/xfs_prjquota)...
Processed 1 (/etc/projects and cmdline) paths for project 101 with recursion depth infinite (-1).
```
最后我们还是使用xfs_quota命令对101我们刚才建立的这个Project ID做Quota限制。
你可以执行下面这条命令,里面的"-p bhard=10m 101"就代表限制101这个project ID限制它的数据块写入量不能超过10MB。
```
# xfs_quota -x -c 'limit -p bhard=10m 101' /
```
做好限制之后,我们可以尝试往/tmp/xfs_prjquota写数据看看是否可以超过10MB。比如说我们尝试写入20MB的数据到/tmp/xfs_prjquota里。
我们可以看到执行dd写入命令就会有个出错返回信息"No space left on device"。这表示已经不能再往这个目录下写入数据了而最后写入数据的文件test.file大小也停留在了10MB。
```
# dd if=/dev/zero of=/tmp/xfs_prjquota/test.file bs=1024 count=20000
dd: error writing '/tmp/xfs_prjquota/test.file': No space left on device
10241+0 records in
10240+0 records out
10485760 bytes (10 MB, 10 MiB) copied, 0.0357122 s, 294 MB/s
# ls -l /tmp/xfs_prjquota/test.file
-rw-r--r-- 1 root root 10485760 Oct 31 10:00 /tmp/xfs_prjquota/test.file
```
好了做到这里我们发现使用XFS Quota的Project模式确实可以限制一个目录里的写入数据量它实现的方式其实也不难就是下面这两步。
第一步给目标目录打上一个Project ID这个ID最终是写到目录对应的inode上。
这里我解释一下inode是文件系统中用来描述一个文件或者一个目录的元数据里面包含文件大小数据块的位置文件所属用户/组,文件读写属性以及其他一些属性。
那么一旦目录打上这个ID之后在这个目录下的新建的文件和目录也都会继承这个ID。
第二步在XFS文件系统中我们需要给这个project ID设置一个写入数据块的限制。
有了ID和限制值之后文件系统就可以统计所有带这个ID文件的数据块大小总和并且与限制值进行比较。一旦所有文件大小的总和达到限制值文件系统就不再允许更多的数据写入了。
用一句话概括XFS Quota就是通过前面这两步限制了一个目录里写入的数据量。
## 解决问题
我们理解了XFS Quota对目录限流的机制之后再回到我们最开始的问题如何确保容器不会写满宿主机上的磁盘。
你应该已经想到了,方法就是**对OverlayFS的upperdir目录做XFS Quota的限流**,没错,就是这个解决办法!
其实Docker也已经实现了限流功能也就是用XFS Quota来限制容器的OverlayFS大小。
我们在用 `docker run` 启动容器的时候,加上一个参数 `--storage-opt size= &lt;SIZE&gt;` 就能限制住容器OverlayFS文件系统可写入的最大数据量了。
我们可以一起试一下这里我们限制的size是10MB。
进入容器之后,先运行 `df -h` 命令,这时候你可以看到根目录(/)overlayfs文件系统的大小就10MB而不是我们之前看到的160GB的大小了。这样容器在它的根目录下最多只能写10MB数据就不会把宿主机的磁盘给写满了。
<img src="https://static001.geekbang.org/resource/image/a7/8a/a7906f56d9d107f0a290e610b8cd6f8a.png" alt="">
完成了上面这个小试验之后我们可以再看一下Docker的代码看看它的实现是不是和我们想的一样。
Docker里[SetQuota()](https://github.com/moby/moby/blob/19.03/daemon/graphdriver/quota/projectquota.go#L155)函数就是用来实现XFS Quota 限制的,我们可以看到它里面最重要的两步,分别是 `setProjectID``setProjectQuota`
其实,这两步做的就是我们在基本概念中提到的那两步:
第一步给目标目录打上一个Project ID第二步为这个Project ID在XFS文件系统中设置一个写入数据块的限制。
```
// SetQuota - assign a unique project id to directory and set the quota limits
// for that project id
func (q *Control) SetQuota(targetPath string, quota Quota) error {
q.RLock()
projectID, ok := q.quotas[targetPath]
q.RUnlock()
if !ok {
q.Lock()
projectID = q.nextProjectID
//
// assign project id to new container directory
//
err := setProjectID(targetPath, projectID)
if err != nil {
q.Unlock()
return err
}
q.quotas[targetPath] = projectID
q.nextProjectID++
q.Unlock()
}
//
// set the quota limit for the container's project id
//
logrus.Debugf("SetQuota(%s, %d): projectID=%d", targetPath, quota.Size, projectID)
return setProjectQuota(q.backingFsBlockDev, projectID, quota)
}
```
`setProjectID``setProjectQuota` 是如何实现的呢?
你可以进入到这两个函数里看一下,**它们分别调用了ioctl()和quotactl()这两个系统调用来修改内核中XFS的数据结构从而完成project ID的设置和Quota值的设置。**具体的细节,我不在这里展开了,如果你有兴趣,可以继续去查看内核中对应的代码。
好了Docker里XFS Quota操作的步骤完全和我们先前设想的一样那么还有最后一个问题要解决XFS Quota限制的目录是哪一个
这个我们可以根据/proc/mounts中容器的OverlayFS Mount信息再结合Docker的[代码](https://github.com/moby/moby/blob/19.03/daemon/graphdriver/overlay2/overlay.go#L335),就可以知道限制的目录是"/var/lib/docker/overlay2/&lt;docker_id&gt;"。那这个目录下有什么呢果然upperdir目录中有对应的"diff"目录,就在里面!
<img src="https://static001.geekbang.org/resource/image/4d/9f/4d4d995f052c9a8e3ff3e413c0e1199f.png" alt="">
讲到这里我想你已经清楚了对于使用OverlayFS的容器我们应该如何去防止它把宿主机的磁盘给写满了吧**方法就是对OverlayFS的upperdir目录做XFS Quota的限流。**
## 重点总结
我们这一讲的问题是容器写了大量数据到OverlayFS文件系统的根目录在这个情况下就会把宿主机的磁盘写满。
由于OverlayFS自己没有专门的特性可以限制文件数据写入量。这时我们通过实际试验找到了解决思路依靠底层文件系统的Quota特性来限制OverlayFS的upperdir目录的大小这样就能实现限制容器写磁盘的目的。
底层文件系统XFS Quota的Project模式能够限制一个目录的文件写入量这个功能具体是通过这两个步骤实现
第一步给目标目录打上一个Project ID。
第二步给这个Project ID在XFS文件系统中设置一个写入数据块的限制。
Docker正是使用了这个方法也就是**用XFS Quota来限制OverlayFS的upperdir目录**通过这个方式控制容器OverlayFS的根目录大小。
当我们理解了这个方法后对于不是用Docker启动的容器比如直接由containerd启动起来的容器也可以自己实现XFS Quota限制upperdir目录。这样就能有效控制容器对OverlayFS的写数据操作避免宿主机的磁盘被写满。
## 思考题
在正文知识详解的部分,我们使用"xfs_quota"给目录打了project ID并且限制了文件写入的数据量。那在做完这样的限制之后我们是否能用xfs_quota命令查询到被限制目录的project ID和限制的数据量呢
欢迎你在留言区分享你的思考或疑问。如果这篇文章让你有所收获,也欢迎转发给你的同事、朋友,一起交流和学习。

View File

@@ -0,0 +1,307 @@
<audio id="audio" title="13 | 容器磁盘限速:我的容器里磁盘读写为什么不稳定?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/25/9a/256fbea277961ca436358754e1ae0f9a.mp3"></audio>
你好,我是程远。今天我们聊一聊磁盘读写不稳定的问题。
上一讲我给你讲了如何通过XFS Quota来限制容器文件系统的大小这是静态容量大小的一个限制。
你也许会马上想到,磁盘除了容量的划分,还有一个读写性能的问题。
具体来说,就是如果多个容器同时读写节点上的同一块磁盘,那么它们的磁盘读写相互之间影响吗?如果容器之间读写磁盘相互影响,我们有什么办法解决呢?
接下来,我们就带着问题一起学习今天的内容。
## 场景再现
我们先用这里的[代码](https://github.com/chengyli/training/tree/master/filesystem/blkio),运行一下 `make image` 来做一个带fio的容器镜像fio在我们之前的课程里提到过它是用来测试磁盘文件系统读写性能的工具。
有了这个带fio的镜像我们可以用它启动一个容器在容器中运行fio就可以得到只有一个容器读写磁盘时的性能数据。
```
mkdir -p /tmp/test1
docker stop fio_test1;docker rm fio_test1
docker run --name fio_test1 --volume /tmp/test1:/tmp registery/fio:v1 fio -direct=1 -rw=write -ioengine=libaio -bs=4k -size=1G -numjobs=1 -name=/tmp/fio_test1.log
```
上面的这个Docker命令我给你简单地解释一下在这里我们第一次用到了"--volume"这个参数。之前我们讲过容器文件系统比如OverlayFS。
不过容器文件系统并不适合频繁地读写。对于频繁读写的数据,容器需要把他们到放到"volume"中。这里的volume可以是一个本地的磁盘也可以是一个网络磁盘。
在这个例子里我们就使用了宿主机本地磁盘,把磁盘上的/tmp/test1目录作为volume挂载到容器的/tmp目录下。
然后在启动容器之后我们直接运行fio的命令这里的参数和我们[第11讲](https://time.geekbang.org/column/article/318173)最开始的例子差不多只是这次我们运行的是write也就是写磁盘的操作而写的目标盘就是挂载到/tmp目录的volume。
可以看到fio的运行结果如下图所示IOPS是18K带宽(BW)是70MB/s左右。
<img src="https://static001.geekbang.org/resource/image/a8/54/a8a156d4a543bc02133751a14ba5a354.png" alt="">
好了,刚才我们模拟了一个容器写磁盘的性能。那么如果这时候有两个容器,都在往同一个磁盘上写数据又是什么情况呢?我们可以再用下面的这个脚本试一下:
```
mkdir -p /tmp/test1
mkdir -p /tmp/test2
docker stop fio_test1;docker rm fio_test1
docker stop fio_test2;docker rm fio_test2
docker run --name fio_test1 --volume /tmp/test1:/tmp registery/fio:v1 fio -direct=1 -rw=write -ioengine=libaio -bs=4k -size=1G -numjobs=1 -name=/tmp/fio_test1.log &amp;
docker run --name fio_test2 --volume /tmp/test2:/tmp registery/fio:v1 fio -direct=1 -rw=write -ioengine=libaio -bs=4k -size=1G -numjobs=1 -name=/tmp/fio_test2.log &amp;
```
这时候我们看到的结果在容器fio_test1里IOPS是15K左右带宽是59MB/s了比之前单独运行的时候性能下降了不少。
<img src="https://static001.geekbang.org/resource/image/cb/64/cb2f19b2da651b03521804e22f14b864.png" alt="">
显然从这个例子中,我们可以看到多个容器同时写一块磁盘的时候,它的性能受到了干扰。那么有什么办法可以保证每个容器的磁盘读写性能呢?
之前我们讨论过用Cgroups来保证容器的CPU使用率以及控制Memroy的可用大小。那么你肯定想到了我们是不是也可以用Cgroups来保证每个容器的磁盘读写性能
没错在Cgroup v1中有blkio子系统它可以来限制磁盘的I/O。不过blkio子系统对于磁盘I/O的限制并不像CPUMemory那么直接下面我会详细讲解。
## 知识详解
### Blkio Cgroup
在讲解blkio Cgroup 前,我们先简单了解一下衡量磁盘性能的**两个常见的指标IOPS和吞吐量Throughput**是什么意思后面讲Blkio Cgroup的参数配置时会用到。
IOPS是Input/Output Operations Per Second的简称也就是每秒钟磁盘读写的次数这个数值越大当然也就表示性能越好。
吞吐量Throughput是指每秒钟磁盘中数据的读取量一般以MB/s为单位。这个读取量可以叫作吞吐量有时候也被称为带宽Bandwidth。刚才我们用到的fio显示结果就体现了带宽。
IOPS和吞吐量之间是有关联的在IOPS固定的情况下如果读写的每一个数据块越大那么吞吐量也越大它们的关系大概是这样的吞吐量=数据块大小*IOPS。
那么我们再回到blkio Cgroup这个概念上blkio Cgroup也是Cgroups里的一个子系统。 在Cgroups v1里blkio Cgroup的虚拟文件系统挂载点一般在"/sys/fs/cgroup/blkio/"。
和我之前讲过的CPUmemory Cgroup一样我们在这个"/sys/fs/cgroup/blkio/"目录下创建子目录作为控制组再把需要做I/O限制的进程pid写到控制组的cgroup.procs参数中就可以了。
在blkio Cgroup中有四个最主要的参数它们可以用来限制磁盘I/O性能我列在了下面。
```
blkio.throttle.read_iops_device
blkio.throttle.read_bps_device
blkio.throttle.write_iops_device
blkio.throttle.write_bps_device
```
前面我们刚说了磁盘I/O的两个主要性能指标IOPS和吞吐量在这里根据这四个参数的名字估计你已经大概猜到它们的意思了。
没错它们分别表示磁盘读取IOPS限制磁盘读取吞吐量限制磁盘写入IOPS限制磁盘写入吞吐量限制。
对于每个参数写入值的格式,你可以参考内核[blkio的文档](https://www.kernel.org/doc/Documentation/cgroup-v1/blkio-controller.txt)。为了让你更好地理解,在这里我给你举个例子。
如果我们要对一个控制组做限制,限制它对磁盘/dev/vdb的写入吞吐量不超过10MB/s那么我们对blkio.throttle.write_bps_device参数的配置就是下面这个命令。
```
echo "252:16 10485760" &gt; $CGROUP_CONTAINER_PATH/blkio.throttle.write_bps_device
```
在这个命令中,"252:16"是 /dev/vdb的主次设备号你可以通过 `ls -l /dev/vdb` 看到这两个值,而后面的"10485760"就是10MB的每秒钟带宽限制。
```
# ls -l /dev/vdb -l
brw-rw---- 1 root disk 252, 16 Nov 2 08:02 /dev/vdb
```
了解了blkio Cgroup的参数配置我们再运行下面的这个例子限制一个容器blkio的读写磁盘吞吐量然后在这个容器里运行一下fio看看结果是什么。
```
mkdir -p /tmp/test1
rm -f /tmp/test1/*
docker stop fio_test1;docker rm fio_test1
docker run -d --name fio_test1 --volume /tmp/test1:/tmp registery/fio:v1 sleep 3600
sleep 2
CONTAINER_ID=$(sudo docker ps --format "{{.ID}}\t{{.Names}}" | grep -i fio_test1 | awk '{print $1}')
echo $CONTAINER_ID
CGROUP_CONTAINER_PATH=$(find /sys/fs/cgroup/blkio/ -name "*$CONTAINER_ID*")
echo $CGROUP_CONTAINER_PATH
# To get the device major and minor id from /dev for the device that /tmp/test1 is on.
echo "253:0 10485760" &gt; $CGROUP_CONTAINER_PATH/blkio.throttle.read_bps_device
echo "253:0 10485760" &gt; $CGROUP_CONTAINER_PATH/blkio.throttle.write_bps_device
docker exec fio_test1 fio -direct=1 -rw=write -ioengine=libaio -bs=4k -size=100MB -numjobs=1 -name=/tmp/fio_test1.log
docker exec fio_test1 fio -direct=1 -rw=read -ioengine=libaio -bs=4k -size=100MB -numjobs=1 -name=/tmp/fio_test1.log
```
在这里,我的机器上/tmp/test1所在磁盘主次设备号是”253:0”你在自己运行这组命令的时候需要把主次设备号改成你自己磁盘的对应值。
还有一点我要提醒一下,不同数据块大小,在性能测试中可以适用于不同的测试目的。但因为这里不是我们要讲的重点,所以为了方便你理解概念,这里就用固定值。
在我们后面的例子里fio读写的数据块都固定在4KB。所以对于磁盘的性能限制我们在blkio Cgroup里就只设置吞吐量限制了。
在加了blkio Cgroup限制10MB/s后从fio运行后的输出结果里我们可以看到这个容器对磁盘无论是读还是写它的最大值就不会再超过10MB/s了。
<img src="https://static001.geekbang.org/resource/image/e2/b3/e26118e821a4b936521eacac924c7db3.png" alt=""><br>
<img src="https://static001.geekbang.org/resource/image/0a/f5/0ae074c568161d24e57d37d185a47af5.png" alt="">
在给每个容器都加了blkio Cgroup限制限制为10MB/s后即使两个容器同时在一个磁盘上写入文件那么每个容器的写入磁盘的最大吞吐量也不会互相干扰了。
我们可以用下面的这个脚本来验证一下。
```
#!/bin/bash
mkdir -p /tmp/test1
rm -f /tmp/test1/*
docker stop fio_test1;docker rm fio_test1
mkdir -p /tmp/test2
rm -f /tmp/test2/*
docker stop fio_test2;docker rm fio_test2
docker run -d --name fio_test1 --volume /tmp/test1:/tmp registery/fio:v1 sleep 3600
docker run -d --name fio_test2 --volume /tmp/test2:/tmp registery/fio:v1 sleep 3600
sleep 2
CONTAINER_ID1=$(sudo docker ps --format "{{.ID}}\t{{.Names}}" | grep -i fio_test1 | awk '{print $1}')
echo $CONTAINER_ID1
CGROUP_CONTAINER_PATH1=$(find /sys/fs/cgroup/blkio/ -name "*$CONTAINER_ID1*")
echo $CGROUP_CONTAINER_PATH1
# To get the device major and minor id from /dev for the device that /tmp/test1 is on.
echo "253:0 10485760" &gt; $CGROUP_CONTAINER_PATH1/blkio.throttle.read_bps_device
echo "253:0 10485760" &gt; $CGROUP_CONTAINER_PATH1/blkio.throttle.write_bps_device
CONTAINER_ID2=$(sudo docker ps --format "{{.ID}}\t{{.Names}}" | grep -i fio_test2 | awk '{print $1}')
echo $CONTAINER_ID2
CGROUP_CONTAINER_PATH2=$(find /sys/fs/cgroup/blkio/ -name "*$CONTAINER_ID2*")
echo $CGROUP_CONTAINER_PATH2
# To get the device major and minor id from /dev for the device that /tmp/test1 is on.
echo "253:0 10485760" &gt; $CGROUP_CONTAINER_PATH2/blkio.throttle.read_bps_device
echo "253:0 10485760" &gt; $CGROUP_CONTAINER_PATH2/blkio.throttle.write_bps_device
docker exec fio_test1 fio -direct=1 -rw=write -ioengine=libaio -bs=4k -size=100MB -numjobs=1 -name=/tmp/fio_test1.log &amp;
docker exec fio_test2 fio -direct=1 -rw=write -ioengine=libaio -bs=4k -size=100MB -numjobs=1 -name=/tmp/fio_test2.log &amp;
```
我们还是看看fio运行输出的结果这时候fio_test1和fio_test2两个容器里执行的结果都是10MB/s了。
<img src="https://static001.geekbang.org/resource/image/67/a9/6719cc30a8e2933dae1ba6f96235e4a9.png" alt=""><br>
<img src="https://static001.geekbang.org/resource/image/de/c8/de4be66c72ff9e4cdc5007fe71f848c8.png" alt="">
那么做到了这一步我们是不是就可以认为blkio Cgroup可以完美地对磁盘I/O做限制了呢
你先别急我们可以再做个试验把前面脚本里fio命令中的 “-direct=1” 给去掉也就是不让fio运行在Direct I/O模式了而是用Buffered I/O模式再运行一次看看fio执行的输出。
同时我们也可以运行iostat命令查看实际的磁盘写入速度。
这时候你会发现即使我们设置了blkio Cgroup也根本不能限制磁盘的吞吐量了。
### Direct I/O 和 Buffered I/O
为什么会这样的呢这就要提到Linux的两种文件I/O模式了Direct I/O和Buffered I/O。
Direct I/O 模式用户进程如果要写磁盘文件就会通过Linux内核的文件系统层(filesystem) -&gt; 块设备层(block layer) -&gt; 磁盘驱动 -&gt; 磁盘硬件,这样一路下去写入磁盘。
而如果是Buffered I/O模式那么用户进程只是把文件数据写到内存中Page Cache就返回了而Linux内核自己有线程会把内存中的数据再写入到磁盘中。**在Linux里由于考虑到性能问题绝大多数的应用都会使用Buffered I/O模式。**
<img src="https://static001.geekbang.org/resource/image/10/46/1021f5f7ec700f3c7c66cbf8e07b1a46.jpeg" alt="">
我们通过前面的测试发现Direct I/O可以通过blkio Cgroup来限制磁盘I/O但是Buffered I/O不能被限制。
那通过上面的两种I/O模式的解释你是不是可以想到原因呢是的原因就是被Cgroups v1的架构限制了。
我们已经学习过了v1 的CPU Cgroupmemory Cgroup和blkio Cgroup那么Cgroup v1的一个整体结构你应该已经很熟悉了。它的每一个子系统都是独立的资源的限制只能在子系统中发生。
就像下面图里的进程pid_y它可以分别属于memory Cgroup和blkio Cgroup。但是在blkio Cgroup对进程pid_y做磁盘I/O做限制的时候blkio子系统是不会去关心pid_y用了哪些内存哪些内存是不是属于Page Cache而这些Page Cache的页面在刷入磁盘的时候产生的I/O也不会被计算到进程pid_y上面。
就是这个原因导致了blkio 在Cgroups v1里不能限制Buffered I/O。
<img src="https://static001.geekbang.org/resource/image/32/ba/32c69a6f69c4ce7f11c842450fe7d9ba.jpeg" alt="">
这个Buffered I/O限速的问题在Cgroup V2里得到了解决其实这个问题也是促使Linux开发者重新设计Cgroup V2的原因之一。
## Cgroup V2
Cgroup v2相比Cgroup v1做的最大的变动就是一个进程属于一个控制组而每个控制组里可以定义自己需要的多个子系统。
比如下面的Cgroup V2示意图里进程pid_y属于控制组group2而在group2里同时打开了io和memory子系统 Cgroup V2里的io子系统就等同于Cgroup v1里的blkio子系统
那么Cgroup对进程pid_y的磁盘 I/O做限制的时候就可以考虑到进程pid_y写入到Page Cache内存的页面了这样buffered I/O的磁盘限速就实现了。
<img src="https://static001.geekbang.org/resource/image/8a/46/8ae3f3282b9f19720b764c696959bf46.jpeg" alt="">
下面我们在Cgroup v2里尝试一下设置了blkio Cgroup+Memory Cgroup之后是否可以对Buffered I/O进行磁盘限速。
我们要做的第一步就是在Linux系统里打开Cgroup v2的功能。因为目前即使最新版本的Ubuntu Linux或者Centos Linux仍然在使用Cgroup v1作为缺省的Cgroup。
打开方法就是配置一个kernel参数"cgroup_no_v1=blkio,memory"这表示把Cgroup v1的blkio和Memory两个子系统给禁止这样Cgroup v2的io和Memory这两个子系统就打开了。
我们可以把这个参数配置到grub中然后我们重启Linux机器这时Cgroup v2的 io还有Memory这两个子系统它们的功能就打开了。
系统重启后我们会看到Cgroup v2的虚拟文件系统被挂载到了 /sys/fs/cgroup/unified目录下。
然后我们用下面的这个脚本做Cgroup v2 io的限速配置并且运行fio看看buffered I/O是否可以被限速。
```
# Create a new control group
mkdir -p /sys/fs/cgroup/unified/iotest
# enable the io and memory controller subsystem
echo "+io +memory" &gt; /sys/fs/cgroup/unified/cgroup.subtree_control
# Add current bash pid in iotest control group.
# Then all child processes of the bash will be in iotest group too,
# including the fio
echo $$ &gt;/sys/fs/cgroup/unified/iotest/cgroup.procs
# 256:16 are device major and minor ids, /mnt is on the device.
echo "252:16 wbps=10485760" &gt; /sys/fs/cgroup/unified/iotest/io.max
cd /mnt
#Run the fio in non direct I/O mode
fio -iodepth=1 -rw=write -ioengine=libaio -bs=4k -size=1G -numjobs=1 -name=./fio.test
```
在这个例子里我们建立了一个名叫iotest的控制组并且在这个控制组里加入了io和Memory两个控制子系统对磁盘最大吞吐量的设置为10MB。运行fio的时候不加"-direct=1"也就是让fio运行在buffered I/O模式下。
运行fio写入1GB的数据后你会发现fio马上就执行完了因为系统上有足够的内存fio把数据写入内存就返回了不过只要你再运行”iostat -xz 10” 这个命令你就可以看到磁盘vdb上稳定的写入速率是10240wkB/s也就是我们在io Cgroup里限制的10MB/s。
<img src="https://static001.geekbang.org/resource/image/81/c6/8174902114e01369193945891d054cc6.png" alt="">
看到这个结果我们证实了Cgoupv2 io+Memory两个子系统一起使用就可以对buffered I/O控制磁盘写入速率。
## 重点总结
这一讲,我们主要想解决的问题是如何保证容器读写磁盘速率的稳定,特别是当多个容器同时读写同一个磁盘的时候,需要减少相互的干扰。
Cgroup V1的blkiio控制子系统可以用来限制容器中进程的读写的IOPS和吞吐量Throughput但是它只能对于Direct I/O的读写文件做磁盘限速对Buffered I/O的文件读写它无法进行磁盘限速。
**这是因为Buffered I/O会把数据先写入到内存Page Cache中然后由内核线程把数据写入磁盘而Cgroup v1 blkio的子系统独立于memory 子系统无法统计到由Page Cache刷入到磁盘的数据量。**
这个Buffered I/O无法被限速的问题在Cgroup v2里被解决了。Cgroup v2从架构上允许一个控制组里有多个子系统协同运行这样在一个控制组里只要同时有io和Memory子系统就可以对Buffered I/O 作磁盘读写的限速。
虽然Cgroup v2 解决了Buffered I/O 磁盘读写限速的问题但是在现实的容器平台上也不是能够立刻使用的还需要等待一段时间。目前从runC、containerd到Kubernetes都是刚刚开始支持Cgroup v2而对生产环境中原有运行Cgroup v1的节点要迁移转化成Cgroup v2需要一个过程。
## 思考题
最后呢,我给你留一道思考题。 其实这是一道操作题,通过这个操作你可以再理解一下 blkio Cgroup与 Buffered I/O的关系。
在Cgroup v1的环境里我们在blkio Cgroup v1的例子基础上把 fio 中"direct=1"参数去除之后再运行fio同时运行iostat查看实际写入磁盘的速率确认Cgroup v1 blkio无法对Buffered I/O限速。
欢迎你在留言区分享你的收获和疑问。如果这篇文章给你带来了启发,也欢迎转发给你的朋友,一起学习和交流。

View File

@@ -0,0 +1,206 @@
<audio id="audio" title="14 | 容器中的内存与I/O容器写文件的延时为什么波动很大" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ab/9b/ab4c658511497d5e94fd9a7bf828b09b.mp3"></audio>
你好,我是程远。这一讲,我们继续聊一聊容器中写文件性能波动的问题。
你应该还记得,我们[上一讲](https://time.geekbang.org/column/article/320123)中讲过Linux中的两种I/O模式Direct I/O和Buffered I/O。
对于Linux的系统调用write()来说Buffered I/O是缺省模式使用起来比较方便而且从用户角度看在大多数的应用场景下用Buffered I/O的write()函数调用返回要快一些。所以Buffered I/O在程序中使用得更普遍一些。
当使用Buffered I/O的应用程序从虚拟机迁移到容器这时我们就会发现多了Memory Cgroup的限制之后write()写相同大小的数据块花费的时间,延时波动会比较大。
这是怎么回事呢?接下来我们就带着问题开始今天的学习。
## 问题再现
我们可以先动手写一个[小程序](https://github.com/chengyli/training/blob/master/filesystem/writeback/bin/test_iowrite),用来模拟刚刚说的现象。
这个小程序我们这样来设计从一个文件中每次读取一个64KB大小的数据块然后写到一个新文件中它可以不断读写10GB大小的数据。同时我们在这个小程序中做个记录记录写每个64KB的数据块需要花费的时间。
我们可以先在虚拟机里直接运行虚拟机里内存大小是大于10GB的。接着我们把这个程序放到容器中运行因为这个程序本身并不需要很多的内存我们给它做了一个Memory Cgroup的内存限制设置为1GB。
运行结束后我们比较一下程序写数据块的时间。我把结果画了一张图图里的纵轴是时间单位us横轴是次数在这里我们记录了96次。图中橘红色的线是在容器里运行的结果蓝色的线是在虚拟机上运行的结果。
结果很明显在容器中写入数据块的时间会时不时地增高到200us而在虚拟机里的写入数据块时间就比较平稳一直在3050us这个范围内。
<img src="https://static001.geekbang.org/resource/image/7c/c0/7c494f4bc587b618f4b7db3db9ce4ac0.jpg" alt="">
通过这个小程序,我们再现了问题,那我们就来分析一下,为什么会产生这样的结果。
## 时间波动是因为Dirty Pages的影响么
我们对文件的写入操作是Buffered I/O。在前一讲中我们其实已经知道了对于Buffer I/O用户的数据是先写入到Page Cache里的。而这些写入了数据的内存页面在它们没有被写入到磁盘文件之前就被叫作dirty pages。
Linux内核会有专门的内核线程每个磁盘设备对应的kworker/flush 线程把dirty pages写入到磁盘中。那我们自然会这样猜测也许是Linux内核对dirty pages的操作影响了Buffered I/O的写操作
想要验证这个想法我们需要先来看看dirty pages是在什么时候被写入到磁盘的。这里就要用到**/proc/sys/vm里和dirty page相关的内核参数**了,我们需要知道所有相关参数的含义,才能判断出最后真正导致问题发生的原因。
现在我们挨个来看一下。为了方便后面的讲述我们可以设定一个比值A**A等于dirty pages的内存/节点可用内存*100%**。
第一个参数dirty_background_ratio这个参数里的数值是一个百分比值缺省是10%。如果比值A大于dirty_background_ratio的话比如大于默认的10%内核flush线程就会把dirty pages刷到磁盘里。
第二个参数是和dirty_background_ratio相对应一个参数也就是dirty_background_bytes它和dirty_background_ratio作用相同。区别只是dirty_background_bytes是具体的字节数它用来定义的是dirty pages内存的临界值而不是比例值。
这里你还要注意dirty_background_ratio和 dirty_background_bytes只有一个可以起作用如果你给其中一个赋值之后另外一个参数就归0了。
接下来我们看第三个参数dirty_ratio这个参数的数值也是一个百分比值缺省是20%。
如果比值A大于参数dirty_ratio的值比如大于默认设置的20%这时候正在执行Buffered I/O写文件的进程就会被阻塞住直到它写的数据页面都写到磁盘为止。
同样第四个参数dirty_bytes与dirty_ratio相对应它们的关系和dirty_background_ratio与dirty_background_bytes一样。我们给其中一个赋值后另一个就会归零。
然后我们来看dirty_writeback_centisecs这个参数的值是个时间值以百分之一秒为单位缺省值是500也就是5秒钟。它表示每5秒钟会唤醒内核的flush线程来处理dirty pages。
最后还有dirty_expire_centisecs这个参数的值也是一个时间值以百分之一秒为单位缺省值是3000也就是30秒钟。它定义了dirty page在内存中存放的最长时间如果一个dirty page超过这里定义的时间那么内核的flush线程也会把这个页面写入磁盘。
好了从这些dirty pages相关的参数定义你会想到些什么呢
进程写操作上的时间波动只有可能是因为dirty pages的数量很多已经达到了第三个参数dirty_ratio的值。这时执行写文件功能的进程就会被暂停直到写文件的操作将数据页面写入磁盘写文件的进程才能继续运行所以进程里一次写文件数据块的操作时间会增加。
刚刚说的是我们的推理那情况真的会是这样吗其实我们可以在容器中进程不断写入数据的时候查看节点上dirty pages的实时数目。具体操作如下
```
watch -n 1 "cat /proc/vmstat | grep dirty"
```
当我们的节点可用内存是12GB的时候假设dirty_ratio是20%dirty_background_ratio是10%那么我们在1GB memory容器中写10GB的数据就会看到它实时的dirty pages数目也就是/ proc/vmstat里的nr_dirty的数值这个数值对应的内存并不能达到dirty_ratio所占的内存值。
<img src="https://static001.geekbang.org/resource/image/cc/68/ccd0b41e3bd9420c539942b84d88f968.png" alt="">
其实我们还可以再做个实验就是在dirty_bytes和dirty_background_bytes里写入一个很小的值。
```
echo 8192 &gt; /proc/sys/vm/dirty_bytes
echo 4096 &gt; /proc/sys/vm/dirty_background_bytes
```
然后再记录一下容器程序里每写入64KB数据块的时间这时候我们就会看到时不时一次写入的时间就会达到9ms这已经远远高于我们之前看到的200us了。
因此我们知道了这个时间的波动并不是强制把dirty page写入到磁盘引起的。
## 调试问题
那接下来,我们还能怎么分析这个问题呢?
我们可以用perf和ftrace这两个工具对容器里写数据块的进程做个profile看看到底是调用哪个函数花费了比较长的时间。顺便说一下我们在专题加餐里会专门介绍如何使用perf、ftrace等工具以及它们的工作原理在这里你只要了解我们的调试思路就行。
怎么使用这两个工具去定位耗时高的函数呢我大致思路是这样的我们发现容器中的进程用到了write()这个函数调用然后写64KB数据块的时间增加了而write()是一个系统调用,那我们需要进行下面这两步操作。
**第一步我们要找到内核中write()这个系统调用函数下,又调用了哪些子函数。**想找出主要的子函数我们可以查看代码也可以用perf这个工具来得到。
然后是**第二步得到了write()的主要子函数之后我们可以用ftrace这个工具来trace这些函数的执行时间这样就可以找到花费时间最长的函数了。**
下面我们就按照刚才梳理的思路来做一下。首先是第一步我们在容器启动写磁盘的进程后在宿主机上得到这个进程的pid然后运行下面的perf命令。
```
perf record -a -g -p &lt;pid&gt;
```
等写磁盘的进程退出之后这个perf record也就停止了。
这时我们再执行 `perf report` 查看结果。把vfs_write()函数展开之后我们就可以看到write()这个系统调用下面的调用到了哪些主要的子函数,到这里第一步就完成了。
<img src="https://static001.geekbang.org/resource/image/91/9d/9191caa5db8c0afe2363540bc31e1d9d.png" alt="">
下面再来做第二步我们把主要的函数写入到ftrace的set_ftrace_filter里然后把ftrace的tracer设置为function_graph并且打开tracing_on开启追踪。
```
# cd /sys/kernel/debug/tracing
# echo vfs_write &gt;&gt; set_ftrace_filter
# echo xfs_file_write_iter &gt;&gt; set_ftrace_filter
# echo xfs_file_buffered_aio_write &gt;&gt; set_ftrace_filter
# echo iomap_file_buffered_write
# echo iomap_file_buffered_write &gt;&gt; set_ftrace_filter
# echo pagecache_get_page &gt;&gt; set_ftrace_filter
# echo try_to_free_mem_cgroup_pages &gt;&gt; set_ftrace_filter
# echo try_charge &gt;&gt; set_ftrace_filter
# echo mem_cgroup_try_charge &gt;&gt; set_ftrace_filter
# echo function_graph &gt; current_tracer
# echo 1 &gt; tracing_on
```
这些设置完成之后我们再运行一下容器中的写磁盘程序同时从ftrace的trace_pipe中读取出追踪到的这些函数。
这时我们可以看到当需要申请Page Cache页面的时候write()系统调用会反复地调用mem_cgroup_try_charge()并且在释放页面的时候函数do_try_to_free_pages()花费的时间特别长有50+us时间单位micro-seconds这么多。
```
1) | vfs_write() {
1) | xfs_file_write_iter [xfs]() {
1) | xfs_file_buffered_aio_write [xfs]() {
1) | iomap_file_buffered_write() {
1) | pagecache_get_page() {
1) | mem_cgroup_try_charge() {
1) 0.338 us | try_charge();
1) 0.791 us | }
1) 4.127 us | }
1) | pagecache_get_page() {
1) | mem_cgroup_try_charge() {
1) | try_charge() {
1) | try_to_free_mem_cgroup_pages() {
1) + 52.798 us | do_try_to_free_pages();
1) + 53.958 us | }
1) + 54.751 us | }
1) + 55.188 us | }
1) + 56.742 us | }
1) ! 109.925 us | }
1) ! 110.558 us | }
1) ! 110.984 us | }
1) ! 111.515 us | }
```
看到这个ftrace的结果你是不是会想到我们在容器内存[那一讲](https://time.geekbang.org/column/article/316436)中提到的Page Cahe呢
是的这个问题的确和Page Cache有关Linux会把所有的空闲内存利用起来一旦有Buffered I/O这些内存都会被用作Page Cache。
当容器加了Memory Cgroup限制了内存之后对于容器里的Buffered I/O就只能使用容器中允许使用的最大内存来做Page Cache。
**那么如果容器在做内存限制的时候Cgroup中memory.limit_in_bytes设置得比较小而容器中的进程又有很大量的I/O这样申请新的Page Cache内存的时候又会不断释放老的内存页面这些操作就会带来额外的系统开销了。**
## 重点总结
我们今天讨论的问题是在容器中用Buffered I/O方式写文件的时候会出现写入时间波动的问题。
由于这是Buffered I/O方式对于写入文件会先写到内存里这样就产生了dirty pages所以我们先研究了一下Linux对dirty pages的回收机制是否会影响到容器中写入数据的波动。
在这里我们最主要的是理解这两个参数,**dirty_background_ratio 和 dirty_ratio**,这两个值都是相对于节点可用内存的百分比值。
**当dirty pages数量超过dirty_background_ratio对应的内存量的时候内核flush线程就会开始把dirty pages写入磁盘; 当dirty pages数量超过dirty_ratio对应的内存量这时候程序写文件的函数调用write()就会被阻塞住直到这次调用的dirty pages全部写入到磁盘。**
在节点是大内存容量并且dirty_ratio为系统缺省值20%dirty_background_ratio是系统缺省值10%的情况下,我们通过观察 /proc/vmstat中的nr_dirty数值可以发现dirty pages不会阻塞进程的Buffered I/O写文件操作。
所以我们做了另一种尝试使用perf和ftrace工具对容器中的写文件进程进行profile。我们用perf得到了系统调用write()在内核中的一系列子函数调用再用ftrace来查看这些子函数的调用时间。
**根据ftrace的结果我们发现写数据到Page Cache的时候需要不断地去释放原有的页面这个时间开销是最大的。造成容器中Buffered I/O write()不稳定的原因正是容器在限制内存之后Page Cache的数量较小并且不断申请释放。**
其实这个问题也提醒了我们在对容器做Memory Cgroup限制内存大小的时候不仅要考虑容器中进程实际使用的内存量还要考虑容器中程序I/O的量合理预留足够的内存作为Buffered I/O 的Page Cache。
比如如果知道需要反复读写文件的大小并且在内存足够的情况下那么Memory Cgroup的内存限制可以超过这个文件的大小。
还有一个解决思路是我们在程序中自己管理文件的cache并且调用Direct I/O来读写文件这样才会对应用程序的性能有一个更好的预期。
## 思考题
我们对 dirty_bytes 和 dirty_background_bytes做下面的设置
```
-bash-4.2# echo 8192 &gt; /proc/sys/vm/dirty_bytes
-bash-4.2# echo 4096 &gt; /proc/sys/vm/dirty_background_bytes
```
然后再运行下面的fio测试得到的结果和缺省dirty_*配置的时候会有差别吗?
```
# fio -direct=1 -iodepth=64 -rw=write -ioengine=libaio -bs=4k -size=10G -numjobs=1 -name=./fio.test
```
欢迎你在留言区提出你的思考或是疑问。如果这篇文章对你有帮助的话,也欢迎你分享给你的朋友、同事,一起学习进步。