Cloud Run CPU 节流如何破坏后台 Goroutine
Cloud Run 仅在处理请求期间为您的容器分配 CPU,因此定时器、心跳和缓存刷新会悄然停滞。本文将解释其原因,并提供四种解决方法。
一个在虚拟机上运行良好的 Go 服务,在迁移到 Cloud Run 的当天开始丢失其租约锁。租约心跳是一个每三十秒触发一次的普通 time.Ticker,但它会一次性静默数分钟。代码没有任何改变。原因是 Cloud Run 的一个默认设置,大多数指南只用一行提及便带过了:默认情况下,你的容器只有在处理请求时才会获得 CPU。在请求之间,CPU 会被限制到几乎为零,任何你期望在后台持续运行的 goroutine 都会随之停止运行。
如果你在 Cloud Run 上有后台循环、ticker、缓存刷新器、心跳、队列消费者,那么这个行为最有可能以一种在本地永远不会出现的方式破坏它们。本文将解释其机制,用 Go 代码展示这个故障,并详细介绍四种修复方法及其各自的权衡。
Cloud Run 如何分配 CPU
Cloud Run 有两种 CPU 分配模式,其默认模式常常出人意料。
默认模式是“在处理请求期间分配 CPU”(有时也称为基于请求的计费或 CPU 节流)。当您的容器正在处理至少一个请求时,它会获得完整的 CPU。一旦最后一个处理中的请求完成,Cloud Run 会将 CPU 节流至一个极小的比例,接近于零。容器仍然存活,其内存也完好无损,但几乎没有 CPU 周期被调度。您只需为请求处理期间使用的 CPU 付费,这就是为什么此模式更便宜的原因。
另一种模式是“CPU 始终分配”(基于实例的计费)。容器在其整个生命周期内都保持完整的 CPU,无论是否有请求正在处理。您需要为实例存在的整个时间付费,作为交换,后台工作可以正常运行。
在本地和普通 VM 上,您的进程始终拥有 CPU,因此后台 goroutine 可以正常工作。Cloud Run 的默认模式打破了这一假设,且无需更改您的一行代码,这正是导致故障令人困惑的原因。程序是正确的。是其底层的调度环境发生了变化。
在请求之间,究竟哪里会出问题
任何假定后台能稳定运行的进程都存在风险。常见情况包括:
- 一个按固定间隔触发以刷新内存缓存的
time.Ticker。由于刷新 goroutine 在请求之间因 CPU 资源不足而无法运行,缓存会变得陈旧。 - 一个“每 N 秒”续期一次的租约或锁心跳。如果续期 goroutine 没有得到调度,租约就会过期,并被另一个 worker 抢走。
- 一个在内存中批处理并通过计时器刷新的指标或日志刷新器。刷新操作会被延迟,直到下一个请求恰好唤醒该实例。
- 一个循环等待消息的后台队列或 Pub/Sub 拉取消费者。在没有入站请求时,它不会处理任何消息。
关于 time.Ticker 的一个重要细节是,计时器本身并不是问题。tick 是基于物理时钟时间(wall-clock time)传递的,其值甚至可以缓冲在 channel 中。问题在于,等待该 channel 的 goroutine 需要 CPU 来被唤醒并执行工作,而 CPU 资源正是被节流(throttling)所剥夺的。因此,tick 会按时“触发”,但其处理程序会延迟运行、或以突发方式运行、或仅在某个请求恰好使实例恢复全部 CPU 资源时才运行。
心跳的例子是最尖锐的,因为它悄无声息且会破坏共享状态。你不仅仅是迟到。你还会丢失一个你以为自己还持有的锁。
会导致自身租约过期的心跳
下面是导致问题的代码结构。一个工作器获取租约,然后在执行长任务时启动一个 goroutine,通过计时器来续订租约。
func (w *Worker) run(ctx context.Context) error {
if err := w.store.AcquireLease(ctx, w.id, 90*time.Second); err != nil {
return err
}
go w.heartbeat(ctx) // renew the lease in the background
return w.processLongJob(ctx)
}
func (w *Worker) heartbeat(ctx context.Context) {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
// Needs CPU to run. Under request-based throttling,
// this may not be scheduled until the next request.
_ = w.store.RenewLease(ctx, w.id, 90*time.Second)
}
}
}
在虚拟机上,这没有问题。在采用默认分配的 Cloud Run 上,如果 processLongJob 本身在等待 I/O 且没有新请求到达,实例就会被节流,心跳 goroutine 不会被调度,九十秒后租约就会过期。另一个实例看到过期的租约,并接手了相同的作业。现在,两个工作器运行相同的任务,而这正是租约本应防止的故障。
根本的困惑在于,代码看起来是并发且正确的,在为 goroutine 提供 CPU 的环境下确实如此。Cloud Run 的默认设置并不保证在请求之间会这样做。有两种解决方法:更改环境以使后台 goroutine 获得 CPU,或者更改设计,使其完全不依赖后台 goroutine。接下来的四个部分将涵盖这两种方法。
方案 1:始终分配 CPU 并设置最小实例数
最直接的修复方法是停止 CPU 限制。将实例设置为始终保留 CPU,并保持至少一个实例处于备用状态。
gcloud run services update my-service \
--no-cpu-throttling \
--min-instances=1
--no-cpu-throttling 将服务切换为基于实例的 CPU,因此 goroutine 可以在请求之间运行。--min-instances=1 保持至少一个实例处于活动状态,以便始终有一个容器可供后台循环在其中运行。您需要同时使用这两者。在一个已缩容到零的实例上,即使 CPU 是始终分配的,循环也不会运行,因为该实例已不复存在。
需要权衡的是成本。现在,您需要为实例运行的整个时间段(而不仅仅是处理请求期间)支付 CPU 和内存费用,而设置最小实例数意味着即使在零流量的情况下,您也需要每天 24 小时付费。对于一个确实需要常驻后台循环的服务来说,这是实实在在的代价,而且通常很小。对于一个出于习惯而添加后台 goroutine 的服务来说,这是在为保持一个本不需要常驻的东西而持续付费。在采用此方案之前,请先考虑这项工作是否真的完全需要在请求之外运行。如果确实需要,那么这就是一个简洁明了的解决方案。
选项 2:将后台工作移至请求路径
如果后台作业的存在只是为了给下一次请求保持某些内容是新鲜的,那么你可能根本不需要后台循环。在请求到达时执行工作,并加以保护,使其在每个时间间隔内最多运行一次。
type cache struct {
mu sync.Mutex
data []Item
refreshed time.Time
}
func (c *cache) get(ctx context.Context, refresh func(context.Context) ([]Item, error)) ([]Item, error) {
c.mu.Lock()
defer c.mu.Unlock()
if time.Since(c.refreshed) < 60*time.Second {
return c.data, nil // still fresh, no work
}
data, err := refresh(ctx)
if err != nil {
if c.data != nil {
return c.data, nil // serve stale on refresh failure
}
return nil, err
}
c.data, c.refreshed = data, time.Now()
return c.data, nil
}
现在,刷新是由流量触发的,而这正是 CPU 得到保证的时候。计时器变成了一种陈旧性检查,而不是调度器。这适用于缓存、小型的周期性重新计算,以及任何其唯一消费者是请求本身的情况。
它不适用于那些必须按计划执行而不管流量如何的工作。如果一小时内没有请求到达,那么一小时内什么都不会刷新。对于缓存来说,这是可以接受的,因为间隔后的第一个请求会承担一次性刷新的成本。对于必须在特定时间触发的任务来说,这是不可接受的,你需要接下来的两个选项之一。
选项 3:将周期性工作拆分为 Cloud Run 作业和 Cloud Scheduler
对于任何真正具有 cron 特征的工作,即按计划运行、执行一个工作单元、然后退出,正确的模型不是在请求服务型服务内部设置一个长期运行的循环,而是由 Cloud Scheduler 触发的 Cloud Run 作业。
Cloud Run 作业会运行一个容器直至完成,而不是用于处理请求,并且在整个运行期间始终拥有 CPU。Cloud Scheduler 根据 cron 表达式调用它。您的服务保持精简和无状态,而批处理工作则存在于其自己的位置,拥有自己的超时和重试策略。
# Deploy the periodic work as a Job (runs to completion, always has CPU)
gcloud run jobs create refresh-job \
--image=REGION-docker.pkg.dev/PROJECT/repo/refresh:latest \
--task-timeout=600 --max-retries=1
# Fire it every 10 minutes with Cloud Scheduler
gcloud scheduler jobs create http refresh-schedule \
--schedule="*/10 * * * *" \
--uri="https://REGION-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/PROJECT/jobs/refresh-job:run" \
--http-method=POST \
--oauth-service-account-email=[email protected]
这将请求-响应模型与批处理工作清晰地分离开来。作业能获得 CPU,因为它完全不受基于请求的节流限制。它在空闲时可缩减至零,两次运行之间不产生任何费用。Scheduler 为您提供了真正的 cron 语义、重试机制以及可供检查的运行历史记录。
需要权衡的是粒度和耦合。Cloud Scheduler 的最小间隔是一分钟,因此,对于必须每隔几秒钟就发生一次的事情,它并不是合适的工具。并且,作业在单独的进程中运行,因此它所需的任何状态都必须存放在双方都能访问的数据库或缓存中,而不是在服务的内存中。对于大多数真正的周期性维护工作,例如刷新物化视图、使旧记录过期、发送摘要等,这种模型与工作本身的形态是匹配的。
方案 4:使用 Pub/Sub 推送实现事件驱动
如果后台循环实际上是在消费队列,请将其反转。不要使用在循环中拉取消息的 goroutine,而是让 Pub/Sub 将每条消息推送到您服务上的 HTTP 端点。每次传送都是一个正常的请求,因此它会获得 CPU,并且没有常驻循环会饿死。
// Pub/Sub push delivers one message per HTTP POST.
// This runs as a request, so it always has CPU.
func handlePush(w http.ResponseWriter, r *http.Request) {
var env struct {
Message struct {
Data []byte `json:"data"`
} `json:"message"`
}
if err := json.NewDecoder(r.Body).Decode(&env); err != nil {
http.Error(w, "bad envelope", http.StatusBadRequest)
return
}
if err := processMessage(r.Context(), env.Message.Data); err != nil {
http.Error(w, "retry", http.StatusInternalServerError) // Pub/Sub redelivers
return
}
w.WriteHeader(http.StatusNoContent) // ack
}
返回 2xx 表示确认,或返回 5xx 让 Pub/Sub 稍后重新传送。订阅的重试和死信策略会处理失败,因此您可以删除一整类自己编写的重试循环。服务可以缩容到零,并在下一条消息到来时唤醒。
其代价是,推送传送是通过 HTTP 逐条消息进行的,这与进行批处理的拉取消费者不同。如果您需要高吞吐量的批处理或有序的拉取处理,那么在具有始终分配 CPU 的组件上使用拉取模型会更合适。但对于一次一个地对事件做出反应,推送将后台工作转变为普通的请求处理,而这正是 Cloud Run 默认设置所构建的模型。
哪种方案适合,以及何时适合使用“CPU 始终开启”
决定性的问题不是“如何让我的 goroutine 保持活动状态”。而是“这是否真的需要一个常驻的后台循环,或者它是否可以改为由请求驱动或事件驱动”。大多数在 Cloud Run 上中断的后台循环从来都不需要是常驻的。它们只是一种便捷的方式来完成工作,而这些工作更适合作为请求、计划作业或事件处理程序来完成。
| 工作模式 | 最佳方案 |
|---|---|
| 请求本身消耗的缓存 | 方案 2,在请求时刷新 |
| Cron 形式的维护,分钟或更粗的粒度 | 方案 3,Job 加 Scheduler |
| 响应排队事件 | 方案 4,Pub/Sub 推送 |
| 真正必须持续运行的循环 | 方案 1,“CPU 始终开启”加最小实例数 |
当工作确实无法表示为请求或事件,并且必须在服务进程内持续运行时,才应选择始终分配 CPU 的方案(方案 1)。例如亚秒级的内部计时、实时的内存中聚合、必须保持连接打开的流式拉取消费者、WebSocket 扇出。这些工作本质上在请求之间也需要 CPU,因此为一个“温”实例付费是正确的选择。这是一个会产生费用的决策,而不是因为计时器行为不当就轻易切换的默认选项。
对于所有其他情况,CPU 节流不是一个需要规避的 bug。它是一个信号,表明该工作应置于请求服务路径之外。租约心跳是最后一个很好的例子。即使使用“CPU 始终开启”模式,最安全的版本也完全不依赖于心跳的触发。将租约设计为基于到期时间,并对截止日期进行 compare-and-swap 操作,这样即使错过了续约,过期的租约也可以被回收。将正确性推入数据中,这样即使进程短暂失去 CPU,也不会破坏共享状态。Cloud Run 的默认设置比 VM 更早地强制实施这种规范,由此产生的系统往往因此而更加稳健。