CPU 사용률은 낮은데 왜 쓰로틀링이 발생할까
Tempo Ingester가 OOM으로 죽었다. 메모리 문제인 줄 알았는데, 근본 원인은 CPU 쓰로틀링이었다. 그런데 CPU 사용률은 한 자릿수였다. 왜?
왜 궁금해졌는가
운영 중인 Tempo Ingester에서 OOMKilled가 반복적으로 발생했습니다. 메모리 사용량은 급증하는데, CPU 사용률 지표는 매우 안정적인 저부하 상태를 유지하고 있었습니다.
메모리 사용량 (급증)

CPU 사용률 (안정적)

단순히 메모리가 부족하다고 판단해서 Pod의 메모리를 증설했지만, OOMKilled는 계속 발생했습니다. 결론부터 말하면, 근본 원인은 CPU 쓰로틀링이었습니다. CPU 리소스가 부족해지면서 GC(Garbage Collection) 같은 메모리 해제 작업이 지연되었고, 이것이 메모리 고갈로 이어져 OOMKilled를 유발한 것입니다.
CPU 쓰로틀링 발생 시점

하지만 의문이 남았습니다. CPU 사용률(Usage) 지표는 한 자릿수로 낮은데, 왜 쓰로틀링이 발생하는 걸까요?
이 질문에 답하려면 K8s의 requests와 limits가 커널 레벨에서 어떻게 동작하는지를 이해해야 했습니다.
탐구
CPU 사용률이 낮은데 쓰로틀링은 왜 발생하지?
먼저 K8s가 CPU 리소스를 관리하는 두 가지 값을 살펴봤습니다.

Requests (요청) 는 스케줄링의 기준입니다. 스케줄러는 노드의 가용 리소스와 Pod의 requests를 비교해서 배치 위치를 결정합니다. CPU 경합이 발생하면, K8s는 각 컨테이너의 requests 값에 비례하여 CPU 시간을 분배합니다. requests가 높은 컨테이너가 더 많은 CPU 시간을 할당받습니다.


즉, requests는 경합 시에만 동작하는 소프트 리밋입니다. 경합이 없으면 requests보다 더 많은 CPU를 사용할 수 있습니다.
Limits (제한) 는 다릅니다. 컨테이너가 사용할 수 있는 절대적인 상한선입니다. 노드에 아무리 많은 유휴 CPU가 있어도 limits를 초과할 수 없습니다. 초과하면 커널이 해당 컨테이너의 CPU 사용을 강제로 멈춥니다. 이것이 CPU 쓰로틀링입니다.

여기서 핵심이 보였습니다. Limits는 경합과 무관하게 동작합니다. 노드에 CPU가 남아돌아도, limits에 걸리면 쓰로틀링됩니다. 이건 requests와 근본적으로 다른 동작 방식입니다.
→ 그렇다면 이 쓰로틀링이 커널 레벨에서 정확히 어떻게 구현되는 걸까요?
Limits가 커널에서 CPU를 제한하는 방식 — CFS Bandwidth Control
K8s의 requests와 limits는 리눅스 커널의 cgroups(Control Groups) 와 CFS(Completely Fair Scheduler) 를 통해 구현됩니다.
requests→cpu.weight: CFS 스케줄러가 CPU 경합 시 이 가중치에 비례하여 CPU 시간을 분배합니다.limits→cpu.max: CFS 대역폭 제어(Bandwidth Control) 메커니즘을 통해 동작합니다.
CFS 대역폭 제어가 쓰로틀링의 실체입니다. CFS는 CPU 시간을 일정한 주기(cpu.cfs_period_us, 기본값 100ms)로 나누고, 각 주기마다 cgroup이 사용할 수 있는 최대 시간(cpu.cfs_quota_us)을 할당합니다.
예를 들어 limits: 0.2(200m)로 설정하면, 컨테이너는 매 100ms 주기마다 20ms의 CPU 시간만 사용할 수 있습니다. 20ms를 모두 소진하면 다음 100ms 주기가 시작되기 전까지 남은 80ms 동안 실행이 강제 중지됩니다. 노드에 유휴 CPU가 있어도 소용없습니다.

여기서 중요한 걸 알게 됐습니다. 쓰로틀링은 100ms 단위의 미시적 현상입니다. 매 100ms 주기마다 “20ms 사용 → 80ms 강제 정지”가 반복될 수 있습니다.
→ 그렇다면 Grafana의 CPU 사용률 지표는 왜 이 미시적 쓰로틀링을 반영하지 못하는 걸까요?
CPU Usage 지표가 낮은 이유 — 5분 평균의 함정
Grafana에서 사용하는 CPU 관련 지표 두 가지를 분해해 봤습니다.
CPU Usage (사용률)
sum(node_namespace_pod_container:container_cpu_usage_seconds_total:sum_rate5m{...})
container_cpu_usage_seconds_total은 컨테이너가 사용한 누적 CPU 시간입니다. rate(...[5m])은 이 누적 값의 5분간 평균 변화율을 계산합니다. 핵심은 이것이 평균이라는 점입니다.
CPU Throttling (쓰로틀링 비율)
sum(increase(container_cpu_cfs_throttled_periods_total{...}[$__rate_interval]))
/ sum(increase(container_cpu_cfs_periods_total{...}[$__rate_interval]))
전체 CFS 스케줄링 주기 중 쓰로틀링이 발생한 주기의 비율입니다. 이 값이 25%를 넘으면 심각한 쓰로틀링으로 판단합니다.
이 두 지표의 시간 해상도가 근본적으로 다릅니다. Usage는 5분 평균이고, Throttling은 100ms 주기 단위입니다.
Tempo Ingester의 워크로드 특성이 여기서 문제가 됩니다. Ingester는 block completing 요청이 들어올 때 vParquet 형태로 데이터를 변환하면서 CPU를 집중적으로 사용(Burst)하고, 나머지 시간은 유휴 상태입니다.
구체적인 시나리오를 그려보겠습니다. limits가 200m인 컨테이너가 100ms 주기 중 처음 20ms 동안 CPU를 100% 사용합니다. 할당량을 모두 소진했으므로 나머지 80ms 동안 쓰로틀링됩니다. 쓰로틀링 지표는 급증합니다. 하지만 5분 평균 CPU 사용률은 이 짧은 버스트를 긴 유휴 시간과 함께 평균 내서 5% 정도의 낮은 값으로 표시합니다.
대시보드상에서는 CPU가 여유로운 것처럼 보이지만, 실제로는 애플리케이션이 CPU를 필요로 하는 매 순간마다 limit에 부딪히고 있었던 것입니다.
→ 여기까지 이해하고 나니, 다음 질문이 자연스럽게 떠올랐습니다. 쓰로틀링이 이런 식으로 성능을 해친다면, CPU limit 자체를 설정하지 않는 것이 맞지 않을까?
CPU Limit을 설정하지 않는 것이 나은가?
많은 사람들이 limits를 다른 컨테이너의 리소스를 침범하는 “시끄러운 이웃(Noisy Neighbor)” 문제를 막기 위해 필요하다고 생각합니다. 하지만 그 역할은 이미 requests가 수행하고 있습니다.
-
requests는 최소한을 보장합니다. K8s는 어떤 경우에도 각 컨테이너가requests로 요청한 CPU를 보장합니다. 다른 컨테이너가 아무리 많은 CPU를 사용해도requests로 보장된 자원은 침범할 수 없습니다. -
limits는 유휴 자원의 활용을 막습니다. 노드에 유휴 CPU가 충분히 남아있어도 컨테이너가 이를 사용하지 못하게 합니다. 갑작스러운 트래픽 증가나 CPU 집약적 작업이 필요할 때 성능을 내지 못하고, 지연 시간 증가 → 타임아웃 → 백로그 누적 → 메모리 증가 → OOMKilled 같은 간접적 문제로 이어질 수 있습니다.
결국 Noisy Neighbor를 막는 것은 requests이고, limits는 유휴 CPU를 못 쓰게 막는 제약일 뿐입니다.
이해한 것
탐구를 통해 정리된 이해는 다음과 같습니다.
-
CPU
limits는 CFS 대역폭 제어로 구현된다. 100ms 주기마다 할당된 CPU 시간을 초과하면 강제 중지된다. 노드의 유휴 CPU 여부와 무관하게 동작하는 하드 리밋이다. -
CPU Usage 지표(5분 평균)는 버스트 패턴을 숨긴다. 짧은 순간에 폭발적으로 CPU를 사용하고 나머지 시간에 쉬는 워크로드는, 평균 사용률이 낮게 나오지만 매 100ms 주기마다 쓰로틀링이 발생할 수 있다. CPU 사용률만 보고 “CPU가 충분하다”고 판단하면 안 된다. 쓰로틀링 비율 지표를 반드시 함께 봐야 한다.
-
대부분의 경우 CPU
limits제거가 유리하다. Noisy Neighbor 방어는requests가 담당한다.limits는 유휴 CPU를 못 쓰게 막아 오히려 성능을 해친다. 최적의 전략은 CPUlimits를 제거하고,requests를 VPA 등으로 정확하게 설정하는 데 집중하는 것이다. -
CPU 쓰로틀링은 메모리 문제로 위장할 수 있다. CPU가 부족하면 GC 같은 백그라운드 작업이 지연되고, 이것이 메모리 고갈로 이어진다. OOMKilled가 발생했을 때 CPU 쓰로틀링 지표를 반드시 확인해야 한다.
남은 질문
CPU는 쓰로틀링으로 “느려지기만” 하고 프로세스가 죽지는 않는다. 하지만 메모리는 다르다. 메모리는 초과하면 프로세스가 즉시 죽는다(OOM Kill). 이 차이가 requests와 limits 설정 전략에 어떤 영향을 줄까? CPU는 limit을 풀어야 한다면, 메모리도 같은 전략이 통할까?
→ 이 질문은 다음 글 K8s 메모리 request와 limit, 어떻게 설정해야 할까에서 탐구한다.