docker的IO性能问题
又又又一次被临时拉进一个群去解决一个性能瓶颈,其实差不多第一眼就发现了问题所在,验证也没花什么力气,但觉得类似的性能问题实际应该会经常发生或者被问起,在这里就简单总结下吧。
一个喜欢在路边摆摊的公司计划一次性搞定历史遗留问题,将某个原本跑在物理机上的某个项目用container封装之后扔到自己现有的容器云里边。考虑到两者的硬件平台已经差别了几代,上手开始用小环境压测几轮做容量规划。当测试nginx的最大吞吐性能时,发现相比在物理机上运行,nginx的进程数越多,性能差距越大(以qps计算),最多会下降50%。
我在很久之前(回头看了一下,这都是5年前了)的一篇帖子中我在描述docker和VM区别的同时用了几组测试数据证实了docker和native的性能差距不是很大,难道是大型打脸现场?
绕开软网桥
由于docker网络端口映射默认通过软网桥虚拟的子网转发,功能上相当于自带了一套前端ha-proxy,转发能力受限于kernel的iptable能力并不高效。在正式的测试前通过--net=host参数为nginx容器都配置了独立端口监听,直接绕开了效率低下的网桥。而host模式带来的一个问题是-p参数的失效,即只能为container绑定所有"ExposedPorts"到host的对应端口,无法做端口映射。
[root@local ~]# docker run --name=www-test-01 --rm -p 80:80 -d nginx
[root@local ~]# ss -antp | grep 80
LISTEN 0 128 0.0.0.0:80 0.0.0.0:* users:(("docker-proxy",pid=328526,fd=4))
# 非host模式,响应TCP 80端口的是“docker-proxy”进程,需要二次转发
[root@local ~]# docker run --name=www-test-01 --rm --net=host -d nginx
# host模式不支持-p参数的端口->端口映射
b78f7c7833004c4980cadf6d2a2d2e92447312b85d3d6148f18aa9646c6710c0
[root@local ~]# ss -antp | grep 80
LISTEN 0 128 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=521567,fd=7),("nginx",pid=521566,fd=7),("nginx",pid=521565,fd=7),("nginx",pid=521564,fd=7),("nginx",pid=521563,fd=7),("nginx",pid=521562,fd=7),("nginx",pid=521561,fd=7),("nginx",pid=521560,fd=7),("nginx",pid=521523,fd=7))
# host模式下,响应TCP80的是具体的进程名称,不需要转发
对穿了一台client,通过wrk工具分别测试了物理主机下和container内的nginx QPS性能,对比了一下,基本符合了原先的描述。
瓶颈数据分析
测试样本中16 thread的版本差异接近1:2而且由于我的测试机有18个物理core,这个设置不需要考虑跨CPU访问的情况,那不妨就以它为样本,做一个全面数据采样吧。结果如下:
Docker | 物理机 | |
---|---|---|
CPU利用率 | 29.9% | 28.6% |
内存读带宽(MB/s) | 2,170 | 5,030 |
内存写带宽(MB/s) | 200 | 430 |
IO读带宽(MB/s) | 2,100 | 4,900 |
IO写带宽(MB/s) | 100 | 240 |
内核CPI | 3.28 | 1.58 |
- CPU利用率,两者大致相同,说面至少在系统调度方面,两者并无太大差别。没有出现由于container封装导致的优先级下降或者CPU分配异常的问题。
- 作为IO型应用,nginx的并发性能(实际上是受压时延)可以通过内存和IO的带宽反应出来。我们的结果无一例外都大致保持着1:2的关系,匹配了性能测试结果。
- 内核态(kernel) CPI,docker 是物理主机的一倍,这意味着当nginx在container内部发起系统调用时,同样的时间kernel内部只会执行一半的指令数。这就是我找到的关键数值差异!
出现性能瓶颈的(极简化)逻辑链应该是:
- client 发起访问请求
- Linux kernel接受请求,并触发nginx socket 通讯,这里会有较多内核态指令和网络IO带宽消耗。
- nginx获取请求,稍加处理(其实nginx的软件栈真的很薄、很高效),认为需要访问磁盘文件。
- nginx 发起文件请求并切换到内核态,这里会有磁盘IO的带宽消耗。
- 系统获取数据,切换回nginx。
- nginx响应socket通讯,再次返回内核态。
- kernel通过网络IO返回数据,通讯完毕!
受限于工具,我们无法正确区分网络和磁盘IO,所以只能进行下一步测试:通过iperf测试container host模式和物理机的最大网络带宽差异,几种条件的测试结果都认为两者之间的差异微乎其微(正负0.03%的水准)。
花了力气,路没走对,但至少排除了network出问题的可能,最大的嫌疑就是docker的磁盘IO性能问题了。
Overlay文件系统解析
作为docker的优势特性之一,overlay文件系统实现了一种逻辑上的继承关系 [1] 。
- container被创建时并不需要拷贝镜像,只有写时才需要复制。
- 分层覆盖,系统在透明修改文件时,旧文件并不受影响。
基于这种显而易见的问题,性能低下也是在情理之中了。同时docker官方认为overlayfs存在限制场景 [2] 的:
先读后写的表现并不符合posix一致性标准,文件操作读写不一致问题(比较容易踩到)
open(2) : OverlayFS only implements a subset of the POSIX standards. This can result in certain OverlayFS operations breaking POSIX standards. One such operation is thecopy-upoperation. Suppose that your application callsfd1=open("foo", O_RDONLY)
and thenfd2=open("foo", O_RDWR)
. In this case, your application expectsfd1
andfd2
to refer to the same file. However, due to a copy-up operation that occurs after the second calling toopen(2)
, the descriptors refer to different files. Thefd1
continues to reference the file in the image (lowerdir
) and thefd2
references the file in the container (upperdir
). A workaround for this is totouch
the files which causes the copy-up operation to happen. All subsequentopen(2)
operations regardless of read-only or read-write access mode reference the file in the container (upperdir
).
不完整支持rename方法(貌似踩到的不多)
rename(2)
: OverlayFS does not fully support the
rename(2)
system call. Your application needs to detect its failure and fall back to a “copy and unlink” strategy.
对比5年前的那个帖子,容器的性能差距还是体现在了IO上。而nginx这个case实际上将IO,特别是磁盘IO的影响放大到了最大。
个人不负责地猜测由于新的overlayfs支持更多的覆盖层级,倾向于功能性的演进导致了IO性能在这些年之后不会更好,只会更差!
修复方法及验证
docker官方的建议是通过外挂一个volume,绕开overlayfs从而提升container的文件读写性能。而基于对nginx测试的特殊要求,我决定直接将返回结果写入nginx的配置,不至于每次都读写磁盘:
location / {
# autoindex on;
# autoindex_localtime on;
# autoindex_exact_size off;
# root /usr/share/nginx/html;
# index index.html index.htm;
# 这是默认的/etc/nginx/config.d/default内容,即在没有index.html的时候(该文件已替换成json)
# 自动index /usr/share/nginx/html下的对应文件。此外使用的是默认的报错“白板页”。
return 200 '{"status":"success","result":"hello world!"}';
# 对于任何访问url / 的请求直接200 返回一个json。没有其他返回。
}
数据比较一致了,被打的脸似乎也没那么疼了。
吐槽时间
自从docker / container技术大火之后,各种解决方案也是随之而来。传统开源社区典型的”各自有各自的想法,于是不断地fork新项目的死循环“也传染到了docker。
话说当时我尝试从测试机上导出一个镜像,习惯告诉我操作应该是:
~# docker save nginx2 -o test.img
refusing to save to terminal. Use -o flag or redirect
报错信息有点懵逼,这是什么版本改命令了吗?
~# docker version
Version: 1.0.2-dev
Go Version: go1.11.5
OS/Arch: linux/amd64
完全没概念了,找回到自己的环境
~# docker version
Client: Docker Engine - Community
Version: 20.10.8
API version: 1.41
Go version: go1.16.6
Git commit: 3967b7d
Built: Fri Jul 30 19:53:39 2021
OS/Arch: linux/amd64
Context: default
Experimental: true
Server: Docker Engine - Community
Engine:
Version: 20.10.8
API version: 1.41 (minimum version 1.12)
Go version: go1.16.6
Git commit: 75249d8
Built: Fri Jul 30 19:52:00 2021
OS/Arch: linux/amd64
Experimental: false
containerd:
Version: 1.4.9
GitCommit: e25210fe30a0a703442421b0f60afac609f950a3