핵심 한 줄
롤링 업데이트는 두 번 조용히 터진다. 첫 번째는 fail 0%로 보이면서 한 발도 못 나가는 데드락, 두 번째는 종료 중인 파드로 라우팅된 요청이 30초 동안 매달리다 죽는 레이스. 둘 다 단일 인스턴스에선 안 보인다.
Scale 시리즈 — k3s 수평 스케일아웃
1. 배포를 걸었더니 아무것도 안 일어났다
03편에서 다진 것은 장애 복구였다. 노드를 죽여도 요청 실패가 0%였다. anti-affinity가 파드를 분산시켰고, 남은 파드가 트래픽을 흡수했다. 자가치유가 됐다.
이제 배포를 걸었다. kubectl rollout restart deployment/payment.
아무것도 안 일어났다.
파드 목록을 새로고침해도 3/3 Running. 롤아웃 상태를 봐도 Waiting for rollout to finish: 0 of 3 updated replicas are available. fail은 0%. 에러 로그도 없다.
조용했다. 그래서 더 위험했다.
2. 함정 1 — surge 데드락
구성을 다시 보자. payment 전용 노드 풀, 정확히 3개. 각 노드에 payment 파드 1개씩. 매니페스트에는 required anti-affinity — 같은 노드에 payment 파드 2개를 허용하지 않는다. 그리고 롤링 전략은 기본값대로 maxSurge:1 / maxUnavailable:0.
RollingUpdate의 기본 동작을 따라가면 문제가 보인다.
maxUnavailable:0이라는 건, 새 파드가 Ready 상태가 되기 전까지는 옛 파드를 죽이지 않는다는 뜻이다. 서비스 연속성을 위한 설정이다. 그리고 maxSurge:1은, 롤링 중에 파드를 하나 더 만들어도 된다는 뜻이다. 원래 3개이니 surge 파드까지 하면 최대 4개.
그런데 갈 노드가 없다. 전용 노드 3개는 이미 모두 옛 파드로 채워져 있고, required anti-affinity 때문에 같은 노드에 두 번째 payment 파드를 스케줄할 수 없다. surge 파드는 Pending 상태로 멈춘다. 영구적으로.
maxUnavailable:0이라 옛 파드는 안 죽는다 → surge 파드가 Ready가 안 되니 옛 파드를 죽일 수 없다 → 빈 노드가 생기지 않는다 → surge 파드는 영원히 Pending. 데드락이다.
fail은 0%였다. 옛 파드 3개가 그대로 살아서 트래픽을 처리하고 있으니까. 에러 로그도 없다. 롤아웃만 멈춰 있을 뿐이다. preStop 훅이 한 번도 발동되지 않은 채로.
이게 조용한 이유다. 장애가 아니다 — 배포가 안 되고 있는 것이다. 모니터링 대시보드는 초록이다.
surge 데드락 — 왜 Pending이 풀리지 않는가
flowchart TD A["rollout restart<br/>maxSurge:1 / maxUnavailable:0"] --> B["surge 파드 생성 시도 (+1 = 4개)"] B --> C{"전용 노드 3개<br/>모두 점유 중?"} C -- "Yes" --> D["required anti-affinity<br/>같은 노드 배치 불가"] D --> E["surge 파드 Pending 영구"] E --> F{"새 파드 Ready?"} F -- "No" --> G["옛 파드 유지<br/>maxUnavailable:0 보장"] G --> H["빈 노드 생기지 않음"] H --> E C -- "No (교정 후)" --> I["maxSurge:0 / maxUnavailable:1<br/>옛 파드 1개 먼저 종료"] I --> J["빈 노드 확보"] J --> K["새 파드 스케줄 성공"] K --> L["롤링 정상 진행"]
교정: maxSurge:0 / maxUnavailable:1
반대로 뒤집으면 문제가 풀린다. maxUnavailable:1은 옛 파드 1개를 먼저 죽인다. 빈 노드가 생긴다. 거기에 새 파드를 스케줄한다. required anti-affinity와 충돌이 없다. 순서를 바꿨을 뿐인데, 데드락이 흐름이 된다.
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 0
maxUnavailable: 1교정 후 롤아웃을 다시 걸었다. 이번엔 파드가 순서대로 교체됐다.
3. 함정 2 — 엔드포인트 레이스
데드락은 해결됐다. 이제 진짜 롤링이 돌아간다. 그런데 롤링 중에 요청이 죽는다.
150rps 부하를 유지하면서 rollout restart를 걸었다. preStop 훅 없이.
fail: 0.7% | max latency: 30,018ms | dropped: 50
0.7%가 크지 않아 보일 수 있다. 하지만 max 30,018ms가 낯익다. terminationGracePeriodSeconds: 30 — 종료 유예 기간과 정확히 일치한다. 이건 요청이 타임아웃된 게 아니라, 종료 중인 파드에 붙어서 유예 기간 내내 매달리다 죽은 것이다.
원인은 엔드포인트 전파 레이스다.
파드가 SIGTERM을 받고 종료를 시작한다. 동시에 kube-proxy가 엔드포인트 목록에서 이 파드를 제거해야 한다 — 그래야 Service가 새 요청을 이 파드로 보내지 않는다. 그런데 이 두 가지가 독립적으로, 비동기로 일어난다.
SIGTERM 전달은 즉시다. 엔드포인트 제거는 Endpoints 오브젝트 업데이트 → kube-proxy가 각 노드에서 iptables 규칙을 갱신하는 연쇄 과정이다. 짧게는 수초가 걸린다. 그 사이에 라우팅된 새 요청들이 이미 종료를 시작한 파드로 향한다. 파드는 요청을 받아도 처리를 완료하지 못하고, 유예 기간이 다 차면 종료된다.
4. preStop A/B — 인과 확정
preStop 훅을 켰다.
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 8"]SIGTERM 전에 8초를 기다린다. 그 8초 동안 엔드포인트 전파가 완료된다. 파드는 새 요청을 더 이상 받지 않는 상태에서 SIGTERM을 받는다.
동일 조건(150rps, rollout restart)에서 A/B를 비교했다.
| preStop | fail% | max latency | dropped |
|---|---|---|---|
| OFF | 0.7% | 30,018ms | 50 |
ON (sleep 8) | 0% | 1,218ms | 0 |
preStop OFF vs ON — 엔드포인트 전파 레이스
sequenceDiagram participant K as kubelet participant P as payment 파드(old) participant E as Service/엔드포인트<br/>(kube-proxy) participant C as 클라이언트 요청 Note over K,C: preStop OFF (fail 0.7% / max 30,018ms) K->>P: SIGTERM (즉시) K->>E: 엔드포인트 제거 시작 (비동기) P->>P: 종료 시작 Note over E: iptables 전파 중 (수초 소요) C->>E: 새 요청 도착 E->>P: 아직 라우팅 가능 → 종료 중 파드로 전달 P-->>C: 처리 불가 — 30,018ms 매달리다 실패 Note over K,C: preStop ON sleep 8 (fail 0% / max 1,218ms) K->>P: preStop exec: sleep 8 Note over P: 8초 대기 (SIGTERM 미전달) K->>E: 엔드포인트 제거 시작 (비동기) Note over E: 8초 이내 전파 완료 C->>E: 새 요청 도착 Note over E: 이 파드 엔드포인트 제거됨 E-->>C: 새 파드로 라우팅 (정상) K->>P: sleep 종료 → SIGTERM P->>P: 잔여 요청 처리 후 종료
숫자 하나가 눈에 띈다. p50/p95는 OFF·ON 동일 — 43ms / 51ms.
preStop은 평균 지연을 줄이지 않는다. p95도 바꾸지 않는다. 꼬리(tail)만 잡는다. 30,018ms짜리 요청이 1,218ms 안으로 들어오는 것 — graceful shutdown 단독으로는 못 덮던 레이스를, preStop 8초가 소거했다.
5. 두 함정의 공통점
두 함정을 나란히 놓으면 패턴이 보인다.
데드락은 조용하다. 롤아웃이 멈춰도 모니터링은 초록이다. 요청이 처리되고 있으니까. preStop이 한 번도 발동되지 않는다는 사실도 — alert으로 잡히지 않는다. 배포 버튼을 눌렀는데 배포가 안 되고 있다는 걸, 직접 롤아웃 상태를 들여다봐야만 안다.
엔드포인트 레이스도 조용하다. 0.7% fail이다. 나머지 99.3%는 정상이다. max 30s짜리 지연이 p95에는 나타나지 않는다 — 꼬리에 숨는다. 꼬리를 보지 않으면 보이지 않는다.
Load 시리즈에서 반복된 말이 있었다.
실행해봐야만 드러나는 결함이 있다.
코드 리뷰가 maxSurge:1/maxUnavailable:0을 틀렸다고 잡지 않는다. 매니페스트 linter도 통과한다. 단일 인스턴스 테스트는 롤링 자체가 없다. 멀티노드에서 직접 롤아웃을 걸어봐야 데드락이 보인다. preStop 없이 부하 중 롤링을 해봐야 꼬리 fail이 보인다.
전용 풀 + anti-affinity + RollingUpdate 기본값. 셋이 만나면 교과서적인 설정이 교과서적인 데드락이 된다. 그 조합을 단일 인스턴스 환경에서 재현할 방법이 없다.
6. 매니페스트에 남긴 것
두 실험 결과를 매니페스트에 반영했다.
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 0 # 전용 노드 풀 + anti-affinity = surge 갈 곳 없음
maxUnavailable: 1 # 옛 파드 먼저 종료 → 빈 노드 확보 → 순서 역전
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 8"] # 엔드포인트 전파 대기 (kube-proxy 레이스 소거)숫자 하나(maxSurge: 0)와 훅 하나(preStop: sleep 8). 두 줄이 없으면, 하나는 배포가 영구히 멈추고 하나는 롤링마다 요청 0.7%가 30초짜리 지연과 함께 죽는다.
한 줄
여기까지가 1막이다. 정합성이 견고한지 확인했고(02편), 노드가 죽어도 무중단인지 확인했고(03편), 배포 중에도 요청이 죽지 않는지 확인했다. 안전하게 늘리는 건 됐다. 이제 진짜 질문 — 늘리면 빨라지나? → 2막 05편에서 첫 숫자가 나온다. 그리고 그 숫자가 통째로 뒤집힌다.