相关文章推荐
健身的水煮鱼  ·  select function ...·  3 天前    · 
忧郁的路灯  ·  使用 shutil.rmtree 和 ...·  1 年前    · 
文雅的开水瓶  ·  json - _CastError ...·  1 年前    · 
docker的IO性能问题

docker的IO性能问题

2 年前 · 来自专栏 开源小站

又又又一次被临时拉进一个群去解决一个性能瓶颈,其实差不多第一眼就发现了问题所在,验证也没花什么力气,但觉得类似的性能问题实际应该会经常发生或者被问起,在这里就简单总结下吧。

一个喜欢在路边摆摊的公司计划一次性搞定历史遗留问题,将某个原本跑在物理机上的某个项目用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内部只会执行一半的指令数。这就是我找到的关键数值差异!

出现性能瓶颈的(极简化)逻辑链应该是:

  1. client 发起访问请求
  2. Linux kernel接受请求,并触发nginx socket 通讯,这里会有较多内核态指令和网络IO带宽消耗。
  3. nginx获取请求,稍加处理(其实nginx的软件栈真的很薄、很高效),认为需要访问磁盘文件。
  4. nginx 发起文件请求并切换到内核态,这里会有磁盘IO的带宽消耗。
  5. 系统获取数据,切换回nginx。
  6. nginx响应socket通讯,再次返回内核态。
  7. kernel通过网络IO返回数据,通讯完毕!

受限于工具,我们无法正确区分网络和磁盘IO,所以只能进行下一步测试:通过iperf测试container host模式和物理机的最大网络带宽差异,几种条件的测试结果都认为两者之间的差异微乎其微(正负0.03%的水准)。

花了力气,路没走对,但至少排除了network出问题的可能,最大的嫌疑就是docker的磁盘IO性能问题了。

Overlay文件系统解析

作为docker的优势特性之一,overlay文件系统实现了一种逻辑上的继承关系 [1]

overlay支持两层覆盖关系而新的overlay2支持更多的层级
  • 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 calls fd1=open("foo", O_RDONLY) and then fd2=open("foo", O_RDWR) . In this case, your application expects fd1 and fd2 to refer to the same file. However, due to a copy-up operation that occurs after the second calling to open(2) , the descriptors refer to different files. The fd1 continues to reference the file in the image ( lowerdir ) and the fd2 references the file in the container ( upperdir ). A workaround for this is to touch the files which causes the copy-up operation to happen. All subsequent open(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