13년차의 서버실

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

[태그:] Kubernetes 프로브 트러블슈팅

  • [k8s] Kubernetes 프로브 트러블슈팅: Liveness/Readiness 실패

    [k8s] Kubernetes 프로브 트러블슈팅: Liveness/Readiness 실패

    Kubernetes 프로브 트러블슈팅: Liveness/Readiness 실패

    Kubernetes 프로브 트러블슈팅이 중요한 이유

    Kubernetes 프로브 트러블슈팅은 단순히 /health 경로가 200을 반환하는지 확인하는 일이 아닙니다. 운영에서는 이 설정 하나가 재시작 루프, 트래픽 블랙홀, 배포 지연, 의존성 장애 전파를 만들기도 하고 막기도 하거든요. 13년 넘게 서비스를 운영하면서 느낀 건, 프로브는 애플리케이션의 상태를 보는 기능이라기보다 쿠버네티스가 장애에 어떻게 반응할지 정하는 정책에 가깝다는 점입니다.

    대표적인 장면은 비슷합니다. 앱 로그만 보면 큰 문제가 없는데 kubectl get pods의 RESTARTS가 계속 올라갑니다. 또는 배포는 끝났는데 Service 뒤에서 파드가 빠져 502, 503이 발생합니다. 이때 무작정 앱을 재배포하거나 노드를 의심하면 시간이 정말 빨리 녹습니다. 먼저 봐야 할 것은 kubelet 이벤트입니다. Liveness probe failed 뒤에 Killing container가 붙는지, Readiness probe failed만 반복되는지에 따라 원인이 완전히 갈립니다.

    위 흐름에서 핵심은 하나입니다. Liveness 실패는 재시작을 부르고, Readiness 실패는 트래픽 제외를 부릅니다. 둘 다 헬스 체크처럼 보이지만 운영 결과는 다릅니다. 그래서 프로브를 설계할 때는 “정상인가?”보다 “실패했을 때 쿠버네티스가 무엇을 해야 하는가?”를 먼저 정해야 합니다.

    Liveness Probe 실패와 Readiness Probe 오류의 차이

    Liveness Probe는 컨테이너가 복구 불가능한 상태에 빠졌는지 판단합니다. 실패가 임계치에 도달하면 kubelet이 해당 컨테이너를 재시작합니다. Readiness Probe는 지금 요청을 받아도 되는지 판단합니다. 실패해도 컨테이너는 계속 실행되지만, 해당 파드는 Service 엔드포인트에서 제외됩니다. Startup Probe는 초기 기동 중인 앱을 보호합니다. Startup Probe가 설정되면 성공하기 전까지 Liveness와 Readiness가 실행되지 않으므로, 기동이 느린 앱에서 조기 재시작을 막는 데 유용합니다.

    운영에서 많이 터지는 실수는 외부 의존성까지 Liveness에 넣는 겁니다. DB가 잠깐 느려졌을 뿐인데 모든 파드가 동시에 재시작되면, 커넥션 재생성·캐시 워밍업·JIT/클래스 로딩 같은 비용이 한꺼번에 몰립니다. 장애를 복구하는 게 아니라 장애에 연료를 붓는 모양이 되더라고요. DB, Redis, 메시지 브로커, 외부 API는 대부분 Readiness에서 다루는 편이 안전합니다.

    구분 Liveness Probe Readiness Probe Startup Probe 운영 판단 기준
    목적 컨테이너를 재시작해야 하는지 판단 트래픽을 받을 준비가 됐는지 판단 초기 기동이 끝났는지 판단 실패 시 원하는 조치가 재시작인지, 트래픽 차단인지부터 정합니다.
    실패 시 동작 컨테이너 재시작 Service 엔드포인트에서 제외 성공 전까지 다른 프로브 보류, 실패 시 컨테이너 재시작 RESTARTS, Events, EndpointSlice를 같이 봐야 오판이 줄어듭니다.
    적합한 검사 이벤트 루프 멈춤, 데드락, 기본 핸들러 무응답 DB 연결, 캐시 준비, 필수 설정 로딩, 마이그레이션 대기 JVM/Node.js/.NET 앱의 긴 부팅, 모델 로딩, 초기 캐시 구성 외부 의존성 실패가 곧 프로세스 사망을 의미하지는 않습니다.
    과도하게 엄격할 때 재시작 루프, 콜드 스타트 비용 증가 정상 파드가 트래픽을 못 받아 가용 용량 감소 기동 실패 감지가 늦어짐 엄격함은 안정성이 아니라 운영 정책입니다. 비용을 함께 계산해야 합니다.
    주로 확인할 증거 Killing container, BackOff, --previous 로그 Ready 0/1, Endpoint/EndpointSlice 누락 기동 로그, 초기화 완료 시점, Startup 실패 이벤트 로그보다 먼저 Events를 보면 방향을 빨리 잡습니다.

    쿠버네티스 헬스 체크 기본 설정 예시

    아래 예시는 세 프로브를 분리한 Deployment입니다. 실제 애플리케이션 이미지가 /livez, /readyz, /startupz 엔드포인트를 제공한다는 전제로 봐주세요. /livez는 프로세스와 HTTP 서버가 응답 가능한지만 확인하고, /readyz는 실제 요청 처리 준비 상태를 확인합니다. /startupz는 초기화가 끝나기 전 Liveness가 끼어들지 않도록 막습니다. 운영 매니페스트에서는 이 정도 분리가 나중에 장애 분석 시간을 꽤 줄여줍니다.

    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: probe-demo
    spec:
      replicas: 2
      selector:
        matchLabels:
          app: probe-demo
      template:
        metadata:
          labels:
            app: probe-demo
        spec:
          terminationGracePeriodSeconds: 30
          containers:
            - name: app
              image: your-registry.example.com/probe-demo:1.0.0
              ports:
                - name: http
                  containerPort: 8080
              startupProbe:
                httpGet:
                  path: /startupz
                  port: http
                periodSeconds: 5
                timeoutSeconds: 2
                failureThreshold: 30
              livenessProbe:
                httpGet:
                  path: /livez
                  port: http
                periodSeconds: 10
                timeoutSeconds: 2
                failureThreshold: 3
              readinessProbe:
                httpGet:
                  path: /readyz
                  port: http
                periodSeconds: 5
                timeoutSeconds: 2
                successThreshold: 1
                failureThreshold: 3

    값을 고를 때는 감이 아니라 앱의 실제 시간을 기준으로 잡습니다. 기동 로그에서 “서버 포트 바인딩 완료”, “마이그레이션 완료”, “캐시 로딩 완료”, “요청 처리 가능” 시점을 분리해 보세요. initialDelaySeconds를 길게 늘리는 방식은 단순하지만, 앱이 빨리 떠도 무조건 기다립니다. 반면 startupProbe는 성공하는 순간 다음 단계로 넘어가므로, 느린 기동과 빠른 회복을 같이 다루기 좋습니다.

    프로브 파라미터는 서로 곱해져 운영 결과를 만듭니다. 예를 들어 periodSeconds: 10, failureThreshold: 3, timeoutSeconds: 2라면 실패를 확정하기까지 여러 번의 검사 주기가 필요합니다. 대략 30초 안팎으로 생각할 수 있지만, 정확한 감지 시간은 스케줄링과 응답 지연에 영향을 받습니다. 고정 수치처럼 외우기보다 “얼마나 빨리 빼고 얼마나 늦게 죽일 것인가”라는 정책으로 보는 편이 낫습니다.

    매니페스트에서 확인할 필드는 httpGet.path, port, periodSeconds, timeoutSeconds, failureThreshold, successThreshold입니다. 참고로 successThreshold는 Liveness와 Startup에서는 1이어야 하고, Readiness에서만 1보다 크게 둘 수 있습니다. Readiness 복귀를 일부러 보수적으로 만들고 싶을 때만 조정하세요.

    실전 디버깅: Kubernetes 프로브 트러블슈팅 명령어

    장애가 났을 때 저는 애플리케이션 로그보다 먼저 쿠버네티스 이벤트를 봅니다. 이벤트는 kubelet이 왜 컨테이너를 죽였는지, 왜 Ready에서 제외했는지 비교적 솔직하게 남깁니다. 아래 순서대로 보면 “앱 문제인지, 프로브 설정 문제인지, Service 라우팅 문제인지”가 빠르게 갈립니다.

    1. 파드의 Ready 상태와 재시작 횟수를 확인합니다.
    2. Events에서 Liveness probe failed, Readiness probe failed, Startup probe failed를 찾습니다.
    3. 현재 로그와 이전 컨테이너 로그를 비교합니다.
    4. Service, Endpoint, EndpointSlice에 파드 IP가 들어갔는지 확인합니다.
    5. 클러스터 내부에서 DNS, 포트, HTTP 경로를 직접 호출합니다.
    NS=default
    APP=probe-demo
    POD=$(kubectl get pod -n "$NS" -l app="$APP" -o jsonpath='{.items[0].metadata.name}')
    
    kubectl get pods -n "$NS" -l app="$APP" -o wide
    kubectl describe pod "$POD" -n "$NS"
    kubectl logs "$POD" -n "$NS" --all-containers=true --tail=200
    kubectl logs "$POD" -n "$NS" --previous --tail=200
    kubectl get events -n "$NS" --sort-by=.lastTimestamp
    kubectl get svc "$APP" -n "$NS"
    kubectl get endpoints "$APP" -n "$NS"
    kubectl get endpointslice -n "$NS" -l kubernetes.io/service-name="$APP"

    해석은 이렇게 합니다. Liveness probe failed 다음에 Killing container가 이어지면 재시작 원인은 Liveness입니다. Readiness probe failed만 반복되고 RESTARTS가 늘지 않으면 파드는 살아 있지만 Service 트래픽에서 제외된 겁니다. connection refused는 포트 미오픈, 잘못된 포트명, 앱 기동 전 검사 시작, 또는 앱이 127.0.0.1에만 바인딩된 경우를 의심합니다. HTTP probe failed with statuscode: 404는 대개 경로 오타입니다. context deadline exceeded나 timeout 계열 메시지는 앱 내부 지연, CPU throttling, I/O 대기, GC 지연까지 같이 봐야 합니다.

    kubectl run curl-debug \
      -n default \
      --image=curlimages/curl:8.5.0 \
      --restart=Never \
      --rm -it \
      -- sh
    
    # 디버그 파드 안에서 실행
    curl -sv --max-time 3 http://probe-demo.default.svc.cluster.local/readyz
    curl -sv --max-time 3 http://probe-demo.default.svc.cluster.local/livez
    curl -sv --max-time 3 http://probe-demo.default.svc.cluster.local:80/readyz

    여기서 중요한 건 “Service DNS로 되는가”와 “Pod IP로 직접 되는가”를 나눠 보는 겁니다. Service DNS는 실패하는데 Pod IP 직접 호출은 성공하면 Service selector나 EndpointSlice 쪽을 봅니다. Pod IP 직접 호출도 실패하면 컨테이너 포트, 앱 바인딩 주소, 경로, 응답 시간을 봅니다. 프로브는 kubelet이 Pod 네트워크의 Pod IP로 직접 보내는 검사라서, Ingress에서 보이는 증상과 다를 수 있습니다.

    재현 가능한 시나리오: Readiness Probe 오류 만들고 확인하기

    테스트 클러스터에서 일부러 잘못된 Readiness 경로를 넣어보면 동작이 선명해집니다. 아래 Pod는 nginx를 띄우지만 존재하지 않는 /not-ready를 Readiness로 검사합니다. 컨테이너는 실행되지만 Ready가 되지 않고, Service 엔드포인트에도 들어가지 않는 상태를 만들 수 있습니다. 이거 한 번 직접 보면 실장애 때 감이 훨씬 빨리 옵니다.

    apiVersion: v1
    kind: Pod
    metadata:
      name: bad-readiness
      labels:
        app: bad-readiness
    spec:
      containers:
        - name: nginx
          image: nginx:1.25
          ports:
            - name: http
              containerPort: 80
          readinessProbe:
            httpGet:
              path: /not-ready
              port: http
            initialDelaySeconds: 3
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 2
    ---
    apiVersion: v1
    kind: Service
    metadata:
      name: bad-readiness
    spec:
      selector:
        app: bad-readiness
      ports:
        - name: http
          port: 80
          targetPort: http
    kubectl apply -f bad-readiness.yaml
    kubectl get pod bad-readiness -w
    kubectl describe pod bad-readiness
    kubectl get endpoints bad-readiness
    kubectl get endpointslice -l kubernetes.io/service-name=bad-readiness
    kubectl delete -f bad-readiness.yaml

    정상적인 관찰 결과는 이렇습니다. kubectl get pod에서 READY가 0/1로 보이고, describe Events에는 Readiness 실패가 찍힙니다. 하지만 RESTARTS는 증가하지 않습니다. Service의 Endpoint 또는 EndpointSlice도 비어 있거나 해당 파드 IP가 빠져 있어야 합니다. 이 작은 실험을 해두면 “왜 컨테이너는 Running인데 트래픽이 안 가지?”라는 질문에 훨씬 빨리 답할 수 있습니다.

    반대로 Liveness 경로를 잘못 지정하면 결과가 달라집니다. READY 이전에 컨테이너가 재시작될 수 있고, Events에는 Liveness 실패와 컨테이너 종료 메시지가 같이 나타납니다. 같은 404라도 Readiness에서는 트래픽 제외, Liveness에서는 재시작입니다. 이 차이를 머리에 넣어두면 디버깅 방향이 흔들리지 않습니다.

    제가 겪은 Liveness Probe 실패 패턴

    가장 위험했던 패턴은 종합 건강검진식 Liveness였습니다. /health 요청 하나에 DB 쿼리, Redis ping, 외부 API 호출, 디스크 체크를 전부 넣는 방식입니다. 겉보기에는 꼼꼼합니다. 그런데 DB가 순간적으로 느려지면 모든 파드가 Liveness 실패로 재시작됩니다. 재시작된 파드는 다시 DB 커넥션을 만들고, 캐시를 채우고, 준비되지 않은 상태에서 또 검사를 받습니다. 문제의 뿌리는 DB 지연인데 증상은 애플리케이션 재시작 폭풍으로 바뀝니다.

    제가 쓰는 기준은 꽤 엄격합니다. Liveness는 “이 프로세스를 죽이면 나아지는가?”에 예라고 답할 수 있을 때만 실패시킵니다. 외부 의존성이 실패했지만 프로세스가 요청 큐를 비우고 회복할 수 있다면 Liveness가 아니라 Readiness입니다. 이 기준이 약간 보수적으로 보여도, 운영에서는 불필요한 재시작을 줄이는 쪽이 대체로 더 싸고 안전했습니다.

    • Liveness: 기본 HTTP 핸들러, 이벤트 루프, 내부 상태 플래그처럼 가볍고 로컬인 검사만 둡니다.
    • Readiness: DB 연결, 브로커 구독, 필수 설정 로딩, 캐시 워밍업처럼 트래픽 처리 조건을 둡니다.
    • Startup: 앱 부팅이 느리거나 초기화 단계가 여러 개인 서비스에서 Liveness 개입을 늦춥니다.
    • 외부 API: 핵심 경로가 아니라면 Readiness에서도 과하게 엄격하게 묶지 않습니다. 서킷 브레이커와 graceful degradation이 더 나은 경우가 많습니다.
    startupProbe:
      httpGet:
        path: /startupz
        port: 8080
      periodSeconds: 5
      timeoutSeconds: 2
      failureThreshold: 30
    livenessProbe:
      httpGet:
        path: /livez
        port: 8080
      periodSeconds: 10
      timeoutSeconds: 2
      failureThreshold: 3
    readinessProbe:
      httpGet:
        path: /readyz
        port: 8080
      periodSeconds: 5
      timeoutSeconds: 2
      failureThreshold: 3

    이 구성을 그대로 복사하기보다, 먼저 앱의 실제 기동 경로를 관찰하세요. 포트가 열리는 시점과 요청을 안전하게 처리할 수 있는 시점은 다를 수 있습니다. 특히 Java, .NET, 대형 Node.js 앱처럼 초기 로딩이 긴 서비스는 포트가 먼저 열리고 내부 초기화가 나중에 끝나는 경우가 있습니다. 이때 Liveness를 빨리 켜면 멀쩡히 뜨는 중인 앱을 kubelet이 반복해서 죽입니다.

    검증/결과: 무엇을 보고 정상이라고 판단할까

    프로브 설정을 바꾼 뒤 “배포 성공”만 보고 끝내면 위험합니다. 제가 확인하는 기준은 네 가지입니다. 파드가 Ready인지, 재시작이 멈췄는지, Service 엔드포인트에 포함됐는지, 그리고 실패 이벤트가 더 이상 증가하지 않는지입니다. 이 확인 루틴은 짧지만, 운영에서는 진짜 편하더라고요.

    NS=default
    APP=probe-demo
    
    kubectl rollout status deployment/$APP -n $NS
    kubectl get pods -n $NS -l app=$APP -o custom-columns='NAME:.metadata.name,READY:.status.containerStatuses[*].ready,RESTARTS:.status.containerStatuses[*].restartCount,PHASE:.status.phase,NODE:.spec.nodeName'
    kubectl get endpoints $APP -n $NS -o wide
    kubectl get endpointslice -n $NS -l kubernetes.io/service-name=$APP -o wide
    kubectl describe deployment $APP -n $NS
    kubectl get events -n $NS --field-selector involvedObject.kind=Pod --sort-by=.lastTimestamp

    판단은 이렇게 가져갑니다. READY가 모든 컨테이너 수와 일치하면 요청을 받을 준비가 된 상태입니다. RESTARTS가 계속 늘면 Liveness 실패 또는 애플리케이션 크래시를 봅니다. EndpointSlice에 파드 IP가 없으면 Readiness 실패, Service selector 불일치, 포트명 불일치, 또는 라벨 문제를 봅니다. connection refused는 포트와 바인딩 주소, timeout은 앱 지연과 리소스 압박, 404는 경로 불일치부터 확인하세요.

    Kubernetes 프로브 트러블슈팅을 위한 kubectl 진단 결과 화면

    운영 대시보드가 있다면 프로브 실패율만 따로 보는 것도 좋습니다. 다만 프로브 자체가 너무 자주 실행되면 노드와 애플리케이션에 부하가 됩니다. 특히 exec 프로브는 컨테이너 안에서 프로세스를 실행하므로, 파드 수가 많고 주기가 짧으면 비용이 눈에 띄게 늘 수 있습니다. HTTP나 TCP로 충분한 경우라면 굳이 셸 명령을 매번 실행하지 않는 편이 낫습니다.

    Kubernetes 프로브 모범 사례와 선택 기준

    제가 팀에 리뷰할 때 가장 많이 보는 항목은 세 가지입니다. 첫째, Liveness가 외부 의존성을 검사하지 않는가. 둘째, Readiness가 실제 트래픽 처리 가능성을 반영하는가. 셋째, 느린 기동을 initialDelaySeconds 땜질이 아니라 Startup Probe로 풀었는가. 이 세 가지만 잡아도 Kubernetes 프로브 트러블슈팅의 절반은 줄어듭니다.

    상황별 추천

    • 애플리케이션 시작이 느리다면: Startup Probe를 추가하고 Liveness의 initialDelaySeconds를 과하게 늘리지 않습니다.
    • DB가 잠깐 끊길 수 있다면: DB 검사는 Readiness Probe에 둡니다. Liveness에서 DB를 때리지 않습니다.
    • 프로세스가 가끔 멈춘다면: 로컬 핸들러 기반의 가벼운 Liveness Probe로 재시작하게 합니다.
    • 경로가 자주 바뀐다면: 비즈니스 API와 헬스 체크 엔드포인트를 분리하고, 라우팅 리팩터링에 영향받지 않는 고정 경로를 씁니다.
    • 서비스 메시나 Ingress를 쓴다면: kubelet의 Pod 프로브, Service 라우팅, 외부 로드밸런서 헬스 체크를 서로 다른 계층으로 보고 분리해서 검증합니다.
    • 파드 수가 많다면: 짧은 periodSeconds와 무거운 exec 프로브 조합을 피합니다. 헬스 체크도 트래픽입니다.

    자주 묻는 질문

    Q. Liveness와 Readiness를 같은 경로로 써도 되나요?
    가능은 하지만 운영 기본값으로 추천하지 않습니다. 같은 경로가 DB나 외부 API를 검사하면 Readiness 실패로 끝날 문제가 Liveness 재시작으로 커질 수 있습니다. 정말 같은 경로를 써야 한다면 그 경로는 로컬 상태만 검사해야 합니다.

    Q. TCP Probe와 HTTP Probe 중 뭘 써야 하나요?
    HTTP 서비스라면 HTTP Probe가 보통 더 낫습니다. 상태 코드와 경로로 의도를 표현할 수 있기 때문입니다. TCP Probe는 포트가 열렸는지만 확인하므로, 앱이 내부 초기화 중이어도 성공할 수 있습니다.

    Q. Exec Probe는 언제 쓰나요?
    HTTP 엔드포인트를 만들기 어려운 배치성 프로세스나 레거시 앱에서 컨테이너 내부 상태 파일·명령 결과를 확인할 때 씁니다. 다만 명령 실행 비용, 이미지 안의 바이너리 존재 여부, 셸 의존성을 반드시 확인해야 합니다.

    Q. gRPC 서비스는 어떻게 하나요?
    gRPC Health Checking Protocol을 구현했다면 Kubernetes v1.27부터 안정화된 gRPC probe를 검토할 수 있습니다. gRPC probe는 HTTP/TCP probe와 달리 named port를 지원하지 않으므로 숫자 포트를 명시해야 합니다. 서비스 이름과 포트 설정을 명확히 하고, HTTP 게이트웨이와 혼동하지 않도록 매니페스트 리뷰를 거치는 편이 좋습니다.

    Liveness Probe Readiness Probe Startup Probe 선택 기준 요약

    공식 동작 기준은 Kubernetes 문서의 Configure Liveness, Readiness and Startup Probes와 Liveness, Readiness, and Startup Probes를 기준으로 확인하되, 실제 값은 서비스의 기동 로그와 장애 패턴에 맞춰 조정하세요. 문서의 예시는 출발점이지 운영 정책 그 자체는 아닙니다. 관련 배포 전략이나 장애 대응 문서가 있다면 내부 링크로 함께 묶어두면 독자가 다음 글로 자연스럽게 이동합니다.

    마무리: 운영에서 덜 아프게 쓰는 결론

    프로브 설계의 기준은 명확합니다. 재시작하면 좋아지는 문제만 Liveness에 넣고, 트래픽만 빼면 되는 문제는 Readiness에 넣고, 느린 기동은 Startup Probe로 보호하세요. 이 원칙을 어기면 프로브는 장애 감지 장치가 아니라 장애 증폭 장치가 됩니다.

    일반 웹 서비스라면 저는 이렇게 시작합니다. Liveness는 /livez에서 프로세스와 기본 핸들러만 확인합니다. Readiness는 /readyz에서 DB 연결, 필수 설정, 내부 큐 상태처럼 요청 처리 조건을 확인합니다. 기동이 느린 서비스는 /startupz를 두고, Startup Probe가 성공하기 전까지 Liveness가 개입하지 않게 합니다. 배포 후에는 kubectl describe pod, kubectl logs --previous, kubectl get endpoints, kubectl get endpointslice를 함께 확인합니다. 이 네 가지를 습관처럼 보면 Kubernetes 프로브 트러블슈팅에서 가장 흔한 삽질을 꽤 많이 줄일 수 있습니다.