- Published on
GCP Cloud Run 503·Cold Start 원인과 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
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 /app/dist ./dist
COPY /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에서도 충분히 “예측 가능한” 응답성과 안정성을 만들 수 있습니다.