1초 헬스 체크 타임아웃이 데이터베이스를 중단시켰다

이미지 pull이 401 오류로 실패한다는 제보, 범인은 인증이 아니었습니다. 1초짜리 liveness probe가 회복 중인 DB를 반복 재시작시킨 장애를 어떻게 추적하고, 설계 결함으로 결론 내렸을까요? 그 전 과정을 기록했습니다.
안녕텐's avatar
Oct 26, 2026
1초 헬스 체크 타임아웃이 데이터베이스를 중단시켰다

AIPub 엔지니어링 블로그 · 장애 기록

이미지 pull이 401 Unauthorized로 실패한다는 제보에서 시작해, 인증과 아무 상관 없는 곳에서 끝난 이야기입니다. 그 과정에서 원인을 세 번 잘못 지목했고, 세 번 다 측정이 바로잡았습니다.

요약 — 1초짜리 헬스 체크가 잠깐 느려진 DB를 장애로 판단해 재시작시켰고, 재시작 후 복구 중인 DB를 다시 죽은 DB로 판단하는 루프에 빠졌습니다.

DB가 왜 느려졌는지는 측정하지 못해 추론으로 남아 있습니다. 다만 느려지는 순간 DB가 가장 먼저 밀리는 구조였다는 것은 확인했습니다.

항목

값

재시작

42회 (한 인스턴스)

크래시 주기

5.7분 — 예외 없이 규칙적

동시 중단

인스턴스에 얹힌 서비스 DB 전체

촉발 요인

1초 — 프로브 타임아웃

01 — 인증 오류처럼 생긴 것

이미지 레지스트리에서 pull이 실패한다는 제보를 받았습니다. 에러는 다음과 같았습니다.

401 unauthorized - Server message: unauthorized: unexpected EOF

인증 오류처럼 보입니다. 그런데 이상한 점이 있었습니다. 가끔 성공합니다.

이 한 줄이 사실 진단의 절반입니다. 잘못된 자격증명은 100% 결정적으로 실패합니다. 비밀번호가 틀렸는데 세 번에 한 번 로그인이 되는 일은 없습니다. 성공과 실패가 번갈아 나타난다면 자격증명 문제일 수 없습니다.

그러면 401은 왜 나오는가. HTTP 상태 코드는 원인이 아니라 분류함입니다. 인증 단계에서 무슨 일이 생기면 그게 무엇이든 401 통에 담겨 나옵니다. 뒤에 붙은 unexpected EOF — Go의 io.ErrUnexpectedEOF, “응답을 다 읽기 전에 연결이 끊겼다” — 가 진짜 힌트였습니다.

같은 시각 레지스트리 코어 로그에는 이렇게 찍혀 있었습니다.

unauthorized: failed to connect to `host=<database> user=postgres database=registry`:
  server error (FATAL: the database system is in recovery mode (SQLSTATE 57P03))

토큰을 발급하려면 데이터베이스가 필요한데, 그 데이터베이스가 복구 모드였습니다. 401과 데이터베이스를 잇는 줄은 이것 하나로 충분합니다.

401은 원인이 아니라 분류함이다

상태 코드는 증상이 어디서 드러났는지를 말할 뿐, 무엇이 잘못됐는지는 말하지 않습니다. 인증 경로에서 발생한 모든 사고는 401로 수렴합니다. 그래서 401을 보고 자격증명부터 확인하면 거의 항상 성과가 없습니다.

02 — 잘못된 원인 지목 세 번

여기서부터가 이 글을 쓰는 이유입니다. 원인을 세 번 잘못 지목했습니다. 세 번 다 그럴듯한 숫자를 인과로 승격시킨 것이 문제였습니다.

① “5분이면 인그레스 타임아웃이네”

실패까지 걸리는 시간을 물었더니 “5분쯤”이라고 했습니다. 인그레스 컨트롤러 설정을 확인하니 streamIdleTimeout: 300s가 있었습니다. 300초 = 5분. 정확히 일치했습니다.

맞지 않았습니다. 실제 주기는 5.7분이었고, 그건 전혀 다른 곳에서 나온 숫자였습니다. 상대가 준 숫자에 맞는 설정값을 찾아냈고, 그게 확증편향이 됐습니다. 가장 위험한 형태의 오류입니다 — 근거가 있어 보이기 때문입니다. 이 진단 때문에 실제로 인그레스 설정이 한 번 변경됐습니다. 해결책이 아니었습니다.

② “NFS가 93% 찼으니 느려서 그렇겠지”

로그에서 다음을 발견했습니다.

LOG: syncing data directory (pre-fsync), elapsed time: 17.19 s

그리고 데이터 디렉터리가 NFS 위, 93% 사용 중이었습니다. 설명이 성립하는 듯했습니다.

역시 아니었습니다. 정상 클러스터와 같은 지표를 나란히 측정해 보니 성립하지 않았습니다.

문제 환경

정상 환경

체크포인트 fsync

0.002–0.014초

0.003–0.010초

디스크 사용률

93%

1%

평상시 I/O 성능이 사실상 같았습니다. 93%는 상관관계였지 원인이 아니었습니다. 그리고 17초짜리 pre-fsync는 종료·기동 경로에서 찍힌 값이지, 헬스 체크와는 아무 상관이 없었습니다. 헬스 체크는 SELECT 1입니다 — 디스크를 건드리지 않습니다. 메모리에서 답합니다.

그런데 이 배제도 절반만 맞았습니다.

비교한 fsync 값은 전부 조용한 시간대의 것이었습니다. 16GB pull이 도는 중의 값은 한 번도 측정하지 않았습니다. “느리지 않다”를 확인한 구간이 애초에 틀렸던 것입니다 — 잘못된 판단을 바로잡는 과정에서 또 한 번 잘못된 구간에서 측정했습니다. 뒤에서 이 이야기로 돌아옵니다.

③ “우리 클러스터는 재시작이 없던데”

“파드는 재시작된 적이 없다”고 단언했습니다. 내부 클러스터에서 RESTARTS 0을 봤기 때문입니다. 문제 환경의 describe pod에는 이렇게 찍혀 있었습니다.

Restart Count:   42

한 환경에서 측정한 값을 다른 환경의 사실로 사용했습니다. 근거로 인용할 때는 어느 환경에서 측정한 값인지 매번 명시해야 합니다.

03 — 주기가 원인을 지목했다

describe pod 한 줄이 전부였습니다.

Liveness: exec [/docker-healthcheck.sh] delay=300s timeout=1s period=10s #failure=3

먼저, 이 한 줄이 무슨 뜻인가

숫자가 넷 있습니다. 각각이 무엇을 정하는지 정리해 둡니다 — 뒤에 나올 산술이 전부 여기서 나옵니다.

⚠️ 한 가지 주의할 점이 있습니다. describe pod는 축약된 이름으로 출력하고, 실제 스펙 필드명은 따로 있습니다. 이 둘을 나란히 두지 않으면 문서를 찾을 때 헤맵니다.

describe pod 출력

실제 스펙 필드

하는 일

delay=300s

initialDelaySeconds: 300

컨테이너 기동 후 첫 검사까지 기다리는 시간. 기동이 느린 앱을 위한 유예

timeout=1s

timeoutSeconds: 1

검사 한 번에 주는 시간. 넘기면 그 회차는 실패 처리

period=10s

periodSeconds: 10

검사 주기. 이 간격으로 반복한다

#failure=3

failureThreshold: 3

몇 번 연속 실패해야 조치하는가. liveness면 컨테이너를 종료시킨다

describe pod 출력에서 initialDelaySeconds를 아무리 찾아도 안 나옵니다 — 거기엔 delay로 적혀 있습니다. 실제 필드명 그대로 확인하려면 kubectl get statefulset <name> -o yaml처럼 원본 스펙을 봐야 합니다. 이 글의 이후 설명과 YAML은 전부 오른쪽 열의 스펙 필드명을 씁니다.

핸들러는 exec입니다. kubelet이 컨테이너 안에서 /docker-healthcheck.sh를 실행하고, 그 스크립트가 psql로 루프백 TCP에 접속해 SELECT 1을 수행합니다. 종료 코드가 0이면 성공.

실패하면 무슨 일이 일어나나

검사 한 번이 timeoutSeconds를 넘기면 그 회차는 실패로 기록될 뿐, 아직 아무 조치도 없습니다. failureThreshold(3회) 연속으로 실패해야 kubelet이 컨테이너를 종료시킵니다. RESTARTS 카운트를 올리는 바로 그 경로입니다.

컨테이너가 종료되면 그 안의 데이터베이스 인스턴스 전체가 함께 내려갑니다. 이 인스턴스에는 여러 서비스의 데이터베이스가 함께 운영되고 있었으므로, 컨테이너 종료 한 번이 그 데이터베이스를 한꺼번에 중단시켰습니다. 그리고 정상 종료되지 못한 채 다시 기동하면 PostgreSQL은 crash recovery부터 수행합니다 — 1절의 recovery mode (SQLSTATE 57P03)가 바로 그 상태입니다.

문제 환경의 describe pod에 남은 흔적은 이것이었습니다.

필드

값

뜻

Restart Count

42

컨테이너가 42번 종료·재기동됐다

Last State 종료 코드

141

컨테이너가 이 코드로 끝났다 (128 + 13 = SIGPIPE)

141은 이 사건에서 끝내 설명하지 못한 숫자입니다. 5절에서 다시 다룹니다.

간격을 세어 보면

로그에서 중단 시각을 추려 보니 07:24부터 08:50까지 1시간 26분 동안 16회였습니다. 그 사이 간격 15개를 분포로 정리하면 이렇습니다.

간격

발생 횟수

5분

4회

6분

11회

그 외

0회

합계

15개 간격 / 86분

5~6분을 벗어난 값이 하나도 없습니다. 평균 5.7분. 부하나 요청량에 따라 흔들리는 장애라면 이렇게 나올 수 없습니다 — 이 규칙성 자체가 설정값에서 나온 주기라는 뜻입니다.

그래서 프로브 설정과 대조했습니다.

프로브 설정

값

initialDelaySeconds — 기동 후 첫 검사까지

300초

failureThreshold × periodSeconds — 3연속 실패까지

3 × 10초 = 30초

합계 — 기동부터 강제 종료까지

330초 = 5.5분

실측 평균 간격

5.7분

5.5분과 5.7분. 컨테이너가 기동하고 프로브 유예시간을 넘겨 살아남은 적이 한 번도 없었다는 뜻입니다. 매번 같은 지점에서 종료됐으니 간격이 설정값 그대로 나옵니다.

주기적으로 종료된다면 이렇게 산술로 원인을 찾을 수 있습니다. 크래시 간격이 initialDelay + failureThreshold × period와 일치하면 원인은 프로브입니다.

04 — 복구를 막는 순환 구조

가장 심각한 부분은 따로 있었습니다.

crash recovery 중 PostgreSQL은 연결을 거부합니다. FATAL: the database system is in recovery mode (SQLSTATE 57P03) — 정상 동작입니다. 복구가 끝나기 전에 쿼리를 받으면 안 되니까요.

그런데 liveness 프로브가 exec로 psql을 실행해 쿼리를 전송합니다. 그러니 복구 중에는 프로브가 설계상 반드시 실패합니다. 한 바퀴를 순서대로 따라가 보면 이렇습니다.

한 번 실패하면 스스로 복구하지 못한다

무슨 일이 일어나는가

①

프로브 응답이 3회 연속 1초를 초과한다
psql 프로세스 기동이 밀리기만 해도 발생한다 — DB 자체는 정상이어도 된다

↓

②

kubelet이 컨테이너를 종료시킨다
그 안의 인스턴스와 모든 데이터베이스가 함께 내려간다

↓

③

재기동한 PostgreSQL이 crash recovery에 들어간다
정상 종료되지 못했으므로 복구부터 해야 한다

↓

④

복구 중에는 연결을 거부한다 — SQLSTATE 57P03
정상 동작이다. 그런데 프로브는 이것을 실패로 읽는다 — 설계상 반드시 실패한다

↺

④의 실패가 곧 다음 ①이다. 복구가 끝나기 전에 프로브가 시작되면, 복구 중인 DB가 다시 ②로 밀려 들어간다

자원을 아무리 늘려도 이 순환은 끊기지 않습니다. ④가 자원 부족 때문이 아니라 PostgreSQL의 정상 동작이기 때문입니다. CPU를 더 주면 ①의 빈도가 줄 뿐, 한 번 ③에 들어가고 나면 빠져나올 길이 없습니다.

liveness를 tcpSocket으로 변경하면 ④가 사라집니다. 복구 중에도 포트는 열려 있으므로 프로브가 통과하고, DB는 끝까지 복구할 수 있습니다. 그 하나로 순환이 끊깁니다.

복구를 방해하는 신호로 살아 있음을 판정하면 복구가 영원히 끝나지 않습니다. 이 순환의 핵심은 ④입니다 — 프로브가 “DB가 정상적으로 복구 중” 인 상태와 “DB가 정지” 를 구분하지 못합니다.

8절의 로그 database system was interrupted while in recovery가 이 순환의 직접 증거입니다. 복구 도중에 다시 종료됐다는 뜻입니다.

05 — 종료 코드가 말해준 것

종료 코드는 가설을 지우는 데 썼습니다. 그리고 한 가지는 끝내 설명하지 못했습니다.

코드

의미

관찰

137

128+9 = SIGKILL — OOMKill은 반드시 이 코드로 끝난다

나타나지 않았다

141

128+13 = SIGPIPE

Last State에 찍힌 컨테이너 종료 코드

OOM이었다면 137이어야 합니다(Reason: OOMKilled가 함께 찍힙니다). 메모리 문제가 아니었습니다. 이 한 가지 확인으로 가설 하나를 근거와 함께 배제할 수 있었습니다. 단, 방향에 주의해야 합니다 — OOM이면 137이지만, 137이라고 OOM은 아닙니다. 종료 유예시간을 넘겨 강제 종료돼도 137입니다.

141은 설명하지 못했다

처음에는 이렇게 해석했습니다. “프로브가 종료되며 소켓이 끊겼고, 응답을 쓰던 PostgreSQL 백엔드가 SIGPIPE로 죽었다.” 그럴듯하지만 성립하지 않습니다. PostgreSQL은 postmaster와 백엔드 모두 SIGPIPE를 무시하도록 설정합니다(postmaster.c·postgres.c의 pqsignal(SIGPIPE, SIG_IGN)). 클라이언트가 떠나면 백엔드는 조용히 정상 종료할 뿐입니다. 그리고 애초에 141은 컨테이너의 종료 코드였습니다 — 백엔드 하나가 죽은 흔적이 아닙니다.

postmaster가 PID 1이라면 141로 끝날 수 없습니다. 그렇다면 PID 1은 엔트리포인트 셸이었을 가능성이 높습니다. 다만 장애 환경을 다시 확인할 수 없어 확정하지 못했습니다.

이 글의 전반부는 숫자를 인과로 승격시켜 틀린 기록입니다. “141 = SIGPIPE”라는 산술에서 곧장 메커니즘을 세운 것도 같은 실수였습니다 — 그래서 여기서는 “컨테이너가 141로 종료됐다”까지만 씁니다.

왜 하필 큰 이미지에서만

pull이 실패한 이미지는 16GB였습니다. “용량이 커서 타임아웃 아닐까” 하는 가설이 자연스럽게 나옵니다. 타임아웃은 아니었습니다. 다만 크기는 진짜로 관련이 있었습니다 — 그리고 그 이유가 무엇인지가 이 글의 마지막 반전입니다.

06 — 경합은 왜 생겼나: 한 노드에 전부

여기까지는 “프로브가 1초를 넘겼다”에서 멈춰 있습니다. 왜 넘겼는지는 아직 답이 아닙니다. 토폴로지를 받아 보니 답의 절반이 구조에 있었습니다.

고객 환경은 워커 1노드 + 마스터 1노드입니다. HA가 없습니다. 그리고 이 제품의 Helm 차트는 데이터베이스를 포함한 레지스트리 전 컴포넌트를 requiredDuringScheduling으로 control-plane에 고정합니다. 마스터가 1대면 선택지가 없습니다.

그리고 pull이 스스로 경합을 만든다

앞에서 “NFS는 원인이 아니다”라고 배제했던 것을 여기서 되짚어야 합니다. 배제가 틀렸던 게 아니라, 경로를 구분하지 않은 채 배제했던 것이 문제였습니다. 헬스 체크가 스토리지 때문에 느려질 수 있는 경로를 하나씩 배제해 보면 다음과 같습니다.

경로

스토리지와 관계

TCP 연결

--host localhost — 루프백입니다. 무관

psql 바이너리 실행

컨테이너 로컬 디스크입니다. 무관 (이쪽은 CPU 문제)

인증·카탈로그 조회

대개 공유 버퍼에 있습니다. 대체로 무관

WAL 쓰기 정체 → 인스턴스 전체 정체

이 경로 하나만 유효합니다

SELECT 1이 디스크를 사용하지 않는다는 것은 맞습니다. 그런데 WAL 쓰기가 지연되면 인스턴스 전체가 지연되고, 디스크를 사용하지 않는 쿼리도 실행 순서를 받지 못합니다. ②에서 배제한 것은 “헬스 체크가 직접 디스크를 기다린다”는 경로였고, 그 배제는 지금도 유효합니다. 남은 경로는 하나뿐입니다.

그리고 레지스트리의 이미지 저장소와 데이터베이스의 볼륨은 같은 파일러를 사용합니다.

워커가 16GB pull 요청
  → 레지스트리(마스터 노드)가 NFS에서 16GB를 읽어 스트리밍
  → 같은 파일러로 DB의 WAL fsync가 경쟁
  → 마스터 노드의 CPU·I/O 부하 상승
  → BestEffort DB가 후순위로 밀림 → 프로브 타임아웃 → 중단 → pull이 401
  → kubelet이 자동 재시도 → 처음으로

이게 맞다면 “큰 이미지에서만 발생한다”가 확률이 아니라 인과입니다. pull이 자신을 중단시킬 경합을 스스로 만드는 구조입니다.

그리고 이 순환은 사람의 개입 없이도 반복됩니다 — 워크로드 배포에서 이미지 pull이 실패하면 kubelet이 자동으로 재시도합니다. 1시간 반 동안 16번이 시계처럼 반복된 이유입니다.

무엇이 지연됐나 — 후보는 둘, 산술이 한쪽을 가리킨다

“프로브가 1초를 넘겼다”까지는 확정입니다. 무엇 때문에 넘겼는지는 두 갈래로 남아 있고, 다행히 둘은 서로 다른 흔적을 남깁니다.

후보

무엇이 지연되나

판별 신호

노드 CPU 경합

psql 프로세스 생성이 지연된다

노드 CPU가 급등하고 체크포인트 sync=는 그대로

링크 포화 → 스토리지 정체

인스턴스 전체가 정체된다

sync=가 급등하고 CPU는 여유가 있을 수 있다

뒤늦게 들어온 토폴로지 제보가 두 번째 가능성을 열었습니다. 마스터는 스토리지 전용망에 연결돼 있지 않습니다. 워커를 게이트웨이 삼아 NAT로 파일러에 접근하고, 그 마스터의 NIC는 1G입니다.

그러면 16GB pull 한 번 동안 마스터의 1G 링크는 내내 점유됩니다 — 파일러에서 읽어 들이고, 다시 워커로 내보냅니다. 같은 링크 위에서 DB의 WAL 쓰기도 파일러로 가야 합니다.

16GB ÷ 1Gbps ≈ 2.1분   (방향별 이론 최소)
실측 pull 소요          6–7분
크래시 주기             5.7분

⚠️ 처음에는 읽기와 내보내기를 더해 32GB ÷ 1Gbps ≈ 4.3분으로 계산했고, 실측 소요와 크래시 주기에 가까워 보였습니다. 틀린 산술이었습니다. 이더넷은 전이중이라 들어오는 방향과 나가는 방향이 합산되지 않습니다. 숫자가 맞아떨어지게 계산을 고른 셈입니다 — 이 글이 경고하는 바로 그 실수입니다.

바로잡은 숫자로 보면, pull은 선로 속도의 3분의 1 수준으로 진행됐습니다. 링크가 포화였는지는 이 산술로 말할 수 없습니다.

다만 이 절은 아직 측정하지 않았습니다. “구조가 그렇다”까지가 확정이고, “그래서 실제로 경합이 났다”는 추론입니다. 그리고 이 글의 전반부는 숫자가 맞아떨어지는 것을 인과로 승격시켜 세 번 틀린 기록입니다 — 여기서도 같은 함정에 빠질 수 있습니다. pull이 도는 중에 노드 CPU·프로브 소요시간·체크포인트 sync=를 함께 측정하면 위 표가 한 번에 판별됩니다.

그전까지 설명은 “경합이 있었다”가 아니라 “경합이 생기면 DB가 가장 먼저 밀리는 구조였다”로 쓰는 게 맞습니다. 확정된 것만으로도 충분히 성립합니다.

워커를 늘려도 해결되지 않는다

자연스럽게 나오는 대응은 “노드를 늘리자”입니다. 이 구조에서는 효과가 없습니다.

레지스트리 전 컴포넌트가 control-plane에 고정돼 있으므로 데이터베이스는 워커를 몇 대 추가하든 계속 마스터에 남습니다. 그리고 워커가 늘면 그 워커들이 받아 갈 pull 트래픽이 전부 마스터의 1G를 지나므로, 늘릴수록 병목이 심해집니다.

부가적으로 지적해 둘 위험이 하나 더 있습니다. 마스터의 NFS 접근이 특정 워커를 경유한다면, 그 워커가 정지하는 순간 데이터베이스가 스토리지를 잃습니다. 워커를 늘린다고 이 경로가 이중화되지도 않습니다.

07 — 고친 것

먼저 무엇을 어디에 적용했는지부터 적어 둡니다. 장애 환경에서는 우선 프로브 타임아웃을 20초로 올려 재시작을 멈췄습니다. 아래의 교정(프로브 분리·자원 요청)은 신규 설치의 기본값으로 반영했고, 이미 운영 중인 환경은 데이터베이스를 한 번 재기동해야 하는 작업이라 점검 시간에 따로 적용합니다.

먼저: 왜 1초였나 — 차트에 답이 적혀 있었다

수정하기 전에 확인할 것이 있었습니다. 업스트림이 이 값을 왜 1초로 설정했는가. 최적 환경을 가정한 것일까요? 차트의 values.yaml을 확인하니 답이 주석에 있었습니다.

# The timeout used in livenessProbe; 1 to 5 seconds
livenessProbe:
  timeoutSeconds: 1
# The timeout used in readinessProbe; 1 to 5 seconds
readinessProbe:
  timeoutSeconds: 1

“1 to 5 seconds”라는 범위를 명시해 놓고 그 하한을 기본값으로 설정했습니다. 그리고 1초는 Kubernetes의 timeoutSeconds 기본값이기도 합니다.

즉 1초는 최적값으로 선택한 것이 아니라 변경하지 않았을 때의 값입니다.

더 결정적인 게 있습니다. 차트 전체에서 프로브 타임아웃을 노출한 컴포넌트가 데이터베이스 하나뿐입니다. 다른 어느 컴포넌트에도 이 설정 항목이 없습니다.

아무도 문제를 겪지 않는 설정을 조절 가능하게 열어 두지는 않습니다. 업스트림도 이 값이 환경에 따라 부족하다는 것을 알고 조절 가능하게 열어 두었습니다. 우리가 그 설정을 사용하지 않았을 뿐입니다.

같은 파일에서 하나 더 나왔습니다.

# resources:
#  requests:
#    memory: 256Mi
#    cpu: 100m
priorityClassName:

자원 요청 예시까지 주석으로 적혀 있었습니다. 앞에서 “업스트림 기본값을 그대로 사용했다”고 표현했는데, 더 정확히는 차트가 권장한 것을 활성화하지 않은 것입니다.

값은 어떻게 선택하나 — 비대칭을 보라

프로브 타임아웃은 두 위험 사이의 절충입니다. 그런데 손실이 한쪽으로 크게 기웁니다.

너무 짧으면

너무 길면

정상인 DB를 종료시킨다 → crash recovery → 데이터베이스 중단 → 데이터 손상 위험

실제로 정지한 DB를 늦게 감지 → 그동안 요청 실패

늦게 감지하면 요청이 실패할 뿐이고, 잘못 종료시키면 상태가 손상됩니다. 비대칭이 명확하므로 길게 설정하는 쪽이 옳습니다. 그래서 쿼리로 확인하는 프로브에는 문서화된 범위의 상한인 5초를 선택했습니다.

그리고 더 나은 방법이 하나 더 있습니다 — 절충 자체를 하지 않는 것입니다.

liveness와 readiness의 역할을 분리했다

# "프로세스가 동작 중인가" — kubelet이 직접 확인, 컨테이너 CPU 불필요
livenessProbe:
  tcpSocket: { port: 5432 }
  initialDelaySeconds: 60
  periodSeconds: 10
  timeoutSeconds: 3        # 생략하면 기본값 1초 — 이 글이 문제 삼은 바로 그 값
  failureThreshold: 6      # 60초 동안 연속으로 포트가 닫혀 있어야 종료

# "쿼리에 응답하는가" — 실패해도 트래픽만 제외된다
readinessProbe:
  exec: { command: ["/docker-healthcheck.sh"] }
  initialDelaySeconds: 10
  periodSeconds: 10
  timeoutSeconds: 5
  failureThreshold: 3

⚠️ timeoutSeconds는 생략하면 1초입니다. tcpSocket은 커널이 연결을 받아 주므로 1초로도 대개 충분하지만, 기본값에 기대지 않고 명시했습니다.

liveness는 “프로세스가 동작 중인가”만 확인해야 합니다. 느린 것은 정지한 것이 아닙니다.

복구 중에도 포트는 열려 있으니 tcpSocket은 통과하고, DB는 끝까지 복구할 수 있습니다. 트래픽 차단은 readiness가 담당합니다. 이 분리가 앞의 순환을 끊습니다.

부가적으로, exec 프로브는 컨테이너 안에서 프로세스를 새로 생성합니다(fork + exec + TCP 연결 + 인증). tcpSocket은 kubelet이 자기 프로세스에서 확인하므로 컨테이너의 CPU 사정과 무관합니다.

이것은 우리만 빠진 함정이 아닙니다. 쿠버네티스 자신이 etcd에서 똑같이 겪었습니다 — etcdctl get foo를 liveness로 사용하다가 kubelet이 정상인 etcd를 재시작시켰고, 이는 컨트롤 플레인 전체가 불안정해진다는 뜻이었습니다. kubeadm은 결국 exec를 제거하고 HTTP /health로 전환했습니다(kubeadm#1708). exec 프로브 자체의 비용도 업스트림 이슈로 남아 있습니다 — 자주 실행되는 프로브가 자신이 감지하려던 바로 그 타임아웃을 만들어냅니다.

그런데 조절 가능한 항목이 하나뿐이었다

위 YAML은 “이렇게 되어야 한다”이지, 그대로 적용할 수 있는 값이 아니었습니다. 차트가 values로 열어 둔 프로브 설정은 timeoutSeconds 하나뿐이고, 종류(exec/tcpSocket)·initialDelaySeconds·periodSeconds·failureThreshold는 템플릿에 하드코딩돼 있습니다. 앞 절에서 “업스트림이 조절 가능하게 열어 두었다”고 했는데, 정작 가장 수정이 필요한 항목에는 해당되지 않았습니다.

그래서 설치 후 kubectl patch로 프로브를 교체하는 쪽을 선택했습니다. 여기에 함정이 하나 있습니다.

{ "livenessProbe": { "exec": null, "tcpSocket": { "port": 5432 } } }

"exec": null을 빠뜨리면 두 핸들러가 공존한 상태가 되어 API 서버가 거부합니다. strategic merge patch는 삭제를 명시해야 삭제합니다. 그리고 --dry-run=server를 추가하면 롤아웃 없이 이 검증만 수행할 수 있습니다 — 다운타임 작업 전에 한 번 실행할 가치가 있습니다.

자원 요청을 부여했다

이 DB는 resources: {}였습니다. 그래서 QoS가 BestEffort였습니다. 장애 환경에서는 가중치를 직접 읽지 못했으므로, 같은 차트·같은 설정으로 운영 중인 내부 클러스터에서 같은 노드의 다른 워크로드와 나란히 cgroup 가중치를 읽어 보니 다음과 같았습니다.

cgroup cpu.weight 실측 — 값이 클수록 CPU 경합에서 유리하다

여기서 눈여겨볼 것은 100입니다. cgroup v2에서 아무것도 설정하지 않은 프로세스의 기본 가중치가 100입니다 — 노드의 systemd, kubelet, 커널 작업이 전부 그 값을 갖습니다. 1코어를 요청한 파드가 정확히 그 값에 해당하고,

requests를 비워 둔 데이터베이스는 그 100분의 1입니다.

“우선순위가 낮다”는 표현으로는 부족합니다. 두 자릿수 배율로 밀립니다.

⚠️ 이 숫자는 컨테이너 런타임 버전에 의존합니다. 쿠버네티스는 cgroup v1의 cpu.shares를 v2의 cpu.weight로 변환하는데, 이 변환식이 2026년 1월에 바뀌었습니다. 옛 선형 공식에서는 같은 워크로드가 250m→10, 1코어→39를 받았습니다 — 1코어를 요청해도 기본값 100의 40%가 안 됐다는 뜻이고, 그게 공식을 바꾼 이유였습니다(kubernetes#131216). 위 값은 새 공식(runc 1.3.2+)에서 읽은 것입니다.

어느 공식이든 requests가 없으면 1이라는 것만은 변하지 않습니다. 바뀌는 건 격차의 크기뿐입니다.

두 가지 함정이 있어서 적어 둡니다.

  • CPU limit은 설정하지 않았습니다. CFS 쿼터 스로틀링이 지금 막으려는 지연을 다시 만듭니다. 하한만 보장하고 상한은 두지 않는 게 맞습니다.

  • 메모리 limit을 request와 동일하게 설정하지 않았습니다. request 2Gi · limit 3Gi로 한 칸 여유를 뒀습니다. 동일하게 제한하면 OOMKill로 같은 문제가 다른 경로로 재현됩니다. 근거는 실사용량입니다 — 내부 클러스터 실측 678Mi, 활성 연결 167개. 참고로 max_connections 1024 × work_mem 4MB ≈ 4GB는 최악값이 아닙니다 — work_mem은 연결당이 아니라 정렬·해시 연산당이라, 이론상 상한은 이보다 큽니다. 연결 수가 설정 상한에 가까워지는 환경이라면 limit을 다시 산정해야 합니다.

08 — “장비가 부족해서”가 아니었다

장애 환경에서 프로브 타임아웃을 20초로 올리자 재시작이 멎었습니다. 그때 자연스럽게 이런 정리가 나왔습니다. “결국 컴퓨팅 파워가 부족해서 생긴 일이네.”

그 결론을 반증한 것이 완화 결과 자체였습니다.

자원도 노드 구성도 그대로였습니다. 컴퓨팅 파워가 진짜 부족했다면 타임아웃을 올려도 DB는 여전히 느려서 사용할 수 없어야 합니다. 그런데 정상 동작합니다. 즉 DB는 처음부터 제 역할을 하고 있었습니다. 견디지 못한 것은 DB가 아니라 1초짜리 프로브였습니다.

SELECT 1이 1.2초 걸리는 것은 DB가 정지했다는 뜻이 아닌데, 그것을 정지로 판정해 종료시킨 것입니다. 이 구분은 표현의 문제가 아닙니다.

내용

귀속

촉발 조건

노드 자원 경합, 스토리지 지연

환경

결함

그 조건에서 시스템이 스스로를 파괴하는 구성

제품

일시적 1초 지연에 대한 정상적인 반응은 “잠깐 느림”입니다. 실제 반응은 컨테이너 강제 종료 → 비정상 종료 → crash recovery → 데이터베이스 동시 중단이었고, 마지막에는 다음 로그까지 기록됐습니다.

LOG:  database system was interrupted while in recovery
HINT: ... some data is corrupted and you will have to use the last backup

복구 도중에 다시 종료되면 PostgreSQL은 백업으로 복원하라고 안내합니다. 1초짜리 타임아웃 하나가 도달할 수 있는 상태로는 지나치게 심각합니다. 원인 대비 반응이 지나치게 과도합니다. 용량 문제가 아니라 설계 취약성입니다.

원인을 “환경 탓”으로 정리하면 같은 기본값이 다음 환경에도 그대로 배포됩니다.

실제로 우리 내부 클러스터는 설정이 글자 하나 다르지 않았습니다. 문제가 발생하지 않던 이유는 안전해서가 아니라 부하가 낮아서였습니다 — 헬스 체크 실측 55ms, 1초 대비 18배 여유. 부하가 높은 날 하루면 같은 일이 발생합니다.

09 — 남은 것

진단에 대하여

  • 간헐적 성공은 그 자체로 증거입니다. 결정적으로 실패해야 할 가설을 배제합니다.

  • HTTP 상태 코드는 분류함입니다. 401은 “인증 단계에서 문제가 발생했다”이지 “자격증명이 틀렸다”가 아닙니다.

  • 주기적 실패는 산술로 원인을 찾습니다. initialDelay + failureThreshold × period와 맞으면 프로브입니다.

  • 종료 코드로 가설을 배제하세요. 137이 아니면 OOM이 아닙니다.

  • 숫자가 맞아떨어지는 것은 인과가 아닙니다. 그 층에서 직접 측정하고, 건강한 기준선과 나란히 비교해야 합니다.

  • 종료 코드의 산술은 설명이 아닙니다. 141 = 128 + 13은 계산일 뿐입니다. 그 신호로 그 프로세스가 실제로 죽는지는 소스로 확인해야 합니다 — PostgreSQL은 SIGPIPE를 무시합니다.

  • 측정한 구간이 맞는지도 확인하세요. 한가한 시간대에 측정하고 “정상”이라 단정하면, 부하가 걸린 순간의 상태를 전부 놓칩니다.

설계에 대하여

  • liveness는 “동작 중인가”만 확인해야 합니다. “빠른가”를 확인하면 느린 것을 종료시킵니다.

  • DB에 1초 타임아웃 프로브를 설정하지 마세요. 프로브가 장애를 감지하는 게 아니라 만들게 됩니다.

  • 복구 중 거부하는 컴포넌트에 exec 프로브를 설정하면 루프가 생깁니다. 복구를 방해하지 않는 신호를 선택해야 합니다.

  • stateful 워크로드에 resources를 비워 두지 마세요. BestEffort는 cgroup 가중치 최저값입니다.

  • 기본값이 “선택한 값”인지 “변경하지 않은 값”인지 확인하세요. 업스트림 주석에 범위가 적혀 있는데 하한을 사용하고 있다면, 그건 선택이 아니라 미설정입니다.

  • 임계값은 비대칭으로 선택합니다. 두 방향의 실패 비용이 다르면 비용이 낮은 쪽으로 기울이세요.

  • 한 인스턴스를 여럿이 공유한다면 그 하나가 어떤 방식으로 중단되는지 전부 확인해야 합니다.

  • 차트가 수정 가능하게 열어 둔 것과, 수정해야 하는 것은 다릅니다. 조절 항목이 없으면 설치 후 패치가 해결책이고, 그 패치도 형상 관리의 일부로 다뤄야 합니다.

  • 노드를 늘리는 것이 답이 아닐 수 있습니다. 스케줄링이 고정돼 있으면 늘어난 노드는 부하만 보내고 병목은 그대로입니다. 늘리기 전에 어디가 병목인지부터 측정하세요.

더 읽을거리


고객사·호스트명·티켓 번호는 익명화했습니다. 로그와 수치는 실제 값입니다.

진단 과정에서 확인한 것 중 본문에 담지 못한 것: 토큰 응답 크기(825–848 bytes, 이미지 이름 길이와 무관), 인그레스 경유 47초 전송 정상 완료, 로봇 계정 만료 없음 — 전부 가설을 지우는 데 쓰였습니다.

Share article