
Liveness와 Readiness Probe
Kubernetes는 Pod의 상태를 판단하기 위해 두 종류의 probe를 제공한다.
LivenessProbe
"이 컨테이너가 살아있는가"를 확인한다.
kubelet이 주기적으로 지정된 엔드포인트를 호출하고, 응답이 없거나 실패하면 컨테이너를 재시작한다. 앱이 데드락에 빠지거나, 메모리 릭으로 응답 불능 상태가 됐을 때 자동으로 복구시키는 역할이다. 단, liveness가 실패하면 컨테이너를 아예 죽이고 다시 만든다는 점을 기억해야 한다. "느리다"와 "죽었다"는 다른 문제인데, liveness는 이 둘을 구분하지 않는다.
ReadinessProbe
"이 컨테이너가 트래픽을 받을 준비가 됐는가"를 확인한다.
실패하면 컨테이너를 재시작하지 않고, Service의 endpoints 목록에서 해당 Pod를 빼서 트래픽 라우팅을 중단한다. DB 연결이 일시적으로 끊어졌거나, 앱이 기동 중이거나, 일시적 과부하 상태일 때 트래픽만 차단하고 Pod 자체는 살려두는 것이다. 상태가 회복되면 다시 endpoints에 추가된다.
또한 Rolling Update를 진행할 때
health check가 없다면 어떻게 될까?
LivenessProbe가 설정되지 않으면 Kubernetes는 해당 probe의 결과를 항상 성공으로 간주한다. 즉, kubelet이 컨테이너의 health check를 하지 않는다.
이 경우 컨테이너가 재시작되는 건 오직 프로세스 자체가 종료(exit)됐을 때뿐이다. restartPolicy에 따라 프로세스가 크래시하면 재시작하지만, 프로세스가 죽지 않고 응답 불능인 상태(데드락, 무한 루프, 메모리 릭으로 인한 가비지 컬렉션 문제 발생)는 감지하지 못한다.
ReadinessProbe가 없으면 Kubernetes는 컨테이너가 Running 상태가 되는 즉시 해당 Pod를 "Ready"로 간주한다 Rolling Update를 하면 새 Pod의 컨테이너 프로세스가 뜨기만 하면 바로 트래픽이 넘어오고, 이전 Pod는 종료되기 시작한다. 마치 Recreate처럼 동작하게 된다.
LivenessProbe가 없으면 비정상 컨테이너가 재시작 없이 계속 떠 있고, ReadinessProbe가 없으면 준비되지 않은 Pod에 트래픽이 라우팅된다. 둘 다 없으면 Kubernetes의 자가 복구 메커니즘이 사실상 작동하지 않으므로, 2개에 대한 설정은 필수라고 볼 수 있다.
애플리케이션과 k8s 설정
k8s 노드의 제어자인 kubelet은 애플리케이션 API를 호출하여 health check를 진행한다.
따라서, 애플리케이션에서 규격화된 health check API를 만들어야 한다.
스프링
Spring Boot는 Actuator가 /actuator/health 엔드포인트에 /liveness와 /readiness를 기본 제공하고, DB와 Redis health indicator도 내장되어 있어서 별도 구현 없이 설정만으로 동작한다.
- build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
- application.yaml
management:
endpoint:
health:
show-details: always
group:
liveness:
include: livenessState
readiness:
include: readinessState, db, redis
# default
endpoints:
web:
exposure:
include: health
아래 링크로 호출하면 liveness와 readiness를 확인할 수 있다.
- http://localhost:8080/actuator/health/liveness
{
"status": "UP",
"components": {
"livenessstate": {
"status": "UP"
}
}
}
- http://localhost:8080/actuator/health/readiness
{
"status": "UP",
"components": {
"readinessstate": {
"status": "UP"
}
}
}
NestJS
반면 NestJS에서는 @nestjs/terminus를 사용해 API를 직접 구현해줘야 한다.
- Controller
@Controller('health')
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly db: TypeOrmHealthIndicator,
private readonly redis: RedisHealthIndicator,
) {}
@Get('liveness')
@HealthCheck()
liveness() {
return this.health.check([]);
}
@Get('readiness')
@HealthCheck()
readiness() {
return this.health.check([
this.db.pingCheck('database', { timeout: 3000 }),
this.redis.isHealthy('redis'),
]);
}
}
Redis의 경우 기본 제공하지 않아 아래와 같이 직접 만들거나 서드파티 라이브러리를 사용해야 한다.
@Injectable()
export class RedisHealthIndicator {
constructor(
private readonly healthIndicatorService: HealthIndicatorService,
@InjectRedis()
private readonly redis: Redis,
) {}
async isHealthy(key: string) {
const indicator = this.healthIndicatorService.check(key);
try {
const res = await this.redis.ping();
if (res === 'PONG') {
return indicator.up();
}
} catch (error) {
// ping 실패 시 아래에서 down 반환
// 필요에 따라 로깅을 넣어도 된다.
}
return indicator.down();
}
}
k8s Probe
참고
k8s 공식 문서에서는 health check를 할 때 /healthz 네이밍을 사용한다. 참고만 하자.
k8s 설정은 그리 어렵지 않다.
자세한 내용은 공식 문서를 참고하길 바란다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-api
spec:
replicas: 2
selector:
matchLabels:
app: my-api
template:
metadata:
labels:
app: my-api
spec:
containers:
- name: my-api
image: my-api:latest
ports:
- containerPort: 8080
livenessProbe:
httpGet:
path: /health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 1
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/readiness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
주의사항
1. 애플리케이션 배포시간
prod에서 배포 직후 Pod가 재시작되는 현상이 발생할 수 있다.
로그를 확인해보면 애플리케이션의 부트스트랩 과정에서 DB 마이그레이션 체크, Redis 연결, 외부 서비스 초기화 등이 initialDelaySeconds 안에 끝나지 않는 경우를 발견하게 될 것이다! kubelet은 앱이 아직 뜨고 있음에도 불구하고 liveness probe로 체크해버리고 응답이 없으면 "죽었다"고 판단해서 컨테이너를 재시작시킬 것이다.
그렇다고 initialDelaySeconds를 넉넉하게 잡자니 다른 문제가 생긴다.
앱이 금방 기동을 끝내도 Kubernetes는 설정된 시간이 지날 때까지 liveness/readiness 체크를 시작하지 않는다. 새 Pod가 이미 준비됐는데도 트래픽 전환이 늦어진다.
이때 사용할 수 있는 것이 StartupProbe다.
StartupProbe는 Liveness와 거의 비슷하지만, 최초에 애플리케이션이 실행됐는지 확인하는 용도로만 사용된다는 점에서 다르다. 이를 설정하면 StartupProbe가 성공하기 전까지 Liveness와 Readiness probe는 아예 실행되지 않는다.
0초: 컨테이너 시작, startupProbe 체크 시작
10초: startupProbe 실패, 기다림
20초: startupProbe 실패, 기다림
30초: startupProbe 실패, 기다림
40초: startupProbe 성공! → 이때부터 liveness/readiness 시작
설정은 아래와 Liveness나 Readiness와 비슷하다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: hiddenmoney-api
spec:
replicas: 2
selector:
matchLabels:
app: hiddenmoney-api
template:
metadata:
labels:
app: hiddenmoney-api
spec:
containers:
- name: hiddenmoney-api
image: hiddenmoney-api:latest
ports:
- containerPort: 4000
startupProbe:
httpGet:
path: /health/liveness
port: 4000
periodSeconds: 10 # 10초마다
timeoutSeconds: 3
failureThreshold: 30 # 30번의 시도 (10초 * 30번 = 총 300초)
2. 로그 노이즈
health check probe는 liveness + readiness 합산 10초마다 2건씩 호출된다. 하루로 치면 애플리케이션 하나에 약 17,280건의 액세스 로그가 찍힌다. 장애 상황에서 로그를 검색할 때 GET /health/liveness 200이 끝없이 반복되면서 실제 의미 있는 로그를 찾기 어렵다.
만약 Fluent Bit, Logstash, OpenTelemetry Collector 등의 로그 필터링 기능을 활용할 수 있는 인프라가 존재한다면 아래와 같이 필터링을 넣어줘 health check 관련 로그가 로깅 시스템에 찍히지 않게 방지하자.
- 구조화된 로그 예시
{
"timestamp": "2026-03-18T09:00:02.456Z",
"level": "info",
"method": "GET",
"path": "/health/readiness",
"status": 200,
"response_time_ms": 5
}
- fluent-bit.conf
[FILTER]
Name grep
Match *
Exclude path /health/