본문으로 건너뛰기
CPU Throttling

02 · CPU Throttling — limit을 다 쓰지도 않았는데 잘린다

  • CFS는 CPU limit을 100ms period로 쪼개서 줍니다. period는 서로 독립이고 안 쓴 quota는 이월되지 않습니다. 그래서 “평균 사용률 34% + throttle 31%” 가 모순 없이 성립합니다 — 총량이 남는데 타이밍이 잘립니다.
  • 사용률 그래프에는 이 상태가 안 보입니다. 대시보드는 내내 여유로운데 꼬리 지연만 길어집니다. container_cpu_cfs_throttled_periods_total / …periods_total을 같이 봐야 처음 보입니다.
  • 잘린 시간은 CPU wait으로 스레드에 그대로 쌓이고 APM에는 “아무것도 안 하는 구간"으로 찍힙니다. 컨테이너 전체가 한꺼번에 멈추므로 꼬리(P99)가 평균보다 훨씬 크게 망가집니다.
  • 코어가 많을수록 더 잘립니다. quota는 병렬도에 비례해 마르기 때문입니다 — 같은 1코어 limit이라도 4코어에서 도는 컨테이너는 period의 ¾을 멈춰 있습니다. 직관과 반대입니다.
  • 대응은 셋 중 하나입니다: limit 제거(가장 효과적이지만 이웃을 노출), CPU Manager static(throttle이 아예 생기지 않지만 조건이 빡빡), CPU Burst(타이밍만 푸는 정답에 가깝지만 k8s 표면이 없습니다).

“CPU를 더 주면 빨라진다"는 직관은 limit이 걸린 컨테이너에서 자주 틀립니다. 총량은 남았는데 쓸 수 있는 타이밍만 잘린 상태가 있습니다. 이 상태는 CPU 사용률 그래프에 나타나지 않아서 몇 시간씩 엉뚱한 곳을 파게 만듭니다. 그래서 이 문서를 따로 두었습니다. 그 상태가 어떻게 만들어지는지, 어떤 지표를 겹쳐 봐야 보이는지, 대응 선택지가 각각 무엇을 대가로 치르는지를 정리합니다.

자매 문서: 챕터 개요 · 이 문제를 커널에서 푸는 03 CPU Burst · limit을 무중단으로 바꾸는 01 In-Place Pod Resize · 실제로 밟은 사례는 istio 09

1. limit은 총량이 아니라 100ms짜리 배급이다

limits.cpu는 “이만큼의 CPU를 쓸 수 있다"가 아니라 “100ms마다 이만큼씩 받는다” 입니다. kubelet이 하는 계산은 한 줄입니다.

quota(µs) = limits.cpu(milli) × period(µs) ÷ 1000     # period 기본 100000µs = 100ms

limits.cpu: 600m → quota 60ms/100ms. 2 → 200ms/100ms. 이 값이 그대로 cpu.cfs_quota_us(cgroup v1) 또는 cpu.max(v2)에 쓰입니다. 이 계산에서 딸려 나오는 제약이 있습니다. quota 하한은 1ms10m 미만은 전부 1ms로 뭉칩니다. period는 kubelet 플래그(--cpu-cfs-quota-period)로 노드 단위로만 바꿀 수 있습니다.

동작은 단순합니다. period가 시작하면 quota가 리필되고 소진하면 period가 끝날 때까지 강제로 재웁니다(throttle). 다음 period에 다시 리필합니다.

period는 서로 완전히 독립입니다. 지난 period에 quota를 하나도 안 썼어도 이월되지 않고 사라집니다. 이 한 줄에서 이 문서의 모든 증상이 나옵니다.

quota를 다 쓴 순간부터 다음 구간이 시작될 때까지 애플리케이션 전체가 멈춘다. 평균 사용률은 0.1코어로 limit(0.2코어) 아래인데도 요청은 80ms 더 걸린다.

CPU 30ms가 연속으로 필요한 요청 하나를 보겠습니다. limit이 없으면 30ms에 끝납니다. 0.2 코어면 quota가 20ms라 20ms를 쓰고 80ms를 그냥 멈춰 있다가 다음 period에 남은 10ms를 씁니다 — 같은 일이 110ms가 됩니다.

이 컨테이너의 평균 CPU 사용량은 0.1 코어, limit(0.2)의 절반입니다. 자원은 충분합니다. 나눠주는 방식이 문제입니다. 커뮤니티에서 오래 굴러온 문제이기도 합니다(#67577, #51135).

2. 그래서 “여유로운데 잘린다"가 성립한다

실제로 관측된 숫자가 이 구조를 그대로 드러냅니다. istiod에서 잰 값은 파드별 CPU 최대 0.207코어, limit 600m의 34%, 그런데 스로틀 분율 31% 였습니다.

평균과 스로틀을 연립해서 풀면 무슨 일이 있었는지 나옵니다. period당 평균 20.7ms인데 24%의 period가 quota(60ms)를 꽉 채워 잘렸다면:

0.243 × 60ms + 0.757 × y = 20.7ms   ⇒   y ≈ 8.1ms

24%의 period는 60ms를 다 태우고 잘립니다. 나머지 76%는 8ms만 씁니다. “평균 207m"은 이 두 상태를 섞어 뭉갠 값일 뿐입니다. 1분 평균 그래프가 여유로워 보인 건 그래서입니다.

CPU 사용률만으로 limit을 사이징하면 안 됩니다. 사용률은 period 안에서 언제 썼는지를 지우고 평균만 남깁니다. throttled_periods / periods 분율을 같이 보지 않으면 이 상태는 관측 자체가 안 됩니다 — 그래프에 안 나타나는 것과 문제가 없는 것은 다릅니다.

3. 잘린 시간은 어디로 가나 — CPU wait과 APM 지연

throttle은 “느려짐"이 아니라 정지입니다. quota가 마르는 순간 그 컨테이너에서 돌던 스레드가 전부 같이 멈춥니다. 하나만 느려지고 끝나지 않습니다. 컨테이너 전체가 다음 period 경계까지 서 있습니다.

멈춘 스레드는 실행 큐에서 대기 상태가 됩니다. 이 시간은 애플리케이션 쪽에서 CPU를 기다린 시간(CPU wait / run-queue 대기) 으로 잡힙니다. 그래서 이렇게 번집니다.

quota 소진이 사용자 경험까지 번지는 경로 — 커널이 재운 시간은 애플리케이션에서 '아무것도 안 한 구간'으로 보이고, 그 구간은 평균이 아니라 꼬리에 쌓입니다
도식 텍스트
  • quota 소진 — period 잔여시간 동안 정지
  • 스레드 동시 정지 — 컨테이너 전체가 함께 선다
  • CPU wait 누적 — 실행 큐에서 대기
  • APM 스팬이 늘어남 — 일한 흔적 없이 시간만 흐름
  • P99 악화 — 평균보다 꼬리가 크게 망가진다
  • 요청마다 확률적으로

APM에서 이게 특징적인 모양으로 나타납니다. 느린 트레이스를 열어보면 DB도 외부 호출도 느리지 않은데 스팬과 스팬 사이가 비어 있습니다. 어디에도 귀속되지 않는 시간이라 “애플리케이션이 느리다"로 보이지, 커널이 재웠다고는 안 보입니다. GC pause를 의심해 GC 로그를 뒤지다가 아무것도 못 찾는 전형적인 경로가 여기입니다.

왜 평균보다 꼬리가 크게 망가질까요? throttle은 매 요청에 고르게 걸리지 않습니다. period 후반에 도착한 요청만 걸리고 앞쪽은 멀쩡합니다. 그래서 대부분의 요청은 정상이고 일부만 최대 한 period(기본 100ms)만큼 통째로 밀립니다 — 평균은 조금 나빠지고 P99는 크게 나빠지는 비대칭이 나옵니다. 03 CPU Burst §6의 실측이 이 비대칭을 보여줍니다: 같은 개선에서 RT 평균은 약 1/3, P99는 약 1/20로 줄었습니다.

4. 다중코어에서는 더 빨리 마른다

여기가 가장 직관과 어긋납니다. quota는 wall time이 아니라 CPU-time으로 소진됩니다. “동시에 몇 개 코어에서 도느냐"에 비례해서 마릅니다.

limit이 1코어(quota 100ms/period)일 때, 1코어에서만 돌면 100ms를 꽉 채우고 period가 끝나 안 잘립니다. 그런데 2코어에서 동시에 돌면 50ms 만에, 4코어면 25ms 만에 그 100ms를 태워버립니다.

limit이 1코어(quota 100ms/period)여도, 코어가 많아 동시 실행이 늘면 그 100ms를 더 빨리 태운다. 1코어는 꽉 채워 안 잘리지만 2코어는 절반·4코어는 ¾ 이 멈춤 — throttle은 CPU 총량이 아니라 병렬도(코어 수)에서 터진다.

그 “동시 개수"는 노드의 코어 수가 받쳐줘야 나옵니다. 그래서 같은 limit이라도 코어가 많은 노드에 스케줄될수록 더 잘 잘립니다. 직관과는 반대 방향입니다. 64코어 노드로 옮겨서 더 느려지는 일이 실제로 일어납니다.

이게 런타임의 병렬도 설정과 맞물리면 증상이 커집니다. 대부분의 런타임이 노드의 코어 수를 보고 스레드풀·GC 워커·GOMAXPROCS를 잡기 때문입니다.

런타임무엇을 보나함정
Go 1.25+cgroup limit(없으면 노드 코어)limit 없으면 64코어 노드에서 P=64개. GOMAXPROCS 있으면 자동감지 꺼짐
Go 1.24 이하노드 코어 수uber-go/automaxprocs 필요. 이쪽은 내림(최소 1)
JVMcgroup 인지(UseContainerSupport, 기본 on)힙은 시작 시점 고정. GC 스레드 수가 많으면 대표적인 bursty 패턴
Node.jslibuv 스레드풀은 고정 4V8 old space는 시작 시 고정
Python노드 코어 수 (os.cpu_count())cgroup 자동 감지 없음 — 워커 공식 2n+1이 노드 기준으로 폭증. GIL 덕에 프로세스 하나는 ~1코어가 천장이지만, 그래서 워커·네이티브 스레드풀이 범인이 된다 → 06

소수점 limit은 이 배선에서 항상 어긋납니다. kubelet의 Downward API는 limits.cpu올림해서 넘기는데(ceil(0.6) = 1) 커널 quota는 올림 없이 정확히 60ms입니다. 600m·1500m·2500m 모두 같은 문제입니다. 정수 코어만 둘이 맞습니다. 자세한 배선은 01 §5 ④에 있습니다.

5. 어떻게 같이 봐야 하나

지표는 겹쳐 읽는다

CPU 사용률은 단독으로 읽으면 안 되는 지표입니다. 같은 그래프도 응답 시간이 어떻게 움직이느냐에 따라 전혀 다른 상황이 됩니다.

CPU응답 시간해석다음에 볼 것
높음높음진짜 CPU 부족 또는 무한 루프트래픽 그래프 — 용량 초과(증설)인지 코드 문제인지 갈린다
낮음높음뭔가를 기다리는DB·락·커넥션 풀·외부 API. CPU 그래프를 붙잡고 있어봐야 답이 없다
높음낮음건강하게 일하는 중헤드룸만 관리
낮음높음 + 스로틀 분율 높음이 문서의 상태quota·period·병렬도. 아래 쿼리

세 번째 줄과 네 번째 줄이 사용률 그래프에서는 구분되지 않습니다. 스로틀 분율을 붙여야 둘이 나뉩니다.

# 스로틀 분율 — 이 값이 0이 아니면 사용률이 낮아도 잘리고 있다
rate(container_cpu_cfs_throttled_periods_total{container!=""}[5m])
  / rate(container_cpu_cfs_periods_total{container!=""}[5m])

# 잘린 시간의 절대량 — "얼마나 오래 멈춰 있었나"
rate(container_cpu_cfs_throttled_seconds_total{container!=""}[5m])

# 명목 limit 대비 실제 사용률 — 위와 겹쳐 보면 "여유로운데 잘린다"가 드러난다
rate(container_cpu_usage_seconds_total{container!=""}[5m])
  / on(pod, container) kube_pod_container_resource_limits{resource="cpu"}

노드에 직접 들어갈 수 있으면 cgroup 파일이 가장 정확합니다.

cat /sys/fs/cgroup/cpu.stat        # cgroup v2 (v1은 .../cpu/<경로>/cpu.stat)
# nr_periods / nr_throttled / throttled_usec  ← 이 셋

해석할 때 조심할 것

  • rate 윈도우가 버스트를 뭉갭니다. rate(...[5m])은 5분 평균이라 초 단위 스파이크를 눌러버립니다. 스로틀은 period(100ms) 현상이므로 짧은 윈도우와 max_over_time을 같이 봅니다.
  • 갓 뜬 파드를 섞지 말 것. 기동 직후에는 클래스 로딩·JIT·커넥션 수립으로 CPU가 튀어 스로틀이 정상적으로 걸립니다. 이 표본이 섞이면 정상 상태의 limit이 실제보다 과하게 조여 보입니다. 파드 나이 필터를 걸고 봅니다.
  • 분율보다 절대량이 판단에 가깝습니다. nr_throttled/nr_periods가 5%여도 매번 100ms씩 잘린다면 P99에는 치명적입니다. 30%여도 1ms씩이면 체감이 없을 수 있습니다.

6. 대응 — 무엇을 대가로 치르나

먼저 효과가 절반뿐인 대응부터 봅니다. 둘 다 “얼마나 주느냐"를 건드리는데 문제는 타이밍입니다.

방법되는 것대가
limit을 올린다실제로 효과 있음requests 연동 여부에 따라 파드 수 감소 또는 오버커밋
period를 늘린다throttle 횟수가 준다한 번에 더 오래 잘린다 — 지연 민감이면 오히려 악화

limit을 올리면 requests = limits인 경우 requests도 같이 올라 노드당 파드 수가 줍니다. requests를 그대로 두고 limit만 올리면 오버커밋이 됩니다. period를 늘리면 throttle 횟수는 주는데(20ms/100ms → 40ms/200ms) 대신 한 번에 더 오래 멈춥니다 — 80ms 멈추던 게 160ms로 늘어납니다. 커널의 period 상한은 1000ms입니다.

BP ① limit을 제거한다

가장 확실하게 듣습니다. #67577의 보고가 유명한데 — ingress controller에서 limit을 1500m까지 올려도 별로 나아지지 않았는데 아예 없애자 p99·p999가 60ms·100ms에서 ~5ms로 떨어졌습니다. 평균 CPU는 30m 남짓이었습니다. limit을 올리는 게 아니라 없애야 풀렸다는 대목에 이 문제의 성격이 그대로 드러납니다.

quota가 없으면 CFS는 cpu.shares(requests에서 파생)로만 조정합니다. 경합이 없으면 남는 CPU를 자유롭게 쓰고, 경합이 생기면 requests 비율대로 나눕니다. 평상시 버스트를 흡수하면서 혼잡할 때는 공정하게 나누는 구조입니다.

그래서 무엇을 잃을까요? 이웃에 걸려 있던 상한이 사라집니다. 폭주하는 파드 하나가 노드의 여유 CPU를 전부 가져갈 수 있습니다. 이건 CPU 총량 문제로 보이지 않고 다른 파드의 지연으로 나타납니다. 무엇보다 requests를 제대로 잡아뒀을 때만 성립하는 전략입니다 — requests가 실제 수요보다 작으면 경합 시 보장받는 몫도 그만큼 작습니다.

resources:
  requests:            # 여기가 진짜 보장선이다. 실측 기반으로 정확히
    cpu: "500m"
    memory: "512Mi"
  limits:
    memory: "512Mi"    # 메모리 limit은 남긴다 — OOM은 이웃이 아니라 노드를 지키는 문제
    # cpu limit 없음

적용 기준은 이렇습니다:

이럴 때 뺀다이럴 때 유지한다
지연에 민감한 온라인 서비스(API·게이트웨이·프록시)멀티테넌트 — 신뢰 경계가 다른 워크로드가 섞인 노드
requests를 실측으로 잡아둔 워크로드과금·용량 계획이 limit에 묶여 있는 환경
스로틀 분율이 실제로 높은 파드폭주 이력이 있거나 검증 안 된 배치·서드파티
bursty한 패턴(웹, GC 많은 JVM)CPU를 꾸준히 꽉 채우는 배치 — 애초에 뺄 이유가 적다
Go 워크로드에서 limit을 뺄 때는 GOMAXPROCS를 같이 챙깁니다. Go 1.25+의 컨테이너 인식은 limit을 보지 requests를 보지 않습니다. limit을 빼면 노드 전체 코어 수가 GOMAXPROCS가 되어 64코어 노드에서 P를 64개 잡습니다. limit을 뺐다면 GOMAXPROCS를 requests 기준으로 명시하는 편이 안전합니다.

노드 전체에서 끄는 방법도 있습니다. kubelet의 --cpu-cfs-quota=false그 노드의 모든 컨테이너에서 quota 적용을 끕니다. 파드별로 고를 수 없어 영향 범위가 크니 지연에 민감한 워크로드만 모은 전용 노드풀에 쓰는 게 맞습니다.

실제 케이스 — 전용 노드의 Ingress Gateway, CPU만 빼고 메모리는 고정

istiod에서 스로틀을 확인하고 같은 방식으로 Istio Ingress Gateway를 들여다봤더니 여기서도 잘리고 있었습니다. 게이트웨이는 전용 노드에 격리해 두는 구성이라(istio 03) BP ①의 최대 약점인 “이웃을 노출한다"가 애초에 성립하지 않습니다. 같은 노드에 있는 게 전부 같은 역할의 게이트웨이 파드입니다. 하나가 여유 CPU를 더 가져가는 것은 그 관문의 처리량으로 되돌아옵니다. 그래서 여기서는 CPU limit을 뺐습니다.

원래는 cpu·memory 모두 requests = limitsGuaranteed 파드였습니다. 메모리 쪽만 그대로 requests = limits로 남겼습니다. 이 비대칭이 이 케이스를 설명합니다. 두 자원의 성격이 다르니 이렇게 나눠놓는 게 맞습니다.

CPU메모리
성격compressible — 뺏으면 느려질 뿐incompressible — 뺏을 수 없다
상한 초과 시throttle (지연)OOM kill (프로세스 종료)
반환즉시런타임이 OS에 잘 안 돌려준다
⇒ limit의 값어치낮다 — 잘라도 이웃이 얻는 게 지연뿐높다 — 상한이 없으면 노드가 죽는다

메모리 limit이 아예 없는 것보다 나은 점

limit이 없으면 컨테이너가 노드 메모리를 끝까지 먹을 수 있습니다. 그때 개입하는 건 둘 중 하나입니다. 운이 좋으면 kubelet eviction이 먼저 돌지만 빠르게 부풀면 커널 OOM killer가 먼저 옵니다. 커널은 파드 경계를 모르고 oom_score만 보므로 누가 죽을지 고르는 정밀도가 떨어집니다 — 게이트웨이 전용 노드라 해도 같은 노드의 kubelet·containerd·CNI가 함께 삽니다. 이쪽이 맞으면 파드 하나로 끝나지 않고 노드가 NotReady로 빠집니다. 관문 노드에서 이건 남북 트래픽 전체의 문제입니다.

메모리 limit은 그 사고를 컨테이너 하나의 재시작으로 가둡니다. CPU limit을 빼는 것과 달리 메모리 limit을 남기는 데는 실질적인 비용이 거의 없습니다 — 정상 동작하는 파드는 limit에 닿지 않기 때문입니다.

requests < limits(가변)보다 나은 점

가변으로 두면 “평소엔 작게 잡고 필요할 때 limit까지 부푼다"를 기대하게 되는데 메모리에서는 이 기대가 잘 안 맞습니다. 한 번 부푼 메모리는 잘 안 돌아옵니다 — Envoy의 메모리는 커넥션 수와 클러스터·엔드포인트 설정 규모를 따라 늘어납니다. 피크가 지나도 할당자가 OS에 즉시 반납하지 않습니다. 스케줄러는 requests로 노드를 채워놨는데 실제 사용량은 limits 쪽에 가까워지는 드러나지 않는 오버커밋이 됩니다.

kubelet이 메모리 압박에서 축출 대상을 고르는 순서를 보면 차이가 더 분명합니다. 정렬의 첫 번째 키는 “사용량이 requests를 초과했는가"라는 불리언입니다. 그다음이 PriorityClass, 그다음이 절대 사용량입니다.

정렬 키: (requests 초과 여부) → PriorityClass → 절대 메모리 사용량

requests = limits면 사용량이 limit을 못 넘으니 requests를 초과하는 일 자체가 일어날 수 없습니다. 즉 이 파드는 언제나 “초과하지 않은” 그룹에 남아 축출 1순위 그룹에 절대 들어가지 않습니다. requests < limits로 두면 requests를 넘긴 순간 이 그룹의 맨 앞으로 이동합니다 — 하필 트래픽이 몰려 메모리가 늘어난 그 순간에 관문이 축출 후보 1순위가 됩니다.

같은 이유가 커널 쪽에도 있습니다. Burstable 파드의 oom_score_adjrequests에서 계산됩니다.

oom_score_adj = 1000 − (1000 × memory requests ÷ 노드 메모리 용량)

requests를 실수요에 맞게 잡아둘수록 이 값이 낮아지고 노드 메모리 압박에서 더 늦게 죽습니다. requests를 낮게 잡고 limits만 크게 주는 구성은 반대로 갑니다.

대신 무엇을 포기했나 — Guaranteed → Burstable 강등

이 게이트웨이는 원래 cpu·memory 모두 requests = limitsGuaranteed였습니다. CPU limit을 빼는 순간 그 등급은 유지할 수 없습니다 — Guaranteed는 모든 컨테이너가 cpu·memory 둘 다 requests = limits여야 성립하기 때문입니다. 메모리만 맞춰도 QoS는 Burstable로 내려옵니다. 이 선택은 “throttle을 없애는 대가로 QoS 한 등급을 지불한 것"입니다. 무엇을 지불했는지는 항목별로 다릅니다.

항목Guaranteed였을 때지금(Burstable)실질 영향
kubelet 축출 순위requests 초과 없음 → 안전 그룹동일없음
커널 oom_score_adj−997 고정1000 − 1000 × memReq ÷ 노드용량실질적 손실
CPU Manager static 자격있음(CPU가 정수였다면)없음손실 아님
스케줄링requests 기준동일없음

축출 순위는 영향이 없습니다. 앞서 본 정렬 키 (requests 초과 여부 → PriorityClass → 사용량)QoS 항이 아예 없고 memory req = limit을 유지했으므로 초과가 불가능한 상태 그대로입니다. oom_score_adj는 실질적 손실입니다. 예를 들어 32Gi 노드에 memory requests 2Gi면 약 938로, 거의 반대편 끝입니다. CPU Manager static 자격은 손실이 아닙니다 — 쓰려면 CPU limit을 되살려야 해서 애초에 이 선택의 목적과 양립하지 않습니다.

잃은 건 사실상 oom_score_adj 하나입니다. 이게 언제 문제가 되는지는 구분해야 합니다.

  • 컨테이너가 자기 메모리 limit을 넘긴 경우 — 이건 cgroup 레벨 OOM이라 그 컨테이너만 죽습니다. oom_score_adj와 무관합니다. 강등 전후가 똑같습니다.
  • 노드 전체 메모리가 마른 경우 — 여기서만 커널 OOM killer가 노드의 프로세스들을 oom_score 순으로 고릅니다. 예전엔 −997이라 kubelet(−999) 다음으로 안전했는데 지금은 상위 후보 쪽에 서 있습니다.

두 번째 시나리오를 막아주는 게 결국 requests = limits와 정확한 requests입니다. 모든 게이트웨이 파드가 자기 limit 이상 못 쓰고 스케줄러는 allocatable 안에서만 파드를 넣으므로 requests 합이 정직하면 노드 전체가 마르는 상황 자체가 잘 안 생깁니다. Guaranteed 시절에는 이게 공짜로 보장됐지만 지금은 requests 정확도에 의존합니다. 안전장치가 사라지지는 않았고, 자동이던 것이 수동으로 바뀐 것에 가깝습니다.

노드에 CPU Manager static policy가 켜져 있었는지 확인할 것. 켜져 있었다면 Guaranteed + 정수 CPU인 이 게이트웨이는 전용 코어를 배정받고 있었고 CPU limit 제거는 그 전용 코어를 반납하고 공유 풀로 돌아간다는 뜻입니다 — 지연 특성이 크게 달라집니다. 그런데 §BP ②에서 본 대로 static policy에서는 quota와 cpuset 크기가 같아 throttle이 성립하지 않습니다. 스로틀이 실제로 관측됐다는 사실 자체가 이 노드는 static policy가 아니었다는(또는 이 컨테이너가 대상이 아니었다는) 정황 증거입니다.

한 가지 더 — QoS class를 키로 쓰는 주변 도구가 있는지 봐야 합니다. “Guaranteed만 받는” 노드풀 정책, QoS별 알람·대시보드, 비용 배분 로직 같은 것들이 아무 알림 없이 어긋날 수 있습니다. 등급이 바뀐 건 파드 스펙 한 줄이지만 그걸 읽는 쪽은 여러 곳입니다.

⇒ 이 구성은 “CPU는 관문이 필요한 만큼 쓰게 두고, 메모리는 노드를 지키는 선에서 못 넘게 막는다” 입니다. 전용 노드가 CPU 쪽 위험을 없앱니다. requests = limits는 메모리 쪽에서 축출 순위를 가장 안전한 자리에 고정합니다. 남은 숙제는 memory requests를 실측으로 유지하는 것 하나입니다. 강등 이후로는 그 숙제의 무게가 전보다 무거워졌습니다. 커넥션 수가 늘어 게이트웨이의 정상 메모리 사용량이 올라갔는데 requests가 옛날 값이면 축출 보호와 OOM 순위가 함께 약해집니다.

BP ② CPU Manager static policy

throttle을 완화하는 접근이 아닙니다. 발생 조건 자체를 없앱니다. kubelet을 --cpu-manager-policy=static으로 띄우면 조건을 만족하는 컨테이너에 전용 코어(exclusive cpuset) 를 배정합니다.

조건은 둘 다 만족해야 합니다 — QoS가 Guaranteed(모든 컨테이너에 requests = limits)이고 CPU 요청이 정수여야 합니다. 1500m은 소수라 대상이 아닙니다. 조건에 안 맞는 파드는 아무 표시 없이 공유 풀에 남습니다.

왜 throttle이 사라질까요? 오해하기 쉬운데 CFS quota가 없어지는 게 아닙니다. kubelet은 exclusive CPU를 받은 컨테이너에도 quota를 평소대로 겁니다(코드상 cpuset만 추가로 설정합니다). 그런데 limits.cpu: 4면 quota는 400ms/period이고 cpuset은 정확히 4개입니다. 4개 코어에서 100ms 동안 쓸 수 있는 최대 CPU-time이 400ms — quota와 같습니다. §4에서 본 “병렬도가 quota를 빨리 태운다"는 문제가 성립할 수 없습니다.

덤으로 따라오는 것도 있습니다. 코어를 독점하니 컨텍스트 스위치와 캐시 오염이 줄고 Memory Manager·Topology Manager와 묶으면 NUMA 정렬까지 맞출 수 있습니다.

대가가 큽니다.

  • 파편화. 정수 코어만 배정되므로 남는 코어가 놀 수 있습니다. 클러스터 사용률이 떨어집니다.
  • Guaranteed 강제. requests = limits를 모든 컨테이너에 걸어야 해서 오버커밋으로 얻던 밀도를 포기합니다.
  • 01의 in-place resize와 상극입니다. static policy 노드의 Guaranteed 파드는 resize가 admission에서 Infeasible로 거부됩니다. 우회 게이트는 아직 alpha입니다.
  • 노드 단위 설정이고 되돌리기가 번거롭습니다. 정책을 바꾸려면 kubelet을 재시작하고 CPU Manager 상태 파일을 지워야 합니다.
  • 관련해서 스케일다운 시 바쁜 CPU를 회수해 affinity를 깨는 리포트도 열려 있습니다(#131309).

저지연이 비용보다 명백히 비싼 워크로드에만 씁니다. 트레이딩·실시간 미디어·NFV처럼 지터 자체가 SLO인 경우입니다. 일반 웹 서비스에는 과합니다. 그쪽은 BP ①이 훨씬 싸게 같은 문제를 풉니다.

BP ③ 타이밍만 푼다 — CPU Burst

타이밍이 문제니 타이밍을 직접 건드리는 게 맞습니다. 안 쓴 quota를 버퍼에 적립해뒀다 바쁠 때 당겨 쓰는 커널 기능이 있습니다. 누적 평균은 limit 그대로 유지됩니다. 커널에는 5.14부터 있지만 k8s에서 켤 표면이 없습니다.03 CPU Burst

정리

throttle 제거이웃 보호사용률적용 단위
limit 제거✗ (requests에 의존)파드
CPU Manager static✗ 파편화노드(kubelet)
CPU Burst○ 불필요한 것만△ 여유 노드 전제노드 직접 조작 or 벤더
limit 상향파드
period 상향△ 횟수만노드(kubelet)

7. 체크리스트

  • 먼저 관측한다nr_throttled / nr_periods가 실제로 0이 아닌가? 아니면 이 문서는 남의 얘기입니다
  • 사용률과 겹쳐 본다 — 사용률이 낮은데 스로틀이 높으면 §2의 그 상태입니다
  • 표본을 정제한다 — 갓 뜬 파드 제외, 짧은 윈도우와 max_over_time 병행
  • APM과 잇는다 — 스팬 사이의 빈 구간이 스로틀 구간과 시간적으로 겹치는지
  • 병렬도를 확인한다 — 노드 코어 수, GOMAXPROCS/스레드풀 설정. 큰 노드로 옮긴 뒤 나빠졌다면 §4입니다
  • CPU limit은 정수 코어로 — 소수점은 런타임(올림)과 커널(정확값)이 항상 어긋납니다
  • 대응을 고른다 — 온라인 서비스면 limit 제거부터, 지터가 SLO면 CPU Manager, 그 사이면 CPU Burst
  • limit을 뺐다면 requests를 실측으로 다시 잡고, Go라면 GOMAXPROCS를 명시했는지 확인
  • Guaranteed에서 내려왔다면 메모리는 requests = limits를 유지했는지, QoS class를 키로 쓰는 노드풀 정책·알람·비용 로직이 어긋나지 않는지, 그 노드가 CPU Manager static이 아니었는지 확인

이 문서에서 가져갈 것

  • limits.cpu는 총량 보장이 아닙니다. 100ms짜리 배급이고 period 간 이월이 없습니다. “평균 사용률 34%인데 period의 31%가 잘린다"가 여기서 나옵니다.
  • 사용률 그래프에는 이 상태가 없습니다. throttled_periods / periods를 겹쳐야 보입니다. 안 보는 것과 없는 것은 다릅니다.
  • 잘린 시간은 CPU wait으로 쌓여 APM 스팬 사이의 빈 구간이 됩니다. 컨테이너 전체가 한꺼번에 서므로 평균보다 꼬리가 훨씬 크게 망가집니다.
  • 코어가 많을수록 더 잘립니다. quota는 병렬도에 비례해 마릅니다 — 큰 노드로 옮겨 느려지는 일이 실제로 있습니다.
  • 대응은 무엇을 포기하느냐의 문제입니다. limit 제거는 이웃 상한을, CPU Manager는 사용률과 유연성을, CPU Burst는 k8s 표면을 포기합니다.
  • CPU limit과 메모리 limit을 같이 취급하지 말 것. CPU는 압축 가능해서 빼도 지연만 오가지만 메모리는 상한이 없으면 노드가 죽습니다. CPU는 빼고 메모리는 requests = limits로 고정하는 비대칭이 전용 노드에서는 가장 안전한 조합입니다.
  • CPU limit을 빼면 Guaranteed는 포기해야 합니다. 그래도 실제로 잃는 건 oom_score_adj(−997 → Burstable 공식) 하나입니다 — kubelet 축출 정렬에는 QoS 항이 없어서 memory req = limit만 유지하면 순위가 그대로입니다. 노드 전체가 마르는 시나리오를 막아주던 게 자동에서 requests 정확도에 의존하는 수동으로 바뀝니다.

참고 자료

마지막 수정 일자