검색 엔진 하나 빠졌을 뿐인데 전체 서비스가 503
백엔드 배포 후 간헐적으로 503이 발생했다. 앱은 정상 부팅되었고 DB Fallback도 동작하고 있었는데, 파드가 재시작되고 있었다. 원인은 livenessProbe와 readinessProbe가 공유하는
/health엔드포인트에 외부 의존성이 전부 묶여 있던 Probe 설계였다.
환경: EKS, NestJS 백엔드, OpenSearch + MySQL + Valkey 날짜: 2026-03-02
상황
백엔드 서버를 배포한 뒤, 간헐적으로 503 Service Unavailable이 발생했습니다. 배포 자체에는 문제가 없었습니다. 코드 변경도 검색 관련 로직과는 무관한 부분이었습니다.
이상한 점은 앱이 “살아있는 상태”에서 죽는다는 것이었습니다. 크래시도 아니고 OOM(Out Of Memory)도 아닌데, 파드가 재시작되면서 503이 발생했습니다. 재시작 후 잠시 정상으로 돌아왔다가, 시간이 지나면 또 같은 패턴이 반복되었습니다.
관찰한 사실
파드 로그를 확인하니, 앱은 정상적으로 부팅된 뒤 OpenSearch 연결이 반복적으로 실패하고 있었습니다.
[NestApplication] Nest application successfully started
[Bootstrap] API is running on port 3000
[OpenSearchService] OpenSearch ping 실패
[OpenSearchService] ConnectionError: connect ECONNREFUSED 10.x.x.x:443
[RaceSearchService] OpenSearch 검색 실패, DB 폴백 사용
여기서 두 가지가 눈에 들어왔습니다.
첫째, 마지막 줄의 “DB 폴백 사용”입니다. 코드에 DB Fallback 로직이 구현되어 있었습니다. OpenSearch가 죽어도 검색은 MySQL로 폴백되니, 서비스 자체는 동작 가능한 상태였습니다. 그런데 왜 503이 발생하는 걸까요.
둘째, 파드의 Restart Count가 계속 올라가고 있었습니다. kubectl describe pod로 확인하니, Liveness Probe 실패로 인한 재시작이었습니다.
Kubernetes Probe 설정을 확인했습니다.
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 90
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 20
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3livenessProbe와 readinessProbe가 모두 같은 /health 엔드포인트를 사용하고 있었습니다. 이 /health의 구현을 확인했습니다.
async check(): Promise<HealthStatus> {
// MySQL check
await this.prismaService.$queryRaw`SELECT 1`;
// Valkey check
const pong = await this.valkeyService.ping();
if (pong !== 'PONG') throw new Error('Unexpected response');
// OpenSearch check
const isHealthy = await this.opensearch.ping();
if (!isHealthy) throw new Error('OpenSearch not responding');
}MySQL, Valkey, OpenSearch를 동기적으로 전부 체크하고 있었습니다. 하나라도 실패하면 /health 전체가 실패합니다.
가설과 검증 과정
가설 1: 배포된 코드에 문제가 있다
503이 배포 직후에 발생했으니, 처음에는 배포된 코드를 의심했습니다. diff를 확인하고, 로컬에서도 돌려봤습니다.
결과: 기각. 앱은 정상 부팅되었고, 로그에도 코드 레벨의 에러는 없었습니다. 문제는 “부팅 후 일정 시간이 지나면” 발생하는 패턴이었습니다.
가설 2: OpenSearch 자체의 장애다
로그에 OpenSearch 연결 실패가 반복되고 있었으니, OpenSearch 클러스터의 상태를 확인했습니다.
결과: 부분 채택. OpenSearch에 간헐적인 지연이 있었습니다. 하지만 OpenSearch가 복구된 후에도 파드 재시작은 계속되었습니다. OpenSearch는 트리거일 뿐, 근본 원인이 아니었습니다.
전환점: “왜 검색 엔진이 느려졌을 뿐인데 파드가 죽는가?”
이 질문이 핵심이었습니다. DB Fallback이 있으니 서비스는 동작 가능한 상태입니다. 그런데 Kubernetes는 이 파드를 “죽은 것”으로 판단하고 있었습니다.
인과 체인을 정리하니 구조가 보였습니다.
sequenceDiagram participant OS as OpenSearch participant App as App (/health) participant K8s as Kubelet participant ALB as AWS ALB Note over OS, App: OpenSearch 지연/장애 발생 loop Every 5s (Readiness) K8s->>App: GET /health App--xOS: Timeout/Error App-->>K8s: 500 Error end Note over K8s: Readiness 실패 누적 (3회) K8s->>ALB: Endpoint 제거 ALB--xApp: 트래픽 차단 (503) loop Every 10s (Liveness) K8s->>App: GET /health App--xOS: Timeout/Error App-->>K8s: 500 Error end Note over K8s: Liveness 실패 누적 (3회) K8s->>App: SIGKILL (Container Restart) Note over App: 불필요한 재시작
문제는 두 가지였습니다.
Probe 역할 미분리. Liveness Probe는 “프로세스가 살아있는가”를 판단해야 합니다. Readiness Probe는 “트래픽을 받을 준비가 됐는가”를 판단해야 합니다. 이 둘은 목적이 다릅니다. 그런데 같은 /health를 쓰고 있어서, 역할 구분 없이 하나의 결과로 양쪽 판정이 동시에 내려지고 있었습니다.
외부 의존성 과포함. /health가 MySQL, Valkey, OpenSearch를 전부 체크합니다. OpenSearch는 DB Fallback이 있는 부가 기능인데, 이것의 실패가 “서버가 죽었다”(Liveness 실패)는 판정으로 이어졌습니다. 검색 기능만 저하(Degraded)되는 상황이 전체 서비스 장애로 확대된 것입니다.
근본 원인
K8s Probe(/health)가 부가 기능의 외부 의존성(OpenSearch)까지 체크하고 있었고, Liveness와 Readiness가 분리되지 않았다.
Liveness Probe는 프로세스의 생존만 판단해야 합니다. 외부 서비스의 상태와는 무관해야 합니다. 그런데 OpenSearch 장애가 Liveness 실패로 이어지면서, 멀쩡히 동작하는 프로세스를 죽이고 있었습니다. Readiness도 마찬가지로, 핵심 기능과 무관한 OpenSearch 상태에 의해 트래픽이 차단되고 있었습니다.
해결 방법
Probe 역할 분리
| 구분 | 변경 전 (/health) | 변경 후 | 목적 |
|---|---|---|---|
| Liveness | MySQL + Valkey + OpenSearch | /livez — 프로세스 생존만 확인 | Deadlock/Crash 감지. 외부 I/O 없음 |
| Readiness | MySQL + Valkey + OpenSearch | /readyz — 핵심 의존성만 체크 | MySQL, Valkey만 체크. OpenSearch 제외 |
| Startup | 없음 | /livez 재사용 | 부팅 구간 보호 |
/livez는 외부 I/O 없이 프로세스가 HTTP 요청에 응답 가능한지만 확인합니다. /readyz는 서비스의 핵심 의존성(MySQL, Valkey)만 체크하고, DB Fallback이 있는 OpenSearch는 제외합니다. 판단 기준은 “이 의존성이 없으면 서비스의 핵심 기능이 불가능한가”입니다.
Manifest 변경
# Before
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 90
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 20
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3# After
startupProbe:
httpGet:
path: /livez
port: 3000
failureThreshold: 30
periodSeconds: 5
livenessProbe:
httpGet:
path: /livez
port: 3000
initialDelaySeconds: 0
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /readyz
port: 3000
initialDelaySeconds: 0
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3startupProbe를 추가한 이유가 있습니다. startupProbe가 성공할 때까지 다른 Probe는 실행되지 않습니다. 기존에는 부팅 시간을 예측해서 initialDelaySeconds: 90을 설정해야 했습니다. 부팅이 90초 안에 끝나면 나머지 시간은 낭비이고, 90초를 넘기면 Liveness 실패로 재시작됩니다. startupProbe를 사용하면 부팅이 완료되는 즉시 Liveness와 Readiness가 시작되므로, initialDelaySeconds를 0으로 설정할 수 있습니다.
교훈
- Liveness Probe에 외부 의존성을 넣지 않는다. Liveness는 “이 프로세스가 살아있는가”만 판단해야 한다. 외부 서비스의 상태는 프로세스의 생존 여부와 무관하다. Liveness에 외부 I/O를 넣는 순간, 네트워크 지연 하나가 파드 재시작으로 이어질 수 있다.
- Liveness와 Readiness는 반드시 분리한다. 같은 엔드포인트를 공유하면, Readiness 실패(트래픽 차단)와 Liveness 실패(프로세스 재시작)가 동시에 일어난다. 트래픽만 빼야 할 상황에서 프로세스까지 죽이는 과잉 대응이 된다.
- Readiness에 포함할 의존성은 “핵심 기능” 기준으로 판단한다. 판단 기준은 “이 의존성이 없으면 이 서비스가 요청을 처리할 수 있는가”이다. Fallback이 있는 부가 기능(검색 → DB 폴백)은 Readiness에서 제외해야 한다. 그렇지 않으면 부가 기능의 일시적 장애가 전체 서비스 장애로 확대된다.