핵심 한 줄

노드 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_total rate: ~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