헬스체크가 서버를 죽인다 — liveness·readiness 분리

부하가 조금 올라 응답이 느려졌을 뿐인데, 헬스체크가 서버를 하나씩 빼더니 몇 분 만에 전부 멈췄어요. 살아 있는 서버를 시스템이 스스로 끄게 되는 판정 방식과 고치는 법을 다뤄요.

부하가 조금 올라 응답이 1초를 넘겼어요. 느려진 것뿐인데 헬스체크가 실패 판정을 내리면서 서버가 로드 밸런서에서 제외됐어요.

그러자 남은 서버가 그 부하를 나눠 받으면서 더 느려지고, 그 서버들도 차례로 제외돼요. 몇 분 만에 전부 빠지고 서비스가 완전히 멈춰요.

각 서버는 죽지 않았어요. 조금 느렸을 뿐이에요. 그런데 살아 있는 서버를 시스템이 스스로 하나씩 꺼 버린 거예요.

용량 부족이 전면 장애로 바뀌는 순간이에요. 그리고 그 전환을 만든 건 부하가 아니라 건강을 판단하는 방식이에요.

부하는 견딜 수 있는 수준이었는데, 판정 기준이 그걸 못 견디는 상황으로 바꾼 겁니다.

왜 그런가

헬스체크는 "이 서버가 트래픽을 받을 수 있는가"를 판단해요. 그런데 판단 기준이 잘못 잡히면 정상인 서버를 죽은 것으로 봅니다.

가장 흔한 실수가 헬스체크에 의존성을 포함하는 거예요. 헬스체크 엔드포인트에서 DB 를 조회하도록 만들어 두면, DB 가 느려지는 순간 모든 서버의 헬스체크가 동시에 실패해요. 서버 자체는 멀쩡한데 전부 제외돼요. 공유 의존성이 단일 실패 지점이 되는 거예요.

두 번째가 응답 시간으로 죽음을 판단하는 거예요. 느린 것과 죽은 것은 다른데, 타임아웃 하나로 둘을 같이 처리하면 부하가 올라 느려진 서버를 죽었다고 판정해요. 그리고 제외하면 남은 서버가 더 느려지니, 이건 스스로 강화되는 고리예요.

세 번째가 임계값이 너무 민감한 것. 한 번 실패에 바로 제외하면 일시적 지연에도 서버가 빠져요. 반대로 너무 둔하면 진짜 죽은 서버로 트래픽이 계속 갑니다.

그리고 이 셋이 겹치면 앞 편의 재시도 증폭과 만나요. 서버가 빠지면 남은 쪽 부하가 오르고, 느려지면 타임아웃이 늘고, 재시도가 늘어 부하가 더 오르고, 또 서버가 빠져요. 두 개의 고리가 서로를 밀어 올립니다.

코드로 보면

살아 있음과 준비됨을 나눈다:

코드
 GET /health  → DB 조회, 캐시 조회, 외부 API 확인
 DB 가 느려지면 모든 서버가 동시에 실패한다

· 살아 있음 — 프로세스가 정상인가. 의존성을 보지 않는다
 GET /healthz/live
 → 200 을 즉시 반환. 실패하면 재시작해야 한다는 뜻

· 준비됨 — 트래픽을 받을 수 있는가
 GET /healthz/ready
 → 기동 완료·커넥션 풀 준비 여부
 → 공유 의존성(DB)은 넣지 않거나 아주 관대하게

· 제외 임계값은 둔감하게, 복귀는 신중하게
 연속 3회 실패 시 제외, 연속 2회 성공 시 복귀
 헬스체크 타임아웃은 일반 요청보다 길게

✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.

실전에서는

핵심은 두 가지 질문을 분리하는 거예요.

"이 프로세스를 재시작해야 하는가"와 "이 서버에 트래픽을 보내도 되는가"는 다른 질문이에요. 앞의 것은 살아 있음, 뒤의 것은 준비됨이라고 부르고, 답도 다르게 나와야 해요.

살아 있음 검사는 프로세스 자체만 봐요. 응답만 하면 됩니다. 여기에 의존성을 넣으면, DB 가 느릴 때 오케스트레이터가 멀쩡한 프로세스를 계속 재시작해요. 재시작한다고 DB 가 나아지지 않으니 무한 재시작 고리에 빠집니다.

준비됨 검사는 트래픽 수용 가능 여부를 봐요. 기동이 끝났는지, 커넥션 풀이 준비됐는지 같은 것들이요. 여기에도 공유 의존성을 넣을지는 신중해야 해요. 넣으면 DB 지연 시 전 서버가 동시에 빠지고, 안 넣으면 DB 가 죽었는데도 트래픽을 받아 전부 에러를 냅니다.

현실적인 절충이 있어요. 의존성 상태를 보되 전부 빠지지는 않게 하는 거예요. 로드 밸런서 쪽에 최소 가용 비율을 두면, 절반 이상이 실패해도 나머지를 유지해요. "전부 실패하면 전부 유지한다"는 규칙이죠. 다 빼는 것보다 다 두는 게 언제나 낫습니다.

그리고 제외보다 부하 조절이 나은 경우가 많아요. 느린 서버를 빼는 대신 그 서버로 가는 트래픽 비중을 줄이는 방식이요. 이진 판단이 아니라 연속적인 조절이라 되먹임 고리가 덜 만들어져요.

기동 시간도 고려해야 해요. 새로 뜬 서버가 준비되기 전에 트래픽을 받으면 느리고, 그러면 또 제외돼요. 기동 유예를 충분히 주고, 캐시가 채워질 시간을 감안해야 합니다.

처방

  1. 살아 있음과 준비됨을 분리하세요. 살아 있음 검사에는 의존성을 절대 넣지 마세요.
  2. 헬스체크에서 공유 의존성을 확인하는 걸 재고하세요. 넣는다면 전 서버가 동시에 빠질 수 있다는 걸 알고 넣으세요.
  3. 로드 밸런서에 최소 가용 비율을 설정하세요. 전부 실패하면 전부 유지하는 규칙이 전면 장애를 막습니다.
  4. 제외 임계값을 둔감하게 잡으세요. 연속 실패를 요구하고, 헬스체크 타임아웃은 일반 요청보다 길게 두세요.
  5. 복귀는 신중하게 하세요. 빠졌던 서버가 한꺼번에 돌아오면 캐시가 비어 있어 다시 느려집니다.
  6. 이진 제외 대신 가중치 조절을 검토하세요. 연속적인 조절이 되먹임 고리를 덜 만듭니다.
  7. 기동 유예를 충분히 주세요. 준비되기 전 트래픽은 새 서버를 바로 다시 죽입니다.
  8. 헬스체크 실패의 원인을 로그로 남기세요. 무엇 때문에 실패했는지 모르면 조사할 수 없습니다.

지금 확인해 보세요

헬스체크가 무엇을 보고 있나:

코드
· 살아 있음 검사에 의존성이 들어 있지 않은지 코드로 확인
 /healthz/live 에서 DB·캐시·외부 API 를 호출한다면 잘못됐다

· 제외가 실제로 일어나고 있는지
 로드 밸런서의 정상 인스턴스 수를 지표로 본다
 이 값이 계단식으로 떨어지면 연쇄 제외 중이다

· 헬스체크 실패 사유별 카운터
 무엇 때문에 실패했는지 남기지 않으면 조사할 수 없다

· 최소 가용 비율이 설정돼 있는가
 전부 실패했을 때 전부 유지하는 규칙이 있는지 확인

· 헬스체크 자체의 부하
 서버 수 × 검사 주기 × 검사 비용
 DB 를 조회하는 검사라면 이것만으로도 상당하다

핵심 한 줄느린 것과 죽은 것은 다르다. 살아 있는 서버를 스스로 끄지 마라.

브루니가 적어요

색을 바꾸며 숨는 카멜레온이에요. 운영에서 실제로 터지는 문제를 한 편씩 적어요. 같은 실수를 두 번 하지 않으려고 쓰는 기록이에요.

배포·관측 글 16편 모두 보기 →