Readiness·liveness·startup 신호를 서로 다른 경로로 분리하는 프로브 장치의 개념 이미지

FastAPI readiness probe: liveness와 분리해 재시작 루프 막기

FastAPI readiness probe에 DB 상태를 넣으면서 같은 endpoint를 liveness에도 연결하면, 잠깐의 DB 장애가 애플리케이션 재시작 판정으로 번질 수 있다. DB가 끊겼을 때는 /readyz가 503을 반환해 트래픽만 빼고, /livez는 앱 프로세스가 요청에 응답할 수 있는지만 확인하는 편이 역할에 맞다. 느린 초기화는 별도의 startup probe가 맡는다.

이 판단을 문서 설명으로만 남기지 않기 위해 2026년 8월 4일, 하나의 최소 앱에 DB 단절과 2.2초 lifespan 지연, 단일 event loop의 2초 정지를 차례로 주입했다. Linux 7.0.0-1009-aws, Python 3.12.3, FastAPI 0.141.1, Starlette 1.3.1, Uvicorn 0.52.1 환경의 loopback 검증이다. 다만 Docker와 Kubernetes 도구가 없어 아래 재시작·Ready 결과는 실제 클러스터 수치가 아니라 HTTP 관측에 Kubernetes 판정 규칙을 적용한 모델이다.

DB 단절을 재시작 신호로 만들었을 때 생긴 차이

같은 앱, 같은 DB 단절, 같은 연속 3회 요청에서 probe가 바라보는 endpoint의 책임만 바꿨다. 변경 전 공용 /healthz는 DB 실패를 503으로 돌려줬고, 이를 liveness로 해석하면 세 번의 실패가 재시작 조건에 닿는다. 변경 후에는 /livez가 세 번 모두 200, /readyz가 세 번 모두 503이었으며 프로세스는 살아 있었다.

아래 블록은 최종 검증 실행의 capture_evidence 원문이다. modeled_restartmodeled_ready는 kubelet에서 읽은 값이 아니라 관측한 HTTP 상태에 failureThreshold=3을 적용한 결과라는 점까지 함께 읽어야 한다.

$ PYTHONPATH=<topic>/.research-verify/vendor python3 <topic>/.research-verify/probe_experiment.py
CASE db_disconnected
before unified_liveness=[503, 503, 503] modeled_restart=True
after split_liveness=[200, 200, 200] modeled_restart=False split_readiness=[503, 503, 503] modeled_ready=false process_alive=True
failed_attempt_body={"status":"not_ready","dependency":"db"}
failed_attempt_error=curl: (22) The requested URL returned error: 503
failed_attempt_exit=22 process_alive=True
...
EXIT 0
SCRIPT_EXIT=0

DB 단절에서 공용 health endpoint와 분리한 livez·readyz의 재시작 모델 전후 터미널 검증

여기서 눈여겨볼 값은 JSON의 not_ready가 아니라 HTTP 503이다. Kubernetes HTTP probe는 응답 본문이 아닌 상태 코드로 성공을 판단하며, 200 이상 400 미만을 성공으로 본다. /readyz가 같은 JSON을 200으로 반환했다면 kubelet에는 성공이다. 반대로 curl --fail-with-body가 종료 22를 냈어도 서버 프로세스는 계속 살아 있었다. readiness 실패와 애플리케이션 crash는 같은 사건이 아니다.

Kubernetes probe 문서의 역할도 이 관측과 맞는다. readiness 실패는 컨테이너를 실행한 채 Pod를 준비되지 않은 상태로 다루고, liveness 또는 startup probe가 임계 횟수만큼 실패하면 컨테이너가 재시작 대상이 된다. 공유 DB 장애를 liveness에 넣으면 회복에 도움이 되지 않는 재시작이 여러 replica에서 이어질 수 있으므로 책임 분리가 필요하다.

/livez/readyz는 회복 방법을 기준으로 나눈다

두 endpoint를 나누는 기준은 이름보다 “이 실패를 재시작으로 회복할 수 있는가”에 가깝다. /livez는 앱 내부 무응답을 찾는 값싼 in-process 응답으로 두고, 원격 DB나 외부 API, 공유 캐시 조회는 넣지 않는다. /readyz에는 이 Pod가 핵심 요청을 처리하는 데 꼭 필요한 의존성만 넣고, 사용할 수 없으면 명시적으로 503을 반환한다.

최소 구현은 다음처럼 단순하다.

@app.get("/livez")
async def livez():
    return {"status": "alive"}

@app.get("/readyz")
async def readyz(response: Response):
    if not app.state.db_ok:
        response.status_code = 503
        return {"status": "not_ready", "dependency": "db"}
    return {"status": "ready"}

모든 의존성을 readiness에 모으는 것도 피해야 한다. 없어도 핵심 요청을 처리할 수 있는 선택 기능이라면 degraded 상태나 별도 관측으로 빼는 편이 낫다. endpoint가 반드시 물리적으로 달라야 하는 것은 아니고, Kubernetes는 같은 저비용 endpoint에 다른 threshold를 적용하는 패턴도 설명한다. 이번 경우에는 DB 단절을 재시작과 분리하려는 목적 때문에 URL까지 나누는 것이 오설정 가능성을 더 잘 드러냈다.

느린 lifespan은 startup probe의 실패 예산으로 감싼다

FastAPI lifespan의 yield 전 코드는 요청 처리 전에 끝내야 하는 초기화 구간이다. ASGI 서버도 lifespan.startup.complete 전에는 연결 처리를 시작하지 않아야 한다. 그래서 2.2초 지연을 넣은 fixture는 초기에 연결 오류를 냈다가 준비가 끝난 뒤 /livez 200으로 바뀌었다.

최종 실행에서는 초기 세 번의 실패를 startup 예산 6회 안에 두었을 때 재시작하지 않는 것으로 모델링했다. 같은 세 번을 liveness의 임계값 3에 바로 적용하면 재시작 판정이 된다. 이어서 단일 event loop를 2초 막았을 때는 /livez가 351ms timeout을 냈고, 정지가 풀린 뒤 2ms에 200으로 돌아왔다.

CASE slow_startup
early=['URLError', 'URLError', 'URLError'] eventual_liveness=200 failures=3 startup_budget=6 modeled_restart=false
without_startup_probe failure_threshold=3 modeled_restart=True process_alive=True
CASE event_loop_stall
during=TimeoutError@351ms after=200@2ms process_alive=True
EXIT 0
SCRIPT_EXIT=0

느린 startup 보호 모델과 event-loop 무응답 timeout을 구분한 터미널 검증

이 출력은 startup과 liveness가 서로 다른 시간을 다룬다는 근거다. startup probe가 성공하기 전에는 liveness와 readiness가 실행되지 않으므로, 초기화 중 발생한 연결 실패를 런타임 장애로 오인하지 않게 한다. 반면 준비가 끝난 앱의 event loop가 응답하지 않는 상황은 liveness timeout으로 드러낼 수 있다. 공식 문서의 startup gating 및 probe 판정과 로컬 관측 방향이 일치했다.

여기서 검증 자체가 두 번 실패했다. 2.2초 지연을 넣고 후속 대기를 1.5초로 잡은 첫 harness는 eventual_liveness=URLError였고, 1.8초로 늘린 두 번째도 같았다. 세 번째에 2.5초를 기다리고서야 200을 확인했다. 설정한 sleep만 보고 보호 창을 계산하면 Python 프로세스 시작, import, 서버 준비 시간을 빠뜨리게 된다는 실패였다.

따라서 startup 예산은 lifespan 지연 하나가 아니라 전체 준비 시간의 분포를 품어야 한다. failureThreshold × periodSeconds는 대략적인 보호 창을 생각하는 출발점일 뿐이며 첫 probe 시점도 고려해야 한다. 이번 3회, 6회와 350ms timeout은 fixture를 비교하기 위한 실험값이지 운영 권장값이 아니다.

Kubernetes 설정은 숫자보다 세 신호의 책임부터 맞춘다

책임을 먼저 나눈 구성 골격은 다음과 같다. 아래 값도 보편적 정답이 아닌 설명용 시작점이다.

startupProbe:
  httpGet: {path: /livez, port: http}
  periodSeconds: 2
  timeoutSeconds: 1
  failureThreshold: 30
livenessProbe:
  httpGet: {path: /livez, port: http}
  periodSeconds: 10
  timeoutSeconds: 1
  failureThreshold: 3
readinessProbe:
  httpGet: {path: /readyz, port: http}
  periodSeconds: 5
  timeoutSeconds: 1
  failureThreshold: 2
  successThreshold: 2

Kubernetes 설정 문서 기준으로 timeoutSeconds 기본값은 1초, periodSeconds는 10초, failureThreshold는 3이며 liveness와 startup의 successThreshold는 1이어야 한다. readiness의 successThreshold를 늘리면 상태 흔들림은 줄지만 트래픽 복귀가 늦어진다. 이 숫자를 복사하기보다 정상 startup과 probe 지연의 상위 구간, 장애 감지와 복구 목표에 맞춰 다시 정해야 한다.

운영 채택 조건도 분명히 둘 수 있다. 의존성 장애 때 readiness만 실패하고 프로세스는 유지되어야 하며, 전체 startup 시간이 예산 안에서 회복해야 한다. 준비가 끝난 뒤 실제 요청 처리 불능이 생겼을 때는 liveness가 timeout으로 실패해야 한다. /livez가 원격 DB를 조회하거나 readiness 실패가 liveness 재시작으로 이어지는 구성이 보이면 적용을 보류하거나 롤백할 조건이다.

로컬 모델을 실제 클러스터 관측으로 오해하지 않는다

이번 결과는 loopback HTTP와 프로세스 생존 여부를 직접 관측한 것이고, Kubernetes의 Ready 전이와 재시작은 공식 규칙을 적용해 모델링한 것이다. 실제 kubelet Event, EndpointSlice, 컨테이너 restartCount는 측정하지 않았다. 그러므로 modeled_restart=False를 운영에서 재시작이 없었다는 수치로 읽으면 안 된다.

다중 worker, ingress나 service mesh, 실제 DB pool, CPU starvation, graceful termination도 검증 범위 밖이다. event-loop 정지는 async def 안에 blocking call을 넣은 단일 worker fixture였으며, readiness 실패 뒤 EndpointSlice 반영 시간이나 기존 연결 종료도 확인하지 않았다. 운영 반영 뒤에는 Pod의 Ready condition, UnhealthyKilling Event, restartCount, EndpointSlice 반영, 오류율을 함께 봐야 로컬 모델과 실제 동작의 차이를 찾을 수 있다.

FastAPI health check를 하나로 합치는 편이 간단해 보여도, 장애의 회복 방법이 다르면 신호도 나누는 편이 안전하다. DB 단절은 트래픽 제외로, 앱 자체의 지속적인 무응답은 재시작으로, 긴 초기화는 startup 실패 예산으로 보낸다. 이 구성을 채택하기 전에는 서비스의 정상 지연 분포로 threshold를 다시 잡고 실제 클러스터에서 Ready 전이와 재시작을 검증해야 한다.

공식 판정 규칙은 Kubernetes probe 구성 문서FastAPI Lifespan 문서에서 이어서 확인할 수 있다. 다른 형태의 런타임 회귀를 어떻게 관측하는지 궁금하다면 Cloudflare Workers 메모리 회귀 관측 사례도 함께 볼 수 있다. 이 링크는 FastAPI probe의 근거가 아니라 인접한 운영 관측 사례다.

비슷한 글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다