Pod가 idle인데도 throttling 되는 이유, 사실 내부적으로는
들어가며
CPU 사용률이 30%대인데 P99 레이턴시가 튄다. container_cpu_cfs_throttled_periods_total을 그래프로 걸어봤더니 주기적으로 확 올라간다. "리밋이랑 요청량이 다 널널한데 왜?" — 이 질문에 명확히 답하려면 결국 CFS 내부까지 내려가야 한다. 최근에 팀 내부에서 cgroup v2 전환한 노드들이 섞이면서 이 이야기를 한 번 정리할 필요가 생겼고, 정리하면서 알게 된 것들을 여기 남긴다.
관련 있는 사람들만 볼 것 같으니 요점부터 말하면, throttling은 "전체 평균 사용률"이 아니라 "100ms 창 안에서의 순간 소비"에 의해 결정된다. 그래서 리밋이 남아 있어 보여도 특정 100ms 구간에서 quota를 다 태우면 그 나머지 구간은 무조건 강제로 재운다. 이게 idle처럼 보이는 서비스에서도 throttling이 발생하는 이유의 핵심이다.
cpu.max, 사실 저 한 줄이 전부다
cgroup v2에서 CPU 리밋을 표현하는 파일은 딱 하나다. cpu.max.
$ cat /sys/fs/cgroup/kubepods.slice/.../cpu.max
50000 100000
앞이 quota, 뒤가 period다. 마이크로초 단위. 위 예시는 100ms 창에서 최대 50ms만 CPU 시간을 쓸 수 있다는 뜻이고, Kubernetes 관점에서는 limits.cpu: 500m에 해당한다. cgroup v1이었으면 cpu.cfs_quota_us, cpu.cfs_period_us 두 파일이었을 것을 v2에서는 하나로 합친 것뿐이다. 동작 원리는 완전히 같다.
여기서 "왜 하필 100ms인가"가 첫 번째 함정이다. Linux CFS 밴드위스 컨트롤러가 하는 일은 굉장히 단순하다. period가 시작되면 quota만큼 예산을 채워둔다. 태스크가 실행되면서 CPU를 소비하면 그 예산에서 차감한다. 예산이 0이 되면 그 cgroup에 속한 모든 태스크를 즉시 dequeue한다 — 즉, 러너블 큐에서 빼버린다. 다음 period가 시작될 때까지 CPU를 못 받는다. 예산은 다시 quota로 채워진다. 이걸 무한 반복하는 게 전부다.
"사용률 낮은데 왜 throttling?"의 정체
관측이 잘 안 되는 이유는 관측 툴이 대부분 초 단위 평균을 본다는 데 있다. cAdvisor가 노출하는 container_cpu_usage_seconds_total을 rate로 잡으면 최소 15-30초 스크레이프 간격 위에서 평균이 나온다. 반면 CFS는 100ms 단위로 판단한다. 그 사이에는 세상이 굉장히 크다.
예를 들어 500m 리밋을 받은 파드가 있다고 하자. 이 서비스가 요청 하나를 처리할 때 워커 스레드 8개가 병렬로 90ms 동안 CPU를 태우고, 이후 800ms는 아무것도 안 한다고 치자. 초 단위로 보면 8 × 90ms = 720ms를 1초에 태운 셈이니 사용률 72%. 리밋이 500ms/s인데도 어? 초과했네? 싶겠지만, 정확히 그렇다. 그 요청이 실행된 100ms 창 안에서 8스레드 × 90ms = 720ms의 CPU 시간을 소비했고, quota는 50ms였으니 12ms 지나면 이미 다 태운 것이다. 나머지 88ms는 강제로 재운다. 결과적으로 응답 시간이 88ms만큼 늘어난다.
여기서 사람들이 자주 놓치는 게 quota는 wall-clock time이 아니라 CPU-time이라는 점이다. 8 vCPU 노드에서 8스레드가 동시에 뛰면 quota가 8배로 빨리 소진된다. 코어를 많이 가진 노드일수록 이 문제가 더 잘 터진다. 우리 팀에서 처음 이 문제를 심하게 겪은 게 32코어 노드로 마이그레이션한 직후였는데, 리밋은 그대로였지만 병렬성이 늘면서 quota를 태우는 속도가 확 빨라진 게 원인이었다.
cgroup v2에서 뭐가 다른가
메커니즘 자체는 v1과 v2가 동일하다. 다만 몇 가지 실무적으로 의미 있는 차이가 있다.
첫째, unified hierarchy다. v1에서는 cpu, cpuset, memory가 각각 다른 계층에 있어서 노드 툴링이 이걸 종합해서 봐야 했는데, v2는 한 트리에서 다 관리된다. 결과적으로 crictl stats나 cadvisor 같은 툴이 리소스 상관관계를 좀 더 정확하게 보여준다. 예를 들어 memory pressure로 인한 stall과 CPU throttling을 같이 걸어 볼 때 유리하다.
둘째, PSI(Pressure Stall Information) 통합이다. v2 노드에서는 /sys/fs/cgroup/.../cpu.pressure가 노출된다. some avg10=... total=... 형식. 이건 "이 cgroup의 태스크 중 최소 하나가 CPU를 못 받고 대기한 비율"을 알려준다. throttling과는 조금 다른 개념 — throttling은 quota 소진, PSI는 스케줄러 경합까지 포함 — 이지만, 실제 서비스가 얼마나 아팠는지를 볼 때는 PSI가 더 직관적이다. throttling은 카운트만 세기 때문에 "얼마나 심하게 아팠는지"가 잘 안 드러난다.
셋째, cpuset 통합. v2에서는 cpuset이 core cpu 컨트롤러와 같은 계층에서 관리되기 때문에, Guaranteed QoS 파드에 CPU 배타 할당을 걸었을 때 CFS 동작과의 상호작용이 v1보다 예측 가능하다. 이 얘긴 길어져서 다음에 따로 쓰겠다.
그래서 실무에서 뭘 봐야 하나
메트릭은 여전히 container_cpu_cfs_throttled_periods_total과 container_cpu_cfs_throttled_seconds_total이 가장 정확한 신호다. 전자는 몇 번 throttling 됐는지, 후자는 그동안 몇 초를 잃었는지. 알림 임계값은 팀마다 다르겠지만, 우리는 5분 창에서 throttled_periods / cfs_periods 비율이 25%를 넘으면 warning을 건다. 그런데 이것만으로는 부족해서, PSI cpu some avg10을 5%가 넘어가면 별도 알림을 건다. 실제 사용자 체감과 더 잘 맞는다.
리밋 자체를 어떻게 잡을지는... 이게 사실 열린 문제다. Buffer를 크게 잡으면 노드 밀도가 낮아지고, 타이트하게 잡으면 지금 얘기한 문제가 다시 도진다. period를 줄이는 방법(--cpu-cfs-period=)도 있는데 kernel 오버헤드가 늘어서 우리는 안 쓰기로 했다. 최근에는 아예 리밋을 안 걸고 request만 사용하는 팀도 늘어난 것 같은데, 이건 클러스터 워크로드 성격에 크게 좌우돼서 일반화하기 어렵다.
여기까지가 지금 우리가 이해하고 있는 만큼이다. 더 파고들 여지가 많은 주제라 다음에 cpuset + Guaranteed pod 조합 이야기, 그리고 최근 kernel에서 논의되는 CFS burst 기능도 다뤄보고 싶다.