Skip to Content

20장. 살아 있음과 받을 수 있음은 다르다 — liveness·readiness·startup

Pod는 Running이고 직접 요청하면 응답이 오는데, Service로 보내면 거부당했습니다. 15장에서 Docker가 기록만 해두던 그 판정으로, 여기서는 트래픽을 끊습니다.

{/* 삽화 자리: 가게 두 곳이 나란히 있다. 왼쪽 가게는 문이 열려 있고 안에서 사람이 일하고 있는데, 문 앞 바닥에 그어져 있던 길이 그 문을 비껴 지나가 오른쪽 가게로만 이어진다. 길을 옮긴 건 문 옆에 선 검사원이다. 왼쪽 가게 주인은 자기 앞으로 아무도 안 오는 걸 모른 채 계속 일한다. 프롬프트: docs/book/illustration-prompts-container.md */}


배경

19장 끝에서 숫자 하나가 남았습니다. Pod가 뜬 직후 여덟 번의 요청이 연속으로 거절당했죠.

아무도 안 물어봤기 때문이었습니다. 쿠버네티스는 컨테이너 프로세스가 떴다는 것만 알고, 그걸로 “받을 수 있다”고 판정했어요.

15장에서 같은 문제를 Docker에서 다뤘습니다. HEALTHCHECK를 붙여 판정을 따로 받았죠. 그때 두 가지를 알았습니다.

하나, unhealthy는 앱의 상태가 아니라 검사의 결과라는 것.

둘, Docker Engine은 그 판정을 보고 아무것도 안 한다는 것. --restart=always를 걸고 40초를 둬도 재시작 횟수가 0이었어요.

쿠버네티스는 그 HEALTHCHECK를 아예 안 봅니다. 검사를 Pod 쪽에 다시 적게 하고 자기가 돌려요.

그럼 그 판정으로는 무엇을 할까요.

스토리

먼저 19장의 그 간격을 다시 쟀습니다.

=== 1. 프로브가 없으면 (19장에서 본 그 상태) === Pod Ready : 시작 +0.0초 Tomcat 요청 접수 : 시작 +2.5초

✅ 실측 (kind 0.33.0 / Kubernetes 1.37.0 / Docker Engine 29.8.0 / Docker Desktop 4.91.0 / Apple M1 / macOS 26.6, 2026-09. 전체 소스: docs/book/code/container/ch20/)

컨테이너가 시작하는 그 순간 Ready입니다. 앱은 2.5초 뒤에야 요청을 받고요.

이제 검사를 붙여봤습니다. 열 줄도 안 됩니다.

readinessProbe: httpGet: path: /actuator/health port: 8080 periodSeconds: 1
=== 2. readinessProbe 를 붙이면 === Pod Ready : 시작 +2.0초 Tomcat 요청 접수 : 시작 +2.2초

✅ 실측 (같은 환경, 같은 소스)

둘이 붙었습니다. 간격이 사라졌어요.

kubelet이 1초마다 /actuator/health를 두드리고, 처음 성공한 뒤에야 Ready로 올립니다. 15장에서 쓴 그 엔드포인트고, 판정 방식도 같습니다.

핵심

실패하면 트래픽을 끊습니다

기동만 봐주는 거라면 readinessProbe에 initialDelaySeconds(첫 검사까지 기다릴 초)를 넉넉히 주는 걸로도 됩니다. 검사를 붙이는 진짜 이유는 뜨고 난 뒤에 있어요.

15장에서 쓴 수법을 그대로 가져왔습니다. 5초 걸리는 /slow를 2초 타임아웃으로 두드리게 한 거죠. 앱은 멀쩡한데 검사만 실패합니다.

=== 3. readiness 가 실패하면 === web-87f67dff8-6n5sw Running false 0 web-87f67dff8-zfrhl Running false 0 (아래 두 열은 ready, serving) 엔드포인트 [10.244.2.30],[10.244.1.40] false,false false,false 그 Pod 에 직접 요청: hello from pid 1 Service 로 요청 : wget: can't connect to remote host (10.96.157.122): Connection refused

✅ 실측 (같은 환경, 같은 소스)

네 가지가 동시에 참입니다.

Pod는 Running입니다. 재시작 횟수도 0이고요. 아무도 안 죽였어요.

READY는 false입니다. EndpointSlice에도 ready: false로 들어가 있고요.

그 Pod에 직접 요청하면 hello from pid 1이 옵니다. 앱은 멀쩡합니다.

그런데 Service로 보내면 거부됩니다.

15장과 갈리는 자리가 여기입니다. 거기서는 Health=unhealthy인 컨테이너가 계속 트래픽을 받았어요. Docker Engine은 상태만 적어두고 아무것도 안 했으니까요. 여기서는 판정이 곧바로 경로를 바꿉니다.

규칙 자체가 사라집니다

19장에서 본 iptables 체인이 어떻게 됐는지 봤습니다.

=== 3-2. 규칙은 어떻게 바뀌었나 (19장의 그 체인) === nat 에서 이 ClusterIP 를 언급하는 규칙 : 0건 -A KUBE-SERVICES -d 10.96.157.122/32 -p tcp -m comment --comment "ch20/web has no endpoints" ... -j REJECT --reject-with icmp-port-unreachable

✅ 실측 (같은 환경, 같은 소스)

19장에서 본 KUBE-SVC 분기 규칙이 통째로 없어졌습니다. 보낼 곳이 하나도 없으니까요.

대신 거절 규칙이 생겼습니다. 주석에 이유가 그대로 적혀 있어요. has no endpoints.

이름은 같은 KUBE-SERVICES지만 nat이 아니라 filter 테이블입니다. 그래서 타임아웃이 아니라 즉시 거절이 나온 거예요. 기다리게 하지 않고 바로 알려주는 거죠.

하나만 실패하면 다릅니다

방금 건 둘 다 실패한 경우입니다. 하나만 실패하면 어떻게 될까요.

=== 3-3. 하나만 실패하면 (전멸이 아니라 부분 실패) === 둘 중 web-86c6f5d65b-qjh4q 에서만 검사 파일을 지운다 ready 조건: false,true nat 에서 이 ClusterIP 를 언급하는 규칙: 2건 KUBE-SVC 안의 분기 수 : 1개 filter 의 REJECT 규칙 : 0건

✅ 실측 (같은 환경, 같은 소스)

체인은 그대로 있습니다. 실패한 Pod로 가던 분기 하나만 빠졌어요. REJECT 규칙도 안 생겼고요.

19장에서 본 확률이 재계산되고 남은 Pod가 그 몫을 받습니다. 롤링 업데이트 중에 실제로 일어나는 게 이쪽입니다.

liveness는 다른 일을 합니다

쿠버네티스는 이 검사를 프로브라고 부릅니다. 셋이에요. 이름이 비슷해서 헷갈리는데 하는 일이 다릅니다.

readiness가 실패하면 트래픽을 끊습니다. 방금 본 거죠.

liveness가 실패하면 죽입니다.

같은 수법을 liveness에 걸어봤습니다. 5초짜리 경로를 2초 타임아웃으로요.

=== 4. liveness 를 무거운 경로에 걸면 (앱은 멀쩡한데) === 20초: Running 재시작=1 40초: Running 재시작=2 60초: Running 재시작=3 80초: CrashLoopBackOff 재시작=3 마지막 종료: Error exit=143 Liveness probe failed: Get "http://10.244.2.31:8080/slow": context deadline exceeded Container app failed liveness probe, will be restarted

✅ 실측 (같은 환경, 같은 소스)

앱은 내내 멀쩡했습니다. 그런데 20초마다 죽었어요.

exit=143을 보세요. 12장에서 본 그 숫자입니다. kubelet이 SIGTERM을 보내고, JVM이 훅을 돌리고 스스로 143을 골라 나간 거예요. 18장에서 본 제자리 재시작이 이렇게 반복됩니다.

SIGTERM을 무시하는 앱이면 어떻게 될까요. kubelet은 정해진 시간만큼 기다렸다가 SIGKILL을 보냅니다. 여는 글에서 본 그 유예예요. 그 시간을 누가 정하는지는 21장에서 봅니다.

그리고 CrashLoopBackOff. 17장에서 본 백오프가 여기 있습니다. 계속 죽으니 다시 띄우는 간격을 점점 벌리는 거죠. 상태 이름에 그게 그대로 들어가 있습니다.

15장에서 “unhealthy는 앱의 상태가 아니라 검사의 결과”라고 했죠. Docker에서는 그게 기록으로 끝났습니다. 쿠버네티스에서는 검사가 틀리면 멀쩡한 앱이 죽습니다. 같은 문장의 값이 달라져요.

그래서 liveness는 조심해서 겁니다

readiness는 틀려도 회복됩니다. 트래픽이 잠깐 안 갈 뿐이고, 검사가 다시 성공하면 돌아와요.

liveness는 안 그렇습니다. 틀리면 앱을 죽이고, 죽은 앱은 다시 떠서 또 죽습니다.

프로브실패하면검사를 잘못 걸었을 때
readiness트래픽을 끊는다트래픽만 잠깐 끊기고 회복된다
liveness컨테이너를 죽인다멀쩡한 앱이 죽는 루프에 빠진다
startup컨테이너를 죽인다기동을 못 하고 죽는 루프에 빠진다

그래서 조언이 대체로 이렇습니다. readiness는 넉넉히 걸고, liveness는 없거나 아주 단순하게. 방금 본 게 그 근거예요 — 멀쩡한 앱이 20초마다 죽었습니다.

그리고 예제 yaml마다 periodSeconds: 1을 적어뒀는데, 그건 실습을 빨리 보려고 넣은 값입니다. 기본값은 이렇습니다.

필드기본값
periodSeconds10초
timeoutSeconds1초
failureThreshold3회
successThreshold1회 (liveness·startup은 1 말고는 못 쓴다)

timeoutSeconds 기본값이 1초라는 게 특히 중요합니다. /actuator/health가 느린 앱이면 기본값 그대로 두는 것만으로 방금 본 그 함정에 걸려요.

liveness에 DB 연결 확인 같은 걸 넣으면 특히 위험합니다. DB가 잠깐 흔들리면 앱 전체가 재시작 루프에 들어가거든요. 그건 readiness가 할 일입니다. DB가 없으면 요청을 못 받는 건 맞지만, 앱을 죽인다고 DB가 돌아오지는 않으니까요.

liveness가 잡아야 하는 건 “스스로는 못 빠져나오는 상태”입니다. 데드락에 걸려 아무 요청도 처리 못 하는 경우요. 그런 건 재시작 말고 방법이 없습니다.

다만 readiness도 공짜는 아닙니다. 모든 Pod가 같은 DB를 보니까, DB가 흔들리면 전부 동시에 ready: false가 돼요. 그러면 앞에서 본 has no endpoints REJECT가 서비스 전체에 걸립니다. 한 대만 빠지는 게 아니라 전부 빠지는 거죠. 공유 의존성은 어느 프로브에도 안 넣는 편이 안전합니다.

startup은 기동만 봐줍니다

문제가 하나 더 있습니다. 기동이 느린 앱이요.

liveness를 걸어두면 기동 중에도 검사가 돕니다. 앱이 뜨기 전에 검사가 실패하면 죽여버려요.

일부러 만들어봤습니다. 검사 주기 1초, 한 번만 실패해도 죽이게요. 앱은 2초 넘게 걸리니 절대 못 뜹니다.

=== 5. 기동이 검사보다 느리면, 그리고 startupProbe 를 붙이면 === liveness-tooearly → 45초 동안 재시작 4회, CrashLoopBackOff 8번 관측 startup → 45초 동안 재시작 0회, CrashLoopBackOff 0번 관측

✅ 실측 (같은 환경, 같은 소스. STATUS는 백오프 주기마다 CrashLoopBackOff와 Running을 오가서, 한 번 찍으면 어느 쪽이든 나옵니다. 그래서 45초 동안 5초마다 세었어요)

첫 줄은 영원히 못 뜹니다. 두 줄의 차이는 startupProbe 하나뿐이고요.

startupProbe: httpGet: { path: /actuator/health, port: 8080 } periodSeconds: 1 failureThreshold: 30 # 기동에 최대 30초까지 봐준다

startupProbe가 붙어 있으면 그게 성공할 때까지 liveness와 readiness는 아예 안 돕니다. 기동 구간을 따로 떼어낸 거예요.

initialDelaySeconds로도 비슷하게 할 수 있지만 차이가 있습니다. 그건 정해진 시간을 무조건 기다립니다. 5초로 잡아두면 1초에 떠도 5초를 기다려요. startupProbe는 뜨는 즉시 다음으로 넘어가고, 안 뜨면 최대 30초까지 봐줍니다.

기동 시간이 들쭉날쭉한 앱일수록 차이가 큽니다.

그 30초를 넘기면요. startup도 실패로 보고 컨테이너를 죽입니다. liveness와 똑같이요. 그래서 failureThreshold × periodSeconds는 최악의 기동 시간보다 넉넉해야 합니다.

그래서 어떻게 잡나

상황하면 좋은 것
배포 직후 잠깐 5xxreadinessProbe를 붙이기. 이게 그 문제의 답이다
readiness 대상앱이 실제로 요청을 받을 수 있는지 보는 경로. 스프링이면 /actuator/health/readiness (기본 그룹엔 DB가 없다)
liveness 대상가볍고, 앱이 스스로 못 빠져나오는 상태만 걸러내는 것. 없어도 된다
liveness에 DB 확인넣지 않기. DB가 흔들리면 앱이 재시작 루프에 들어간다
기동이 느린 앱startupProbe. initialDelaySeconds보다 낫다
검사 타임아웃정상 응답 시간의 2~3배. 기본값 1초를 그대로 두면 멀쩡한 앱이 죽는다
검사 방식httpGet 말고 exec·tcpSocket도 있다. 15장의 HEALTHCHECK CMD가 사실상 exec이다
프로브를 걸었는데 계속 재시작liveness를 의심. kubectl describe pod의 이벤트에 이유가 적힌다

15장에서 심어둔 이야기를 여기서 갚습니다. 그때 스프링에 management.endpoint.health.probes.enabled=true를 켜면 두 경로가 생긴다고 했죠.

쿠버네티스 위에서는 켤 필요가 없습니다.

=== 6. 스프링의 두 경로는 언제 생기나 (15장에서 심어둔 것) === 앱 설정에 probes.enabled 를 쓴 적이 있나: 0건 쿠버네티스 위: /actuator/health → {"status":"UP","groups":["liveness","readiness"]} /actuator/health/readiness → {"status":"UP"} docker run 으로 띄운 같은 이미지: /actuator/health → {"status":"UP"} /actuator/health/readiness → HTTP 404

✅ 실측 (같은 환경, 같은 소스)

같은 이미지입니다. 설정도 안 바꿨고요. 그런데 쿠버네티스 위에서는 groups에 둘이 붙어 있고 /actuator/health/readiness가 200을 돌려줍니다. docker run으로 띄우면 404고요.

스프링 부트가 환경 변수를 보고 “여기 쿠버네티스구나” 판단해서 알아서 켜기 때문입니다. 여는 글에서 시작한 이야기가 여기서 또 나오네요. 제가 안 쓴 것들이 동작을 정합니다.

한 가지 조건이 붙습니다. 기본 readiness 그룹에는 DB 상태가 안 들어갑니다. 넣으려면 management.endpoint.health.group.readiness.include에 따로 적어야 해요.

정리

  • 프로브가 없으면 Pod는 컨테이너가 시작한 그 순간 Ready가 됩니다. 실측에서 앱이 요청을 받기 시작한 건 2.5초 뒤였어요.
  • readinessProbe를 붙이니 그 둘이 붙었습니다. Ready가 +2.0초, 앱이 +2.2초.
  • readiness가 실패하면 트래픽을 끊습니다. 실측에서 Pod는 Running에 재시작 0회, 직접 요청하면 응답이 오는데 Service로 가면 거부당했어요.
  • 다만 하나만 실패하면 다릅니다. 체인은 그대로 있고 그 Pod로 가던 분기 하나만 빠져요.
  • 15장과 갈리는 자리가 거기입니다. Docker Engine은 판정을 적어두기만 했고, 쿠버네티스는 경로를 바꿉니다.
  • 엔드포인트가 0개가 되면 19장에서 본 KUBE-SVC 분기 규칙이 통째로 사라지고, 대신 has no endpoints라고 적힌 거절 규칙이 생깁니다.
  • liveness는 다른 일을 합니다. 실패하면 컨테이너를 죽여요. 실측에서 멀쩡한 앱이 20초마다 exit 143으로 죽고 CrashLoopBackOff에 빠졌습니다.
  • 그 143은 12장의 그 숫자입니다. kubelet이 SIGTERM을 보내고 JVM이 스스로 고른 값이에요.
  • CrashLoopBackOff의 백오프는 17장에서 본 그 백오프입니다.
  • readiness는 틀려도 회복되지만 liveness는 안 됩니다. readiness는 넉넉히, liveness는 없거나 단순하게.
  • liveness에 DB 확인을 넣으면 DB가 흔들릴 때 앱이 재시작 루프에 들어갑니다. 그건 readiness가 할 일이에요.
  • startupProbe가 성공할 때까지 liveness와 readiness는 안 돕니다. 실측에서 그것 하나로 45초 동안 4회 재시작하던 게 0회가 됐어요.
  • 스프링의 두 경로는 쿠버네티스 위에서 저절로 켜집니다. 같은 이미지를 docker run으로 띄우면 404예요.
  • 16장이 남긴 한계 ②가 여기서 풀렸습니다.

생각해볼 질문

이제 배포해도 5xx가 안 납니다. 준비된 Pod에만 트래픽이 가고요.

그런데 프로브가 실패하는 흔한 원인이 하나 있습니다. 앱 잘못이 아닌 것도요.

13장에서 힙 바깥이 166MB였죠. 14장에서는 CPU를 0.5개만 줬더니 주기마다 90ms씩 멈췄고요. 메모리가 모자라면 앱이 죽고, CPU가 모자라면 느려집니다. 느려진 앱은 검사 타임아웃에 걸리고, 그러면 방금 본 그 재시작 루프가 시작돼요.

쿠버네티스에서는 그 선을 어디에 긋을까요. 그리고 누가 어느 노드에 올릴지 정할 때 그 숫자를 어떻게 쓸까요.

그리고 하나 더. 17장에서 새 이미지를 반영하려면 컨테이너를 다시 만들어야 한다고 했습니다. 지금 Pod가 셋인데, 그 셋을 하나씩 갈아 끼우면서 끊기지 않게 하려면 무엇이 필요할까요.

▶ 21장. 요청과 제한, 그리고 롤아웃 — requests/limits·terminationGracePeriodSeconds

Last updated on