作者:来自 Elastic Constanca Manteigas
学习我们如何在 Google Cloud Run 上对用于 GCP 的 EDOT Cloud Forwarder 进行负载测试,并识别每个实例的实际容量上限。我们展示了运行时调优如何提升稳定性,并将结果转化为具体的配置和扩展指导。
用于 GCP 的 EDOT Cloud Forwarder( ECF )是一个事件触的 serverless OpenTelemetry Collector 部署,面向 Google Cloud。它在 Cloud Run 上运行 OpenTelemetry Collector,从 Pub/Sub 和 Google Cloud Storage 摄取事件,将 Google Cloud 服务日志解析为 OpenTelemetry 语义约定,并将生成的 OTLP 数据转发到 Elastic,同时依赖 Cloud Run 进行扩展、执行和基础设施生命周期管理。
要在规模化场景下自信地运行用于 GCP 的 ECF,你需要了解它的容量特性和规模行为。对于作为更广泛 ECF 架构一部分的用于 GCP 的 ECF,我们通过可重复的负载测试并基于测量数据来回答这些问题。
我们将介绍测试设置,解释每个运行时设置,并分享我们在单个实例上观察到的容量数据。
我们如何对用于 GCP 的 EDOT Cloud Forwarder 进行负载测试
架构
负载测试架构模拟了一个真实的高吞吐量流水线:
-
我们开发了一个负载测试服务,以尽可能快的速度将生成的日志文件上传到一个 GCS bucket。
-
该 Google Cloud Storage( GCS )bucket 中的每次文件创建都会触发一个事件通知到 Pub/Sub。
-
Pub/Sub 将推送消息发送到一个 Cloud Run 服务,在该服务中 EDOT Cloud Forwarder 获取并处理这些日志文件。
我们的设置暴露了两个主要的可调参数,它们直接影响 Cloud Run 的扩展行为和内存压力:
-
通过 concurrency 设置请求压力(每个 ECF 实例可以处理的并发请求数)。
-
通过 log count 设置每个请求的工作量(每个上传对象中每个文件包含的日志数量)。
在我们的测试中,我们使用了一个测试系统,该系统:
-
部署完整的测试基础设施。这包括完整的 ECF 基础设施、一个 mock backend 等。
-
根据配置的 log count 生成日志文件,使用大小约为 1.4 KB 的 Cloud Audit log。
-
在 concurrency 和日志数量的所有组合上运行一组矩阵测试。
-
为每个测试的 concurrency 级别生成一份报告,其中包含多项统计数据,例如 CPU 使用率和内存消耗。
为了保证可复现性和隔离性,EDOT Cloud Forwarder 中的 otlphttp exporter 使用一个始终返回 HTTP 200 的 mock backend。这确保所有观察到的行为都归因于 ECF 本身,而不是下游系统或网络波动。
步骤 1:在测量容量之前建立稳定的运行时
在询问单个实例可以处理多少负载之前,我们首先建立了一个稳定的运行时基线。
我们很快发现,一个名为 cpu_idle 的标志就可能让 Cloud Run 陷入垃圾回收( GC )饥饿的陷阱。这一点又被 ECF 当前架构中的一个已知限制所放大:现有的 OpenTelemetry 实现会在处理之前将整个日志文件读入内存。我们的目标是消除配置带来的副作用,使容量测试能够反映 ECF 的真实极限。
我们重点关注了三个运行时参数:
| 设置 | 控制内容 | 对 ECF 的重要性 |
|---|---|---|
| cpu_idle | CPU 是始终分配还是仅在请求期间分配 | 决定 垃圾回收器( GC )获得多少后台时间来回收内存 |
| GOMEMLIMIT | 容器内 Go 堆大小的上限 | 防止进程悄然增长直到 Cloud Run 因 OOM 将其终止 |
| GOGC | Go 中堆增长和回收的激进程度 | 以更高的 CPU 消耗换取更低的内存使用 |
所有参数隔离测试都使用单个 Cloud Run 实例( min 0,max 1 ),为所研究的场景固定 concurrency,并在各次运行中保持输入文件和测试矩阵完全一致。这样的设计使我们能够将差异直接归因于被测试的参数本身。
CPU 分配:停止让 垃圾回收器 挨饿
Cloud Run 提供两种 CPU 分配模式:
-
基于请求(受限)。通过 cpu_idle: true 启用。CPU 仅在请求被主动处理时可用。
-
基于实例(始终开启)。通过 cpu_idle: false 启用。CPU 在空闲时仍然可用,从而允许 垃圾回收 等后台工作运行。
测试在完全相同的条件下对这两种模式进行了对比:
| 参数 | 值 |
|---|---|
| vCPU | 1 |
| Memory | 4 GiB(足够高以消除 OOM 作为因素) |
| GOMEMLIMIT | 内存的 90% |
| GOGC | 默认(未设置) |
| Concurrency | 10 |
我们观察到的结果
当 CPU 仅在请求期间分配( cpu_idle: true )时:
-
内存波动极大(±71% RSS,±213% 堆内存)。
-
在最差的运行中,堆内存峰值达到约 304 MB。
-
样本中出现请求拒绝(成功率 90%)。
当 CPU 始终分配( cpu_idle: false )时:
-
内存波动变得严格受控(±8% RSS,±32% 堆内存)。
-
在最差的运行中,堆内存峰值降至约 89 MB。
-
样本中没有出现请求拒绝(成功率 100%)。
从这些运行中我们看到:
-
当 CPU 被限制时,Go 垃圾回收器实际上被饿死,导致堆积累和运行间大幅波动。
-
当 CPU 始终可用时,垃圾回收跟得上分配速度,导致内存使用更低且更可预测。
结论:对于这组测试, cpu_idle: false 是最稳定的基线配置。基于请求的 CPU 限制引入了人为的不稳定性,使容量规划更加困难。
Go 内存限制:受限容器中的 GOMEMLIMIT
Cloud Run 在容器级别强制执行硬内存限制。如果进程超出限制,实例将被 OOM 杀死。
我们测试了 Cloud Run 配置如下:
| 参数 | 值 |
|---|---|
| Container memory | 512 MiB |
| vCPU | 1 |
| Concurrency | 20 |
| GOGC | 默认(未设置) |
| cpu_idle | false |
测试比较了:
-
无 GOMEMLIMIT(Go 依赖操作系统压力)。
-
GOMEMLIMIT=460MiB(或容器内存的 90%)。
结果非常明确:
| GOMEMLIMIT | 结果 | 说明 |
|---|---|---|
| 未设置 | 不稳定;多次 OOM 杀死 | 服务从未产生稳定结果 |
| 460MiB | 稳定;运行完成 | 最差情况下峰值 RSS 达到约 505 MB,但进程仍在容器限制内 |
结论:在像 Cloud Run 这样的内存受限环境中,将 GOMEMLIMIT 设置接近(但低于)容器限制对于在负载下获得可预测行为至关重要。
GOGC:内存节省与可靠性
GOGC 参数控制堆在 GC 周期之间可以增长的百分比(%):
-
较低值(如 GOGC=50):更频繁的回收,内存较低,CPU 较高。
-
较高值(如 GOGC=100):回收较少,内存较高,CPU 较低。
测试覆盖了:
-
GOGC=50(激进)
-
GOGC=75(适中)
-
GOGC=100(默认/未设置)
设置:
| 参数 | 值 |
|---|---|
| Container memory | 4 GiB(足够高以消除 OOM 作为因素) |
| vCPU | 1 |
| Concurrency | 10(安全水平) |
| GOMEMLIMIT | 内存的 90% |
| cpu_idle | false |
我们观察到的结果
从这些运行中:
| GOGC | 峰值 RSS(样本) | CPU 行为 | 失败率 | 说明 |
|---|---|---|---|---|
| 50 | ~267 MB | 非常高;经常饱和 | 30% | GC 消耗了摄取所需的周期 |
| 75 | ~454 MB | ~83.5% 平均 | 10% | GC 消耗了摄取所需的周期 |
| 100(默认) | ~472 MB | ~83.5% 平均;为突发保留余量 | — | — |
这些运行的结论很明确:降低 GOGC 以换取内存会降低可靠性,而这种权衡对 ECF 并不划算。
结论:对于此工作负载,默认的 GOGC=100 提供了最佳平衡。通过降低 GOGC 来优化内存的尝试直接降低了可靠性。
步骤 2:找到容量和临界点
在运行时稳定后,我们通过增加 concurrency 直到出现失败,评估单个实例可以承受的流量。
如何阅读表格:每个 concurrency 水平进行了 20 次运行,覆盖轻负载(每个文件 240 条日志,约 362KB 文件大小)和重负载(每个文件超过 6k 条日志,约 8MB 文件大小)。表格报告了轻负载的基线 RSS 以及最差运行的峰值。
Concurrency 5:稳定基线
在 concurrency 5 时,服务运行稳定。
| 情况 | 内存(RSS) | CPU 利用率 | 请求被拒绝 |
|---|---|---|---|
| 基线(最轻负载平均) | 99.89 MB | — | — |
| 最差运行 | 211.02 MB | 86.43% | 否 |
这证明单个实例可以轻松处理中等负载,内存使用保持在安全范围内。
Concurrency 10:安全但波动较大
在 concurrency 10 时,系统仍然可用,但波动较大。
| 情况 | 内存(RSS) | CPU 利用率 | 请求被拒绝 |
|---|---|---|---|
| 基线(最轻负载平均) | 100.33 MB | — | — |
| 最差运行 | 424.80 MB | 94.10% | 否(样本中) |
我们还注意到内存使用显示出极端波动:
-
最佳运行 RSS:178 MB
-
最差运行 RSS:425 MB
这种行为主要由两个因素造成:
-
Pub/Sub 突发交付:10 个重负载请求几乎同时到达。
-
Collector 内部使用 io.ReadAll:每个请求将整个日志文件读入内存。
当所有 10 个请求同时到达时,我们实际上在 GC 清理之前堆叠了约 10 倍文件大小的内存。当请求稍微错开时,GC 有时间在请求之间回收内存,从而导致峰值大幅下降。
这带来了一个关键的容量规划洞察:
-
不要使用平均内存(例如约 260 MB)来进行服务容量规划。
-
应根据观察到的最差突发情况(约 425 MB)进行容量规划,以避免 OOM 或 GC 停顿。
-
实践中,在 concurrency 10 时,每个实例的内存限制应至少设置为 512 MiB。
Concurrency 20:不稳定,出现系统性负载削减
在 concurrency 20 时,系统持续开始削减负载。
| 情况 | 内存(RSS) | CPU 利用率 | 请求被拒绝 |
|---|---|---|---|
| 基线(最轻负载平均) | 97.44 MB | — | — |
| 最差运行 | 482.42 MB | 88.90% | 是(每次运行) |
即使内存和 CPU 指标看起来并没有比 concurrency 10 时明显恶化,行为却发生了质的变化:服务开始持续拒绝请求。
Concurrency 40:失败模式
在 concurrency 40 时,实例完全崩溃。内存和 CPU 超负荷,摄取可靠性崩溃。
| 情况 | 内存(RSS) | CPU 利用率 | 请求被拒绝 |
|---|---|---|---|
| 基线(最轻负载平均) | 100.20 MB | — | — |
| 最差运行 | 1234.28 MB | 96.57% | 是(所有运行) |
破坏点:1 vCPU 实例的实际极限
| Concurrency | 峰值 RSS(MB) | 稳定性 | 请求被拒绝? | 状态 |
|---|---|---|---|---|
| 5 | 211.02 | 低波动 | 否 | 稳定基线 |
| 10 | 424.80 | 高波动 | 否 | 安全但波动较大 |
| 20 | 482.42 | 高波动 | 是(频繁) | 不稳定(削减负载) |
| 40 | 1234.28 | 极端波动 | 是(总是) | 失败(内存爆炸) |
结合 CPU 数据(concurrency 10 时峰值 94%),这支持一个实际规则:对于此工作负载和架构,每个 1 vCPU 实例最多可处理 10 个并发重负载请求。
将发现转化为具体建议
这些实验得出了明确、可操作的建议,用于在 Cloud Run 上运行 ECF OpenTelemetry collector,作为更广泛 Elastic Cloud Forwarder 部署的一部分。
范围:这些建议适用于我们测试的工作负载和测试环境(轻负载与重负载日志文件,最大 8MB,以及 Pub/Sub 突发交付),使用下列调优后的运行时设置。如果你的日志大小、请求突发性或管道结构有显著差异,请针对自身流量验证这些限制。
运行时和容器配置
| 区域 | 建议 | 原因 |
|---|---|---|
| CPU 分配 | 设置 cpu_idle: false(始终开启 CPU) | 避免 GC 饥饿,稳定内存波动,并消除因长时间 GC 暂停导致的请求失败 |
| Go 内存限制 | 将 GOMEMLIMIT 设置为约容器内存的 90% | 强制堆边界与 Cloud Run 限制对齐,使 Go 在操作系统之前做出反应,防止 OOM 杀死 |
| 垃圾回收 | 保持 GOGC 为 100(默认) | 降低 GOGC 会以更高 CPU 消耗和可测失败率为代价降低内存使用 |
容量和每实例限制
对于运行 ECF OpenTelemetry collector 且已调优的 1 vCPU Cloud Run 实例:
| 限制 | 建议 | 原因 |
|---|---|---|
| 硬并发 | 每个实例限制在 10 个请求 | 在 concurrency 10 时,CPU 在最差运行已达到约 94%;更高并发会引发不稳定(请求被拒、GC 停顿) |
| 内存 | 每个实例至少使用 512 MiB(针对 concurrency 10) | 最差观察到的 RSS 约为 425 MB;512 MiB 为突发对齐提供了狭窄但可用的安全余量 |
扩展策略:水平扩展,而非垂直扩展
-
垂直扩展(增加每实例并发)很快就会触及 CPU 和内存极限,不适合此工作负载。
-
水平扩展更合适:将每个实例视为工作单元,硬限制为 10 个并发重任务。
实际操作:
-
配置服务,使任何实例的并发请求不超过 10。
-
让自动扩展通过增加实例应对负载增加,而不是增加每实例的并发数。
关键结论
-
调优的运行时设置与原始资源同样重要:像 cpu_idle 这样的单个标志就可能决定行为是可预测还是由 GC 驱动的混乱。
-
Go 在容器中需要明确的限制:在内存受限环境中必须设置 GOMEMLIMIT,否则在重负载摄取下 OOM 杀死不可避免。
-
“更低内存” 并不总是更好:激进的 GC 调优(GOGC < 100)确实降低了内存使用,但直接提高了失败率。
-
对于 1 vCPU 的 ECF 实例,concurrency 10 是现实上限;超过此值,请求拒绝和不稳定将成为常态。
-
水平扩展是正确模型:每个实例应视为一个 10 请求的工作单元,更高的总吞吐量应通过增加工作实例数量实现,而不是提高每个实例的并发数。