Published on

EKS CrashLoopBackOff - livenessProbe 오진단 해결

Authors

서버가 멀쩡해 보이는데 EKS에서 CrashLoopBackOff 가 반복된다면, 실제 원인은 애플리케이션 크래시가 아니라 livenessProbe 의 오진단(false positive)일 수 있습니다. 특히 스프링 부트/Node.js/Go 서비스가 콜드 스타트가 길거나, 시작 직후 DB 마이그레이션·캐시 워밍업·외부 API 초기화 같은 작업을 수행할 때 자주 발생합니다.

이 글에서는 livenessProbe 오진단이 왜 CrashLoopBackOff 로 이어지는지, 어떤 신호를 보면 “앱이 죽는 게 아니라 쿠버네티스가 죽이고 있다”를 확신할 수 있는지, 그리고 EKS 환경에서 안전한 probe 설계로 바꾸는 방법을 단계별로 정리합니다.

관련해서 CrashLoopBackOff 자체의 범용 원인 추적은 아래 글도 함께 참고하면 좋습니다.

livenessProbe 오진단이 CrashLoopBackOff로 이어지는 메커니즘

쿠버네티스에서 livenessProbe 는 “이 컨테이너 프로세스가 살아있는가”를 판단합니다. 실패하면 kubelet이 컨테이너를 재시작합니다.

문제는 많은 팀이 livenessProbe 를 “서비스 준비 완료(ready)” 체크로 잘못 사용한다는 점입니다.

  • livenessProbe 실패: 컨테이너 재시작(강제)
  • readinessProbe 실패: 트래픽에서 제외(재시작하지 않음)

즉, 준비가 늦어졌을 뿐인데 livenessProbe 가 이를 장애로 오판하면 kubelet이 컨테이너를 죽이고 다시 올립니다. 재시작하면서 초기화가 다시 시작되고, 다시 probe가 실패하고… 이 루프가 반복되면 CrashLoopBackOff 가 됩니다.

오진단이 특히 잘 나는 대표 시나리오

  1. 콜드 스타트가 길다(자바, 대형 번들 Node, AOT 미적용 등)
  2. 시작 시 DB 마이그레이션/스키마 체크/인덱스 생성
  3. 외부 의존성(예: Redis, Kafka, RDS)이 준비되기 전까지 블로킹
  4. 헬스 엔드포인트가 “의존성까지 포함한 종합 건강”을 반환
  5. CPU 제한이 빡빡해서 시작 초기에 이벤트 루프/GC가 밀린다
  6. timeoutSeconds 가 너무 짧다(특히 TLS, 프록시 경유)

“앱이 죽는 게 아니라 쿠버네티스가 죽인다”를 확인하는 법

1) 이벤트에서 probe 실패가 반복되는지 확인

kubectl describe pod -n your-ns your-pod

아래 같은 이벤트가 반복되면 거의 확정입니다.

  • Liveness probe failed: HTTP probe failed with statuscode: 500
  • Liveness probe failed: Get ...: context deadline exceeded
  • Back-off restarting failed container

2) 직전 종료 사유가 Error 인데 애플리케이션 로그가 정상일 수 있다

kubectl get pod -n your-ns your-pod -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}'
kubectl get pod -n your-ns your-pod -o jsonpath='{.status.containerStatuses[0].lastState.terminated.exitCode}'

exitCode137 이면 OOM 가능성이 있지만, 오진단 케이스에서는 보통 애플리케이션이 스스로 패닉/크래시 로그를 남기지 않습니다. 대신 kubelet이 SIGKILL/SIGTERM로 끊어버립니다.

3) 이전 컨테이너 로그 확인

kubectl logs -n your-ns your-pod -c your-container --previous

--previous 로그가 “정상적으로 부팅 중” 또는 “워밍업 중”에서 끊긴다면 probe가 원인일 확률이 높습니다.

가장 흔한 설계 실수: liveness에 종합 헬스를 연결

많은 서비스가 /health 에서 DB, Redis, 외부 API까지 모두 체크합니다. 이 엔드포인트를 livenessProbe 에 연결하면 다음 문제가 생깁니다.

  • DB가 잠깐 느려져도 “프로세스는 살아있는데” 재시작됨
  • 외부 API 장애가 나도 “우리 프로세스는 살아있는데” 재시작됨
  • 재시작이 오히려 장애를 증폭(커넥션 폭증, 캐시 미스 폭발)

livenessProbe 는 “프로세스 자체가 회복 불가능하게 멈췄는가”만 보게 만드는 것이 안전합니다.

해결 전략 1: startupProbe를 도입해 콜드 스타트 구간을 분리

쿠버네티스는 startupProbe 를 제공하며, 이 probe가 성공하기 전까지는 livenessProbe 가 동작하지 않습니다. 콜드 스타트가 긴 서비스라면 가장 먼저 고려해야 합니다.

아래 예시는 시작에 최대 5분을 허용합니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  template:
    spec:
      containers:
        - name: api
          image: your-image
          ports:
            - containerPort: 8080
          startupProbe:
            httpGet:
              path: /health/startup
              port: 8080
            periodSeconds: 5
            failureThreshold: 60
            timeoutSeconds: 2
          livenessProbe:
            httpGet:
              path: /health/liveness
              port: 8080
            periodSeconds: 10
            failureThreshold: 3
            timeoutSeconds: 2
          readinessProbe:
            httpGet:
              path: /health/readiness
              port: 8080
            periodSeconds: 5
            failureThreshold: 3
            timeoutSeconds: 2

핵심은 엔드포인트를 분리하는 것입니다.

  • /health/startup: “부팅이 끝났는가”
  • /health/liveness: “프로세스가 살아있는가(최소 조건)”
  • /health/readiness: “트래픽을 받을 준비가 되었는가(의존성 포함 가능)”

해결 전략 2: liveness는 의존성을 빼고 ‘프로세스 생존’만 확인

권장 기준

  • DB 연결, Redis ping, 외부 API 호출 같은 네트워크 의존성은 readiness 로 이동
  • liveness 는 다음 정도만 확인
    • 이벤트 루프가 멈췄는지(가능하면)
    • 치명적 내부 상태(데드락 감지 플래그, 스레드풀 완전 고갈 등)
    • 단순 200 OK (최후의 수단)

Node.js(Express) 예시

import express from 'express';

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

async function bootstrap() {
  // 예: 마이그레이션/워밍업
  await new Promise(r => setTimeout(r, 15000));
  started = true;
}

app.get('/health/liveness', (req, res) => {
  // 프로세스가 요청을 처리할 수 있으면 일단 살아있다고 본다
  res.status(200).send('OK');
});

app.get('/health/startup', (req, res) => {
  res.status(started ? 200 : 503).send(started ? 'STARTED' : 'STARTING');
});

app.get('/health/readiness', async (req, res) => {
  // 여기서 DB/Redis 등 의존성 체크
  // 실패하면 503으로 트래픽에서 제외만 하고 재시작은 하지 않게 만든다
  res.status(200).send('READY');
});

app.listen(8080, () => {
  bootstrap().catch(err => {
    // 부팅 실패는 프로세스를 종료해 재스케줄되게 하는 편이 낫다
    console.error(err);
    process.exit(1);
  });
});

해결 전략 3: initialDelaySeconds만으로 버티려 하지 말기

initialDelaySeconds 를 크게 주면 당장은 해결되는 것처럼 보이지만, 다음 문제가 남습니다.

  • 배포 환경/노드 상태에 따라 스타트 시간이 흔들리면 다시 실패
  • 부팅 이후에도 일시적 지연(예: GC 스톨, CPU throttling)로 오진단 가능

콜드 스타트 문제는 startupProbe 로, 준비 상태는 readinessProbe 로 분리하는 것이 재현성 있는 해결책입니다.

해결 전략 4: timeoutSeconds, failureThreshold, periodSeconds를 “합”으로 설계

probe는 개별 값보다 “실패 판정까지의 총 허용 시간”이 중요합니다.

  • 총 허용 시간(대략) = periodSeconds * failureThreshold
  • 단, 각 시도는 timeoutSeconds 내에 응답해야 성공

예를 들어 periodSeconds: 10, failureThreshold: 3 이면 약 30초 내 3번 실패 시 재시작입니다. 애플리케이션이 일시적으로 20~25초 멈출 수 있는 상황(예: stop-the-world GC, CPU 제한으로 인한 지연)이 있다면 너무 공격적일 수 있습니다.

권장 접근:

  • timeoutSeconds 는 프록시/네트워크 홉을 고려해 12초에서 시작하되, TLS/사이드카가 있으면 35초도 검토
  • failureThreshold 는 3에서 시작하되, 불안정 구간이 있으면 5 이상으로 늘려 재시작 폭주 방지
  • periodSeconds 는 5~10초가 일반적

해결 전략 5: EKS에서 자주 섞이는 원인들(오진단처럼 보이는 케이스)

livenessProbe 오진단과 증상이 비슷하지만 원인이 다른 경우가 있습니다.

1) CPU 제한으로 인한 throttling

부팅 시 CPU를 많이 쓰는데 resources.limits.cpu 가 낮으면 응답이 늦어져 probe timeout이 발생합니다. 이 경우 해결은 probe 조정만이 아니라 CPU limit 상향 또는 requests 적정화가 필요합니다.

resources:
  requests:
    cpu: "250m"
    memory: "512Mi"
  limits:
    cpu: "1000m"
    memory: "1024Mi"

2) 서비스가 0.0.0.0에 바인딩되지 않음

컨테이너 내부에서 localhost 로만 바인딩하면 kubelet의 probe가 실패할 수 있습니다. 애플리케이션이 0.0.0.0 로 listen 하는지 확인하세요.

3) Istio/Envoy, Nginx 사이드카가 있는 경우 경로/포트 혼선

probe가 실제 앱이 아니라 프록시로 들어가며 503을 받을 수 있습니다. 이때는 probe의 포트/경로를 명확히 하거나, 사이드카 리라이트 설정을 검토해야 합니다.

실전 디버깅 체크리스트(바로 적용)

  1. kubectl describe pod 이벤트에 Liveness probe failed 가 반복되는지 확인
  2. kubectl logs --previous 로 “부팅 중 끊김” 패턴인지 확인
  3. liveness 가 DB/Redis 같은 의존성을 체크하고 있지 않은지 확인
  4. 콜드 스타트가 길면 startupProbe 를 먼저 도입
  5. readiness 로 의존성 체크를 이동
  6. timeoutSecondsfailureThreshold 를 합산 허용 시간 관점에서 재설계
  7. CPU throttling 가능성이 있으면 requests/limits 재조정

권장 probe 설계 템플릿

마지막으로, 팀 표준으로 삼기 좋은 템플릿을 정리합니다.

  • startupProbe: 부팅 완료까지 넉넉히 허용(분 단위)
  • livenessProbe: 프로세스 최소 생존만 확인(가벼운 체크)
  • readinessProbe: 의존성 포함 가능(실패해도 재시작 금지)
startupProbe:
  httpGet:
    path: /health/startup
    port: 8080
  periodSeconds: 5
  failureThreshold: 60
  timeoutSeconds: 2

livenessProbe:
  httpGet:
    path: /health/liveness
    port: 8080
  periodSeconds: 10
  failureThreshold: 5
  timeoutSeconds: 2

readinessProbe:
  httpGet:
    path: /health/readiness
    port: 8080
  periodSeconds: 5
  failureThreshold: 3
  timeoutSeconds: 2

마무리

EKS에서 CrashLoopBackOff 를 보면 “앱이 죽는다”부터 의심하기 쉽지만, 실제로는 livenessProbe 가 준비 상태나 외부 의존성 문제를 장애로 오판해 kubelet이 컨테이너를 반복 재시작시키는 경우가 많습니다.

해결의 핵심은 probe의 역할 분리입니다. startupProbe 로 콜드 스타트를 보호하고, livenessProbe 는 프로세스 생존만, readinessProbe 는 트래픽 수용 가능 여부를 판단하도록 재설계하면 재시작 폭주를 끊고 안정적으로 운영할 수 있습니다.