# CPU limit이 파드를 재시작시키는 구조 - 스로틀링과 liveness probe
파드가 CrashLoopBackOff도 아닌데 재시작 횟수만 계속 올라갑니다. 로그에는 오류가 없고, 애플리케이션은 정상 종료된 것처럼 보입니다. kubectl describe를 보면 Liveness probe failed가 찍혀 있습니다.
CPU 사용률 그래프를 보면 limit 근처에도 안 갑니다. 그래서 리소스 문제가 아니라고 판단하고 probe 설정을 의심하게 됩니다.
실제 원인은 대개 CPU 스로틀링입니다. 평균 사용률로는 안 보이고, probe의 기본값이 워낙 빡빡해서 짧은 스로틀 구간만으로도 실패합니다. 이 글에서는 limit이 어떻게 강제되는지, 왜 평균 그래프로는 안 보이는지, 그리고 그것이 어떻게 재시작 루프가 되는지 정리합니다.
# 1. CPU limit은 죽이지 않고 멈춘다
먼저 두 리소스의 강제 방식 차이를 확인합니다.
cpulimits are enforced by CPU throttling. When a container approaches itscpulimit, the kernel will restrict access to the CPU corresponding to the container's limit. Thus, acpulimit is a hard limit the kernel enforces. Containers may not use more CPU than is specified in theircpulimit.
memorylimits are enforced by the kernel with out of memory (OOM) kills. ...memorylimits are enforced reactively.
CPU는 커널이 미리 막고, 메모리는 넘긴 뒤에 죽입니다. CPU limit을 넘겨서 컨테이너가 죽는 일은 없습니다. 그래서 "CPU limit은 안전하다"고 여기게 되는데, 이 글의 주제가 그 판단이 틀리는 경우입니다.
메모리 쪽의 강제 방식과 그것이 노드 안정성에 주는 영향은 메모리 request=limit이 노드를 지키는 이유 (opens new window)에 정리했습니다. 이 글은 반대편, 즉 CPU 쪽 이야기입니다.
# 2. 스로틀링은 100ms 단위로 일어난다
"막는다"가 구체적으로 무엇인지가 핵심입니다. 리눅스 커널의 CFS 대역폭 제어는 주기와 할당량으로 동작합니다.
Within each period, a task group receives up to "quota" microseconds of CPU time.
throttled threads will not be able to run again until the next period when the quota is replenished
기본 주기는 100ms입니다. cpu: 500m이면 100ms마다 50ms의 CPU 시간을 받습니다. 그 50ms를 다 쓰면 남은 50ms 동안 그 컨테이너의 스레드는 아예 실행되지 않습니다.
여기서 평균 그래프가 배신하는 이유가 나옵니다. 1초를 10개의 주기로 보면, 5개 주기에서 50ms 할당량을 다 쓰고 남은 50ms를 멈춰 있고 나머지 5개 주기는 거의 쉬었다고 해도, 1초 동안 쓴 CPU는 250ms(0.25코어)라 1초 평균 사용률은 limit(0.5코어)의 절반으로 보입니다. 대시보드는 평온한데 실제로는 바쁜 주기마다 50ms씩 멈춰 있는 상태입니다.
스레드가 많으면 더 나빠집니다. 할당량은 컨테이너마다 하나이고 코어별로 나뉘지 않습니다. 4개 스레드가 동시에 돌면 50ms의 할당량을 12.5ms 만에 소진합니다. 코어가 많은 노드일수록 같은 limit에서 더 자주 스로틀됩니다. 개발 환경에서는 안 나던 문제가 운영 노드에서 나는 흔한 이유입니다.
커널이 이 사실을 통계로 노출합니다.
nr_periods: elapsed enforcement intervals nr_throttled: throttling occurrences throttled_time: total nanoseconds spent throttled
nr_throttled / nr_periods가 실제로 봐야 할 값입니다. 평균 사용률이 아닙니다.
# 3. probe의 기본값은 생각보다 빡빡하다
이제 다른 쪽 조각입니다. probe 필드의 기본값이 이렇습니다.
| 필드 | 기본값 |
|---|---|
initialDelaySeconds | 0 |
timeoutSeconds | 1 |
periodSeconds | 10 |
successThreshold | 1 |
failureThreshold | 3 |
timeoutSeconds가 1초입니다. probe가 1초 안에 응답을 못 받으면 그 시도는 실패입니다.
건강 확인 엔드포인트는 대개 상수를 반환하는 정도라 1초는 넉넉해 보입니다. 그런데 스로틀된 컨테이너에서는 그 상수를 반환할 스레드가 CPU를 못 받습니다. 요청을 받은 것도, 처리한 것도 아닌 채로 주기가 끝나기를 기다립니다. 응답 자체의 연산량과 무관하게 지연이 생깁니다.
그리고 실패가 세 번 이어지면 kubelet이 개입합니다.
If the command returns a non-zero value, the kubelet kills the container and restarts it.
기본값 기준으로 periodSeconds: 10 × failureThreshold: 3이므로 약 30초 동안 스로틀이 이어지면 컨테이너가 죽습니다.
# 4. 왜 루프가 되는가
한 번 죽는 것으로 끝나지 않는 이유가 여기 있습니다. 컨테이너가 재시작하면 시작 작업이 돌아갑니다. 캐시를 채우고, 설정을 읽고, 연결을 맺고, 런타임이 JIT 컴파일을 합니다. 시작 구간은 정상 운영보다 CPU를 많이 씁니다.
즉 순환이 만들어집니다.
CPU 스로틀링
→ liveness probe 응답 지연
→ 3회 실패 → kubelet이 컨테이너 종료
→ 재시작, 시작 작업으로 CPU 수요 급증
→ 더 심한 스로틀링
→ 또 probe 실패
재시작이 원인을 악화시키는 구조라 스스로 빠져나오지 못합니다. 그리고 이 상태에서도 CPU 평균 그래프는 limit 아래에 머물러 있어, 원인을 리소스에서 찾지 않게 됩니다.
레플리카가 여럿이면 더 고약합니다. 하나가 재시작하면 그 트래픽이 나머지로 몰리고, 나머지도 스로틀 구간에 들어가 차례로 죽습니다. 부하 증가가 아니라 재시작 자체가 전파되는 형태입니다.
# 5. 무엇을 고칠 것인가
세 층위의 대응이 있고, 순서가 있습니다.
(A) startup probe로 시작 구간을 분리한다. 가장 먼저 할 일입니다. 시작 작업이 도는 동안 liveness가 개입하지 못하게 막습니다.
startupProbe:
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 5
failureThreshold: 60 # 최대 5분까지 시작을 기다린다
livenessProbe:
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 10
timeoutSeconds: 3 # 기본 1초는 스로틀에 취약하다
failureThreshold: 3
startup probe가 성공하기 전까지 liveness는 실행되지 않으므로, 4절의 순환에서 재시작 구간이 끊깁니다. 시작이 느린 워크로드에 startup probe가 없는 것이 이 문제의 절반입니다.
(B) timeoutSeconds를 늘린다. 기본 1초는 스로틀 환경에서 지나치게 짧습니다. 3초 정도면 짧은 스로틀 구간을 흡수합니다. 다만 이것은 완화이지 해결이 아닙니다. 스로틀 자체는 그대로이므로 애플리케이션은 여전히 느립니다.
(C) limit을 조정하거나 없앤다. 근본 대응입니다. 스로틀 비율이 높다면 limit이 실제 필요량보다 작은 것입니다.
여기서 판단이 갈립니다. CPU limit을 아예 설정하지 않는 선택지가 있고, 실제로 합리적인 경우가 많습니다. 근거는 이렇습니다.
| limit 설정 | limit 미설정 | |
|---|---|---|
| 여유 CPU 활용 | 못 함 (할당량까지만) | 함 |
| 성능 예측 가능성 | 높음 | 낮음 |
| 스로틀로 인한 probe 실패 | 가능 | 없음 |
| 이웃 파드 보호 | limit이 담당 | request 비율로 경쟁 |
CPU는 압축 가능한 자원이라 경합이 생겨도 느려질 뿐 죽지 않습니다. 그리고 노드에 여유가 있을 때 request는 최소 보장으로, 경합할 때는 request 비율로 배분됩니다. 즉 request만 제대로 잡으면 이웃 보호는 이미 됩니다. limit이 추가로 하는 일은 "여유가 있어도 못 쓰게 막는 것"뿐입니다.
그럼에도 limit이 필요한 경우가 있습니다. 성능을 재현 가능하게 만들어야 하는 벤치마크 환경, 비용을 노드 단위로 정산해야 하는 멀티테넌트, 폭주하는 버그가 있는 워크로드입니다. 기본은 미설정, 이유가 있을 때만 설정이 실용적인 기준입니다.
request 값을 근거 있게 정하는 방법은 VPA 권고로 request를 정할 때 (opens new window)에 정리했습니다. limit을 없애는 선택은 request가 제대로 잡혀 있다는 전제 위에서만 성립합니다.
# 6. liveness probe 자체를 다시 볼 것
한 걸음 더 물러서면, liveness probe가 정말 필요한지도 따져 볼 만합니다.
liveness probe의 목적은 재시작으로 회복되는 상태를 감지하는 것입니다. 데드락에 빠졌거나 내부 상태가 망가져 스스로 복구할 수 없을 때입니다. 그런데 실무에서 붙는 liveness probe 상당수는 readiness와 같은 엔드포인트를 가리킵니다. 그러면 "일시적으로 느린 상태"까지 재시작 사유가 됩니다.
구분하면 이렇습니다.
- readiness: 지금 트래픽을 받을 수 있는가 → 실패하면 엔드포인트에서 뺀다
- liveness: 재시작해야 회복되는가 → 실패하면 죽인다
일시적 과부하는 readiness가 답할 문제입니다. 트래픽을 잠깐 안 받으면 부하가 내려가고 회복됩니다. 같은 상황에서 liveness가 반응하면 재시작으로 상황을 악화시킵니다.
liveness 엔드포인트가 의존성을 확인하지 않게 하는 것도 중요합니다. DB 연결을 확인하는 liveness는 DB가 잠깐 느려질 때 모든 애플리케이션 파드를 동시에 재시작시킵니다. 이런 확인은 readiness에 두어야 합니다.
기본 probe 구성 자체는 Kubernetes의 Probe 알아보기 (opens new window)에 정리해 두었는데, 여기서 덧붙일 규칙은 하나입니다. liveness를 붙일 이유를 설명할 수 없다면 붙이지 않는 편이 낫습니다. 없어서 생기는 문제보다 있어서 생기는 문제가 더 흔합니다.
# 7. 직접 확인하는 방법
먼저 스로틀링 여부를 확인합니다. 평균 사용률이 아니라 스로틀 비율을 봅니다.
# 스로틀된 주기의 비율 - 이것이 실제 지표다
sum by (pod, container) (
rate(container_cpu_cfs_throttled_periods_total{namespace="myns"}[5m])
) /
sum by (pod, container) (
rate(container_cpu_cfs_periods_total{namespace="myns"}[5m])
)
# 스로틀에 묶여 있던 시간 (초/초)
sum by (pod) (rate(container_cpu_cfs_throttled_seconds_total{namespace="myns"}[5m]))
공식 기준값은 없지만, 경험적으로는 첫 질의의 값이 0.05(5%) 정도를 넘으면 지연을 의심하고, 0.25 정도를 넘으면 probe 실패 가능성까지 함께 살펴보는 식으로 잡을 수 있습니다. 워크로드마다 다르므로 예시 기준으로만 참고합니다. 평균 사용률이 limit의 절반이어도 이 값은 높을 수 있습니다.
노드에서 직접 볼 수도 있습니다.
# cgroup v2 기준 - nr_throttled / nr_periods
kubectl debug node/<node> -it --image=busybox -- \
sh -c 'cat /host/sys/fs/cgroup/kubepods.slice/*/*/cpu.stat'
재시작 원인이 probe인지 확인합니다.
# 종료 사유 - Liveness probe failed 이벤트
kubectl describe pod myapp | grep -A5 'Last State\|Events'
# 이전 컨테이너 로그에 오류가 없다면 외부 요인(probe)일 가능성이 높다
kubectl logs myapp --previous --tail=50
# 재시작 횟수 상위
kubectl get pods -A --sort-by='.status.containerStatuses[0].restartCount' \
-o custom-columns='NS:.metadata.namespace,POD:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount' | tail -15
OOM kill은 Reason: OOMKilled, 종료 코드 137(SIGKILL)로 남습니다. 반면 probe 실패 시 kubelet은 먼저 SIGTERM(이미지에 정의된 STOPSIGNAL)을 보내고 grace period가 지나도 남아 있을 때만 SIGKILL을 보냅니다. 그래서 probe 실패로 인한 종료 코드는 SIGTERM에 종료하면 143, 스스로 정상 종료하면 0, grace period를 넘기면 137처럼 달라집니다. 종료 코드만으로는 구분이 어려우므로 Reason 필드와 Liveness probe failed 이벤트를 함께 봐야 합니다.
probe 응답 시간을 직접 재 보는 것도 유효합니다.
# 스로틀 중인 파드 안에서 자기 health 엔드포인트 응답 시간 측정
kubectl exec myapp -- sh -c \
'for i in $(seq 1 20); do
wget -q -O /dev/null -T 5 http://127.0.0.1:8080/healthz 2>&1
echo "$i: $?"
done'
# 8. 트러블슈팅
| 증상 | 원인 | 조치 |
|---|---|---|
| 로그에 오류 없이 재시작만 반복 | liveness 실패로 kubelet이 종료 | describe로 종료 사유 확인 |
| CPU 그래프는 여유로운데 느림 | 100ms 주기 스로틀이 평균에 안 보임 | 스로틀 비율 질의 |
| 개발에선 정상, 운영에서 재시작 | 코어가 많아 할당량을 빨리 소진 | limit 상향 또는 제거 |
| 시작 직후에만 재시작 | 시작 구간의 CPU 급증 | startupProbe 추가 |
| 파드들이 차례로 재시작 | 재시작이 부하를 이웃에 전가 | limit 재검토, 레플리카 여유 |
| DB가 느려지자 전체 재시작 | liveness가 의존성을 확인 | 의존성 확인은 readiness로 |
| 종료 코드 143/137인데 OOM 아님 | probe 실패에 의한 SIGTERM 또는 grace period 후 SIGKILL | Reason 필드와 이벤트로 구분 |
| timeout을 늘렸는데 여전히 느림 | 스로틀 자체는 그대로 | limit 조정이 근본 대응 |
# 9. 마무리
- CPU limit은 컨테이너를 죽이지 않습니다. 대신 100ms 주기마다 할당량을 다 쓰면 남은 시간 동안 스레드를 세웁니다.
- 그래서 1초 평균 사용률로는 안 보입니다. 봐야 할 값은
nr_throttled / nr_periods입니다. - 스레드가 많고 코어가 많을수록 같은 limit에서 더 자주 스로틀됩니다. 개발과 운영의 차이가 여기서 납니다.
- probe의
timeoutSeconds기본값은 1초입니다. 스로틀된 컨테이너는 상수를 반환하는 엔드포인트조차 이 안에 응답하지 못할 수 있습니다. - 재시작은 시작 작업으로 CPU 수요를 늘려 스스로를 악화시킵니다. startup probe로 시작 구간을 분리하는 것이 첫 대응입니다.
- CPU limit은 기본으로 두지 않고, 이유가 있을 때만 설정하는 편이 낫습니다. request만 제대로 잡으면 이웃 보호는 이미 되고, limit이 추가로 하는 일은 여유 자원을 못 쓰게 막는 것뿐입니다.
- liveness probe는 재시작으로 회복되는 상태에만 붙입니다. 일시적 과부하와 의존성 장애는 readiness의 몫입니다.
# 참고
- Kubernetes - Resource Management for Pods and Containers (opens new window)
- Kubernetes - Configure Liveness, Readiness and Startup Probes (opens new window)
- Kubernetes API - Probe 필드 기본값 (opens new window)
- Linux Kernel - CFS Bandwidth Control (opens new window)
- Kubernetes - Pod Lifecycle (probes) (opens new window)