Skip to Content

14장. CPU 제한과 스레드 풀 — CFS quota 스로틀링

--cpus 0.5를 건 컨테이너에서 무한 루프 두 개를 돌렸더니, 52번의 주기 중 52번 모두 제동이 걸렸습니다. 죽지 않았어요. 매번 멈췄다 다시 갈 뿐입니다.

{/* 삽화 자리: 일정한 간격으로 눈금이 그어진 긴 띠(주기). 각 칸마다 일꾼이 절반쯤 일하다가 남은 절반은 팔짱을 끼고 서 있다. 멈춰 있는 구간이 칸마다 반복된다. 일꾼은 지쳐 보이지 않고 그냥 대기 중이다. 프롬프트: docs/book/illustration-prompts-container.md */}


배경

13장에서 메모리를 봤습니다. 넘으면 죽었죠. CPU는 안 죽습니다. 대신 멈췄다 갑니다.

그런데 “멈췄다 간다”는 게 평균 그래프에는 안 보입니다. CPU 사용률은 50%를 가리키는데 p99만 튀어요. 그 50%가 어떻게 생긴 50%인지를 이 장에서 봅니다.

시작은 13장이 남긴 질문입니다. CPU를 0.5개만 준 컨테이너에서 JVM은 자기가 몇 개를 가졌다고 답할까요.

스토리

물어봤습니다.

docker run --rm --cpus 0.5 eclipse-temurin:21-jre \ java -XshowSettings:system -version
=== 1. JVM 이 보는 프로세서 수 === 제한 없음 Effective=8 quota=-1 period=100000us --cpus 0.1 Effective=1 quota=10000us period=100000us --cpus 0.5 Effective=1 quota=50000us period=100000us --cpus 1 Effective=1 quota=100000us period=100000us --cpus 1.5 Effective=2 quota=150000us period=100000us --cpus 2 Effective=2 quota=200000us period=100000us --cpus 2.5 Effective=3 quota=250000us period=100000us --cpus 4 Effective=4 quota=400000us period=100000us

✅ 실측 (Docker Engine 29.8.0 / Docker Desktop 4.91.0 / Temurin 21.0.12 / Apple M1 8코어 / macOS 26.6, 2026-09. 전체 소스: docs/book/code/container/ch14/cpu.sh)

제한이 없으면 8개, 걸면 그만큼입니다.

그런데 --cpus 0.5에서 Effective=1입니다. 0.5라고 답하지 않아요. 프로세서 개수는 정수여야 하니까요.

여기서 “0개일 수는 없으니 1로 깔아주는구나”로 읽기 쉬운데, 그게 아닙니다. 1.5와 2.5 줄을 보세요. 1.5는 2로, 2.5는 3으로 답합니다. 반올림도 버림도 아니라 올림이에요.

올림한 그 1이 뒤에서 스레드 풀 크기를 정합니다.

핵심

quota와 period

출력의 뒤쪽 두 열이 CPU 제한의 실체입니다. JVM이 중계한 값이고, 원본은 파일에 있어요.

7장에서 memory.max를 만졌죠. CPU 쪽 파일은 cpu.max입니다. 열어봤습니다.

=== 2. 그 값이 실제로 적혀 있는 파일 === 제한 없음 cpu.max = max 100000 --cpus 0.5 cpu.max = 50000 100000 --cpus 1 cpu.max = 100000 100000 --cpus 2 cpu.max = 200000 100000

✅ 실측 (같은 환경, 같은 소스)

값이 두 개입니다. quota와 period요. 제한이 없으면 quota 자리에 max라는 문자열이 들어가고, JVM은 그걸 -1로 바꿔서 보여줍니다.

--cpus 0.5의 50000 100000을 읽으면 이렇습니다. 100밀리초마다 50밀리초씩 쓸 수 있다.

메모리 제한과 성격이 다릅니다. 메모리는 “총량”이지만 CPU는 “주기마다 얼마”예요. 리눅스의 CFS 대역폭 제어가 100ms 단위로 시간을 끊어서, 그 안에서 할당량을 다 쓰면 다음 주기가 올 때까지 그 그룹을 멈춥니다.

이걸 스로틀링이라고 합니다.

이름에 CFS가 붙어 있는데, 커널 6.6부터 기본 스케줄러는 EEVDF로 바뀌었습니다. 다만 대역폭 제어는 이름도 동작도 그대로예요. cpu.max도 nr_throttled도 같습니다.

52번 중 52번 멈췄습니다

무한 루프 두 개를 --cpus 0.5 컨테이너에서 돌리고 cpu.stat을 읽어봤습니다.

docker run -d --name ch14 --cpus 0.5 eclipse-temurin:21-jre \ sh -c 'while :; do :; done & while :; do :; done & wait' sleep 5 docker exec ch14 cat /sys/fs/cgroup/cpu.stat
=== 3. 제한을 넘기면 죽는 게 아니라 멈춘다 (스로틀링) === usage_usec 2642264 nr_periods 52 nr_throttled 52 throttled_usec 7819091

✅ 실측 (같은 환경, 같은 소스. 주기 수는 따라 하면 50~53 사이로 갈립니다)

읽어봅시다.

nr_periods가 52입니다. 5초 동안 100ms 주기가 52번 지나갔어요.

nr_throttled도 52입니다. 그 52번 전부에서 할당량을 다 쓰고 멈췄습니다.

usage_usec는 2.6초. 5초 동안 실제로 CPU를 2.6초 썼습니다. 절반이죠. 제한대로예요.

문제는 throttled_usec입니다. 7.8초예요. 벽시계로는 5초밖에 안 지났는데요.

7.8초는 무엇의 합인가

“스레드가 둘이니 각자 멈춘 시간이 더해졌겠지”가 자연스러운 설명입니다. 저도 처음엔 그렇게 읽었어요.

틀렸습니다. 스레드 수를 그대로 두고 코어만 하나로 묶어 확인했습니다.

=== 5. throttled_usec 는 스레드 단위인가, CPU 별 런큐 단위인가 === 스레드 1, 코어 자유 nr_periods=52 throttled_usec=2716232 주기당 52.2ms 스레드 2, 코어 자유 (본문 조건) nr_periods=52 throttled_usec=7926618 주기당 152.4ms 스레드 2, 한 코어에 묶음 nr_periods=51 throttled_usec=2560156 주기당 50.2ms 스레드 4, 코어 자유 nr_periods=53 throttled_usec=19529661 주기당 368.5ms

✅ 실측 (같은 환경, 같은 소스)

스레드는 똑같이 둘인데 한 코어에 묶으니 152ms가 50ms로 떨어집니다. 두 번째 줄과 세 번째 줄이 그거예요. 스레드 단위로 더해진다면 여기서도 두 배가 나와야 하죠.

더해지는 단위는 스레드가 아니라 CPU별 런큐입니다. 커널은 컨테이너의 cgroup에 대해 CPU마다 런큐를 하나씩 두고, 제동이 걸리면 그 런큐들이 각각 멈춥니다. 멈춰 있던 시간은 런큐별로 따로 세어 더해져요.

그래서 스레드 둘이 서로 다른 코어에 올라가면 런큐 둘이 각각 76ms씩 멈추고, 합이 152ms가 됩니다. 한 코어에 묶으면 런큐가 하나뿐이라 50ms고요.

읽는 법이 중요합니다. throttled_usec를 벽시계로 나눠 스레드 수를 역산하면 안 돼요. 그건 “몇 개의 런큐가 얼마나 멈췄나”의 합입니다.

=== 4. 그동안 CPU 를 실제로 얼마나 썼나 === ch14b: CPU 50.58%

✅ 실측 (같은 환경, 같은 소스)

docker stats로 봐도 50%입니다.

그러니까 제한은 정확히 지켜졌습니다. 다만 지켜지는 방식이 “느리게 돌려주기”가 아니라 “돌리다 멈추기”예요.

이게 왜 문제가 되나

평균으로 보면 아무 문제 없습니다. 50%를 주기로 했고 50%를 썼으니까요.

문제는 응답 시간에서 생깁니다.

요청을 처리하던 스레드가 주기 중간에 할당량을 다 쓰면, 남은 시간 동안 그냥 멈춥니다. 다음 주기가 올 때까지 그 요청은 아무 진전이 없어요.

얼마나 서 있느냐는 동시에 도는 스레드 수에 달렸습니다. 방금 실측한 값을 런큐 하나 기준으로 나눠 보면 이렇습니다.

동시에 도는 스레드런큐 하나가 주기마다 멈춘 시간
152ms
276ms
492ms

스레드가 많을수록 할당량이 빨리 소진되니 멈춰 있는 시간이 길어집니다. 100ms에 수렴하되 닿지는 않아요. 50ms는 어떻게든 돌아야 하니까요.

CPU 사용률 그래프는 50%를 가리키는데 p99 응답 시간이 튀는 상황이 이래서 생깁니다. 평균은 멀쩡한데 개별 요청이 90ms씩 서 있는 거죠.

그리고 한 번만 걸리는 게 아닙니다. CPU를 200ms 쓰는 요청이면 주기 네 개를 지나야 하니 제동을 서너 번 맞습니다. 90ms가 한 번이 아니라 여러 번 붙어요.

올림된 1이 만드는 일

--cpus 0.5에서 JVM은 프로세서를 1개라고 답했습니다. 그런데 실제로 쓸 수 있는 건 0.5개예요.

JVM과 여러 라이브러리가 그 “1”을 보고 스레드 풀 크기를 정합니다. GC 스레드도, ForkJoinPool.commonPool도요.

“작게 잡히니 안전하겠네”로 넘어가면 안 됩니다. GC 알고리즘 자체가 바뀌거든요.

=== 2-2. 프로세서 수가 GC 선택까지 바꾼다 (--memory 2g 로 메모리 변수는 고정) === --cpus 0.5 ParallelGCThreads=0 UseG1GC=false UseSerialGC=true --cpus 1 ParallelGCThreads=0 UseG1GC=false UseSerialGC=true --cpus 2 ParallelGCThreads=2 UseG1GC=true UseSerialGC=false --cpus 4 ParallelGCThreads=4 UseG1GC=true UseSerialGC=false

✅ 실측 (같은 환경, 같은 소스)

프로세서를 2개 미만으로 보면 JVM은 G1이 아니라 SerialGC를 고릅니다. 힙 크기 때문에 바뀐 게 아니라는 걸 확인하려고 --memory 2g로 고정하고 잰 값이에요.

크기가 작아지는 게 아니라 stop-the-world 특성이 달라지는 겁니다. 하필 p99를 다루는 이 장에서요.

위험은 반대 방향에도 있습니다. --cpus 0.5인데 톰캣 threads.max를 기본값 200으로 두면, 부하가 몰렸을 때 스레드 200개가 0.5개 분량의 시간을 나눠 씁니다. 평소엔 티가 안 납니다. 톰캣은 200개를 미리 만들지 않고 min-spare 10개만 띄우거든요. 터지는 건 트래픽이 올 때예요.

『잠드는 스레드』와는 반대 방향의 문제입니다. 거기서는 스레드가 I/O를 기다리느라 자리만 차지하는 게 문제였죠 — CPU는 3%인데 풀이 꽉 찼습니다. 여기서는 CPU가 50%인데 p99가 튑니다. 스레드는 깨어 있는데 할당량이 없어서 못 도는 거예요. 증상은 둘 다 “느리다”로 보이지만 고칠 곳이 다릅니다.

그래서 어떻게 잡나

상황하면 좋은 것
CPU를 1개 미만으로 주기되도록 피하기. 올림 오차가 제일 큰 구간이고(0.5→1, 두 배), 프로세서를 1로 보면 SerialGC가 걸린다
JVM이 보는 개수를 직접 정하고 싶을 때-XX:ActiveProcessorCount=N. 실측에서 --cpus 0.5에 2를 박으니 Effective=2가 됐다. 다만 GC 선택까지 따라 바뀌니 정하고 나서 재볼 것
스레드 풀 크기CPU 개수에 맞춰 줄이기. 톰캣 threads.max 200 기본값을 그대로 두지 않기
스로틀링 확인cpu.stat의 nr_throttled를 보기. 0이 아니면 제동이 걸리고 있다
응답 시간이 튈 때평균 CPU 사용률 말고 throttled_usec를 보기

마지막 줄이 실무에서 제일 쓸모 있습니다. “CPU가 남는데 왜 느리지”의 답이 그 파일에 있거든요.

쿠버네티스에서는 이 값을 limits.cpu로 정합니다. 그리고 거기에는 requests라는 짝이 하나 더 있어요. 둘이 무엇이 다른지는 21장입니다.

정리

  • CPU 제한은 메모리와 성격이 다릅니다. 총량이 아니라 “주기마다 얼마”예요. cpu.max에 quota와 period 두 값이 적힙니다.
  • --cpus 0.5는 50000 100000, 즉 100ms마다 50ms입니다. 할당량을 다 쓰면 다음 주기까지 멈춰요. 이게 스로틀링입니다.
  • 실측에서 5초 동안 52번의 주기가 지났고 52번 전부 제동이 걸렸습니다. 실제로 쓴 CPU는 2.6초였어요.
  • throttled_usec 7.8초는 스레드별 합이 아니라 CPU별 런큐별 합입니다. 스레드 둘을 한 코어에 묶으니 같은 스레드 수인데 152ms/주기가 50ms/주기로 떨어졌습니다.
  • 제한은 정확히 지켜집니다. docker stats로도 50%였어요. 다만 지켜지는 방식이 “느리게”가 아니라 “멈췄다 가기”입니다.
  • 그래서 평균 CPU는 멀쩡한데 p99가 튑니다. 런큐 하나가 주기마다 멈춘 시간은 스레드 1개에 52ms, 2개에 76ms, 4개에 92ms였어요. 긴 요청이면 여러 주기에 걸쳐 반복해서 맞습니다.
  • JVM은 --cpus 0.5에서 프로세서를 1개로 봅니다. 올림이에요. 1.5는 2, 2.5는 3으로 답합니다.
  • 그 개수가 GC 선택까지 바꿉니다. 2개 미만으로 보면 G1이 아니라 SerialGC입니다.
  • 톰캣 threads.max 200은 상한이고 평소엔 min-spare 10개만 떠 있습니다. 문제는 트래픽이 올 때 터져요.
  • “CPU가 남는데 왜 느리지”의 답은 cpu.stat의 nr_throttled와 throttled_usec에 있습니다.

생각해볼 질문

여기까지 컨테이너 안의 앱이 어떻게 사는지를 봤습니다. 이미지로 담고(11장), 종료 신호를 받고(12장), 메모리를 나눠 쓰고(13장), CPU를 나눠 썼어요(14장).

그런데 바깥에서 볼 때 궁금한 게 하나 남습니다. 이 컨테이너가 지금 멀쩡한지 어떻게 알까요.

프로세스가 살아 있으면 멀쩡한 걸까요. 12장에서 본 shell form 컨테이너는 프로세스가 멀쩡히 살아 있었는데 종료 신호를 못 듣고 있었습니다. 14장의 스로틀링 걸린 컨테이너도 프로세스는 살아 있고요.

살아 있는 것과 일을 할 수 있는 것은 다릅니다. 그 둘을 어떻게 구분해서 바깥에 알릴까요.

▶ 15장. 살아 있음을 증명하기 — HEALTHCHECK·actuator·로그 드라이버

Last updated on