이 글의 목차
쿠버네티스 CPU 스로틀링은 컨테이너 cgroup의 cpu.stat에서 nr_throttled / nr_periods 비율로, 클러스터 전체에서는 container_cpu_cfs_throttled_periods_total을 container_cpu_cfs_periods_total로 나눈 비율로 확인합니다. 평균 CPU 사용량이 limit보다 한참 낮아도 이 비율이 높고 지연 시간이 함께 튄다면 CPU limit(CFS quota)이 원인입니다.
이 글은 “CPU는 남는데 왜 느리지?”를 숫자로 확인하려는 운영자를 위한 점검표입니다. CFS 스케줄러의 배경과 사례는 Kubernetes CPU Throttling과 CFS Quota 개념 정리에 있고, 여기서는 확인 명령·지표·PromQL·판단 기준과 조치의 대가에 집중합니다. 명령과 기능 상태는 Kubernetes 1.35~1.37, cgroup v2 노드 기준입니다.
| 확인 수단 | 보는 값 | 쓰는 때 |
|---|---|---|
cat /sys/fs/cgroup/cpu.max | 실제 적용된 quota와 period | limit이 의도대로 들어갔는지 |
cat /sys/fs/cgroup/cpu.stat | nr_periods, nr_throttled, throttled_usec | 특정 Pod 즉석 점검 |
cAdvisor container_cpu_cfs_* | 스로틀링 비율과 추세 | 클러스터 전체 비교, 경보 |
kubectl top pod | 일정 구간의 평균 사용량 | 스로틀링 판단에는 부적합 |
CFS quota는 CPU limit을 어떻게 시간으로 바꾸나
Kubernetes 문서는 CPU limit을 “CPU 스로틀링으로 강제되는, 커널이 집행하는 hard limit”이라고 설명합니다. kubelet은 limit을 CFS 대역폭 제어 값으로 바꿉니다. 기본 주기(cpu.cfs_period_us)는 100ms이고, quota는 limit(밀리코어) × 주기 ÷ 1000입니다.
| CPU limit | cgroup v1 (cfs_quota_us / cfs_period_us) | cgroup v2 cpu.max | 100ms마다 쓸 수 있는 CPU 시간 |
|---|---|---|---|
| 250m | 25000 / 100000 | 25000 100000 | 25ms |
| 500m | 50000 / 100000 | 50000 100000 | 50ms |
| 1 (1000m) | 100000 / 100000 | 100000 100000 | 100ms |
| 2 | 200000 / 100000 | 200000 100000 | 200ms (여러 코어 합산) |
| 설정 안 함 | -1 | max 100000 | 제한 없음 |
핵심은 quota가 컨테이너 안 모든 스레드의 합계라는 점입니다. limit 500m 컨테이너에서 스레드 4개가 동시에 돌면 50ms quota를 12.5ms 만에 다 쓰고, 그 주기의 남은 87.5ms 동안 실행 가능한 스레드가 있어도 멈춥니다. CPU request는 스케줄링과 CPU 가중치에 쓰일 뿐 quota를 만들지 않습니다.
커널 문서상 period는 1ms~1s 범위에서 바꿀 수 있지만, Kubernetes에서는 노드 단위 kubelet 설정 cpuCFSQuotaPeriod로만 바꿀 수 있고 이 설정에는 알파 상태인 CustomCPUCFSQuotaPeriod 기능 게이트가 필요합니다. Pod 단위로 주기를 바꾸는 표준 필드나 어노테이션은 없습니다.
컨테이너 안에서 스로틀링 확인하기
먼저 노드가 cgroup v2인지 확인합니다. Kubernetes 1.35부터 cgroup v1 지원은 deprecated 상태이고, kubelet 설정 failCgroupV1의 기본값이 true라서 cgroup v1 호스트에서는 기본적으로 kubelet이 시작되지 않습니다.
kubectl exec -n prod my-app-7d9c5b6f4-abcde -c app -- stat -fc %T /sys/fs/cgroup/# cgroup2fs 이면 cgroup v2, tmpfs 이면 cgroup v1cgroup v2에서는 컨테이너 안의 /sys/fs/cgroup이 곧 그 컨테이너의 cgroup입니다. 적용된 limit부터 봅니다.
kubectl exec -n prod my-app-7d9c5b6f4-abcde -c app -- cat /sys/fs/cgroup/cpu.max# 50000 100000 → 100ms마다 50ms, 즉 limit 500m다음으로 cpu.stat을 읽습니다.
kubectl exec -n prod my-app-7d9c5b6f4-abcde -c app -- cat /sys/fs/cgroup/cpu.statusage_usec 81234567user_usec 70123456system_usec 11111111nr_periods 152340nr_throttled 20871throttled_usec 912345678nr_bursts 0burst_usec 0(값은 설명용 예시입니다.) 커널 cgroup v2 문서 기준 필드의 의미는 다음과 같습니다.
| 필드 | 의미 | 단위 |
|---|---|---|
usage_usec, user_usec, system_usec | 누적 CPU 사용 시간 (항상 표시) | 마이크로초 |
nr_periods | 경과한 집행 주기 수 | 횟수 |
nr_throttled | 스로틀링된 주기 수 | 횟수 |
throttled_usec | 스로틀링된 누적 시간 | 마이크로초 |
nr_bursts, burst_usec | cpu.max.burst 사용 횟수·시간 | 횟수, 마이크로초 |
cgroup v1의 파일은 /sys/fs/cgroup/cpu/cpu.stat(배포판에 따라 cpu,cpuacct 경로)이고, 누적 시간 필드가 throttled_time이며 단위가 나노초입니다. 두 버전의 숫자를 섞어 비교하지 마세요.
위 예시의 누적 비율은 20871 ÷ 152340 ≈ 13.7%입니다. 다만 누적값은 컨테이너가 시작된 뒤 전체 평균이라 지금 상황을 가립니다. 1분 간격 두 스냅샷의 차이를 보는 편이 정확합니다.
NS=prod; POD=my-app-7d9c5b6f4-abcde; C=appsnap() { kubectl exec -n "$NS" "$POD" -c "$C" -- cat /sys/fs/cgroup/cpu.stat; }snap > a.txt; sleep 60; snap > b.txtawk 'NR==FNR {a[$1]=$2; next} {d[$1]=$2-a[$1]} END { r = (d["nr_periods"] > 0) ? 100 * d["nr_throttled"] / d["nr_periods"] : 0 printf "periods=%d throttled=%d ratio=%.1f%% throttled=%.0fms\n", d["nr_periods"], d["nr_throttled"], r, d["throttled_usec"] / 1000 }' a.txt b.txtcat이 없는 distroless 이미지라면 노드에서 같은 파일을 읽을 수 있습니다. 컨테이너 PID의 cgroup 경로를 찾아 호스트의 cgroup 트리에서 엽니다.
# 노드에서 실행 (containerd + crictl 기준)crictl ps --name app # CONTAINER ID 확인crictl inspect <CONTAINER_ID> | grep -m1 '"pid"'cat /proc/<PID>/cgroup # 0::/kubepods.slice/... 형태의 경로cat /sys/fs/cgroup/<위에서 나온 경로>/cpu.statPrometheus로 쿠버네티스 CPU 스로틀링 비율 보기
kubelet에 내장된 cAdvisor가 /metrics/cadvisor로 아래 카운터를 내보냅니다. cAdvisor 코드상 이 세 지표는 CPU quota가 설정된 컨테이너에만 생성됩니다.
| 지표 | 타입 | 뜻 |
|---|---|---|
container_cpu_cfs_periods_total | Counter | 경과한 집행 주기 수 (nr_periods) |
container_cpu_cfs_throttled_periods_total | Counter | 스로틀링된 주기 수 (nr_throttled) |
container_cpu_cfs_throttled_seconds_total | Counter | 스로틀링된 누적 시간(초) |
container_spec_cpu_quota, container_spec_cpu_period | Gauge | 적용된 quota와 period |
컨테이너별 스로틀링 비율(0~1)은 이렇게 구합니다. container=""는 Pod 단위 cgroup 집계 시계열이고 container="POD"는 런타임에 따라 남아 있는 pause 컨테이너이므로 제외합니다.
sum by (namespace, pod, container) ( rate(container_cpu_cfs_throttled_periods_total{container!="", container!="POD"}[5m]))/sum by (namespace, pod, container) ( rate(container_cpu_cfs_periods_total{container!="", container!="POD"}[5m]))sum by로 분자와 분모를 같은 라벨로 묶어 두면, 같은 컨테이너가 다른 id·name 라벨이나 중복 수집으로 두 번 잡혀도 비율은 그대로입니다. job·metrics_path 라벨은 설치 방식마다 다르니 필요하면 셀렉터에 추가하세요.
가장 심한 컨테이너 10개는 위 식을 topk(10, ...)로 감싸면 됩니다. 스로틀링이 실제로 얼마나 기다리게 했는지는 시간 지표로 봅니다.
sum by (namespace, pod, container) ( rate(container_cpu_cfs_throttled_seconds_total{container!="", container!="POD"}[5m]))이 값은 CPU별 대기 시간이 쌓이는 방식이라 1초를 넘을 수 있습니다. 비율이 아니라 배포 전후나 컨테이너끼리 비교하는 용도로 쓰세요. 평균 사용량이 limit의 몇 %인지는 kube-state-metrics 없이 cAdvisor 값만으로 계산할 수 있습니다.
sum by (namespace, pod, container) ( rate(container_cpu_usage_seconds_total{container!="", container!="POD"}[5m]))/sum by (namespace, pod, container) ( container_spec_cpu_quota{container!="", container!="POD"} / container_spec_cpu_period{container!="", container!="POD"})몇 %부터 문제인가: 판단 기준과 경보
공식적인 “정상 스로틀링 비율”은 없습니다. 참고할 만한 기본값은 kube-prometheus 계열이 쓰는 kubernetes-mixin의 CPUThrottlingHigh 경보로, 5분 구간 비율이 25%를 넘는 상태가 15분 지속되면 severity: info로 발생합니다. 이 경보의 런북은 “그 자체로는 정보성”이며 애플리케이션에 다른 문제가 없으면 limit을 올리지 말라고 안내합니다.
그래서 비율 하나로 결론 내리지 말고 증상과 함께 봅니다.
| 상황 | 해석 | 다음 행동 |
|---|---|---|
| 비율이 높지만 p99 지연·타임아웃 변화 없음 | 배치·백그라운드 작업이면 정상일 수 있음 | 관찰, 경보는 info로 유지 |
| 비율 상승과 p99 지연 상승 시점이 겹침 | limit이 지연의 원인일 가능성 높음 | 아래 조치 검토 |
| 평균 사용량이 limit에 근접 + 높은 비율 | 단순 용량 부족 | limit·request 상향 또는 스케일 아웃 |
| 평균 사용량은 낮은데 높은 비율 | 짧은 다중 스레드 burst | 스레드 수·limit 조정 |
| liveness probe 타임아웃·재시작 동반 | 스로틀링이 probe 응답을 늦춤 | probe timeoutSeconds 재검토와 함께 조치 |
마지막 경우는 CrashLoopBackOff 디버깅 가이드의 probe 점검과 함께 보면 좋습니다. 경보 규칙은 mixin과 같은 기준으로 시작해 서비스 SLO에 맞게 조정하세요.
groups: - name: cpu-throttling rules: - alert: ContainerCPUThrottlingHigh expr: | sum by (namespace, pod, container) ( increase(container_cpu_cfs_throttled_periods_total{container!="", container!="POD"}[5m]) ) / sum by (namespace, pod, container) ( increase(container_cpu_cfs_periods_total{container!="", container!="POD"}[5m]) ) > 0.25 for: 15m labels: severity: info annotations: summary: "{{ $labels.namespace }}/{{ $labels.pod }} {{ $labels.container }} CPU 스로틀링 {{ $value | humanizePercentage }}"평균 CPU 사용량이 낮은데 스로틀링되는 이유
kubectl top과 대시보드의 CPU 사용량은 수십 초~수 분 구간의 평균입니다. 스로틀링은 100ms 주기 안에서 결정됩니다. limit 500m 웹 서버가 1초에 두 번, 스레드 4개로 각 50ms 분량의 CPU를 쓰는 요청을 처리한다고 해 보겠습니다.
- 요청 하나가 quota 50ms를 12.5ms 만에 소진하고 그 주기의 나머지 87.5ms 동안 멈춥니다.
- 1초 평균 사용량은 100ms, 즉 100m입니다. limit의 20%로 “여유”처럼 보입니다.
- 같은 1초 동안 10개 주기 중 2개가 스로틀링되어 비율은 20%이고, 해당 요청은 최대 87.5ms 늦어집니다.
이런 burst를 만드는 흔한 원인은 런타임이 노드 코어 수만큼 스레드를 띄우는 경우입니다.
- GC: JVM의 병렬 GC 스레드, Go GC의 mark 단계(공식 GC 가이드 기준 CPU의 25%를 사용)
- JIT 컴파일: JVM 기동 직후 컴파일러 스레드가 몰려 시작 시간이 길어짐
- 워커 풀: Go의
GOMAXPROCS, Node.js libuv 스레드 풀, 애플리케이션 스레드 풀
오래된 커널(5.4 이전)은 CPU별로 나눠 준 quota 조각이 주기마다 만료되는 문제로, 다중 스레드 앱이 quota를 다 쓰지 않고도 스로틀링되는 버그가 있었습니다. Kubernetes 이슈 #67577로 보고되어 커널 커밋 de53fd7aedb1에서 고쳐졌습니다. 아주 오래된 노드 이미지라면 커널 버전도 확인하세요.
해결 방법과 대가
| 방법 | 효과 | 대가와 조건 |
|---|---|---|
| CPU limit 상향 (request 유지) | quota 증가 | 노드가 붐비면 결국 request 비율대로 경쟁 |
| CPU limit 제거 (request 유지) | quota 없음, 스로틀링 0 | 이웃 Pod 영향, Guaranteed QoS 불가, LimitRange 기본 limit 확인 필요 |
| 런타임 스레드 수를 limit에 맞춤 | burst 폭 축소 | 최대 처리량 감소 가능 |
CPU Manager static + 정수 CPU Guaranteed Pod | 전용 코어, quota 미적용 | 노드 설정 변경, 정수 단위, 유휴 코어 낭비 |
| 실행 중 Pod CPU 크기 조정(in-place resize) | 재시작 없이 limit 변경 | 일시적 조치, limit 제거 불가 |
메모리 limit은 반대로 초과 즉시 종료되므로 CPU limit만 제거하고 메모리 limit은 유지하는 구성이 일반적입니다. 메모리 쪽 증상은 OOMKilled 디버깅 가이드와 Exit Code 137 진단을 참고하세요.
resources: requests: cpu: "500m" # 스케줄링과 CPU 가중치 memory: "512Mi" limits: memory: "512Mi" # CPU limit만 제거limit을 지웠는데 다시 생긴다면 네임스페이스의 LimitRange가 기본 limit을 넣는 것입니다. kubectl get limitrange -n prod -o yaml로 확인하세요.
Go: GOMAXPROCS
Go 1.25부터 Linux 런타임은 cgroup CPU limit이 논리 CPU 수보다 작으면 GOMAXPROCS 기본값을 limit에 맞추고, limit이 바뀌면 주기적으로 다시 맞춥니다. request는 고려하지 않습니다. 런타임 문서상 소수점 limit은 올림하고, CPU가 2개 이상인 노드에서는 2 미만으로 내려가지 않습니다. 즉 limit 500m이어도 GOMAXPROCS는 2라서 두 코어로 burst할 수 있습니다.
GOMAXPROCS환경 변수나runtime.GOMAXPROCS로 직접 지정하면 이 자동 동작은 꺼집니다.- GODEBUG 기본값은
go.mod의go버전을 따르므로, 1.25 툴체인이라도go지시어가 1.24 이하라면 이전 동작에 가까워질 수 있습니다. - 1.25 미만에서는
go.uber.org/automaxprocs를 import하는 방식이 널리 쓰입니다.
JVM: 컨테이너 인식과 ActiveProcessorCount
JDK의 UseContainerSupport는 Linux에서 기본으로 켜져 있어 컨테이너의 CPU·메모리 제한을 읽습니다. JDK 19(11.0.17, 17.0.5로 백포트)부터는 CPU shares, 즉 request를 프로세서 수 계산에 쓰지 않습니다. GC·ForkJoinPool 스레드 수를 직접 정하려면 다음 옵션을 씁니다.
# 컨테이너 인식 결과 확인java -Xlog:os+container=trace -version 2>&1 | grep -i cpu
# 스레드 풀 계산에 쓸 CPU 수를 고정java -XX:ActiveProcessorCount=2 -jar app.jarlimit을 없애면 JVM은 노드 전체 코어를 보고 스레드를 늘립니다. limit을 제거할 때는 ActiveProcessorCount를 함께 지정하는 편이 예측 가능합니다.
Node.js: UV_THREADPOOL_SIZE
libuv 스레드 풀(파일 I/O, crypto, zlib, DNS lookup 등)의 기본 크기는 4이고 최대 1024이며, 프로세스 시작 시 UV_THREADPOOL_SIZE 환경 변수로 정합니다. 처리량을 위해 이 값을 크게 올렸다면 작은 CPU limit 아래에서 동시에 깨어난 스레드가 quota를 빠르게 소진할 수 있으니 limit과 함께 조정하세요.
CPU Manager static 정책
kubelet의 cpuManagerPolicy: static(v1.26 GA)에서는 Guaranteed Pod이면서 CPU request가 정수인 컨테이너만 전용 CPU를 받습니다. DisableCPUQuotaWithExclusiveCPUs 기능 게이트(v1.33부터 베타, 기본 활성)가 켜져 있으면 이런 컨테이너에는 CFS quota를 걸지 않습니다. 대신 정책 변경 시 노드 drain과 /var/lib/kubelet/cpu_manager_state 삭제가 필요하고, 0보다 큰 CPU 예약이 필요하며, 이 Pod는 in-place resize 대상에서 빠집니다.
실행 중 Pod의 CPU limit 바꾸기
In-place Pod resize(InPlacePodVerticalScaling)는 v1.27 알파, v1.33 베타를 거쳐 v1.35에서 GA가 되었습니다. kubectl v1.32 이상에서 resize 하위 리소스로 CPU limit을 바꿀 수 있습니다.
kubectl patch pod my-app-7d9c5b6f4-abcde -n prod --subresource resize --patch \ '{"spec":{"containers":[{"name":"app","resources":{"limits":{"cpu":"1500m"}}}]}}'
# 반영 확인: status 값과 cgroup 값kubectl get pod my-app-7d9c5b6f4-abcde -n prod \ -o jsonpath='{.status.containerStatuses[?(@.name=="app")].resources}'kubectl exec -n prod my-app-7d9c5b6f4-abcde -c app -- cat /sys/fs/cgroup/cpu.max# 150000 100000공식 문서의 제약을 알고 쓰세요. QoS 클래스는 바꿀 수 없고(Guaranteed는 request와 limit을 계속 같게 유지), 이미 설정된 request·limit을 제거할 수는 없으며, Windows Pod와 static CPU·Memory Manager 정책 Pod는 대상이 아닙니다. 또 Pod 하나를 고친 것이라 Deployment 템플릿을 바꾸지 않으면 새로 뜨는 Pod는 원래 값으로 돌아갑니다. 확인용 긴급 조치로 쓰고, 효과가 확인되면 매니페스트에 반영하세요. Go 1.25 이상 앱이라면 limit 변경 후 GOMAXPROCS도 런타임이 다시 맞춥니다.
CFS burst는 아직 Kubernetes에서 쓸 수 없습니다
리눅스 커널에는 쉬는 주기에 남긴 quota를 모아 두었다가 쓰는 burst 기능이 있습니다. cgroup v2는 cpu.max.burst(기본 0, 범위 0~quota), v1은 cpu.cfs_burst_us입니다. 하지만 2026년 10월 31일 기준 Kubernetes Pod 스펙이나 kubelet 설정에는 이를 지정하는 필드가 없습니다. 관련 요청은 kubernetes/kubernetes 이슈 #104516(2021년)과 2026년 9월 열린 enhancement 이슈 #6333 “Support CFS Bursting in Kubelet”에서 논의 중이며, KEP는 아직 정해지지 않았습니다. 노드에서 cgroup 파일을 직접 고치는 방식은 kubelet이 값을 다시 쓸 수 있어 권장하지 않습니다.
자주 묻는 질문
cfs quota는 k8s cpu limit과 어떤 관계인가요?
CPU limit이 곧 CFS quota입니다. kubelet이 limit을 “주기(기본 100ms)마다 쓸 수 있는 CPU 시간”으로 바꿔 cgroup v2의 cpu.max(v1은 cpu.cfs_quota_us)에 씁니다. limit 500m이면 50000 100000입니다. request는 quota를 만들지 않습니다.
container_cpu_cfs_periods_total이 안 보이거나 0인 이유는?
cAdvisor는 CPU quota가 설정된 컨테이너에만 container_cpu_cfs_* 지표를 만듭니다. CPU limit이 없는 컨테이너라면 시계열이 없는 것이 정상입니다. limit이 있는데도 없다면 Prometheus가 kubelet의 /metrics/cadvisor를 수집하는지, container 라벨 필터가 맞는지 확인하세요.
CPU 스로틀링 비율은 몇 %부터 조치해야 하나요?
정해진 표준은 없습니다. kubernetes-mixin 기본 경보는 25%가 15분 지속될 때 info 수준으로 알립니다. 실무에서는 비율 자체보다 p99 지연, 타임아웃, probe 실패가 비율 상승과 같은 시점에 나타나는지를 보고 판단하는 편이 안전합니다.
k8s cpu limit을 없애도 괜찮나요?
CPU request를 실제 사용량에 맞게 두고, 노드 여유와 이웃 워크로드 영향을 모니터링할 수 있다면 흔히 쓰는 선택입니다. 대신 Pod는 Guaranteed QoS가 될 수 없고, 멀티테넌트 클러스터에서는 한 서비스가 남는 CPU를 독식할 수 있습니다. 메모리 limit은 유지하세요.
참고 자료와 확인 범위
- Kubernetes: Resource Management for Pods and Containers
- Kubernetes: Resize CPU and Memory Resources assigned to Containers / v1.35 In-Place Pod Resize GA 발표
- Kubernetes: Control CPU Management Policies on the Node
- Kubernetes: Kubelet Configuration (v1beta1) / Feature Gates
- Kubernetes: About cgroup v2 / Pod Quality of Service Classes
- Linux Kernel: Control Group v2 / CFS Bandwidth Control
- cAdvisor Prometheus 지표 목록
- kubernetes-mixin 리소스 경보 / CPUThrottlingHigh 런북
- Go 1.25 릴리스 노트 / Go runtime 패키지 문서 / Go GODEBUG
- Java 명령 옵션 (JDK 21) / JDK-8281181
- libuv Thread pool
- Kubernetes 이슈 #104516 / enhancements 이슈 #6333 / 커널 커밋 de53fd7aedb1
2026년 10월 31일 자료 확인. Kubernetes 1.35~1.37과 cgroup v2 노드를 기준으로 했으며, 관리형 서비스(EKS·GKE·AKS 등)는 kubelet 설정과 기능 게이트를 사용자가 바꾸지 못할 수 있습니다. 예시 출력 값은 설명용이며, 경보 임계값은 서비스 지연 목표에 맞게 조정하세요.