Published on

GCP Cloud Run 503·콜드스타트 타임아웃 해결법

Authors

Cloud Run을 운영하다 보면 평소엔 잘 되다가 트래픽이 몰리거나 오랜 유휴 뒤 첫 요청에서 503이 튀고, 로그에는 타임아웃 또는 컨테이너 시작 지연이 보이는 경우가 많습니다. 특히 min-instances=0로 비용 최적화해 둔 서비스에서 콜드스타트가 길어지면, 로드밸런서나 클라이언트가 먼저 포기하면서 503으로 관측됩니다.

이 글은 “왜 503이 나는지”를 모호하게 뭉개지 않고, Cloud Run의 요청 경로(로드밸런서, 큐잉, 인스턴스 생성, 컨테이너 부팅, 앱 준비, 다운스트림 의존성 연결)에서 병목 지점을 분해해 원인별로 해결책을 적용하는 방식으로 정리합니다.

1) Cloud Run 503을 먼저 분류해야 한다

Cloud Run의 503은 원인이 여러 층에 걸쳐 있습니다. 같은 503이라도 해결책이 정반대일 수 있어, 먼저 아래처럼 분류합니다.

1-1. 전형적인 503 유형

  • 유휴 후 첫 요청에서만 503
    • 콜드스타트 또는 초기화(의존성 연결, 마이그레이션, 모델 로딩)가 느림
  • 트래픽 스파이크에서 503
    • 인스턴스 생성 속도보다 요청 유입이 빠름
    • 동시성 설정이 과도하거나(앱이 못 버팀) 너무 낮아서(인스턴스가 과도하게 필요) 스케일 지연
  • 특정 엔드포인트에서만 503
    • 해당 핸들러가 느리거나, 외부 API/DB 호출이 타임아웃
  • 간헐적 503 + 로그에 메모리 OOM/프로세스 크래시
    • 메모리 부족, 런타임 크래시로 인스턴스가 죽고 재기동 반복

1-2. 확인해야 할 관측 포인트

  • Cloud Logging
    • Request 로그에서 status=503인 항목의 latencytrace 확인
    • 컨테이너 로그에서 부팅 직후 출력, 예외 스택트레이스, OOM 메시지 확인
  • Cloud Monitoring
    • Instance count, Request count, Request latency, Container startup latency(유사 지표) 확인
  • Cloud Run 설정
    • min instances, max instances, concurrency, cpu, memory, startup probe(2세대), request timeout

2) 콜드스타트가 길어지는 핵심 원인 7가지

Cloud Run 콜드스타트는 대략 “인스턴스 생성 + 컨테이너 시작 + 앱 준비 완료”의 합입니다. 아래 항목 중 하나만 길어져도 첫 요청이 타임아웃나며 503이 됩니다.

2-1. 이미지가 크고 레이어 캐시 효율이 낮다

  • 대형 베이스 이미지, 불필요한 빌드 산출물 포함, 패키지 설치가 런타임에 발생
  • 해결
    • 멀티 스테이지 빌드로 런타임 이미지를 최소화
    • 의존성 설치는 빌드 단계에서 끝내고 런타임에는 실행만

예시: 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 node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package*.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
EXPOSE 8080
CMD ["node", "dist/server.js"]

2-2. 앱 시작 시 무거운 초기화를 한다

  • 첫 요청 전에 모델 로딩, 대규모 설정 fetch, 마이그레이션, 외부 API warm-up 등을 수행
  • 해결
    • 초기화는 가능한 한 지연 로딩(lazy)하거나, 캐시 가능한 것은 빌드 시 포함
    • “요청 처리 가능 상태”를 빠르게 만들고, 이후 백그라운드로 보강

2-3. CPU 설정 때문에 부팅이 느려진다

Cloud Run은 CPU 할당 정책에 따라 “요청 처리 중에만 CPU 제공”을 선택할 수 있습니다. 유휴 상태에서 CPU가 없으면, 요청이 들어온 뒤에야 초기화가 본격 진행되어 콜드스타트가 체감상 더 길어질 수 있습니다.

  • 해결
    • 콜드스타트 민감 서비스는 “항상 CPU 할당”을 검토
    • 또는 min instances로 따뜻한 인스턴스를 유지

2-4. 동시성(concurrency)이 앱 특성과 맞지 않는다

동시성이 너무 높으면 한 인스턴스에 요청이 몰려서 CPU/메모리/스레드가 포화되고, 응답 지연이 누적되어 타임아웃 503으로 관측됩니다. 반대로 동시성이 너무 낮으면 인스턴스가 많이 필요해 스케일아웃이 늦어질 수 있습니다.

  • 해결
    • CPU 바운드면 동시성을 낮게, I/O 바운드면 적절히 높게
    • 부하 테스트로 p95 지연과 오류율 기준으로 최적점 찾기

2-5. 의존성(DB, Redis, 외부 API) 연결이 병목이다

콜드스타트 시점에 DB 커넥션 풀을 크게 잡아두면, 인스턴스가 늘어날 때 DB 연결 폭주가 발생하고 결국 실패가 503으로 전이됩니다.

  • 해결
    • 풀 크기를 인스턴스당 작게 시작하고 점진 확장
    • Cloud SQL이라면 커넥터/프록시 사용, 커넥션 재사용 최적화

DB 커넥션 폭주 패턴과 차단 전략은 RDS 사례지만 개념은 동일합니다. 커넥션 풀과 프록시로 “서버리스 스케일링이 DB를 죽이는 문제”를 막는 관점은 아래 글도 참고할 만합니다.

2-6. 메모리 부족으로 OOM이 나고 재기동한다

  • 증상
    • 간헐적 503
    • 로그에 OOM kill, 프로세스 종료
  • 해결
    • 메모리 상향 또는 런타임 메모리 제한 튜닝
    • 캐시/버퍼/이미지 처리 등 피크 메모리 사용 구간 점검

2-7. 플랫폼 타임아웃(요청 타임아웃, 프록시 타임아웃)

Cloud Run의 요청 타임아웃, 앞단 HTTPS LB/Cloud CDN, 클라이언트 타임아웃이 서로 다르면 “서버는 처리 중인데 앞단이 먼저 끊어 503”이 됩니다.

  • 해결
    • 엔드투엔드 타임아웃 예산을 정하고 계층별로 정렬
    • 스트리밍/비동기 작업(큐, Pub/Sub, Cloud Tasks)으로 장기 작업 분리

타임아웃/헬스체크/프록시 계층에서 오류가 증폭되는 구조는 AWS ALB의 502/504 문제와도 유사합니다. 개념 정리용으로 참고할 수 있습니다.

3) 실전 해결 체크리스트: 비용과 안정성의 균형

여기서는 “503을 줄이는 방향”으로 우선순위를 제시합니다. 모든 설정을 최대로 올리면 비용이 급증하므로, 효과 대비 비용이 좋은 순으로 적용하는 것이 핵심입니다.

3-1. min instances로 콜드스타트를 구조적으로 제거

  • 권장
    • 사용자-facing API, 결제/로그인 등 실패 비용이 큰 경로는 min instances=1 이상
    • 트래픽 패턴이 뚜렷하면 스케줄 기반으로 시간대별 min instances 조정

단점은 유휴 시간에도 비용이 발생한다는 점이지만, 첫 요청 503로 인한 이탈 비용이 더 크면 이게 가장 확실합니다.

3-2. 동시성과 CPU/메모리를 함께 튜닝한다

  • 동시성을 올리면 인스턴스 수는 줄지만, 인스턴스당 자원 요구량이 늘어납니다.
  • 동시성을 낮추면 인스턴스 수가 늘어 스케일아웃 지연이 문제될 수 있습니다.

실무 팁

  • CPU 바운드(이미지 변환, 암호화, ML 추론)
    • 동시성을 낮추고 CPU를 올리는 편이 안정적
  • I/O 바운드(외부 API, DB 쿼리 중심)
    • 동시성을 중간 이상으로 두고 타임아웃/재시도/서킷브레이커로 방어

3-3. 초기화 로직을 “요청 처리 가능” 기준으로 재설계

가장 흔한 실수는 “서버 시작 시 모든 준비를 끝내야 한다”는 접근입니다. Cloud Run에서는 인스턴스가 자주 생성될 수 있으므로, 초기화는 최소화해야 합니다.

예시: Express에서 지연 초기화 패턴

import express from 'express'

const app = express()
let clientPromise

function getClient() {
  if (!clientPromise) {
    clientPromise = (async () => {
      // 예: 외부 의존성 연결, 비밀값 로딩 등
      // 반드시 타임아웃을 둔다
      const client = await createClientWithTimeout(2000)
      return client
    })()
  }
  return clientPromise
}

app.get('/healthz', (req, res) => {
  // 콜드스타트 중에도 빠르게 200을 주고 싶다면
  // "ready"와 "alive"를 분리하는 전략을 고려
  res.status(200).send('ok')
})

app.get('/api', async (req, res) => {
  const client = await getClient()
  const data = await client.query('select 1')
  res.json({ data })
})

app.listen(8080)

포인트

  • 초기화는 “요청이 실제로 필요로 하는 시점”으로 미루되
  • 무한 대기 방지를 위해 타임아웃을 넣고
  • 실패 시 빠르게 오류를 반환하거나 대체 경로를 제공

3-4. 재시도는 반드시 백오프와 함께, 그리고 멱등성 고려

콜드스타트나 순간적인 스케일 지연은 짧은 시간 후엔 정상화될 수 있어 재시도가 효과적입니다. 다만 무작정 재시도하면 트래픽을 더 키워 503을 증폭시킵니다.

  • 권장
    • 지수 백오프 + 지터
    • GET 등 멱등 요청 중심
    • POST는 멱등 키를 도입하거나 큐 기반으로 전환

재시도/백오프 설계는 외부 API 429 대응 글이지만, 503에도 동일한 원칙이 적용됩니다.

예시: Node fetch 재시도(백오프 + 지터)

export async function fetchWithRetry(url, options = {}, maxRetries = 3) {
  let attempt = 0
  while (true) {
    try {
      const res = await fetch(url, options)
      if (res.status >= 500 && res.status <= 599) {
        throw new Error(`server error: ${res.status}`)
      }
      return res
    } catch (e) {
      if (attempt >= maxRetries) throw e
      const base = 200 * Math.pow(2, attempt)
      const jitter = Math.floor(Math.random() * 100)
      const delayMs = base + jitter
      await new Promise(r => setTimeout(r, delayMs))
      attempt += 1
    }
  }
}

3-5. 장기 작업은 요청-응답에서 분리한다

Cloud Run은 HTTP 요청 처리에 최적화되어 있습니다. 30초, 60초, 5분짜리 작업을 동기 처리하면 타임아웃과 503이 필연적으로 늘어납니다.

  • 해결
    • Cloud Tasks로 비동기 작업 큐잉
    • Pub/Sub로 이벤트 기반 처리
    • 작업 상태는 DB나 캐시에 저장하고 폴링/웹훅으로 전달

4) 503이 계속될 때의 디버깅 루틴

설정을 이것저것 바꾸기 전에, 아래 순서로 원인을 좁히면 시간을 크게 아낍니다.

4-1. 503 발생 시점의 로그를 하나의 트레이스로 묶기

  • Cloud Logging에서 trace 또는 요청 ID 기준으로
    • 요청 수신 시각
    • 컨테이너 로그의 부팅/초기화 시각
    • 외부 의존성 호출 시간
    • 최종 응답까지 걸린 시간

이렇게 보면 “인스턴스가 늦게 떴는지”, “떴는데 앱이 늦는지”, “앱은 빠른데 DB가 느린지”가 분리됩니다.

4-2. 스케일아웃 지연인지, 인스턴스 내부 포화인지 확인

  • 스케일아웃 지연 신호
    • 인스턴스 수가 늦게 증가
    • 큐잉이 늘고 첫 요청이 오래 기다림
  • 내부 포화 신호
    • 인스턴스 수는 충분한데 p95 지연이 상승
    • CPU 100% 또는 메모리 압박

4-3. 의존성 장애를 503으로 착각하지 않기

예를 들어 DB 커넥션 풀이 고갈되면 앱이 응답을 못 하고 결국 503처럼 보일 수 있습니다. 이 경우 Cloud Run 튜닝이 아니라 DB/풀/쿼리 최적화가 우선입니다.

5) 권장 설정 조합 예시

정답은 서비스마다 다르지만, 운영에서 자주 쓰는 출발점은 있습니다.

5-1. 사용자-facing REST API(응답 1초 내 목표)

  • min instances: 1 또는 2
  • concurrency: 20~80에서 부하 테스트로 결정
  • CPU: 1 이상(지연 민감하면 상향)
  • 메모리: OOM 없을 만큼 여유
  • 타임아웃: 엔드포인트 SLA에 맞게 짧게(예: 10~30초)
  • 재시도: 클라이언트에서 제한적으로, 백오프 필수

5-2. 배치성 웹훅 처리(지연 허용, 처리량 우선)

  • min instances: 0
  • concurrency: 높게(앱이 I/O 바운드라면)
  • 타임아웃: 작업 길이에 맞추되, 가능하면 큐로 분리

6) 마무리: 503은 “증상”이고, 콜드스타트는 “설계 변수”다

Cloud Run의 503은 단일 원인이라기보다, 스케일링 방식과 초기화 설계, 동시성/리소스, 타임아웃 예산이 맞물릴 때 나타나는 결과입니다. 가장 빠른 해결은 min instances로 콜드스타트를 제거하는 것이고, 장기적으로는 이미지/초기화/의존성 연결/재시도 전략을 다듬어 “인스턴스가 언제 생겨도 빠르게 준비되는 앱”으로 만드는 것입니다.

운영 단계에서는 다음 3가지만 기억해도 재발률이 크게 줄어듭니다.

  • 콜드스타트 민감 경로는 min instances로 보호
  • 동시성은 성능이 아니라 “포화 모델”에 맞춰 결정
  • 타임아웃과 재시도는 계층 전체에서 예산을 맞춘다