Published on

K8s CrashLoopBackOff - liveness probe 오탐 해결

Authors

서버가 죽지 않았는데 Pod가 계속 재시작되는 상황은 운영에서 가장 피로한 케이스 중 하나입니다. 특히 CrashLoopBackOff가 떴지만 애플리케이션 로그를 보면 치명적인 에러가 없고, 부하가 조금만 올라가도 재시작이 반복된다면 liveness probe 오탐(false positive) 을 가장 먼저 의심해야 합니다.

이 글에서는 liveness/readiness/startup probe의 역할 차이부터, 오탐을 증명하는 방법, 그리고 “죽은 프로세스만 확실히 죽이고(=liveness)”, “트래픽만 안전하게 막는(=readiness)”, “느린 부팅을 허용하는(=startup)” 형태로 프로브를 재설계하는 과정을 정리합니다.

CrashLoopBackOff에서 liveness 오탐을 의심해야 하는 신호

다음 징후가 함께 보이면, 실제 크래시가 아니라 kubelet이 liveness 실패로 강제 재시작시키는 패턴일 가능성이 큽니다.

  • 컨테이너 종료 코드가 137(SIGKILL) 또는 143(SIGTERM)처럼 “외부에서 죽임”에 가까움
  • kubectl describe pod의 Events에 Liveness probe failed가 반복
  • 애플리케이션 로그에는 치명적 예외가 없고, 재시작 직전까지 정상 요청도 처리
  • CPU 스파이크/GC/DB 지연 등으로 특정 구간에서 응답이 느려질 때만 재현

> 참고로, 네트워크/iptables 계열 문제로 특정 컴포넌트가 CrashLoopBackOff가 나는 케이스도 있습니다. 예를 들어 kube-proxy가 iptables 오류로 재시작되는 유형은 접근법이 다르니, 유사 증상이면 EKS kube-proxy CrashLoopBackOff iptables 오류 해결도 같이 확인해보세요.

1) 먼저 “진짜 크래시”인지 “프로브가 죽였는지” 증명하기

Pod 이벤트 확인

kubectl describe pod <pod-name> -n <ns>

Events에 아래가 반복되면 거의 확정입니다.

  • Liveness probe failed: ...
  • Killing container with id ...

종료 원인/코드 확인

kubectl get pod <pod-name> -n <ns> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}{" "}{.status.containerStatuses[0].lastState.terminated.exitCode}{" "}{.status.containerStatuses[0].lastState.terminated.finishedAt}{"\n"}'
  • reason=Error라도 exitCode가 137/143이면 “프로세스 내부 크래시”보다 “외부 종료”일 가능성이 큼
  • OOMKilled면 probe가 아니라 메모리 문제이므로 limit/누수/GC를 봐야 함

kubelet 관점에서 probe 실패 메시지 확인

클러스터 접근 권한이 있다면 노드에서 kubelet 로그를 확인하면 가장 명확합니다.

# systemd 기반 노드
sudo journalctl -u kubelet -f

노드에서 서비스가 반복 재시작하는 경우의 추적 기법은 Kubernetes에 국한되지 않습니다. 원인 추적 흐름이 필요하면 systemd 서비스가 반복 재시작될 때 원인 추적법도 도움이 됩니다.

2) liveness/readiness/startup probe 역할을 헷갈리면 오탐이 필연

프로브 설계의 핵심은 “각 probe가 무엇을 책임지는지”를 분리하는 것입니다.

  • livenessProbe: 프로세스가 “회복 불가능하게” 멈췄는지 확인 → 실패 시 컨테이너 재시작
  • readinessProbe: 지금 트래픽을 받을 준비가 되었는지 확인 → 실패해도 재시작하지 않고 Endpoints에서 제외
  • startupProbe: 부팅이 느린 앱이 초기화 동안 liveness에 맞아 죽지 않게 보호 → 성공할 때까지 liveness/readiness를 유예

운영에서 가장 흔한 실수는 아래 둘입니다.

  1. liveness에 “외부 의존성(DB, Redis, 외부 API)” 체크를 넣는다
  • DB가 잠깐 느려져도 앱은 살아있는데, liveness가 실패해서 컨테이너를 죽임
  1. readiness와 liveness를 동일 엔드포인트로 둔다
  • 트래픽을 잠깐 끊으면 될 상황을 재시작으로 악화시킴

3) 오탐을 만드는 전형적인 패턴과 해결 전략

패턴 A: /health가 너무 무겁다 (DB/캐시/외부 API 포함)

증상

  • DB 커넥션 풀 고갈, 네트워크 지연, 외부 API rate limit 때마다 liveness 실패

해결

  • liveness는 “프로세스 내부 상태”로 최소화
  • readiness에서만 의존성 체크(또는 별도 /_ready)

권장 엔드포인트 설계 예:

  • /_live: 이벤트 루프/스레드가 살아있고, 치명적 데드락/패닉 상태가 아님
  • /_ready: DB 연결 가능, 큐 소비 가능, 필수 의존성 OK

패턴 B: timeout/period/failureThreshold가 너무 공격적

예: timeoutSeconds: 1, failureThreshold: 3, periodSeconds: 5

  • 순간적인 GC Stop-the-world, CPU 스파이크에도 15초 안에 재시작

해결

  • timeout을 현실적으로(예: 2~5초)
  • failureThreshold를 늘려 “짧은 흔들림”을 흡수
  • periodSeconds를 늘려 probe 자체 부하도 줄임

패턴 C: 초기 부팅이 느린데 startupProbe가 없다

증상

  • 기동 시 마이그레이션/캐시 워밍업/모델 로딩 때문에 30~120초 걸리는데
  • liveness가 10초 내 응답을 기대 → 뜨자마자 죽음

해결

  • startupProbe로 “기동 완료 전까지는 죽이지 않기”

4) 실전 YAML: 오탐을 줄이는 프로브 구성

아래 예시는 흔한 웹 API(HTTP) 기준이며, 핵심은 liveness는 가볍게, readiness는 의존성 포함 가능, startup으로 초기 구간 보호입니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: myrepo/api:1.2.3
          ports:
            - containerPort: 8080

          # 1) 느린 부팅 보호: 이 probe가 성공하기 전까지 liveness/readiness는 사실상 유예됨
          startupProbe:
            httpGet:
              path: /_startup
              port: 8080
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 30   # 최대 150초(5*30)까지 부팅 허용

          # 2) 프로세스 생존만 확인: 외부 의존성 체크 금지
          livenessProbe:
            httpGet:
              path: /_live
              port: 8080
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 6    # 최대 60초 흔들림 허용

          # 3) 트래픽 수신 가능 여부: 여기에는 DB/캐시 등 의존성 체크가 들어가도 됨
          readinessProbe:
            httpGet:
              path: /_ready
              port: 8080
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 3

엔드포인트 구현 팁(개념)

  • /_live: 단순히 200 OK를 반환하되, 앱 내부적으로 “치명 상태” 플래그(예: 이벤트 루프 정지 감지, 메인 워커가 죽음, panic 상태)를 감지하면 500 반환
  • /_ready: DB ping, 필수 큐/캐시 접근 등 “트래픽을 받으면 실패할 조건”을 포함
  • /_startup: 마이그레이션/워밍업 완료 후 200 반환

Node.js/Java/Spring/Go 모두 동일한 원칙으로 적용 가능합니다.

5) 디버깅 체크리스트: 오탐인지 빠르게 가르는 방법

(1) probe 실패 시각과 앱 로그 시각을 맞춰보기

  • probe 실패 직전 GC 로그, 스레드 덤프, DB 타임아웃이 있는지
  • 애플리케이션이 “죽기 직전”까지 정상 처리했는지

(2) probe 자체를 Pod 내부에서 재현

kubectl exec -n <ns> -it <pod-name> -- sh

# 컨테이너 내부에서 동일 경로 호출
wget -qO- http://127.0.0.1:8080/_live
wget -qO- http://127.0.0.1:8080/_ready

컨테이너 내부에서 빠른데 kubelet에서만 실패하면, 다음을 의심합니다.

  • 애플리케이션이 0.0.0.0이 아니라 127.0.0.1에만 바인딩(또는 그 반대)
  • 네트워크 정책/사이드카 프록시/iptables 영향
  • TLS 리다이렉트(HTTP→HTTPS)로 probe가 301/302를 받고 실패

(3) HTTP 상태 코드/리다이렉트/인증 요구 확인

  • probe는 기본적으로 단순 HTTP GET이며, 인증 헤더를 넣기 어렵습니다.
  • /health가 인증을 요구하거나 302로 튕기면 실패합니다.

6) 운영에서 자주 쓰는 “안전한” 튜닝 가이드

정답은 서비스 특성마다 다르지만, 오탐을 줄이는 방향성은 비교적 일정합니다.

  • timeoutSeconds: 1초는 너무 짧은 경우가 많음(특히 JVM/파이썬/부하 시). 2~5초부터 시작
  • failureThreshold: liveness는 3보다 5~10이 운영 친화적(짧은 스파이크 흡수)
  • periodSeconds: 5초는 민감, 10초는 무난. 너무 짧으면 probe 트래픽 자체가 부하가 됨
  • startupProbe: “최악의 콜드 스타트 시간”을 기준으로 넉넉히

추가로, readiness 실패 시 트래픽이 빠지는지(Endpoints에서 제외되는지) 확인해야 합니다.

kubectl get endpointslice -n <ns> -l app=api

7) 결론: liveness는 ‘죽었을 때만’, readiness는 ‘못 받을 때만’

CrashLoopBackOff가 보이면 대부분은 애플리케이션 크래시를 먼저 떠올리지만, 실제 현장에서는 liveness probe 오탐이 매우 흔합니다. 해결의 핵심은 다음 3가지입니다.

  1. Events에서 Liveness probe failed를 확인해 “kubelet이 죽였는지”부터 증명
  2. liveness를 가볍게(외부 의존성 제거), readiness로 트래픽 제어
  3. 느린 기동은 startupProbe로 보호하고, timeout/threshold를 현실적으로 튜닝

이렇게 바꾸면 “살아있는 프로세스를 계속 죽이는” 악순환을 끊고, 장애를 재시작이 아닌 트래픽 제어(readiness) 로 완화하는 방향으로 운영 안정성을 끌어올릴 수 있습니다.