Skip to Content
BlogCPU를 다 쓰지도 않았는데 스로틀링에 걸렸습니다

CPU를 다 쓰지도 않았는데 스로틀링에 걸렸습니다

컨테이너에 CPU limit을 걸면 커널은 100ms마다 예산을 끊어서 줍니다. 한 번에 몰아주는 게 아니라요.

그리고 Go는 GOMAXPROCS정수로만 가질 수 있습니다. 0.6코어짜리 런타임 같은 건 없으니까요.

이 둘이 맞물리면 이상한 일이 생깁니다. CPU 사용률은 limit의 3분의 1인데, period의 3분의 1이 잘립니다.

그래프는 내내 여유로워 보이는데 워크로드는 계속 멈춰 서 있는 거죠.

저는 이걸 istiod에서 밟았습니다. 그래서 이 글의 사례는 서비스 메시 이야기지만, 밟은 함정 자체는 컨테이너에 CPU limit을 건 Go 애플리케이션이면 누구나 밟을 수 있는 것입니다.


발단은 지난 글에서 돌린 손잡이 하나였습니다.

keepaliveMaxServerConnectionAge를 30분에서 15분으로 줄여 커넥션 재분배 공백을 절반으로 만들었는데, 그러면 재연결이 두 배로 잦아집니다. 재연결은 공짜가 아니고요.

대가가 얼마일지는 겪어봐야 알 일이었는데, 생각보다 빨리 왔습니다. 다음 이벤트에서 istiod CPU가 스로틀링에 걸리면서 장애가 났거든요.

그런데 메트릭을 열어보니 이상했습니다.

파드별 CPU 최대가 0.207코어. limit 600m의 34%밖에 안 됐습니다.

limit을 다 쓰지도 않았는데 왜 잘렸을까요?

장애 당일 09:30–11:00, 파드별 최대치를 1분 해상도로. 좌축은 퍼센트, 우축은 pilot_xds_push_time p99(초)입니다.

CPU ÷ limit(600m)잘린 period 분율push p99 (우축)
파드 20대 소멸스케일아웃 — 커넥션 3배0%10%20%30%40%0.00s0.25s0.50s0.75s1.00s09:3009:4510:0010:1510:3010:4510:59period의 30%가 잘림같은 시각 CPU는 limit의 31%

두 선이 거의 붙어 있는 게 핵심입니다 — limit을 3분의 1밖에 안 썼는데 period는 3분의 1이 잘렸습니다.

원인을 찾는 과정에서 정리한 내용들을 순서대로 적어보겠습니다.


CPU 사용률은 단독으로 읽으면 안 됩니다

우선 CPU 그래프를 어떻게 읽어야 하는지부터 짚고 가겠습니다.

CPU 사용률은 응답 시간과 겹쳐 봐야 의미가 생깁니다. 같은 그래프라도 응답 시간이 어떻게 움직이느냐에 따라 전혀 다른 상황이 됩니다.

CPU 사용률응답 시간
건강한 상태
시간 →

CPU가 트래픽을 따라 완만히 움직이고 응답 시간은 낮게 눕는다.

CPU는 한가한데 느리다
시간 →

계산이 아니라 무언가를 기다리는 중. I/O·락·커넥션 풀을 봐야 한다.

CPU 100% 고착
시간 →

톱니가 사라지면 포화. 트래픽도 같이 올랐나로 증설과 무한루프를 가른다.

같은 "CPU 그래프"라도 응답 시간을 겹쳐 놓으면 셋은 완전히 다른 상황입니다. CPU 90%가 문제인지 아닌지는 CPU 그래프가 아니라 응답 시간이 말해줍니다.

가운데가 가장 헷갈리는 상황입니다. CPU는 20~30%로 한가한데 응답 시간만 폭증하죠.

이건 스레드들이 계산이 아니라 무언가를 기다리고 있다는 뜻입니다. 느려진 DB, 락, 고갈된 커넥션 풀, 외부 API 타임아웃 같은 것들요. 이럴 땐 CPU 그래프를 붙잡고 있어봐야 답이 안 나옵니다.

오른쪽은 반대로 명확해 보이지만 갈래가 둘입니다. 트래픽 그래프를 같이 봐야 용량 초과(증설)인지 코드 무한 루프인지 갈립니다.

그런데 컨테이너에는 여기에 네 번째 패턴이 있습니다. 위 셋 중 어디에도 안 들어가는 것 — CPU는 한가하고, 기다리는 것도 없는데, 느린 경우입니다.

이번 사례가 여기 해당합니다.


커넥션 지표와 CPU 지표는 다른 순간에 튑니다

처음에는 커넥션 분포만 보고 “파드가 무더기로 죽은 순간이 제일 위험하다”고 짐작했습니다.

그날 09:40에 파드 20대가 90초 만에 사라진 구간이 있었거든요. 커넥션 불균형(CoV)이 146%까지 튀었으니 당연히 거기가 최악일 거라 봤죠.

CPU를 붙여보니 아니었습니다.

구간CPU 최대스로틀 분율pilot_xds_push_time p99
평시0.0160.6%0.060
파드 20대 동시 소멸0.13920%0.076
스케일아웃(커넥션 3배 증가)0.20731%0.782

push 지연 p99가 평시 대비 79배로 뛴 건 파드가 죽을 때가 아니라 커넥션 총량이 불어날 때였습니다.

⇒ 불균형이 가장 험한 순간과 CPU가 가장 험한 순간은 다릅니다.

재분배 지표만 보고 CPU 부하를 짐작하면 엉뚱한 손잡이를 잡게 됩니다. 처음에 “파드가 어떻게 죽는가” 쪽을 보다가 한참 돌아갔습니다.


평균이 스로틀을 가립니다

CFS quota는 100ms period 단위로 소진됩니다. 1분 평균이 아니라요.

평균 CPU 0.207코어면 period당 20.7ms인데, 이걸 스로틀 분율과 연립해서 풀어보면:

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

24%의 period는 quota를 꽉 채워 잘리고, 나머지 76%는 8ms만 씁니다.

평균 207m이라는 숫자는 이 두 상태를 섞어놓은 값일 뿐이었습니다. 그래프가 여유로워 보인 건 그래서였고요.

요청 하나 관점으로 바꿔 보면 더 분명해집니다.

0ms100ms200ms300msCPU limit 없음요청 하나를 30ms에 끝낸다CPU limit 0.2 코어멈춤같은 요청이 110ms — 80ms는 그냥 멈춰 있었다
quota로 실행스로틀 — 멈춰 있음할 일 없음작업 진행 — 멈추면 빨갛게

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

여기서 평균 CPU 사용률은 0.1코어입니다. limit(0.2코어)의 절반이죠. 대시보드에는 여유로운 값으로 나옵니다.

그런데 요청 하나는 80ms를 멈춰 있었습니다.

그리고 도는 코어가 여러 개면 이 예산은 더 빨리 마릅니다.

0ms100ms200ms300ms1 코어100ms 꽉 참quota 100ms를 딱 채우고 끝 — 안 잘림2 코어throttle50msthrottle50ms50ms에 quota 소진 → period 절반 멈춤4 코어throttle25msthrottle25ms25ms에 소진 → period의 ¾ 이 멈춤
quota로 실행스로틀 — 멈춰 있음작업 진행 — 멈추면 빨갛게

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

quota는 “동시에 몇 코어가 도느냐”에 비례해서 소진됩니다. limit 1코어면 period당 CPU-time은 100ms인데, 1코어면 그걸 딱 채우고 끝나 안 잘리고, 2코어면 50ms·4코어면 25ms 만에 태워버립니다.

그 “동시 개수”는 노드 코어가 받쳐줘야 나옵니다. 같은 limit이라도 코어 많은 노드에 올릴수록 더 잘 잘리는 거죠 — 직관과 반대입니다.

그리고 quota가 마르는 순간 돌던 스레드가 다 같이 멈춥니다. 하나만 느려지는 게 아니라 컨테이너 전체가 정지하는 거죠. 요청 하나하나의 꼬리가 길어지는 이유입니다.

이번 사례는 반대쪽이었습니다. GOMAXPROCS=1이라 P가 하나뿐이니 quota를 빨리 태울 수 없는 조건인데도 잘렸거든요… 이유는 조금 뒤에 나옵니다.

같은 문제가 보고된 사례도 있습니다. kubernetes#67577을 보면 ingress controller에서 CPU limit을 걷어냈더니 p99 지연이 12~20배 줄었습니다. 평균 CPU는 30m 남짓이었고, limit을 1500m까지 올려봐도 별로 나아지지 않았는데, 아예 없애자 p99·p999가 60ms·100ms에서 ~5ms로 떨어졌다고요.

limit을 높이는 게 아니라 없애야 풀렸다는 게 이 문제의 성격을 그대로 보여줍니다. 총량이 모자란 게 아니라 쓸 수 있는 타이밍이 잘린 거니까요.

결론 : CPU 사용률 그래프만 보고 limit을 사이징하면 안 됩니다. throttled_periods / periods 분율을 같이 보지 않으면 이 상태는 아예 안 보입니다.


quota가 명목대로 안 쓰이는 구간도 조금 있습니다

여기에 2차 요인이 하나 더 붙습니다.

CFS는 quota를 통째로 주는 게 아니라 CPU별로 5ms 슬라이스씩 떼어서 넘깁니다. 그리고 슬라이스를 받아간 CPU에서 스레드가 잠들면 1ms만 남기고 나머지는 글로벌 풀로 돌아갑니다.

돌아가긴 하는데, 그 1ms씩은 남습니다. CPU를 여러 개 훑을수록 조금씩 새는 거죠(kubernetes#67577 ).

istiod가 그럴 조건이긴 했습니다. OS 스레드가 17개였거든요. GOMAXPROCS=1인데도요.

GOMAXPROCS는 goroutine을 실행하는 P의 개수를 제한할 뿐 OS 스레드 개수를 줄이지 않습니다. sysmon·GC 마크 워커·netpoller는 전부 P 바깥에서 따로 돌고, 전부 같은 quota에 청구되니까요.

다만 규모는 크지 않았습니다. 좌초가 전혀 없다고 보고(quota 60ms 그대로) 계산했을 때 앞뒤가 안 맞는 표본은 6.5%뿐이었습니다. 나머지는 60ms로 설명이 되고요.

주된 원인은 버스트고, 슬라이스 잔류는 2차 요인입니다. 비중을 뒤집어 읽으면 안 됩니다.


GOMAXPROCS=1은 어디서 왔나

GOMAXPROCS를 직접 설정한 적은 없었습니다. Istio 차트가 1.19부터 istiod에 자동으로 주입하고 있더군요.

- name: GOMAXPROCS valueFrom: resourceFieldRef: resource: limits.cpu # ← 여기 divisor: "1"

그리고 kubelet이 이 값을 math.Ceil올림합니다. ceil(600m ÷ 1코어) = 1.

CPU limit을 600m로 건 순간 GOMAXPROCS=1이 따라온 겁니다.

문제는 올림이 한쪽에만 적용된다는 점입니다. 커널 quota는 올림 없이 정확히 60ms고요.

OptionTypeDescription
GOMAXPROCSceil(0.6) = 1 — 런타임은 1코어를 태울 준비를 함
CFS quota0.6 × 100ms = 60ms — 커널은 0.6코어만 허용

런타임과 커널이 서로 다른 값을 기준으로 동작하는 셈입니다.

PILOT_PUSH_THROTTLEmin(15 + 5 × GOMAXPROCS, 100)으로 여기서 파생됩니다. 동시 push 슬롯이 20개가 되죠.

오해하기 쉬운데, 이 20이 잘못된 값은 아닙니다. push 한 건은 대부분 네트워크 대기라 I/O 구간에서는 P를 점유하지 않거든요. 1코어에서 goroutine 20개가 떠 있는 건 정상입니다.

문제는 각 push의 CPU-bound 구간(설정 조립·마샬링·TLS)입니다. 이건 P가 있어야 하는데, GOMAXPROCS=1이면 20건이 한 줄로 늘어섭니다. 그리고 그 한 줄마저 quota에서 잘리고요.

⇒ 20이 많은 게 아니라, 20건이 만들어내는 CPU 수요를 받아낼 자리가 없는 것입니다.

말로는 잘 안 와닿으니 움직여 보겠습니다. 아래 그림에서 셋만 따라가시면 됩니다.

왼쪽에서 요청이 계속 도착합니다. 가운데 CPU 슬롯은 GOMAXPROCS=1이라 한 번에 하나만 통과시키고요.

그 슬롯이 쓸 수 있는 quota는 period당 60ms뿐입니다. 다 쓰면 슬롯이 닫히고(스로틀), 그동안 도착한 요청은 큐에 그대로 쌓입니다. 다음 period가 와야 다시 열립니다.

① period 시작 — quota 가득, 게이트 열림period 1대기 큐대기 0건한 번에 하나만 (P=1)요청CPU · GOMAXPROCS=1통과 가능quota (60ms / 100ms)처리됨
통과 중대기 · 게이트 열림대기 · 게이트 닫힘(스로틀)

quota 60ms를 다 쓰면 그 period 남은 40ms 동안 GOMAXPROCS=1 슬롯이 닫힌다 — 요청은 큐에 쌓여 다음 period를 기다린다. 평균 사용률은 그대로인데 꼬리만 길어지는 이유다.

큐가 쌓였다 빠지기를 반복하는 게 보이시죠. CPU 슬롯은 노는 게 아닙니다 — quota는 매번 다 씁니다. 다만 하나짜리 슬롯이 주기적으로 닫히니 요청이 그때마다 밀립니다.

평균은 한가한데 꼬리가 길어지는 게, 그림으로 보면 이겁니다.


limit을 올리거나 period를 늘리면 되나

먼저 떠오르는 답이 둘 있는데, 둘 다 절반만 듣습니다.

limit을 올린다. 실제로 효과는 있습니다. 다만 request = limit으로 맞춰 쓰고 있었다면 limit을 올리는 만큼 request도 올라가면서 노드에 파드가 덜 들어갑니다. 클러스터 전체 사용률이 떨어지죠. request는 그대로 두고 limit만 올리면 이번엔 오버커밋이 됩니다. 평소엔 괜찮지만 부하가 몰리는 시점에 문제가 됩니다.

period를 늘린다. quota 20ms/period 100ms를 40ms/200ms로 바꾸면, 같은 0.2코어인데 스로틀 횟수는 절반이 됩니다. 그런데 한 번 잘릴 때 더 오래 잘립니다. 80ms 멈추던 게 160ms 멈추는 거죠. 지연에 민감한 서비스라면 오히려 나빠집니다. 게다가 Linux는 period 상한이 1000ms라 크게 키울 수도 없습니다.

⇒ 둘 다 “얼마나 주느냐”를 건드리는 답입니다. 문제는 총량이 아니라 타이밍이었는데요.


CPU Burst — 안 쓴 quota를 모아뒀다 빌려 씁니다

타이밍을 직접 건드리는 방법이 커널에 있습니다.

사실 2024년에 컨테이너 격리를 정리하면서 한 번 지나갔던 기능입니다. 그때는 “이런 게 있다”까지만 적고 넘어갔는데, 이렇게 직접 밟고 나서 다시 보게 됐네요…

Linux 커널 문서의 표현이 정확합니다.

This feature borrows time now against our future underrun, at the cost of increased interference against the other system users.

한가할 때 안 쓰고 남긴 quota를 버퍼에 적립해뒀다가, 바쁠 때 당겨 쓰는 겁니다.

0ms100ms200ms300msBurst 없음멈춤멈춤1구간에서 남긴 20ms는 그냥 사라진다버퍼 없음버퍼 없음버퍼 없음Burst 켬멈춤남긴 20ms를 모아뒀다 2구간에서 10ms 빌려 쓴다 — 스로틀 없음버퍼 20ms버퍼 10ms버퍼 0
quota로 실행버퍼에서 빌려 실행스로틀 — 멈춰 있음할 일 없음작업 진행 — 멈추면 빨갛게

안 쓴 quota를 버퍼에 모아뒀다 필요할 때 당겨 쓴다. 버퍼가 비면 그때는 그대로 스로틀된다 — 긴 구간 평균은 여전히 limit을 넘지 않는다.

여기서 중요한 건 계약이 깨지지 않는다는 점입니다.

Burst 없음 : CPUTime ≤ Quota Burst 켬 : CPUTime ≤ Quota + Buffer ← 한 구간에서는 넘을 수 있지만 버퍼가 과거의 미사용분에서만 오므로 : ΣCPUTime ≤ Quota × Periods ← 긴 구간 평균은 그대로

순간적으로는 limit을 넘지만, 버퍼가 과거의 미사용분에서만 채워지니 긴 구간 평균은 limit으로 수렴합니다. “0.2코어”라는 약속은 지켜지고, 그 0.2코어를 언제 쓸지만 자유로워지는 거죠.

다만 이건 설계 의도지 강한 보장이 아닙니다. 2021년 커널 메일링리스트에 이런 리포트가 올라왔거든요.

Tasks might get more cpu than quota in persistent periods… Once this group gets a free period which leaves enough runtime, it has a chance to get computing power more than its quota for 10 periods or more

sched/fair: prevent cpu burst too many periods , Honglei Wang, 2021-11

quota 100ms에 버퍼 100ms를 주고 평균 수요가 105ms쯤 되는 그룹이면, 한 번 한가한 구간이 생기는 순간 그걸로 10주기 넘게 계속 초과할 수 있습니다. 보고자의 표현으로는 태스크가 버스트 몫을 “훔쳐서 평상시 일에 쓰는” 셈이죠.

버퍼를 크게 잡을수록 “가끔 튀는 걸 흡수한다”에서 “상시 초과한다”로 성격이 바뀝니다. quota의 몇 배씩 주는 건 그래서 위험합니다.

설정과 관측은 이렇습니다.

# cgroup v1 cpu.cfs_burst_us # 적립 상한. 기본 0 = 적립 안 함 # quota보다 크게는 못 잡는다 # cgroup v2 cpu.max.burst # 얼마나 쓰였나 — cpu.stat에 두 항목이 붙는다 nr_bursts # burst가 발생한 period 수 burst_time # quota를 초과해 쓴 누적 시간

기본값이 0이라는 데 주의해야 합니다. 커널이 지원해도 명시적으로 켜지 않으면 아무 일도 일어나지 않습니다.

효과는 발표 자료에 실측이 있습니다. 어떤 워크로드에서 평균 응답 30ms대 → 9.6ms, p99 500ms대 → 27.3ms. 다른 케이스에선 평균 1310ms → 952ms로 27% 감소.

같은 수치가 커널 커밋 메시지에도 있습니다 — Java 워크로드 p99 500ms+ → 27ms, schbench p99 933us → 12us. 발표만의 주장이 아니라 패치를 머지할 때 제시된 근거였다는 뜻입니다.

Alibaba의 Apache HTTP Server 벤치마크(limit 4코어)도 같이 보면 좋습니다.

OptionTypeDescription
p99 응답111.69ms71.30ms (−36%)
스로틀된 비율33%0%
평균 CPU 사용률32.5%33.8%

평균 CPU는 거의 그대로입니다. 더 많이 쓴 게 아니라 쓰는 타이밍만 바뀌었는데 꼬리가 36% 깎였습니다. 이 기능의 성격이 이 표에 잘 드러납니다.

공짜는 아닙니다

커널 문서에 적힌 “at the cost of increased interference against the other system users”가 그 대가입니다.

내 컨테이너가 버퍼를 당겨 쓰는 동안, 같은 노드의 다른 컨테이너는 그만큼 밀립니다. 원래대로면 지켰을 deadline을 놓칠 수 있고, 그게 반복되면 계속 밀리기만 하는 상태에 빠질 수도 있습니다.

발표자들은 이걸 큐잉 이론과 몬테카를로 시뮬레이션으로 따져봤는데, 결론이 “노드 평균 CPU 사용률이 70% 아래면 안전하다” 였습니다. 다만 조건이 붙습니다 — cputime 수요가 지수분포, 컨테이너 20개, 버퍼 = quota 100% 기준입니다. 컨테이너 수가 많을수록, 버퍼가 작을수록 안전한 쪽이고요.

여유 있는 노드에서 튀는 워크로드에는 잘 맞고, 이미 꽉 찬 노드에서는 쓰면 안 되는 기능입니다.

운영 함정도 몇 개 문서화돼 있습니다. Alibaba의 koordinator 문서에 적힌 것들인데, 세 번째가 특히 이 글과 맞물립니다.

  • 반응이 늦습니다. quota 조정 방식은 스로틀이 실제로 감지된 뒤에야 움직입니다. 첫 번째 스파이크는 이미 맞고 난 뒤죠.
  • 한 파드의 남용이 노드 전체로 번집니다. 노드 CPU가 임계(기본 50%)를 넘으면 그 노드의 모든 파드 quota가 일괄 리셋됩니다.
  • cfs_quota_us를 읽어 스레드 수를 정하는 앱은 오작동할 수 있습니다. quota 값이 요동치니까요.

세 번째를 다시 읽어보시죠. 이 글이 내내 다룬 게 limits.cpu에서 GOMAXPROCS가 파생되는 배선이었습니다. quota를 동적으로 흔드는 방식과 quota에서 병렬성을 읽어가는 런타임을 같이 쓰면, 그 둘이 서로를 밟습니다.

Alibaba Cloud Linux에도 비슷한 함정이 있습니다 — 자식 cgroup은 기본적으로 burst가 꺼져 있어서 개별로 켜줘야 합니다. 부모에 걸었다고 상속되지 않습니다.

쓸 수 있나

커널은 준비돼 있는데 위층이 안 따라왔습니다.

  • Linux 5.14+ 메인라인에 들어가 있습니다
  • OCI runtime-spec에는 burst 필드가 있습니다. 2023년 1월에 머지됐어요
  • 그런데 Kubernetes에는 이걸 켤 방법이 없습니다. #104516 이 2021년 8월에 열렸는데 5년째 담당자도 연결된 PR도 없이 그대로입니다

⇒ 커널에도 있고 런타임 스펙에도 있는데 파드 스펙에서 닿을 수가 없는 상태입니다. 그래서 지금 쓰려면 노드에서 cgroup 파일을 직접 만지거나(DaemonSet으로 흉내 내는 사례들이 있습니다), Alibaba ACK처럼 벤더가 별도 애노테이션(koordinator.sh/cpuBurst)을 얹어준 환경이어야 합니다.

2024년 글에서 이 기능을 적으면서 “쿠버네티스는 그닥 관심을 보이지 않는듯 합니다” 라고 썼는데, 2년 지나 다시 찾아보니 그 말이 그대로 맞았습니다.

관심을 보인 쪽은 여전히 Alibaba였고요.

확인은 파일 존재 여부로 합니다.

kubectl exec <pod> -- cat /sys/fs/cgroup/cpu.max.burst # cgroup v2 # "0" 이면 커널은 지원하는데 꺼져 있다는 뜻 # 파일이 없으면 커널이 5.14 미만

저희 환경은 아직 확인 전입니다. 다만 3편의 그 파드처럼 평균은 한가한데 버스트에서 잘리는 모양이라면, 이게 정확히 겨냥하는 케이스입니다.


그래서 Go 워크로드에 CPU limit을 걸 때

이번 일을 일반화하면 규칙 몇 개가 나옵니다.

소수점 CPU limit은 이 배선에서 항상 어긋납니다. 1000m 미만이면 GOMAXPROCS는 1로 올림되는데 quota는 그 값 그대로니까요. 600m·1500m·2500m 전부 같은 문제입니다. 정수 코어만 둘이 맞습니다.

1코어 미만 limit에 CPU-bound Go를 올리는 건 피하는 게 좋습니다. GOMAXPROCS 하한이 1이라 애초에 맞출 수가 없습니다.

그렇다고 limit을 아예 빼는 것도 답은 아닙니다. Go 1.25부터 런타임이 cgroup을 읽는데, 릴리스 노트가 명시하듯 request가 아니라 limit을 봅니다. limit이 없으면 노드 전체 코어 수가 GOMAXPROCS가 되죠. 64코어 노드에서 P 64개는 그것대로 낭비입니다.

반올림 방향이 구현마다 다르다는 것도 알아두면 좋습니다.

설정 주체반올림limit 0.6limit 1.4
Istio 차트(Downward API + kubelet)올림12
Go 1.25+ 네이티브올림12
uber-go/automaxprocs내림(최소 1)11

1.4에서 갈립니다. automaxprocs가 내림을 택한 이유가 CHANGELOG에 있는데 — “포화 시 상시 스로틀을 보장하느니, 소수 부분은 Go 스케줄러 바깥 요인을 위한 여유로 보겠다” 는 논리입니다. 이번 사건이 딱 그 “상시 스로틀” 사례였고요.

참고로 istiod 1.24는 Go 1.22 기반이라 런타임의 컨테이너 인식이 없습니다. GOMAXPROCS가 전적으로 차트의 Downward API 주입값에 달려 있죠. 그리고 차트가 환경변수를 명시적으로 넣기 때문에, 나중에 Go 1.25+로 빌드되더라도 런타임 자동 감지는 계속 꺼진 상태로 남습니다.


정리

  • CPU 그래프는 혼자 읽으면 안 됩니다. 응답 시간과 겹쳐야 “한가한데 느린” 상태가 보이고, 컨테이너에서는 거기에 스로틀이라는 네 번째 패턴이 하나 더 있습니다.
  • 재연결 주기를 절반으로 줄인 대가는 CPU-bound 작업의 증가로 돌아왔습니다. 재연결 한 건마다 설정 조립·마샬링·TLS가 따라붙으니까요.
  • 그런데 CPU 사용률은 limit의 34%였는데 period의 31%가 잘렸습니다. quota는 100ms 단위로 소진되지 평균으로 판정되지 않기 때문입니다.
  • 제가 처음 의심한 “파드가 무더기로 죽는 순간”은 2순위였습니다. 불균형이 험한 순간과 CPU가 험한 순간은 다릅니다.
  • GOMAXPROCS=1은 제가 설정한 게 아니라 limits.cpu: 600m이 만들어낸 값이었습니다. 차트가 주입하고 kubelet이 올림하거든요.
  • 런타임은 1코어를 믿고 커널은 0.6코어를 줍니다. 소수점 limit은 항상 이렇게 어긋납니다.
  • 문제가 총량이 아니라 타이밍이라면, limit을 올리거나 period를 늘리는 건 반쯤만 듣습니다. CPU Burst는 안 쓴 quota를 적립해 그 타이밍을 직접 풀어주되, 누적 평균은 그대로 지킵니다. 대신 같은 노드의 이웃을 밀어내니 여유 있는 노드에서만 쓸 것.

이 글에 크게 빚진 자료 둘 :

  • 서버 모니터링 분석 가이드  (kciter) — “CPU 사용률이라는 말의 함정” 절. 이 글 앞부분의 CPU 그래프 세 패턴은 여기서 얻은 관점입니다. 지표를 혼자 읽지 말라는 이야기를 이보다 잘 정리한 걸 못 봤습니다.
  • CPU Burst: Getting Rid of Unnecessary Throttling — KubeCon + CloudNativeCon China 2021, Huaixin Chang & Tianchen Ding (Alibaba Cloud). 0.2코어 / 30ms → 110ms 예시, CPU Burst 설계와 WCET 시뮬레이션 결과가 전부 이 발표에서 나왔습니다. 발표 영상  · 슬라이드 PDF  · 커널 반영은 commit f4183717b370 

두 자료의 도해는 그대로 쓰지 않고, 이 글의 색 체계에 맞춰 전부 새로 그렸습니다. 수치와 논지는 원본을 따르되 그림은 제 것입니다.

그 밖에 :

메트릭 정의의 함정, keepalive 기본값의 출처, ambient·Delta xDS·리비전 샤딩까지 — 공식 문서와 소스를 어디까지 파봤는지는 따로 정리해뒀습니다 .


지난 글에서 손잡이 세 개를 돌리고 나서, 저는 문제를 해결했다고 생각했습니다.

이번 글은 그 손잡이 중 하나가 다른 층위에 청구서를 남겼다는 이야기입니다. 커넥션 층위에서 푼 문제가 CPU 층위로 옮겨간 거죠.

그리고 그 청구서를 못 알아본 이유가, 하필 평균이라는 가장 익숙한 렌즈 때문이었다는 게 오래 남습니다. 그래프는 내내 여유로워 보였거든요.

limits.cpu를 정수로 올리고 나면 push 지연이 어떻게 달라지는지는, 다시 재보고 남기겠습니다.