Liveness와 Readiness: 컨테이너 헬스체크의 두 가지 관점
Docker나 Kubernetes 환경에서 서비스를 운영하다 보면 헬스체크(Health Check)라는 개념을 마주하게 됩니다. 처음에는 단순히 "서버가 200을 반환하면 되는 것 아닌가?"라고 생각했는데, 실제로 구현하면서 Liveness와 Readiness라는 두 가지 관점이 있다는 것을 알게 되었습니다.
헬스체크가 필요한 이유
컨테이너 환경에서는 애플리케이션의 상태를 자동으로 감지하고 대응해야 합니다. 사람이 24시간 모니터링할 수 없기 때문입니다. 헬스체크는 크게 두 가지 질문에 답합니다.
- Liveness: 프로세스가 살아있는가? (죽었으면 재시작)
- Readiness: 트래픽을 받을 준비가 되었는가? (안 되었으면 트래픽 차단)
이 두 가지는 비슷해 보이지만 실패 시 대응이 완전히 다릅니다.
Liveness: 프로세스가 살아있는가?
Liveness 체크는 가장 기본적인 질문입니다. "이 컨테이너가 살아있는가?" 단순히 HTTP 요청에 200을 반환하는 것만으로도 충분합니다.
// NestJS 예시 - 단순 Liveness
@Get('health')
getHealth() {
return { status: 'ok', timestamp: new Date().toISOString() };
}
이 체크가 실패하면 컨테이너 런타임(Docker, Kubernetes)은 컨테이너를 재시작합니다. 무한 루프에 빠졌거나 메모리 누수로 응답을 못 하는 상황에서 유용합니다.
Readiness: 트래픽을 받을 준비가 되었는가?
Readiness는 더 까다로운 질문입니다. "이 서비스가 실제로 요청을 처리할 수 있는가?" 프로세스가 살아있어도 데이터베이스 연결이 끊어졌다면 요청을 제대로 처리할 수 없습니다.
// NestJS 예시 - Readiness (MongoDB 연결 확인)
@Get('health/ready')
async getHealthReady() {
const dbReadyState = this.connection.readyState;
if (dbReadyState !== 1) {
throw new ServiceUnavailableException({
status: 'not_ready',
checks: { database: { status: 'disconnected' } }
});
}
// 실제 ping으로 응답 시간까지 측정
await this.connection.db.admin().ping();
return {
status: 'ready',
checks: { database: { status: 'connected' } }
};
}
이 체크가 실패하면 트래픽만 차단됩니다. 컨테이너를 재시작하지 않습니다. 데이터베이스가 잠시 끊어졌다가 복구되면 자동으로 트래픽이 다시 들어옵니다.
두 체크의 차이가 중요한 이유
실제 운영 상황을 생각해보면 차이가 명확해집니다.
시나리오: MongoDB가 5분간 점검으로 중단됨
Liveness만 사용하는 경우:
1. DB 연결 실패
2. 헬스체크 실패
3. 컨테이너 재시작
4. 또 DB 연결 실패
5. 또 재시작... (무한 반복)
→ 로그가 어지러워지고, 콜드 스타트 비용 발생
Readiness를 사용하는 경우:
1. DB 연결 실패
2. Readiness 체크 실패
3. 로드밸런서에서 제외 (컨테이너는 유지)
4. DB 복구
5. Readiness 체크 성공
6. 트래픽 다시 수신
→ 깔끔한 복구, 재시작 없음
실제 구현: Backend와 Web의 연결
제 블로그는 Backend(NestJS)와 Web(Next.js)으로 구성되어 있습니다. 각각의 Readiness 체크를 다르게 구현했습니다.
Backend: MongoDB 연결 확인
Backend는 MongoDB에 의존하므로, 데이터베이스 연결 상태를 확인합니다.
// packages/backend/src/app.service.ts
async getHealthReady(): Promise<ReadinessStatus> {
const startTime = Date.now();
const dbReadyState = this.connection.readyState;
// readyState: 0=disconnected, 1=connected, 2=connecting, 3=disconnecting
if (dbReadyState !== 1 || !this.connection.db) {
throw new ServiceUnavailableException({
status: 'not_ready',
checks: { database: { status: 'disconnected' } }
});
}
// 실제 ping으로 확인
await this.connection.db.admin().ping();
const responseTimeMs = Date.now() - startTime;
return {
status: 'ready',
checks: { database: { status: 'connected', responseTimeMs } }
};
}
Web: Backend 연결 확인
Web은 Backend API에 의존합니다. Backend의 Readiness 엔드포인트를 호출해서 전체 체인이 정상인지 확인합니다.
// packages/personal/web/app/api/health/ready/route.ts
export async function GET() {
const backendUrl =
process.env.BACKEND_URL || // Docker 내부 통신용
process.env.NEXT_PUBLIC_API_URL || // 외부 URL
'http://localhost:3000';
try {
const response = await fetch(`${backendUrl}/health/ready`, {
signal: AbortSignal.timeout(5000),
cache: 'no-store',
});
if (!response.ok) {
return NextResponse.json(
{ status: 'not_ready', checks: { backend: { status: 'unhealthy' } } },
{ status: 503 }
);
}
const backendHealth = await response.json();
return NextResponse.json({
status: 'ready',
checks: {
backend: {
status: 'healthy',
database: backendHealth.checks?.database?.status
}
}
});
} catch (error) {
return NextResponse.json(
{ status: 'not_ready', checks: { backend: { status: 'unreachable' } } },
{ status: 503 }
);
}
}
이렇게 하면 Web → Backend → MongoDB 전체 체인의 상태를 한 번에 확인할 수 있습니다.
Dockerfile HEALTHCHECK 설정
Coolify나 Docker Compose에서는 Dockerfile의 HEALTHCHECK 지시어를 사용합니다.
# Backend Dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health/ready', (r) => { \
r.on('data', () => {}); \
r.on('end', () => process.exit(r.statusCode === 200 ? 0 : 1)); \
}).on('error', () => process.exit(1))" || exit 1
# Web Dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "require('http').get('http://localhost:3001/api/health/ready', (r) => { \
r.on('data', () => {}); \
r.on('end', () => process.exit(r.statusCode === 200 ? 0 : 1)); \
}).on('error', () => process.exit(1))" || exit 1
각 옵션의 의미는 다음과 같습니다.
- interval=30s: 30초마다 체크
- timeout=5s: 5초 내 응답 없으면 실패
- start-period=15s: 컨테이너 시작 후 15초는 실패해도 무시 (초기화 시간)
- retries=3: 3번 연속 실패해야 unhealthy
Liveness와 Readiness 중 하나만 선택해야 한다면
Kubernetes에서는 둘 다 설정할 수 있지만, Docker나 Coolify의 HEALTHCHECK는 하나만 설정 가능합니다. 이 경우 Readiness를 선택하는 것이 더 실용적입니다.
Readiness 체크가 성공한다는 것은 Liveness도 당연히 성공한다는 의미이기 때문입니다. 프로세스가 죽었다면 HTTP 요청 자체가 실패하니까요. 반대로 Liveness만 체크하면 "살아는 있지만 제대로 동작하지 않는" 상태를 감지하지 못합니다.
외부 서비스 체크는 어디까지?
처음에는 Cloudinary, Google Gemini API 같은 외부 서비스도 체크해야 하나 고민했습니다. 결론은 핵심 의존성만 체크하는 것이 좋다는 것입니다.
- 체크해야 할 것: 데이터베이스 (없으면 서비스 불가)
- 체크하지 않아도 될 것: CDN, 외부 API (일시적 실패는 런타임에서 graceful하게 처리)
Cloudinary가 잠시 불안정해도 이미지만 안 보이지 글 목록 조회는 가능합니다. 이런 부분적 실패는 헬스체크가 아닌 에러 핸들링과 모니터링(Sentry 등)으로 대응하는 것이 맞습니다.
마무리
헬스체크를 구현하면서 "단순히 200을 반환하면 되는 것"에서 "서비스가 실제로 동작 가능한 상태인지 확인하는 것"으로 관점이 바뀌었습니다. Liveness와 Readiness의 차이를 이해하고 나니, 왜 Kubernetes가 이 두 가지를 분리했는지 이해가 되었습니다.
작은 프로젝트에서는 과한 설정처럼 보일 수 있지만, 한 번 제대로 설정해두면 배포와 운영이 훨씬 안정적이 됩니다. 새벽에 DB가 재시작되어도 서비스가 알아서 복구되는 경험을 하면, 이 설정의 가치를 체감하게 됩니다.
관련 글
k6와 실시간 Pool 모니터링으로 시스템 한계점 찾기
k6로 시스템 한계점을 찾는 Breakpoint 테스트와 NestJS Connection Pool 실시간 모니터링 시스템을 구현한 경험. 최적 RPS를 찾기까지의 과정을 정리했습니다.
작은 서비스에도 모니터링이 필요한 이유 - NestJS + Prometheus + Grafana Cloud 구축기
작은 서비스도 리소스 제약 때문에 모니터링이 필수입니다. NestJS에 Prometheus와 Grafana Cloud를 연동하여 효율적인 리소스 관리 시스템을 구축하는 방법을 알아보세요.
PM2 vs Coolify: 상황에 맞는 Node.js 배포 전략 선택하기
Node.js 배포 도구인 PM2와 Coolify의 차이점을 분석하고, 프로젝트 특성에 따른 선택 기준을 제시합니다.