在混部场景下,内存管理是一个很重要的话题:一方面,当节点或容器的内存紧张时,业务的性能可能会受到影响,比如出现时延抖动或者 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 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 成为热点。
在生产环境落地内存动态迁移功能的过程中,我们曾遇到可能导致系统 Hang 住的异常情况,因此对内存迁移的方式进行了优化。这一部分的实践经验将在后续的技术文章中展开介绍。
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 顺序。
该特性是 Katalyst 社区编程挑战活动 [4] 的课题之一,感兴趣的同学可以点击最下方的链接,欢迎交流和报名。
此外,字节跳动内核团队近期为 Linux 内核提交了一个 Patch [5],期望通过 BPF 将内核的 OOM 行为可编程化,从而更加灵活地自定义 OOM 的策略。
冷内存卸载
节点上可能存在一些较少被使用的内存未被释放 (即冷内存),导致可以出让给离线作业使用的内存量较少,无法实现有效的内存超卖。
为了获得更多的内存出让量,我们参考了 Meta 的 Transparent Memory Offloading (TMO) 论文 [6],后续将使 Memory Advisor 在用户态通过 PSI 感知内存压力,当内存压力较小时提前触发内存回收。并通过内存冷热探测子模块 DAMON 统计内存热度信息,将冷内存换出到相对廉价的存储设备上,或通过 zRAM 将其压缩,从而节省内存空间,提高内存资源利用率。
该特性的技术细节将在后续的技术文章中进行介绍。
在字节跳动,Katalyst 部署了超过 900,000 个节点,管理了数千万核,统一管理各种类型的工作负载,包括微服务、搜广推、存储、大数据和 AI 作业等。将天级资源利用率从 20% 提升至 60% 的同时,保障了各种类型的工作负载的稳定运行。
未来,Katalyst Memory Advisor 将持续迭代优化,冷内存卸载、内存迁移方式优化等更多技术原理将在后续的文章中进行解析,敬请期待。
[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] Katalyst 社区编程挑战活动: https://github.com/kubewharf/katalyst-core/discussions/253
[5] 通过 BPF 自定义内核 OOM 行为的 Patch: https://lore.kernel.org/lkml/20230804093804.47039-1-zhouchuyi@bytedance.com/
[6] TMO 论文: https://www.pdl.cmu.edu/ftp/NVM/tmo_asplos22.pdf