Nomad调度框架对CGroups的使用
2020-03-18 / modified at 2023-02-09 / 3.3k words / 12 mins
️This article has been over 1 years since the last update.

本文从应用侧调度软件开始介绍调度器,并主要以单机版OS为例,介绍了CGroups的隔离。

面向读者:已经有Docker等使用经验,但是希望更深入了解的人。不适用于Java初学者,也不适用于内核级专家。注意本文较长。

在阅读本文前,我个人建议直接去阅读RedHat的文档,专职团队的文档质量肯定比很多博客写的更加好。

调度器的介绍

什么是调度

所谓调度,就是将资源在一定时间内对任务(Workload)的分配与隔离,比如最简单的切蛋糕,到复杂的PaaS容器编排,再到现实生活的敏捷迭代、打车、外卖都是调度。不过本文主要介绍的是单机版CGroup调度。

我们可以分类

  • 简单调度:上下文无关,用贪心算法即可,满足当前最优
  • 复杂调度:是对复杂系统的处理,或者叫做NP问题,在短期很难有最优解,而且最优情况不同,比如亲和性(NUMA),借用/预占资源,队列优先级等HPC领域问题

调度软件的介绍

常见的有LXC, Docker(Runc), Nomad(新但是部署复杂), Mesos, Platform LSF, Kubernetes等。它们既可以用于HPC作科学计算,也可以用作PaaS服务Web业务。

我在日常开发中, 搭建并使用过多种调度器

  • 私有云:缺点非常明显,维保异常昂贵。如果只是部署Web服务等单功能业务,不推荐上云。
  • Kubernetes:这个用起来方便,配合各种方法学实施,大部分Web应用都能很好地部署、扩容与滚动发布。缺点是它本身的运维/插件/网络部署太重了,YAML(特别是annotaion/CRD)学习与方言比较陡峭,中小团队投入的运维成本可能比纯Jar包部署还高,所以用k8s的前提是有人帮你运维或者买现成的。
  • Nomad:一款新的调度框架,内部采用mvcc锁,对Docker/Raw都有支持,它的安装部署很简单(单个Go文件即可),功能也够用,而且支持广域网调度(基于Consul转发),商用产品有CircleCI、fly.io等产品。我在项目中用它来做Jenkins的动态扩容,可以管理至少600U的虚拟/物理机。
  • HPC类:比如IBM LSF/Slurm,其中LSF费用很贵而且受到USA出口管制,但是功能强大,主要基于核数/MPI框架进行调度,可以管理上千台机器(基本上都是28核以上每台),千万级HPC任务。它有叫做OpenLava的开源实现版,但是被IBM的律师给整残了。更多可以参考这位的博文
  • iSulad/volcano/karmada:由HW的各种实验室开发,符合HW各自为政的内卷作风

此外有

  • 多云编排调度,比如Terraform,这里强调的是状态而不是动作
  • 数据中心/HPC超算的硬件调度方案,比如Atlas/TaiShan的ARM算力集群等。
  • 应用层的Workflow调度,比如Jenkins的CPS编排,Flink的管道计算,ARM的Lava等

但是它们不再本文介绍范围内。

唠叨这么多,主要就是想告诉大家,调度是一个广阔的领域,虽然读者与我可能是IT/互联网行业,但是不要限制自己的视野止于“应用”经验。更广阔的底层调度软硬件,编译器,国内极少数企业才能烧钱研发的。

总体调度策略

大部分调度器总体策略基本如下

  • 在OS外: 通过BinPack等算法让正确的资源分给需要的任务进行规划调度,一般需要维护状态机/队列。它的难点在于约束,目前主流的调度方法均是求出当前最优解,而不考虑未来。这个玩意太烧钱了,是一般人永远也达不到的天花板,大部分产品都是西方高校或者实验室才能搞出来。
  • 在OS内: 主要通过CGroups(由谷歌开源)进行调度,如果OS不支持CGroup或者非Root模式,那么可能会需要全表扫描/proc定位PID(比如Jenkins实现了ps读取/proc),但是性能较弱。我个人认为CGroups本质上就是PID的倒排索引,实现了避免全表扫描。在Docker/Nomad中,Runc是底层容器框架,它的规格与实现由多家厂商(比如RedHat/HW等)制定规范,是2016年后容器的事实标准。它将CGroups/chroot等命令封装为Go的SDK(libcontainer),而不用命令行去管理CGroups。在KVM虚拟机中,也是用CGgroup进行资源限制。

有哪些资源可以被隔离调度?

更多可以看libcontainer的参数配置

关于CGroups(Control Groups),网上有手动命令行的介绍,比如Redhat的文档就很详细。但是值得注意的是,根据阿姆达尔定律,能够并行的计算是有限的,需要通过PERT方法进行拆解。

CPU调度

CFS调度介绍

CFS通过vruntime维护了每个进程的总虚拟运行时间(实际运行时间*加权),并用红黑树实现优先级的排序,总时间越少的排在前面。

1
2
# 进程结构体的如下信息将在 cfs_rq 树中进行排序
task_struct -> sched_entity(contains the vruntime) -> rb_node

在CGroup中,通过干预权重,使vruntime更低,更加容易被优先调度到

它在内核开关内部如下

1
2
3
4
#ifdef CONFIG_RT_GROUP_SCHED
// 这里以及其它配置
init_rt_bandwidth(&root_task_group.rt_bandwidth, global_rt_period(), global_rt_runtime());
#endif

这个宏表示

This option allows you to create arbitrary task groups using the “cgroup” pseudo filesystem and control the cpu bandwidth allocated to each such task group

更多参考:https://developer.ibm.com/tutorials/l-completely-fair-scheduler/?mhsrc=ibmlearning_l&mhq=cfs

CFS硬分配(Docker主导)

此方案是按照CPS时钟进行划分,也是Docker/Kubernetes的默认实现,不管当前机器是否空闲,它都只能限制到固定的算力,这样应用可能会缺少爆发力(burst)。此方案与主频是无关的。如下是以Docker为例,分别分配4核/1核/0.5核的场景,注意Linux文档中,并没有说100000就一定是一个核心,这里只是一个常见的默认值

1
2
3
4
# one second, the default value in containers
cpu.cfs_period_us = 100000;
# allocated cpus = x core
cpu.cfs_quota_us = x*100000;

我在CI构建中,也显示配置了这些最大总硬限制,防止某些不可控的应用把整个OS给搞挂。

cpu.shares软限制(Nomad主导)

此参数为相对值,与CFS中的vruntime的权重成反比,强调是占用了相对值/SUM(相对值),而不是固定的频率/核数,使用它的潜规则是你的OS中只能基于一个容器平台维护,否则每个平台映射的含义可能不同。

在 Nomad框架中,将主频与此一一映射对应,假如定义了1234Mhz的CPU资源,那么在cpu.shares也将配置为1234。我认为这个是最好的思路,它将相对值转为了主频总和的绝对值分配问题。但是一旦集群中加入了不同型号的CPU,那么可能性能不完全相同。底层透传给了runc库以启动容器。

在Docker中,默认一核为1024,但是这个开关默认没有打开。

具体如何影响内核,可以参考cpu.sharesの内部動作

假如使用Nomad部署业务,千万不要为了省钱让Server节点承载任务,否则压力上来后,整个调度节点因为shares的均分调度而卡死。

高级使用(混合配置)

假设你想对某个任务软性限制 300MHz主频,硬性限制一个CPU,可以如下配置,比如在nomad的raw_exec中使用。

1
2
3
cpu.shares = 300
cpu.cfs_period_us = 100000;
cpu.cfs_quota_us = 100000;

再次强调这里的值都是相对值,只有机器内所有CGroup全部遵循这个要求才能说shares约等于主频。

cpuset(绑核,LSF主导)

当启动任务时,指定Dedicated CPU的核数,bsub -n 8表示使用了8个物理CPU。此类场景主要是涉及到License或者对Context/cache affinity的任务,比如某些工业软件/EDA/科学计算通过CPU个数进行授权,运行费用甚至比CPU本身还贵。而且这类机器基本都是NUMA+关闭超线程,因为超线程/超售CPU在这种场景下软件授权是赔本的。这种一般需要租赁bare metal machine云主机算力,甚至自己搭建RMDA存储网络。

此外,很多中间件也需要绑核,比如SDN、Cache、Queue等,否则会抖动。

Facts: 大部分工业软件只能单核运行。

Memory控制

程序内存的组成

在一个程序启动后。有如下内存将被分配,假如我启动了一个bash,它将进行如下分配

1
2
3
4
5
6
7
8
# 启动一个Bash程序
bash
# 查看内存映射分配
pmap $$ -X
# 按照真实地址维度计算的话
# 用户态的内存分别是 TEXT, HEAP, LIB, STACK,共约115M,有些共享的内存可能会重复统计
# 按照是否共享Lib库的维度,分别有VSS, R(Resident)SS,P(Proportional)SS, U(Unique)SS
# 注意CGroup控制的是“RSS总量”

值得注意

  • CGroup对RAM的统计比较激进,只统计RSS用量,共享库会重复统计。
  • 在纯运算调度器中(比如LSF调度器),内存占用都特指物理内存PSS(自己占用+共享库均分)的统计。比如你启动多个JVM,可以发现平均占用还降低了。或者OS将你的内存扔到分页里面了,导致内存统计维度占用显得很低。

更多参考:http://linuxperf.com/?cat=7

使用CGroups进行RSS内存限制

如果需要限制内存总量,主要有如下配置进行限制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 进程的用户内存(包括文件缓存),比如2G,这里是k8s等调度器的首选项
# 实际上为了性能,一般不开启文件缓存,因此可以看作物理内存
memory.limit_in_bytes
# 软限制,比如1.5G,供超售,一般小于hard
memory.soft_limit_in_bytes
# 物理+Swap限制,比如4G,一般推荐是 -1(不限制) 或者 物理内存*2,需要高性能SSD
memory.memsw.limit_in_byte
# 当程序需要内存时,系统分配给程序的既可以是物理内存,也可能是分页缓存
# 这里表示OS优先使用物理内存还是分页,默认是60,越小更倾向于分配物理内存,如果为0将请求关闭虚拟内存
# 强调一下,这里只是请求,实际分配取决于各个内核厂商的实现
# Web服务大部分场景为0
memory.swappiness
# 使用统计:用户内存(包括文件缓存)。实际上可以看作物理内存
memory.usage_in_bytes

至于是否配置虚拟内存比例,有如下场景

  • 如果是WebService/K8S类应用,一般默认会配置swappiness为0(The kubelet right now lacks the smarts to provide the right amount of predictable behavior here across pods.),这样基于物理内存的统计调度更加简单准确,当全部内存(RAM)耗尽时,取决于node-oom-behavior,通常是Kill掉,这时你要引入Oberservity工具,分析为啥挂了
  • 如果是高性能计算,原则上不限制SWAP,需要配置昂贵的NVMe SSD进行swap配置,一般是物理RAM的2倍。

测试代码

1
2
3
4
5
6
7
8
mkdir -p /sys/fs/cgroup/memory/test/test-1
# 限制总内存为1M
cgset -r memory.memsw.limit_in_bytes=1M /test/test-1
# 只有1M内存,理所当然无法启动
cgexec -g memory:/test/test-1 bash
cgset -r memory.memsw.limit_in_bytes=128M /test/test-1
# 成功启动了
cgexec -g memory:/test/test-1 bash

Nomad对CGroup资源的管理

在Nomad中,假如我希望配置某个任务的CPU/内存(含超售

1
2
3
4
5
6
7
# Mebibyte
resources {
# the equivalence of memory.soft_limit_in_bytes/10^20
memory = 400
# the equivalence of memory.limit_in_bytes/10^20
memory_max = 520
}

它在内部将在如下位置生成libcontainer的CGroups

1
drivers/shared/executor/executor_linux.go:configureCgroups

转化如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// runc容器的默认配置项
memory, memoryReservation := memoryLimits(0, task.Resources.NomadResources.Memory)
// softBytes: Memory reservation or soft_limit (in bytes)
// hard: Memory limit (in bytes) or hard limit
func memoryLimits(0, taskMemory drivers.MemoryResources) (memory, reserve int64) {
softBytes := taskMemory.MemoryMB * 1024 * 1024
hard := taskMemory.MemoryMaxMB
if hard <= 0 {
return softBytes, 0
}
return hard * 1024 * 1024, softBytes
}
// 关闭分页,这里比较坑,没有上游配置就直接写死0了
cfg.Cgroups.Resources.MemorySwappiness = 0
// 配置weight,CPU并不是与核数/频率对应
cfg.Cgroups.Resources.CpuShares = uint64(cpuShares)

K8S对内存的管理

默认场景下优先以limit为准,而软限制是无限。建议不要配置过远。

RDMA技术

基于RDMA协议跨过Infiniband/RoCE网线访问内存,场景主要还是GPU加速卡/存储资源的直通,比如GPU Direct,但是与CGroup的关系不大。

除了极其昂贵以外,没有太多缺点。

总结

当前CGroup调度器主要就是一个内核系统的外壳,Docker等组件并不是非常复杂。如果需要了解内部实现,需要去学习内核。如果对CGroup有兴趣,可以研究HPC开源平台OpenLava代码,它是纯CGroup而没有namespace,分析更简单。

参考