블로그

2026년 8월 15일 · 9분 읽기

쿠버네티스 사이드카 readiness: 컨테이너 하나가 파드 전체를 내릴 때

readiness probe가 없는 사이드카가 크래시해 파드 전체가 Service 엔드포인트에서 빠진 사건입니다. SPA는 200인데 API만 간헐 503이던 증상을 따라갑니다.

  • 쿠버네티스
  • readiness probe
  • 장애 진단
  • 셀프호스팅

2026년 8월 23일 업데이트

weather.example.net을 열면 화면이 떴습니다. 데이터 패널만 전부 "불러오기 실패"였습니다.

/는 항상 200이고 /v1/*만 간헐적으로 503이었습니다. API 컨테이너는 계속 ready였고 재시작도 0이었습니다.

같은 파드의 다른 컨테이너가 죽고 있었습니다. 그것 때문에 건강한 API가 Service 엔드포인트에서 빠졌습니다.

컨테이너 하나가 503까지 이어진 연쇄

한눈에 보기

문제 `weather.
결정 왜 나눴는지가 매니페스트 주석 세 줄에 남아 있습니다.
결과 크래시 창에서 엔드포인트를 직접 세어 확인했습니다.
제약 엔드포인트를 세는 것이 그래서 진단의 첫 줄이 아니라 두 번째 줄입니다.

SPA는 200인데 API 엔드포인트가 사라졌습니다

화면 전체가 내려간 것처럼 보였지만 경로마다 달랐습니다.

요청 결과
/ 항상 200
/v1/* 간헐 503, "no available server"
SPA 데이터 패널 전부 "불러오기 실패"

/는 정적 파일이고 /v1/*는 API Service를 지납니다. 첫 화면이 열리는 것과 API가 사는 것은 다른 사실입니다.

경로가 갈리는 자리는 Ingress 한 장입니다. 오늘 다시 읽어 보니 규칙이 여섯 줄이고, 그중 다섯 줄이 API로 갑니다.

# kubectl -n posy-weather get ingress posy-weather (2026-08-15 읽기)
weather.example.net/v1        → posy-weather        # API
weather.example.net/healthz   → posy-weather
weather.example.net/readyz    → posy-weather
weather.example.net/livez     → posy-weather
weather.example.net/metrics   → posy-weather
weather.example.net/          → posy-weather-web    # SPA 정적 파일

한 호스트 이름 뒤에 Service가 둘 있습니다. 브라우저 주소창은 하나이니 사용자에게는 사이트 하나입니다. 화면이 뜨는 것은 마지막 줄이 살아 있다는 뜻이고, 데이터가 오는 것은 위의 다섯 줄이 살아 있다는 뜻입니다. 둘은 따로 죽습니다.

파드는 컨테이너 세 개였습니다. api, nowcast-loop, daily-loop입니다. 요청을 받는 서버와 주기적으로 도는 배치 워커가 한 파드에 있었습니다.

nowcast-loop가 exit 1로 죽고 있었습니다. CrashLoopBackOff 백오프가 5분이고, 8시간 동안 56번 넘게 다시 떴습니다.

not-running 컨테이너 하나가 파드를 not-Ready로 만듭니다

nowcast-loop에는 readiness probe가 없었습니다. probe가 없으면 그 컨테이너의 준비 여부를 묻지 않으니 파드에 영향이 없을 것 같습니다.

아닙니다. probe가 없어도 컨테이너가 실행 중이 아니면 파드는 Ready가 아닙니다. probe는 실행 중인 컨테이너가 요청을 받을 준비가 됐는지를 묻는 장치이고, 실행 자체가 멈춘 것은 그 앞의 조건입니다.

순서 무엇이 일어났나 밖에서 보이는 것
1 nowcast-loop exit 1 워커가 멈춥니다
2 CrashLoopBackOff, 백오프 5분 파드 안에 not-running 컨테이너가 생깁니다
3 파드 Ready=False probe가 없어도 그렇습니다
4 Service 엔드포인트 0개 Traefik이 보낼 곳이 없어집니다
5 /v1 503 데이터 패널이 비웁니다

크래시 창에서 엔드포인트를 직접 세어 확인했습니다. kubectl -n posy-weather get endpoints posy-weather가 0개였고 파드는 Ready=False였습니다.

api는 그 시간에도 ready이고 재시작 0이었습니다. 자기 잘못이 아닌 이유로 트래픽에서 빠졌습니다.

nowcast-loop는 뜨고 26초 뒤에 죽었습니다. startedAt 20:20:15, finishedAt 20:20:41입니다. 백오프가 5분이니 5분마다 5분씩 파드가 not-Ready가 되는 셈이었습니다. 그래서 503이 "간헐"이었습니다.

traceback을 못 얻은 이유는 버퍼링이었습니다

nowcast-loop가 실행하는 명령은 python -m posy_platform.cli nowcast-loop --store-dir /workspace/var/store --interval 300입니다.

로그에 남은 것이 여기까지입니다.

STEPS 초기화
Rain fraction 0.0046
Extrapolation complete and precipitation fields aligned
(exit 1)

직전 경고가 하나 있었습니다. x contains non-finite values입니다. 레이더 입력에 NaN이나 inf가 있었다는 뜻입니다.

정확한 traceback은 없었습니다. Python이 stdout을 버퍼에 담고 있었고 PYTHONUNBUFFERED가 설정돼 있지 않았습니다. 프로세스가 죽을 때 버퍼에 있던 출력이 그대로 사라졌습니다.

그래서 "pysteps STEPS 앙상블 계산 중에 죽었다"까지가 말할 수 있는 범위입니다. 어느 줄에서 무슨 예외가 났는지는 모릅니다. 경고와 exit 1 사이에 무엇이 있었는지도 모릅니다.

버퍼링은 성능을 위한 기본값입니다. 컨테이너에서는 그 기본값이 진단을 지웁니다. 프로세스가 정상 종료하면 버퍼가 비워지는데, 크래시는 정상 종료가 아닙니다.

두 옵션을 적었고 나중에 둘 다 들어갔습니다

구조 자체가 문제였습니다. 자주 죽는 배치 워커와 요청을 받는 서버가 한 파드에 있고, 그 사이에 격리가 없었습니다.

당시 조사는 읽기만 했습니다. 클러스터를 바꾸지 않았고, 대신 방향을 둘로 적어 뒀습니다.

옵션 무엇을 바꾸나 무엇이 남나
A 워커를 API 파드에서 떼어 별도 Deployment로 워커 배포를 따로 관리
B PYTHONUNBUFFERED=1로 traceback을 확보하고 non-finite 입력을 처리 배포 소스에 손대야 함

A는 증상을 끊습니다. 워커가 죽어도 api가 Ready로 남습니다. B는 원인으로 갑니다. 로그를 먼저 보이게 하고 그 다음에 입력 처리를 고칩니다.

저장소 문서에 그 구조가 이미 적혀 있었습니다. 운영 준비 검토 문서의 P2-4 항목입니다.

# clab_weather/docs/architecture/live-readiness-review-v1.md:67-70
P2-4. 단일 레플리카 / RWO PVC  (📋 = 미적용)
근거: replicas:1, strategy Recreate, RWO PVC를
      api + nowcast-loop + daily-loop 3컨테이너가 공유
영향: 노드 장애 = 서비스 전면 중단. 수평 확장 불가

검토는 이것을 확장성 문제로 적었습니다. 📋는 미적용 표시입니다. 세 컨테이너가 한 볼륨을 공유한다는 사실은 그때도 알고 있었고, 그것이 readiness까지 묶는다는 것은 적혀 있지 않았습니다.

같은 한 줄이 두 가지 결과를 냅니다. 확장이 안 되는 것은 트래픽이 늘 때 아픕니다. 엔드포인트가 사라지는 것은 워커가 죽을 때 아픕니다. 먼저 온 것은 두 번째였습니다.

지금 그 네임스페이스는 이렇게 생겼습니다

오늘 다시 봤습니다. 둘 다 들어가 있습니다.

Deployment가 셋으로 갈렸습니다. posy-weather에는 api 컨테이너 하나만 있습니다. 워커는 posy-weather-loops로 나갔고 그 안에 네 개가 있습니다. nowcast-loop, daily-loop, weather-history-loop, air-quality-history-loop입니다. 워커가 죽어도 API의 엔드포인트는 그대로입니다.

워커 컨테이너의 환경 변수에 B가 들어 있습니다.

PYTHONUNBUFFERED=1        # 크래시 때 출력을 잃지 않는다
PYTHONFAULTHANDLER=1      # 치명적 시그널에도 스택을 찍는다
OMP_NUM_THREADS=1         # 수치 라이브러리의 스레드를 하나로
OPENBLAS_NUM_THREADS=1
MKL_NUM_THREADS=1
NUMEXPR_NUM_THREADS=1

PYTHONFAULTHANDLER는 예상하지 못한 추가입니다. 예외가 아니라 시그널로 죽는 경우까지 스택을 남깁니다. 수치 계산 라이브러리 안에서 죽으면 Python 예외가 아니라 세그멘테이션 폴트로 끝날 수 있습니다.

스레드 수를 1로 묶은 것도 있습니다. 노드가 한 대인 클러스터에서 수치 라이브러리가 코어를 다 쓰면 같은 노드의 다른 파드가 굶습니다.

KMA_DAILY_CALL_CAP도 워커마다 다릅니다. nowcast는 2,000, daily는 12,000입니다. 외부 기상 API의 일일 호출 상한을 워커별로 나눠 둔 값입니다.

워커 넷을 나란히 놓으면 주기와 상한이 다 다릅니다.

워커 주기 일일 호출 상한 스레드 상한
nowcast-loop 300초 2,000 넷 다 1
daily-loop 43,200초 12,000 넷 다 1
weather-history-loop 86,400초 없음 없음
air-quality-history-loop 86,400초 없음 없음

nowcast-loop만 5분마다 돕니다. 나머지 셋은 12시간과 24시간입니다. 5분마다 도는 것이 처음에 죽던 그 워커입니다. 자주 도는 워커가 자주 죽었고, 그것이 요청을 받는 서버와 한 파드에 있었습니다.

daily-loop에는 POSY_DAILY_MIN_REFRESH_SECONDS=21600이 하나 더 붙어 있습니다. 주기가 12시간인데 최소 간격이 6시간입니다. 파드가 다시 뜰 때마다 루프가 처음부터 도니, 재시작이 반복되면 주기를 무시하고 외부 API를 다시 긁습니다. 그 하한을 6시간으로 잡은 값입니다.

스레드 상한은 넷 중 둘에만 있습니다. 수치 계산을 하는 nowcast-loopdaily-loop에만 붙었고, 이력을 받아 적는 두 워커에는 없습니다.

그런데 같은 변수가 이미지 안에도 있습니다. 배포 소스를 찾아 Dockerfile을 열어 보니 첫 줄부터 나옵니다.

# clab_weather/Dockerfile:3-9
ENV PYTHONUNBUFFERED=1 \
    PYTHONFAULTHANDLER=1 \
    OMP_NUM_THREADS=1 \
    OPENBLAS_NUM_THREADS=1 \
    MKL_NUM_THREADS=1 \
    NUMEXPR_NUM_THREADS=1 \
    VECLIB_MAXIMUM_THREADS=1

일곱 개입니다. Deployment의 여섯 개보다 하나 많습니다. VECLIB_MAXIMUM_THREADS는 macOS Accelerate 계열 백엔드를 묶는 변수인데, 이미지에는 있고 Deployment에는 없습니다.

같은 값을 두 곳에 적었으니 한쪽만 고치면 갈립니다. 앞으로 Deployment에서 스레드 상한을 지워도 이미지 안의 값이 남아서 동작은 바뀌지 않습니다. 그러면 매니페스트를 읽는 사람은 상한이 없다고 읽고, 컨테이너는 상한이 있는 상태로 돕니다. 지금 확인한 것은 두 곳의 값이 같은 방향이라는 것까지입니다.

나누면서 두 가지가 더 정해졌습니다

매니페스트를 읽어 보니 분리가 readiness만을 위한 것이 아니었습니다. 아래는 저장소에 적힌 값입니다. 라이브의 지금 값은 6절에 있습니다.

posy-weather posy-weather-loops
복제본 2 1
롤아웃 전략 RollingUpdate, maxUnavailable: 0 Recreate
저장소 마운트 읽기 전용 쓰기
probe /readyz, /livez 없음

API는 저장소를 읽기만 하니 복제본을 두 개 두고 무중단으로 굴릴 수 있습니다. maxUnavailable: 0이라 새 파드가 준비된 뒤에 옛 파드가 내려갑니다.

워커는 반대입니다. 복제본이 하나여야 합니다. 저장소가 RWO이고 쓰는 주체가 하나여야 하기 때문입니다. 그래서 전략이 Recreate입니다.

왜 나눴는지가 매니페스트 주석 세 줄에 남아 있습니다.

# deploy/k8s/posy-weather.yaml:83-96
# Single-writer loops: exactly one pod owns store writes (RWO local-path).
# Kept separate from the api Deployment so api rollouts/replicas never
# duplicate the KMA-fetching loops (quota) or race store writes.
spec:
  replicas: 1
  strategy:
    type: Recreate            # never two writers, even during a rollout

주석이 이유를 둘 적었습니다. 할당량과 경쟁 쓰기입니다. readiness는 적혀 있지 않습니다. 처음에 증상으로 만난 것은 readiness였고, 나중에 나눌 때 손에 잡힌 이유는 다른 둘이었습니다.

같은 파드에 두면 이 둘을 나눌 수 없습니다. API 복제본을 두 개로 늘리는 순간 외부 기상 API를 긁는 루프도 두 벌이 됩니다. 일일 호출 상한이 있는 API에서 그것은 조용히 할당량을 두 배로 씁니다.

상한을 워커별로 쪼갠 값도 복제본이 하나일 때만 맞습니다. nowcast-loop이 2,000이고 daily-loop이 12,000인 이유는 긁는 범위가 다릅니다. CLI 도움말에 그 근거가 있습니다. daily-loop은 5km 격자 셀 단위로 받아서 하루 1만 번 남짓을 씁니다. nowcast-loop은 시도 단위 레이더입니다. 격자를 훑는 쪽이 여섯 배를 받습니다.

readiness 격리가 분리의 계기였고, 할당량과 단일 writer가 분리로 함께 얻은 것입니다.

같은 네임스페이스인데 절반만 깃옵스입니다

매니페스트는 저장소에 있습니다. deploy/k8s/posy-weather.yaml입니다. 그러면 20편의 root가 이 파일도 읽고 있을 것 같습니다.

오늘 Argo CD에 직접 물어봤습니다. posy-weather 네임스페이스를 보는 Application은 하나이고, 그것이 관리하는 리소스는 셋입니다.

# kubectl -n argocd get application posy-weather-web (2026-08-15 읽기)
repo: <org>/clab-weather-web      # 웹 저장소
path: deploy/k8s
resources:
  Service     posy-weather-web
  Deployment  posy-weather-web       # SPA만
  Ingress     posy-weather           # 경로 여섯 줄이 여기 있다

posy-weatherposy-weather-loops가 목록에 없습니다. 라이브에서 그 둘을 다시 읽어 보니 argocd.argoproj.io/instance 레이블이 없고 kubectl.kubernetes.io/last-applied-configuration이 붙어 있습니다. 사람이 kubectl apply로 올렸다는 표시입니다.

posy-weather-web posy-weather · posy-weather-loops
올리는 주체 Argo CD 사람
저장소 clab-weather-web 다른 저장소
손으로 바꾼 값 되돌아온다 그대로 남는다
파일을 지우면 클러스터에서도 사라진다 아무 일도 없다

Ingress가 어느 쪽에 있는지가 이 표에서 가장 신경 쓰이는 칸입니다. 경로 여섯 줄이 적힌 Ingress는 웹 저장소에 있고, 그 다섯 줄이 가리키는 API Service는 사람이 올립니다. 라우팅 규칙과 그 규칙이 가리키는 대상이 서로 다른 관리 체계 아래 있습니다.

웹 저장소에서 Ingress의 /v1 한 줄을 지우면 Argo CD가 3분 안에 그 줄을 클러스터에서 지웁니다. API는 그대로 살아 있고 아무도 부르지 못합니다. 반대로 API Deployment의 포트를 바꾸면 Argo CD는 모릅니다. 자기 목록에 없는 리소스는 drift가 아닙니다.

선언이 Git에 있는 것과 클러스터가 그 Git을 따라가는 것은 다릅니다. 20편의 구조로 옮기면 이 두 Deployment도 root가 읽는 목록에 들어갑니다. 지금은 사람이 기억해서 적용합니다.

지금은 0/0인데 어디에도 빨간불이 없습니다

오늘 세 Deployment가 전부 0/0입니다. 서비스를 세워 두지 않았습니다.

그런데 화면에 빨간 것이 하나도 없습니다.

무엇을 물어봤나 대답
Deployment posy-weather 0/0, Available=True
Deployment 조건의 사유 MinimumReplicasAvailable
Argo CD Application Synced, Healthy
Service posy-weather 있음
Endpoints posy-weather <none>

복제본을 0으로 내리면 최소 복제본 조건이 충족됩니다. 0개가 준비되어야 하고 0개가 준비되었기 때문입니다. Argo CD도 같은 값을 읽고 Healthy로 표시합니다.

엔드포인트 0개는 크래시 때와 같은 값입니다. 원인은 반대입니다. 그때는 파드가 있는데 Ready가 아니어서 0개였고, 지금은 파드 자체가 없어서 0개입니다. 밖에서 보이는 증상이 같으니 /v1 503만 보고는 둘을 가릴 수 없습니다.

엔드포인트를 세는 것이 그래서 진단의 첫 줄이 아니라 두 번째 줄입니다. 먼저 파드가 몇 개인지 세고, 그 다음에 그중 몇 개가 Ready인지 셉니다.