# Docker# Kubernetes

CPU 사용률은 낮은데 컨테이너는 왜 throttling에 걸릴까?

@Hong2026-10-01

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
컨테이너 CPU 사용률은 낮은데 응답 시간이 튀는 이유는 무엇일까?
답
CPU limit은 100ms 구간마다 쓸 CPU 시간을 자르므로, 평균 0.20코어 부하도 --cpus 1에서 구간의 28~31%가 멈췄고 p99는 210~285ms로 limit 없음의 47~66ms보다 길었다.
조건
Docker Engine 27.5.1(Docker Desktop, VM CPU 8개, cgroup v2), Temurin 21.0.11, 20ms 요청 8건 묶음, 설정마다 60초씩 3회

환경

Docker
macOS의 Docker Desktop, Docker Engine 27.5.1
리눅스 VM
CPU 8개, cgroup v2
Java
Temurin 21.0.11(apache/kafka:4.3.1 이미지에 든 런타임)
부하
1초마다 /burn?ms=20 8건 묶음, python:3.12-slim 컨테이너의 열린 부하 클라이언트
반복
설정마다 60초 부하 3회
목차
  1. 실습 준비
  2. 실습 1: limit별 p99와 throttling
  3. 실습 2: --cpus와 --cpuset-cpus는 다릅니다
  4. 정리

이번 시간에는 컨테이너에 건 CPU limit이 응답 시간을 어떻게 늘리는지, Docker에서 limit을 바꿔 가며 같은 부하의 응답 시간과 cgroup(리눅스 커널이 프로세스 묶음의 CPU·메모리 사용을 제한하고 세는 기능)의 cpu.stat 값을 비교하며 배워가는 시간입니다. limit별 응답 시간과 throttling, 그리고 --cpus와 --cpuset-cpus의 차이를 차례로 볼게요.

CPU limit은 짧은 구간(period)마다 쓸 수 있는 CPU 시간(quota)을 정해 두고, 그 시간을 다 쓰면 구간이 끝날 때까지 컨테이너의 프로세스를 멈춥니다. 그래서 평균 사용량이 낮아도 한 구간 안에서 많이 쓰면 멈추게 돼요.

응답 시간은 같은 노트북에서 도는 다른 작업에 따라 흔들립니다. 그래서 여기서는 설정마다 60초 부하를 3번씩 보내 범위로 적고, 판단은 cgroup이 직접 세는 카운터를 근거로 하는 방식으로 가볼게요.

실습 준비

이 글의 명령은 모두 실습 코드의 container-cpu-throttling/ 디렉터리에서 실행합니다.

  • java/BurnServer.java: GET /burn?ms=N마다 정해진 반복 횟수만큼 계산하고 cpu_ms·wall_ms를 돌려주는 HTTP 서버(스레드 8개)
  • compose.yaml: CPU 설정을 환경 변수로 받는 server와 부하 클라이언트 client 서비스
  • load.py: 묶음 단위로 요청을 응답과 상관없이 정해진 시간에 시작하는 부하 클라이언트
  • lab1.sh, lab3.sh: 실습 1, 2의 설정을 세 번씩 돌리는 스크립트(lib.sh의 셸 함수를 불러 씀)

그 밖의 파일은 데이터·보조 스크립트입니다.

서버는 Java의 내장 HTTP 서버에 스레드 8개짜리 풀을 붙여서 만들었어요. GET /burn?ms=20은 limit이 없을 때 약 20밀리초가 걸리는 계산을 하고 응답합니다.

여기서 계산 양은 시간이 아니라 반복 횟수로 정해요. 왜 그럴까요? 시간으로 정하면 멈춰 있던 시간까지 계산 시간으로 세어 버리기 때문입니다.

응답에는 이 요청이 실제로 쓴 CPU 시간(cpu_ms)과 처리에 걸린 시간(wall_ms)을 넣었어요. 1밀리초에 해당하는 반복 횟수는 환경 변수 ITERS_PER_MS로 받습니다.

호스트의 JDK 17 이상으로 컴파일해서 java 디렉터리에 둘게요.

javac --release 17 -d java java/BurnServer.java

Java 런타임은 이미 받아 둔 apache/kafka:4.3.1 이미지에 들어 있는 Temurin 21.0.11을 빌려 씁니다. Java만 따로 받고 싶으시면 image를 eclipse-temurin:21-jre-alpine으로 바꿔 주시면 됩니다.

compose.yaml은 CPU 설정을 환경 변수로 받아요. 값을 주지 않으면 그 설정은 빠집니다. cpu_period, cpu_quota는 이 글에서는 쓰지 않아요.

services:
  server:
    image: apache/kafka:4.3.1
    entrypoint: ["java", "-cp", "/app", "BurnServer"]
    environment:
      ITERS_PER_MS: ${ITERS_PER_MS:-360000}
    volumes:
      - ./java:/app:ro
    ports:
      - "7200:8080"
    cpus: ${CPUS:-0}
    cpuset: ${CPUSET:-}
    cpu_period: ${CPU_PERIOD:-0}
    cpu_quota: ${CPU_QUOTA:-0}
  client:
    image: python:3.12-slim
    profiles: ["client"]
    volumes:
      - ./load.py:/load.py:ro

부하 클라이언트는 같은 compose 네트워크에서 도는 Python 컨테이너예요. 응답을 기다렸다가 다음 요청을 보내면 서버가 느려질수록 요청도 덜 보내게 되어 지연이 덜 보이니, 응답과 상관없이 정해진 시간에 요청을 시작해요. 이런 방식을 열린 부하(open loop)라고 부릅니다.

python load.py <URL> <묶음 크기> <초> [간격초]로 실행하면 간격(기본 1초)마다 묶음 크기만큼 요청을 동시에 시작해요. 끝나면 p50·p99·최대 응답 시간과 시작 지연 최댓값을 한 줄로 찍고, 묶음 크기가 2 이상이면 묶음 안 순서별 중앙값을 둘째 줄에 찍습니다.

p99 응답 시간은 요청을 응답 시간 순으로 늘어놓았을 때 99% 지점의 값이에요. 둘째 줄은 한 묶음 8건을 응답이 빠른 순서로 놓고, 순서마다 60개 묶음의 중앙값을 낸 거예요.

COMPOSE_PROJECT_NAME으로 compose 프로젝트 이름을 cputh로 정해 두기 때문에, 아래 명령은 모두 이 프로젝트 안에서만 컨테이너를 만들고 지웁니다.

아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.

export COMPOSE_PROJECT_NAME=cputh
export ITERS_PER_MS=360000

server() {
  env "$@" docker compose up -d --force-recreate --wait server > /dev/null 2>&1
  docker exec cputh-server-1 cat /sys/fs/cgroup/cpu.max
}

load() { docker compose run --rm client python /load.py "$@"; }

cpustat() { docker exec cputh-server-1 cat /sys/fs/cgroup/cpu.stat; }

measure() {
  load "http://server:8080/burn?ms=20" 8 10 > /dev/null
  cpustat > before.txt
  load "http://server:8080/burn?ms=20" 8 60 "${1:-1}" &
  sleep 30
  docker stats --no-stream --format 'docker stats CPU {{.CPUPerc}}' cputh-server-1
  wait
  cpustat > after.txt
  awk 'NR == FNR { b[$1] = $2; next } { d[$1] = $2 - b[$1] }
    END {
      ratio = d["nr_periods"] ? d["nr_throttled"] / d["nr_periods"] * 100 : 0
      printf "nr_periods %d  nr_throttled %d (%.0f%%)  throttled_usec %d  평균 CPU %.2f코어\n",
        d["nr_periods"], d["nr_throttled"], ratio, d["throttled_usec"], d["usage_usec"] / 60e6
    }' before.txt after.txt
}

server는 서버를 주어진 CPU 설정으로 다시 띄워요. server CPUS=1, server CPUSET=0, server(제한 없음)처럼 씁니다. load는 부하 클라이언트를 돌리고, 인자는 load.py에 그대로 넘겨요. measure는 JIT 워밍업 뒤 60초 부하를 보내고, 그동안 늘어난 cpu.stat 값을 출력합니다.

measure가 출력하는 평균 CPU는 cpu.stat의 usage_usec(컨테이너가 쓴 CPU 시간 누계, 마이크로초) 증가량을 부하를 보낸 60초로 나눈 값이에요. 부하 도중 30초 시점의 docker stats 값도 함께 찍습니다.

cgroup 파일 확인

먼저 --cpus 1로 서버를 띄우고 cpu.max와 cpu.stat을 읽어 볼게요.

server CPUS=1
cpustat
100000 100000
usage_usec 125226
user_usec 99137
system_usec 26088
nr_periods 6
nr_throttled 0
throttled_usec 0
nr_bursts 0
burst_usec 0

출력을 보면 cpu.max가 100000 100000, 곧 100밀리초마다 100밀리초입니다. limit 없이 띄우면 quota 자리가 max가 돼요.

nr_periods는 지나간 period 수, nr_throttled는 그중 quota를 다 써서 멈춘 period 수, throttled_usec은 멈춘 시간을 더한 값(마이크로초)이에요. 이렇게 멈추는 것을 throttling이라고 부릅니다.

반복 횟수 맞추기

limit 없이 요청을 하나씩 0.1초 간격으로 보내서, 20밀리초 근처가 되는 반복 횟수를 찾아볼게요. 1밀리초에 360000번일 때 서버 안에서 잰 CPU 시간이 20밀리초 남짓이었어요. 워밍업으로 한 번 돌린 뒤 잰 값입니다.

server
load "http://server:8080/burn?ms=20" 1 20 0.1
for i in 1 2 3; do curl -s "http://localhost:7200/burn?ms=20"; done
max 100000
요청 200건  p50 25.9 ms  p99 30.0 ms  최대 52.0 ms  (시작 지연 최대 9.6 ms)
cpu_ms=20.8 wall_ms=20.8 x=-994806788162906761
cpu_ms=20.1 wall_ms=20.1 x=-994806788162906761
cpu_ms=20.1 wall_ms=20.1 x=-994806788162906761

반복 횟수는 노트북마다 다르니, 직접 맞춰서 ITERS_PER_MS에 넣어 주세요.

실습 1: limit별 p99와 throttling

부하는 1초마다 /burn?ms=20 8건을 동시에 보내는 묶음이에요. 묶음 하나가 CPU 시간 약 160밀리초를 요구하고, 1초에 한 번이므로 평균으로는 0.16코어 남짓입니다.

이 부하를 limit 없음, --cpus 4, 2, 1, 0.5에서 60초씩 보냈어요. 설정 순서대로 한 바퀴 돌고, 이를 세 번 반복했습니다. 아래는 limit 없음과 --cpus 1의 한 회차예요.

server
measure
max 100000
docker stats CPU 18.24%
요청 480건  p50 30.7 ms  p99 52.2 ms  최대 56.4 ms  (시작 지연 최대 16.3 ms)
묶음 안 순서별 중앙값(ms): 24 25 26 27 32 33 34 37
nr_periods 0  nr_throttled 0 (0%)  throttled_usec 0  평균 CPU 0.18코어
server CPUS=1
measure
100000 100000
docker stats CPU 21.90%
요청 480건  p50 89.5 ms  p99 285.4 ms  최대 373.5 ms  (시작 지연 최대 31.1 ms)
묶음 안 순서별 중앙값(ms): 83 84 86 87 90 93 94 96
nr_periods 277  nr_throttled 77 (28%)  throttled_usec 30032556  평균 CPU 0.20코어

다섯 설정을 세 번씩 돌린 결과를 모아 볼게요. 세 바퀴는 ./lab1.sh가 설정마다 server와 measure를 차례로 불러 돌리고, 전체 출력은 싣지 않았어요. 시작 지연은 모든 회차에서 60밀리초 아래였습니다.

  • limit 없음: p50 29.3~30.7ms, p99 47.1~65.7ms, nr_throttled 0, 평균 0.18~0.19코어
  • --cpus 4: p50 29.3~30.1ms, p99 48.2~136.5ms, nr_throttled 0~1(0%), 평균 0.18~0.19코어
  • --cpus 2: p50 29.3~30.6ms, p99 43.5~45.3ms, nr_throttled 0~2(0~1%), 평균 0.18코어
  • --cpus 1: p50 52.3~110.8ms, p99 210.4~285.4ms, nr_throttled 비율 28~31%, throttled_usec 13.7~38.4초, 평균 0.20코어
  • --cpus 0.5: p50 227.2~304.3ms, p99 394.2~412.7ms, nr_throttled 비율 58~60%, throttled_usec 82.7~129.8초, 평균 0.21~0.23코어

이 결과는 quota 계산으로 설명할 수 있어요. --cpus 2의 quota는 period마다 200밀리초라서 묶음 하나(160밀리초)가 한 period 안에 들어갑니다. --cpus 1은 100밀리초라 묶음 하나가 quota를 넘고, --cpus 0.5는 50밀리초라 묶음 하나를 처리하는 데 period 네 개가 필요해요.

--cpus 1에서는 8건이 83~96밀리초에 거의 함께 끝났어요. 8개 스레드가 함께 멈췄다가 다음 period에 함께 끝난 겁니다.

--cpus 1 결과만 떼어서 보면 docker stats CPU는 18.0~23.8%(코어 하나를 100%로 셈), usage_usec로 계산한 평균은 0.20코어로 limit의 5분의 1인데, nr_throttled 비율은 28~31%, p99는 limit 없음의 4배 이상이에요.

평균은 1초나 몇 초 단위로 낸 값이고, throttling은 100밀리초 period 안에서 일어납니다. 그래서 1초 중 대부분은 쉬고 있어도, 묶음이 몰린 100밀리초 안에서 quota를 넘으면 멈춰요.

실습 2: --cpus와 --cpuset-cpus는 다릅니다

--cpus 1과 --cpuset-cpus 0은 둘 다 "CPU 하나"처럼 보여요.

그런데 앞은 CPU 8개를 다 쓰되 시간을 자르고, 뒤는 0번 CPU 하나만 쓰되 시간은 자르지 않습니다. 같은 부하를 번갈아 세 번씩 보내 볼게요.

server CPUSET=0
measure
max 100000
docker stats CPU 16.93%
요청 480건  p50 165.7 ms  p99 273.2 ms  최대 285.6 ms  (시작 지연 최대 18.9 ms)
묶음 안 순서별 중앙값(ms): 152 154 160 163 167 169 169 171
nr_periods 0  nr_throttled 0 (0%)  throttled_usec 0  평균 CPU 0.18코어

세 번 번갈아 돌린 범위는 이렇습니다. ./lab3.sh가 두 설정을 번갈아 돌리고(period를 바꾼 설정 두 개도 함께 돌지만 이 글에서는 쓰지 않아요), 전체 출력은 싣지 않았어요.

  • --cpus 1: p50 31.3~44.8ms, p99 142.3~245.9ms, nr_throttled 비율 22~28%
  • --cpuset-cpus 0: p50 165.6~166.2ms, p99 189.9~273.2ms, nr_periods와 nr_throttled 모두 0

--cpuset-cpus 0은 quota가 없으므로(cpu.max가 max) throttling이 한 번도 없었어요.

그런데 p50은 165밀리초로 --cpus 1보다 훨씬 깁니다. 8건의 계산 160밀리초를 CPU 하나가 모두 처리해야 하기 때문이에요. 순서별 중앙값을 보면 가장 빠른 요청도 152밀리초였습니다.

이 부하에서는 --cpus 1이 더 빨랐어요. 묶음 하나의 160밀리초 가운데 100밀리초를 CPU 8개로 12.5밀리초 만에 끝내고, 나머지만 다음 period로 넘기기 때문입니다. 그러니 "--cpus 1이면 코어 하나에 묶인다"는 말은 틀렸습니다.

정리

범위: Docker Desktop(Docker Engine 27.5.1, VM CPU 8개, cgroup v2), Temurin 21.0.11, 1초마다 /burn?ms=20 8건 묶음을 설정마다 60초씩 3회.

  • 평균 0.20코어(docker stats 18.0~23.8%)인 부하가 --cpus 1에서 nr_throttled 비율 28~31%, p99 210~285ms였고, limit 없음은 p99 47~66ms였습니다. CPU limit을 건 서비스는 평균 사용률만 보지 말고 cpu.stat의 nr_throttled/nr_periods 비율을 함께 봐 주세요.
  • 묶음 하나 160ms가 period당 quota 200ms 안에 들어가는 --cpus 2에서는 throttling이 0~1%, p99가 43.5~45.3ms였습니다. limit은 평균 사용량이 아니라 한 번에 몰리는 CPU 요구량이 quota 안에 들어가게 잡으세요.
  • --cpuset-cpus 0은 throttling이 0번이었지만 8건이 CPU 하나를 나눠 써서 p50이 165ms로 --cpus 1보다 길었습니다. --cpus 1을 코어 하나에 묶는 설정으로 보지 마세요.
내 링크 만들기