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 expectsfd1andfd2to 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. Thefd1continues to reference the file in the image (lowerdir) and thefd2references the file in the container (upperdir). A workaround for this is totouchthe 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