Published on

GCP Cloud Run 504·콜드 스타트 10분 지연 해결법

Authors

서버리스라서 운영이 ‘없다’고 믿고 시작했다가, Cloud Run에서 504 Gateway Timeout이 터지고 첫 요청이 10분 가까이 대기하는 상황을 만나면 체감 난이도는 급상승합니다. 특히 증상이 “가끔” 발생하면 더 답답합니다. 이 글은 Cloud Run의 요청 경로(프록시/로드밸런서/컨테이너)와 제약(요청 시간 제한, 인스턴스 프로비저닝, VPC egress)을 기준으로 504와 10분 콜드 스타트를 분리해서 진단하고, 재발을 막는 설정/코드/빌드 최적화까지 정리합니다.

아래 내용은 “Cloud Run 서비스 자체가 느린 것인지”, “외부 의존성이 느린 것인지”, “네트워크 경로가 막힌 것인지”, “인스턴스가 안 떠서 기다리는 것인지”를 관측 가능한 신호로 나눠 접근합니다.

1) 먼저 결론: 504와 10분 지연은 보통 원인이 다릅니다

504 Gateway Timeout의 전형적인 원인

  • 요청 처리 시간이 Cloud Run/프록시 제한을 초과(동기 HTTP로 오래 잡고 있음)
  • 앱이 응답을 늦게/못함(스레드 고갈, 데드락, GC 스톨)
  • 업스트림 호출이 타임아웃 없이 대기(DB/외부 API)
  • VPC 커넥터/NAT/DNS 문제로 egress가 지연되어 결국 타임아웃

“콜드 스타트가 10분”의 전형적인 원인

  • 실제로는 콜드 스타트가 아니라 인스턴스 생성/스케일아웃이 막혀 대기
    • 동시성/최대 인스턴스 제한으로 큐잉
    • 리전 자원 부족, 할당 쿼터 제한
    • 컨테이너가 뜨지만 ready가 늦음(초기화 작업 과다)
  • 이미지 pull이 느림(큰 이미지, 레이어 비효율, Artifact Registry 네트워크)
  • VPC 커넥터 경유 시 초기 네트워크 설정/라우팅 이슈

핵심은 **HTTP 504는 “요청 단위 타임아웃”**이고, “10분 지연”은 스케일/프로비저닝/ready 지연일 가능성이 큽니다. 따라서 로그와 메트릭에서 둘을 분리해서 봐야 합니다.

2) 증상 분리 체크리스트(관측부터)

A. Cloud Run 요청 로그에서 latency와 상태코드 확인

Cloud Run의 Request log(Cloud Logging)에서 다음을 봅니다.

  • httpRequest.status가 504인지
  • httpRequest.latency가 얼마나 되는지
  • 같은 시각의 container 로그에 앱이 요청을 받았는지(access log)

포인트: 504인데 앱 access log가 없다면, 요청이 컨테이너까지 못 갔거나(프로비저닝/라우팅) 컨테이너가 준비되지 않았을 수 있습니다.

B. “인스턴스가 없어서 대기”인지 확인: Instance count / container startup

Cloud Monitoring에서 Cloud Run 메트릭을 봅니다.

  • 인스턴스 수(활성/유휴)
  • 요청 수, 동시 요청 수
  • 컨테이너 시작 시간/재시작

C. 의존성 지연인지 확인: 업스트림 타임아웃과 DNS

10분 지연이 “첫 요청에서만”이 아니라 특정 API 호출에서 반복된다면, 아래가 흔합니다.

  • DB 연결이 타임아웃 없이 무한 대기
  • DNS가 느리거나, VPC 커넥터 경유로 라우팅이 꼬임

네트워크 타임아웃류는 Kubernetes에서도 504로 보이지만 원인이 다른 경우가 많습니다. 유사한 접근(요청 경로 분리, 업스트림 타임아웃 확인)은 이 글도 도움이 됩니다: EKS ALB Ingress 504인데 Pod는 정상일 때

3) Cloud Run에서 504를 만드는 대표 패턴과 해결

3.1 동기 요청으로 “긴 작업”을 처리

보고서 생성, 대용량 변환, 크롤링 등 “몇 분” 걸리는 작업을 HTTP 요청 하나로 처리하면 504가 나기 쉽습니다.

해결: 비동기 작업 큐(Cloud Tasks / Pub/Sub)로 분리

  • HTTP는 작업 생성만 하고 빠르게 202 응답
  • 실제 작업은 백그라운드 워커(Cloud Run job 또는 별도 서비스)가 처리

간단한 Node.js 예시(요청은 즉시 반환):

import express from "express";
import { CloudTasksClient } from "@google-cloud/tasks";

const app = express();
app.use(express.json());

const client = new CloudTasksClient();

app.post("/generate", async (req, res) => {
  const payload = { userId: req.body.userId };

  // TODO: queuePath, url 등 환경변수로 관리
  // 실제론 Cloud Tasks에 HTTP task를 넣고 워커가 처리

  res.status(202).json({ ok: true, message: "queued" });
});

app.listen(process.env.PORT || 8080);

3.2 업스트림 호출 타임아웃 미설정(무한 대기)

DB/외부 API 호출이 기본 설정으로 오래 대기하면, 결국 프록시/클라이언트 쪽에서 504로 끊깁니다.

해결: 애플리케이션 레벨 타임아웃 + 재시도 정책

Node.js(undici/fetch)에서 AbortController로 상한을 둡니다.

export async function fetchWithTimeout(url, { timeoutMs = 3000, ...opts } = {}) {
  const controller = new AbortController();
  const t = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const res = await fetch(url, { ...opts, signal: controller.signal });
    return res;
  } finally {
    clearTimeout(t);
  }
}
  • 타임아웃은 “전체 요청 상한”과 “연결/읽기 타임아웃”을 분리할 수 있으면 더 좋습니다.
  • 재시도는 멱등성(GET/PUT 등)과 백오프를 고려합니다.

3.3 동시성 설정으로 인한 큐잉 → 결국 504

Cloud Run은 인스턴스당 동시성(concurrency)을 높이면 효율이 좋아지지만,

  • CPU가 부족한 런타임(싱글 스레드, heavy CPU)에서 동시성이 과하면
  • 요청이 내부 큐에 쌓여 지연되고
  • 상위 레벨에서 타임아웃이 터집니다.

해결

  • CPU 바운드면 concurrency를 낮추고 인스턴스를 늘리기
  • 메모리/CPU를 올려 GC/스케줄링 지연을 줄이기
  • 최대 인스턴스 제한(max instances)을 너무 낮게 잡지 않기

4) “콜드 스타트 10분”을 만드는 진짜 원인들

4.1 최소 인스턴스(min instances) 0 + 큰 이미지 + 무거운 초기화

콜드 스타트는 보통 수 초~수십 초인데, 10분이면 컨테이너가 정상적으로 ready 되지 않거나, 스케일아웃이 막혀 대기하는 경우가 많습니다.

해결 1: min instances로 워밍 유지

  • 트래픽이 일정하거나 SLA가 필요하면 min-instances=1 이상
  • 비용과 지연 사이 트레이드오프

gcloud 예시:

gcloud run services update my-svc \
  --region=asia-northeast3 \
  --min-instances=1 \
  --cpu=2 --memory=1Gi

해결 2: Startup 작업을 “요청 경로”에서 분리

문제 패턴:

  • 서버 시작 시 DB 마이그레이션
  • 외부 API 워밍업을 동기적으로 수행
  • 대용량 모델/사전 로딩

원칙:

  • 컨테이너 부팅 시점에는 필수 최소만 수행
  • 나머지는 lazy load 또는 백그라운드로

Express 예시(ready를 빠르게, 비필수 워밍업은 비동기):

let warmed = false;

async function warmUp() {
  // 캐시 프리로드, 외부 API 핑 등(실패해도 서비스는 뜨게)
  await new Promise(r => setTimeout(r, 500));
  warmed = true;
}

warmUp().catch(err => {
  console.error("warmUp failed", err);
});

app.get("/healthz", (req, res) => {
  // readiness를 엄격히 하고 싶으면 warmed를 반영
  res.status(200).json({ ok: true, warmed });
});

4.2 이미지 pull/빌드 레이어가 비효율 → 프로비저닝이 느림

  • 베이스 이미지가 무겁고 레이어가 자주 바뀜
  • npm install/pip install 레이어가 매번 invalidate
  • 멀티스테이지 빌드 미사용

해결: 멀티스테이지 + 레이어 캐시 최적화

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
COPY --from=build /app/package.json ./package.json

ENV NODE_ENV=production
CMD ["dist/server.js"]
  • distroless로 런타임 크기 축소
  • 의존성 설치 레이어를 소스 코드 복사보다 먼저 두어 캐시 효율 상승

4.3 VPC 커넥터/NAT/DNS 이슈로 “첫 통신”이 오래 걸림

Cloud Run에서 Serverless VPC Access를 붙이고, 모든 egress를 VPC로 보내는 구성은 흔합니다. 여기서

  • Cloud NAT 포트 부족/설정 문제
  • 사설 DNS/Cloud DNS 정책 문제
  • 방화벽/라우팅 문제 가 있으면 “외부 통신이 되는 듯 안 되는 듯” 하다가 지연이 누적될 수 있습니다.

해결 방향

  • 정말 VPC egress가 필요한 트래픽만 VPC로 보낼지 재검토
  • NAT/라우팅/방화벽/Cloud DNS 정책을 점검
  • 앱에서 DNS/연결 타임아웃을 명시

네트워크 정책/라우팅 문제는 증상이 ‘간헐적 타임아웃’으로 나타나기 쉬운데, 원인 분리 접근은 이 글도 참고가 됩니다: EKS CoreDNS CrashLoopBackOff - upstream 타임아웃 해결

4.4 최대 인스턴스 제한으로 스케일아웃이 막힘

max-instances를 낮게 잡아두면, 트래픽 스파이크에서 새로운 인스턴스를 못 띄워 요청이 대기합니다. 대기 시간이 길어지면 결국 클라이언트/프록시가 먼저 타임아웃을 냅니다.

해결

  • 피크 트래픽을 기준으로 max-instances 재설정
  • 동시성(concurrency)을 현실적인 수준으로 조정
  • 큐잉이 필요한 워크로드는 Cloud Tasks/PubSub로 흡수

5) 10분 문제를 재현/진단하는 실전 절차

5.1 “콜드 스타트만” 재현하기

  • min-instances=0으로 두고
  • 한동안 트래픽을 끊어 인스턴스가 0이 되게 만든 뒤
  • 첫 요청을 보내고 시간 측정

curl 예시:

time curl -i https://YOUR_SERVICE_URL/healthz

5.2 로그 상관관계 만들기(요청 ID)

앱 로그에 요청 ID를 남기면 “요청이 컨테이너에 도달했는지”가 바로 보입니다.

Express 미들웨어 예시:

import crypto from "crypto";

app.use((req, res, next) => {
  const rid = req.header("X-Cloud-Trace-Context") || crypto.randomUUID();
  req.rid = rid;
  res.setHeader("X-Request-Id", rid);
  console.log(JSON.stringify({ rid, path: req.path, t: Date.now() }));
  next();
});
  • Cloud Run은 X-Cloud-Trace-Context가 들어오는 경우가 많아 추적에 유리합니다.

5.3 컨테이너 시작 로그 확인

컨테이너가 뜰 때 찍히는 “server listening” 로그가 늦게 찍히면

  • 이미지 pull/컨테이너 부팅/초기화가 느린 것입니다. 반대로 빨리 뜨는데 첫 요청만 늦으면
  • 네트워크/업스트림/동시성 큐잉을 의심합니다.

6) 운영 관점의 권장 설정 조합(현실적인 타협)

지연 민감 API(사용자-facing)

  • min-instances=1~N (트래픽 패턴에 따라)
  • concurrency 낮게(예: 10~40)에서 시작해 부하 테스트로 조정
  • CPU/메모리 여유 있게(특히 Node/Python은 메모리 부족이 지연으로 직결)
  • 모든 외부 호출에 타임아웃/서킷브레이커(최소 타임아웃은 필수)

배치/긴 작업

  • HTTP로 오래 잡지 말고 큐 기반 비동기
  • Cloud Run Jobs 또는 워커 서비스로 분리

7) 마무리: “10분 콜드 스타트”를 없애는 핵심 3가지

  1. 관측으로 분리: 504(요청 타임아웃) vs 프로비저닝/ready 지연(인스턴스/시작 로그)
  2. 초기화/이미지 최적화: 멀티스테이지 빌드, distroless, 부팅 시 필수만 수행
  3. 큐잉과 타임아웃 설계: 긴 작업은 비동기, 업스트림 타임아웃은 코드에서 강제

Cloud Run은 설정 몇 개로 극적으로 좋아지지만, 반대로 “조금만 복잡한 네트워크/의존성/초기화”가 섞이면 증상이 길게 늘어질 수 있습니다. 위 체크리스트대로 요청이 어디서 멈추는지만 정확히 잡으면, 10분짜리 지연도 대부분은 수십 초 이하로 줄일 수 있습니다.