云平台的容器线程数量限制机制

58云计算平台(以下简称云平台)是TEG-架构线基于Kubernetes + Docker的私有云(Kubernetes简写为K8S),旨在为集团内部提供一套业务实例管理平台。58云平台具有简单,轻量的特点,能够高效利用物理资源,更快的部署和统一规范的标准化运行环境,通过云平台,实现服务标准化,上线流程规范化,资源利用合理化。Docker对于容器的CPU,内存做了限制,云平台对于容器的网络进行了限制,但是缺少对于线程数量的限制,本文针对如何限制容器线程数量进行讨论。 如果你想和更多Kubernetes技术专家交流,可以加我微信liyingjiese,备注『加群』。群里每周都有全球各大公司的最佳实践以及行业最新动态

1、问题描述

目前云平台对容器的CPU,内存以及网络都做了限制,但是缺少对于线程数量的限制,这可能会引入一些问题。比如某些容器上的服务,大量的创建线程,会耗光系统资源,导致系统无法新建线程,CPU负载过高等问题。

通过跟进社区的releasenotes,发现Kubernetes 1.10 + Docker 1.11之后开始支持限制容器创建的进程数,社区采用的方式是使用pids子系统限制创建的进程数量,但是没有实现实时感知的机制, 而Kubernetes 1.7 + Docker 1.10没有该功能的支持。如果部署在容器上的应用,代码编写不规范,大量的创建线程,可能会导致系统的pid(Linux上的线程可以认为是使用进程模拟的)耗尽,进而导致宿主机无法创建子进程的问题,使得整个宿主机处于一个很危险的状态。

针对上述问题,我们计划设计一个通用的方案,能够对Kubernetes 1.10 + Docker 1.11之前的版本提供限制容器线程数量的功能,同时提供实时感知容器线程数触达上线的机制。

2、问题复现

import time

import os

from multiprocessing import    Process

s = []

def func(i):

    print 'hello', i

    time.sleep(1000)



def main():

    for i in range(50000):

            p = Process(target=func,    args=(i,))

            s.append(p)

    for p in s:

            p.start()

    time.sleep(2000)



main()

基于1中的调研,我们找了两台机器做实验,上面设置的kernel.pid_max = 40960,即系统允许创建的最大进程数是40960,我们又写了一个Python脚本不断去创建进程,看宿主机是会出现1中描述的问题。

通过运行上面的脚本,发现当进程创建到27000的时候,执行Docker命令就会很卡,当进程数达到kernel.pid_max定义上限的时候,已经耗尽系统的pid资源,同时报出报出fork:retry: Resource temporarily unavailable的错误。

3、解决方案

针对2中潜在的问题,我们讨论了该问题的可行方案,最终确定如下:

  1. 调大kernel.pid_max,允许系统创建更多的进程。
  2. 限制单个容器创建的线程数量。

3.1 kernel.pid_max参数

kernel.pid_max是内核允许系统创建的最大进程数,这个值在64位机上最大可设置为4194304(4M)。目前从4.18的代码来看,调大后的影响是内核在维护PID时要多用一些内存,分配PID时要花更多一点时间,这些与进程本身的消耗相比,可以忽略。

内核的推荐值是CPU数 1024,也就是说内核认为一个CPU最多可以应对1024个进程,如果一台机器有40个CPU,那 40 1024 = 40960。但根据当前云平台宿主机的实际情况,内核推荐的设置已经不适用于58云平台宿主机。所以从稳定性的角度,结合58云平台的业务,建议将宿主机的kernel.pid_max设置成1048576 (1M)。

kernel.pid_max仅仅是调大了系统允许创建的进程/线程数,这并没有从根本上解决问题,单个容器依然可能创建很多的进程/线程,我们需要通过cgroup的pids子系统限制每个容器启动的最大进程/线程数。

3.2 解决方案设计

经过调研,我们采用CGroup pids子系统 + inotify的方式,限制容器启动的进程/线程数量。

3.2.1 CGroup pids子系统

CGroup是Control Groups的缩写,是Linux内核提供的一种可以限制、记录、隔离进程组(process groups)所使用的物理资源(如CPU, memory,I/O 等等)的机制,它是容器限制资源的基础。

CGroup是将任意进程进行分组化管理的Linux内核功能。CGroup本身是提供将进程进行分组化管理的功能和接口的基础结构,I/O或内存的分配控制等具体的资源管理功能是通过这个功能来实现的。这些具体的资源管理功能称为CGroup子系统或控制器。

在Linux Kernel 4.3中,引入了一个新的CGroup子系统pids,通过这个子系统,可以实现对某个CGroup中进程和线程的总数进行限制。如下图所示:

其中,pids.max控制该组中最多可以拥有的进程数(也可以用来限制线程数)。pids.current存储了当前CGroup的进程(线程)总数。cgroup.procs是需要限制的进程pid列表。pid.events记录CGroup触发进程上限的次数。

Facebook在2016年向内核提交了一个patch(135b8b),实现了pid.events的功能。当CGroup尝试fork新进程的时候,会调用pids_can_fork判断是否可以fork,如果不能,会通过cgroup_file_notify触发pid.events的事件。

内核推荐的宿主机机进程数上限为:CPU核数*1024,云平台的容器也将使用这一内核推荐值,即随着核数的增加,允许的最大线程上限也随之增加。考虑到部分云实例的CPU核数较少,如按照上述方式配置,部分服务会受影响,容器核数不足6核的,按照6核计算。

通过cgroup pids子系统可以有效的限制容器启动的线程数量,但是当容器创建的线程触达上限的时候,会使得容器处于不可预知的状态,我们需要通过inotify实时检测pids.events文件的变化,感知容器线程数量是否触达上限。

3.2.2 inotify

inotify是Linux中用于监控文件系统变化的一个框架,不同于前一个框架dnotify,inotify可以实现基于inode的文件监控。也就是说监控对象不再局限于目录,也包含了文件。不仅如此,在事件的通知方面,inotify摈弃了dnotify的信号方式,采用在文件系统的处理函数中放置hook函数的方式实现。

inotify提供一个简单的API,使用最小的文件描述符,并且允许细粒度监控。与inotify的通信是通过系统调用实现。主要的API如下:

  • inotify_init,用于创建一个inotify实例的系统调用,并返回一个指向该实例的文件描述符。
  • inotify_add_watch,增加对文件或者目录的监控,并指定需要监控哪些事件。标志用于控制是否将事件添加到已有的监控中,是否只有路径代表一个目录才进行监控,是否要追踪符号链接,是否进行一次性监控,当首次事件出现后就停止监控。
  • inotify_rm_watch,从监控列表中移出监控项目。
  • read,读取包含一个或者多个事件信息的缓存。
  • close,关闭文件描述符,并且移除所有在该描述符上的所有监控。当关于某实例的所有文件描述符都关闭时,资源和下层对象都将释放,以供内核再次使用。

由3.2.1中pids子系统的机制可知,我们可以通过inotify_add_watch和read监控pid.events文件的变化,可以感知CGroup的进程是否触达pid.max定义的上限,并进行后续的处理。

4、总结

总体来说,上述方案很好的解决了限制容器启动线程数量的问题,并能够提供实时感知容器触达线程上限的机制。从本次方案设计的过程中,我们发现平台还存在一些潜在的问题。我们需要改进监控和报警系统,尽早发现潜在的问题并解决。我们也会持续关注社区演进,同时会将我们比较好的功能提交到社区。

原文链接: https://mp.weixin.qq.com/s/f43hbIoxxNQy3qJvoL8kLg