개발자 이진우
devops·

오버커밋 배포 장애 회고

개발계 백엔드 20개 서비스의 CPU request를 낮추자 배포가 무한 Progressing에 빠졌다. 스케줄러가 request만 보고 한 노드에 파드를 과밀 배치했고, 동시에 콜드스타트한 JVM들이 CPU 기아에 빠져 CrashLoop이 반복됐다. 노드별 재시작 분포로 원인을 규명하고 재발 방지책을 정리한다.

목차

1. 증상

  개발계 백엔드 20개 서비스(Spring Boot / JVM)의 CPU request를 낮추는 PR을 머지했다. Argo CD가 자동 동기화라 배포가 바로 시작됐고, 08:21:04부터 08:21:22까지 약 13초 만에 20개 앱의 신규 파드가 한꺼번에 생성됐다.

  그리고 배포가 끝나지 않았다. 신규 파드 다수가 Ready에 실패하고 재시작을 반복했으며, rollout은 이 메시지에서 멈췄다.

Waiting for rollout to finish: 1 old replicas are pending termination

  maxUnavailable 0 설정 덕분에 구 파드가 유지되어 서비스 장애는 없었다. 다만 새 파드가 Ready가 되질 못하니 구 파드도 종료되지 못했고, 배포는 무한 Progressing 상태로 교착됐다.

  당시 리소스, 프로브 설정은 아래와 같았다. 뒤에서 이 값들이 하나씩 작동한다.

  • CPU request 25m, CPU limit 미설정(Burstable), memory 512Mi, replicas 1
  • RollingUpdate maxSurge 1 / maxUnavailable 0
  • liveness initialDelay 40s / period 5s / failureThreshold 3, readiness initialDelay 20s, startupProbe 없음

  프로브는 쿠버네티스가 컨테이너 상태를 주기적으로 확인하는 헬스체크다. 세 종류가 있고 하는 일이 다르다.

  • livenessProbe: 컨테이너가 살아있는지 확인한다. 실패하면 kubelet이 컨테이너를 재시작한다.
  • readinessProbe: 트래픽을 받을 준비가 됐는지 확인한다. 실패하면 서비스에서 잠시 빼고, 재시작은 하지 않는다.
  • startupProbe: 부팅이 끝났는지 확인한다. 이게 통과하기 전까지 liveness, readiness 판정을 미뤄서, 느린 부팅이 liveness에 걸려 죽는 것을 막는다.

  파라미터 중 initialDelay는 컨테이너 시작 후 첫 검사까지 대기하는 시간, period는 검사 주기, failureThreshold는 연속 몇 번 실패하면 조치할지를 정한다. 위 설정이면 liveness는 컨테이너가 뜨고 40초 뒤부터 5초 간격으로 검사하고, 3번 연속 실패하면 재시작한다. startupProbe가 없으니 부팅이 이 임계(약 55초)를 넘기면 그대로 죽는다.

2. 원인

  파드가 프로브 실패로 죽고 있었으니 프로브 설정부터 의심했다. 그런데 20개 앱의 프로브 설정은 전부 동일했다. 설정이 원인이라면 재시작은 노드와 무관하게 골고루 흩어져야 한다.

  노드별로 신규 파드 수와 재시작 횟수를 세어 보니, 둘이 나란히 늘어났다.

노드신규 파드 수재시작 횟수
10-0-33-2231244
10-0-19-12552
10-0-23-9030
10-0-45-2120
10-0-61-3910

  총 재시작 46회 중 44회(96%)가 파드 12개가 몰린 한 노드에 집중됐고, 파드가 1~3개인 노드는 재시작이 0이었다. 설정이 문제였다면 나올 수 없는 분포다. 그 노드에서 자원 경합, 즉 과밀 배치가 일어났다고 봐야 한다.

  나머지 증거도 같은 곳을 가리켰다.

  • PodScheduled = True: 스케줄링은 전부 성공했다(Pending 없음). 스케줄러는 노드에 여유가 있다고 판단했고, 그래서 Cluster Autoscaler도 발동하지 않았다.
  • exit code 143 (SIGTERM): OOMKill(137)이 아니니 메모리 부족은 배제된다. liveness 실패로 kubelet이 컨테이너를 종료시킨 것이다.
  • 직전 컨테이너 로그: MongoDB 토폴로지 탐색 중 종료, ping RTT 4.68초. CPU를 못 받아 스레드가 제때 스케줄되지 못했음을 보여준다.
  • 컨테이너는 74초를 살고 죽기를 반복했다(restartCount 4).

3. 원인 분석

  앞서 확인한 원인을 순서대로 정리하면 다음과 같다. 각 단계에 번호를 붙였고, 재발 방지는 이 중 어느 단계를 막느냐의 문제다.

① CPU request 25m 하향
   → 스케줄러가 request 합계만 보고 노드를 여유롭다고 판단
② 한 노드에 신규 파드 12개 집중 배치 (request 합계는 겨우 300m)
③ 12개 JVM이 동시 콜드스타트 → 실수요가 코어 단위로 급증 → 노드 물리 CPU 한계 초과
④ cpu.shares 가중치 25/1024 인 파드들이 CPU 기아
⑤ 부팅이 liveness 임계(initialDelay 40s + 5s×3 ≈ 55s)를 초과 → kubelet SIGTERM(exit 143)
⑥ 재시작 → 경합 재발 → CrashLoop 반복

  스케줄러가 계산한 용량(request 합계 300m)과 노드의 실제 용량(물리 CPU)이 벌어져서 생긴 일이다. request를 낮추자 스케줄러가 파드를 한 노드에 몰았고, 그 과밀이 부팅 때 용량 부족을 만들었다. request 하향이 이 흐름의 출발점(①)이다.

4. 재발 방지 방법

  교훈을 나열하는 대신, 위 과정에서 어느 단계를 막을 수 있는가를 정리하고자 한다. 이 중 한 단계만 막아도 CrashLoop은 이어지지 않는다.

대책막는 단계효과
request를 부팅 피크 기준으로 설정스케줄러가 부팅 부하를 반영해 과밀 배치를 예방. 하한을 콜드스타트 피크로 잡는다(운영 평균 3~15m는 부팅 때 부족하다)
Pod Topology Spread / anti-affinity동일 서비스군을 노드에 분산해, 부팅 피크가 한 노드에 겹치지 않게 한다
Argo CD sync wave로 롤아웃 분할20개를 단기간에 몰지 않고 웨이브로 나눠 동시 콜드스타트 수를 제한한다
CPU limit 명시 + JVM ActiveProcessorCount 고정③④컨테이너가 인식하는 코어 수를 제어해, 스레드풀, JIT 병렬도를 예측 가능하게 하고 부팅 폭주를 억제한다
startupProbe 도입부팅 구간과 운영 구간을 분리해, 느린 부팅이 liveness kill로 이어지지 않게 한다

  관측 측면에서 하나 더. request 기반 오토스케일러는 이런 오버커밋을 잡지 못한다. 파드가 Pending 없이 Scheduled로 뜨기 때문에 Cluster Autoscaler는 개입하지 않는다. 노드 수가 그대로라고 해서 여유가 있다는 뜻은 아니다.

5. 왜 저절로 복구됐는가?

  리소스를 손대지 않았는데 시간이 지나며 20개 서비스가 전부 Synced로 복구돼 대부분 Healthy에 도달했다. 반복해서 죽던 CrashLoop이 어떻게 스스로 풀렸을까.

  쿠버네티스의 CrashLoopBackOff가 작용했다. 컨테이너가 반복해서 죽으면 kubelet은 재시작 간격을 지수적으로 늘린다(10s → 20s → 40s → … → 최대 5분). 처음엔 12개 파드가 거의 동시에 재시작하며 경합을 다시 일으켰지만, 죽는 횟수가 쌓일수록 backoff가 벌어지면서 재시작 타이밍이 시간축으로 흩어졌다.

  동시 콜드스타트 수가 줄자 노드의 순간 CPU 수요도 물리 한계 아래로 내려왔고, 파드들이 하나씩 부팅에 성공하기 시작했다. JVM은 부팅할 때 클래스 로딩과 JIT 컴파일로 CPU를 많이 쓰지만, 부팅을 마치면 사용량이 운영 수준으로 떨어진다. 이 서비스들의 운영 시 CPU 사용량은 평균 3~15m였다. 그래서 파드 하나가 Ready가 되면 그 파드의 CPU 사용이 부팅 피크에서 운영 수준으로 내려가 노드에 여유가 생겼고, 그 여유만큼 다음 파드가 부팅할 CPU를 확보했다. 이렇게 경합이 backoff로 풀렸다.

  결과만 보면 이 복구는 4절의 sync wave 방식을 흉내 낸 셈이다. 롤아웃을 웨이브로 나눴어야 할 일을, backoff가 실패를 반복하며 시간차로 대신했다. 다만 이건 운이 좋았던 경우다. 노드가 조금 더 붐볐거나 liveness가 조금 더 빡빡했다면 backoff가 벌어지기 전에 더 크게 무너졌을 수 있다. 저절로 나았다는 사실이 오히려 재발 방지를 갖춰야 할 이유가 된다.

6. 마무리

  request는 스케줄링 밀도를 결정한다. 값을 낮추면 파드가 더 조밀하게 배치되고, 부팅 피크가 겹치면 이번처럼 무너진다. request는 콜드스타트 피크를 기준으로 잡아야 한다. 그것으로 부족하면 배치(topology)/동시성(sync wave)/부팅 격리(startupProbe)로 여러 단계를 겹쳐서 막아둘 수 있다.

관련 글