针对 Kubernetes 工作负载优化–Go 的垃圾收集器|一种动态调优方法
Go 的垃圾收集器 (GC) 设计精良,对于大多数应用程序来说开箱即用,性能卓越。然而,在 Kubernetes 中运行容器化工作负载时,我们发现可以通过平衡内存和 CPU 使用率来进一步优化并降低成本。在本文中,我将分享我们动态调整 Go GC 行为以利用内存换取 CPU 的方法。
动机:CPU 密集型 Kubernetes 集群
在深入研究技术解决方案之前,首先要了解是什么促使我们探索 GC 优化。与许多运行大规模 Kubernetes 部署的组织一样,我们发现集群中的节点通常受 CPU 限制,而不是受内存限制。
我们通常仅根据 CPU 利用率进行自动扩展,但仍然使用 CPU 和内存请求来调度我们的 pod。
在这种情况下,只要我们不改变内存请求,任何用内存换取 CPU 的优化都变得非常有价值。即使 CPU 使用率略有降低,也能带来以下好处:
-
允许节点上更高的 pod 密度
-
降低总体基础设施成本
-
通过释放 CPU 周期来处理业务逻辑,从而缩短应用程序响应时间
当我们分析 Go 应用程序时,我们发现垃圾收集在许多服务中消耗了 10% 到 20% 的 CPU 时间。这代表着一个重大的机遇:如果我们能够通过充分利用未充分利用的内存预算来降低 GC 的 CPU 开销,那么我们就能在整个平台上实现显著的效率提升。
理解 Go 的 GC 行为:超越显而易见
在深入探讨优化策略之前,让我们先了解一下 Go 垃圾收集器中一些经常让开发者感到惊讶的违反直觉的方面。
暂停时间与内存大小无关
最常见的误解之一是 GC 暂停时间与释放的内存量相关。实际上,GC 暂停时长主要取决于 goroutine 的数量,而不是堆大小。这意味着,无论内存压力如何,具有大量并发 goroutine 的应用程序都可能经历更长的暂停。
每个周期的固定成本
垃圾收集器每个周期的计算成本是相对固定的。这意味着频繁的 GC 周期会消耗大量的 CPU 资源,即使每个周期处理的内存相对较少。这里的关键在于,降低 GC 频率可以显著节省 CPU 资源。
CPU-内存权衡
Go 的 GC 本质上是在 CPU 使用率和内存消耗之间进行权衡。通过允许堆在触发垃圾收集之前增长,我们可以降低 GC 周期的频率,从而节省 CPU 时间。然而,这需要以更高的内存使用率为代价。
Kubernetes 为何改变游戏规则
Go 的默认 GC 行为针对可用内存因其他应用程序竞争资源而波动的环境进行了优化。垃圾收集器认为它需要对内存使用保持保守,因为它无法预测有多少可用内存。作为语言运行时的默认行为,这种行为非常合理,因为语言运行时不应该对 Go 应用程序的运行环境做出特别的假设。
然而,Kubernetes 从根本上改变了这一假设。当我们为容器定义内存请求和限制时,我们明确地为应用程序预留了可用的内存。这为我们提供了可预测的内存预算,我们可以利用它来进行 GC 优化。
GOMEMLIMIT 简介:优化的关键
Go 1.19 引入了 GOMEMLIMIT,它允许我们设置 GC 使用的软内存限制。如果配置正确,它可以显著降低 GC 频率和 CPU 开销。但是,有一个关键的警告:GOMEMLIMIT 仅考虑堆内存,而不是进程的总内存使用量。
为了有效地使用 GOMEMLIMIT,我们需要考虑所有非堆内存的使用情况并相应地设定目标。
挑战:收益递减
虽然内存与 CPU 之间的权衡关系很强大,但其收益却呈递减趋势。实际上,内存使用量大致为:总计 = 基数 + GC 间隔 * 每秒分配内存。正如 Golang 文档中所述,每个周期的 GC 成本是恒定的,因此,如果我们想将 GC 的 CPU 时间减半,就需要将执行的周期数减半,或者换句话说,我们需要将两个周期之间的间隔加倍。这意味着我们的内存使用量将几乎翻倍。
当然,将 GC 周期加倍的绝对影响要高于将其减半的绝对影响。例如
-
如果我们将 20% 的 CPU 时间花在 GC 上以维持 1 GiB 的内存使用量:
-
要消耗 10% 的内存,我们需要大约 2 GiB 的内存,我们实际上以每 GiB 内存 5% 的 CPU 时间进行交易
-
要花费 5% 我们需要大约 4 GiB,现在交易是每 GiB 1.2% 的 CPU
-
要花费 2.5%,我们需要 8 GiB,每 GiB 需要 0.3% 的 CPU。
当然,这些数字只是近似值,但它们给出了正确的直觉。
我们的解决方案:动态 GC 调优
我们并没有试图找到完美的静态 GC 配置来平衡内存和 CPU,而是开发了一个库,可以根据运行时观察不断调整 GC 设置。它的工作原理如下:
算法
-
保守启动:首先将 GOMEMLIMIT 设置为容器内存请求的 80%,并将 GOGC 设置为 maxInt
-
监控内存使用情况:如果总内存使用量超过我们的阈值,则减少 GOMEMLIMIT 以触发更频繁的收集
-
监控 CPU 使用率:如果 GC CPU 使用率超过总 CPU 时间的 1%,请增加 GOMEMLIMIT 以降低收集频率。此处的 1% 完全是任意值。
-
定期重复:根据当前情况每分钟调整设置
实施策略
// Pseudocode for the tuning logicfunc tuneGC() { memoryUsage := getCurrentMemoryUsage() gcCPUPercent := getGCCPUUsage()
if memoryUsage > memoryThreshold { // Memory pressure: reduce target to free up memory decreaseGOMEMLIMIT() } else if gcCPUPercent > 1.0 { // High GC CPU usage: increase target to reduce frequency increaseGOMEMLIMIT() }
// Apply the new limit debug.SetMemoryLimit(newLimit)}
为什么有效
这种方法解决了几个关键挑战:
限制内存浪费:通过监控实际内存使用情况,我们避免设置不必要的高限制,从而浪费分配的内存而不提供 CPU 优势。
适应工作负载变化:应用程序在其整个生命周期中通常会有不同的内存分配模式。我们的动态方法可以自动适应这些变化。
平衡竞争约束:该算法根据实时指标不断平衡内存效率和 CPU 性能的竞争需求。
结果:显著节省 CPU
这种方法对我们的应用程序组合产生了巨大的影响:
性能改进
-
CPU 减少:大多数应用程序的 GC CPU 使用率从 10-20% 下降到 1% 左右
-
内存利用率:内存使用量按预期增加,但仍在容器限制范围内
-
优化资源利用:由于我们的集群主要受 CPU 限制,因此用内存交换 CPU 周期可以显著提高基础设施效率
结论
Go 的垃圾收集器默认性能出色,但 Kubernetes 环境提供了独特的优化机会。通过根据运行时内存和 CPU 指标动态调整 GOMEMLIMIT,我们可以显著降低 GC 开销,同时高效利用分配的容器内存。
对于在 Kubernetes 中运行 Go 服务并希望最大限度提高资源效率的团队来说,动态 GC 调整代表了一种强大的优化技术,它与 Go 精心设计的垃圾收集系统相辅相成,而不是相互对抗。
随手关注或者”在看“,诚挚感谢!
更多推荐


所有评论(0)