io.latency块设备IO控制器的构建

Linux在各用户之间的共享存储非常糟糕。各app有不同的延迟需求,并且永远不会一致。限流可以使各用户公平的共享可用带宽,但是大多数I/O是写回机制,在系统其它部分没有写入压力时,限流都会太晚。磁盘们也都是不一样的,spinning rust、各类SSD,每种设备都有不同的性能特性;即便是同种设备,在不同的负载下性能也是不同。很难用一种I/O控制器解决所有问题,但我们(脸书)认为,我们找到了一种有所依据的解决办法。
之前内核有两种用于控制组的I/O控制器,第一种io.max,允许为每一个设备的使用带宽或秒I/O操作(IOPS)设置硬限制。第二种io.cfq.weight,由CFQ I/O调度器提供。基于脸书已经做的工作:<高负载下性能瓶颈信息的提取>和<统一控制组层次结构>,逐渐这两种控制器都不能解决我们的问题。一般来说,我们会有一个主工作负载,然后还有后台运行的周期性系统工具:chef每小时都会运行几次,更新系统设置,安装软件包;fbpkg工具每天下载三四次系统运行的应用程序的新版本。
io.max控制器允许我们限制这些系统工具,但会使他们总是运行的不可忍受的慢。调高限制则会使它们太影响主工作负载,所以也不是非常好的解决方法。CFQ io.cfq.weight控制器也是没啥希望,因为CFQ不能与多队列块设备层一起工作,更不必说CFQ的一般应用带来的延迟问题,为支持deadline调度,CFQ已经被关闭多年。
Jens Axboe的<让写回不那么烦人>提出一种新的方式来监控并减缩负载。它会测量读磁盘的延迟,如果延迟超过设定的阈值,它将会降低允许到达磁盘的写的数量。这位于I/O调度器之上,很重要,因为我们可以为任何单个设备提供合适数量的请求。这个数量有/sys/block/<device>/queue/nr_requests控制。我们称之为设备的队列深度。写回限制机制通过在申请写操作之前降低队列深度,允许必要的读可用请求和写入限制。
这个方案解决了一个问题:fbpkg可能拉下好几GB的包来升级运行的程序。由于应用程序倾向于同时推送所有更新,我们会看到全局延迟剧增,因为突然涌入的写操作影响了已经在运行的程序。

进入新的I/O控制器

写回限制不是控制组可感知的,仅关注单磁盘读写延迟的性价比。但它还是有很多好的思想,我都直接偷来用在新的控制器上了,我称之为io.latency。io.latency必须在spinning rust和高端SSD上都能工作,因此需要更低的开销。我的目标是在快速路径上不增加锁,几乎要实现了。最初我们真的想同时要负载保护和均衡控制,我们无论如何都需要保护主负载的场景,可是也有其他我们想要堆叠多负载并使之共同良好工作的场景。最终我们不得不放弃均衡控制而只实现工作负载保护,然后为均衡控制设计另外的解决方案。
在io.latency,group会设有延迟阈值;如果超出这个阈值一定时间(一般250ms),控制器将会限制其他有更高延迟阈值的group;限制机制与写回限制相同:控制器简单的降低目标控制组的队列深度。这一限制仅对控制组层次结构下的group生效。比如:一个控制组层次root下分a/b,b中有fast和slow,当fast超出延迟阈值250ms,同层次的b下slow将会被限制,而a下的不会受影响。
我实现上述功能的无锁是通过在其父子节点加入cookie,例如:当fast错过目标,会减其父组(b)的cookie,当slow提出一个I/O请求,控制器检查b中cookie以及slow中的备份,如果cookie值降了,slow减少其队列深度,如果值升了,slow加队列深度。
在普通I/O路线,io.latency增加了两个原子操作:一个是读父的cookie,另一个是在队列加键。在完整路线下,普通情况我们仅有一个原子操作就是释放队列键,还有一个per-cpu操作来统计I/O用的时间。在慢速情况下,发生在每个窗口采样时间(那个250ms),我们请求父组的一个锁来增I/O统计信息,以及检查我们的延迟是否超阈值。
部分io.latency正在统计I/O时间,因为我们关心受程序影响的总延迟,我们统计每个操作提请到完成的时间。这一时间放在一个per-cpu结构中,记录所有窗口总周期。我们在测试中发现使用平均延迟在旋转驱动器中是不错的,但在SSD下却不够敏捷。因此对于SSD,我们有一个百分数计算,如果90%的延迟超阈值,那么是时候限制低重要程度的兄弟组(slow)了。
io.latency的最后一块儿是一个每秒触发一次的定时器。这个控制器基本上是无锁构建,由正在执行的I/O驱动。但是如果你有主工作负载将慢速工作负载限制到死,到停止I/O,我们将不再有机制来恢复慢速组。而周期定时器就是来处理这种情况的,当某触发I/O并且这个受限组一直在等这个I/O;也就是这个timer松开受限组,以使其可以继续工作。

一切都非常完美,是吗?

不幸的是,内核是一个各部分相互联系的大系统,其中许多部分都不喜欢(被限制)突然间它调用的submit_bio()花费很长时间才返回。我们不断被卷入各种优先级反转,这在生产环境中测试整个系统时消耗了我们许多时间。
我们的测试场景是一个超载的网络服务器,还有缓慢的内存泄露,运行在slow组。一般来说,可能发生的是fast工作负载将会开始被拖入内存回收然后为所有可以获得的页执行swap I/O。页是被连接到其所属的控制组的,这就意味着任何使用这些页的I/O都在其所有者的限制下执行。我们的高优先级工作负载在swap低优先级组的页(这些页需要低优先级组执行I/O),这就意味着它被不正确的限制了。
这很容易被解决,给这些I/O操作添加REQ_SWAP标志,然后I/O控制器只需要使此I/O操作不受限制的执行下去。REQ_META标志的操作也要这么做,因为我们可能被阻塞在慢组提起的元数据I/O上。尽管现在慢组正在引起大量I/O压力,但并未导致它被限制,因为所有REQ_SWAP的I/O都是自由的。不好的工作负载只会不断的申请内存,而不能执行I/O,因此在其淹没主工作负载前没有办法限制它,当内存压力开始建立,工作负载的延迟确实会飞涨,因为大多数情况下,主工作负载是内存敏感而不是I/O敏感。
解决此问题需要添加另一组基础框架。我们知道我们替行为不端的控制组做了很多I/O;我们仅仅需要一种方式来告诉内存管理器:这个组行为异常。为了解决这个问题,我为块控制组结构增加了一个拥堵统计,来指示一个控制组正在有很多I/O自由且不受限制的执行。当我们知道了那个控制组代表着这些被提起的页,我们就可以给这个组打上拥堵的标签,然后内存管理层将会知道开始限制之类的事情。
另一个问题是与mmap_sem信号量相关的。在我们的工作负载中,有一些监视代码干着跟ps一样的活儿,读取/proc/<pid>/cmdline,反过来再使用mmap_sem。另一个使用mmap_sem的是缺页处理。当任务发生缺页被限制,因此持有mmap_sem,然后主工作负载尝试读一个受限任务的/proc/<pid>/cmdline文件,主工作负载将会阻塞并等待被限制了的I/O。这意味着我们需要找到一种方法:在任何可能的内核锁的路线外,来做I/O的严格限制。增加blkcg_maybe_throttle_current()框架来处理这一难题。我们将会增加人工延迟到当前任务,然后当我们返回用户空间时,当我们知道我们没有持有任何内核锁,我们可能暂停给定的时间,来确定我们是否仍在受限。
有了所有这些东西,我们有了一个工作系统。

结果

之前,我们在没有I/O控制器时进行这个内存泄露测试,这个box将会被拖入swap并反复好多分钟,直到OOM干掉所有或我们的自动健康监测提示有问题,重启box。这花费了一些时间来使我们的box重新上线,然后重整到cluster,再开始接受任务,大概会有45-50分钟的下线时间。
在所有设置到位,oomd(脸书自己的用户态oom)监控着所有人,我们降低了每秒钟大概10%的请求;然后内存hog将会非常受限,进而oomd将会看到它并杀死它。这将在超载的网络服务器上降低10%的开销,在普通工况你在全局性能将会看到很少或没有影响。

未来工作

io.latency控制器与我们其他所有控制组工作以及oomd,整运行在我们所有的网络服务器、build服务器、消息服务器中。已经稳定运行一年,大幅降低了这些层级未知重启次数。下一步是构建一个均衡I/O控制器,称作io.weight。现在正在开发中;生产测试将会很快开始并将在未来几个月推送到主线。幸运的是所有的各种优先级反转都被在io.latency中发现并修复,这使添加新的I/O控制器变得更简单。

发布了4 篇原创文章 · 获赞 3 · 访问量 1938

猜你喜欢

转载自blog.csdn.net/ytfy339784578/article/details/103945920