검색 엔진 하나 빠졌을 뿐인데 전체 서비스가 503

DB Fallback이 작동하는데 파드가 죽었다. 배포 후 간헐적으로 터지는 503이었는데, 원인은 검색 엔진이 아니라 Probe 설계였다. /health 하나가 MySQL, Valkey, OpenSearch를 동기적으로 전부 체크하면서 Liveness와 Readiness를 함께 판정하고 있었다. 이 글은 그 장애 연쇄를 추적하고, Probe 역할을 어떻게 분리했는지를 다룬다.

환경: EKS, NestJS 백엔드, OpenSearch + MySQL + Valkey


앱은 살아있는데 파드가 재시작됐다

배포 직후에는 정상이었다. 문제는 시간이 지나면서 시작됐다. 503이 발생하고, 잠시 후 자동으로 회복되다가, 일정 시간이 지나면 다시 503. 전형적인 재시작 패턴이었다.

파드 로그를 열면 앱은 멀쩡했다.

[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 폴백 사용

마지막 줄이 흥미로운 부분이다. OpenSearch가 죽어도 검색은 MySQL로 폴백되고 있었다. 서비스 레벨에서는 Degraded 상태이지, 완전 불능은 아닌 상황이다. 그런데 kubectl describe pod를 보니 Liveness Probe 실패로 인한 재시작이 이미 여러 차례 누적되어 있었다.


/health가 Liveness와 Readiness를 동시에 결정하고 있었다

배포된 코드의 diff는 깨끗했고, OpenSearch 클러스터 자체에도 일시적 지연 외에 이상은 없었다. 시선을 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: 3

Liveness와 Readiness가 동일한 /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는 500을 반환한다. DB Fallback이 코드 레벨에서 구현되어 있더라도, Probe 레벨에서는 그 맥락을 전혀 알지 못한다.

결과적으로 인과 체인은 다음과 같이 흘렀다.

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: 불필요한 재시작

OpenSearch 지연이 /health 실패를 만들고, Readiness 실패가 트래픽을 끊고, Liveness 실패가 프로세스를 죽인다. 검색 기능의 일시적 저하가 전체 서비스 장애로 증폭되는 구조였다.

두 가지 설계 결함이 겹쳐 있었다.

첫째, Liveness와 Readiness의 역할이 섞여 있었다. Liveness는 “프로세스가 살아서 HTTP 요청에 응답할 수 있는가”를 판단해야 한다. Readiness는 “트래픽을 받을 준비가 됐는가”를 판단해야 한다. 이 둘은 목적이 다르고, 행동 결과도 다르다 — Readiness 실패는 트래픽을 차단하고, Liveness 실패는 컨테이너를 죽인다. 같은 엔드포인트를 쓰면 항상 동시에 판정이 내려진다.

둘째, Liveness에 외부 I/O가 들어가 있었다. 프로세스의 생존은 외부 서비스와 무관하다. OpenSearch가 느려지거나 일시 다운되어도, NestJS 프로세스 자체는 살아 있고 요청을 처리할 수 있다. Liveness에 외부 I/O를 넣는 순간, 네트워크 지연 하나가 프로세스 재시작으로 이어질 수 있다.


Probe 역할 분리: 세 엔드포인트, 세 질문

핵심 원칙은 하나다. 각 Probe는 딱 하나의 질문에만 답해야 한다.

Probe질문엔드포인트체크 대상실패 시 행동
Startup부팅이 완료됐는가?/livez프로세스 생존다른 Probe 실행 대기
Liveness프로세스가 살아있는가?/livez프로세스 생존컨테이너 재시작
Readiness트래픽을 받을 수 있는가?/readyzMySQL + Valkey트래픽 차단 (재시작 없음)

/livez는 외부 I/O 없이 HTTP 200만 반환한다. 프로세스가 이벤트 루프를 처리하고 있다면 살아있는 것이다. /readyz는 핵심 의존성인 MySQL과 Valkey만 체크한다. 판단 기준은 “이 의존성이 없으면 서비스의 핵심 기능이 불가능한가”였다. OpenSearch는 DB Fallback이 있는 부가 기능이므로 제외했다.

변경된 Manifest는 다음과 같다.

# 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: 3

startupProbe를 추가한 이유가 있다. startupProbe가 성공할 때까지 Liveness와 Readiness는 실행되지 않는다. 기존에는 부팅 시간을 예측해서 initialDelaySeconds: 90을 박아야 했는데, 부팅이 90초 안에 끝나면 나머지 시간은 낭비이고 90초를 넘기면 Liveness 실패로 재시작되는 구조였다. startupProbe를 쓰면 부팅 완료 즉시 Liveness가 시작되므로 initialDelaySeconds를 0으로 설정할 수 있다.


이 장애가 남긴 것

Fallback 로직은 코드 레벨의 복원력이다. 그러나 Probe 설계가 틀리면, 코드가 아무리 잘 만들어져 있어도 인프라 레벨에서 그 복원력을 덮어쓴다. OpenSearch Fallback이 동작하고 있는 동안에도 Kubernetes는 그 사실을 알지 못했고, 판단 기준은 오직 /health의 응답 코드였다.

세 가지를 정리하면 이렇다.

Liveness Probe에 외부 의존성을 넣지 않는다. 프로세스의 생존과 외부 서비스의 상태는 다른 질문이다. 네트워크 지연 하나가 프로세스 재시작으로 이어지는 구조는 Liveness의 목적을 벗어난다.

Liveness와 Readiness는 반드시 분리한다. 같은 엔드포인트를 공유하면, 트래픽만 빼야 할 상황에서 프로세스까지 죽이는 과잉 대응이 된다. 두 Probe의 행동 결과가 다른 만큼, 판단 기준도 달라야 한다.

Readiness의 체크 대상은 “핵심 기능” 기준으로 좁힌다. Fallback이 있는 부가 기능은 Readiness에서 제외해야 한다. 그렇지 않으면 부가 기능의 일시적 장애가 전체 서비스 장애로 증폭된다.


앞으로의 방향

이번 수정은 Probe 역할 분리에 집중했지만, /readyz의 타임아웃 설정은 여전히 보수적으로 잡혀 있다. MySQL이나 Valkey에 응답 지연이 생기면 /readyz가 연속 실패하면서 불필요한 트래픽 차단이 발생할 수 있다. 의존성별 독립 체크와 Circuit Breaker 패턴을 Readiness에도 적용하는 것이 다음 과제다.

또한 OpenSearch 상태는 현재 어떤 Probe에서도 모니터링되지 않는다. 서비스 입장에서는 의도된 설계지만, OpenSearch 장애가 장기화될 경우 Degraded 상태가 silent하게 유지된다. 별도 alerting 채널로 부가 기능 의존성을 모니터링하는 방안을 검토 중이다.


참고 자료


정보 공백 요약

#필요한 정보이유위치
1Restart Count 추이 그래프 (CloudWatch 또는 kubectl describe)“파드가 반복 재시작됐다”는 관찰의 시각적 증거오프닝 섹션
2변경 전후 503 발생 빈도 비교 수치”해결 후 안정화”의 정량적 근거해결 방법 섹션