Published on

GCP Cloud Run 503·Cold Start 원인과 튜닝

Authors

서론

Cloud Run은 “요청이 오면 컨테이너를 올리고, 없으면 내린다”는 단순한 운영 모델 덕분에 배포·스케일링 부담이 크게 줄어듭니다. 하지만 운영 단계에서 가장 자주 마주치는 이슈가 바로 간헐적인 503첫 요청이 유독 느린 cold start입니다. 특히 트래픽이 듬성듬성하거나, 컨테이너 초기화가 무거운 서비스(예: Spring Boot, 대형 Node.js 번들, Python ML 라이브러리 포함)는 503/지연이 체감될 정도로 나타납니다.

이 글에서는 Cloud Run에서 503이 뜨는 경로를 (1) 라우팅/인스턴스 준비, (2) 애플리케이션 준비, (3) 업스트림 의존성(DB/외부 API) 준비로 나눠서 진단하고, 설정/아키텍처/코드 레벨에서 튜닝하는 방법을 정리합니다. (쿠버네티스 환경에서 “Pod는 Running인데 503”을 추적하는 사고 방식도 상당 부분 유사합니다: EKS에서 Pod는 Running인데 503가 뜰 때 점검)


Cloud Run의 503은 언제 발생하나

Cloud Run에서 사용자 요청이 503으로 보이는 대표 상황은 다음과 같습니다.

1) 인스턴스가 준비되기 전에 요청이 들어옴 (cold start/scale from zero)

  • 인스턴스 수가 0인 상태에서 첫 요청이 들어오면 새 인스턴스를 띄웁니다.
  • 이때 이미지 pull, 컨테이너 start, 앱 부팅, 포트 listen까지의 시간이 길면 지연이 커집니다.
  • 일정 조건에서는 요청이 대기열에서 타임아웃되어 503으로 끝날 수 있습니다.

2) 동시성/인스턴스 한계로 큐잉이 길어짐 (과부하)

  • concurrency(요청 동시 처리 수)가 높으면 한 인스턴스가 많은 요청을 받아 큐잉이 늘 수 있습니다.
  • 반대로 concurrency가 너무 낮으면 인스턴스가 더 많이 필요해져 scale-out이 따라오지 못할 때 지연/503이 발생할 수 있습니다.
  • max instances를 낮게 잡아둔 경우(비용 통제 목적) 트래픽 스파이크에서 병목이 됩니다.

3) 애플리케이션이 “프로세스는 뜨지만 준비가 안 된” 상태

Cloud Run은 Kubernetes의 readiness probe처럼 세밀한 준비 상태를 직접 설정하진 않지만, 실제로는 다음이 빈번한 원인입니다.

  • 서버가 포트를 열기 전에 요청이 라우팅됨(초기화가 긴데 listen이 늦음)
  • 애플리케이션이 부팅 중 예외로 재시작 루프에 빠짐
  • 메모리 부족(OOM)로 프로세스가 죽고 재기동

(쿠버네티스에서 CrashLoopBackOff를 로그/리소스/프로브로 디버깅하는 접근은 Cloud Run에서도 그대로 유효합니다: Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅)

4) 업스트림 의존성 지연으로 요청이 타임아웃

  • DB 커넥션 풀 생성이 느리거나, 첫 쿼리가 느려서 응답이 늦음
  • 외부 API/JWKS/JWK 로딩 등 네트워크 호출이 부팅 경로에 포함됨
  • 특정 인증/키 캐시 미스가 “첫 요청”에만 발생

503·cold start를 숫자로 분해하기: 로그/메트릭 체크리스트

“느리다/503이다”는 체감만으로는 튜닝이 어렵습니다. 먼저 어디에서 시간이 쓰이는지를 나눠야 합니다.

1) Cloud Logging: 요청 로그에서 상태코드/지연 확인

Cloud Run 요청 로그는 보통 다음 정보를 포함합니다.

  • httpRequest.status (예: 200, 503)
  • httpRequest.latency (요청 처리 시간)
  • severity 및 애플리케이션 stdout/stderr

로그 탐색기에서 서비스 단위로 필터링 예시:

resource.type="cloud_run_revision"
resource.labels.service_name="YOUR_SERVICE"
(httpRequest.status=503 OR httpRequest.latency>"2s")

여기서 중요한 포인트:

  • 503이 Cloud Run 레벨(라우팅/큐잉) 인지, 애플리케이션이 503을 반환했는지 구분해야 합니다.
  • 애플리케이션이 503을 반환한다면 앱 로그에 원인이 남아있습니다(예: DB 연결 실패).

2) Cloud Monitoring: 인스턴스/요청/대기 지표

Cloud Run에서 자주 보는 지표(이름은 콘솔에서 확인):

  • 요청 수, 4xx/5xx 비율
  • 인스턴스 수(활성), CPU/메모리
  • 요청 지연 분포(p50/p95/p99)
  • (가능하면) 큐잉/동시성 관련 지표

패턴으로 원인 추정

  • p95 지연이 높고 인스턴스가 급격히 늘어남 → cold start 또는 scale-out 지연
  • 인스턴스가 max에 도달하고 503 증가 → max instances 제한/동시성 병목
  • 메모리가 톱니 형태로 치솟다가 재시작 → 메모리 부족/OOM

3) 애플리케이션 레벨 APM/트레이싱

가능하면 OpenTelemetry로 다음을 분리해보면 튜닝이 빨라집니다.

  • 컨테이너 시작~서버 listen까지(부팅 시간)
  • 요청 처리 시간(핸들러)
  • 외부 호출(DB/Redis/HTTP) 시간

원인별 튜닝 전략 (설정 → 아키텍처 → 코드)

1) cold start 자체를 줄이는 튜닝

(1) 최소 인스턴스(min instances)로 scale from zero 제거

가장 확실한 방법은 항상 1개 이상을 warm 상태로 유지하는 것입니다.

  • 장점: 첫 요청 지연/503 급감
  • 단점: 비용 증가(상시 인스턴스 과금)

gcloud 예시:

gcloud run services update YOUR_SERVICE \
  --min-instances=1 \
  --region=asia-northeast3

트래픽이 업무시간에만 있다면, 스케줄러로 시간대별 min instances를 조정하는 방식도 고려할 수 있습니다.

(2) 컨테이너 이미지 최적화(크기/레이어/시작속도)

cold start 경로에는 이미지 pull과 압축 해제가 포함됩니다.

  • 멀티스테이지 빌드로 런타임에 불필요한 빌드 도구 제거
  • 의존성 설치 레이어를 최대한 캐시 친화적으로 구성
  • 불필요한 OS 패키지 제거

Node.js 예시(Dockerfile 요지):

# build stage
FROM node:20-bookworm AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# runtime stage
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
ENV NODE_ENV=production
CMD ["dist/server.js"]

(3) CPU 부스팅/CPU 항상 할당 이해하기

Cloud Run에는 “요청이 없을 때 CPU를 할당하지 않음”이 기본 동작입니다. 이 경우 백그라운드 초기화/캐시 워밍이 제한됩니다.

  • 초기화가 요청 경로에 얽혀 있다면 cold start가 더 커질 수 있습니다.
  • 반대로 CPU always allocated는 비용이 늘 수 있습니다.

운영 전략:

  • min instances=1로 warm 유지 + CPU 기본 정책으로도 충분한지 먼저 확인
  • 앱이 시작 후 백그라운드로 캐시/커넥션을 준비해야 한다면 CPU 정책을 재검토

2) 503(과부하/큐잉) 줄이기: 동시성·인스턴스·타임아웃

(1) concurrency를 “CPU 코어 수와 작업 특성”에 맞추기

Cloud Run의 concurrency는 한 인스턴스가 동시에 처리할 요청 수입니다.

  • I/O bound(대기 많은 API 서버): concurrency를 높여 효율↑
  • CPU bound(이미지 처리/암호화/ML 추론): concurrency를 낮춰 tail latency↓

실전 팁:

  • 1 vCPU라면 concurrency 10~80 같은 큰 값이 항상 좋은 게 아닙니다.
  • p95/p99가 튀면 concurrency를 낮추고 인스턴스가 더 빨리 늘도록 유도하는 편이 안정적일 수 있습니다.

gcloud 예시:

gcloud run services update YOUR_SERVICE \
  --concurrency=20 \
  --cpu=1 --memory=512Mi \
  --region=asia-northeast3

(2) max instances 제한은 “비용 상한”이 아니라 “가용성 상한”

max instances를 낮게 설정하면 스파이크에서 큐잉이 길어지고 503이 증가합니다.

  • 비용 통제를 위해 max를 걸었다면, 503 증가가 트레이드오프라는 점을 명확히 해야 합니다.
gcloud run services update YOUR_SERVICE \
  --max-instances=100 \
  --region=asia-northeast3

(3) 요청 타임아웃(timeout)과 업스트림 타임아웃을 정렬

Cloud Run 서비스 타임아웃이 너무 짧으면, 앱이 정상 처리 중이어도 플랫폼에서 끊길 수 있습니다.

  • Cloud Run timeout(예: 60s)
  • 애플리케이션 서버 타임아웃(예: Express/Netty)
  • 업스트림(HTTP client/DB) 타임아웃

정렬 원칙:

  • 업스트림 타임아웃 < 애플리케이션 타임아웃 < Cloud Run 타임아웃

gcloud 예시:

gcloud run services update YOUR_SERVICE \
  --timeout=60 \
  --region=asia-northeast3

3) 애플리케이션 부팅/준비 최적화(“첫 요청”에서 무거운 일 제거)

(1) 서버 listen을 최대한 빨리, 무거운 초기화는 지연/비동기

가장 흔한 실수는 앱 부팅 단계에서 DB 연결 확인, 외부 API 호출, 대형 모델 로딩을 모두 끝내고 나서야 포트를 여는 것입니다.

  • Cloud Run은 포트 listen이 늦으면 “준비 안 됨”으로 간주되어 요청이 지연되거나 실패할 수 있습니다.

Node.js(Express) 예시: listen을 먼저 하고, 준비는 백그라운드로

import express from "express";

const app = express();
let ready = false;

app.get("/healthz", (req, res) => {
  if (!ready) return res.status(503).send("warming");
  res.status(200).send("ok");
});

app.get("/", async (req, res) => {
  // 실제 트래픽 엔드포인트
  res.send("hello");
});

const port = process.env.PORT || 8080;
app.listen(port, () => {
  // listen은 즉시
  warmUp().catch((e) => {
    console.error("warmUp failed", e);
  });
});

async function warmUp() {
  // 예: DB pool 생성, JWK prefetch 등
  await new Promise((r) => setTimeout(r, 500));
  ready = true;
}

이때 /healthz를 로드밸런서나 모니터링에서 호출해 readiness를 확인하도록 설계하면, “준비 전 트래픽 유입”을 줄일 수 있습니다(Cloud Run 자체 probe는 제한적이므로, 클라이언트/게이트웨이 레벨에서 활용).

(2) DB 커넥션 풀: 과도한 초기 풀 생성 금지

인스턴스가 스케일 아웃될 때마다 풀을 크게 잡으면 DB가 먼저 터집니다.

  • 풀 크기를 작게 시작하고 점진적으로 늘리거나
  • Cloud SQL이라면 커넥터/프록시 전략을 재검토

Java(HikariCP)에서는 minimumIdle, maximumPoolSize를 트래픽/인스턴스 수에 맞춰 보수적으로 시작하는 게 안전합니다.

(3) 외부 키/JWKS/설정 로딩은 캐시 전략을 명확히

첫 요청에서 인증키를 가져오다 실패하면 5xx가 늘어납니다. 특히 JWKS는 캐시 갱신 전략이 중요합니다.

  • kid 변경 시 캐시 미스 처리
  • 백그라운드 갱신

관련 사고 패턴은 JWT 환경에서도 자주 발생합니다: JWT 검증 실패 - kid 불일치와 JWK 캐시 갱신법


4) 리소스(OOM/CPU)로 인한 503 방지

(1) 메모리 부족은 “느려짐”이 아니라 “재시작/503”로 온다

Cloud Run에서 메모리가 부족하면 컨테이너가 종료되며, 그 순간 요청은 실패(5xx)로 관측될 수 있습니다.

  • 메모리 사용량 그래프가 톱니 형태로 반복되면 의심
  • 로그에 OOM/종료 시그널 흔적 확인

대응:

  • 메모리 상향(예: 512Mi → 1Gi)
  • 누수 점검(특히 Node/Python)
  • 대형 캐시/버퍼를 요청 단위로 만들지 않기

(2) CPU 부족은 tail latency를 키워 503(타임아웃)로 이어진다

  • 압축, 암호화, 템플릿 렌더링, 이미지 처리처럼 CPU 작업이 많은 경우
  • concurrency를 낮추거나 CPU를 올려야 합니다.

운영에서 바로 쓰는 “503·cold start” 튜닝 플레이북

1단계: 증상 분리

  • 503이 **특정 시간대(스파이크)**에만? → 동시성/max instances/업스트림 병목
  • 503이 첫 요청/오랜만의 요청에만? → cold start/scale from zero
  • 503이 특정 엔드포인트에만? → 해당 핸들러의 외부 호출/CPU 작업

2단계: 빠른 완화(Quick fix)

  • min instances=1로 cold start 제거(비용과 교환)
  • timeout 상향(근본 원인이 과부하라면 임시 완화)
  • memory 상향(OOM 의심 시)

3단계: 근본 개선

  • 이미지/부팅 최적화(레이어, distroless, 초기화 지연)
  • concurrency 재조정 + max instances 현실화
  • DB 풀/외부 API 타임아웃/재시도/서킷브레이커 정비

결론

Cloud Run의 503과 cold start는 “플랫폼이 알아서 해주겠지”로 넘기기 쉬운 영역이지만, 실제로는 스케일 정책(concurrency/min/max), 타임아웃 정렬, 부팅 경로 최적화, 업스트림 의존성 제어라는 네 가지 축에서 대부분 해결됩니다.

가장 먼저 할 일은 503을 하나의 현상으로 뭉뚱그리지 말고, 로그/메트릭으로 (1) 인스턴스 준비 문제인지 (2) 앱 문제인지 (3) 의존성 문제인지를 분해하는 것입니다. 그 다음 min instances 같은 운영 레버로 급한 불을 끄고, 이미지/코드/의존성 튜닝으로 비용 대비 성능을 맞추면 Cloud Run에서도 충분히 “예측 가능한” 응답성과 안정성을 만들 수 있습니다.