핵심 한 줄
노드 CPU가 여유인데 파드가 느리다. 사용률이 아니라 CFS throttled 비율이 진짜 신호다 —
container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total이 0.2를 넘을 때야 “앱이 느린 게 아니라 k8s cgroup 쿼터에 깎이고 있다”고 확정할 수 있다.
시리즈 — 장애를 '구별 가능'하게 만든 카오스 벤치
1. 배경 — 지연의 수많은 얼굴
운영에서 p99 latency↑ 알림이 뜨면 원인 후보가 십수 가지다. 락 경합·GC 폭주·N+1·커넥션풀 고갈·CPU 스로틀… 전부 같은 증상으로 도착한다. 이 시리즈의 목표는 각 원인을 다른 원인은 못 내는 단 하나의 신호로 가르는 것이다(0.서문).
W7의 주인공은 CPU 스로틀(CFS throttling) 이다. 이 장애가 특별히 까다로운 이유는 하나 — CPU 사용률 대시보드를 보면 멀쩡하게 보인다. 노드 전체 CPU는 여유 있고, 파드의 cpu usage도 1코어 수준에서 “정상”처럼 찍힌다. 그런데 요청은 느리다.
2. 무엇을 망가뜨렸나 — 1-core 제한 + CPU 버너
chaos 프로파일에서 cpuThrottle을 arm하면 CpuBurner가 4개의 busy-loop 데몬 스레드(chaos-cpu-burn-0 ~ chaos-cpu-burn-3)를 띄운다. 각 스레드는 JIT이 최적화할 수 없는 산술 루프를 돌며 CPU를 최대한 태운다 — 총 4코어 분량의 수요다.
// CpuBurner.java — @Profile("chaos"), @Scheduled reconcile
@Scheduled(fixedRateString = "${chaos.cpu.interval-ms:2000}")
void reconcile() {
if (this.state.isArmed(ActiveChaosFaults.CPU_THROTTLE)) {
if (this.burning.compareAndSet(false, true)) {
startBurn(); // chaos-cpu-burn-N 데몬 스레드 기동
}
}
else {
this.burning.set(false); // 스레드들이 루프 탈출 후 자기 소멸
}
}CpuBurner는 @Profile("chaos")으로 격리되어 기본 빌드에는 존재하지 않는다(A1 불변). ChaosState.isArmed()만 바라보므로 ChaosFaults 인터페이스 변경도 없다.
그런데 파드에는 **CPU limit "1" (1코어)**이 걸려 있다(k8s/petclinic-deployment.yaml). 4코어 수요 vs 1코어 쿼터 — 커널 CFS 스케줄러가 나머지 실행 시간을 throttle한다. 사용량은 1코어 언저리에 고정된 채 스레드들이 실행 시간을 깎인다.
3. 증상 — 느린데 CPU는 여유
부하를 주면 응답이 느려진다. 그런데 대시보드를 열면 단서가 없다:
- 노드 전체 CPU 사용률: 여유
- 파드
container_cpu_usage_seconds_totalrate: ~1코어 — “정상처럼 보임” - 스레드 덤프: BLOCKED 없음
- GC 로그: 평온
top을 보거나 CPU 사용률 패널만 봐서는 아무것도 잡히지 않는다. 앱이 느린 게 아니라 스케줄러가 실행 시간을 나눠 주지 않는 것인데, CPU 사용률 지표는 그 사실을 숨긴다. 사용률이 limit치와 일치한다는 것은 “충분히 쓰고 있다”가 아니라 “쿼터에 막혔다”는 신호일 수 있다.
4. 판별 신호 — CFS throttled 비율과 사용률 대조
cAdvisor가 kubelet /metrics/cadvisor 엔드포인트에 CFS 계량값을 노출한다. 판별 신호는 이 비율 하나다:
sum(rate(container_cpu_cfs_throttled_periods_total{namespace="petclinic-bench", pod=~"petclinic.*"}[1m]))
/
sum(rate(container_cpu_cfs_periods_total{namespace="petclinic-bench", pod=~"petclinic.*"}[1m]))이 값이 0.2를 넘으면 CpuThrottled 알림이 발화한다. “전체 CFS 주기 중 throttled된 비율이 20% 초과” — cgroup 쿼터에 막혀 실행 시간을 깎이고 있다는 직접 증거다.
두 신호를 표로 놓으면 CPU 스로틀과 단순 CPU 포화가 갈린다.
| 신호 | 사용률만 볼 때 | CFS throttled 비율 추가 시 |
|---|---|---|
container_cpu_usage_seconds_total rate | ~1코어 (limit치와 일치 — “정상처럼 보임”) | 동일 — 변화 없음 |
container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total | 측정 안 함 | > 0.2 (쿼터 초과가 원인) |
| 진단 가능? | ❌ (원인 불명) | ✅ (“CPU limit에 깎힘”) |
사용률은 두 상황을 가르지 못한다. CFS throttled 비율만이 “노드는 여유 있는데 쿼터에 막힘”을 지목한다.
5. 측정 — kubelet /metrics/cadvisor 스크레이프
표준 Prometheus 스크레이프 타깃에는 CFS 계량값이 없다. cAdvisor가 kubelet에 임베드되어 /metrics/cadvisor에 노출하는데, 이 엔드포인트는 고카디널리티라 그냥 긁으면 안 된다.
W7 벤치는 in-cluster Alloy(alloy-events)가 Kubernetes node discovery로 kubelet을 찾고, SA 토큰으로 HTTPS 인증한 뒤 CFS 3개 시리즈만 relabel-keep해서 호스트 Prometheus로 remote-write한다(container_cpu_cfs_throttled_periods_total, container_cpu_cfs_periods_total, container_cpu_usage_seconds_total). RBAC은 nodes/nodes/metrics에 대한 get·list·watch만 추가했다.
스모크(scripts/smoke/cpu-throttle-to-slack.sh)는 이 파이프라인의 E2E를 검증한다 — cpuThrottle을 arm하고 ~75초 대기한 뒤 throttle 비율이 0.2를 넘어 CpuThrottled가 FIRING 상태에 도달하는지, 그리고 그 순간 cpu_usage가 여전히 “1코어 수준(정상처럼)“으로 찍히는지를 함께 출력한다. 두 값의 동시 비교가 판별 신호의 핵심이다.
6. 교훈
CPU '사용률'이 아니라 '스로틀'을 봐야 한다
CPU 사용률은 컨테이너가 얼마나 썼는지를 보여주지, 얼마나 더 쓰고 싶었는데 못 썼는지를 보여주지 않는다. k8s CPU limit이 걸린 환경에서 앱이 느려질 때 사용률이 “정상”이면, CFS throttled 비율이 진짜 신호다.
container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total > 0.2— 이 한 줄이 “앱 코드 문제”와 “인프라 쿼터 문제”를 가른다. 사용률로 진단을 끝내면 반드시 오진한다.
관련 글
- 0.서문 — 이 ‘판별 신호’ 논제의 전체 그림
- 9.멈춘 건 GC였다 — 같은 지연 패밀리, 다른 신호(GC pause rate)
- 8.컨슈머가 못 따라잡았다 — 또 다른 지연 구별(컨슈머 lag)
- 2.장애 도구가 장애에 죽지 않게 — 이 신호를 소비하는 자동 진단
- 근거: spring-petclinic
CpuBurner,ActiveChaosFaults.CPU_THROTTLE = "cpuThrottle",k8s/petclinic-deployment.yaml(cpu limit"1"),k8s/alloy-events.yaml(cadvisor scrape + relabel-keep),scripts/smoke/cpu-throttle-to-slack.sh