13년차의 서버실

서버, 인프라, 홈랩 기술 블로그

[태그:] CrashLoopBackOff

  • [k8s] Kubernetes Liveness Probe: Readiness·Startup 차이와 설정 가이드

    [k8s] Kubernetes Liveness Probe: Readiness·Startup 차이와 설정 가이드

    Kubernetes Liveness Probe: Readiness, Startup 차이와 운영 가이드

    Kubernetes Liveness Probe를 처음 만졌을 때 가장 크게 데인 지점은, 프로브를 “상태 확인”이 아니라 “불안하면 일단 재시작” 버튼처럼 썼다는 점이었습니다. 앱이 조금 늦게 뜨기만 해도 죽은 것으로 판정했고, 결과는 뻔했죠. Pod(파드, 쿠버네티스의 최소 배포 단위)는 부팅 도중 반복 재시작했고, 실제 버그보다 운영 설정이 더 큰 장애를 만들더라고요.

    운영에서 프로브는 단순 헬스 체크가 아닙니다. 저는 이걸 장애 감지 장치보다 복구 정책 스위치에 가깝게 봅니다. Kubernetes Liveness Probe를 실패시키면 kubelet이 해당 컨테이너를 다시 시작하고, Readiness Probe가 실패하면 Service 트래픽 대상에서 빠지며, Startup Probe는 느린 기동 구간을 보호합니다. 같은 probe라도 실패 뒤에 이어지는 동작이 완전히 다르기 때문에, 이 차이를 설계하지 않고 문법만 맞추면 장애가 길어집니다.

    이번 글에서는 홈랩과 실무에서 반복해서 검증했던 기준으로, 언제 Liveness를 써야 하는지, 언제 오히려 빼는 게 나은지, Readiness를 어디까지 엄격하게 볼지, Startup Probe를 어떤 식으로 계산해 붙일지를 실행 가능한 예시와 함께 정리해보겠습니다. 배포 전략이나 Service 동작 원리가 헷갈린다면 블로그의 관련 글도 함께 읽어보시면 흐름이 더 잘 잡힙니다.

    Kubernetes Liveness Probe를 포함한 프로브 전체 흐름 아키텍처 이미지

    liveness, readiness, startup probe가 각각 어떤 순간에 동작하고 서비스 트래픽에 어떤 영향을 주는지 보여주는 개요 다이어그램입니다.

    Kubernetes Liveness Probe가 왜 중요한가

    Liveness Probe(라이브니스 프로브, 생존 확인)는 “이 프로세스를 계속 살려둘 가치가 있는가”를 묻습니다. 핵심은 회복 불가능성입니다. 요청이 일시적으로 느린 상태, DB가 잠깐 흔들린 상태, 외부 API가 timeout 나는 상태는 대개 liveness의 대상이 아니거든요. 반대로 이벤트 루프가 멈췄다거나, 스레드 데드락으로 더 이상 요청을 처리할 수 없고 자체 회복도 기대하기 어렵다면 liveness를 실패시켜 재기동시키는 편이 낫습니다.

    Readiness Probe(레디니스 프로브, 요청 처리 준비 상태 확인)는 질문이 다릅니다. 지금 이 Pod로 트래픽을 보내도 되나?를 판단하죠. 살아는 있지만 아직 준비가 안 됐거나, 의존 서비스가 불안정해 새 요청을 받으면 실패율만 올릴 상황이라면 readiness를 false로 두고 엔드포인트에서 빠지는 쪽이 안전합니다.

    Startup Probe(스타트업 프로브, 초기 기동 확인)는 더 실무적입니다. 느리게 뜨는 앱에게 “운영 중 기준”을 너무 일찍 들이대지 않게 해 줍니다. JVM 앱, 대용량 캐시를 적재하는 API, 마이그레이션 이후에만 정상 동작하는 서비스는 startup probe가 없으면 정상 기동 중에도 liveness에 맞아 죽기 쉽습니다.

    현장에서 가장 자주 보는 오해는 이것입니다. “헬스 체크는 엄격할수록 좋다.” 실제 운영은 반대인 경우가 많습니다. 프로브는 엄격함보다 의미 분리가 더 중요합니다. Liveness는 재시작을 정당화할 수 있을 때만, Readiness는 트래픽 차단이 이득일 때, Startup은 초기화 변동 폭을 흡수할 때 써야 합니다.

    Kubernetes Liveness Probe와 Probe 3종 역할 구분표

    Probe 종류 실무 질문 실패 시 쿠버네티스 동작 넣어야 하는 경우 빼거나 약하게 둬야 하는 경우
    Liveness Probe 프로세스가 회복 불가능하게 멈췄는가 컨테이너 재시작 deadlock, event loop 정지, 내부 워커 hang 외부 의존성 장애에 자주 흔들리는 앱, 자체 hang 가능성이 낮은 단순 API
    Readiness Probe 지금 요청을 받아도 되는가 Service 엔드포인트에서 제외 DB 연결 전, 캐시 워밍업 중, 소비자 초기화 전, 배포 중 drain 필요 상태 판단에 무거운 쿼리나 외부 API 호출이 필요한 경우
    Startup Probe 초기 부팅이 아직 끝나지 않았는가 기동 구간 보호, 실패 지속 시 재시작 JVM, 모델 로딩, schema migration, 큰 플러그인 초기화 기동 시간이 매우 짧고 변동 폭이 작은 앱

    운영 판단을 더 거칠게 요약하면 아래처럼 보시면 됩니다.

    • Liveness: 계속 두면 더 나빠질 프로세스만 다시 띄웁니다.
    • Readiness: 살아 있어도 지금은 손님 받지 말자는 신호입니다.
    • Startup: 부팅 중인 앱을 운영 기준으로 성급하게 심판하지 않기 위한 완충 장치입니다.

    이 차이를 놓치면 증상이 묘해집니다. Pod는 Running인데 요청은 503이 쌓이거나, DB가 잠깐 느려졌을 뿐인데 liveness가 재시작 루프를 만들어 장애 반경을 넓혀 버리죠. 프로브 설계의 핵심은 “정상/비정상 판정”보다 실패 도메인을 어디서 끊을지에 있습니다.

    Kubernetes 헬스 체크 방식도 성격이 다릅니다

    Kubernetes에서는 보통 HTTP GET, TCP Socket, Exec 세 방식으로 검사합니다. 셋 다 쓸 수 있다고 해서 셋 다 좋은 건 아닙니다. 중요한 건 무엇을 검증하고 무엇을 버리는지를 알고 선택하는 겁니다.

    • HTTP GET: 가장 해석이 쉽습니다. <code>/live, /ready처럼 의미를 분리하기 좋고, 애플리케이션 레벨 판단이 가능합니다.
    • TCP Socket: 포트가 연결 가능한지만 확인합니다. 포트는 열려 있는데 실제 요청 처리는 불안정한 상황은 못 잡을 수 있습니다.
    • Exec: 컨테이너 안에서 명령을 실행하므로 유연하지만, 프로세스 생성 비용과 스크립트 실패 모드까지 같이 관리해야 합니다.

    웹 애플리케이션이라면 대체로 Readiness는 HTTP, Liveness는 아주 가볍고 보수적으로, Startup은 실제 기동 시간을 기준으로 별도 구성하는 쪽을 권합니다. 반대로 배치 워커처럼 HTTP 서버가 없고, 작업 큐 polling 상태나 pid 파일 확인이 더 직접적인 경우에는 exec probe가 더 맞을 수 있습니다.

    중요한 판단 하나만 더 짚으면, Liveness에서 외부 의존성을 확인하는 순간 프로브는 자가치유 장치가 아니라 장애 증폭 장치가 되기 쉽습니다. DB, Redis, Kafka, 외부 API는 readiness 쪽에서 다루고, liveness는 프로세스 내부 생존성만 보는 편이 운영 사고가 적었습니다.

    실전 구현: Deployment에 Liveness, Readiness, Startup Probe 넣기

    재현 가능한 예시로 가보겠습니다. 아래 YAML은 HTTP 엔드포인트를 분리한 애플리케이션 기준입니다. 데모용 구조지만 실무에서도 그대로 많이 씁니다. 포인트는 세 가지입니다. startup은 부팅 보호, readiness는 트래픽 허용 판단, liveness는 내부 고장 감지입니다.

    1. 애플리케이션에서 /live와 /ready를 분리합니다.
    2. /ready는 요청 처리에 필요한 최소 의존성이 준비됐을 때만 200을 반환하게 합니다.
    3. /live는 외부 의존성을 빼고, 프로세스가 hang 상태인지 중심으로 판단합니다.
    4. 기동 시간이 흔들리는 앱이면 startup probe로 운영 구간 판정을 늦춥니다.
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: probe-demo
    spec:
      replicas: 2
      selector:
        matchLabels:
          app: probe-demo
      template:
        metadata:
          labels:
            app: probe-demo
        spec:
          containers:
            - name: app
              image: your-registry/your-app:1.0.0
              ports:
                - containerPort: 8080
              startupProbe:
                httpGet:
                  path: /live
                  port: 8080
                periodSeconds: 5
                timeoutSeconds: 2
                failureThreshold: 24
              readinessProbe:
                httpGet:
                  path: /ready
                  port: 8080
                periodSeconds: 5
                timeoutSeconds: 2
                failureThreshold: 3
                successThreshold: 1
              livenessProbe:
                httpGet:
                  path: /live
                  port: 8080
                periodSeconds: 10
                timeoutSeconds: 2
                failureThreshold: 3

    여기서 많이 놓치는 계산이 있습니다. startupProbe의 허용 시간은 보통 failureThreshold * periodSeconds로 먼저 읽으면 편합니다. 위 예시는 5초마다 검사하고 24번까지 실패를 허용하니, 대략 120초까지는 기동 보호 구간이라고 해석할 수 있습니다. 앱이 어떤 날은 15초, 어떤 날은 80초 걸린다면 initialDelaySeconds 하나로 버티는 것보다 startup probe가 훨씬 안전합니다.

    startupProbe, readinessProbe, livenessProbe가 YAML 안에서 어떤 식으로 연결되고 순서대로 동작하는지 보여주는 구성 이미지입니다.

    적용 후에는 아래 순서로 보는 게 제일 빠릅니다. 배포, 상태 변화, 이벤트, 직전 로그를 묶어서 확인해야 원인이 잘 보입니다.

    kubectl apply -f probe-demo.yaml
    kubectl rollout status deployment/probe-demo
    kubectl get pods -l app=probe-demo -w
    kubectl describe pods -l app=probe-demo

    kubectl get pods -w는 READY와 RESTARTS 변화를 실시간으로 보기 좋고, kubectl describe pods -l app=probe-demo는 probe 실패 메시지와 kubelet 이벤트를 확인할 때 특히 유용합니다. 앱 로그만 보고 있으면 왜 재시작됐는지 놓치는 경우가 많습니다.

    Kubernetes Liveness Probe 파라미터는 이렇게 읽으시면 됩니다

    • initialDelaySeconds: 검사 시작 전 대기 시간입니다. 다만 느린 앱 보호를 이 값 하나로 해결하려 들면 운영 단계의 장애 감지가 늦어집니다.
    • periodSeconds: 검사 주기입니다. 너무 짧으면 노이즈와 부하가 늘고, 너무 길면 장애 감지가 늦습니다.
    • timeoutSeconds: 응답 대기 시간입니다. readiness가 자주 timeout 난다면 엔드포인트 자체가 무겁다는 신호일 수 있습니다.
    • failureThreshold: 연속 실패 허용 횟수입니다. 일시적 지연과 실제 장애를 구분하는 완충 장치로 보면 이해가 쉽습니다.
    • successThreshold: 연속 성공 횟수입니다. readiness의 flap을 줄일 때 의미가 있고, liveness와 startup에서는 1이어야 합니다.

    제 경험상 기동이 느린 앱에 initialDelaySeconds만 크게 주는 방식은 나중에 운영 감지를 둔하게 만듭니다. 시작 구간과 정상 운영 구간은 실패 의미가 다르기 때문에, startup probe로 분리하는 편이 더 깔끔하더라고요.

    헬스 엔드포인트는 이렇게 나누는 편이 덜 사고 납니다

    문법보다 더 중요한 건 애플리케이션이 무엇을 200으로 돌려주느냐입니다. 같은 Spring Boot나 FastAPI라도 엔드포인트 설계가 엉성하면 probe는 멀쩡해 보이는데 운영은 계속 흔들립니다. 저는 아래처럼 역할을 나누는 편을 선호합니다.

    엔드포인트 포함할 것 빼야 할 것 이유
    /live 메인 루프 정상 동작, 내부 큐 정지 여부, 프로세스 핵심 스레드 상태 DB ping, 외부 API 호출, 느린 파일 시스템 검사 재시작이 의미 있는 내부 고장만 잡아야 하기 때문
    /ready DB 연결 풀 준비, 필수 캐시 준비, 메시지 소비자 초기화 완료 비핵심 외부 연동, 과도한 상세 진단, 고비용 쿼리 트래픽 수용 가능 여부만 빠르게 판단해야 하기 때문

    예를 들어 주문 API가 있고, 요청 한 건을 처리하려면 DB 연결과 내부 캐시 로드가 필수라고 해보겠습니다. 이 경우 /ready는 DB pool이 usable인지, 필수 캐시가 준비됐는지만 보면 됩니다. 반면 분석용 외부 API가 잠깐 느린 건 새 주문 수락과 직접 관계가 없다면 readiness에 넣지 않는 편이 낫습니다. 모든 연동을 readiness에 다 넣으면 안전해 보일 수는 있어도, 실제로는 트래픽을 과하게 차단하게 됩니다.

    실무에서 많이 하는 실수 4가지

    여기서부터가 설정 문법보다 훨씬 중요합니다. 대부분의 장애는 YAML 오타보다 잘못된 운영 가정에서 나옵니다.

    1. Liveness Probe에 외부 의존성을 넣는 경우

    예를 들어 /healthz가 DB 질의를 수행하고, DB 응답이 늦으면 liveness가 실패하도록 만들면 어떻게 될까요. 앱 프로세스는 멀쩡한데 외부 의존성 장애가 컨테이너 재시작으로 번집니다. 재시작 직후 연결 폭증까지 붙으면 DB는 더 버거워지고, 결국 앱과 DB가 같이 흔들립니다. 이 패턴은 진짜 오래 사람을 괴롭히더라고요. 재시작이 복구가 아니라 증폭이 되는 겁니다.

    분리 기준은 단순합니다.

    • Liveness: 프로세스 내부 상태만 확인
    • Readiness: 요청 처리에 반드시 필요한 의존성만 확인

    2. 느린 앱에 Startup Probe 없이 Liveness만 거는 경우

    JVM, 대형 모델 로딩, 플러그인 초기화, schema migration이 있는 앱은 부팅 시간이 환경마다 다릅니다. 이때 liveness가 먼저 붙으면 정상 기동 중인 프로세스를 죽입니다. 증상은 CrashLoopBackOff로 보이는데, 실제 원인은 코드 버그보다 프로브 타이밍인 경우가 꽤 많습니다.

    권하는 방식은 먼저 실제 기동 로그를 보고, 포트 오픈 시점이 아니라 요청 처리 준비 완료 시점을 기준으로 startup budget을 잡는 겁니다. 앱이 포트를 열었다고 준비가 끝난 건 아니니까요.

    3. Readiness 엔드포인트를 너무 무겁게 만든 경우

    Readiness는 자주 호출됩니다. 여기에 비싼 SQL, 다중 외부 API 확인, 긴 디스크 검사까지 넣으면 체크 자체가 시스템 부하가 됩니다. 더 나쁜 경우는 readiness endpoint가 느려서 timeout 나고, 그 timeout 때문에 엔드포인트에서 빠졌다가 붙었다가를 반복하는 겁니다. 서비스는 살아 있는데 트래픽 경로만 흔들리죠.

    실무에서는 readiness를 빠르고, 결정적이며, 요청 처리 가능 여부만 말하는 엔드포인트로 두는 게 안정적입니다.

    4. probe 실패 로그를 앱 로그만으로 해석하는 경우

    Pod가 재시작하면 애플리케이션 로그부터 보는 경우가 많습니다. 그런데 probe 실패의 1차 단서는 대개 kubelet 이벤트에 있습니다. “왜 죽였는지”는 kubectl describe pod에 더 잘 남습니다. 재시작된 후 현재 컨테이너 로그만 보면 이미 중요한 구간이 사라졌을 수도 있고요.

    kubectl describe pod <pod-name>
    kubectl logs <pod-name> --previous
    kubectl get events --sort-by=.metadata.creationTimestamp

    --previous는 직전 컨테이너 로그를 보기에 특히 중요합니다. probe 때문에 재시작된 직후에는 현재 로그가 너무 깨끗해서 오히려 단서가 안 남는 경우가 많습니다.

    트러블슈팅: CrashLoopBackOff가 probe 때문인지 확인하는 방법

    재현 가능한 시나리오 하나를 잡아보겠습니다. 앱이 시작하면서 DB migration을 수행하고, HTTP 포트는 먼저 뜨지만 실제 요청 처리는 migration 완료 후에만 가능하다고 해보죠. 이때 liveness가 너무 빨리 붙으면, Kubernetes는 애플리케이션이 아직 기동 중인데도 불안정하다고 판단해 재시작시킬 수 있습니다. 문제는 재시작하면 migration이 다시 시작된다는 점입니다. 이 루프가 반복되면 코드가 아니라 프로브가 장애 원인입니다.

    1. Pod 상태 확인: kubectl get pods -w에서 RESTARTS가 계속 증가하는지 봅니다.
    2. 이벤트 확인: kubectl describe pod에서 Liveness probe failed, Readiness probe failed, Startup probe failed 메시지를 찾습니다.
    3. 직전 로그 확인: kubectl logs --previous로 종료 직전 애플리케이션 상태를 봅니다.
    4. 기동 순서 분리: 포트 오픈 시점, migration 완료 시점, ready 응답 가능 시점을 로그로 나눠 읽습니다.
    5. Startup Probe 추가 또는 조정: 준비 완료 전까지 liveness 간섭을 차단합니다.

    이때 숫자를 감으로 찍지 말고, 실제 이벤트와 로그 타임라인을 맞춰 보셔야 합니다. 앱의 “실제 준비 완료”와 Kubernetes가 “준비됐다고 믿는 시점”이 어긋날 때 문제가 생깁니다. 먼저 그 간격을 맞춘 뒤 threshold를 조정해야 덜 헤맵니다. 순서가 반대면 결국 숫자 놀음이 되더라고요.

    kubectl get pod <pod-name> -o wide
    kubectl describe pod <pod-name> | sed -n '/Events:/,$p'
    kubectl logs <pod-name> --previous --timestamps
    kubectl logs <pod-name> --timestamps

    여기서 볼 포인트는 단순합니다. 이벤트 시각과 앱 로그 시각을 맞춰서, probe 실패가 먼저였는지, 앱 내부 오류가 먼저였는지를 분리하는 겁니다. 순서를 잘못 읽으면 원인과 결과를 바꿔 잡게 됩니다.

    검증: 설정 후 무엇을 보고 정상이라고 판단할까

    설정을 넣는 것과 운영이 안정화되는 것은 별개입니다. 배포 직후에는 아래 네 가지를 같이 보는 편이 좋습니다. 하나만 보면 착시가 생깁니다.

    • 배포 직후 READY 변화: Running보다 READY가 중요합니다. Running인데 READY가 0/1이면 서비스는 사실상 트래픽을 못 받습니다.
    • 엔드포인트 반영: readiness 실패 시 실제로 Service 엔드포인트에서 빠지는지 확인해야 합니다.
    • RESTARTS 안정화: 정상 상태에 들어간 뒤 RESTARTS가 더 늘지 않아야 합니다.
    • 이벤트 노이즈 여부: 간헐적 probe failure가 계속 쌓인다면, 배포 피크나 노드 부하 때 실제 장애로 이어질 수 있습니다.
    kubectl rollout status deployment/probe-demo
    kubectl get pods -l app=probe-demo -o wide
    kubectl get endpoints
    kubectl describe deployment probe-demo

    검증 기준을 더 분명하게 두자면 이렇습니다. READY는 기대 복제 수만큼 올라와야 하고, RESTARTS는 안정 구간에서 멈춰야 하며, endpoints에는 준비된 Pod만 남아야 합니다. Pod가 떠 있다는 사실만으로 정상 판정을 내리면 실제 장애를 놓치기 쉽습니다.

    Kubernetes Liveness Probe 검증과 Pod 상태 확인 이미지

    Pod 상태, 이벤트 로그, readiness 반영 여부를 함께 보는 검증 흐름 이미지입니다.

    운영 팁: 앱 유형별로 probe를 다르게 잡아야 합니다

    앱 유형 추천 구성 이유 보수적으로 볼 포인트
    가벼운 웹 API Readiness + 보수적 Liveness 기동이 빠르고 요청 처리 여부 판단이 단순함 Liveness가 굳이 필요 없는 서비스도 적지 않음
    기동이 느린 JVM 앱 Startup + Readiness + Liveness 초기화 시간 보호와 운영 구간 분리가 필요함 initialDelaySeconds 하나로 해결하려 하지 않기
    배치 워커 Exec 또는 최소 Liveness HTTP 엔드포인트가 없을 수 있음 작업 큐 적체를 liveness 실패로 바로 연결하지 않기
    외부 의존성이 많은 앱 Readiness 강화, Liveness 단순화 장애 전파를 재시작 루프로 만들지 않기 위함 핵심 의존성만 readiness에 포함하기

    혹시 “애플리케이션은 분명 떠 있는데 로드밸런서 뒤에서만 실패하는” 경험이 있었다면, 대체로 readiness가 너무 느슨하거나 너무 무거운 경우가 많습니다. 카프카 소비자가 붙은 앱에서도 비슷한 장면이 자주 나옵니다. HTTP 포트는 빨리 떠도 소비자 그룹 조인이 끝나기 전엔 실제 처리 품질이 불안정할 수 있거든요. 그때 /ready를 소비자 초기화 완료 이후에만 true로 바꾸면 배포 중 실패율이 꽤 줄어듭니다. 이거 실무에서 진짜 편하더라고요.

    자주 묻는 질문

    Readiness만 있으면 Liveness는 없어도 될까요?

    가능합니다. 이건 의외로 많은 팀이 과하게 쓰는 부분입니다. 애플리케이션이 hang 상태에 빠질 가능성이 낮고, 장애 시 프로세스가 명확하게 종료되며, readiness만으로도 트래픽 차단이 충분하다면 liveness를 아예 두지 않는 선택도 실무적으로 타당합니다. Liveness는 필수 옵션이 아니라 재시작이 실제 복구 수단일 때만 가치가 있습니다.

    TCP probe면 충분하지 않나요?

    정말 단순한 서비스라면 됩니다. 하지만 대부분의 웹 애플리케이션은 “포트가 열려 있다”와 “요청을 정상 처리할 수 있다”가 다릅니다. 그래서 운영 해석 가능성을 높이려면 HTTP 기반 엔드포인트 분리가 더 낫습니다.

    헬스 체크 엔드포인트는 하나로 합쳐도 될까요?

    작게 시작할 때는 가능합니다. 다만 운영 기간이 길어질수록 역할 분리가 이깁니다. /live, /ready를 분리하면 장애 원인과 후속 조치가 선명해집니다. 최소한 readiness와 liveness는 분리하는 쪽을 권합니다.

    Kubernetes Liveness Probe 선택 기준 요약 인포그래픽

    앱 유형별로 어떤 probe 조합을 선택하면 좋은지 한눈에 보는 요약 이미지입니다.

    마무리: Kubernetes Liveness Probe는 이렇게 고르면 덜 흔들립니다

    Kubernetes Liveness Probe는 만능 안정화 버튼이 아닙니다. 잘못 걸면 재시작이 복구가 아니라 장애 확산 경로가 됩니다. 반대로 Readiness Probe를 제대로 설계하면 준비 안 된 Pod로 트래픽이 들어가는 문제를 깔끔하게 줄일 수 있고, Startup Probe는 느린 기동 서비스를 불필요한 CrashLoopBackOff에서 지켜줍니다.

    기준은 꽤 분명합니다. 가벼운 API라면 Readiness를 먼저 정확히 만들고, Liveness는 꼭 필요한 경우에만 보수적으로 추가하세요. 느리게 뜨는 앱이라면 Startup Probe를 분리하세요. 외부 의존성이 많은 서비스라면 Liveness는 단순하게 두고 Readiness에서 트래픽 차단을 설계하세요. 운영 안정성은 “많이 검사하는 것”보다 무엇을 실패시켰을 때 어떤 후속 조치가 일어나는지를 정확히 설계할 때 올라갑니다.

  • [k8s] 쿠버네티스 Pod 트러블슈팅: CrashLoopBackOff, OOMKilled 완벽 해결

    쿠버네티스 Pod가 계속 죽는다면? 당신만 그런 게 아닙니다

    쿠버네티스를 처음 운영하다 보면 꼭 한 번씩 마주치는 상황이 있어요. 열심히 작성한 배포 파일을 kubectl apply로 올렸는데, Pod 상태가 CrashLoopBackOff거나 OOMKilled로 떠 있는 겁니다. 처음엔 진짜 당황스럽죠. 로그를 봐도 뭔가 무한루프처럼 에러가 쌓이고, 뭘 고쳐야 할지 막막하고요.

    저도 입사 초반에 이거 때문에 밤새 삽질한 기억이 있습니다. 당시엔 kubectl describe pod 명령어조차 제대로 활용 못 했거든요. 지금은 쿠버네티스 Pod 트러블슈팅이 거의 반사적으로 손가락이 움직일 정도가 됐지만, 처음엔 정말 막막했어요. 이번 글에서는 제가 직접 겪고 해결한 경험을 바탕으로, CrashLoopBackOff와 OOMKilled 두 가지 대표적인 Pod 오류를 어떻게 진단하고 해결하는지 단계별로 풀어드릴게요.

    ▲ 쿠버네티스 Pod의 주요 상태 전환 흐름. Pending → Running → CrashLoopBackOff / OOMKilled 경로를 시각화한 다이어그램

    CrashLoopBackOff와 OOMKilled, 이게 대체 뭔가요?

    CrashLoopBackOff — “계속 죽었다 살아났다 반복”

    쉽게 말해서, 컨테이너가 시작됐다가 바로 죽고, 쿠버네티스가 다시 살리고, 또 죽고를 반복하는 상태예요. 쿠버네티스는 기본적으로 컨테이너가 죽으면 재시작(restart)을 시도하는데, 계속 실패하면 재시작 간격을 점점 늘리면서 BackOff 상태로 전환됩니다. 10초, 20초, 40초… 이런 식으로요.

    원인은 정말 다양합니다:

    • 애플리케이션 자체 버그 (예외 처리 안 된 panic, exit code 1)
    • 잘못된 환경 변수(Environment Variable) 설정
    • ConfigMap이나 Secret 마운트 실패
    • Liveness Probe(생존 확인 프로브) 설정 오류
    • 의존 서비스(DB, 외부 API 등)에 연결 못 하는 경우

    OOMKilled — “메모리를 너무 많이 먹어서 강제 종료”

    OOM은 Out Of Memory의 약자입니다. 컨테이너가 설정된 메모리 Limit(제한)을 초과하면, 리눅스 커널의 OOM Killer가 해당 프로세스를 강제로 죽여버려요. kubectl describe pod로 확인하면 OOMKilled라고 딱 찍혀 있고, exit code는 137이에요.

    이건 크게 두 가지 경우인데요:

    • 메모리 Limit이 너무 낮게 설정된 경우: 실제 앱이 필요한 메모리보다 Limit이 작아서 죽는 거예요
    • 메모리 누수(Memory Leak)가 있는 경우: Limit은 적절한데 앱이 메모리를 계속 잡아먹고 반환 안 하는 경우
    오류 유형 주요 원인 Exit Code 확인 명령어
    CrashLoopBackOff 앱 오류, 설정 문제, Probe 실패 1, 2 등 다양 kubectl logs, kubectl describe
    OOMKilled 메모리 Limit 초과, 메모리 누수 137 kubectl describe, metrics-server

    1단계: 상황 파악 — 일단 뭐가 문제인지 보자

    쿠버네티스 Pod 트러블슈팅의 첫 번째 단계는 항상 현재 상태 파악이에요. 저는 이 순서대로 확인합니다.

    Pod 상태 전체 확인

    # 네임스페이스 전체 Pod 상태 확인
    kubectl get pods -n <네임스페이스> -o wide
    
    # 모든 네임스페이스에서 문제 있는 Pod만 필터링
    kubectl get pods -A | grep -v Running | grep -v Completed

    여기서 RESTARTS 컬럼 숫자가 높으면 CrashLoopBackOff 의심이에요. 한 자리면 괜찮은데, 두세 자리 넘어가면 심각한 거거든요.

    Pod 상세 이벤트 확인 — 이게 핵심입니다

    kubectl describe pod  -n <네임스페이스>

    출력 맨 아래 Events: 섹션을 꼭 보세요. 여기에 실제로 무슨 일이 있었는지 타임라인이 찍혀 있어요. OOMKilled라면 이런 식으로 보입니다:

    Events:
      Type     Reason     Age                From               Message
      ----     ------     ----               ----               -------
      Warning  BackOff    2m (x5 over 5m)   kubelet            Back-off restarting failed container
      Normal   Pulled     6m                kubelet            Successfully pulled image
      Normal   Started    6m                kubelet            Started container app
      Warning  OOMKilling 5m                kubelet            Memory limit reached, killing container

    로그 확인 — 현재 로그와 이전 컨테이너 로그

    # 현재 컨테이너 로그
    kubectl logs  -n <네임스페이스>
    
    # 이전에 죽은 컨테이너 로그 (이게 더 중요할 때가 많아요!)
    kubectl logs  -n <네임스페이스> --previous
    
    # 실시간 로그 스트리밍
    kubectl logs -f  -n <네임스페이스>
    
    # 멀티 컨테이너 Pod인 경우 컨테이너 지정
    kubectl logs  -c  -n <네임스페이스> --previous

    💡 팁: --previous 플래그를 꼭 써보세요. 현재 로그에는 아무것도 없어도, 이전에 죽을 때의 로그가 여기 남아 있거든요. 저도 처음엔 이걸 몰라서 한참 헤맸습니다 ㅎㅎ

    ▲ kubectl describe pod 명령어 실행 결과. Events 섹션에서 OOMKilled 및 CrashLoopBackOff 원인을 확인하는 화면

    2단계: CrashLoopBackOff 원인별 해결법

    케이스 1: 애플리케이션 자체 오류

    로그에서 스택 트레이스(stack trace)나 panic, exception 같은 키워드가 보인다면 앱 코드 문제예요. 이건 개발팀에 공유해야 하는 케이스고, 인프라 엔지니어 입장에서 할 수 있는 건 명확한 로그를 전달하는 거예요.

    # 마지막 100줄 로그 확인
    kubectl logs  --previous --tail=100 -n <네임스페이스>

    케이스 2: Liveness Probe 설정 오류

    이게 은근히 많은 케이스더라고요. Liveness Probe(생존 확인 프로브)가 너무 엄격하게 설정되어 있으면, 앱이 정상인데도 쿠버네티스가 죽었다고 판단해서 재시작시켜 버립니다.

    # 잘못된 설정 예시 — 너무 빠른 초기 딜레이
    livenessProbe:
      httpGet:
        path: /health
        port: 8080
      initialDelaySeconds: 3   # 앱 시작에 10초 걸리는데 3초만 기다림
      periodSeconds: 5
      failureThreshold: 1      # 1번만 실패해도 재시작
    
    ---
    
    # 올바른 설정 예시
    livenessProbe:
      httpGet:
        path: /health
        port: 8080
      initialDelaySeconds: 30  # 앱 시작 시간보다 여유 있게
      periodSeconds: 10
      failureThreshold: 3      # 3번 연속 실패해야 재시작
      timeoutSeconds: 5        # 응답 타임아웃도 여유 있게

    ⚠️ 주의: initialDelaySeconds는 컨테이너 시작 후 처음 Probe를 시도하기 전 대기 시간이에요. 앱이 완전히 뜨는 데 걸리는 시간보다 넉넉하게 잡아야 합니다. 저는 보통 실제 시작 시간의 1.5~2배로 잡아요.

    케이스 3: ConfigMap / Secret 마운트 실패

    # ConfigMap 존재 여부 확인
    kubectl get configmap -n <네임스페이스>
    
    # Secret 존재 여부 확인
    kubectl get secret -n <네임스페이스>
    
    # describe에서 Volume 마운트 실패 메시지 확인
    kubectl describe pod  -n <네임스페이스> | grep -A5 "Volumes"

    describe 출력에서 이런 메시지가 보이면 ConfigMap이나 Secret이 없는 겁니다:

    Warning  FailedMount  10s   kubelet  MountVolume.SetUp failed for volume "config" :
    configmap "my-app-config" not found

    케이스 4: 환경 변수 누락

    # 실행 중인 Pod의 환경 변수 확인
    kubectl exec  -n <네임스페이스> -- env | sort
    
    # Pod가 죽어서 exec가 안 되는 경우 — 임시 디버그 Pod 실행
    kubectl run debug-pod --image=busybox -it --rm -- /bin/sh

    3단계: OOMKilled 해결법

    현재 메모리 사용량 확인

    먼저 실제로 얼마나 쓰고 있는지 봐야죠. metrics-server가 설치되어 있다면:

    # Pod 리소스 사용량 확인
    kubectl top pod  -n <네임스페이스>
    
    # 컨테이너별 상세 확인
    kubectl top pod  -n <네임스페이스> --containers

    현재 설정된 Limit 확인:

    kubectl get pod  -n <네임스페이스> -o jsonpath='{.spec.containers[*].resources}'

    메모리 Limit 조정

    실제 사용량이 Limit에 근접하거나 초과한다면 Limit을 올려야 해요. 이건 Deployment를 수정해야 합니다:

    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: my-app
    spec:
      template:
        spec:
          containers:
          - name: app
            image: my-app:latest
            resources:
              requests:
                memory: "256Mi"   # 스케줄링 기준 (최소 보장량)
                cpu: "250m"
              limits:
                memory: "512Mi"   # 최대 허용량 (이 이상 쓰면 OOMKilled)
                cpu: "500m"

    💡 Request와 Limit의 차이:

    • requests: 쿠버네티스가 Pod를 노드에 배치할 때 기준이 되는 최소 보장량이에요. 이 공간이 있는 노드에만 배치됩니다.
    • limits: 컨테이너가 절대 넘을 수 없는 상한선이에요. 메모리 Limit을 넘으면 OOMKilled, CPU Limit을 넘으면 쓰로틀링(throttling)이 발생해요.

    메모리 누수 의심 케이스

    Limit을 올렸는데도 계속 OOMKilled가 난다면 메모리 누수를 의심해야 해요. 이 경우엔 시간에 따른 메모리 증가 패턴을 봐야 합니다. Prometheus + Grafana 조합이 있다면 메모리 사용량 그래프를 확인하세요. 선형으로 계속 올라간다면 누수 가능성이 높습니다.

    # 메모리 사용량 변화를 5초 간격으로 모니터링
    watch -n 5 kubectl top pod  -n <네임스페이스>

    누수가 확인되면 개발팀에 공유하고, 임시 방편으로는 Deployment에 주기적 재시작을 설정하는 방법도 있어요 (권장하진 않지만요):

    # CronJob으로 주기적 재시작 — 근본 해결책은 아닙니다!
    apiVersion: batch/v1
    kind: CronJob
    metadata:
      name: restart-my-app
    spec:
      schedule: "0 4 * * *"  # 매일 새벽 4시
      jobTemplate:
        spec:
          template:
            spec:
              containers:
              - name: kubectl
                image: bitnami/kubectl
                command:
                - kubectl
                - rollout
                - restart
                - deployment/my-app
              restartPolicy: OnFailure

    ▲ Grafana 대시보드에서 Pod 메모리 사용량 추이를 모니터링하는 화면. 메모리 누수 시 선형 증가 패턴이 확인됨

    4단계: 고급 디버깅 — 그래도 모르겠을 때

    임시 디버그 컨테이너 (kubectl debug)

    쿠버네티스 1.23 이상이라면 kubectl debug를 활용할 수 있어요. 이미지에 쉘이 없거나, distroless 이미지를 쓰는 경우에도 쿠버네티스 Pod를 디버깅할 수 있습니다.

    # 실행 중인 Pod에 디버그 컨테이너 추가
    kubectl debug -it  -n <네임스페이스> \
      --image=busybox \
      --target=
    
    # Pod를 복사해서 디버그 버전으로 실행
    kubectl debug  -n <네임스페이스> \
      -it \
      --copy-to=debug-pod \
      --image=ubuntu \
      -- bash

    Pod가 계속 죽어서 exec가 안 될 때

    이건 진짜 난감한 상황인데요. 이럴 때 쓰는 트릭이 있어요. 컨테이너 커맨드를 sleep infinity로 덮어써서 앱이 실행되지 않은 상태에서 내부를 들여다보는 겁니다:

    # 임시로 커맨드를 sleep으로 덮어쓰기
    kubectl debug  -n <네임스페이스> \
      --copy-to=debug-pod \
      --image= \
      -- sleep infinity
    
    # 그 다음 exec로 들어가서 환경 확인
    kubectl exec -it debug-pod -n <네임스페이스> -- bash

    네트워크 연결 문제 확인

    DB나 외부 서비스 연결 실패로 CrashLoopBackOff가 나는 경우도 많아요:

    # 네임스페이스 내 Service 확인
    kubectl get svc -n <네임스페이스>
    
    # DNS 해석 확인 (임시 Pod 사용)
    kubectl run dns-test --image=busybox -it --rm -n <네임스페이스> \
      -- nslookup my-db-service
    
    # TCP 연결 확인
    kubectl run tcp-test --image=busybox -it --rm -n <네임스페이스> \
      -- nc -zv my-db-service 5432

    검증 — 제대로 고쳐졌는지 확인하기

    수정 후에는 꼭 아래 순서로 확인해요:

    1. Pod 상태가 Running으로 안정적으로 유지되는지 확인
    2. RESTARTS 카운트가 더 이상 올라가지 않는지 확인
    3. 로그에 정상 동작 메시지가 찍히는지 확인
    4. Readiness Probe(준비 확인 프로브)가 통과하는지 확인
    # 실시간으로 Pod 상태 변화 모니터링
    kubectl get pods -n <네임스페이스> -w
    
    # Rollout 상태 확인
    kubectl rollout status deployment/ -n <네임스페이스>
    
    # 최근 이벤트 확인
    kubectl get events -n <네임스페이스> --sort-by='.lastTimestamp' | tail -20

    🎉 kubectl get pods에서 STATUS가 Running이고 READY가 1/1이면서 RESTARTS가 안 올라가면 성공입니다!

    쿠버네티스 Pod 트러블슈팅 체크리스트 정리

    확인 항목 CrashLoopBackOff OOMKilled 명령어
    Pod 상태 확인 ✅ ✅ kubectl get pods
    이벤트 로그 확인 ✅ ✅ kubectl describe pod
    이전 컨테이너 로그 ✅ ✅ kubectl logs –previous
    Probe 설정 확인 ✅ – kubectl get pod -o yaml
    메모리 사용량 확인 – ✅ kubectl top pod
    리소스 Limit 조정 – ✅ kubectl edit deployment
    ConfigMap/Secret 확인 ✅ – kubectl get cm,secret
    네트워크 연결 확인 ✅ – kubectl run (임시 Pod)

    ▲ CrashLoopBackOff와 OOMKilled 트러블슈팅 플로우차트. 증상별 진단 경로와 해결 방법을 한눈에 정리한 인포그래픽

    마무리 — 결국 로그가 답이더라고요

    여러 해 동안 수많은 인프라 사고를 겪어오면서 느낀 건, 쿠버네티스 Pod 트러블슈팅의 핵심은 결국 로그와 이벤트를 제대로 읽는 것이에요. 화려한 도구가 없어도, kubectl describe와 kubectl logs --previous 두 개만 잘 써도 대부분의 문제는 해결됩니다.

    CrashLoopBackOff는 원인이 다양하니까 로그를 꼼꼼히 보고 하나씩 제거해 나가는 방식이 좋아요. OOMKilled는 일단 메모리 사용량 측정부터 시작해서 Limit 조정이냐, 누수 수정이냐를 판단하면 되고요.

    정리하면:

    • ✅ CrashLoopBackOff: kubectl logs --previous로 죽기 직전 로그 확인 → Probe 설정, 환경 변수, 마운트 순서로 체크
    • ✅ OOMKilled: kubectl top pod로 실제 사용량 확인 → Limit 상향 조정 또는 누수 수정
    • ✅ 막막할 땐 kubectl debug로 임시 컨테이너 붙여서 내부 확인

    다음 글에서는 Pending 상태에서 Pod가 뜨지 않는 경우 (노드 리소스 부족, Taints/Tolerations 문제 등)를 다뤄볼 예정입니다. Pod가 아예 시작조차 안 된다면 그쪽 글을 참고해 주세요!

    혹시 이 글에서 다루지 않은 케이스로 고생하고 계신 분 있으시면 댓글로 남겨주세요. 같이 고민해 볼게요 😊