Cloud Run CPU 스로틀링이 백그라운드 고루틴을 중단시키는 방식
Cloud Run은 요청 중에만 컨테이너에 CPU를 할당하므로 티커, 하트비트, 캐시 새로고침이 조용히 중단됩니다. 그 이유와 이를 해결하는 네 가지 방법은 다음과 같습니다.
VM에서 잘 실행되던 Go 서비스가 Cloud Run으로 이전한 날부터 임대 잠금(lease lock)을 잃기 시작했습니다. 30초마다 실행되는 단순한 time.Ticker인 임대 하트비트가 한 번에 몇 분씩 응답이 없곤 했습니다. 코드에는 아무런 변경 사항이 없었습니다. 원인은 대부분의 가이드에서 한 줄로 언급하고 넘어가는 Cloud Run의 기본 설정 때문입니다. 즉, 기본적으로 컨테이너는 요청을 처리하는 동안에만 CPU를 할당받습니다. 요청과 요청 사이에는 CPU가 거의 0에 가깝게 조절(throttle)되며, 백그라운드에서 계속 실행될 것으로 예상했던 모든 고루틴(goroutine)도 함께 실행을 멈춥니다.
Cloud Run에서 백그라운드 루프, 티커, 캐시 리프레셔, 하트비트, 큐 컨슈머 등을 사용한다면, 이 동작은 로컬 환경에서는 절대 나타나지 않는 방식으로 해당 기능들을 망가뜨릴 가장 유력한 단일 동작입니다. 이 게시물에서는 그 메커니즘을 설명하고, Go 코드로 실패 사례를 보여주며, 각 방법의 장단점과 함께 네 가지 해결 방법을 단계별로 안내합니다.
Cloud Run이 CPU를 할당하는 방법
Cloud Run에는 두 가지 CPU 할당 모드가 있으며, 기본 모드는 사람들을 놀라게 하는 것입니다.
기본 모드는 "요청 처리 중에만 CPU 할당"(요청 기반 청구 또는 CPU 조절이라고도 함)입니다. 컨테이너는 하나 이상의 요청을 처리하는 동안 전체 CPU를 할당받습니다. 마지막 진행 중인 요청이 완료되는 순간, Cloud Run은 CPU를 거의 0에 가까운 아주 작은 부분으로 조절합니다. 컨테이너는 계속 활성 상태이고 메모리는 그대로 유지되지만, CPU 사이클은 거의 예약되지 않습니다. 요청 중에 사용된 CPU에 대해서만 요금이 청구되므로 이 모드가 더 저렴합니다.
다른 모드는 "CPU 상시 할당"(인스턴스 기반 청구)입니다. 컨테이너는 요청의 진행 여부와 관계없이 전체 수명 주기 동안 전체 CPU를 유지합니다. 인스턴스가 존재하는 전체 시간에 대해 비용을 지불하며, 그 대가로 백그라운드 작업은 정상적으로 실행됩니다.
로컬 환경과 일반 VM에서는 프로세스가 항상 CPU를 가지므로 백그라운드 goroutine이 그냥 작동합니다. 기본 Cloud Run 모드는 코드 한 줄 변경하지 않고 그러한 가정을 제거하며, 이것이 바로 실패가 혼란스러운 이유입니다. 프로그램은 올바릅니다. 그 아래의 스케줄링 환경이 변경된 것입니다.
요청 사이에 실제로 중단되는 것
꾸준한 백그라운드 진행을 가정하는 모든 것이 위험에 처합니다. 일반적인 경우는 다음과 같습니다.
- 고정된 간격으로 실행되어 인메모리 캐시를 새로고침하는
time.Ticker. 요청 사이에 새로고침 고루틴(goroutine)이 CPU를 할당받지 못해 캐시가 오래된 상태가 됩니다. - "N초마다" 갱신하는 임대(lease) 또는 잠금(lock) 하트비트. 갱신 고루틴이 스케줄링되지 않으면 임대가 만료되고 다른 워커가 이를 가져갑니다.
- 메모리에서 일괄 처리하고 타이머에 따라 플러시하는 메트릭 또는 로그 플러셔. 다음 요청이 발생하여 인스턴스를 깨울 때까지 플러시가 지연됩니다.
- 메시지를 기다리며 반복하는 백그라운드 큐 또는 Pub/Sub pull 소비자. 인바운드 요청이 없는 동안에는 아무것도 처리하지 않습니다.
time.Ticker에 대한 중요한 세부 사항은 타이머 자체가 문제가 아니라는 것입니다. 틱(tick)은 실제 시간(wall-clock time)을 기준으로 전달되며, 값은 채널에 버퍼링되어 있을 수도 있습니다. 문제는 해당 채널에서 대기하는 고루틴이 깨어나서 작업을 실행하려면 CPU가 필요하며, 스로틀링(throttling)이 바로 그 CPU를 빼앗아 간다는 것입니다. 따라서 틱은 스케줄에 따라 "발생"하지만, 핸들러는 늦게 실행되거나, 폭발적으로 실행되거나, 또는 요청이 발생하여 인스턴스의 CPU를 완전히 복구시킬 때만 실행됩니다.
하트비트 사례는 조용히 발생하고 공유 상태를 손상시키기 때문에 가장 치명적입니다. 단순히 늦는 것이 아닙니다. 가지고 있다고 생각했던 잠금을 잃게 됩니다.
자신의 리스를 만료시키는 하트비트
다음은 문제가 발생한 코드의 형태입니다. 워커는 리스를 획득한 다음, 긴 작업을 수행하는 동안 타이머로 리스를 갱신하는 고루틴을 시작합니다.
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)
}
}
}
VM에서는 이것이 괜찮습니다. 기본 할당을 사용하는 Cloud Run에서는 processLongJob 자체가 I/O를 기다리고 있고 새로운 요청이 도착하지 않으면 인스턴스가 스로틀링되어 하트비트 고루틴이 스케줄링되지 않고 90초 후에 리스가 만료됩니다. 다른 인스턴스가 만료된 리스를 보고 동일한 작업을 선택합니다. 이제 두 워커가 동일한 태스크를 실행하게 되는데, 이는 리스가 방지하고자 했던 바로 그 실패입니다.
근본적인 혼란은 코드가 동시성으로 올바르게 보인다는 점이며, 고루틴에 CPU를 제공하는 환경에서는 실제로 그렇습니다. Cloud Run의 기본 설정은 요청과 요청 사이에 이를 보장하지 않습니다. 두 가지 해결 방법이 있습니다. 백그라운드 고루틴이 CPU를 얻도록 환경을 변경하거나, 백그라운드 고루틴에 전혀 의존하지 않도록 설계를 변경하는 것입니다. 다음 네 개의 섹션에서 이 두 가지 방법을 모두 다룹니다.
옵션 1: 최소 인스턴스로 CPU를 항상 할당
가장 직접적인 해결책은 조절(throttling)을 중지하는 것입니다. 인스턴스가 항상 CPU를 유지하도록 설정하고 최소 하나의 인스턴스를 준비된 상태(warm)로 유지합니다.
gcloud run services update my-service \
--no-cpu-throttling \
--min-instances=1
--no-cpu-throttling은 서비스를 인스턴스 기반 CPU로 전환하여 요청 사이에 goroutine이 실행되도록 합니다. --min-instances=1은 최소 하나의 인스턴스를 활성 상태로 유지하여 백그라운드 루프가 실행될 컨테이너가 항상 있도록 합니다. 두 가지 모두 필요합니다. 0으로 축소된 인스턴스에 항상 할당된 CPU는 인스턴스가 더 이상 존재하지 않기 때문에 여전히 실행 중인 루프가 없습니다.
단점은 비용입니다. 이제 요청 중일 때뿐만 아니라 인스턴스가 실행되는 전체 시간 동안 CPU와 메모리 비용을 지불하게 되며, 최소 인스턴스는 트래픽이 0일 때도 하루 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 Job과 Cloud Scheduler로 분리
진정한 cron 형태의 작업, 즉 일정에 따라 실행되고, 작업 단위를 수행한 후, 종료되는 모든 작업의 경우 올바른 모델은 요청 처리 서비스 내의 오래 실행되는 루프가 아닙니다. Cloud Scheduler에 의해 트리거되는 Cloud Run Job이 올바른 모델입니다.
Cloud Run Job은 요청을 처리하는 대신 컨테이너를 완료될 때까지 실행하며, 실행되는 동안 항상 CPU를 할당받습니다. Cloud Scheduler는 cron 표현식에 따라 이를 호출합니다. 서비스는 가볍고 상태 비저장(stateless)으로 유지되며, 배치 작업은 자체적인 타임아웃 및 재시도 정책을 가지고 별도의 공간에 존재하게 됩니다.
# 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]
이는 요청-응답 모델을 배치 작업과 깔끔하게 분리합니다. Job은 요청 기반의 조절(throttling) 대상이 전혀 아니므로 CPU를 할당받습니다. 유휴 상태일 때는 0으로 축소되어 실행 간에 비용이 발생하지 않습니다. Scheduler는 실제 cron 시맨틱, 재시도, 그리고 검사할 수 있는 실행 기록을 제공합니다.
단점은 세분성(granularity)과 결합도(coupling)입니다. Cloud Scheduler의 가장 짧은 간격은 1분이므로, 몇 초마다 실행되어야 하는 작업에는 적합한 도구가 아닙니다. 그리고 Job은 별도의 프로세스에서 실행되므로, 필요한 모든 상태는 서비스의 메모리가 아닌 양쪽에서 모두 접근할 수 있는 데이터베이스나 캐시에 있어야 합니다. 구체화된 뷰 새로고침, 오래된 레코드 만료, 다이제스트 전송과 같은 대부분의 진정한 주기적 유지보수 작업의 경우, 이 모델이 작업의 형태와 일치합니다.
옵션 4: Pub/Sub 푸시를 사용한 이벤트 기반
백그라운드 루프가 실제로 큐를 소비하고 있었다면 이를 반대로 하세요. 루프에서 메시지를 가져오는 goroutine 대신 Pub/Sub가 각 메시지를 서비스의 HTTP 엔드포인트로 푸시하도록 하세요. 각 전송은 일반적인 요청이므로 CPU를 할당받으며, 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가 나중에 다시 전송하도록 합니다. 구독의 재시도 및 데드 레터 정책이 실패를 처리하므로 직접 만든 재시도 루프 클래스 전체를 삭제할 수 있습니다. 서비스는 0으로 축소될 수 있으며 다음 메시지에서 재개될 수 있습니다.
단점은 푸시 전송이 HTTP를 통해 메시지별로 이루어진다는 것이며, 이는 일괄 처리하는 풀(pull) 소비자와는 다릅니다. 높은 처리량의 일괄 처리 또는 순서가 지정된 풀(pull) 처리가 필요한 경우, 항상 CPU가 할당된 구성요소의 풀(pull) 모델이 더 적합합니다. 하지만 한 번에 하나씩 이벤트에 반응하는 경우, 푸시는 백그라운드 작업을 일반적인 요청 처리로 전환하며, 이는 Cloud Run의 기본값이 구축된 모델입니다.
어떤 옵션이 적합하며, 언제 상시 할당 CPU가 적절한가
결정적인 질문은 "내 goroutine을 어떻게 계속 실행 상태로 유지할까"가 아닙니다. 질문은 "이것이 정말로 상주하는 백그라운드 루프를 필요로 하는가, 아니면 대신 요청 기반이나 이벤트 기반으로 처리될 수 있는가"입니다. Cloud Run에서 중단되는 대부분의 백그라운드 루프는 애초에 상주할 필요가 없었습니다. 그것들은 요청, 예약된 작업 또는 이벤트 핸들러로 더 적합한 작업을 수행하는 편리한 방법이었습니다.
| 작업 패턴 | 최적의 선택 |
|---|---|
| 요청 자체가 소비하는 캐시 | 옵션 2, 요청 시 새로고침 |
| Cron 형태의 유지보수, 분 단위 또는 그 이상의 세분성 | 옵션 3, 작업 및 스케줄러 |
| 큐에 추가된 이벤트에 반응 | 옵션 4, Pub/Sub 푸시 |
| 정말로 계속 실행되어야 하는 루프 | 옵션 1, 상시 할당 CPU 및 최소 인스턴스 |
작업이 진정으로 요청이나 이벤트로 표현될 수 없고 서빙 프로세스 내에서 계속 실행되어야 할 때 상시 할당 CPU(옵션 1)를 사용하세요. 초 미만의 내부 틱, 실시간 인메모리 집계, 연결을 계속 열어 두어야 하는 스트리밍 풀 소비자, WebSocket 팬아웃. 이러한 작업들은 본질적으로 요청 사이에 CPU를 필요로 하며, 준비된 인스턴스(warm instance)에 비용을 지불하는 것이 올바른 결정입니다. 이는 티커가 잘못 동작했다는 이유로 바꾸는 기본 설정이 아니라, 비용이 수반되는 결정입니다.
그 외 모든 경우에는 스로틀링이 해결해야 할 버그가 아닙니다. 이는 해당 작업이 요청 처리 경로 외부에 속한다는 신호입니다. 임대(lease) 하트비트가 좋은 마지막 예입니다. 상시 할당 CPU를 사용하더라도 가장 안전한 버전은 하트비트 발생에 전혀 의존하지 않습니다. 마감 시간에 compare-and-swap을 사용하여 임대를 만료 기반으로 만들어, 갱신을 놓치더라도 오래된 임대를 회수할 수 있도록 하세요. 정확성을 데이터에 반영하여, 잠시 CPU를 잃은 프로세스가 공유 상태를 손상시킬 수 없도록 하세요. Cloud Run의 기본 설정은 VM보다 훨씬 더 일찍 이러한 원칙을 강제하며, 그 결과로 나오는 시스템은 더 견고해지는 경향이 있습니다.
관련 글
Cloud Run 서비스 vs 잡: 하나의 Go 바이너리, 두 개의 진입점
Cloud Run 서비스와 작업은 하나의 Go 이미지와 하나의 빌드를 공유할 수 있습니다. 여기서는 단일 바이너리가 서브 모드와 배치 작업으로 분할되는 방법과 그렇게 하지 말아야 할 경우를 설명합니다.
Redis 키스페이스 알림과 리스를 이용한 GPU 워커 풀 로드 밸런싱
GPU는 비싸고 한 번에 하나의 무거운 작업만 실행하므로, 라운드 로빈 라우팅은 바쁜 워커 뒤에서 지연됩니다. 여기에 Redis 리스와 키스페이스 알림으로 구축된 바쁨을 인지하는 스케줄러가 있습니다.
MongoDB 변경 스트림을 이용한 Cron 스케줄 핫 스와핑
하드코딩된 cron은 스케줄이 변경될 때마다 재배포를 의미합니다. 데이터베이스에 스케줄을 저장하고 변경 스트림으로 데몬이 편집에 반응하도록 합니다. 여기에 그 설계와 실패 처리 방법이 있습니다.
단일 실행기 작업을 위한 CAS 및 하트비트를 사용하는 임대 잠금
일반적인 잠금은 홀더가 죽으면 영원히 교착 상태에 빠집니다. 리스는 만료됩니다. 여기서는 compare-and-swap 획득과 하트비트를 사용하여 리스를 구축하는 방법과, 설계를 결정하는 실패 모드에 대해 설명합니다.
멱등성 업서트를 위한 결정론적 ULID
데이터베이스가 자동 쓰기 재시도를 금지하는 경우, 모든 쓰기는 그 자체로 멱등성을 가져야 합니다. 콘텐츠에서 파생된 ID를 사용하면 이를 비용 없이 달성할 수 있습니다. 여기에 그 유도 과정과 안전성을 보장하는 한 가지 불변식이 있습니다.
Firestore의 MongoDB 호환성에서 `retryWrites=false` 함정
Firestore는 MongoDB 와이어 프로토콜을 지원하지만, 재시도 가능 쓰기는 거부합니다. 드라이버에서 이 기능이 기본적으로 활성화되어 있으면, 쓰기가 무작위로 실패하는 것처럼 보입니다. 그 이유와 해결 방법은 다음과 같습니다.