Probe 한눈에 보기
컨테이너 프로세스가 죽으면 kubelet 이 restartPolicy 에 따라 다시 띄우기 때문에, 프로세스가 죽는 장애는 Probe 가 없어도 복구된다.
Probe 가 필요한 건 프로세스는 살아 있지만 교착 상태에 있거나 아직 시작 중이라 요청을 처리하지 못하는 경우다.
kubelet 은 이런 상태를 프로세스 밖에서 알 수 없어서 주기적으로 컨테이너를 검사하고, 실패하면 Probe 마다 정해진 대응을 한다.
| Probe | 역할 | 실행 시점 | 실패했을 때 대응 | 잡으려는 상황 |
|---|---|---|---|---|
| Startup | 시작 단계를 넘겼는지 확인 | 시작부터 성공할 때까지 | 컨테이너 재시작 | 시작이 오래 걸리는 경우 |
| Liveness | 컨테이너 재시작 여부 확인 | startup 성공 뒤부터 계속 | 컨테이너 재시작 | 교착 상태 |
| Readiness | 트래픽 받을 준비 상태 확인 | startup 성공 뒤부터 계속 | 트래픽 대상에서 제외 | 시작 중, 과부하, 유지보수 |
startup Probe 가 있으면 이것이 성공할 때까지 liveness, readiness 는 실행되지 않고, startup Probe 가 없으면 컨테이너가 시작된 직후부터 실행된다.
readiness 는 실패해도 재시작하지 않고, 다시 성공하면 트래픽 대상으로 돌아온다.
검사 한 번의 결과는 Success, Failure, Unknown 중 하나인데, Unknown 은 검사 자체를 하지 못한 경우라, kubelet 은 아무 조치 없이 다음 검사를 기다린다.
Probe 를 설정하지 않으면 결과를 항상 Success 로 보기 때문에, readiness 가 없는 Pod 는 애플리케이션이 실제로 준비됐는지 확인하지 않은 채 Service 트래픽 대상이 된다.
설정 값
세 Probe 는 모두 같은 필드를 쓰는데, 기본값과 의미는 아래와 같다.
| 필드 | 기본값 | 의미 |
|---|---|---|
initialDelaySeconds | 0 | 컨테이너가 시작된 뒤 첫 검사까지 기다리는 시간, startup Probe 가 있으면 liveness, readiness 는 startup 성공 뒤부터 센다 |
periodSeconds | 10 | 검사 주기, Ready 가 아닌 동안 readiness 는 더 빨리 Ready 가 되도록 이 주기 밖에서도 실행될 수 있다 |
timeoutSeconds | 1 | 검사 한 번이 응답을 기다리는 시간 |
failureThreshold | 3 | 연속으로 몇 번 실패해야 실패로 판정할지 |
successThreshold | 1 | 실패한 뒤 연속으로 몇 번 성공해야 회복으로 볼지, liveness 와 startup 은 1만 쓸 수 있다 |
terminationGracePeriodSeconds | Pod 에 설정한 값 (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초 뒤에 강제로 종료한다.
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) |
| Probe | liveness /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 를 한 줄로 이어 붙인 것이다.
# 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 만 실패시켜보면 아래와 같다.
curl -X POST "http://<Pod IP>:8080/chaos/liveness?broken=true"
+ 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 ]
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 에 부하가 더 몰린다.
liveness 는 정말로 회복할 수 없는 상태일 때만 실패하도록 만드는 편이 좋고,
문제가 생기면 프로세스가 스스로 종료되는 애플리케이션이라면 liveness 를 두지 않는 것도 방법이다.
Readiness Probe
readiness 는 컨테이너가 트래픽을 받을 준비가 됐는지 정하는 Probe 다.
실패하면 Pod 의 Ready 조건이 false 가 되고, EndpointSlice 에서 이 Pod 가 ready=false 로 바뀌어 Service 트래픽 대상에서 빠지는데,
한 Pod 의 readiness 만 실패시켜보면 아래와 같다.
curl -X POST "http://<Pod IP>:8080/chaos/readiness?refuse=true"
+ 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 로 바뀌기 때문이다.
Startup Probe
startup 은 컨테이너가 시작 단계를 넘겼는지 확인하는 Probe 로, 성공할 때까지 liveness 와 readiness 가 실행되지 않도록 막아준다.
트래픽을 받을 준비가 됐는지는 readiness 가 따로 판단한다.
한 번 성공하고 나면 더 이상 실행되지 않고, 그때부터 liveness 와 readiness 가 검사를 시작한다.
startup 이 기다려주는 시간은 최대 failureThreshold × periodSeconds 인데, 이 시간 안에 한 번도 성공하지 못하면 liveness 처럼 컨테이너를 재시작한다.
시작이 40초 걸리는 애플리케이션을 liveness 만으로 띄워서 startup 이 없을 때 어떤 일이 일어나는지 확인해보았는데,
liveness 가 컨테이너가 뜨자마자 검사를 시작해서, 5초 주기로 3번 연속 실패하는 15초 안에 실패로 판정한다.
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 를 가장 오래 걸리는 시작 시간보다 길게 잡는 것이 해결책이다.
이렇게 하면 liveness 는 짧은 설정을 그대로 유지할 수 있어서, 시작한 뒤에 생기는 교착 상태는 여전히 빨리 잡아낸다.
검사 방식
Probe 하나에는 아래 네 가지 검사 방식 중 하나를 지정한다.
| 방식 | 성공 조건 | 알아둘 점 |
|---|---|---|
httpGet | 상태 코드 200 이상 400 미만 | 본문은 10KiB 까지만 읽고, 상태 코드로만 판정한다 |
tcpSocket | TCP 연결 성공 | 노드에서 연결하므로 host 에 Service 이름을 쓸 수 없다 |
exec | 명령 종료 코드 0 | 검사마다 프로세스를 새로 만들어 노드 CPU 를 쓴다 |
grpc | 응답이 SERVING | service 필드로 Probe 마다 응답을 나눌 수 있다 |
가장 많이 쓰는 HTTP Probe 는 아래 동작을 알아두면 좋다.
| 항목 | 동작 |
|---|---|
| 요청 헤더 | User-Agent: kube-probe/<kubelet 버전>, Accept: */* 를 보내고, httpHeaders 로 바꾸거나 빈 값으로 지울 수 있다 |
| HTTPS | scheme 을 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 쪽이 더 잘 맞는다.
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 환경 변수를 확인해서 쿠버네티스 배포 환경을 자동으로 감지한다.
쿠버네티스는 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 4.1.2 버전의 애플리케이션을 쿠버네티스 환경 변수가 없는 로컬에서 설정 없이 띄웠을 때도, 두 엔드포인트가 모두 200 을 응답했다.
생성되는 엔드포인트와 상태는 아래와 같다.
| 엔드포인트 | 포함하는 health indicator | 실패할 때 응답 |
|---|---|---|
/actuator/health/liveness | livenessState | 503, status DOWN |
/actuator/health/readiness | readinessState | 503, status OUT_OF_SERVICE |
시작, 종료 과정의 상태 변화
Spring Boot 는 시작하고 종료하는 과정에서 liveness, readiness 상태를 아래와 같이 바꾼다.
| 단계 | LivenessState | ReadinessState | HTTP 서버 |
|---|---|---|---|
| 시작 중 (빈 초기화) | BROKEN | REFUSING_TRAFFIC | 아직 안 뜸 (connection refused) |
| 컨텍스트 refresh 완료 (Runner 실행 중) | CORRECT | REFUSING_TRAFFIC | 200 (liveness) / 503 (readiness) |
| Runner 실행 완료 | CORRECT | ACCEPTING_TRAFFIC | 200 (모두 정상) |
| graceful shutdown 시작 | CORRECT | REFUSING_TRAFFIC | 새 연결 거부, 진행 중인 요청만 마무리 |
| 종료 완료 | 없음 | 없음 | 종료 |
캐시 워밍처럼 ApplicationRunner 에서 10초가 걸리는 작업을 두고 시작 과정의 엔드포인트를 호출해보면, 상태 변화가 그대로 드러난다.
# 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 레벨로 남기기 때문에 기본 설정에서는 보이지 않는데, 아래와 같이 로그 레벨을 켜면 확인할 수 있다.
logging:
level:
org.springframework.boot.availability: DEBUG
테스트 애플리케이션을 띄웠다가 SIGTERM 을 보냈을 때는 아래와 같이 남았는데, 보기 쉽게 날짜와 로거, 스레드 이름은 줄였다.
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 를 실패시켰다.
@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 설정은 보통 아래와 같이 잡을 수 있다.
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 를 두는 것을 고려하라.
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 복구 후 |
|---|---|---|
| liveness | Pod 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, 외부 캐시 같은 외부 시스템에 장애가 나면 모든 인스턴스를 재시작해서 연쇄 장애를 만들 수 있다.
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 에 넣고 외부 서비스가 내려가면 애플리케이션이 서비스에서 빠지는 것을 받아들이거나, 넣지 않고 호출하는 쪽의 서킷 브레이커처럼 더 위에서 장애를 처리할지 판단해야 한다.
정리하면 아래와 같다.
| 외부 시스템 | readiness 에 | 이유 |
|---|---|---|
| Pod 마다 따로 쓰는 시스템 | 넣어도 된다 | 장애가 난 Pod 만 빠지고, 나머지 Pod 가 트래픽을 받는다 |
| 서킷 브레이커, fallback 이 있어서 없어도 동작하는 시스템 | 넣지 않는다 | 장애 중에도 응답할 수 있는데 트래픽만 잃는다 |
| 공용 DB 처럼 모든 Pod 가 같이 쓰는 시스템 | 판단이 필요하다 | 넣으면 장애 때 모든 Pod 가 빠지고, 빼면 호출하는 쪽에서 장애를 처리해야 한다 |
즉, 외부 시스템은 liveness 에는 넣지 않고, readiness 에는 위 표처럼 Pod 마다 따로 쓰는지, 없어도 동작하는지를 보고 넣을지 정한다.
timeoutSeconds 1초는 느린 응답에도 재시작된다
timeoutSeconds 기본값은 1초라서, 애플리케이션이 멀쩡해도 health 응답이 1초를 넘기면 검사가 실패하고, 이 실패가 failureThreshold 만큼 이어지면 재시작된다.
응답에 1.5초가 걸리는 엔드포인트를 liveness 로 걸어보면 아래와 같이 시간 초과로 실패한다.
Liveness probe failed: Get "http://10.244.0.82:8080/slow?millis=1500": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
| timeoutSeconds | 45초 동안 재시작 횟수 |
|---|---|
| 1, 기본값 | 3번 |
| 3 | 0번 |
GC 멈춤이나 스레드 풀이 바쁜 순간처럼 health 응답이 늦어지는 일은 생각보다 흔한데, 이런 지연이 연속 검사에서 반복돼 liveness 가 실패하면 연쇄 장애로 이어질 수 있다.
그래서 liveness 는 readiness 보다 넉넉히 잡는 편이 연쇄 장애를 줄이는 데 유리하다.
readiness 가 있어도 배포 중 요청이 실패한다
Pod 3개에 요청을 하나 보내고 0.1초 쉬기를 1분 동안 반복하면서 kubectl rollout restart 를 해보면, readiness 를 설정했는데도 요청 몇 건이 실패한다.
Pod 가 종료될 때는 kubelet 이 preStop 훅을 실행하고 SIGTERM 을 보내는 일과, 컨트롤 플레인이 EndpointSlice 에 이 Pod 를 종료 중, ready=false 로 표시하는 일이 동시에 시작된다.
엔드포인트 변경이 각 노드의 kube-proxy 에 반영되기 전에 애플리케이션이 먼저 새 요청을 거부하면, 그 사이 들어온 요청이 실패한다.
이 틈은 readiness 와 상관없이 생기는데, 아래와 같이 preStop 에서 몇 초 기다리면 엔드포인트 변경이 퍼질 시간을 벌어서 이런 실패를 줄일 수 있다.
lifecycle:
preStop:
sleep:
seconds: 5
preStop 의 sleep 동작은 KEP-3960 으로 Kubernetes 1.29 에 들어와 1.30 부터 기본으로 켜졌고, 그 전에는 아래와 같이 컨테이너 안에서 sleep 명령을 exec 으로 실행했다.
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 을 받을 수 있다.
정리
| 상황 | 설정 기준 | 주의할 점 |
|---|---|---|
| 역할 구분 | liveness 는 재시작, readiness 는 트래픽 제외 | liveness 가 실패하는 동안에도 트래픽은 들어온다 |
| 시작이 느린 앱 | liveness 와 같은 검사로 startupProbe 를 두고, failureThreshold × periodSeconds 를 최대 시작 시간보다 길게 | initialDelaySeconds 를 늘리면 장애 감지도 늦어진다 |
| 응답이 가끔 느린 앱 | liveness 가 readiness 보다 늦게 실패로 판정되도록 주기와 실패 기준을 조정 | 짧게 잡으면 GC 멈춤에도 재시작 |
| DB 같은 외부 시스템 | liveness 에는 넣지 않고, readiness 는 Pod 끼리 같이 쓰는지 보고 판단 | liveness 에 넣으면 DB 장애 때 모든 Pod 가 재시작 |
| 롤링 업데이트 | preStop 대기와 graceful shutdown | 시간의 합이 terminationGracePeriodSeconds 보다 짧게 |