Readiness와 Liveness Check의 차이

Readiness와 Liveness Check의 차이

한눈에 보기

Liveness 실패는 컨테이너 재시작, Readiness 실패는 트래픽 제외, Startup 실패는 시작 완료 전 컨테이너 재시작으로 이어진다. 세 검사를 같은 엔드포인트와 조건으로 만들면 일시적인 의존성 장애가 재시작 폭풍으로 번질 수 있다. 먼저 각 검사가 답할 질문과 실패했을 때 원하는 조치를 정해야 한다.

목차

Health Check 하나로는 부족한 이유

처음에는 /health 하나로 충분해 보인다.

app.get("/health", async (_req, res) => {
  await database.query("SELECT 1");
  res.status(200).json({ status: "ok" });
});

Kubernetes의 Liveness와 Readiness가 모두 이 엔드포인트를 호출한다고 해보자. 데이터베이스가 30초 동안 응답하지 않으면 두 검사 모두 실패한다.

sequenceDiagram
    participant DB as Database
    participant A as Pod A
    participant B as Pod B
    participant K as Kubelet

    DB--xA: timeout
    DB--xB: timeout
    K->>A: liveness failure
    K->>B: liveness failure
    K->>A: restart
    K->>B: restart
    Note over A,B: 시작하면서 연결·캐시 워밍을 다시 수행
    A->>DB: reconnect burst
    B->>DB: reconnect burst

DB 장애가 애플리케이션 프로세스를 재시작한다고 해결되지는 않는다. 오히려 모든 Pod가 동시에 재시작하면서 다음 문제가 생긴다.

반대로 Liveness가 단순히 200 OK만 반환하면 이벤트 루프가 완전히 막힌 상태에서 응답하지 못해 실패할 수는 있지만, 내부 작업 루프가 영구 정지한 상태를 감지하지 못할 수도 있다.

문제는 검사 코드의 복잡도가 아니라 검사 실패 후 오케스트레이터가 취할 행동을 구분하지 않은 데 있다.

엔드포인트보다 먼저 정할 것

“이 조건이 실패하면 컨테이너를 재시작해야 하는가, 아니면 트래픽만 잠시 빼야 하는가?”를 먼저 묻는다. 그 답이 Liveness와 Readiness를 나눈다.

세 Probe가 묻는 질문과 실패 동작

Kubernetes는 Startup, Liveness, Readiness Probe를 구분한다.

Probe 질문 실패 시 동작 실행 시기
Startup 애플리케이션이 시작을 완료했는가 임계치 초과 시 컨테이너 재시작 시작 중, 성공할 때까지
Liveness 프로세스가 재시작 없이는 회복하기 어려운가 임계치 초과 시 컨테이너 재시작 실행 중 주기적
Readiness 지금 새 트래픽을 받아도 되는가 Service 엔드포인트에서 제외 실행 중 주기적

Startup Probe가 설정되어 있으면 성공하기 전까지 Liveness와 Readiness가 실행되지 않는다. 따라서 초기화가 오래 걸리는 애플리케이션에 Liveness의 initialDelaySeconds를 과도하게 늘리는 대신 시작 단계만 별도로 보호할 수 있다.

상태 전이를 단순화하면 다음과 같다.

stateDiagram-v2
    [*] --> Starting
    Starting --> Starting: startup 실패, 임계치 이내
    Starting --> Restart: startup 실패, 임계치 초과
    Starting --> Ready: startup 성공 + readiness 성공
    Ready --> NotReady: readiness 실패
    NotReady --> Ready: readiness 회복
    Ready --> Restart: liveness 실패, 임계치 초과
    NotReady --> Restart: liveness 실패, 임계치 초과
    Restart --> Starting

Readiness 실패는 컨테이너를 멈추지 않는다. Kubelet은 검사를 계속하고 다시 성공하면 트래픽 대상으로 복귀시킨다. 이 특성 때문에 일시적인 과부하나 필수 의존성 장애에 적합하다.

Liveness는 훨씬 강한 조치다. 재시작으로 회복되는 상태에만 사용해야 한다.

Liveness는 외부 의존성을 확인하지 않는다

데이터베이스, Redis, 외부 API를 Liveness에 넣으면 외부 장애가 애플리케이션 재시작으로 전파된다.

// Liveness로 사용하기에 위험한 예
app.get("/health/live", async (_req, res) => {
  await Promise.all([
    database.query("SELECT 1"),
    redis.ping(),
    paymentApi.health(),
  ]);

  res.status(200).end();
});

결제 API가 실패한다고 현재 프로세스를 재시작해도 결제 API는 회복되지 않는다. 프로세스가 자체적으로 회복 불가능한지를 검사해야 한다.

가장 단순한 Liveness

HTTP 요청에 응답할 수 있다는 사실 자체가 이벤트 루프와 HTTP 서버가 동작 중임을 보여준다.

app.get("/health/live", (_req, res) => {
  res.status(200).json({ status: "alive" });
});

이 엔드포인트는 DB나 다른 네트워크 호출을 하지 않는다. 이벤트 루프가 완전히 막히면 제한 시간 안에 응답하지 못하므로 Probe가 실패한다.

내부 진행 상태를 포함할 때

애플리케이션 내부의 필수 소비 루프가 멈췄는데 HTTP 서버만 살아 있을 수 있다. 재시작으로 소비 루프를 복구할 수 있고 그것이 컨테이너의 핵심 역할이라면 마지막 진행 시간을 확인할 수 있다.

class ProgressWatchdog {
  #lastProgressAt = Date.now();

  markProgress(): void {
    this.#lastProgressAt = Date.now();
  }

  hasStalled(now: number, maxIdleMs: number): boolean {
    return now - this.#lastProgressAt > maxIdleMs;
  }
}
app.get("/health/live", (_req, res) => {
  if (
    consumerWatchdog.hasStalled(
      Date.now(),
      120_000,
    )
  ) {
    res.status(503).json({
      status: "stalled",
      component: "required-consumer",
    });
    return;
  }

  res.status(200).json({ status: "alive" });
});

하지만 메시지가 원래 2분 이상 오지 않을 수 있다면 “진행 없음”은 정지가 아니다. 주기적인 heartbeat나 소비 루프의 자체 tick을 기준으로 해야 한다.

Liveness 실패는 정말 재시작할 상태인가

메모리 사용량이 높거나 이벤트 루프 지연이 잠깐 증가했다는 이유만으로 즉시 실패시키면 과부하 때 용량을 더 줄인다. 먼저 Readiness로 트래픽을 줄이고, 지속적이며 회복 불가능한 상태에만 Liveness를 사용한다.

Readiness에는 필수 의존성만 포함한다

Readiness는 “이 인스턴스가 지금 새 요청을 받아 유효한 응답을 만들 가능성이 있는가?”를 묻는다.

type ReadinessState = Readonly<{
  acceptingTraffic: boolean;
  databaseAvailable: boolean;
  shuttingDown: boolean;
}>;

app.get("/health/ready", (_req, res) => {
  const state = readiness.current();

  if (
    !state.acceptingTraffic ||
    !state.databaseAvailable ||
    state.shuttingDown
  ) {
    res.status(503).json({
      status: "not_ready",
    });
    return;
  }

  res.status(200).json({ status: "ready" });
});

무엇이 필수 의존성인가

서비스의 모든 외부 연결을 넣는 것이 아니다.

의존성 장애 시 주요 요청 처리 Readiness 포함
주 데이터베이스 불가능 보통 포함
인증 키 캐시 새 키 조회도 불가하면 불가능 정책에 따라 포함
추천 API 추천 없이 응답 가능 제외
분석 이벤트 전송 비동기 적재 가능 제외
선택적 이미지 변환 일부 경로만 영향 전체 Readiness에서는 보통 제외

하나의 서비스가 서로 독립적인 기능을 많이 제공하면 Pod 전체 Readiness 하나로 표현하기 어렵다. 선택 기능 장애 때문에 전체 트래픽을 끊으면 더 큰 장애가 된다. 가능한 대응은 다음과 같다.

매 Probe마다 DB 쿼리를 실행해야 할까

Pod가 많고 Probe 주기가 짧으면 헬스 체크 자체가 DB 부하가 된다.

Pod 200개 × 5초마다 1회 = 초당 40개의 상태 확인 쿼리

짧은 시간 동안의 연결 상태를 애플리케이션이 내부적으로 갱신하고, Readiness 엔드포인트는 메모리 상태만 읽도록 만들 수 있다.

class DependencyStatus {
  #database:
    | { ok: true; checkedAt: number }
    | { ok: false; checkedAt: number; reason: string } = {
      ok: false,
      checkedAt: 0,
      reason: "not_checked",
    };

  updateDatabase(result: typeof this.#database): void {
    this.#database = result;
  }

  databaseIsReady(now: number): boolean {
    const fresh =
      now - this.#database.checkedAt < 10_000;

    return fresh && this.#database.ok;
  }
}

별도 루프가 제한 시간과 간격을 두고 확인한다.

async function refreshDatabaseStatus(): Promise<void> {
  try {
    await withTimeout(
      database.query("SELECT 1"),
      300,
    );

    dependencyStatus.updateDatabase({
      ok: true,
      checkedAt: Date.now(),
    });
  } catch (error) {
    dependencyStatus.updateDatabase({
      ok: false,
      checkedAt: Date.now(),
      reason: classifyDependencyError(error),
    });
  }
}

캐시된 상태가 너무 오래되면 안전하게 NotReady로 판단한다. 상태 확인 루프 자체가 멈춘 것을 정상으로 오해하지 않기 위해서다.

Startup Probe로 느린 시작을 분리한다

애플리케이션이 다음 작업 때문에 시작에 시간이 걸릴 수 있다.

Liveness를 시작 직후부터 실행하면 초기화 중인 컨테이너를 죽이고 다시 시작하는 루프가 생긴다.

시작 → 20초 초기화 중 → Liveness 실패 → 재시작
     → 20초 초기화 중 → Liveness 실패 → 재시작

Startup Probe는 시작 완료를 위한 별도 시간 예산을 준다.

startupProbe:
  httpGet:
    path: /health/startup
    port: http
  periodSeconds: 5
  timeoutSeconds: 1
  failureThreshold: 24

이 예시는 대략 5초 × 24회 = 120초의 시작 실패 허용 구간을 만든다. 실제 최대 판정 시간에는 첫 실행 시점과 요청 제한 시간 등 세부 차이가 있으므로 단순 곱은 운영 예산을 잡는 근사치로 사용한다.

애플리케이션은 초기화 완료 여부를 메모리에 둔다.

type LifecycleState =
  | "starting"
  | "ready"
  | "draining"
  | "failed";

let lifecycleState: LifecycleState = "starting";

app.get("/health/startup", (_req, res) => {
  if (lifecycleState === "starting") {
    res.status(503).json({ status: "starting" });
    return;
  }

  if (lifecycleState === "failed") {
    res.status(503).json({ status: "failed" });
    return;
  }

  res.status(200).json({ status: "started" });
});

설정이 잘못되어 절대 성공할 수 없다면 120초를 기다리지 말고 프로세스가 비정상 종료해야 한다. Startup Probe는 정상적으로 느린 시작을 보호하는 장치이지 설정 오류를 숨기는 장치가 아니다.

마이그레이션을 모든 Pod가 실행하지 않게 한다

롤링 배포의 각 Pod가 시작하면서 같은 DB 마이그레이션을 수행하면 잠금과 경합이 생길 수 있다. 마이그레이션은 별도 배포 단계나 단일 Job으로 실행하고, 애플리케이션은 필요한 스키마 버전을 확인하는 식으로 분리한다.

Node.js에서 상태를 명시적으로 관리한다

세 엔드포인트가 각자 임의 조건을 확인하면 상태 규칙이 흩어진다. 생명주기와 필수 의존성 상태를 한 객체에서 관리한다.

type DependencyName = "database" | "redis";

type DependencyHealth = Readonly<{
  ok: boolean;
  checkedAt: number;
  reason?: string;
}>;

class HealthState {
  #lifecycle: LifecycleState = "starting";
  #dependencies = new Map<
    DependencyName,
    DependencyHealth
  >();

  markReady(): void {
    this.#lifecycle = "ready";
  }

  markDraining(): void {
    this.#lifecycle = "draining";
  }

  markFailed(): void {
    this.#lifecycle = "failed";
  }

  updateDependency(
    name: DependencyName,
    health: DependencyHealth,
  ): void {
    this.#dependencies.set(name, health);
  }

  startup(): { ok: boolean; status: string } {
    return {
      ok: this.#lifecycle !== "starting" &&
          this.#lifecycle !== "failed",
      status: this.#lifecycle,
    };
  }

  liveness(): { ok: boolean; status: string } {
    return {
      ok: this.#lifecycle !== "failed",
      status: this.#lifecycle,
    };
  }

  readiness(now: number): {
    ok: boolean;
    status: string;
    reasons: string[];
  } {
    const reasons: string[] = [];

    if (this.#lifecycle !== "ready") {
      reasons.push(`lifecycle:${this.#lifecycle}`);
    }

    const database = this.#dependencies.get("database");

    if (
      !database ||
      !database.ok ||
      now - database.checkedAt > 10_000
    ) {
      reasons.push("database:unavailable");
    }

    return {
      ok: reasons.length === 0,
      status:
        reasons.length === 0 ? "ready" : "not_ready",
      reasons,
    };
  }
}

라우트는 상태를 HTTP로 표현하는 역할만 한다.

function sendHealth(
  res: Response,
  result: { ok: boolean; status: string },
): void {
  res
    .status(result.ok ? 200 : 503)
    .json({ status: result.status });
}

app.get("/health/startup", (_req, res) => {
  sendHealth(res, healthState.startup());
});

app.get("/health/live", (_req, res) => {
  sendHealth(res, healthState.liveness());
});

app.get("/health/ready", (_req, res) => {
  const result = healthState.readiness(Date.now());

  res
    .status(result.ok ? 200 : 503)
    .json({
      status: result.status,
      reasons: result.reasons,
    });
});

외부에 이유를 공개하고 싶지 않다면 응답에는 상태만 주고 상세 이유는 내부 메트릭과 속도 제한된 상태 전이 로그로 남긴다.

res
  .status(result.ok ? 200 : 503)
  .json({ status: result.status });

Health 엔드포인트는 인증 없이 내부 네트워크에서 호출되는 경우가 많다. DB 호스트, 오류 스택, 자격 증명, 라이브러리 버전 같은 정보를 본문에 노출하지 않는다.

검사 자체가 장애를 만들지 않게 한다

Probe는 정상 시에도 계속 호출되는 운영 트래픽이다. 가볍고 제한된 시간 안에 끝나야 한다.

전용 엔드포인트를 사용한다

일반 API 라우트나 홈페이지를 Probe로 사용하지 않는다.

# 피하고 싶은 예
livenessProbe:
  httpGet:
    path: /
    port: http

/가 템플릿 렌더링, 인증, DB 조회를 수행하면 불필요한 비용과 실패 조건이 추가된다. 상태 코드와 아주 작은 본문만 반환하는 전용 경로가 낫다. Kubernetes 공식 문서도 HTTP Probe 응답 본문을 작게 유지하도록 권장한다.

짧은 자체 제한 시간을 둔다

Readiness가 DB를 확인해야 한다면 Probe의 timeoutSeconds보다 짧은 애플리케이션 제한 시간을 둔다.

async function withTimeout<T>(
  promise: Promise<T>,
  timeoutMs: number,
): Promise<T> {
  const controller = new AbortController();
  const timeout = setTimeout(
    () => controller.abort(),
    timeoutMs,
  );

  try {
    return await Promise.race([
      promise,
      new Promise<never>((_, reject) => {
        controller.signal.addEventListener(
          "abort",
          () => reject(new Error("health check timeout")),
          { once: true },
        );
      }),
    ]);
  } finally {
    clearTimeout(timeout);
  }
}

이 예시의 Promise.race는 원래 DB 쿼리를 실제로 취소하지 않을 수 있다. 드라이버가 AbortSignal이나 쿼리 취소를 지원한다면 그것을 사용해야 한다. 그렇지 않으면 Probe가 실패한 뒤에도 쿼리가 쌓일 수 있다.

결과를 캐시한다

매 초 수백 Pod가 같은 의존성을 확인하지 않도록 상태 확인 주기와 Probe 응답을 분리한다. 단, 캐시 만료를 확인한다.

Probe 로그를 조절한다

정상 Probe 요청을 일반 액세스 로그에 모두 남기면 실제 사용자 요청 로그가 묻힌다.

function shouldLogAccess(req: Request): boolean {
  return !req.path.startsWith("/health/");
}

실패와 상태 전이는 기록한다.

if (previousReady && !currentReady) {
  logger.warn(
    { reasons },
    "application became not ready",
  );
}

매 실패 호출마다 같은 로그를 남기기보다 Ready → NotReady, NotReady → Ready 전이를 기록하면 노이즈를 줄일 수 있다.

Kubernetes 임계값을 시간으로 계산한다

기본 예시를 그대로 복사하기보다 장애 감지와 복구 시간 예산으로 설정한다.

livenessProbe:
  httpGet:
    path: /health/live
    port: http
  periodSeconds: 10
  timeoutSeconds: 1
  failureThreshold: 3

readinessProbe:
  httpGet:
    path: /health/ready
    port: http
  periodSeconds: 5
  timeoutSeconds: 1
  failureThreshold: 2
  successThreshold: 2

대략적인 연속 실패 판정 시간은 다음과 같이 생각할 수 있다.

실패 감지 시간 ≈ periodSeconds × failureThreshold

예시에서는 Readiness가 약 10초 지속해서 실패하면 트래픽에서 빠지고, Liveness는 약 30초 지속해서 실패하면 재시작 후보가 된다. 실제 시점은 검사 스케줄과 요청 제한 시간에 따라 달라진다.

회복 시 successThreshold: 2를 두면 한 번의 우연한 성공으로 즉시 트래픽에 복귀하지 않는다. Liveness와 Startup Probe의 successThreshold는 1이어야 한다.

너무 민감한 설정

periodSeconds: 1
timeoutSeconds: 1
failureThreshold: 1

1초의 CPU 일시 정지나 네트워크 흔들림도 즉시 상태 변경을 만든다. Readiness가 자주 출렁이면 트래픽이 남은 Pod로 이동하고 다시 과부하를 만들 수 있다.

너무 둔감한 설정

periodSeconds: 30
failureThreshold: 10

회복 불가능한 상태를 약 5분 동안 트래픽 대상으로 둘 수 있다. 서비스의 요청 제한 시간, 재시도 정책, SLO와 함께 감지 예산을 정한다.

Probe 주기와 애플리케이션 제한 시간을 함께 본다

timeoutSeconds가 1초인데 Readiness 내부 DB 쿼리 제한 시간이 5초라면 Probe가 먼저 연결을 끊고 쿼리는 계속 실행될 수 있다. 내부 확인은 외부 Probe 제한보다 짧게 끝나도록 한다.

종료 과정과 Readiness를 연결한다

SIGTERM을 받은 인스턴스는 새 요청을 받지 않으면서 기존 요청을 마쳐야 한다. 종료 시작과 동시에 상태를 draining으로 바꾼다.

async function beginShutdown(
  signal: NodeJS.Signals,
): Promise<void> {
  logger.info({ signal }, "shutdown started");

  healthState.markDraining();

  await stopAcceptingNewRequests();
  await waitForInflightRequests();
  await closeDependencies();
}
process.once("SIGTERM", () => {
  void beginShutdown("SIGTERM");
});

Readiness는 draining 상태에서 503을 반환한다.

readiness(now: number) {
  if (this.#lifecycle === "draining") {
    return {
      ok: false,
      status: "not_ready",
      reasons: ["lifecycle:draining"],
    };
  }

  // 나머지 필수 의존성 검사
}

Kubernetes에서는 Pod 삭제가 시작되면 EndpointSlice의 준비 조건도 변경되므로 애플리케이션 Readiness만이 유일한 트래픽 차단 장치는 아니다. 하지만 자체 상태 전환은 로드 밸런서, 직접 헬스 체크, Kubernetes 외 환경에서도 일관된 종료 동작을 만드는 데 도움이 된다.

종료 순서와 grace period는 Node.js 서버의 Graceful Shutdown 구현하기에서 더 자세히 다룬다.

흔한 실패 설계와 개선 방법

1. 모든 Probe가 같은 깊은 검사를 사용한다

startupProbe:
  httpGet: { path: /health, port: http }
livenessProbe:
  httpGet: { path: /health, port: http }
readinessProbe:
  httpGet: { path: /health, port: http }

경로가 같다는 사실 자체가 항상 틀린 것은 아니다. 그러나 질문과 실패 조건이 실제로 같다면 대개 설계를 다시 볼 필요가 있다.

startupProbe:
  httpGet: { path: /health/startup, port: http }
livenessProbe:
  httpGet: { path: /health/live, port: http }
readinessProbe:
  httpGet: { path: /health/ready, port: http }

2. Liveness에서 DB를 확인한다

DB 장애 때 Pod 전체가 재시작된다. DB는 보통 Readiness 또는 기능별 degrade 상태에 반영한다.

3. TCP 연결 성공만으로 준비됐다고 판단한다

TCP Probe는 포트가 열렸는지만 확인한다. 애플리케이션이 설정 로딩 중이거나 필수 캐시가 준비되지 않아도 성공할 수 있다. 프로토콜 수준 준비 상태가 필요하면 HTTP나 gRPC Probe를 사용한다.

4. Readiness가 항상 200을 반환한다

배포는 빠르게 끝나지만 초기화 중인 Pod가 요청을 받아 실패한다. 시작 단계와 필수 의존성 상태를 반영한다.

5. Health Check가 큰 JSON과 스택을 반환한다

{
  "database": {
    "host": "internal-db.example",
    "error": "authentication failed for user ..."
  }
}

내부 구조와 오류 정보가 노출된다. 외부 응답은 최소화하고 상세 정보는 접근 제어된 로그와 대시보드에 둔다.

6. 일시 과부하 때 모든 Pod가 동시에 NotReady가 된다

모든 Pod가 같은 임계값으로 동시에 트래픽에서 빠지면 서비스 엔드포인트가 0개가 될 수 있다. Readiness를 과부하 제어로 사용할 때는 히스테리시스, 부하 분산 정책, 최소 가용 Pod, rate limit과 함께 설계한다. 단순히 CPU가 높다는 이유만으로 전체를 NotReady로 만들지 않는다.

7. 실패 Probe가 외부 호출을 계속 누적한다

검사 제한 시간이 외부 쿼리를 취소하지 못하면 매 주기마다 새 호출이 추가된다. 동시 실행 방지, 명시적 취소, 결과 캐시를 적용한다.

let refreshInFlight: Promise<void> | undefined;

function scheduleDependencyRefresh(): Promise<void> {
  if (refreshInFlight) {
    return refreshInFlight;
  }

  refreshInFlight = refreshDatabaseStatus()
    .finally(() => {
      refreshInFlight = undefined;
    });

  return refreshInFlight;
}

테스트와 운영 관측

Probe는 정상 응답만 테스트해서는 부족하다. 실패에 따른 오케스트레이터 동작까지 확인해야 한다.

애플리케이션 테스트

it("is not ready before initialization completes", async () => {
  const app = createTestApplication();

  await request(app)
    .get("/health/ready")
    .expect(503);
});
it("remains live when the database is temporarily unavailable", async () => {
  const app = createReadyTestApplication();

  healthState.updateDependency("database", {
    ok: false,
    checkedAt: Date.now(),
    reason: "timeout",
  });

  await request(app)
    .get("/health/live")
    .expect(200);

  await request(app)
    .get("/health/ready")
    .expect(503);
});
it("becomes not ready while draining", async () => {
  healthState.markDraining();

  await request(app)
    .get("/health/ready")
    .expect(503);
});

배포 환경 실험

1. DB 네트워크를 일시 차단한다.
2. Pod가 NotReady로 바뀌는지 확인한다.
3. 컨테이너 재시작 횟수가 증가하지 않는지 확인한다.
4. DB를 복구한다.
5. 연속 성공 임계치 뒤 Ready로 돌아오는지 확인한다.

Liveness 실험은 테스트 환경에서 내부 필수 루프를 의도적으로 정지시키고, 설정한 감지 시간 뒤 재시작되는지 확인한다. 운영에서 무작정 프로세스를 멈추는 실험은 별도 장애 훈련 절차와 승인 아래 수행해야 한다.

관측할 지표

Node.js에서는 monitorEventLoopDelay()로 이벤트 루프 지연 분포를 관측할 수 있다. 반환 단위가 나노초라는 점을 주의한다.

import {
  monitorEventLoopDelay,
} from "node:perf_hooks";

const eventLoopDelay =
  monitorEventLoopDelay({ resolution: 20 });

eventLoopDelay.enable();

setInterval(() => {
  metrics.eventLoopDelayP99.observe(
    eventLoopDelay.percentile(99) / 1_000_000,
  );

  eventLoopDelay.reset();
}, 10_000).unref();

이 지표는 Liveness 조건으로 바로 연결하기보다 과부하와 응답 지연의 원인을 분석하는 관측값으로 먼저 사용한다.

배포 전 체크리스트

마무리

Health Check는 상태를 보여주는 장식용 API가 아니다. 실패 결과가 트래픽 제거와 컨테이너 재시작으로 직접 이어지는 제어 신호다.

Liveness는 “재시작 없이는 회복하기 어려운가”, Readiness는 “지금 새 요청을 받을 수 있는가”, Startup은 “초기화를 완료했는가”를 판단한다. 세 질문을 같은 조건으로 답하려 하면 일시 장애가 재시작 폭풍으로 확대된다.

Liveness는 가볍고 외부 의존성과 독립적으로 유지한다. Readiness에는 핵심 요청에 필요한 의존성과 종료 상태를 반영하고, Startup Probe에는 정상적인 최대 초기화 시간을 준다. 각 임계값은 복사한 숫자가 아니라 감지·복구 시간 예산으로 계산하고, 실제 의존성 차단 실험으로 기대한 동작을 확인해야 한다.

참고 자료

관련 노트