쿠버네티스는 장애를 복구하는 것이 아니라, 우리가 정의한 장애를 실행한다
readinessProbe와 livenessProbe가 나란히 같은 /health를 가리키고 있는 매니페스트. 쿠버네티스를 쓰는 조직에서 제일 자주 보게 되는 그림입니다. 장애는 대개 여기서 시작합니다.
쿠버네티스를 도입하는 이유에서 자동복구가 빠지는 경우는 거의 없습니다. 컨테이너에 문제가 생기면 자동으로 재시작하고, 정상이 아닌 Pod를 트래픽 대상에서 제외합니다. 새 버전을 배포할 때도 기존 Pod와 새 Pod를 순서대로 교체합니다. 그래서 이런 기대가 자연스럽게 생깁니다.
"쿠버네티스가 애플리케이션의 상태를 알아서 판단하고 복구해 줄 것이다."
쿠버네티스는 애플리케이션이 정말 정상인지 모릅니다. 개발자와 운영자가 설정한 Probe의 결과를 보고 미리 정해진 동작을 수행할 뿐입니다. 문제를 잘못 정의하면 그 잘못된 판단을 아주 빠르고 정확하게 실행합니다.
자동복구에서 중요한 건 자동화가 아니라 무엇을 장애로 판단했는가입니다.
쿠버네티스가 애플리케이션에 묻는 세 가지 질문
쿠버네티스에는 컨테이너 상태를 확인하는 세 가지 Probe가 있습니다.
| Probe | 확인하는 것 | 실패했을 때 |
|---|---|---|
| Startup Probe | 애플리케이션 시작이 완료됐는가? | 일정 횟수 실패 후 컨테이너 재시작 |
| Readiness Probe | 지금 요청을 받을 준비가 됐는가? | Service 트래픽 대상에서 제외 |
| Liveness Probe | 재시작해야 회복할 수 있는 상태인가? | 일정 횟수 실패 후 컨테이너 재시작 |
세 Probe 모두 상태를 확인하지만 질문의 목적은 완전히 다릅니다. 병원에 빗대 보면 Startup은 병원 문을 열 준비가 끝났는지를, Readiness는 지금 새 환자를 받을 수 있는지를, Liveness는 의료 시스템이 멈춰서 재가동해야 하는지를 묻습니다. 진료 준비가 조금 늦었다고 병원 전원을 껐다 켜지는 않습니다. 환자가 몰렸다고 병원을 폐쇄하지도 않습니다. 쿠버네티스에서도 같습니다.
가장 흔한 문제: 모든 상태를 /health 하나로 확인한다
현장에서는 하나의 상태 확인 URL을 Readiness와 Liveness가 함께 쓰는 경우가 많습니다.
readinessProbe:
httpGet:
path: /health
port: 8080
livenessProbe:
httpGet:
path: /health
port: 8080
구성은 간단합니다. 문제는 /health가 무엇을 보느냐입니다. 이 URL이 애플리케이션 프로세스에 더해 데이터베이스와 Redis, Kafka, 외부 결제 API, 외부 인증 API까지 한꺼번에 검사한다면 외부 결제 API 하나가 잠시 느려지는 순간 /health는 그대로 실패합니다.
이 결과를 Liveness Probe가 쓰면 쿠버네티스는 컨테이너를 재시작합니다. 외부 API 장애는 컨테이너를 재시작한다고 풀리지 않는데도 그렇습니다. 문제는 클러스터 바깥에 있는데 멀쩡한 컨테이너만 반복해서 재시작하는 그림입니다.
작은 DB 지연이 전체 서비스 장애로 커지는 과정
모든 Pod가 같은 데이터베이스를 이용한다고 가정해 보겠습니다. 데이터베이스 응답이 잠시 느려졌습니다. 그런데 Liveness Probe가 DB 연결 상태까지 확인하도록 구성돼 있습니다.
무너지는 순서는 이렇습니다. DB 응답이 지연되면 모든 Pod의 Liveness Probe가 실패하고 kubelet이 컨테이너를 재시작합니다. 재시작된 애플리케이션들이 동시에 DB 연결을 시도합니다. 여기에 클라이언트와 다른 서비스의 재시도 요청까지 몰리면서 DB와 애플리케이션 부하가 다시 올라갑니다. 회복 중이던 Pod가 또 실패합니다. 그다음은 CrashLoopBackOff와 연쇄 장애입니다.
처음 문제는 DB의 짧은 응답 지연이었습니다. 잘못된 Liveness 설정 하나가 정상 동작하던 애플리케이션까지 전부 재시작시키면서 서비스 전체 장애로 키웠습니다.
쿠버네티스가 자기 판단으로 장애를 만든 게 아닙니다. 사실상 이렇게 지시한 겁니다.
"DB가 느려지면 모든 애플리케이션 컨테이너를 재시작하라."
그리고 쿠버네티스는 그 정책을 충실하게 실행했습니다.
Readiness와 Liveness를 분리하는 기준
Readiness는 지금 이 Pod에 신규 요청을 보내도 되는지를 판단합니다. 실패하면 해당 Pod가 Service의 일반적인 트래픽 대상에서 빠집니다. 컨테이너 자체를 바로 재시작하지는 않습니다. Liveness는 이 프로세스를 계속 살려두는 게 의미가 있는지를 판단합니다. 일정 횟수 실패하면 kubelet이 컨테이너를 재시작합니다. 묻는 게 다르니 실패 조건도 달라야 정상입니다.
Readiness에 들어갈 상태는 필수 초기화가 아직 끝나지 않았거나, 캐시나 설정을 준비하는 중이거나, 지금 요청을 제대로 처리하기 어려운 경우입니다. 종료 절차에 들어가 신규 요청을 받으면 안 되는 상황도 여기에 들어갑니다. 잠깐 트래픽에서 빠지는 편이 서비스 전체에 유리하다면 그게 Readiness의 자리입니다.
Liveness는 성격이 다릅니다. 메인 스레드나 이벤트 루프가 멈췄을 때, 필수 내부 작업 스레드가 종료됐을 때, 프로세스는 떠 있는데 요청 처리가 완전히 정지했을 때. 애플리케이션이 자체적으로 복구하지 못하고 컨테이너를 재시작하면 회복될 가능성이 높은 상태만 여기 들어갑니다.
판단 기준은 한 줄입니다.
이 문제는 컨테이너를 재시작하면 실제로 해결되는가.
답이 흐릿하면 Liveness 실패 조건에서 뺍니다. 솔직히 저는 Liveness를 습관처럼 걸어두는 관행 자체를 별로 좋아하지 않습니다. 재시작으로 풀리지 않는 문제에 재시작 버튼을 자동으로 눌러 두는 셈이거든요.
실무 예제 1: 일반적인 웹 API 서버
일반적인 웹 API에서는 세 Probe의 엔드포인트를 목적에 따라 분리하는 것이 좋습니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-api
spec:
replicas: 3
selector:
matchLabels:
app: order-api
template:
metadata:
labels:
app: order-api
spec:
terminationGracePeriodSeconds: 30
containers:
- name: order-api
image: example.com/order-api:1.0.0
ports:
- name: http
containerPort: 8080
startupProbe:
httpGet:
path: /health/startup
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 24
readinessProbe:
httpGet:
path: /health/readiness
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
successThreshold: 1
livenessProbe:
httpGet:
path: /health/liveness
port: http
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
각 엔드포인트는 서로 다른 질문에 답합니다. /health/startup은 초기 설정과 필수 데이터 로딩이 완료됐는지를, /health/readiness는 현재 신규 요청을 처리할 상태인지를, /health/liveness는 애플리케이션 내부 실행 흐름이 살아 있는지를 봅니다.
Startup Probe
app.get("/health/startup", (req, res) => {
if (initializationCompleted) {
return res.status(200).json({ status: "STARTED" });
}
return res.status(503).json({ status: "STARTING" });
});
예제 설정에서는 5초 간격으로 최대 24번의 실패를 허용합니다.
5초 × 24회 = 약 120초
실제 재시작 시점은 Probe 수행시간과 시스템 상황에 따라 조금씩 달라집니다. 애플리케이션에 약 2분의 시작 시간을 주는 구성이라고 보면 됩니다. 기준은 부하가 높은 상황까지 포함한 최악의 정상 시작 시간입니다. 평균값으로 잡으면 모자랍니다.
Readiness Probe
app.get("/health/readiness", (req, res) => {
if (!acceptingTraffic) {
return res.status(503).json({
status: "NOT_READY"
});
}
return res.status(200).json({
status: "READY"
});
});
Readiness가 실패하면 프로세스를 재시작하는 대신 신규 트래픽에서 제외합니다.
다만 DB나 외부 시스템 장애를 Readiness에 넣을 때도 조심합니다. 모든 Pod가 같은 의존성을 확인하다가 동시에 NotReady가 되면 서비스의 전체 엔드포인트가 통째로 사라지기 때문입니다. 공유 의존성을 넣기 전에 스스로 물어봅니다.
이 의존성이 없으면 정말 어떤 요청도 처리하지 못하는가.
조회 캐시나 일부 부가기능이 없어도 제한적으로 서비스를 제공한다면 전체 Readiness를 실패시키는 것보다 기능별 예외처리나 성능 저하 모드가 낫습니다.
Liveness Probe
app.get("/health/liveness", (req, res) => {
if (eventLoopStalled || criticalWorkerStopped) {
return res.status(503).json({
status: "UNHEALTHY"
});
}
return res.status(200).json({
status: "ALIVE"
});
});
Liveness에서는 가급적 애플리케이션 내부의 생존 상태만 확인합니다. 이런 구성은 위험합니다.
app.get("/health/liveness", async (req, res) => {
const databaseUp = await checkDatabase();
const redisUp = await checkRedis();
const paymentApiUp = await checkPaymentApi();
if (!databaseUp || !redisUp || !paymentApiUp) {
return res.sendStatus(503);
}
return res.sendStatus(200);
});
DB나 결제 API가 멈췄다고 애플리케이션 컨테이너까지 반복해서 재시작하게 되니까요.
실무 예제 2: 시작 시간이 긴 Spring Boot 애플리케이션
Spring Boot 애플리케이션의 시작이 늦어지는 이유는 대개 정해져 있습니다. 클래스와 Bean 초기화, 데이터베이스 연결, 대량 설정 로딩, 캐시 구성, 보안 모듈 초기화, 외부 시스템 연결. 이런 서비스에 Liveness만 설정하면 애플리케이션은 시작을 마치기도 전에 재시작됩니다.
Spring Boot Actuator는 Kubernetes Probe에 그대로 쓸 수 있는 상태 엔드포인트를 제공합니다.
management:
endpoint:
health:
probes:
enabled: true
endpoints:
web:
exposure:
include: health,info
대표적인 게 /actuator/health/liveness와 /actuator/health/readiness 둘입니다. 쿠버네티스 쪽 설정은 이렇게 잡습니다.
containers:
- name: payment-api
image: example.com/payment-api:2.1.0
ports:
- name: http
containerPort: 8080
startupProbe:
httpGet:
path: /actuator/health/liveness
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 36
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: http
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
이 예제는 애플리케이션 시작에 약 180초를 허용합니다.
5초 × 36회 = 약 180초
Startup Probe가 성공하기 전에는 Readiness와 Liveness 검사가 아예 시작되지 않습니다. 정상적으로 초기화 중인 애플리케이션을 너무 일찍 재시작하는 일은 이 순서로 막습니다.
전체 Health 엔드포인트를 그대로 사용하면 안 되는 이유
/actuator/health
이 엔드포인트에는 설정에 따라 데이터베이스와 Redis, Kafka, 메시지 브로커, 디스크 공간, 외부 스토리지가 함께 묶입니다. 이 전체 상태를 Liveness에 연결하면 외부 시스템 하나의 장애가 애플리케이션 재시작으로 이어집니다. Liveness와 Readiness 전용 그룹에 어떤 상태 항목이 들어 있는지는 배포 전에 직접 열어보는 편이 낫습니다.
실무 예제 3: AI 모델처럼 초기화가 오래 걸리는 서비스
AI 추론 서버는 프로세스가 뜬 뒤에도 할 일이 남아 있습니다. GPU 초기화, 모델 파일 로딩, 모델 검증, 메모리 할당, Warm-up 추론을 거치고 나서야 요청 수신이 시작됩니다. 프로세스가 실행됐다는 이유로 바로 트래픽을 전달하면 첫 요청은 실패하거나 매우 느려집니다.
containers:
- name: inference-api
image: example.com/inference-api:3.0.0
ports:
- name: http
containerPort: 8080
startupProbe:
httpGet:
path: /health/model-loaded
port: http
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 60
readinessProbe:
httpGet:
path: /health/ready
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet:
path: /health/live
port: http
periodSeconds: 15
timeoutSeconds: 2
failureThreshold: 4
이 구성은 모델 초기화에 약 10분을 허용합니다.
10초 × 60회 = 약 600초
엔드포인트의 역할도 나눕니다. /health/model-loaded는 GPU 초기화와 모델 로딩, Warm-up이 끝났는지를, /health/ready는 현재 신규 추론 요청을 받을 상태인지를, /health/live는 추론 엔진과 필수 작업 스레드가 살아 있는지를 봅니다.
Readiness에 GPU 사용률 90% 이상, CPU 사용률 90% 이상, 메모리 사용률 90% 이상 같은 조건을 그냥 넣을 때는 조심합니다. Pod 하나가 과부하라는 이유로 트래픽에서 빠지면 그 요청이 나머지 Pod로 몰립니다. 남은 Pod도 차례로 과부하 상태가 되면 가용 Pod 전체가 사라지는 연쇄 장애로 이어집니다.
고부하는 Probe 하나로 풀 문제가 아닙니다. 요청 큐와 동시 요청 수 제한, Rate Limit, HPA, 요청 타임아웃, Circuit Breaker, 점진적인 트래픽 증가를 같이 놓고 설계합니다.
Probe가 없으면 배포가 빨라 보이는 이유
Probe를 제거하면 Pod가 훨씬 빨리 Ready가 되는 것처럼 보입니다. Kubernetes에 확인할 신호가 없으니 컨테이너가 시작된 상태를 그대로 정상으로 취급할 뿐입니다. 애플리케이션의 준비 여부와는 상관이 없습니다.
롤링 업데이트에서는 새로운 컨테이너 프로세스가 시작되자마자 Kubernetes가 그 Pod를 Ready로 취급하고 기존 Pod를 줄이기 시작합니다. 트래픽은 새 Pod로 갑니다. 애플리케이션 초기화가 끝나지 않아 요청이 실패합니다. 준비 상태를 확인하는 절차를 생략했으니 빨라 보일 수밖에 없습니다.
반대 방향도 있습니다. Deployment는 새로운 Pod가 사용 가능한 상태가 돼야 다음 기존 Pod를 교체합니다. Probe 주기가 지나치게 길면 애플리케이션은 이미 준비됐는데 Kubernetes가 다음 Probe를 실행할 때까지 그대로 기다립니다. Pod 수가 많으면 이 대기시간이 롤링 업데이트 과정 내내 누적됩니다.
Probe 설정 하나가 트래픽 투입 시점과 롤링 업데이트 속도, 가용 Pod 수, 컨테이너 재시작, 장애 전파 범위까지 건드립니다. 헬스체크라는 이름 때문에 가벼워 보이지만 배포 속도와 장애 대응을 동시에 쥐고 있는 제어 신호입니다.
무중단 배포를 고려한 실무 구성
무중단 배포는 Probe만 잘 설정한다고 끝나지 않습니다. 종료되는 Pod가 신규 요청을 중단하고 이미 처리 중인 요청을 마칠 시간도 필요합니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-api
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
minReadySeconds: 10
selector:
matchLabels:
app: order-api
template:
metadata:
labels:
app: order-api
spec:
terminationGracePeriodSeconds: 30
containers:
- name: order-api
image: example.com/order-api:1.0.0
ports:
- name: http
containerPort: 8080
lifecycle:
preStop:
exec:
command:
- /bin/sh
- -c
- sleep 5
startupProbe:
httpGet:
path: /health/startup
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 24
readinessProbe:
httpGet:
path: /health/readiness
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet:
path: /health/liveness
port: http
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
설정별로 무엇을 담당하는지 정리했습니다.
| 설정 | 목적 |
|---|---|
maxUnavailable: 0 |
배포 중 가용 Pod 수가 기존보다 줄어들지 않도록 함 |
maxSurge: 1 |
새로운 Pod를 하나씩 추가하며 교체 |
minReadySeconds: 10 |
잠깐 Ready가 된 Pod를 즉시 안정 상태로 간주하지 않음 |
preStop |
종료 전 트래픽 전파와 정리를 위한 시간을 확보 |
terminationGracePeriodSeconds |
처리 중인 요청을 마칠 시간을 제공 |
| Startup Probe | 초기화가 완료될 때까지 기다림 |
| Readiness Probe | 준비된 Pod에만 트래픽을 전달 |
| Liveness Probe | 자체 회복이 어려운 컨테이너만 재시작 |
다만 preStop에 sleep 5를 쓴 건 이해하기 쉬운 기본 예제입니다. 실제 운영에서는 애플리케이션이 SIGTERM을 정상적으로 처리하도록 만드는 쪽이 더 중요합니다. 종료 신호를 받으면 신규 요청 수신을 멈추고, 처리 중인 요청을 끝낸 다음, DB와 메시지 브로커 연결을 정리하고 제한 시간 안에 빠져나가는 순서입니다.
terminationGracePeriodSeconds에는 preStop 실행시간과 애플리케이션 종료시간이 함께 포함됩니다. 두 시간을 같이 계산해서 잡습니다.
maxUnavailable: 0이 높여 주는 건 배포 중 Pod 가용성까지입니다. 노드 장애나 클러스터 전체 장애는 별개 문제라 복제본 수, PodDisruptionBudget, 노드 분산, 리소스 여유를 따로 설계합니다.
자동 재시도도 장애를 키웁니다
Probe를 올바르게 구성해도 재시도 정책이 잘못돼 있으면 장애는 그대로 확대됩니다. 장애가 나면 모바일 앱과 웹 클라이언트, API Gateway, Service Mesh, 내부 마이크로서비스, 메시지 소비자, 배치 프로그램이 동시에 재시도를 겁니다. 복구된 Pod 하나에 밀려 있던 요청이 한꺼번에 몰리면 그 Pod는 살아나자마자 다시 과부하에 빠집니다. 흔히 thundering herd라고 부르는 현상입니다.
그래서 재시도에는 제한 장치가 붙습니다. 최대 재시도 횟수와 지수 백오프, 무작위 지연인 Jitter, 요청 타임아웃, Circuit Breaker, 동시 요청 제한, 점진적인 트래픽 복구. 자동복구와 자동재시도는 따로 떼어 보면 다 좋은 기능입니다. 잘못 결합하면 장애를 자동으로 증폭하는 시스템이 됩니다.
운영 환경에서 확인할 질문
배포 전에 이 질문들에 답이 나오는지만 봅니다.
Startup Probe
- 정상적인 최악의 시작 시간은 얼마인가?
- 초기화가 멈춘 상태와 그냥 느린 상태를 구분하는가?
- 시작 실패 후 재시작하면 실제로 회복될 가능성이 있는가?
- 외부 의존성의 일시적 지연 때문에 무한 재시작하지 않는가?
Readiness Probe
- 이 Pod를 제외하면 전체 서비스 상태가 실제로 좋아지는가?
- 모든 Pod가 동시에 NotReady가 될 조건은 없는가?
- 공유 DB나 외부 API 장애가 전체 엔드포인트 제거로 이어지지 않는가?
- 일부 기능이 실패해도 제한적인 서비스 제공이 가능하지 않은가?
- 종료 과정에서 신규 트래픽을 제대로 차단하는가?
Liveness Probe
- 실패 원인이 컨테이너 내부에 있는가?
- 재시작하면 실제로 문제가 해결되는가?
- 모든 Pod가 동시에 재시작될 가능성은 없는가?
- 네트워크 지연을 프로세스 장애로 잘못 판단하지 않는가?
- Probe 자체가 무겁거나 다른 공유 시스템에 의존하지 않는가?
Probe를 가장 쉽게 이해하는 방법
세 Probe의 역할은 세 문장이면 끝납니다. Startup은 아직 준비 중이니 기다려 달라는 말이고, Readiness는 살아 있지만 지금은 일을 받지 않겠다는 말이며, Liveness는 기다려도 회복되지 않으니 재시작해 달라는 말입니다.
Probe는 헬스체크 URL이 아니라 애플리케이션이 플랫폼에 제출하는 장애 정의서입니다. 200 OK를 반환하는 일과 장애 상태에 조치를 연결하는 일은 완전히 다른 작업입니다.
자동복구보다 먼저 설계하는 것
쿠버네티스는 장애 원인을 스스로 이해하지 못합니다. Probe가 실패하면 트래픽에서 제외하거나 컨테이너를 재시작합니다. 그 판단 기준을 설계하는 쪽은 사람입니다.
좋은 Probe는 장애를 격리하고 서비스가 회복할 시간을 법니다. 잘못된 Probe는 일시적인 지연을 반복적인 재시작으로 바꾸고, 외부 시스템 하나의 장애를 모든 Pod의 장애로 퍼뜨립니다.
문제는 자동복구 기능이 있느냐가 아닙니다. 무엇을 장애로 판단하고 그 장애에 어떤 조치를 붙였는가입니다.
쿠버네티스는 장애를 복구하지 않습니다. 사람이 정의한 장애 대응 정책을 실행합니다. 자동화된 시스템에서는 올바른 판단만 빠르게 퍼지는 게 아닙니다. 잘못된 판단도 같은 속도로 퍼집니다. failureThreshold를 몇으로 잡는 게 맞는지는 저도 서비스마다 매번 다시 셉니다.
이 글이 도움이 되셨나요?
버튼 하나가 다음 글을 쓰는 힘이 됩니다