Skip to content

Split View: CPU steal time과 스로틀링 — top의 st, 버스터블 크레딧, CFS 쿼터를 가르는 법

✨ Learn with Quiz
|

CPU steal time과 스로틀링 — top의 st, 버스터블 크레딧, CFS 쿼터를 가르는 법

들어가며 — CPU는 40%인데 p99가 3초입니다

용량 회의에서 가장 자주 나오는 문장이 있습니다. "CPU 사용률이 40%밖에 안 되니까 CPU 문제는 아닙니다." 이 문장은 세 가지 상황에서 틀립니다. 그리고 그 세 가지가 이 글의 전부입니다.

top -bn1 | head -4
# top - 14:22:31 up 9 days,  4:12,  1 user,  load average: 3.21, 3.04, 2.88
# Tasks: 214 total,   2 running, 212 sleeping,   0 stopped,   0 zombie
# %Cpu(s): 31.2 us,  4.1 sy,  0.0 ni, 42.6 id,  0.4 wa,  0.0 hi,  0.5 si, 21.2 st

마지막 열의 21.2 st가 이 글의 출발점입니다. 유휴가 42.6%로 보이지만 21.2%는 이미 남에게 뺏긴 시간이고, 애플리케이션이 쓸 수 있었던 CPU는 그만큼 줄었습니다. 그리고 여기 나타나지 않는 세 번째 원인이 하나 더 있습니다. 컨테이너 스로틀링은 이 화면 어디에도 표시되지 않습니다.

steal time의 정의 — 하이퍼바이저가 주지 않은 시간

stvCPU가 실행할 준비가 되어 있었는데 하이퍼바이저가 물리 CPU를 배정하지 않아 대기한 시간의 비율입니다. 게스트 커널 안에서 일어난 일이 아니라 게스트 바깥에서 일어난 일이고, 게스트는 이것을 사후에 통보받을 뿐입니다.

동작 원리는 준가상화 인터페이스입니다. KVM에서는 게스트 커널이 MSR_KVM_STEAL_TIME을 통해 공유 메모리 구조체를 등록하고, 하이퍼바이저가 vCPU를 스케줄 아웃한 누적 나노초를 거기에 씁니다. 게스트 커널은 그 값을 읽어 /proc/stat의 8번째 필드에 반영합니다. Xen도 runstate 정보로 같은 일을 합니다.

head -2 /proc/stat
# cpu  9284712 1832 1204831 88213904 42193 0 18922 1928374 0 0
# cpu0 1160589  229  150604 11026738  5274 0  2365  241046 0 0
#      user    nice system idle    iowait irq softirq steal guest guest_nice

여덟 번째 숫자가 steal 누적 tick입니다. top이나 mpstat은 이 값의 차분을 보여 주는 것뿐입니다.

mpstat -P ALL 1 3 | tail -5
# 14:23:03  CPU   %usr  %nice  %sys %iowait  %irq %soft %steal %guest %gnice  %idle
# 14:23:03  all  30.51   0.00  4.02    0.38  0.00  0.51  21.34   0.00   0.00  43.24
# 14:23:03    0  29.90   0.00  3.96    0.50  0.00  0.99  22.28   0.00   0.00  42.37
# 14:23:03    1  31.14   0.00  4.08    0.26  0.00  0.03  20.40   0.00   0.00  44.09

CPU별로 고르게 21% 근처면 호스트 전체가 과밀한 것이고, 특정 vCPU에만 몰려 있다면 그 vCPU가 배치된 물리 코어에 경쟁자가 있는 것입니다. 후자는 인스턴스를 재기동해 다른 호스트로 옮기는 것만으로 해결되는 경우가 많습니다.

여기서 가장 흔한 오해를 짚습니다. st가 높다고 내 프로세스가 CPU를 많이 쓰는 것이 아닙니다. 오히려 반대입니다. 내가 쓰고 싶었는데 못 쓴 시간이고, 원인은 인스턴스 바깥에 있습니다. 애플리케이션 코드를 아무리 최적화해도 이 수치는 내려가지 않습니다.

두 번째 오해도 중요합니다. st가 0이라고 해서 하이퍼바이저 경합이 없다는 뜻은 아닙니다. steal 회계는 하이퍼바이저가 그 정보를 게스트에 노출할 때만 동작합니다. VMware 환경의 게스트는 대체로 st가 0으로 보이고, 실제 경합은 호스트 쪽 %RDY(CPU ready) 지표에서만 확인됩니다. 온프레미스 가상화에서 "st가 0이니 문제없다"고 결론 내리는 것이 대표적인 오진입니다.

# 준가상화 클럭과 steal 회계가 활성인지 확인
grep -o 'kvm\|xen\|vmware\|hyperv' /sys/hypervisor/type 2>/dev/null
systemd-detect-virt
# kvm

grep -E 'CONFIG_PARAVIRT_TIME_ACCOUNTING' /boot/config-$(uname -r)
# CONFIG_PARAVIRT_TIME_ACCOUNTING=y

st가 몇 퍼센트부터 문제인가, 그리고 무엇을 할 수 있는가

절대 기준은 없지만 운영 경험칙은 비교적 일관됩니다.

  • 1% 미만: 정상입니다. 공유 인프라에서 0은 오히려 드뭅니다.
  • 1~5%: 관찰 대상입니다. 지연에 민감한 서비스라면 이미 p99에 영향이 있을 수 있습니다.
  • 5~10% 지속: 조치 대상입니다. 처리량이 실제로 그만큼 깎입니다.
  • 10% 초과 지속: 즉시 옮겨야 합니다. 그 호스트에서는 어떤 튜닝도 효과가 없습니다.

중요한 것은 순간값이 아니라 지속성입니다. 백업 창이나 이웃의 배치 작업 때문에 몇 분간 튀는 것은 흔한 일입니다. 알람은 5분이나 15분 평균으로 걸어야 오탐이 줄어듭니다.

대응은 네 가지입니다.

# 1) 인스턴스 재배치 — 중지 후 시작하면 다른 물리 호스트에 배치됩니다
#    (재부팅은 같은 호스트에 남으므로 효과가 없습니다)
aws ec2 stop-instances --instance-ids i-0abc123def4567890
aws ec2 start-instances --instance-ids i-0abc123def4567890

재부팅과 중지-시작의 차이가 핵심입니다. 게스트 안에서의 reboot은 물리 호스트를 바꾸지 않습니다. 클라우드 API를 통한 정지와 시작이라야 배치가 다시 일어납니다.

나머지 세 가지는 구조적 조치입니다. 물리 코어 전체를 점유하는 크기의 인스턴스로 올리면 이웃 자체가 사라집니다. 전용 호스트나 베어메탈 인스턴스는 하이퍼바이저 공유를 아예 없애는 대신 비용이 큽니다. 그리고 지연에 민감한 워크로드를 st가 낮은 인스턴스 패밀리로 분리 배치하는 것도 실용적인 선택입니다.

한 가지 함정. st가 높을 때 vCPU 개수를 늘리는 것은 종종 역효과입니다. 호스트가 이미 과밀한 상태라면 vCPU가 늘어난 만큼 스케줄링 기회를 더 기다려야 하고, 게스트 안에서는 스핀락 경합까지 늘어납니다. 코어 수보다 인스턴스 크기(호스트 점유 비율)를 올리는 편이 맞습니다.

버스터블 인스턴스의 크레딧 고갈 — 같은 증상, 다른 원인

두 번째 원인입니다. AWS의 T 계열, 다른 클라우드의 유사 등급은 기준 성능(baseline)과 크레딧이라는 개념으로 동작합니다. 기준 이하로 쓰면 크레딧이 쌓이고, 기준을 넘겨 쓰면 크레딧을 소모합니다.

인스턴스vCPUvCPU당 기준 성능시간당 적립 크레딧
t3.nano25%6
t3.micro210%12
t3.small220%24
t3.medium220%24
t3.large230%36
t3.xlarge440%96
t3.2xlarge840%192

크레딧 1개는 vCPU 하나를 100% 성능으로 1분 쓸 수 있는 양입니다. t3.medium은 시간당 24개를 벌고 vCPU가 2개이므로, 지속 가능한 사용량은 2 곱하기 20%, 즉 vCPU 하나 기준 40%에 해당합니다. 그 이상 계속 쓰면 잔고는 반드시 0으로 갑니다.

증상이 특징적입니다. 처음 몇 시간은 멀쩡하다가 어느 순간부터 성능이 계단식으로 떨어져 회복되지 않습니다. 부하는 그대로인데 응답 시간만 몇 배가 됩니다. standard 모드에서 크레딧이 바닥나면 하이퍼바이저가 기준 성능으로 강제 제한하고, 게스트는 이것을 steal time으로 인식합니다. 즉 st가 올라가지만 원인은 이웃이 아니라 내 예산입니다.

구분하는 방법은 간단합니다. steal이 시간에 따라 무작위로 오르내리면 이웃이고, 크레딧 잔고가 0에 닿은 시각부터 steal이 계단처럼 올라 고정되면 크레딧입니다.

aws cloudwatch get-metric-statistics \
  --namespace AWS/EC2 --metric-name CPUCreditBalance \
  --dimensions Name=InstanceId,Value=i-0abc123def4567890 \
  --start-time 2026-07-25T00:00:00Z --end-time 2026-07-26T00:00:00Z \
  --period 300 --statistics Average \
  --query 'sort_by(Datapoints, &Timestamp)[].[Timestamp,Average]' --output text | tail -6
# 2026-07-25T09:00:00Z	142.3
# 2026-07-25T10:00:00Z	88.1
# 2026-07-25T11:00:00Z	31.4
# 2026-07-25T12:00:00Z	0.0
# 2026-07-25T13:00:00Z	0.0
# 2026-07-25T14:00:00Z	0.0

12시부터 0입니다. 지연 그래프가 정확히 그 시각부터 나빠졌다면 진단은 끝났습니다.

선택지는 세 가지입니다. unlimited 모드로 전환하면 크레딧이 없어도 계속 버스트하고 초과분은 추가 과금됩니다(CPUSurplusCreditsCharged 지표로 실제 비용을 확인할 수 있습니다). 지속적으로 기준을 넘겨 쓰고 있다면 M이나 C 계열의 고정 성능 인스턴스로 옮기는 것이 결국 더 쌉니다. 버스트가 진짜로 간헐적이라면 T 계열을 유지하되 크레딧 잔고를 알람에 거는 것이 맞습니다.

# unlimited 전환
aws ec2 modify-instance-credit-specification \
  --instance-credit-specifications \
  'InstanceId=i-0abc123def4567890,CpuCredits=unlimited'

비용 판단 기준은 이렇습니다. unlimited 모드의 초과 과금이 상시 발생한다면, 그 금액과 한 단계 위 고정 성능 인스턴스의 차액을 비교하십시오. 상시 버스트 상태에서는 대부분 후자가 저렴하고 성능도 예측 가능합니다.

컨테이너 CFS 쿼터 스로틀링 — top에 전혀 보이지 않는 세 번째 원인

가장 중요한 절입니다. 앞의 두 원인은 최소한 st라는 흔적을 남깁니다. 스로틀링은 아무 흔적도 남기지 않습니다.

동작은 이렇습니다. cgroup에 CPU 상한이 걸려 있으면 커널은 주기(기본 100ms)마다 쿼터를 배분합니다. 그 그룹의 태스크들이 주기 안에서 쿼터를 다 쓰면, 커널은 남은 시간 동안 그 그룹 전체를 런큐에서 빼 버립니다. 다음 주기가 시작될 때까지 아무것도 실행되지 않습니다.

이 상태에서 무슨 일이 벌어지는지 봅시다. CPU limit이 1코어인 파드에 스레드가 8개 있습니다. 8개가 동시에 돌면 100ms 쿼터를 12.5ms 만에 소진합니다. 나머지 87.5ms 동안 이 파드는 완전히 정지합니다. 요청 하나가 하필 그 구간에 걸리면 응답에 87.5ms가 더해집니다. 평균 CPU 사용률은 정확히 100%(1코어 중 1코어)로 보이지도 않습니다. 12.5% 근처로 보입니다.

그래서 이런 그래프가 나옵니다. 평균 CPU 사용률은 낮고, 스틸도 0이고, 로드 애버리지도 조용한데, p99만 규칙적으로 튑니다. 스로틀링당한 태스크는 런큐에서 빠지기 때문에 로드 애버리지에도 잡히지 않습니다.

확인은 cpu.stat 한 파일이면 됩니다.

# cgroup v2 (컨테이너 안에서, cgroup 네임스페이스가 private이면 그대로 보입니다)
cat /sys/fs/cgroup/cpu.max
# 100000 100000

cat /sys/fs/cgroup/cpu.stat
# usage_usec 4128374621
# user_usec 3719283746
# system_usec 409090875
# nr_periods 86400
# nr_throttled 41287
# throttled_usec 2914837261

cpu.max의 두 숫자는 쿼터와 주기이고 단위는 마이크로초입니다. 100000 100000은 100ms 주기에 100ms 쿼터, 즉 1코어입니다. 제한이 없으면 첫 값이 max입니다.

읽어야 할 비율은 하나입니다.

스로틀링 비율 = nr_throttled / nr_periods = 41287 / 86400 = 47.8%
주기당 평균 정지 시간 = throttled_usec / nr_throttled = 2914837261 / 41287 = 70.6ms

전체 주기의 절반에서 스로틀링이 발생했고, 발생할 때마다 평균 70ms씩 멈췄습니다. p99 지연의 정체가 여기 있습니다. 실무 기준으로 비율 5%를 넘으면 조사 대상, 10%를 넘으면 조치 대상입니다.

cgroup v1이라면 파일이 다릅니다.

cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
# 100000
cat /sys/fs/cgroup/cpu/cpu.cfs_period_us
# 100000
cat /sys/fs/cgroup/cpu/cpu.stat
# nr_periods 86400
# nr_throttled 41287
# throttled_time 2914837261000

v1의 throttled_time은 나노초, v2의 throttled_usec은 마이크로초입니다. 1000배 차이이므로 대시보드를 옮길 때 자주 틀리는 지점입니다.

호스트에서 파드 단위로 훑으려면 이렇게 합니다.

for f in /sys/fs/cgroup/kubepods.slice/*/*/cpu.stat; do
  awk -v p="${f%/cpu.stat}" '
    /^nr_periods/  {np=$2}
    /^nr_throttled/{nt=$2}
    END {if (np > 0 && nt/np > 0.05) printf "%5.1f%%  %s\n", nt*100/np, p}
  ' "$f"
done | sort -rn | head -5
#  47.8%  /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod9a1c8f2e.slice
#  12.3%  /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod4d22b71a.slice

프로메테우스에서는 cAdvisor 지표를 씁니다.

sum by (pod) (rate(container_cpu_cfs_throttled_periods_total[5m]))
  / sum by (pod) (rate(container_cpu_cfs_periods_total[5m]))

container_cpu_cfs_throttled_seconds_total만 보는 대시보드를 자주 보는데, 그 값은 "얼마나 오래 멈췄나"이고 정작 필요한 것은 "몇 번 중 몇 번 멈췄나"입니다. 두 개를 함께 걸어야 합니다.

커널 버전도 확인할 값입니다. 5.4 이전 커널에는 CPU 슬라이스 만료 처리 버그가 있어, 쿼터를 다 쓰지 않았는데도 스로틀링이 발생했습니다. 5.4에서 이 만료 로직이 제거되어 증상이 크게 줄었습니다. 오래된 노드에서 설명되지 않는 스로틀링이 보인다면 커널부터 확인하십시오.

uname -r
# 6.8.0-45-generic

5.14부터는 cpu.max.burst가 추가되어, 쓰지 않은 쿼터를 일정 한도까지 적립했다가 순간적으로 초과 사용할 수 있습니다. 짧은 스파이크성 워크로드에 효과가 큽니다.

# 최대 50ms까지 적립 허용
echo 50000 | sudo tee /sys/fs/cgroup/kubepods.slice/.../cpu.max.burst

세 원인을 가르는 표와 쿠버네티스 CPU limit 논쟁

지금까지의 세 가지를 한 표로 정리합니다. 증상이 비슷해 보이지만 진단 지점과 대응이 전부 다릅니다.

원인실제 사건top의 stcpu.stat 변화로드 애버리지대응
하이퍼바이저 경합이웃 VM이 물리 CPU를 점유5% 이상변화 없음영향 적음중지 후 시작, 큰 인스턴스, 전용 호스트
크레딧 고갈내 버스트 예산 소진계단식 상승변화 없음영향 적음unlimited 전환, M/C 계열 이동
CFS 쿼터 스로틀링cgroup 상한으로 강제 정지0nr_throttled 증가오르지 않음limit 상향/제거, 병렬도 조정, burst

세 번째 행의 "로드 애버리지가 오르지 않음"이 특히 반직관적입니다. 스로틀링된 태스크는 런큐에서 제거되므로 대기열에 잡히지 않습니다. 로드도, CPU 사용률도, steal도 전부 조용한데 서비스만 느립니다. 이 조합이 나오면 cpu.stat을 보십시오.

이제 논쟁입니다. 쿠버네티스에서 CPU limit을 아예 제거하자는 주장이 있습니다. 양쪽 근거를 정확히 봐야 합니다.

제거를 지지하는 논거. CPU는 압축 가능한 자원입니다. 메모리와 달리 부족해도 죽지 않고 느려질 뿐입니다. 그리고 request가 이미 cpu.weight(v1의 cpu.shares)로 변환되어 경합 시 비례 배분을 보장합니다. 즉 노드가 바쁠 때는 request 비율대로 나눠 갖고, 노드가 한가할 때는 limit이 없으면 남는 CPU를 그냥 쓸 수 있습니다. limit이 있으면 노드에 CPU가 남아도는 상황에서도 강제로 멈춥니다. 이것은 순수한 낭비입니다.

유지를 지지하는 논거. limit이 없으면 한 파드의 폭주가 같은 노드의 이웃을 실제로 느리게 만듭니다. weight는 경합 시 최소 몫을 보장할 뿐 상한을 두지 않으므로, 스레드를 수백 개 만드는 파드는 컨텍스트 스위치와 캐시 오염으로 이웃의 실효 성능을 떨어뜨립니다. 또한 limit이 없으면 성능 특성이 노드의 여유에 따라 달라져 용량 계획과 부하 테스트 결과의 재현성이 무너집니다. 멀티테넌트 클러스터라면 격리는 협상 대상이 아닙니다. 그리고 CPU 매니저의 static 정책으로 전용 코어를 할당받으려면 Guaranteed QoS, 즉 limit이 request와 같아야 합니다.

균형점은 다음과 같이 정리할 수 있습니다.

  1. request는 항상 설정합니다. 이것은 논쟁 대상이 아닙니다. 스케줄링 배치와 경합 시 몫이 모두 여기서 나옵니다.
  2. limit을 제거할 후보는 지연에 민감하고 신뢰할 수 있는 자사 서비스입니다. 제거하되 노드 전체 CPU 사용률과 이웃 파드의 지연을 함께 관찰해야 합니다.
  3. limit을 유지할 대상은 배치 작업, 신뢰도가 낮은 워크로드, 멀티테넌트 네임스페이스입니다. 여기서는 예측 가능성이 효율보다 중요합니다.
  4. limit을 유지한다면 병렬도에 맞춰 잡습니다. limit 1코어에 스레드 8개는 구조적으로 스로틀링을 만듭니다.
  5. 런타임에 상한을 알려 줍니다. 이것이 단일 조치로는 효과가 가장 큽니다.

5번을 부연합니다. JVM이나 Go 런타임은 기본적으로 호스트의 전체 코어 수를 봅니다. 64코어 노드에서 CPU limit 1인 컨테이너 안의 Go 프로그램은 GOMAXPROCS를 64로 잡고, 64개 스레드가 1코어짜리 쿼터를 나눠 쓰다가 즉시 스로틀링에 걸립니다.

# Go: 자동 조정 라이브러리를 쓰거나 환경 변수로 직접
GOMAXPROCS=1 ./myapp

# JVM: 컨테이너 인식은 기본 활성이지만 확인이 필요합니다
java -XX:+PrintFlagsFinal -version | grep -E 'ActiveProcessorCount|UseContainerSupport'
#      intx ActiveProcessorCount   = -1
#      bool UseContainerSupport    = true

# 명시적으로 지정
java -XX:ActiveProcessorCount=2 -jar app.jar

JVM의 컨테이너 인식은 CPU limit을 코어 수로 환산할 때 올림을 씁니다. limit이 1.5코어면 2로 봅니다. 소수점 limit을 쓴다면 이 반올림이 스로틀링을 만드는지 확인해야 합니다.

노드 차원의 선택지도 두 가지 있습니다. kubelet의 --cpu-manager-policy=static은 정수 CPU를 요청한 Guaranteed 파드에 전용 코어를 배타 할당해 CFS 쿼터 자체를 우회합니다. --cpu-cfs-quota-period를 100ms에서 10ms 정도로 줄이면 한 번의 정지 시간이 짧아져 지연 스파이크의 진폭이 줄어듭니다. 다만 주기가 짧아지면 스케줄링 오버헤드가 늘고, 이 플래그는 아직 안정 기능이 아니므로 검증 후 적용해야 합니다.

재발 방지 — 대시보드에 무엇을 걸 것인가

세 원인이 각각 다른 지표로 잡히므로 셋 다 걸어야 합니다. 하나라도 빠지면 그 원인은 영원히 보이지 않습니다.

# 1) steal — 노드 단위, 5분 평균
avg by (instance) (rate(node_cpu_seconds_total{mode="steal"}[5m])) > 0.05

# 2) 스로틀링 비율 — 파드 단위
sum by (namespace, pod) (rate(container_cpu_cfs_throttled_periods_total[5m]))
  / sum by (namespace, pod) (rate(container_cpu_cfs_periods_total[5m])) > 0.10

# 3) CPU 압박 — PSI. 스로틀링과 경합을 모두 포착합니다
avg by (instance) (rate(node_pressure_cpu_waiting_seconds_total[5m])) > 0.20

PSI를 함께 거는 이유가 있습니다. cpu.pressure는 "실행할 수 있었는데 못 한 시간"을 직접 재기 때문에, steal이든 스로틀링이든 런큐 경합이든 원인과 무관하게 결과를 잡아냅니다. cgroup 단위로도 존재합니다.

cat /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod9a1c8f2e.slice/cpu.pressure
# some avg10=68.42 avg60=61.03 avg300=44.18 total=182937461283
# full avg10=52.11 avg60=47.88 avg300=33.02 total=91827364512

some이 68%라는 것은 최근 10초 중 6.8초 동안 이 파드의 어떤 태스크가 CPU를 기다렸다는 뜻입니다. 스로틀링 비율 47.8%와 함께 보면 같은 사실을 두 각도에서 확인하는 셈입니다.

정기 점검 스크립트로 남겨 두면 장애 때 기억을 더듬지 않아도 됩니다.

#!/usr/bin/env bash
# cpu-triage.sh — CPU 지연의 세 가지 원인을 한 번에 확인합니다
set -u

echo "== 1. steal time (5초 평균) =="
mpstat 5 1 | awk '/Average/ {printf "  %%steal = %s\n", $(NF-3)}'

echo "== 2. 가상화 환경 =="
printf '  '; systemd-detect-virt

echo "== 3. cgroup 스로틀링 (v2) =="
if [ -r /sys/fs/cgroup/cpu.stat ]; then
  awk '/^nr_periods/{np=$2} /^nr_throttled/{nt=$2} /^throttled_usec/{tu=$2}
       END {
         if (np > 0)
           printf "  비율 %.1f%%  (%d/%d), 발생 시 평균 %.1fms\n",
                  nt*100/np, nt, np, (nt>0 ? tu/nt/1000 : 0)
       }' /sys/fs/cgroup/cpu.stat
  printf '  cpu.max = %s\n' "$(cat /sys/fs/cgroup/cpu.max 2>/dev/null)"
fi

echo "== 4. CPU 압박 (PSI) =="
[ -r /proc/pressure/cpu ] && sed 's/^/  /' /proc/pressure/cpu

마치며 — 사용률이 낮다는 것이 CPU가 한가하다는 뜻은 아닙니다

하나만 기억한다면 이것입니다. CPU 사용률은 "얼마나 썼나"이지 "얼마나 쓸 수 있었나"가 아닙니다. 쓰고 싶었지만 못 쓴 시간은 steal, 크레딧 고갈, 스로틀링이라는 세 가지 형태로 나타나고, 그중 스로틀링은 사용률 그래프 어디에도 흔적을 남기지 않습니다.

판단 순서를 압축하면 이렇습니다.

  1. mpstat -P ALL 1%steal을 먼저 봅니다. 5분 평균 5%를 넘으면 인스턴스 문제이고 게스트 안에서 할 수 있는 일이 없습니다.
  2. steal이 특정 시각부터 계단식으로 올라 고정됐다면 크레딧 잔고를 확인합니다. 이웃이 아니라 예산 문제입니다.
  3. steal이 0인데 지연이 튀면 cpu.statnr_throttled를 봅니다. 사용률과 로드 애버리지는 이 문제를 보여 주지 못합니다.
  4. 스로틀링이 확인되면 limit을 올리기 전에 런타임의 병렬도부터 맞춥니다. GOMAXPROCSActiveProcessorCount 조정이 대개 더 큰 효과를 냅니다.
  5. CPU limit 제거는 만능 처방이 아닙니다. request를 정확히 설정하는 것이 먼저이고, 제거는 신뢰할 수 있는 워크로드에 한정해 관측과 함께 진행합니다.
  6. 알람은 steal, 스로틀링 비율, PSI 세 개를 모두 겁니다. 세 원인이 서로를 가려 주지 않습니다.

CPU steal time and throttling — telling apart the st column in top, burstable credits, and CFS quota

Introduction — CPU is at 40% but p99 is 3 seconds

There is one sentence that comes up in capacity meetings more than any other. "CPU utilization is only 40%, so this is not a CPU problem." That sentence is wrong in three situations. And those three situations are what this whole article is about.

top -bn1 | head -4
# top - 14:22:31 up 9 days,  4:12,  1 user,  load average: 3.21, 3.04, 2.88
# Tasks: 214 total,   2 running, 212 sleeping,   0 stopped,   0 zombie
# %Cpu(s): 31.2 us,  4.1 sy,  0.0 ni, 42.6 id,  0.4 wa,  0.0 hi,  0.5 si, 21.2 st

The 21.2 st in the last column is where this article starts. Idle looks like 42.6%, but 21.2% is time that has already been taken away by someone else, and the CPU the application could have used shrank by exactly that much. And there is a third cause that does not appear here at all. Container throttling shows up nowhere on this screen.

What steal time means — the time the hypervisor did not give you

st is the share of time a vCPU was ready to run but waited because the hypervisor did not assign it a physical CPU. It is not something that happened inside the guest kernel but something that happened outside the guest, and the guest is merely notified after the fact.

The mechanism is a paravirtualized interface. On KVM the guest kernel registers a shared memory structure through MSR_KVM_STEAL_TIME, and the hypervisor writes into it the accumulated nanoseconds for which it scheduled the vCPU out. The guest kernel reads that value and reflects it in the 8th field of /proc/stat. Xen does the same thing with its runstate information.

head -2 /proc/stat
# cpu  9284712 1832 1204831 88213904 42193 0 18922 1928374 0 0
# cpu0 1160589  229  150604 11026738  5274 0  2365  241046 0 0
#      user    nice system idle    iowait irq softirq steal guest guest_nice

The eighth number is the accumulated steal ticks. top and mpstat only show you the delta of this value.

mpstat -P ALL 1 3 | tail -5
# 14:23:03  CPU   %usr  %nice  %sys %iowait  %irq %soft %steal %guest %gnice  %idle
# 14:23:03  all  30.51   0.00  4.02    0.38  0.00  0.51  21.34   0.00   0.00  43.24
# 14:23:03    0  29.90   0.00  3.96    0.50  0.00  0.99  22.28   0.00   0.00  42.37
# 14:23:03    1  31.14   0.00  4.08    0.26  0.00  0.03  20.40   0.00   0.00  44.09

If every CPU sits evenly near 21%, the whole host is oversubscribed; if it is concentrated on one particular vCPU, there is a competitor on the physical core that vCPU landed on. The latter is often resolved just by restarting the instance so it moves to a different host.

Here is the most common misunderstanding. A high st does not mean your process is using a lot of CPU. It is the opposite. It is time you wanted to use but could not, and the cause sits outside the instance. No amount of optimizing application code brings this number down.

The second misunderstanding matters just as much. An st of 0 does not mean there is no hypervisor contention. Steal accounting only works when the hypervisor exposes that information to the guest. Guests on VMware generally show st as 0, and the real contention is visible only in the host-side %RDY (CPU ready) metric. Concluding that "st is 0, so nothing is wrong" is the classic misdiagnosis in on-premises virtualization.

# Check whether the paravirtualized clock and steal accounting are active
grep -o 'kvm\|xen\|vmware\|hyperv' /sys/hypervisor/type 2>/dev/null
systemd-detect-virt
# kvm

grep -E 'CONFIG_PARAVIRT_TIME_ACCOUNTING' /boot/config-$(uname -r)
# CONFIG_PARAVIRT_TIME_ACCOUNTING=y

At what percentage does st become a problem, and what can you do

There is no absolute threshold, but the operational rules of thumb are fairly consistent.

  • Under 1%: normal. On shared infrastructure a flat 0 is actually the rare case.
  • 1~5%: worth watching. For a latency-sensitive service, p99 may already be affected.
  • 5~10% sustained: worth acting on. Throughput really is cut by that much.
  • Over 10% sustained: move immediately. No tuning works on that host.

What matters is not the instantaneous value but the persistence. Spiking for a few minutes because of a backup window or a neighbor batch job is common. Alerts should fire on 5-minute or 15-minute averages to cut down false positives.

There are four responses.

# 1) Relocate the instance — stop then start and it lands on a different physical host
#    (a reboot stays on the same host, so it has no effect)
aws ec2 stop-instances --instance-ids i-0abc123def4567890
aws ec2 start-instances --instance-ids i-0abc123def4567890

The difference between a reboot and a stop-then-start is the key point. A reboot from inside the guest does not change the physical host. Only a stop and a start through the cloud API makes placement happen again.

The other three are structural. Moving up to an instance size that occupies an entire physical core makes the neighbors disappear altogether. A dedicated host or a bare metal instance removes hypervisor sharing entirely, at a much higher cost. And separating latency-sensitive workloads onto instance families with low st is another practical option.

One trap. Adding vCPUs when st is high often backfires. If the host is already oversubscribed, the extra vCPUs simply have to wait for more scheduling opportunities, and inside the guest even spinlock contention increases. Raising the instance size, meaning the share of the host you occupy, is the right move rather than the core count.

Credit exhaustion on burstable instances — same symptom, different cause

This is the second cause. The AWS T family, and the equivalent tiers on other clouds, run on two concepts: baseline performance and CPU credits. Use less than the baseline and credits accumulate; use more than the baseline and credits are consumed.

InstancevCPUBaseline performance per vCPUCredits earned per hour
t3.nano25%6
t3.micro210%12
t3.small220%24
t3.medium220%24
t3.large230%36
t3.xlarge440%96
t3.2xlarge840%192

One credit is enough to run a single vCPU at 100% performance for one minute. A t3.medium earns 24 per hour and has 2 vCPUs, so the sustainable usage is 2 times 20%, which comes out to 40% of a single vCPU. Keep using more than that and the balance is certain to reach 0.

The symptom is distinctive. Everything is fine for the first few hours, and then from some moment on, performance drops in a step and never recovers. The load is unchanged, yet response times multiply. In standard mode, once credits run out the hypervisor clamps you to baseline performance, and the guest perceives that as steal time. So st goes up, but the cause is not a neighbor, it is your own budget.

Telling them apart is simple. If steal rises and falls at random over time it is a neighbor; if steal climbs like a staircase and stays there from the moment the credit balance touches 0, it is credits.

aws cloudwatch get-metric-statistics \
  --namespace AWS/EC2 --metric-name CPUCreditBalance \
  --dimensions Name=InstanceId,Value=i-0abc123def4567890 \
  --start-time 2026-07-25T00:00:00Z --end-time 2026-07-26T00:00:00Z \
  --period 300 --statistics Average \
  --query 'sort_by(Datapoints, &Timestamp)[].[Timestamp,Average]' --output text | tail -6
# 2026-07-25T09:00:00Z	142.3
# 2026-07-25T10:00:00Z	88.1
# 2026-07-25T11:00:00Z	31.4
# 2026-07-25T12:00:00Z	0.0
# 2026-07-25T13:00:00Z	0.0
# 2026-07-25T14:00:00Z	0.0

It is 0 from 12:00 onward. If the latency graph turned bad from exactly that moment, the diagnosis is finished.

There are three options. Switch to unlimited mode and the instance keeps bursting even without credits, with the excess billed separately (the CPUSurplusCreditsCharged metric shows the actual cost). If you are continuously running above the baseline, moving to a fixed-performance instance in the M or C family ends up cheaper. If the bursts really are intermittent, keep the T family but put the credit balance on an alert.

# Switch to unlimited
aws ec2 modify-instance-credit-specification \
  --instance-credit-specifications \
  'InstanceId=i-0abc123def4567890,CpuCredits=unlimited'

The cost criterion is this. If overage charges in unlimited mode happen all the time, compare that amount against the price difference to the next fixed-performance instance up. In a permanently bursting state the latter is usually cheaper, and its performance is predictable.

Container CFS quota throttling — the third cause that top never shows

This is the most important section. The first two causes at least leave a trace called st. Throttling leaves no trace at all.

Here is how it works. When a cgroup has a CPU ceiling, the kernel hands out a quota every period, 100ms by default. If the tasks in that group use up the quota within the period, the kernel pulls the entire group off the run queue for the remaining time. Nothing runs until the next period begins.

Let us look at what happens in that state. A pod with a CPU limit of 1 core has 8 threads. If all 8 run at once, they burn through the 100ms quota in 12.5ms. For the remaining 87.5ms this pod is completely stopped. If a request happens to land in that window, 87.5ms is added to its response. And the average CPU utilization does not even look like 100%, meaning 1 core out of 1. It looks like something near 12.5%.

That is why you get a graph like this. Average CPU utilization is low, steal is 0, the load average is quiet, and only p99 spikes at regular intervals. A throttled task is taken off the run queue, so it is not caught by the load average either.

Checking takes a single file, cpu.stat.

# cgroup v2 (from inside the container; if the cgroup namespace is private you see it as is)
cat /sys/fs/cgroup/cpu.max
# 100000 100000

cat /sys/fs/cgroup/cpu.stat
# usage_usec 4128374621
# user_usec 3719283746
# system_usec 409090875
# nr_periods 86400
# nr_throttled 41287
# throttled_usec 2914837261

The two numbers in cpu.max are the quota and the period, in microseconds. 100000 100000 means a 100ms quota on a 100ms period, that is, 1 core. With no limit the first value is max.

There is one ratio you need to read.

throttling ratio = nr_throttled / nr_periods = 41287 / 86400 = 47.8%
average stall time per period = throttled_usec / nr_throttled = 2914837261 / 41287 = 70.6ms

Throttling occurred in half of all periods, and each time it occurred the group stopped for 70ms on average. That is where the p99 latency comes from. As a practical rule, above a 5% ratio it is worth investigating, and above 10% it is worth acting on.

On cgroup v1 the files are different.

cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
# 100000
cat /sys/fs/cgroup/cpu/cpu.cfs_period_us
# 100000
cat /sys/fs/cgroup/cpu/cpu.stat
# nr_periods 86400
# nr_throttled 41287
# throttled_time 2914837261000

throttled_time on v1 is in nanoseconds and throttled_usec on v2 is in microseconds. That is a factor of 1000, and it is the spot people get wrong most often when porting a dashboard.

To sweep per pod from the host, do this.

for f in /sys/fs/cgroup/kubepods.slice/*/*/cpu.stat; do
  awk -v p="${f%/cpu.stat}" '
    /^nr_periods/  {np=$2}
    /^nr_throttled/{nt=$2}
    END {if (np > 0 && nt/np > 0.05) printf "%5.1f%%  %s\n", nt*100/np, p}
  ' "$f"
done | sort -rn | head -5
#  47.8%  /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod9a1c8f2e.slice
#  12.3%  /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod4d22b71a.slice

In Prometheus you use the cAdvisor metrics.

sum by (pod) (rate(container_cpu_cfs_throttled_periods_total[5m]))
  / sum by (pod) (rate(container_cpu_cfs_periods_total[5m]))

I often see dashboards that watch only container_cpu_cfs_throttled_seconds_total, but that value answers "how long was it stopped" when what you actually need is "how many periods out of how many were stopped". You have to chart both.

The kernel version is another value to check. Kernels before 5.4 had a bug in CPU slice expiration handling that caused throttling even when the quota had not been used up. That expiration logic was removed in 5.4 and the symptom dropped sharply. If you see unexplained throttling on an old node, check the kernel first.

uname -r
# 6.8.0-45-generic

From 5.14 onward, cpu.max.burst was added, so unused quota can be banked up to a limit and then spent above the ceiling for a moment. It is very effective for short, spiky workloads.

# Allow banking up to 50ms
echo 50000 | sudo tee /sys/fs/cgroup/kubepods.slice/.../cpu.max.burst

A table that separates the three causes, and the Kubernetes CPU limit debate

Here are the three causes so far in a single table. The symptoms look similar, but the diagnostic point and the response are entirely different.

CauseWhat actually happensst in topcpu.stat changeLoad averageResponse
Hypervisor contentionA neighbor VM occupies the physical CPU5% or moreNo changeLittle effectStop then start, larger instance, dedicated host
Credit exhaustionYour own burst budget runs outStaircase riseNo changeLittle effectSwitch to unlimited, move to the M/C family
CFS quota throttlingForced stop at the cgroup ceiling0nr_throttled risesDoes not riseRaise or remove the limit, tune parallelism, burst

The "load average does not rise" in the third row is especially counterintuitive. A throttled task is removed from the run queue, so it is never caught in the queue. Load, CPU utilization, and steal are all quiet, and only the service is slow. When you see that combination, look at cpu.stat.

Now for the debate. There is an argument for removing CPU limits in Kubernetes altogether. You need to look at both sides precisely.

The argument for removal. CPU is a compressible resource. Unlike memory, a shortage does not kill anything, it only makes things slower. And request is already translated into cpu.weight (cpu.shares on v1), which guarantees a proportional share under contention. In other words, when the node is busy you split it by the request ratio, and when the node is idle, with no limit you can simply use the CPU that is left over. With a limit you are forcibly stopped even when the node has CPU to spare. That is pure waste.

The argument for keeping them. Without a limit, one runaway pod really does slow down its neighbors on the same node. Weight guarantees only a minimum share under contention and sets no ceiling, so a pod that creates hundreds of threads degrades the effective performance of its neighbors through context switches and cache pollution. Also, without a limit the performance characteristics shift with how much room the node has, which destroys the reproducibility of capacity planning and load test results. On a multi-tenant cluster, isolation is not negotiable. And to be given dedicated cores by the static policy of the CPU manager you need Guaranteed QoS, meaning limit has to equal request.

The balance point can be summarized as follows.

  1. Always set request. This is not up for debate. Both scheduling placement and the share under contention come from here.
  2. The candidates for removing a limit are latency-sensitive, trustworthy first-party services. Remove it, but you have to watch node-wide CPU utilization and neighbor pod latency at the same time.
  3. The ones to keep a limit on are batch jobs, low-trust workloads, and multi-tenant namespaces. Here predictability matters more than efficiency.
  4. If you keep a limit, size it to the parallelism. A limit of 1 core with 8 threads structurally produces throttling.
  5. Tell the runtime about the ceiling. As a single action this has the biggest effect of all.

Let me expand on number 5. The JVM and the Go runtime look at the total core count of the host by default. Inside a container with a CPU limit of 1 on a 64-core node, a Go program sets GOMAXPROCS to 64, and 64 threads share a quota worth 1 core and hit throttling immediately.

# Go: use an automatic tuning library, or set the environment variable directly
GOMAXPROCS=1 ./myapp

# JVM: container awareness is on by default, but it needs verifying
java -XX:+PrintFlagsFinal -version | grep -E 'ActiveProcessorCount|UseContainerSupport'
#      intx ActiveProcessorCount   = -1
#      bool UseContainerSupport    = true

# Set it explicitly
java -XX:ActiveProcessorCount=2 -jar app.jar

The container awareness of the JVM rounds up when converting a CPU limit into a core count. A limit of 1.5 cores is seen as 2. If you use fractional limits, check whether this rounding is what creates the throttling.

There are two node-level options as well. The kubelet flag --cpu-manager-policy=static assigns dedicated cores exclusively to Guaranteed pods that requested whole CPUs, bypassing the CFS quota entirely. Reducing --cpu-cfs-quota-period from 100ms to around 10ms shortens each individual stall, which lowers the amplitude of latency spikes. That said, a shorter period increases scheduling overhead, and this flag is not a stable feature yet, so verify it before applying it.

Preventing recurrence — what to put on the dashboard

Each of the three causes is caught by a different metric, so you have to chart all three. Leave out even one and that cause stays invisible forever.

# 1) steal — per node, 5-minute average
avg by (instance) (rate(node_cpu_seconds_total{mode="steal"}[5m])) > 0.05

# 2) throttling ratio — per pod
sum by (namespace, pod) (rate(container_cpu_cfs_throttled_periods_total[5m]))
  / sum by (namespace, pod) (rate(container_cpu_cfs_periods_total[5m])) > 0.10

# 3) CPU pressure — PSI. Catches both throttling and contention
avg by (instance) (rate(node_pressure_cpu_waiting_seconds_total[5m])) > 0.20

There is a reason to chart PSI alongside them. cpu.pressure directly measures "time that could have been spent running but was not", so it catches the outcome regardless of whether the cause is steal, throttling, or run queue contention. It exists per cgroup too.

cat /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod9a1c8f2e.slice/cpu.pressure
# some avg10=68.42 avg60=61.03 avg300=44.18 total=182937461283
# full avg10=52.11 avg60=47.88 avg300=33.02 total=91827364512

some at 68% means that out of the last 10 seconds, some task in this pod waited for CPU for 6.8 of them. Read alongside the 47.8% throttling ratio, you are confirming the same fact from two angles.

Leaving it behind as a routine check script means you do not have to dig through your memory during an incident.

#!/usr/bin/env bash
# cpu-triage.sh — check the three causes of CPU latency in one pass
set -u

echo "== 1. steal time (5-second average) =="
mpstat 5 1 | awk '/Average/ {printf "  %%steal = %s\n", $(NF-3)}'

echo "== 2. virtualization environment =="
printf '  '; systemd-detect-virt

echo "== 3. cgroup throttling (v2) =="
if [ -r /sys/fs/cgroup/cpu.stat ]; then
  awk '/^nr_periods/{np=$2} /^nr_throttled/{nt=$2} /^throttled_usec/{tu=$2}
       END {
         if (np > 0)
           printf "  ratio %.1f%%  (%d/%d), average %.1fms when it happens\n",
                  nt*100/np, nt, np, (nt>0 ? tu/nt/1000 : 0)
       }' /sys/fs/cgroup/cpu.stat
  printf '  cpu.max = %s\n' "$(cat /sys/fs/cgroup/cpu.max 2>/dev/null)"
fi

echo "== 4. CPU pressure (PSI) =="
[ -r /proc/pressure/cpu ] && sed 's/^/  /' /proc/pressure/cpu

Wrapping up — low utilization does not mean the CPU is idle

If you remember one thing, remember this. CPU utilization tells you how much was used, not how much could have been used. The time you wanted to use but could not shows up in three forms, steal, credit exhaustion, and throttling, and of those, throttling leaves no trace anywhere on the utilization graph.

Compressed into a decision order, it goes like this.

  1. Look at %steal first with mpstat -P ALL 1. Above a 5-minute average of 5% it is an instance problem, and there is nothing you can do from inside the guest.
  2. If steal climbed like a staircase from a particular moment and stayed there, check the credit balance. It is a budget problem, not a neighbor.
  3. If steal is 0 but latency spikes, look at nr_throttled in cpu.stat. Utilization and load average cannot show you this problem.
  4. Once throttling is confirmed, match the parallelism of the runtime before raising the limit. Adjusting GOMAXPROCS or ActiveProcessorCount usually has a bigger effect.
  5. Removing CPU limits is not a universal cure. Setting request accurately comes first, and removal should be confined to trustworthy workloads and carried out together with observation.
  6. Alert on all three, steal, throttling ratio, and PSI. The three causes do not cover for one another.