- Published on
EKS CrashLoopBackOff - livenessProbe 오진단 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 멀쩡해 보이는데 EKS에서 CrashLoopBackOff 가 반복된다면, 실제 원인은 애플리케이션 크래시가 아니라 livenessProbe 의 오진단(false positive)일 수 있습니다. 특히 스프링 부트/Node.js/Go 서비스가 콜드 스타트가 길거나, 시작 직후 DB 마이그레이션·캐시 워밍업·외부 API 초기화 같은 작업을 수행할 때 자주 발생합니다.
이 글에서는 livenessProbe 오진단이 왜 CrashLoopBackOff 로 이어지는지, 어떤 신호를 보면 “앱이 죽는 게 아니라 쿠버네티스가 죽이고 있다”를 확신할 수 있는지, 그리고 EKS 환경에서 안전한 probe 설계로 바꾸는 방법을 단계별로 정리합니다.
관련해서 CrashLoopBackOff 자체의 범용 원인 추적은 아래 글도 함께 참고하면 좋습니다.
livenessProbe 오진단이 CrashLoopBackOff로 이어지는 메커니즘
쿠버네티스에서 livenessProbe 는 “이 컨테이너 프로세스가 살아있는가”를 판단합니다. 실패하면 kubelet이 컨테이너를 재시작합니다.
문제는 많은 팀이 livenessProbe 를 “서비스 준비 완료(ready)” 체크로 잘못 사용한다는 점입니다.
livenessProbe실패: 컨테이너 재시작(강제)readinessProbe실패: 트래픽에서 제외(재시작하지 않음)
즉, 준비가 늦어졌을 뿐인데 livenessProbe 가 이를 장애로 오판하면 kubelet이 컨테이너를 죽이고 다시 올립니다. 재시작하면서 초기화가 다시 시작되고, 다시 probe가 실패하고… 이 루프가 반복되면 CrashLoopBackOff 가 됩니다.
오진단이 특히 잘 나는 대표 시나리오
- 콜드 스타트가 길다(자바, 대형 번들 Node, AOT 미적용 등)
- 시작 시 DB 마이그레이션/스키마 체크/인덱스 생성
- 외부 의존성(예: Redis, Kafka, RDS)이 준비되기 전까지 블로킹
- 헬스 엔드포인트가 “의존성까지 포함한 종합 건강”을 반환
- CPU 제한이 빡빡해서 시작 초기에 이벤트 루프/GC가 밀린다
timeoutSeconds가 너무 짧다(특히 TLS, 프록시 경유)
“앱이 죽는 게 아니라 쿠버네티스가 죽인다”를 확인하는 법
1) 이벤트에서 probe 실패가 반복되는지 확인
kubectl describe pod -n your-ns your-pod
아래 같은 이벤트가 반복되면 거의 확정입니다.
Liveness probe failed: HTTP probe failed with statuscode: 500Liveness probe failed: Get ...: context deadline exceededBack-off restarting failed container
2) 직전 종료 사유가 Error 인데 애플리케이션 로그가 정상일 수 있다
kubectl get pod -n your-ns your-pod -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}'
kubectl get pod -n your-ns your-pod -o jsonpath='{.status.containerStatuses[0].lastState.terminated.exitCode}'
exitCode 가 137 이면 OOM 가능성이 있지만, 오진단 케이스에서는 보통 애플리케이션이 스스로 패닉/크래시 로그를 남기지 않습니다. 대신 kubelet이 SIGKILL/SIGTERM로 끊어버립니다.
3) 이전 컨테이너 로그 확인
kubectl logs -n your-ns your-pod -c your-container --previous
--previous 로그가 “정상적으로 부팅 중” 또는 “워밍업 중”에서 끊긴다면 probe가 원인일 확률이 높습니다.
가장 흔한 설계 실수: liveness에 종합 헬스를 연결
많은 서비스가 /health 에서 DB, Redis, 외부 API까지 모두 체크합니다. 이 엔드포인트를 livenessProbe 에 연결하면 다음 문제가 생깁니다.
- DB가 잠깐 느려져도 “프로세스는 살아있는데” 재시작됨
- 외부 API 장애가 나도 “우리 프로세스는 살아있는데” 재시작됨
- 재시작이 오히려 장애를 증폭(커넥션 폭증, 캐시 미스 폭발)
livenessProbe 는 “프로세스 자체가 회복 불가능하게 멈췄는가”만 보게 만드는 것이 안전합니다.
해결 전략 1: startupProbe를 도입해 콜드 스타트 구간을 분리
쿠버네티스는 startupProbe 를 제공하며, 이 probe가 성공하기 전까지는 livenessProbe 가 동작하지 않습니다. 콜드 스타트가 긴 서비스라면 가장 먼저 고려해야 합니다.
아래 예시는 시작에 최대 5분을 허용합니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
template:
spec:
containers:
- name: api
image: your-image
ports:
- containerPort: 8080
startupProbe:
httpGet:
path: /health/startup
port: 8080
periodSeconds: 5
failureThreshold: 60
timeoutSeconds: 2
livenessProbe:
httpGet:
path: /health/liveness
port: 8080
periodSeconds: 10
failureThreshold: 3
timeoutSeconds: 2
readinessProbe:
httpGet:
path: /health/readiness
port: 8080
periodSeconds: 5
failureThreshold: 3
timeoutSeconds: 2
핵심은 엔드포인트를 분리하는 것입니다.
/health/startup: “부팅이 끝났는가”/health/liveness: “프로세스가 살아있는가(최소 조건)”/health/readiness: “트래픽을 받을 준비가 되었는가(의존성 포함 가능)”
해결 전략 2: liveness는 의존성을 빼고 ‘프로세스 생존’만 확인
권장 기준
- DB 연결, Redis ping, 외부 API 호출 같은 네트워크 의존성은
readiness로 이동 liveness는 다음 정도만 확인- 이벤트 루프가 멈췄는지(가능하면)
- 치명적 내부 상태(데드락 감지 플래그, 스레드풀 완전 고갈 등)
- 단순
200 OK(최후의 수단)
Node.js(Express) 예시
import express from 'express';
const app = express();
let started = false;
async function bootstrap() {
// 예: 마이그레이션/워밍업
await new Promise(r => setTimeout(r, 15000));
started = true;
}
app.get('/health/liveness', (req, res) => {
// 프로세스가 요청을 처리할 수 있으면 일단 살아있다고 본다
res.status(200).send('OK');
});
app.get('/health/startup', (req, res) => {
res.status(started ? 200 : 503).send(started ? 'STARTED' : 'STARTING');
});
app.get('/health/readiness', async (req, res) => {
// 여기서 DB/Redis 등 의존성 체크
// 실패하면 503으로 트래픽에서 제외만 하고 재시작은 하지 않게 만든다
res.status(200).send('READY');
});
app.listen(8080, () => {
bootstrap().catch(err => {
// 부팅 실패는 프로세스를 종료해 재스케줄되게 하는 편이 낫다
console.error(err);
process.exit(1);
});
});
해결 전략 3: initialDelaySeconds만으로 버티려 하지 말기
initialDelaySeconds 를 크게 주면 당장은 해결되는 것처럼 보이지만, 다음 문제가 남습니다.
- 배포 환경/노드 상태에 따라 스타트 시간이 흔들리면 다시 실패
- 부팅 이후에도 일시적 지연(예: GC 스톨, CPU throttling)로 오진단 가능
콜드 스타트 문제는 startupProbe 로, 준비 상태는 readinessProbe 로 분리하는 것이 재현성 있는 해결책입니다.
해결 전략 4: timeoutSeconds, failureThreshold, periodSeconds를 “합”으로 설계
probe는 개별 값보다 “실패 판정까지의 총 허용 시간”이 중요합니다.
- 총 허용 시간(대략) =
periodSeconds * failureThreshold - 단, 각 시도는
timeoutSeconds내에 응답해야 성공
예를 들어 periodSeconds: 10, failureThreshold: 3 이면 약 30초 내 3번 실패 시 재시작입니다. 애플리케이션이 일시적으로 20~25초 멈출 수 있는 상황(예: stop-the-world GC, CPU 제한으로 인한 지연)이 있다면 너무 공격적일 수 있습니다.
권장 접근:
timeoutSeconds는 프록시/네트워크 홉을 고려해 12초에서 시작하되, TLS/사이드카가 있으면 35초도 검토failureThreshold는 3에서 시작하되, 불안정 구간이 있으면 5 이상으로 늘려 재시작 폭주 방지periodSeconds는 5~10초가 일반적
해결 전략 5: EKS에서 자주 섞이는 원인들(오진단처럼 보이는 케이스)
livenessProbe 오진단과 증상이 비슷하지만 원인이 다른 경우가 있습니다.
1) CPU 제한으로 인한 throttling
부팅 시 CPU를 많이 쓰는데 resources.limits.cpu 가 낮으면 응답이 늦어져 probe timeout이 발생합니다. 이 경우 해결은 probe 조정만이 아니라 CPU limit 상향 또는 requests 적정화가 필요합니다.
resources:
requests:
cpu: "250m"
memory: "512Mi"
limits:
cpu: "1000m"
memory: "1024Mi"
2) 서비스가 0.0.0.0에 바인딩되지 않음
컨테이너 내부에서 localhost 로만 바인딩하면 kubelet의 probe가 실패할 수 있습니다. 애플리케이션이 0.0.0.0 로 listen 하는지 확인하세요.
3) Istio/Envoy, Nginx 사이드카가 있는 경우 경로/포트 혼선
probe가 실제 앱이 아니라 프록시로 들어가며 503을 받을 수 있습니다. 이때는 probe의 포트/경로를 명확히 하거나, 사이드카 리라이트 설정을 검토해야 합니다.
실전 디버깅 체크리스트(바로 적용)
kubectl describe pod이벤트에Liveness probe failed가 반복되는지 확인kubectl logs --previous로 “부팅 중 끊김” 패턴인지 확인liveness가 DB/Redis 같은 의존성을 체크하고 있지 않은지 확인- 콜드 스타트가 길면
startupProbe를 먼저 도입 readiness로 의존성 체크를 이동timeoutSeconds와failureThreshold를 합산 허용 시간 관점에서 재설계- CPU throttling 가능성이 있으면
requests/limits재조정
권장 probe 설계 템플릿
마지막으로, 팀 표준으로 삼기 좋은 템플릿을 정리합니다.
startupProbe: 부팅 완료까지 넉넉히 허용(분 단위)livenessProbe: 프로세스 최소 생존만 확인(가벼운 체크)readinessProbe: 의존성 포함 가능(실패해도 재시작 금지)
startupProbe:
httpGet:
path: /health/startup
port: 8080
periodSeconds: 5
failureThreshold: 60
timeoutSeconds: 2
livenessProbe:
httpGet:
path: /health/liveness
port: 8080
periodSeconds: 10
failureThreshold: 5
timeoutSeconds: 2
readinessProbe:
httpGet:
path: /health/readiness
port: 8080
periodSeconds: 5
failureThreshold: 3
timeoutSeconds: 2
마무리
EKS에서 CrashLoopBackOff 를 보면 “앱이 죽는다”부터 의심하기 쉽지만, 실제로는 livenessProbe 가 준비 상태나 외부 의존성 문제를 장애로 오판해 kubelet이 컨테이너를 반복 재시작시키는 경우가 많습니다.
해결의 핵심은 probe의 역할 분리입니다. startupProbe 로 콜드 스타트를 보호하고, livenessProbe 는 프로세스 생존만, readinessProbe 는 트래픽 수용 가능 여부를 판단하도록 재설계하면 재시작 폭주를 끊고 안정적으로 운영할 수 있습니다.