点击上方👆蓝字关注我们!
背景
在混部场景下,内存管理是一个很重要的话题:一方面,当节点或容器的内存紧张时,业务的性能可能会受到影响,比如出现时延抖动或者 OOM。在混部场景下,由于对内存进行了超卖,该问题可能会更加严重。另一方面,节点上可能存在一些较少被使用但未被释放的内存,导致可以出让给离线作业使用的内存量较少,无法实现有效的超卖。
针对上述问题,字节跳动将其在大规模在离线混部过程中积累的 精细化 的内存管理经验,总结成了一套 用户态 的 Kubernetes 内存管理方案 Memory Advisor,并在资源管理系统 Katalyst 中开源。本文将重点介绍 Kubernetes 和 Linux 内核原生的内存管理机制及其局限,以及 Katalyst 如何通过 Memory Advisor 在提升内存利用率的同时,保障业务的内存服务质量。
原生方案的局限
内核原生的内存分配与回收机制
由于访问内存的速度比访问磁盘快很多,Linux 使用内存的策略比较贪婪,采取尽量分配,当内存水位较高时才触发回收的策略。
内存分配
内核的内存分配方式主要包含 2 种:
- 快速内存分配 :首先尝试进行快速分配,判断分配完成后整机的空闲水位是否会低于 Low Watermark,如果低于的话先进行一次快速内存回收,然后再判断是否可以分配。如果还不满足,则进入慢速路径。
- 慢速内存分配 :慢速路径中会首先唤醒 Kswapd 进行异步内存回收,然后尝试进行一次快速内存分配。如果分配失败,则会尝试对内存页进行 Compact 操作。如果还无法分配,则尝试进行全局直接内存回收,该操作会将所有的 Zone 都扫描一遍,比较耗时。如果还不成功,则会触发整机 OOM 释放一些内存,再尝试进行快速内存分配。
内存回收
内存回收根据针对的目标不同,可以分为针对 Memcg 的和针对 Zone 的。内核原生的内存回收方式包含以下几种:
- Memcg 直接内存回收 :如果一个 Cgroup 的 Memory Usage 达到阈值,则会触发 Memcg 级别的同步内存回收来释放一些内存。如果还不成功,则会触发 Cgroup 级别的 OOM。
- 全局快速内存回收 :上文在介绍快速内存分配时提到了快速内存回收,其之所以快速,是因为只要求回收这次分配所需的页数量即可。
- 全局异步内存回收 :如上图所示,当整机的空闲内存降到 Low Watermark 时,会唤醒 Kswapd 在后台异步地回收内存,回收到 High Watermark 为止。
- 全局直接内存回收 :如上图所示,如果整机的空闲内存降到 Min Watermark,则会触发全局直接内存回收。因为该过程是同步的,发生在进程内存分配的上下文,对业务的性能影响较大。
K8s 原生的内存管理机制
Memory Limit
Kubelet 依据 Pod 中各个 Container 声明的 Memory Limit 设置 Cgroup 接口
memory.limit\_in\_bytes
,约束了 Pod 和 Container 的内存用量上限。当 Pod 或 Container 的内存用量达到该限制时,将触发直接内存回收甚至 OOM。
驱逐
当节点的内存不足时,K8s 将选择部分 Pod 进行驱逐,并为节点打上
Taint node.kubernetes.io/memory-pressure
,避免将 Pod 再调度到该节点。
内存驱逐的触发条件条件为整机的 Working Set 达到阈值,即:
memory.available := node.status.capacity[memory] - node.stats.memory.workingSet
其中
memory.available
为用户配置的阈值。
- 在对待驱逐的 Pod 进行排序时,首先判断 Pod 的内存使用量是否超过其 Request,如果超过则优先被驱逐;其次比较 Pod 的 Priority,优先级低的 Pod 先被驱逐;最后比较 Pod 的内存使用量超过其 Request 的差值,超出越多则越先被驱逐。
OOM
如果全局直接内存回收仍然满足不了节点上的进程对内存的需求,将触发整机的 OOM。Kubelet 在启动容器时,会根据其所属 Pod 的 QoS 级别与其对内存的申请量,为其配置
/proc/<pid>/oom\_score\_adj
,从而影响其被 OOM Kill 的顺序:
- 对于 Critical Pod 或 Guaranteed Pod 中的容器,将其
oom\_score\_adj
设置为 -997 - 对于 BestEffort Pod 中的容器,将其
oom\_score\_adj
设置为 1000 - 对于 Burstable Pod 中的容器,根据以下公式计算其
oom\_score\_adj
min{max[1000 - (1000 * memoryRequest) / memoryCapacity, 1000 + guaranteedOOMScoreAdj], 999}
Memory QoS
K8s 从 v1.22 版本开始,基于 Cgroups v2 实现了 Memory QoS 特性 [2],可以为容器的内存 Request 提供保障,进而保障了全局内存回收在 Pod 间的公平性。
具体的 Cgroups 配置方式如下:
memory.min
: 依据requests.memory
配置。memory.high
: 依据limits.memory * throttling factor
(或node allocatable memory * throttling factor
) 配置。memory.max
: 依据limits.memory
(或node allocatable memory
) 配置。
在 K8s v1.27 版本中,对 Memory QoS 特性进行了增强。主要是为了解决以下问题:
- 当容器的 Requests 和 Limits 比较接近时,由于
memory.high
memory.min
的限制,
memory.high
中配置的 Throttle 阈值可能不生效。
- 按照上述方式计算出的
memory.high
可能较低,导致频繁的 Throttle,影响业务性能。 throttling factor
的默认值 0.8 过于激进,一些 Java 应用通常会用到 85% 以上的内存,经常被 Throttle。
因此进行了以下优化:
- 对
memory.high
的计算方式进行改进:
memory.high = floor{[requests.memory + memory throttling factor * (limits.memory or node allocatable memory - requests.memory)]/pageSize} * pageSize
- 将
throttling factor
的默认值调整为 0.9。
局限
从前两节的介绍中,我们可知 K8s 和内核原生的内存管理机制存在以下局限:
-
全局内存回收缺少公平性机制 :当对内存进行超卖时,即使所有容器的内存使用量都显著低于 Limit,整机内存也可能触及全局内存回收水位线。在当前使用最广泛的 Cgroups v1 环境下,Container 声明的 Memory Request 默认不会体现在 Cgroups 配置上,仅作为调度的依据。因此,全局内存回收在 Pod 间缺少公平性保障,容器的可用内存不会像 CPU 一样按 Request 比例划分。
-
全局内存回收缺少优先级机制 :在混部场景下,低优离线容器往往运行着资源消耗型任务,可能大量申请内存。而内存回收并不感知业务的优先级,导致节点上的高优在线容器进入直接内存回收的慢速路径,干扰到在线应用的内存资源质量。
-
原生驱逐机制的触发时机可能较晚 :K8s 当前主要通过 kubelet 驱逐的方式保障内存使用的优先级与公平性,但是原生驱逐机制的触发时机可能发生在全局内存回收之后,不能及时生效。
-
Memcg 直接内存回收会影响业务性能 :当容器的内存使用量达到阈值时,会触发 Memcg 直接内存回收,造成内存分配的延迟,可能导致业务抖动。
Katalyst Memory Advisor 方案
系统架构
Katalyst Memory Advisor 的架构经过多次讨论和迭代,采用可插拔的设计,以框架加插件的模式便于开发者灵活扩展功能和策略。各组件或模块的职责如下:
- Katalyst Agent: 单机上的资源管理 Agent。本功能中涉及以下模块:
- Eviction Manager: 带外对 kubelet 原生驱逐策略进行扩展的框架。在本功能中负责周期性地调用各驱逐插件的接口,获取驱逐策略计算的结果并执行驱逐动作。
- Memory Eviction Plugins: Eviction Manager 的插件。本功能中涉及以下插件:
- System Memory Pressure 插件:基于整机级别内存压力的驱逐策略。
- NUMA Memory Pressure 插件:基于 NUMA Node 级别内存压力的驱逐策略。
- RSS Overuse 插件:基于 Pod 级别的 RSS 超用情况的驱逐策略。
- Reclaimed Resource Pressure 插件:基于离线 Pod 的内存资源满足度的驱逐策略。
- Memory QRM Plugin: 内存资源管理插件。在本功能中负责离线大框的 Memcg 配置,以及 Drop Cache 动作的实现。
- SysAdvisor: 单机上的算法模块,支持通过插件扩展算法策略。在本功能中涉及以下插件:
- Cache Reaper 插件:计算 Drop Cache 动作的触发时机,以及需要被 Drop Cache 的 Pod。
- Memory Guard 插件:计算离线大框实时的 Memory Limit。
- Memset Binder 插件:动态计算离线 Pod 应该绑定的 NUMA Node。
- Reporter: 带外信息上报框架。在本功能中负责上报内存压力相关的 Taint 到 Node 或 CustomNodeResource CRD 中。
- MetaServer: Katalyst Agent 中的元信息管理组件。在本功能中负责提供 Pod、Container 的元信息,缓存 Metrics,以及提供动态配置能力。
- Malachite: 单机上的 Metrics 数据采集组件。在本功能中负责提供 Node、NUMA、Container 级别的内存指标。
- Katalyst Scheduler: 中心调度器。本功能涉及的插件:
- 原生的 TaintToleration 插件,基于 Node Taint 进行过滤。
- 扩展 QoSAwareTaintToleration 插件,基于 CustomNodeResource CRD 中扩展的 Taint 实现 QoS 感知的禁止调度。
详细方案
多维度的干扰检测
Memory Advisor 通过周期性的干扰检测,提前感知内存压力,并触发对应的缓解措施。当前已支持下列维度的干扰检测:
- 整机 和 NUMA 级别 的内存水位:比较整机和 NUMA 级别的空闲内存水位和全局异步内存回收的阈值水位 Low Watermark 之间的关系,尽量避免触发全局直接内存回收。
- 整机 的 Kswapd 回收内存的速率:如果全局异步内存回收的速率较高,并且持续较久的时间,那么说明此时整机的内存压力较大,后续极有可能会触发全局直接内存回收。
- Pod 级别 的 RSS 超用情况:通过超卖可以使节点的内存得到充分使用,但是无法控制超卖的内存被用作 Page Cache 还是 RSS。如果某些 Pod 使用的 RSS 远超过其 Request,可能造成节点内存水位过高且无法被回收。进而影响其他 Pod 无法使用足够的 Page Cache 而性能受损,或者可能导致 OOM。
- QoS 级别
的内存资源满足度:通过比较节点 Relcaimed Memory 的供应量和该节点上
reclaimed\_cores
QoS 级别总的 Memory 申请量,计算离线作业的内存资源满足度,避免离线作业的服务质量受到严重影响。
多层级的缓解措施
根据干扰检测反馈的异常级别不同,Memory Advisor 支持多层级的缓解措施。在避免高优 Pod 受到干扰的同时,尽量减轻对 Victim Pod 的影响。
- 禁止调度
禁止调度是影响程度最小的缓解措施。当干扰检测反馈任何程度的整机异常时,都会触发该节点的禁止调度,避免调度更多的 Pod 使情况进一步恶化。
当前 Memory Advisor 已通过 Node Taint 支持对所有 Pod 的禁止调度,后续我们将使调度器能够感知 CustomNodeResource CRD 中扩展的 Taint,从而实现针对
reclaimed\_cores Pod
的精细化禁止调度。
- Tune Memcg
Tune Memcg 是一种对 Victim Pod 影响程度较小的缓解措施。当干扰检测反馈的异常程度较低时,会触发 Tune Memcg 操作,挑选部分
reclaimed\_cores Pod
,并为其配置较高的内存回收触发阈值,使离线 Pod 尽早触发内存回收,释放出来一些内存,从而尽量避免触发全局直接内存回收。
Tune Memcg 因为需要配合 veLinux 内核开源的 Memcg 异步内存回收特性 [3] 一起使用,默认不会开启,不影响使用。
- Drop Cache
Drop Cache 是一种对 Victim Pod 影响程度中等的缓解措施。当干扰检测反馈的异常程度中等时,会触发 Drop Cache 操作,挑选部分 Cache 用量较高的
reclaimed\_cores
Pod,强制释放其缓存,从而尽量避免触发全局直接内存回收。
在 Cgroups v1 环境下,通过
memory.force\_empty
接口触发缓存释放:
echo 0 > memory.force\_empty
在 Cgroups v2 环境下,通过向
memory.reclaim
接口写入一个较大的值触发缓存释放,比如:
echo 100G > memory.reclaim
因为 Drop Cache 是一个比较耗时的操作,我们实现了一个异步的任务执行框架,避免阻塞主流程。这一部分的技术细节将在后续的技术文章中进行介绍。
- 驱逐
驱逐是一种对 Victim Pod 影响较大的措施,也是最为快速、有效的兜底措施。当干扰检测反馈的异常程度较高时,会触发整机或 NUMA 级别的驱逐 (或仅对
reclaimed\_cores
Pod 的驱逐),从而有效避免触发全局直接内存回收。
Memory Advisor 支持用户通过配置自定义待驱逐 Pod 的排序逻辑。如果用户未配置,默认的排序逻辑如下:
- 根据 Pod 的 QoS 级别排序,
reclaimed\_cores
shared\_cores
/
dedicated\_cores
。
- 根据 Pod 的 Priority 排序,优先级低的先被驱逐。
- 根据 Pod 的 Memory Usage 排序,Usage 高的先被驱逐。
基于“策略器插件化,执行器收敛”的设计理念,我们在 Katalyst Agent 中抽象出了一个 Eviction Manager 框架,将驱逐策略下放到 Plugin 中,将驱逐动作收敛在 Manager。具有以下优势:
- Plugin 和 Manager 可以通过本地函数调用或远程 gRPC 协议通信,方便灵活启停插件。
- 可以在 Manager 中方便地支持一些针对驱逐的治理操作,比如过滤、限流、排序、审计等。
- 支持对插件进行 Dry Run,方便对策略进行充分验证后再使其真正生效。
离线大框
为了避免离线的容器过度使用内存影响到在线容器的服务质量,我们通过离线大框限制
reclaimed\_cores
QoS 级别总的内存用量。
具体实现上,我们在单机算法组件 SysAdvisor 中扩展了一个 Memory Guard 插件,周期性地计算
reclaimed\_cores
Pod 可以使用的内存总量,并通过 Memory QRM Plugin 将其下发到 BestEffort QoS 层级 Cgroup 的
memory.limit\_in\_bytes
文件中。
内存动态迁移
在 Flink 等业务场景下,服务的性能与内存带宽和内存延迟有较强的相关性,同时对内存容量也有一定规模的占用。默认的内存分配策略会优先从本地的 NUMA Node 分配内存,从而得到较小的内存访问延迟。但是另一方面,默认的内存分配策略可能会造成各个 NUMA Node 的内存使用不均衡,某些 NUMA Node 的压力过大成为热点,进而严重影响服务的性能,出现 LAG。
因此,我们通过 Memory Advisor 感知各个 NUMA Node 的内存水位,并动态调整容器绑定的 NUMA Node 进行内存迁移,避免某个 NUMA Node 成为热点。
Memcg 差异化回收策略
因 Memcg 直接内存回收对业务性能会造成较大影响,字节跳动内核团队为 veLinux 内核增强了 Memcg 异步内存回收特性,并已开源 [3]。
在混部场景下,在线业务主要的 IO 行为是读写日志,而离线任务读写文件更频繁,Page Cache 对离线作业的性能影响较大。因此,我们通过 Memory Advisor 支持了 Memcg 级别的差异化内存回收策略:
- 对于需要使用大量 Page Cache 的业务 (比如离线作业),用户可以通过 Pod Annotation 为其指定一个相对较低的 Memcg 异步内存回收水位,使其内存回收更保守,从而可以使用更多 Page Cache;
- 而某些业务更倾向于尽量避免触发直接内存回收造成性能抖动,则可以通过 Pod Annotation 为其配置相对激进的 Memcg 异步回收策略。
Memcg 差异化回收策略因为需要配合 veLinux 内核的开源特性一起使用,默认不会开启。
后续规划
在 Katalyst 后续的版本中,我们将持续迭代 Memory Advisor,使其能够支持更多用户场景。
将部分能力与 QoS 解耦
Memory Advisor 在混部场景下扩展了一些增强的内存管理能力,其中一些能力本质上是与 QoS 正交的,在非混部场景下依然适用。
因此,我们后续会将 Memcg 差异化回收策略、干扰检测与缓解等功能与 QoS 解耦,打造成通用场景下的精细化内存管理能力,使非混部场景的用户也可以使用。
OOM 优先级
上文中介绍到 Kubernetes 会根据容器其所属 Pod 的 QoS 级别,为其配置不同的
oom\_score\_adj
。但是最终的 OOM Score 还会受到内存用量等其他因素的影响。
在潮汐混部场景下,在离线 Pod 属于相同的 QoS 级别,可能无法保证离线 Pod 一定早于在线 Pod 被 OOM Kill。因此,需要扩展一个 Katalyst QoS Enhancement:QoS 优先级。Memory Advisor 需要在用户态为属于不同 QoS 优先级的容器配置对应的
oom\_score\_adj
,严格保证在离线 Pod 的 OOM 顺序。
此外,字节跳动内核团队近期为 Linux 内核提交了一个 Patch [4],期望通过 BPF 将内核的 OOM 行为可编程化,从而更加灵活地自定义 OOM 的策略。
冷内存卸载
节点上可能存在一些较少被使用的内存未被释放 (即冷内存),导致可以出让给离线作业使用的内存量较少,无法实现有效的内存超卖。
为了获得更多的内存出让量,我们参考了 Meta 的 Transparent Memory Offloading (TMO) 论文 [5],后续将使 Memory Advisor 在用户态通过 PSI 感知内存压力,当内存压力较小时提前触发内存回收。并通过内存冷热探测子模块 DAMON 统计内存热度信息,将冷内存换出到相对廉价的存储设备上,或通过 zRAM 将其压缩,从而节省内存空间,提高内存资源利用率。
总结
在字节跳动,Katalyst 部署了超过 900,000 个节点,管理了数千万核,统一管理各种类型的工作负载,包括微服务、搜广推、存储、大数据和 AI 作业等。将天级资源利用率从 20% 提升至 60% 的同时,保障了各种类型的工作负载的稳定运行。
相关链接
[1] K8s 原生的驱逐策略: https://kubernetes.io/docs/concepts/scheduling-eviction/node-pressure-eviction/
[2] Memory QoS KEP: https://github.com/kubernetes/enhancements/tree/master/keps/sig-node/2570-memory-qos
[3] Memcg 异步内存回收特性: https://github.com/bytedance/kernel/commit/7d7386ec89caf078f21836c5cae33ffa886125c4
[4] 通过 BPF 自定义内核 OOM 行为的 Patch: https://lore.kernel.org/lkml/20230804093804.47039-1-zhouchuyi@bytedance.com/
[5] TMO 论文: https://www.pdl.cmu.edu/ftp/NVM/tmo\_asplos22.pdf