Docker로 FastAPI 서비스를 운영하면서 처음에는 컨테이너 상태를 꽤 단순하게 생각했다.
컨테이너가 실행되고 있고 FastAPI 프로세스도 죽지 않았다면 서비스는 정상이라고 생각했다. 실제로 docker ps를 실행했을 때 컨테이너가 Up 상태로 보이고, 로그에서도 Uvicorn이나 애플리케이션 프로세스가 정상적으로 실행되고 있다면 적어도 서버가 살아 있다는 뜻이라고 받아들이기 쉬웠다.
그런데 Health Check를 적용하고 나면 조금 이상한 상황을 만날 수 있다.
컨테이너는 분명히 실행 중이다.
FastAPI 프로세스도 살아 있다.
그런데 Docker에서는 상태가 이렇게 보인다.
Up ... (unhealthy)
처음 이 상태를 보면 자연스럽게 이런 생각이 든다.
프로세스가 살아 있는데 왜 Docker는 이 컨테이너가 건강하지 않다고 판단하는 걸까?
나도 처음에는 running과 healthy가 거의 같은 의미라고 생각했다.
그런데 Docker의 Health Check가 무엇을 확인하는지를 다시 보면서 두 상태가 애초에 다른 질문에 대한 답이라는 것을 이해하게 됐다.
Docker에서 컨테이너가 실행 중이라는 것은 기본적으로 컨테이너의 메인 프로세스가 종료되지 않았다는 것에 가깝다.
반면 Health Check는 그 프로세스가 살아 있다는 사실에서 한 단계 더 나아가 우리가 미리 정의한 검사 명령을 실제로 통과하고 있는지를 본다. Docker 공식 문서에서도 Health Check는 프로세스가 살아 있어도 웹 서버가 무한 루프 등에 빠져 새로운 연결을 처리하지 못하는 상황을 감지할 수 있는 별도의 상태 검사라고 설명한다. Health Check가 설정되면 컨테이너는 일반 실행 상태와 별도로 starting, healthy, unhealthy 상태를 갖는다.
이 차이를 이해하고 나니 unhealthy라는 상태를 보는 방식도 달라졌다.
Docker가 컨테이너 프로세스가 죽었다고 말하는 것이 아니라, 내가 정의한 “정상 조건”을 현재 컨테이너가 만족하지 못하고 있다고 말하는 것이었다.
처음에는 Health Check를 단순히 /health 한 번 호출하는 기능으로 생각했다
FastAPI 서비스를 운영한다면 Health Check는 보통 복잡하게 시작하지 않는다.
예를 들어 애플리케이션에 이런 endpoint를 만든다.
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
async def health():
return {
"status": "ok"
}
그리고 Docker에서는 일정 시간마다 이 주소를 호출한다.
Compose라면 대략 다음과 같은 형태다.
services:
api:
build: .
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
처음 보면 구조가 아주 단순하다.
Docker
↓
/health 호출
↓
200 OK
↓
healthy
그래서 Health Check가 실패하면 자연스럽게 FastAPI 서버 자체가 죽었다고 생각하기 쉽다.
그런데 실제로는 /health 요청 하나가 성공하기까지 여러 조건이 숨어 있다.
우선 컨테이너 안에 curl 명령이 실제로 존재해야 한다.
FastAPI가 8000 포트에서 정말 열려 있어야 한다.
Health Check가 실행되는 위치에서 localhost:8000으로 접근할 수 있어야 한다.
/health라는 경로가 실제로 존재해야 한다.
그 endpoint가 인증이나 Query Parameter를 요구하지 않아야 한다.
응답이 timeout 안에 돌아와야 한다.
그리고 curl -f를 사용했다면 HTTP 오류 응답도 Health Check 실패로 처리될 수 있다. curl 공식 문서에서 -f/--fail은 HTTP 400 이상 응답을 오류로 처리하고 non-zero exit code를 반환하도록 설명되어 있다.
결국 화면에서는 단순히:
unhealthy
라고 한 단어만 보이지만 그 한 단어 뒤에는 여러 종류의 실패가 숨어 있을 수 있었다.
Health Check에서 422가 나왔을 때 처음에는 더 혼란스러웠다
실제로 서비스를 구성하는 과정에서 Health Check 요청이 422로 떨어지는 상황을 본 적이 있었다.
이게 처음에는 꽤 이상하게 느껴졌다.
FastAPI 서버는 정상적으로 실행되고 있었다.
프로세스가 죽은 것도 아니었다.
그런데 Docker가 상태를 확인하려고 요청을 보내면 HTTP 422가 돌아왔다.
이런 상황에서는 얼핏 보면 Docker Health Check 자체가 잘못된 것처럼 느껴진다.
하지만 조금 다르게 생각해보면 중요한 단서가 하나 있다.
HTTP 응답이 422로 돌아왔다는 것은 적어도 FastAPI 서버까지 요청이 도착했다는 뜻이다.
포트 자체에 연결하지 못했다면 전혀 다른 형태의 연결 오류가 나왔을 가능성이 높다.
서버가 요청을 받았고 FastAPI가 HTTP 응답까지 만들었다.
그런데 그 응답이 200이 아니라 422였다.
그렇다면 문제를 바라보는 위치가 달라진다.
Docker
↓
네트워크 연결
↓
FastAPI까지 요청 도착
↓
FastAPI가 요청 검증
↓
422 반환
이 구조라면:
Docker가 FastAPI에 연결하지 못한다.
보다:
Health Check가 보내는 요청이 해당 FastAPI endpoint가 기대하는 요청 형식과 맞지 않는 것은 아닐까?
라는 질문이 더 자연스럽다.
당시의 정확한 endpoint 정의와 실패 조건을 지금 여기서 기억에 의존해 원인으로 확정하고 싶지는 않다.
하지만 이 경험 때문에 한 가지는 분명해졌다.
Health Check 실패라는 결과만 보고 서버가 죽었다고 판단하면 문제를 완전히 잘못된 위치에서 찾을 수 있다는 것이다.
예를 들어 /health가 평범한 Health Check가 아니었다면 문제가 생길 수 있다
가장 단순한 Health Check는 이런 형태다.
@app.get("/health")
async def health():
return {"status": "ok"}
아무것도 필요하지 않다.
그냥 요청하면 된다.
하지만 만약 endpoint가 다음처럼 만들어져 있다고 생각해보자.
@app.get("/health")
async def health(lib_code: str):
return {
"status": "ok",
"lib_code": lib_code
}
이 함수에서 lib_code에는 기본값이 없다.
FastAPI는 이를 필수 Query Parameter로 해석할 수 있다.
그런데 Docker는 다음 주소만 호출한다.
http://localhost:8000/health
그러면 Health Check가 기대하는 것은 단순하다.
GET /health
하지만 FastAPI endpoint가 기대하는 것은:
GET /health?lib_code=...
에 가깝다.
결국 Docker 입장에서 보면 서버까지 연결은 성공했지만 Health Check 명령은 성공하지 못한다.
curl -f를 사용한다면 4xx 응답은 실패 exit code로 처리되기 때문에 Docker는 이 Health Check를 실패로 기록할 수 있다.
이 상황이 반복되면 컨테이너는 계속 실행되고 있음에도 unhealthy가 될 수 있다.
이 예를 생각하고 나니 Health Check endpoint는 일반적인 비즈니스 API와 조금 다르게 설계해야겠다는 생각이 들었다.
Health Check에는 왜 사용자 정보가 필요하지 않아야 할까
예를 들어 일반 검색 API에는 사용자 정보가 필요할 수 있다.
@app.get("/search")
async def search(
keyword: str,
x_user_id: str = Header(...)
):
...
이건 자연스럽다.
검색 로그를 사용자별로 구분해야 할 수도 있고 사용 제한을 적용할 수도 있기 때문이다.
그런데 Health Check에도 같은 Header를 요구한다면 이야기가 달라진다.
Docker가 알고 싶은 것은:
이 사용자가 누구인가?
가 아니다.
이 애플리케이션이 지금 요청을 받을 수 있는 상태인가?
다.
사용자 인증이나 비즈니스 데이터는 Health Check의 목적과 관계가 없다.
그런데 일반 API에서 사용하던 dependency를 그대로 Health Check에도 적용하면 의도하지 않게 인증 Header나 DB 데이터가 필요한 endpoint가 될 수 있다.
이렇게 되면 Health Check는 애플리케이션 자체의 생존 여부보다 훨씬 많은 것을 검사하기 시작한다.
그리고 검사 대상이 많아질수록 unhealthy가 의미하는 것도 모호해진다.
여기서 “어디까지 확인해야 건강한 것인가?”라는 질문이 생겼다
처음에는 /health가 정상적으로 200을 반환하기만 하면 충분하다고 생각했다.
그런데 서비스 구조를 생각해보면 FastAPI 프로세스만 살아 있어도 실제 서비스는 사용할 수 없는 경우가 있다.
예를 들어 내 API가 PostgreSQL 없이는 아무것도 할 수 없다고 하자.
FastAPI는 실행 중이다.
/health도 그냥 문자열만 반환하기 때문에 정상이다.
@app.get("/health")
async def health():
return {"status": "ok"}
하지만 PostgreSQL은 죽어 있다.
실제 사용자 검색 요청은 전부 실패한다.
그런데 Health Check는 계속 이렇게 말한다.
healthy
그러면 이번에는 반대 문제가 생긴다.
Health Check를 너무 단순하게 만들어서 애플리케이션 프로세스가 살아 있다는 사실만 확인하고 실제 서비스 가능 여부는 확인하지 못하고 있는 것이다.
그래서 자연스럽게 이런 생각으로 이어진다.
그렇다면
/health에서 PostgreSQL도 확인하고 Elasticsearch도 확인하고 외부 LLM API도 전부 확인해야 하는 것 아닐까?
처음에는 이쪽이 훨씬 완벽해 보인다.
그런데 모든 외부 의존성을 검사하면 또 다른 문제가 생긴다
예를 들어 Health Check를 다음처럼 구성한다고 생각해보자.
/health 호출
↓
PostgreSQL 확인
↓
Elasticsearch 확인
↓
Redis 확인
↓
외부 AI API 확인
↓
모두 성공하면 200
이렇게 하면 정말 서비스 전체가 정상인지 확인하는 것처럼 보인다.
하지만 다시 생각해보면 Health Check 자체가 상당히 무거워졌다.
30초마다 실행한다고 하면 PostgreSQL에도 30초마다 요청이 간다.
Elasticsearch에도 간다.
외부 API까지 호출한다면 불필요한 네트워크 요청이나 비용이 발생할 수도 있다.
더 큰 문제는 외부 서비스 하나가 일시적으로 느려졌을 때다.
FastAPI 프로세스에는 아무 문제가 없는데 외부 시스템의 응답이 늦어서 Health Check timeout을 넘는다.
그러면 Docker는 FastAPI 컨테이너를 unhealthy로 기록한다.
즉:
외부 서비스 문제
↓
Health Check 실패
↓
FastAPI unhealthy
가 된다.
이것이 원하는 의미인지 생각해야 한다.
내 애플리케이션 자체가 죽은 것과, 특정 외부 의존성이 일시적으로 사용할 수 없는 것은 운영 관점에서 서로 다른 문제일 수 있기 때문이다.
그래서 Health Check 하나에 너무 많은 의미를 넣으면 안 된다고 생각하게 됐다
이 지점에서 Health Check의 목적을 조금 나눠서 생각하는 편이 자연스러웠다.
가장 단순한 검사는 이런 질문에 답한다.
FastAPI 프로세스가 HTTP 요청을 받을 수 있는가?
이 경우에는 매우 가벼운 endpoint면 충분하다.
@app.get("/health")
async def health():
return {"status": "ok"}
여기에는 DB Query도 필요하지 않고 인증도 필요하지 않다.
반면 운영 모니터링에서는 별도의 질문도 중요하다.
PostgreSQL에 연결할 수 있는가?
Elasticsearch가 검색 요청을 받을 수 있는가?
실제 핵심 기능을 수행할 수 있는가?
이것들은 더 깊은 상태 확인이다.
나는 지금 다시 구성한다면 이 모든 질문을 하나의 /health에 몰아넣기보다 목적에 따라 검사 수준을 나누는 방법을 먼저 생각할 것 같다.
예를 들어 애플리케이션 자체의 가벼운 상태 확인과 외부 의존성까지 확인하는 상태를 분리하는 식이다.
이렇게 해야 unhealthy가 떴을 때 그 상태가 무엇을 의미하는지도 더 명확해진다.
starting 상태가 존재하는 이유도 실제로 생각해보면 자연스러웠다
Health Check를 처음 켜면 컨테이너가 즉시 healthy가 되는 것이 아니라 starting 상태를 거친다.
Docker 공식 문서에서는 Health Check가 있는 컨테이너는 처음 starting 상태를 가지며, 검사 성공 시 healthy, 정해진 횟수만큼 연속 실패하면 unhealthy로 전환된다고 설명한다.
처음에는 이걸 단순한 상태 표시 정도로 생각했다.
그런데 실제 애플리케이션 부팅 과정을 보면 starting이 왜 필요한지 이해하기 쉽다.
컨테이너 프로세스가 시작됐다고 해서 애플리케이션이 바로 요청을 받을 준비가 끝나는 것은 아니다.
FastAPI를 띄우기 전에 설정 파일을 읽을 수도 있다.
DB Pool을 초기화할 수도 있다.
모델이나 데이터를 메모리에 올릴 수도 있다.
여러 초기화 작업이 끝난 뒤에야 실제 API가 정상적으로 동작하는 서비스도 있다.
즉:
Container Process Started
와:
Application Ready
사이에 시간이 존재할 수 있다.
Docker Compose 공식 문서도 docker compose up이 기본적으로 컨테이너가 running이 되는 것만 기다릴 뿐 내부 서비스가 실제 요청을 받을 준비가 끝나는 것까지 자동으로 기다리는 것은 아니라고 설명한다. 이런 경우 healthcheck와 depends_on: condition: service_healthy를 이용해 의존 서비스의 준비 상태를 기준으로 다음 서비스를 시작할 수 있다.
이 설명을 보고 나니 예전에 생각했던:
컨테이너가 떴는데 왜 DB 연결에 실패하지?
같은 현상도 이해하기 쉬워졌다.
프로세스의 시작 순서와 서비스가 실제로 준비되는 순서는 같은 것이 아니었다.
그래서 start_period를 단순 대기시간으로 보면 부족했다
예를 들어 애플리케이션 부팅에 평소 15초 정도가 필요한데 Health Check가 컨테이너 시작 직후부터 실패를 세기 시작한다고 하자.
아직 애플리케이션이 정상적으로 초기화 중인데 Docker 입장에서는:
첫 검사 실패
두 번째 검사 실패
세 번째 검사 실패
라고 기록할 수 있다.
그러면 서비스가 정상적으로 준비되기도 전에 unhealthy가 될 수 있다.
이 문제를 완화하기 위해 Docker Health Check에는 start_period가 있다.
Docker 공식 문서에서는 start_period를 컨테이너가 초기화될 시간을 주는 옵션으로 설명하며, 이 기간 중의 probe 실패는 retry 횟수에 포함하지 않는다. 다만 이 기간 안에 한 번 성공하면 컨테이너가 시작된 것으로 보고 이후 실패는 정상적으로 계산한다.
처음에는:
start_period: 30s
을 그냥 “30초 기다렸다 검사한다”고 이해하기 쉬웠다.
하지만 실제 의미를 보면 조금 더 세밀하다.
초기화 중 발생할 수 있는 정상적인 실패를 즉시 서비스 장애로 판단하지 않도록 유예 기간을 제공하는 것에 가깝다.
이 차이를 이해하면 값을 정할 때도 단순히 크게 잡기보다 실제 애플리케이션이 정상적으로 부팅되는 시간을 먼저 측정하고 싶어진다.
그렇다면 start_period를 아주 크게 잡으면 안전할까
여기까지 생각하면 또 극단적인 해결책이 떠오른다.
애플리케이션이 준비되는 데 시간이 필요하다면:
start_period: 5m
처럼 아주 크게 만들면 초기화 중 unhealthy가 되는 문제를 피할 수 있을 것 같다.
하지만 그만큼 실제로 애플리케이션이 시작 단계에서 실패했을 때 문제를 발견하는 시간도 늦어질 수 있다.
예를 들어 애플리케이션이 정상적으로는 10초 안에 준비되는데 잘못된 설정 때문에 영원히 제대로 실행되지 않는다고 하자.
start_period를 과도하게 길게 잡으면:
실제 문제 발생
↓
하지만 아직 start period
↓
실패가 retry에 반영되지 않음
↓
문제 판단 지연
이라는 상황이 될 수 있다.
결국 start_period 역시:
클수록 안전하다.
가 아니라:
정상적인 초기화에 필요한 시간은 허용하되, 비정상적인 초기화 실패는 너무 늦지 않게 발견할 수 있는 값
으로 보는 편이 더 자연스러웠다.
interval, timeout, retries도 각각 왜 있는지를 생각해야 했다
Health Check 설정을 보면 숫자가 여러 개 있다.
healthcheck:
interval: 30s
timeout: 5s
retries: 3
처음에는 인터넷 예제를 보고 거의 그대로 복사하기 쉽다.
나 역시 이런 값들은 그냥 무난한 기본 설정 정도로 보기 쉬웠다.
그런데 각각이 어떤 질문에 대한 설정인지 생각해보면 의미가 달라진다.
interval은:
얼마나 자주 상태를 확인할 것인가?
에 대한 값이다.
timeout은:
검사 하나가 얼마나 오래 걸리면 실패라고 판단할 것인가?
다.
retries는:
일시적인 한 번의 실패를 바로 장애로 볼 것인지, 몇 번 연속 실패해야 상태를 바꿀 것인지?
를 결정한다.
Docker 공식 문서에서도 Health Check 명령이 timeout을 넘기면 실패로 처리되고, 설정된 retries만큼 연속 실패해야 unhealthy가 된다고 설명한다.
세 값을 따로 생각하면 Health Check가 결국 얼마나 민감하게 장애를 판단할 것인지 설정하는 것이라는 생각이 들었다.
너무 민감한 Health Check는 정상 서비스도 unhealthy로 만들 수 있다
예를 들어 평소 /health가 100ms 안에 응답하지만 가끔 서버 부하가 올라가 1초 정도 걸릴 수 있다고 하자.
그런데:
timeout: 500ms
로 설정했다면 정상적으로 조금 느려진 상황도 실패로 볼 수 있다.
interval도 매우 짧고 retries도 1이라면 일시적인 지연 한 번만으로 바로 unhealthy가 될 수 있다.
반대로:
interval: 10m
retries: 10
처럼 지나치게 느슨하면 실제 장애를 발견하는 데 너무 오래 걸릴 수 있다.
그래서 Health Check 수치를 정할 때도 단순히 “30초, 5초, 3회가 일반적이다”라고 끝내면 부족하다.
실제 애플리케이션이 정상일 때 /health가 어느 정도 시간 안에 응답하는지,
순간적인 지연이 어느 정도 발생하는지,
장애를 몇 초 안에 발견해야 하는지,
검사 자체가 시스템에 어느 정도 부하를 만드는지를 생각해야 한다.
결국 이 값들도 서비스의 정상 동작을 먼저 알아야 정할 수 있는 숫자였다.
그런데 unhealthy의 원인은 애플리케이션 코드가 아닐 수도 있었다
Health Check가 실패하면 개발자는 자연스럽게 FastAPI 코드를 먼저 본다.
하지만 Health Check 명령 자체가 실행되지 않는 경우도 있다.
예를 들어 Dockerfile을 가볍게 만들기 위해 최소한의 Python 이미지로 구성했고 그 안에 curl을 설치하지 않았다고 하자.
그런데 healthcheck는 다음과 같다.
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
FastAPI에는 아무 문제가 없다.
/health도 정상이다.
하지만 컨테이너 안에서 curl이라는 명령을 실행할 수 없다.
그러면 Health Check는 실패한다.
즉:
FastAPI 정상
/health 정상
하지만
healthcheck 명령 실행 불가
→ unhealthy
가 가능하다.
이걸 생각하고 나니 Health Check가 실패했을 때 가장 먼저 해야 할 일은 /health 코드를 수정하는 것이 아니라 Docker가 실제로 어떤 명령을 실행했고 그 명령이 왜 실패했는지를 직접 확인하는 것이라는 생각이 들었다.
Docker 공식 문서는 Health Check 명령이 출력한 stdout/stderr 일부를 health status에 저장하며 docker inspect를 통해 확인할 수 있다고 설명한다.
그래서 docker inspect가 훨씬 중요해졌다
컨테이너가 unhealthy라고 보이면 docker ps만 계속 볼 필요는 없다.
docker ps는 결과만 보여준다.
내가 알고 싶은 것은 왜 실패했는가다.
현재 상태를 확인하려면 예를 들어:
docker inspect container_name
으로 정보를 볼 수 있고 Health 관련 부분만 확인하도록 출력 범위를 줄일 수도 있다.
Health Check 로그에는 검사 결과와 출력이 남기 때문에 이런 상황을 구분할 수 있다.
curl: command not found
인지,
Connection refused
인지,
HTTP 422
인지,
Operation timed out
인지에 따라 봐야 할 위치가 전혀 다르다.
이 네 가지는 전부 최종적으로는:
unhealthy
라는 한 단어가 되지만 원인은 각각 다르다.
curl이 없다면 이미지 구성 문제다.
Connection refused라면 프로세스, 포트, bind 주소, 시작 시점 등을 본다.
HTTP 422라면 요청 형식과 endpoint 정의를 본다.
Timeout이라면 endpoint 안에서 무엇을 기다리고 있는지 본다.
이렇게 생각하면 unhealthy는 오류의 원인이 아니라 여기서부터 조사를 시작하라는 상태 표시에 더 가까웠다.
Health endpoint에서 DB까지 확인하도록 만들었다면 timeout도 다시 생각해야 했다
예를 들어 /health를 다음처럼 만든다고 생각해보자.
@app.get("/health")
async def health():
async with pool.acquire() as conn:
await conn.fetchval("SELECT 1")
return {"status": "ok"}
이제 이 endpoint는 FastAPI만 확인하지 않는다.
PostgreSQL Connection Pool에서 Connection을 하나 가져오고 실제 Query가 성공해야 200을 반환한다.
이 방식의 장점은 분명하다.
PostgreSQL까지 사용할 수 있어야 healthy가 된다.
그런데 앞에서 Connection Pool 문제를 생각했던 내용을 연결해보면 한 가지 상황이 떠오른다.
실제 사용자 요청이 급증해서 Connection Pool이 모두 사용 중이다.
Health Check가 /health를 호출한다.
/health도 Connection을 얻기 위해 기다린다.
Health Check timeout은 3초다.
3초 안에 Connection을 얻지 못한다.
Health Check 실패.
높은 DB 부하
↓
Health Check도 DB Connection 대기
↓
timeout
↓
unhealthy
여기에서 컨테이너가 unhealthy가 된 것은 틀린 정보일까?
완전히 틀렸다고 하기도 어렵다.
실제 서비스가 DB Connection을 제때 확보하지 못하고 있으니 사용자 요청도 느려지고 있을 수 있기 때문이다.
하지만 이것을 FastAPI 프로세스 자체가 죽었다고 해석하면 역시 잘못이다.
이처럼 Health Check endpoint가 무엇을 검사하느냐에 따라 unhealthy가 의미하는 범위가 달라진다.
이때부터 Health Check는 “코드 한 줄”이 아니라 운영 정책이라고 생각하게 됐다
처음에는 다음 한 줄이 Health Check라고 생각했다.
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
그런데 실제로는 이 한 줄보다 그 뒤의 질문들이 더 중요했다.
/health가 무엇을 검사하는가.
HTTP 서버만 보는가.
DB도 보는가.
Elasticsearch도 보는가.
외부 AI API도 보는가.
얼마나 늦으면 실패인가.
몇 번 실패하면 장애인가.
부팅 중에는 얼마나 기다려줄 것인가.
Health Check가 실패한 뒤 어떤 시스템이 그 상태를 이용하는가.
Docker Compose에서는 의존 서비스의 Health Check를 service_healthy 조건으로 이용해 다른 서비스를 시작할 수도 있다. 즉 Health Check 결과는 단순 화면 표시를 넘어 서비스 시작 순서에도 영향을 줄 수 있다.
이걸 보고 나니 Health Check를 그냥 “Docker에 넣는 좋은 설정 하나” 정도로 취급하면 안 된다는 생각이 들었다.
무엇을 정상이라고 정의할 것인지 결정하는 운영 정책에 가까웠다.
정상 기준을 너무 엄격하게 만들면 장애가 전파될 수도 있다
예를 들어 FastAPI가 PostgreSQL과 Elasticsearch를 사용한다고 하자.
Health Check가 둘 다 확인한다.
FastAPI OK
PostgreSQL OK
Elasticsearch 실패
그러면 컨테이너는 unhealthy가 된다.
이 상태를 다른 자동화가 보고 해당 컨테이너를 계속 재시작하도록 구성되어 있다면 어떻게 될까?
Elasticsearch가 잠깐 느렸던 문제 때문에 정상적인 FastAPI 프로세스까지 반복적으로 재시작될 수 있다.
그 과정에서 DB Pool도 다시 만들어지고 진행 중인 요청도 영향을 받을 수 있다.
즉 Health Check가 실제 문제를 감지한 것은 맞지만 문제에 대한 대응 방법이 적절한지는 또 다른 문제다.
이 부분 때문에 Health Check와 restart policy를 하나로 생각하면 안 된다고 느꼈다.
Health Check는 상태를 판단한다.
그 상태를 보고 무엇을 할지는 별도의 운영 정책이다.
반대로 너무 단순한 Health Check는 거짓으로 healthy라고 말할 수도 있다
반대 상황도 존재한다.
@app.get("/health")
async def health():
return {"status": "ok"}
이 endpoint는 거의 실패하지 않을 수 있다.
FastAPI event loop가 요청 자체를 받을 수만 있으면 200을 반환한다.
그런데 실제 핵심 검색 API는 Elasticsearch 연결 문제 때문에 전부 500을 반환하고 있다고 하자.
사용자 입장에서는 서비스 장애다.
Health Check 입장에서는:
healthy
다.
이런 상태를 보면 Health Check를 복잡하게 만들고 싶은 유혹이 다시 생긴다.
그래서 결국 완벽한 하나의 endpoint를 만드는 문제라기보다 어떤 수준의 건강 상태를 어떤 목적으로 측정할 것인지 나누는 문제에 가깝다고 생각한다.
지금 다시 만든다면 최소한 애플리케이션 자체 확인은 최대한 단순하게 둘 것 같다
지금의 내가 Docker 자체 Health Check를 만든다면 기본 endpoint는 상당히 가볍게 유지하려고 할 것 같다.
예를 들어:
@app.get("/health")
async def health():
return {
"status": "ok"
}
이 endpoint에는 사용자 Header를 요구하지 않는다.
검색 Query Parameter도 필요하지 않다.
사용자 인증도 붙이지 않는다.
LLM API도 호출하지 않는다.
그리고 가능하면 이 endpoint 자체가 다른 복잡한 서비스 로직에 의존하지 않도록 한다.
이렇게 하는 이유는 단순히 구현이 쉽기 때문이 아니다.
Docker가 unhealthy라고 말했을 때 의미를 명확하게 하고 싶기 때문이다.
이 FastAPI 애플리케이션이 최소한 HTTP 요청에 응답할 수 있는가?
에 집중한다.
그다음 PostgreSQL, Elasticsearch 같은 의존성은 별도의 상세 상태 검사나 모니터링으로 확인하는 구조를 고려한다.
그러면 422 같은 오류도 훨씬 줄이기 쉬워진다
Health Check endpoint에 입력값이 없으면 Docker가 어떤 요청을 만들어야 할지 고민할 필요가 거의 없다.
GET /health
하나면 된다.
반대로 Health endpoint가 일반 비즈니스 API와 같은 dependency 구조를 사용하면서:
X-User-ID 필요
libCode 필요
Query Parameter 필요
같은 요구사항이 붙기 시작하면 Docker Compose의 Health Check에도 그 모든 조건을 맞춰줘야 한다.
그리고 애플리케이션 요구사항이 바뀔 때 Health Check 설정까지 같이 변경해야 한다.
Health Check의 목적에 필요하지 않은 입력값 때문에 422가 발생한다면 운영상 불필요한 결합이 생긴 셈이다.
그래서 예전에 Health Check에서 422를 봤던 경험을 다시 생각하면, 단순히:
curl URL을 잘못 썼다.
정도로만 기억하기보다 Health Check endpoint가 정말 Health Check에 필요한 만큼만 단순했는가라는 질문을 같이 하게 된다.
직접 확인할 때도 Docker가 실행하는 환경에서 확인해야 했다
호스트에서 다음 요청이 성공한다고 하자.
curl http://localhost:8000/health
그렇다고 Docker Health Check도 반드시 성공한다고 단정할 수는 없다.
Health Check 명령은 컨테이너 안에서 실행된다. Docker 공식 HEALTHCHECK 문서도 검사 명령을 컨테이너 내부에서 실행해 상태를 판단한다고 정의한다.
따라서 문제를 재현하려면 실제 컨테이너 안에서 같은 명령을 실행해보는 편이 더 직접적이다.
docker exec -it container_name \
curl -f http://localhost:8000/health
이렇게 했을 때도 실패한다면 Docker가 Health Check에서 보고 있는 환경과 훨씬 가까운 조건에서 확인할 수 있다.
여기서:
curl 자체가 없음
을 발견할 수도 있고,
Connection refused
를 볼 수도 있고,
실제 FastAPI의:
422 Unprocessable Entity
를 볼 수도 있다.
나는 지금이라면 unhealthy가 떴을 때 설정 파일을 바로 수정하기 전에 Docker가 실행하고 있는 Health Check 명령을 컨테이너 내부에서 그대로 한번 실행해보는 것부터 시작할 것 같다.
복잡한 추측보다 훨씬 빠르게 문제 범위를 좁힐 수 있기 때문이다.
결국 중요한 것은 healthy라는 초록색 상태 자체가 아니었다
Health Check를 처음 적용할 때는 목표가 단순했다.
docker ps에서:
healthy
라고 보이면 성공이고,
unhealthy
라고 보이면 실패라고 생각했다.
그런데 운영 관점에서 보면 더 중요한 질문이 남는다.
무엇을 확인했기 때문에 healthy라고 말할 수 있는가?
아무것도 하지 않고 200 OK만 반환한다면 프로세스 생존 확인에는 도움이 되지만 실제 의존성 문제는 알 수 없다.
반대로 DB, Elasticsearch, 외부 API까지 모두 검사한다면 실제 서비스 가능 여부는 더 잘 볼 수 있지만 Health Check 자체가 무거워지고 외부 장애까지 애플리케이션 장애로 표현할 수 있다.
정답 하나가 존재한다기보다 서비스에서 Health Check를 어떤 의미로 사용할지 결정해야 한다.
그래서 지금은 Health Check를 만들 때 코드부터 쓰기보다 먼저 문장으로 정의해보는 편이 좋다고 생각한다.
예를 들면:
이 Health Check의 목적은 FastAPI 프로세스가 HTTP 요청을 받을 수 있는 상태인지 확인하는 것이다.
라고 정했다면 /health는 단순하게 만든다.
반대로:
이 검사는 사용자의 핵심 검색 요청을 실제로 처리할 수 있는 상태인지 확인한다.
가 목적이라면 PostgreSQL이나 Elasticsearch까지 포함해야 할 수 있다.
두 검사는 이름은 모두 Health Check라고 부를 수 있지만 실제로 대답하는 질문은 다르다.
마무리
FastAPI 컨테이너가 unhealthy라고 표시됐을 때 처음에는 Docker가 애플리케이션에 문제가 있다고 판단했다고 생각했다.
하지만 Health Check의 동작을 다시 보고 나니 표현을 조금 바꾸는 것이 더 정확했다.
Docker가 알고 있는 것은 내가 Health Check에 작성한 명령뿐이다.
그 명령을 실행하고 exit code가 성공이면 healthy, 연속해서 정해진 횟수만큼 실패하면 unhealthy로 상태를 바꾼다. 검사 명령의 exit status 0은 성공, 1은 unhealthy를 의미하며, 출력 내용은 일정 범위까지 docker inspect로 확인할 수 있다.
결국:
unhealthy
라는 말은
FastAPI 전체가 죽었다.
라는 진단 결과가 아니다.
더 정확하게는:
내가 정의한 Health Check 명령이 계속 실패하고 있다.
에 가깝다.
그래서 지금 다시 같은 상황을 만나면 unhealthy라는 단어 자체를 고치려고 하지 않을 것 같다.
먼저 Docker가 실제로 무슨 명령을 실행했는지 확인한다.
그 명령을 컨테이너 안에서 직접 실행해본다.
HTTP 응답이 있는지 본다.
응답이 422라면 FastAPI 요청 형식과 endpoint 정의를 본다.
연결 자체가 되지 않는다면 프로세스와 포트를 확인한다.
Timeout이라면 Health endpoint가 내부에서 무엇을 기다리는지 본다.
초기화 중에만 실패한다면 start_period가 실제 부팅시간과 맞는지 확인한다.
그리고 그 모든 것을 보기 전에 한 가지를 먼저 정한다.
내가 이 Health Check로 확인하고 싶은 “건강함”은 정확히 무엇인가?
이 질문이 명확하지 않으면 /health가 200을 반환해도 그것이 무엇을 보장하는지 설명하기 어렵고, 반대로 unhealthy가 되어도 실제로 어느 부분이 고장났는지 알기 어렵다.
결국 Docker Health Check는 단순히 운영 환경에 붙이는 한 줄짜리 검사 명령이 아니었다.
서비스에서 무엇을 정상이라고 정의할 것인지 코드로 표현하는 작은 운영 규칙에 더 가까웠다.
그리고 그 정의가 단순하고 명확할수록 문제가 생겼을 때도 unhealthy라는 결과에서 실제 원인까지 훨씬 자연스럽게 따라갈 수 있다고 생각한다.