Kubernetes Probe 이해하기 - Liveness, Readiness, Startup

약 23분
#kubernetes#Spring
Probe는 관성적으로, 다른 프로젝트에서 복사해 쓰는 경우가 많은데, 잘못 설정한다면 DB 장애 하나가 전체 Pod 재시작으로 번지기도 한다.
쿠버네티스와 Spring Boot 공식 문서의 내용을 기준으로 각 Probe 를 정리하고, Probe 가 실패했을 때 실제로 어떤 일이 일어나는지 같이 알아보자.

Probe 한눈에 보기

컨테이너 프로세스가 죽으면 kubelet 이 restartPolicy 에 따라 다시 띄우기 때문에, 프로세스가 죽는 장애는 Probe 가 없어도 복구된다.
Probe 가 필요한 건 프로세스는 살아 있지만 교착 상태에 있거나 아직 시작 중이라 요청을 처리하지 못하는 경우다.
kubelet 은 이런 상태를 프로세스 밖에서 알 수 없어서 주기적으로 컨테이너를 검사하고, 실패하면 Probe 마다 정해진 대응을 한다.

Probe역할실행 시점실패했을 때 대응잡으려는 상황
Startup시작 단계를 넘겼는지 확인시작부터 성공할 때까지컨테이너 재시작시작이 오래 걸리는 경우
Liveness컨테이너 재시작 여부 확인startup 성공 뒤부터 계속컨테이너 재시작교착 상태
Readiness트래픽 받을 준비 상태 확인startup 성공 뒤부터 계속트래픽 대상에서 제외시작 중, 과부하, 유지보수
한 줄 요약
startup 은 시작이 끝날 때까지 기다려 준다.
liveness 는 멈춘 컨테이너를 재시작한다.
readiness 는 트래픽을 넣고 뺀다.

startup Probe 가 있으면 이것이 성공할 때까지 liveness, readiness 는 실행되지 않고, startup Probe 가 없으면 컨테이너가 시작된 직후부터 실행된다.
readiness 는 실패해도 재시작하지 않고, 다시 성공하면 트래픽 대상으로 돌아온다.
검사 한 번의 결과는 Success, Failure, Unknown 중 하나인데, Unknown 은 검사 자체를 하지 못한 경우라, kubelet 은 아무 조치 없이 다음 검사를 기다린다.
Probe 를 설정하지 않으면 결과를 항상 Success 로 보기 때문에, readiness 가 없는 Pod 는 애플리케이션이 실제로 준비됐는지 확인하지 않은 채 Service 트래픽 대상이 된다.


설정 값

세 Probe 는 모두 같은 필드를 쓰는데, 기본값과 의미는 아래와 같다.

필드기본값의미
initialDelaySeconds0컨테이너가 시작된 뒤 첫 검사까지 기다리는 시간,
startup Probe 가 있으면 liveness, readiness 는 startup 성공 뒤부터 센다
periodSeconds10검사 주기, Ready 가 아닌 동안 readiness 는 더 빨리 Ready 가 되도록 이 주기 밖에서도 실행될 수 있다
timeoutSeconds1검사 한 번이 응답을 기다리는 시간
failureThreshold3연속으로 몇 번 실패해야 실패로 판정할지
successThreshold1실패한 뒤 연속으로 몇 번 성공해야 회복으로 볼지, liveness 와 startup 은 1만 쓸 수 있다
terminationGracePeriodSecondsPod 에 설정한 값
(Pod 기본 값 30)
liveness, startup 실패로 컨테이너를 종료할 때 강제로 죽이기 전까지 기다리는 시간.
readiness 에는 지정할 수 없다

검사가 주기대로 바로 실패한다고 보면, 상태가 바뀐 뒤 첫 실패는 다음 검사에서 잡혀서, 실패 판정까지 (failureThreshold - 1) × periodSeconds 에서 failureThreshold × periodSeconds 사이가 걸린다.

terminationGracePeriodSeconds 는 Pod 에도 있고 Probe 에도 있는데, Probe 에 지정하지 않으면 Pod 의 값을 그대로 쓰고, Pod 에도 지정하지 않았다면 30초다.

Pod 의 값은 배포나 삭제로 정상 종료할 때 SIGTERM 을 보낸 뒤 SIGKILL 까지 기다리는 시간이라,
처리 중인 작업을 마무리하는 데 오래 걸리는 애플리케이션은 이 값을 3600초처럼 길게 잡기도 한다.
이 상태에서 liveness 가 실패하면 이미 응답하지 못하는 컨테이너를 재시작하는데도 최대 3600초를 기다리게 되는데,
아래와 같이 Probe 에 60초를 지정하면 정상 종료는 3600초를 그대로 기다리고 Probe 실패로 재시작할 때만 60초 뒤에 강제로 종료한다.

yaml
spec:
  terminationGracePeriodSeconds: 3600    # 배포, 삭제로 정상 종료할 때
  containers:
    - name: app
      livenessProbe:
        httpGet:
          path: /actuator/health/liveness
          port: 8080
        terminationGracePeriodSeconds: 60    # liveness 실패로 재시작할 때

테스트 환경

항목값
클러스터kind v0.32 (Kubernetes v1.36.1), 단일 노드
애플리케이션Spring Boot 4.1.2, Actuator, JDK 21 (eclipse-temurin:21-jre)
Probeliveness /actuator/health/liveness, readiness /actuator/health/readiness
Probe 설정periodSeconds 5, failureThreshold 3, timeoutSeconds 1, 실패 판정까지 10~15초

애플리케이션에는 liveness, readiness 상태를 일부러 바꾸는 엔드포인트와 시작을 지연시키는 설정을 넣었다.
각 테스트에서 Probe 별 결과에 나오는 상태 로그는 아래 두 명령을 1초마다 실행해서,
Pod 의 ready 여부와 재시작 횟수, EndpointSlice 에 ready 로 들어 있는 Pod IP 를 한 줄로 이어 붙인 것이다.

bash
# Pod 의 ready 여부와 재시작 횟수
kubectl get pods -l app=probes-app \
  -o custom-columns='NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount'

# EndpointSlice 에 ready 로 들어 있는 Pod IP
kubectl get endpointslices -l kubernetes.io/service-name=probes-app \
  -o jsonpath='{range .items[*].endpoints[?(@.conditions.ready==true)]}{.addresses[0]} {end}'

테스트 Service 에서 엔드포인트에서 빠진다고 쓴 것은, EndpointSlice 에서 ready=false 가 되어 위 목록에서 사라졌다는 뜻이다.


Liveness Probe

liveness 는 컨테이너를 언제 재시작할지 정하는 Probe 인데, 재시작 말고는 빠져나올 방법이 없는 상태를 잡는 것이 목적이다.
Pod 2개 중 한 Pod 의 liveness 만 실패시켜보면 아래와 같다.

bash
curl -X POST "http://<Pod IP>:8080/chaos/liveness?broken=true"
text
+  0s  n5s57 ready=true restarts=0 running | r4ffw ready=true restarts=0 running |  endpoints[10.244.0.77 10.244.0.78 ]
+ 17s  n5s57 ready=true restarts=0 running | r4ffw ready=true restarts=0 running |  endpoints[10.244.0.77 10.244.0.78 ]
+ 18s  n5s57 ready=false restarts=1 running | r4ffw ready=true restarts=0 running |  endpoints[10.244.0.78 ]
+ 24s  n5s57 ready=true restarts=1 running | r4ffw ready=true restarts=0 running |  endpoints[10.244.0.77 10.244.0.78 ]
text
2026-11-03T12:41:56Z   Unhealthy   Liveness probe failed: HTTP probe failed with statuscode: 503
2026-11-03T12:41:56Z   Killing     Container app failed liveness probe, will be restarted

상태를 바꾼 시각이 12:41:43 이라 이벤트 기준으로 약 13초 만에 실패로 판정되고, 18초쯤 컨테이너가 재시작됐는데, Pod 는 그대로 두고 컨테이너만 다시 띄우기 때문에 Pod 이름도 그대로다.
눈여겨볼 점은 재시작되기 전까지 약 17초 동안 이 Pod 가 여전히 엔드포인트에 남아서 트래픽을 받았다는 것인데, liveness 는 트래픽을 빼는 장치가 아니기 때문이다.
테스트에서는 LivenessState 만 BROKEN 으로 바꿨고, Spring Boot 의 readiness 엔드포인트는 readinessState 만 보기 때문에 그동안 readiness 는 계속 200 을 응답했다.
엔드포인트에서 빠진 것은 재시작 이후 readiness 가 다시 성공할 때까지의 약 6초뿐이었다.

liveness 는 잘못 설정했을 때 피해가 큰 Probe 라서, 쿠버네티스 문서에도 아래와 같이 따로 경고가 붙어 있다.

Incorrect implementation of liveness probes can lead to cascading failures. This results in restarting of container under high load; failed client requests as your application became less scalable; and increased workload on remaining pods due to some failed pods.

liveness probe 를 잘못 구현하면 연쇄 장애로 이어질 수 있다. 부하가 높을 때 컨테이너가 재시작되고, 처리할 수 있는 양이 줄어 클라이언트 요청이 실패하며, 일부 Pod 가 실패한 만큼 남은 Pod 에 부하가 더 몰린다.

Kubernetes 문서 - Liveness probe

liveness 는 정말로 회복할 수 없는 상태일 때만 실패하도록 만드는 편이 좋고,
문제가 생기면 프로세스가 스스로 종료되는 애플리케이션이라면 liveness 를 두지 않는 것도 방법이다.

liveness 는 프로세스는 살아 있지만 응답하지 못하는 상태를 잡아 재시작한다. 트래픽은 빼지 않는다.

Readiness Probe

readiness 는 컨테이너가 트래픽을 받을 준비가 됐는지 정하는 Probe 다.
실패하면 Pod 의 Ready 조건이 false 가 되고, EndpointSlice 에서 이 Pod 가 ready=false 로 바뀌어 Service 트래픽 대상에서 빠지는데,
한 Pod 의 readiness 만 실패시켜보면 아래와 같다.

bash
curl -X POST "http://<Pod IP>:8080/chaos/readiness?refuse=true"
text
+  0s  n5s57 ready=true restarts=0 running | r4ffw ready=true restarts=0 running |  endpoints[10.244.0.77 10.244.0.78 ]
+ 13s  n5s57 ready=false restarts=0 running | r4ffw ready=true restarts=0 running |  endpoints[10.244.0.78 ]
시점Service 로 요청 40번
실패 전두 Pod 가 나눠서 응답
실패 후40번 모두 다른 Pod 가 응답

재시작 없이 약 13초 뒤 엔드포인트에서만 빠졌고, 다시 성공하게 돌리자 5초 만에 엔드포인트로 돌아왔다.
successThreshold 기본값이 1 이라 다음 검사에서 한 번만 성공하면 Ready 로 돌아오는데, Ready 가 아닌 동안에는 readiness 가 주기와 상관없이 실행되기도 해서 periodSeconds 보다 빨리 돌아올 수 있다.
Pod 가 Ready 로 바뀐 뒤 EndpointSlice 와 각 노드의 트래픽 경로에 반영되는 시간은 이와 별개로 더 걸린다.

readiness 는 컨테이너가 살아 있는 동안 계속 실행되기 때문에,
시작할 때 캐시 워밍이나 파일 로딩이 끝날 때까지 기다리는 용도 외에도 실행 중 과부하나 유지보수로 잠시 트래픽에서 빠지는 용도로도 쓸 수 있다.
반대로 Pod 를 삭제할 때 요청을 정리하려는 목적이라면 readiness 가 꼭 필요하지는 않은데,
Pod 가 삭제되면 EndpointSlice 의 ready 조건이 readiness 와 상관없이 false 로 바뀌기 때문이다.

readiness 는 잠시 트래픽에서 빼고 다시 넣는다. 재시작은 하지 않는다.

Startup Probe

startup 은 컨테이너가 시작 단계를 넘겼는지 확인하는 Probe 로, 성공할 때까지 liveness 와 readiness 가 실행되지 않도록 막아준다.
트래픽을 받을 준비가 됐는지는 readiness 가 따로 판단한다.
한 번 성공하고 나면 더 이상 실행되지 않고, 그때부터 liveness 와 readiness 가 검사를 시작한다.
startup 이 기다려주는 시간은 최대 failureThreshold × periodSeconds 인데, 이 시간 안에 한 번도 성공하지 못하면 liveness 처럼 컨테이너를 재시작한다.

시작이 40초 걸리는 애플리케이션을 liveness 만으로 띄워서 startup 이 없을 때 어떤 일이 일어나는지 확인해보았는데,
liveness 가 컨테이너가 뜨자마자 검사를 시작해서, 5초 주기로 3번 연속 실패하는 15초 안에 실패로 판정한다.

text
Liveness probe failed: Get "http://10.244.0.79:8080/actuator/health/liveness": dial tcp 10.244.0.79:8080: connect: connection refused
Container app failed liveness probe, will be restarted
설정결과
liveness 만재시작을 반복하고 끝내 ready 가 되지 않음, 기록에서는 40초와 83초에 재시작, 롤링 업데이트가 멈춰서 기존 Pod 가 계속 트래픽을 처리
startupProbe 추가 (5초 × 12번, 최대 60초)재시작 없이 43초에 ready

응답이 503 이 아니라 connection refused 인 것은, 애플리케이션이 아직 HTTP 서버를 띄우지 못한 상태이기 때문이다.
startupProbe 를 5초 × 12번으로 두면 최대 60초까지 기다려주기 때문에, 43초에 시작을 마치고 재시작 없이 ready 가 됐다.

liveness 의 initialDelaySeconds 를 40초 넘게 잡아도 시작은 되지만, 시작 시간이 더 늘어나면 다시 재시작에 걸리고 그만큼 장애 감지도 늦어진다.
쿠버네티스 문서에서는 아래와 같이 liveness 와 같은 검사로 startup Probe 를 두라고 안내한다.

The solution is to set up a startup probe with the same command, HTTP or TCP check, with a failureThreshold * periodSeconds long enough to cover the worst case startup time.

liveness 와 같은 명령, HTTP 또는 TCP 검사로 startup probe 를 두고, failureThreshold × periodSeconds 를 가장 오래 걸리는 시작 시간보다 길게 잡는 것이 해결책이다.

Kubernetes 문서 - Protect slow starting containers with startup probes

이렇게 하면 liveness 는 짧은 설정을 그대로 유지할 수 있어서, 시작한 뒤에 생기는 교착 상태는 여전히 빨리 잡아낸다.

startup 은 성공할 때까지 liveness, readiness 를 미룬다.

검사 방식

Probe 하나에는 아래 네 가지 검사 방식 중 하나를 지정한다.

방식성공 조건알아둘 점
httpGet상태 코드 200 이상 400 미만본문은 10KiB 까지만 읽고, 상태 코드로만 판정한다
tcpSocketTCP 연결 성공노드에서 연결하므로 host 에 Service 이름을 쓸 수 없다
exec명령 종료 코드 0검사마다 프로세스를 새로 만들어 노드 CPU 를 쓴다
grpc응답이 SERVINGservice 필드로 Probe 마다 응답을 나눌 수 있다

가장 많이 쓰는 HTTP Probe 는 아래 동작을 알아두면 좋다.

항목동작
요청 헤더User-Agent: kube-probe/<kubelet 버전>, Accept: */* 를 보내고, httpHeaders 로 바꾸거나 빈 값으로 지울 수 있다
HTTPSscheme 을 HTTPS 로 두면 인증서 검증 없이 요청한다
리다이렉트같은 호스트로만 따라가고, 다른 호스트로 가거나 10번을 넘으면 성공으로 보고 ProbeWarning 이벤트를 남긴다
큰 응답 본문10KiB 를 넘으면 읽다가 연결을 끊어서, 애플리케이션 로그에 connection reset 이나 broken pipe 가 남을 수 있다

나머지 세 방식도 liveness 로 걸어서 실패시켜봤는데, 결과는 아래와 같다.

방식실패시킨 방법결과
TCP애플리케이션 프로세스를 SIGSTOP 으로 멈춤30초 동안 한 번도 실패하지 않음
Exec (test -f /tmp/alive)파일 삭제재시작, 이벤트에는 Liveness probe failed: 뒤가 비어 있음
gRPC (agnhost)헬스 상태를 NOT_SERVING 으로 바꿈재시작, service unhealthy (responded with "NOT_SERVING")

TCP 는 프로세스가 멈춰서 요청을 전혀 처리하지 못하는데도 통과했는데, TCP 연결은 운영체제 커널이 받아주기 때문에 애플리케이션이 멈춰도 연결 자체는 맺어진다.
애플리케이션이 실제로 응답하는지 봐야 하는 liveness 에는 TCP 보다 HTTP 나 gRPC 쪽이 더 잘 맞는다.

Exec Probe 는 명령이 아무것도 출력하지 않으면 실패 이벤트에 원인이 남지 않는다. 실패할 때 이유를 출력하는 스크립트를 쓰면 나중에 원인을 찾기 쉽다.

Spring Boot Actuator 와 연결하기

자동으로 생기는 엔드포인트

Spring Boot 는 애플리케이션의 liveness, readiness 상태를 직접 관리하고 있어서,
spring-boot-starter-actuator 의존성만 추가하면 엔드포인트가 준비되는데, 버전에 따라 자동으로 켜지는 조건이 다르다.

버전liveness, readiness 엔드포인트가 자동으로 켜지는 조건
Spring Boot 3.x쿠버네티스 환경에서 실행될 때만
Spring Boot 4.x실행 환경과 상관없이 항상

Spring Boot 3.x 는 쿠버네티스 환경임을 스스로 감지해서 엔드포인트를 켜는데, 감지하는 방법은 아래와 같다.

Spring Boot auto-detects Kubernetes deployment environments by checking the environment for "*_SERVICE_HOST" and "*_SERVICE_PORT" variables.

Spring Boot 는 *_SERVICE_HOST, *_SERVICE_PORT 환경 변수를 확인해서 쿠버네티스 배포 환경을 자동으로 감지한다.

Spring Boot 3.5 문서 - Kubernetes

쿠버네티스는 Pod 에 KUBERNETES_SERVICE_HOST 같은 환경 변수를 넣어주기 때문에,
3.x 도 쿠버네티스에 배포하면 management.endpoint.health.probes.enabled: true 를 따로 설정하지 않아도 된다.
로컬처럼 쿠버네티스가 아닌 환경에서 엔드포인트를 확인하려면 이 설정을 켜야 한다.

Spring Boot 4.x 부터는 실행 환경과 상관없이 probes 가 기본 활성화되는데,
Spring Boot 문서에서도 아래와 같이 쿠버네티스 조건이 빠지고 같은 설정으로 끌 수 있다고 바뀌었다.

These health groups are automatically enabled. You can disable them by using the management.endpoint.health.probes.enabled configuration property.

이 health 그룹은 자동으로 켜지고, management.endpoint.health.probes.enabled 설정으로 끌 수 있다.

Spring Boot 문서 - Kubernetes Probes

테스트에서는 Spring Boot 4.1.2 버전의 애플리케이션을 쿠버네티스 환경 변수가 없는 로컬에서 설정 없이 띄웠을 때도, 두 엔드포인트가 모두 200 을 응답했다.

생성되는 엔드포인트와 상태는 아래와 같다.

엔드포인트포함하는 health indicator실패할 때 응답
/actuator/health/livenesslivenessState503, status DOWN
/actuator/health/readinessreadinessState503, status OUT_OF_SERVICE
관리 포트(management.server.port)를 메인 포트와 분리해 운영 중이라면,
management.endpoint.health.probes.add-additional-paths: true 를 추가해 메인 서버 포트에도 /livez, /readyz 로 열어둘 수 있다.

시작, 종료 과정의 상태 변화

Spring Boot 는 시작하고 종료하는 과정에서 liveness, readiness 상태를 아래와 같이 바꾼다.

단계LivenessStateReadinessStateHTTP 서버
시작 중 (빈 초기화)BROKENREFUSING_TRAFFIC아직 안 뜸 (connection refused)
컨텍스트 refresh 완료 (Runner 실행 중)CORRECTREFUSING_TRAFFIC200 (liveness) / 503 (readiness)
Runner 실행 완료CORRECTACCEPTING_TRAFFIC200 (모두 정상)
graceful shutdown 시작CORRECTREFUSING_TRAFFIC새 연결 거부, 진행 중인 요청만 마무리
종료 완료없음없음종료

캐시 워밍처럼 ApplicationRunner 에서 10초가 걸리는 작업을 두고 시작 과정의 엔드포인트를 호출해보면, 상태 변화가 그대로 드러난다.

bash
# 1. Runner 실행 중 (포트는 열렸으나 초기화 진행 중)
$ curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/actuator/health/liveness
200
$ curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/actuator/health/readiness
503

# 2. Runner 실행 완료 (초기화 완료 후)
$ curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/actuator/health/readiness
200

빈 생성자나 @PostConstruct 에서 무거운 작업을 하면 HTTP 서버가 뜨지 않아 liveness 가 connection refused 로 실패하고, 이 상태가 failureThreshold 에 닿을 만큼 길어지면 컨테이너가 재시작된다.
ApplicationRunner 에 두면 liveness 는 200 으로 통과하고 readiness 만 503 을 유지한다. 시작 작업 중 트래픽을 받지 않으면서도 재시작 루프에 빠지지 않는 이유다.
종료할 때도 마찬가지인데, Pod 에 SIGTERM 이 들어오면 Spring Boot 는 graceful shutdown 을 시작하기 전에 ReadinessState.REFUSING_TRAFFIC 을 발행한다.

이 상태 변화는 ApplicationAvailabilityBean 이 DEBUG 레벨로 남기기 때문에 기본 설정에서는 보이지 않는데, 아래와 같이 로그 레벨을 켜면 확인할 수 있다.

yaml
logging:
  level:
    org.springframework.boot.availability: DEBUG

테스트 애플리케이션을 띄웠다가 SIGTERM 을 보냈을 때는 아래와 같이 남았는데, 보기 쉽게 날짜와 로거, 스레드 이름은 줄였다.

text
21:05:25.743  INFO main            ProbesApplication           : Started ProbesApplication in 1.359 seconds (process running for 1.641)
21:05:25.789 DEBUG main            ApplicationAvailabilityBean : Application availability state LivenessState changed to CORRECT
21:05:25.792 DEBUG main            ApplicationAvailabilityBean : Application availability state ReadinessState changed to ACCEPTING_TRAFFIC
21:05:26.788 DEBUG ShutdownHook    ApplicationAvailabilityBean : Application availability state ReadinessState changed from ACCEPTING_TRAFFIC to REFUSING_TRAFFIC
21:05:26.790  INFO ShutdownHook    GracefulShutdown            : Commencing graceful shutdown. Waiting for active requests to complete
21:05:30.273  INFO tomcat-shutdown GracefulShutdown            : Graceful shutdown complete

REFUSING_TRAFFIC 으로 바뀐 직후 graceful shutdown 이 시작되면, Tomcat 이 서버 소켓을 닫아 새 연결은 거부하고 진행 중인 요청만 spring.lifecycle.timeout-per-shutdown-phase 안에서 마무리한다.
위 로그에서도 진행 중이던 4초짜리 요청이 끝날 때까지 기다려서, 3.5초 뒤에 종료됐다.
그래서 종료되는 Pod 는 readiness 실패가 아니라 Pod 삭제에 따라 엔드포인트가 종료 중으로 표시되면서 Service 트래픽 대상에서 빠진다.

애플리케이션 코드에서 상태를 직접 바꾸려면 AvailabilityChangeEvent 를 발행하면 된다.
앞의 Liveness, Readiness Probe 를 테스트할 때도 아래와 같이 이벤트를 발행하는 테스트용 엔드포인트를 만들어두고, 이 엔드포인트를 호출해서 Probe 를 실패시켰다.

java
@PostMapping("/chaos/liveness")
public Map<String, String> liveness(@RequestParam boolean broken) {
	// BROKEN 이면 /actuator/health/liveness 가 503 을 응답한다
	LivenessState state = broken ? LivenessState.BROKEN : LivenessState.CORRECT;
	AvailabilityChangeEvent.publish(this.publisher, this, state);
	return Map.of("liveness", state.name());
}

@PostMapping("/chaos/readiness")
public Map<String, String> readiness(@RequestParam boolean refuse) {
	// REFUSING_TRAFFIC 이면 /actuator/health/readiness 가 503 을 응답한다
	ReadinessState state = refuse ? ReadinessState.REFUSING_TRAFFIC : ReadinessState.ACCEPTING_TRAFFIC;
	AvailabilityChangeEvent.publish(this.publisher, this, state);
	return Map.of("readiness", state.name());
}

이렇게 코드에서 상태를 바꾸면, 앞의 DEBUG 로그에도 changed from ACCEPTING_TRAFFIC to REFUSING_TRAFFIC: ...ChaosController 처럼 이벤트를 발행한 곳까지 같이 남는다.

컨테이너의 Probe 설정 예시

Spring Boot 애플리케이션이라면 컨테이너의 Probe 설정은 보통 아래와 같이 잡을 수 있다.

yaml
startupProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  periodSeconds: 5
  failureThreshold: 24    # 최대 120초까지 시작을 기다린다
livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  periodSeconds: 10
  timeoutSeconds: 3
  failureThreshold: 3
readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080
  periodSeconds: 5
  timeoutSeconds: 3
  failureThreshold: 3

Spring Boot 공식 문서에서는 아래와 같이 설명한다.

If an application takes longer to start than the configured liveness period, Kubernetes mentions the "startupProbe" as a possible solution. Generally speaking, the "startupProbe" is not necessarily needed here as the "readinessProbe" fails until all startup tasks are done. However, if your application takes a long time to start, consider configuring a "startupProbe" that uses the liveness HTTP probe to make sure that Kubernetes won't kill your application while it is in the process of starting.

애플리케이션 시작이 설정한 liveness 기간보다 오래 걸린다면, 쿠버네티스는 해결책으로 startupProbe 를 언급한다. 일반적으로는 모든 시작 작업이 끝날 때까지 readinessProbe 가 실패하기 때문에, 여기서 startupProbe 가 꼭 필요하지는 않다. 그래도 시작이 오래 걸린다면, 시작하는 동안 쿠버네티스가 애플리케이션을 죽이지 않도록 liveness HTTP 검사를 쓰는 startupProbe 를 두는 것을 고려하라.

Spring Boot 문서 - Kubernetes Probes

ApplicationRunner 에서 도는 작업은 readiness 가 트래픽을 막아주지만, @PostConstruct 나 Flyway 처럼 포트가 열리기 전에 도는 작업은 liveness 가 실패로 본다.
이런 작업이 liveness 의 검사 한도를 넘기면 시작 중인 컨테이너가 재시작되기 때문에, 시작이 무거운 애플리케이션이라면 liveness 엔드포인트로 startup Probe 를 두고, failureThreshold 를 넉넉하게 잡아주는 편이 좋다.


잘못된 설정으로 생기는 장애

DB 체크를 liveness 에 넣으면 전체가 재시작된다

DB 체크를 liveness 에 넣으면 DB 장애가 실패 판정 시간보다 길게 이어질 때 모든 Pod 가 재시작된다.
Pod 3개를 띄우고 DB 를 내렸다가 다시 올리면서, DB 체크를 liveness 에 넣었을 때와 readiness 에 넣었을 때를 비교해보면 아래와 같다.

DB 체크 위치DB 가 내려가 있는 동안DB 복구 후
livenessPod 3개가 13~16초, 28~30초, 43~46초에 차례로 재시작했고, 60초쯤에는 재시작 대기 중인 CrashLoopBackOff 상태로 엔드포인트 0개CrashLoopBackOff 의 재시작 대기 때문에 약 21초 걸려서 회복
readiness재시작 없음, 약 15초 뒤 3개 모두 엔드포인트에서 제외5초 안에 3개 모두 회복

DB 장애는 애플리케이션을 재시작한다고 고쳐지지 않는다.
그렇기 때문에 liveness 에 넣으면 DB 장애가 애플리케이션 전체 재시작으로 번지고, 재시작 대기 시간이 쌓여서 DB 가 돌아온 뒤에도 회복이 늦어진다.
Spring Boot 가 liveness, readiness 그룹에 외부 시스템 체크를 기본으로 넣지 않는 것도 이 때문인데, Spring Boot 문서에서는 아래와 같이 설명한다.

The "liveness" probe should not depend on health checks for external systems. If the liveness state of an application is broken, Kubernetes tries to solve that problem by restarting the application instance. This means that if an external system (such as a database, a Web API, or an external cache) fails, Kubernetes might restart all application instances and create cascading failures.

liveness probe 는 외부 시스템의 health check 에 의존하면 안 된다. 애플리케이션의 liveness 상태가 깨지면 쿠버네티스는 애플리케이션 인스턴스를 재시작해서 문제를 해결하려 하기 때문에, 데이터베이스, Web API, 외부 캐시 같은 외부 시스템에 장애가 나면 모든 인스턴스를 재시작해서 연쇄 장애를 만들 수 있다.

Spring Boot 문서 - Checking External State With Kubernetes Probes

readiness 에 넣을 때도 따져볼 것이 있는데, 모든 Pod 가 같은 DB 를 보고 있다면 결국 전부 엔드포인트에서 빠지기 때문이다. 이렇게 되면 Service 뒤에 응답할 Pod 가 하나도 없는데, Spring Boot 문서에 따르면 ClusterIP, NodePort Service 는 이때 503 같은 HTTP 응답 없이 연결 단계에서 실패한다. readiness 에 외부 시스템을 넣을지는 같은 문서에서 아래와 같이 판단하라고 안내한다.

Unfortunately, an external system that is shared by all application instances is common, and you have to make a judgement call: Include it in the readiness probe and expect that the application is taken out of service when the external service is down or leave it out and deal with failures higher up the stack, perhaps by using a circuit breaker in the caller.

아쉽게도 모든 인스턴스가 같이 쓰는 외부 시스템이 흔한데, 이때는 readiness probe 에 넣고 외부 서비스가 내려가면 애플리케이션이 서비스에서 빠지는 것을 받아들이거나, 넣지 않고 호출하는 쪽의 서킷 브레이커처럼 더 위에서 장애를 처리할지 판단해야 한다.

Spring Boot 문서 - Checking External State With Kubernetes Probes

정리하면 아래와 같다.

외부 시스템readiness 에이유
Pod 마다 따로 쓰는 시스템넣어도 된다장애가 난 Pod 만 빠지고, 나머지 Pod 가 트래픽을 받는다
서킷 브레이커, fallback 이 있어서 없어도 동작하는 시스템넣지 않는다장애 중에도 응답할 수 있는데 트래픽만 잃는다
공용 DB 처럼 모든 Pod 가 같이 쓰는 시스템판단이 필요하다넣으면 장애 때 모든 Pod 가 빠지고, 빼면 호출하는 쪽에서 장애를 처리해야 한다

즉, 외부 시스템은 liveness 에는 넣지 않고, readiness 에는 위 표처럼 Pod 마다 따로 쓰는지, 없어도 동작하는지를 보고 넣을지 정한다.

timeoutSeconds 1초는 느린 응답에도 재시작된다

timeoutSeconds 기본값은 1초라서, 애플리케이션이 멀쩡해도 health 응답이 1초를 넘기면 검사가 실패하고, 이 실패가 failureThreshold 만큼 이어지면 재시작된다.
응답에 1.5초가 걸리는 엔드포인트를 liveness 로 걸어보면 아래와 같이 시간 초과로 실패한다.

text
Liveness probe failed: Get "http://10.244.0.82:8080/slow?millis=1500": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
timeoutSeconds45초 동안 재시작 횟수
1, 기본값3번
30번

GC 멈춤이나 스레드 풀이 바쁜 순간처럼 health 응답이 늦어지는 일은 생각보다 흔한데, 이런 지연이 연속 검사에서 반복돼 liveness 가 실패하면 연쇄 장애로 이어질 수 있다.
그래서 liveness 는 readiness 보다 넉넉히 잡는 편이 연쇄 장애를 줄이는 데 유리하다.

liveness 와 readiness 의 여유
liveness 의 periodSeconds × failureThreshold > readiness 의 periodSeconds × failureThreshold
응답이 느려져 두 검사가 함께 실패할 때, readiness 를 더 자주 검사하고 liveness 에 여유를 두면 readiness 가 먼저 트래픽을 뺄 가능성이 높다.

readiness 가 있어도 배포 중 요청이 실패한다

Pod 3개에 요청을 하나 보내고 0.1초 쉬기를 1분 동안 반복하면서 kubectl rollout restart 를 해보면, readiness 를 설정했는데도 요청 몇 건이 실패한다.

Pod 가 종료될 때는 kubelet 이 preStop 훅을 실행하고 SIGTERM 을 보내는 일과, 컨트롤 플레인이 EndpointSlice 에 이 Pod 를 종료 중, ready=false 로 표시하는 일이 동시에 시작된다.
엔드포인트 변경이 각 노드의 kube-proxy 에 반영되기 전에 애플리케이션이 먼저 새 요청을 거부하면, 그 사이 들어온 요청이 실패한다.
이 틈은 readiness 와 상관없이 생기는데, 아래와 같이 preStop 에서 몇 초 기다리면 엔드포인트 변경이 퍼질 시간을 벌어서 이런 실패를 줄일 수 있다.

yaml
lifecycle:
  preStop:
    sleep:
      seconds: 5

preStop 의 sleep 동작은 KEP-3960 으로 Kubernetes 1.29 에 들어와 1.30 부터 기본으로 켜졌고, 그 전에는 아래와 같이 컨테이너 안에서 sleep 명령을 exec 으로 실행했다.

yaml
lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 5"]

같은 테스트를 3번씩 반복했을 때 preStop 대기를 넣으면 실패가 거의 사라졌다.

설정실패 건수, 3번 반복
preStop 없음3건, 3건, 5건
preStop sleep 5초1건, 0건, 0건

terminationGracePeriodSeconds 는 preStop 실행 전부터 세기 때문에, preStop 대기와 Spring Boot graceful shutdown 시간(spring.lifecycle.timeout-per-shutdown-phase)을 합친 값이 이보다 길면 처리 중인 요청을 마무리하기 전에 SIGKILL 을 받을 수 있다.

종료 시간 계산
preStop 대기 + spring.lifecycle.timeout-per-shutdown-phase < terminationGracePeriodSeconds

정리

요약
✔ liveness 는 컨테이너를 재시작하고, readiness 는 재시작 없이 트래픽만 뺀다
✔ liveness 가 실패하는 동안에도 Pod 는 트래픽을 받으므로, 요청을 막는 것은 readiness 의 역할이다
✔ 시작이 오래 걸리면 initialDelaySeconds 를 늘리기보다 liveness 와 같은 엔드포인트로 startupProbe 를 두는 편이 낫다
✔ TCP Probe 는 애플리케이션이 멈춰도 포트만 열려 있으면 통과하므로 liveness 에는 HTTP 나 gRPC 가 더 잘 맞는다
✔ Spring Boot 는 Actuator 만 추가하면 liveness, readiness 엔드포인트를 만들어주고, 외부 의존성은 liveness 에 넣지 않는 것을 권장한다
✔ liveness 는 readiness 보다 여유 있게 잡고, 롤링 업데이트의 요청 실패는 preStop 대기와 graceful shutdown 으로 줄일 수 있다
상황설정 기준주의할 점
역할 구분liveness 는 재시작, readiness 는 트래픽 제외liveness 가 실패하는 동안에도 트래픽은 들어온다
시작이 느린 앱liveness 와 같은 검사로 startupProbe 를 두고,
failureThreshold × periodSeconds 를 최대 시작 시간보다 길게
initialDelaySeconds 를 늘리면 장애 감지도 늦어진다
응답이 가끔 느린 앱liveness 가 readiness 보다 늦게 실패로 판정되도록 주기와 실패 기준을 조정짧게 잡으면 GC 멈춤에도 재시작
DB 같은 외부 시스템liveness 에는 넣지 않고, readiness 는 Pod 끼리 같이 쓰는지 보고 판단liveness 에 넣으면 DB 장애 때 모든 Pod 가 재시작
롤링 업데이트preStop 대기와 graceful shutdown시간의 합이 terminationGracePeriodSeconds 보다 짧게

📚 Reference