Skip to content
Published on

Kubernetes 프로브 3종 제대로 쓰기 — liveness, readiness, startup의 정확한 경계

공유하기
Authors

들어가며 — 멀쩡한 파드가 몇 분마다 재시작합니다

애플리케이션은 정상 동작하는데 RESTARTS만 조용히 올라갑니다.

kubectl get pod -n payments

NAME                        READY   STATUS    RESTARTS       AGE
checkout-7d9f6c5b4d-2xk9p   1/1     Running   11 (4m2s ago)  3h18m
checkout-7d9f6c5b4d-lm4vz   1/1     Running   9 (12m ago)    3h18m

로그에는 종료 직전까지 정상 요청 처리 기록만 있습니다. 답은 이벤트에 있습니다.

kubectl describe pod checkout-7d9f6c5b4d-2xk9p -n payments | tail -5

Events:
  Type     Reason     Age                    From     Message
  ----     ------     ----                   ----     -------
  Warning  Unhealthy  4m12s (x33 over 3h17m) kubelet  Liveness probe failed: Get "http://10.42.3.17:8080/healthz": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
  Normal   Killing    4m2s (x11 over 3h17m)  kubelet  Container checkout failed liveness probe, will be restarted

쿠버네티스가 이 컨테이너를 죽이고 있습니다. 정확히는 우리가 그렇게 하라고 지시해 둔 것입니다.

세 프로브는 서로 다른 질문에 답합니다

세 프로브의 문법은 똑같아서 헷갈리지만, 실패했을 때 하는 일이 완전히 다릅니다.

  • startupProbe — "기동이 끝났는가". 성공할 때까지 liveness와 readiness를 완전히 정지시킵니다. 한 번 성공하면 그 컨테이너의 생애 동안 다시 실행되지 않습니다. failureThreshold를 다 소진하면 컨테이너를 죽입니다
  • readinessProbe — "지금 트래픽을 받아도 되는가". 실패하면 서비스 엔드포인트에서 파드 주소가 빠집니다. 컨테이너를 재시작하지 않습니다. 성공하면 다시 들어옵니다
  • livenessProbe — "이 프로세스를 재시작해야 하는가". 실패하면 kubelet이 컨테이너를 죽이고 restartPolicy에 따라 다시 띄웁니다

여기서 가장 자주 나오는 오해를 먼저 정리합니다. readiness 실패는 절대 재시작을 유발하지 않습니다. 파드가 계속 0/1로 남아 있는데 재시작이 안 된다고 당황하는 경우가 많은데, 그건 설계대로 동작하는 것입니다. 재시작은 오직 liveness와 startup의 권한입니다.

readiness 실패는 엔드포인트에서 확인합니다.

kubectl get endpointslice -n payments -l kubernetes.io/service-name=checkout

NAME             ADDRESSTYPE   PORTS   ENDPOINTS               AGE
checkout-x7k2m   IPv4          8080    10.42.3.17,10.42.1.9    3h20m

세 개의 파드 중 두 개만 들어가 있습니다. 나머지 하나가 readiness에 실패한 파드입니다.

kubectl get pod -n payments -l app=checkout \
  -o custom-columns='POD:.metadata.name,IP:.status.podIP,READY:.status.containerStatuses[*].ready'

POD                         IP           READY
checkout-7d9f6c5b4d-2xk9p   10.42.3.17   true
checkout-7d9f6c5b4d-lm4vz   10.42.1.9    true
checkout-7d9f6c5b4d-q8w2n   10.42.2.31   false

잘못 쓰면 나는 세 가지 사고

증상원인진단해결
정상 파드가 주기적으로 재시작liveness가 과민, 특히 timeoutSeconds 기본값 1초Events의 Unhealthy와 KillingtimeoutSeconds 상향, 헬스 엔드포인트 경량화
배포 직후 502와 커넥션 리셋readiness 없음 또는 실제 준비 상태를 반영 못 함EndpointSlice에 파드가 언제 들어오는지readiness 추가, maxUnavailable 0
느린 기동 앱이 Ready에 도달 못 함startup 없이 liveness가 기동 중 컨테이너를 죽임Started와 Killing 이벤트의 간격startupProbe 추가
데이터베이스 장애 시 전 파드 동시 재시작liveness에 의존성 검사 포함헬스 핸들러가 무엇을 호출하는지liveness에서 의존성 제거
엔드포인트 0개로 서비스 전면 중단readiness에 모든 파드가 공유하는 의존성 포함엔드포인트 수 시계열부분 저하 허용, 의존성 분리
종료 중 요청 실패엔드포인트 전파 전에 SIGTERM 도착파드 종료 시각과 실패 시각 대조preStop 지연 삽입
종료 시 Exit Code 137유예 시간 내에 종료하지 못함Reason Error에 137드레인 시간 단축, 유예 기간 상향
kubelet CPU 상승exec 프로브 과다프로브 종류 집계httpGet 또는 grpc로 교체

사고 1 — 과민한 liveness가 만드는 자기 파괴

가장 위험한 조합입니다. 부하가 몰리면 애플리케이션 응답이 느려집니다. 프로브도 함께 느려집니다. 타임아웃이 나면 쿠버네티스가 컨테이너를 죽입니다. 남은 파드에 부하가 더 몰립니다. 그 파드들도 타임아웃이 납니다. 부하가 높을수록 더 많이 죽는 구조가 되어, 시스템이 스스로를 무너뜨립니다.

이 시나리오에서 liveness는 안정성 장치가 아니라 증폭기입니다. 진단은 재시작 시각과 트래픽 그래프를 겹쳐 보면 즉시 드러납니다. 피크 시간에만 재시작이 몰린다면 확정입니다.

사고 2 — readiness 없이 배포하기

readiness가 없으면 컨테이너 프로세스가 뜬 순간 파드는 Ready로 간주되고 곧바로 엔드포인트에 들어갑니다. 애플리케이션이 아직 커넥션 풀을 만들고 캐시를 채우는 중이어도 요청이 들어옵니다.

kubectl get events -n payments --field-selector reason=Started --sort-by=.lastTimestamp | tail -2

배포 직후 몇 초 동안만 5xx가 튀고 곧 정상으로 돌아온다면 거의 항상 이 문제입니다. 롤링 업데이트마다 반복되므로 하루에 여러 번 배포하는 팀에서는 상시 오류율로 굳어집니다.

사고 3 — 느린 기동을 liveness가 학살하는 경우

기동에 90초가 걸리는 애플리케이션에 initialDelaySeconds 30짜리 liveness를 걸면, 30초 시점부터 프로브가 실패하기 시작해 컨테이너가 죽고, 다시 뜨고, 또 죽습니다. 영원히 Ready에 도달하지 못합니다.

kubectl describe pod ledger-0 -n payments | grep -E "Started|Killing"

  Normal   Started    2m11s (x5 over 7m32s)  kubelet  Started container ledger
  Normal   Killing    91s (x4 over 6m52s)    kubelet  Container ledger failed liveness probe, will be restarted

Started와 Killing의 간격이 약 40초로 일정합니다. 기동 완료 전에 반복적으로 죽고 있다는 명백한 신호입니다.

흔한 오답: initialDelaySeconds를 200으로 올리는 것. 두 가지 대가가 따릅니다. 첫째, 진짜로 죽은 컨테이너도 첫 200초 동안은 감지되지 않습니다. 둘째, 그 값은 가장 느린 기동에 맞춰야 하므로 노드가 바쁜 날에는 여전히 부족합니다. 정답은 startupProbe입니다.

파라미터 계산 — 실제로 몇 초 뒤에 죽는가

다섯 개 파라미터의 기본값과 의미를 먼저 확정합니다.

  • initialDelaySeconds (기본 0) — 컨테이너 시작 후 첫 프로브까지의 대기
  • periodSeconds (기본 10) — 프로브 실행 주기
  • timeoutSeconds (기본 1) — 한 번의 프로브가 응답을 기다리는 시간
  • failureThreshold (기본 3) — 연속 실패 몇 번을 실패로 볼 것인가
  • successThreshold (기본 1) — 연속 성공 몇 번을 성공으로 볼 것인가. liveness와 startup에서는 1만 허용됩니다

timeoutSeconds 기본값 1초가 사고의 가장 흔한 씨앗입니다. GC 일시 정지, 순간적인 스레드 풀 포화, 디스크 지연 어느 것이든 1초를 넘기게 만들 수 있습니다. 헬스 엔드포인트가 아무리 가벼워도 프로세스 전체가 멈추면 응답이 늦습니다.

이제 계산합니다. 아래 설정에서 컨테이너가 t초에 완전히 멈췄다고 가정합니다.

livenessProbe:
  httpGet:
    path: /healthz
    port: 8080
  initialDelaySeconds: 0
  periodSeconds: 10
  timeoutSeconds: 3
  failureThreshold: 3
  • 최악의 경우 첫 실패 프로브는 t 직후가 아니라 다음 주기, 즉 최대 t 더하기 10초 시점에 시작합니다
  • 세 번 연속 실패해야 하므로 10초 간격으로 세 번, 마지막 실패는 t 더하기 30초 근처입니다
  • 각 프로브는 타임아웃 3초를 소진하므로 실제 판정은 그보다 몇 초 늦습니다

감지 지연의 최악값은 대략 periodSeconds 곱하기 failureThreshold 더하기 timeoutSeconds입니다. 위 설정은 약 33초입니다. 이 값을 SLO와 맞춰야 합니다. 30초 안에 복구되어야 하는 서비스라면 periodSeconds를 5로 줄여야 합니다.

startupProbe의 예산은 곱셈으로 정합니다.

startupProbe:
  httpGet:
    path: /healthz
    port: 8080
  periodSeconds: 5
  failureThreshold: 60
  timeoutSeconds: 3

5초 곱하기 60회로 최대 300초의 기동을 허용합니다. 이 300초 동안 liveness는 아예 실행되지 않습니다. 기동이 끝나면 startup은 영구히 비활성화되고 liveness가 짧은 주기로 넘겨받습니다. 느슨한 기동과 민첩한 감지를 동시에 얻는 유일한 방법이 이것입니다.

세 프로브를 함께 쓰는 완성된 형태는 이렇습니다.

containers:
  - name: checkout
    image: registry.example.com/checkout:1.14.2
    ports:
      - name: http
        containerPort: 8080
    startupProbe:
      httpGet:
        path: /healthz
        port: http
      periodSeconds: 5
      failureThreshold: 60
      timeoutSeconds: 3
    readinessProbe:
      httpGet:
        path: /readyz
        port: http
      periodSeconds: 5
      timeoutSeconds: 3
      failureThreshold: 2
      successThreshold: 1
    livenessProbe:
      httpGet:
        path: /healthz
        port: http
      periodSeconds: 10
      timeoutSeconds: 5
      failureThreshold: 6

세 가지 설계 의도가 들어 있습니다. readiness는 빠르게 빠지고 빠르게 돌아오도록 주기와 임계값을 작게 잡았습니다. liveness는 반대로 관대하게 잡아 60초 이상 확실히 죽어 있어야만 재시작합니다. 경로도 다릅니다. liveness는 /healthz, readiness는 /readyz로 분리했습니다.

liveness에 의존성을 넣으면 장애가 전면화됩니다

헬스체크를 처음 만들 때 가장 자연스러워 보이는 구현이 이것입니다.

GET /healthz
  → 데이터베이스에 SELECT 1
  → 레디스에 PING
  → 결제 게이트웨이에 HEAD 요청
  → 전부 성공하면 200

정직해 보이지만 프로덕션에서는 재난입니다. 데이터베이스가 3분간 흔들리면 무슨 일이 벌어지는지 순서대로 보겠습니다.

  • 모든 파드의 liveness가 동시에 실패합니다. 같은 의존성을 보고 있으므로 예외 없이 전부입니다
  • kubelet이 모든 컨테이너를 죽입니다
  • 파드들이 동시에 재시작하며 커넥션 풀을 새로 만들고, 캐시가 전부 비워집니다
  • 데이터베이스가 회복되는 순간 모든 파드가 동시에 연결과 쿼리를 쏟아붓습니다
  • 데이터베이스가 다시 넘어갑니다
  • 반복됩니다

3분짜리 데이터베이스 장애가 30분짜리 서비스 장애로 확대됩니다. 게다가 재시작 때문에 진행 중이던 요청, 인메모리 큐, 워밍업된 캐시가 전부 사라져 회복이 더 느려집니다.

liveness가 답해야 하는 질문은 오직 하나입니다. "이 프로세스를 재시작하면 상황이 나아지는가." 데이터베이스가 죽었을 때 프로세스를 재시작하면 나아지는 것이 없습니다. 그러므로 데이터베이스는 liveness에 들어가면 안 됩니다.

liveness에 넣어도 되는 것은 재시작으로 실제 해결되는 상태뿐입니다. 이벤트 루프 정지, 데드락, 복구 불가능한 내부 상태 손상 정도입니다.

GET /healthz
  → 프로세스가 요청을 받아 응답을 만들 수 있는가만 확인
  → 항상 200, 다만 이벤트 루프가 막혀 있으면 응답 자체가 나가지 않는다

이 단순한 핸들러가 대부분의 경우 최선입니다. 그리고 여기서 한 걸음 더 나아간 결론도 가능합니다. 재시작으로 고쳐질 실패 모드가 없다면 liveness 프로브를 아예 두지 않는 것이 더 안전합니다. 프로브가 없으면 컨테이너는 프로세스가 죽을 때만 재시작되고, 그건 대개 옳은 동작입니다.

readiness에는 어디까지 넣어도 되는가

readiness는 재시작을 유발하지 않으므로 의존성 검사를 넣을 여지가 있습니다. 기준은 이것입니다. 그 의존성 없이 이 파드가 처리할 수 있는 요청이 하나라도 있는가.

  • 모든 요청이 데이터베이스를 필요로 한다면 readiness에 넣는 것이 합리적입니다. 어차피 실패할 요청을 받지 않는 편이 낫습니다
  • 일부만 필요하다면 넣지 않아야 합니다. 정적 응답이나 캐시로 처리 가능한 요청까지 차단됩니다

그리고 넣기로 했다면 반드시 한 가지를 확인해야 합니다. 모든 파드가 동시에 NotReady가 되면 서비스의 엔드포인트가 0개가 되고, 클라이언트는 연결 자체를 거부당합니다. 이건 5xx보다 나쁜 경험일 수 있고, 회복 시점에 헬스 신호가 전혀 없어 진단도 어려워집니다.

kubectl get endpointslice -n payments -l kubernetes.io/service-name=checkout -o wide

NAME             ADDRESSTYPE   PORTS   ENDPOINTS   AGE
checkout-x7k2m   IPv4          8080    <unset>     3h44m

ENDPOINTS가 비어 있으면 이 상태입니다. 의존성 검사를 readiness에 넣을 때는 최소 한 대는 남기는 정책이나, 의존성 실패를 부분 저하로 처리하는 설계를 함께 검토해야 합니다.

프로브 종류 선택과 exec의 비용

네 가지 방식이 있고 비용과 신뢰도가 다릅니다.

httpGet — 기본 선택지입니다. kubelet이 파드 IP로 직접 요청합니다. 200부터 399까지를 성공으로 봅니다. 여기에 함정이 하나 있습니다. 인증 프록시가 헬스 경로를 로그인 페이지로 302 리다이렉트하면 프로브는 성공으로 판정됩니다. 애플리케이션이 죽어 있어도 성공합니다. 헬스 경로는 반드시 인증에서 제외하고 200을 직접 반환해야 합니다.

tcpSocket — 포트가 열려 있는지만 봅니다. 리스너 소켓은 커널이 유지하므로, 애플리케이션 스레드가 전부 멈춰도 성공합니다. 신뢰도가 가장 낮아 다른 선택지가 없을 때만 씁니다.

grpc — gRPC 서비스라면 표준 헬스 프로토콜을 kubelet이 직접 호출합니다. 예전처럼 헬스 체크 바이너리를 이미지에 넣어 exec로 부를 필요가 없습니다.

readinessProbe:
  grpc:
    port: 9000
    service: readiness
  periodSeconds: 5

exec — 가장 비쌉니다. 프로브가 돌 때마다 컨테이너 안에서 프로세스를 새로 만듭니다. 컨테이너 런타임이 태스크를 생성하고, 네임스페이스를 설정하고, 프로세스를 회수하는 비용이 매번 듭니다. 주기 10초짜리 exec 프로브를 파드 500개에 걸면 클러스터 전체에서 초당 50회의 프로세스 생성이 상시 발생합니다.

비용은 두 곳에서 나타납니다. 노드의 kubelet과 런타임 CPU가 올라가고, 프로브 프로세스가 컨테이너의 cgroup에 속하므로 애플리케이션의 CPU와 메모리 예산을 잠식합니다. 셸 스크립트를 부르는 exec 프로브라면 셸 자체의 메모리도 여기에 포함됩니다. 메모리 한계가 빠듯한 컨테이너에서는 프로브가 OOM의 방아쇠가 되기도 합니다.

현황을 세어 보면 대개 놀랍습니다.

kubectl get pods -A -o json | \
  jq -r '.items[].spec.containers[].livenessProbe | select(.!=null) | keys[]' | \
  grep -v Seconds | grep -v Threshold | sort | uniq -c | sort -rn

    412 httpGet
     87 exec
     23 tcpSocket

exec 87개 중 상당수는 컬을 호출하는 스크립트이고, 그건 httpGet으로 바꾸면 그대로 사라지는 비용입니다.

서비스 메시를 쓴다면 한 가지 더 있습니다. kubelet은 파드 IP로 직접 요청하므로 프로브는 사이드카의 mTLS 정책을 우회합니다. 메시가 엄격한 상호 TLS를 요구하면 프로브가 실패하므로, 대부분의 메시는 프로브 경로를 사이드카가 대신 받도록 재작성하는 옵션을 제공합니다. 프로브가 갑자기 전부 실패하기 시작했는데 애플리케이션은 멀쩡하다면 이쪽을 의심합니다.

종료 경로 — 엔드포인트 제거와 SIGTERM의 경쟁 조건

프로브를 아무리 잘 잡아도 롤링 업데이트 때마다 오류가 튄다면 원인은 종료 경로에 있습니다.

파드가 삭제되면 두 가지 일이 동시에, 서로 조율 없이 시작됩니다.

  • kubelet 경로 — preStop 훅을 실행하고, 컨테이너 주 프로세스에 SIGTERM을 보내고, 유예 시간이 끝나면 SIGKILL을 보냅니다
  • 엔드포인트 경로 — 컨트롤러가 EndpointSlice에서 파드를 제거하고, 그 변경이 모든 노드의 kube-proxy와 인그레스 컨트롤러, 서비스 메시 사이드카로 전파됩니다

문제는 두 번째가 느리다는 것입니다. 컨트롤러가 감지하고, EndpointSlice를 갱신하고, 모든 노드가 그것을 받아 규칙을 다시 쓰기까지 수백 밀리초에서 수 초가 걸립니다. 큰 클러스터나 외부 로드밸런서가 끼면 더 걸립니다.

그동안 컨테이너는 이미 SIGTERM을 받고 리스너를 닫았습니다. 아직 이 파드로 라우팅되는 요청은 커넥션 거부를 만납니다. 배포 중 소량의 502와 커넥션 리셋이 나오는 이유가 정확히 이것입니다.

해결은 SIGTERM을 일부러 늦추는 것입니다. preStop 훅은 SIGTERM보다 먼저 실행되고 완료될 때까지 SIGTERM이 보류되므로, 여기에 전파를 기다리는 지연을 넣습니다.

spec:
  terminationGracePeriodSeconds: 60
  containers:
    - name: checkout
      lifecycle:
        preStop:
          exec:
            command: ['sh', '-c', 'sleep 15']

최신 버전에서는 셸을 부르지 않는 전용 핸들러를 쓸 수 있습니다. 이미지에 셸이 없는 distroless 환경에서 특히 유용합니다.

lifecycle:
  preStop:
    sleep:
      seconds: 15

여기서 유예 시간 예산을 반드시 계산해야 합니다. terminationGracePeriodSeconds는 preStop 실행 시간까지 포함한 전체 예산입니다.

  • preStop 지연 15초
  • 진행 중 요청 드레인 최대 30초
  • 여유 15초
  • 합계 60초

이 계산을 안 하면 preStop이 끝나자마자 유예 시간이 소진되어 SIGKILL이 날아갑니다. 종료 코드 137에 Reason이 Error로 찍히는 상황이 그것이고, OOMKilled 편에서 다룬 메모리 문제와 종료 코드가 같아 오진하기 쉽습니다.

애플리케이션 쪽도 SIGTERM을 제대로 처리해야 합니다. 새 연결은 거부하되 진행 중인 요청은 끝내고, 백그라운드 워커는 현재 작업을 마무리한 뒤 종료합니다. SIGTERM 핸들러가 없는 애플리케이션은 기본 동작으로 즉시 죽으므로, preStop을 아무리 길게 잡아도 진행 중 요청이 잘립니다.

kubectl exec -it checkout-7d9f6c5b4d-2xk9p -n payments -- \
  sh -c 'grep SigCgt /proc/1/status'

SigCgt: 0000000000014002

이 비트마스크에 15번 비트가 서 있는지로 SIGTERM 핸들러 등록 여부를 확인할 수 있습니다. 셸을 통해 실행되는 컨테이너에서는 셸이 PID 1이 되어 시그널을 자식에게 전달하지 않는 고전적인 문제도 함께 확인해야 합니다.

표준 템플릿과 점검 목록

새 서비스를 만들 때 아래를 출발점으로 쓰고, 값만 서비스 특성에 맞게 조정합니다.

spec:
  terminationGracePeriodSeconds: 60
  containers:
    - name: app
      lifecycle:
        preStop:
          sleep:
            seconds: 15
      startupProbe:
        httpGet: { path: /healthz, port: http }
        periodSeconds: 5
        failureThreshold: 60
        timeoutSeconds: 3
      readinessProbe:
        httpGet: { path: /readyz, port: http }
        periodSeconds: 5
        timeoutSeconds: 3
        failureThreshold: 2
      livenessProbe:
        httpGet: { path: /healthz, port: http }
        periodSeconds: 10
        timeoutSeconds: 5
        failureThreshold: 6

배포 전에 다섯 가지를 확인합니다.

  • liveness 핸들러가 외부 의존성을 호출하지 않는가
  • timeoutSeconds가 1로 남아 있지 않은가
  • 기동 시간의 최악값이 startupProbe 예산 안에 들어가는가
  • terminationGracePeriodSeconds가 preStop 지연과 드레인 시간의 합보다 큰가
  • 애플리케이션이 SIGTERM을 받아 드레인을 시작하는가

재발 방지 지표로는 프로브 실패 자체를 추적하는 것이 유용합니다. 재시작으로 이어지기 전 단계에서 잡히기 때문입니다.

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: probe-alerts
  namespace: monitoring
spec:
  groups:
    - name: probes
      rules:
        - alert: LivenessProbeFailing
          expr: increase(prober_probe_total{probe_type="Liveness",result="failure"}[15m]) > 5
          for: 10m
          labels:
            severity: warning
          annotations:
            summary: "{{ $labels.namespace }}/{{ $labels.pod }} liveness failing without restart yet"
        - alert: ServiceHasNoEndpoints
          expr: kube_endpoint_address_available == 0
          for: 3m
          labels:
            severity: critical

마치며 — 프로브는 안전장치가 아니라 권한 위임입니다

프로브를 설정한다는 것은 쿠버네티스에게 트래픽을 끊을 권한과 프로세스를 죽일 권한을 넘기는 일입니다. 그 권한을 어떤 근거로 행사할지를 우리가 정합니다. 근거가 부실하면 아무 문제 없는 시스템이 자기 방어 장치에 의해 무너집니다.

기억할 한 문장은 이것입니다. liveness는 오직 "재시작하면 나아지는가"에만 답해야 하고, 그 답이 아니오인 모든 조건은 readiness로 보내거나 아예 프로브에서 빼야 합니다.