평균 CPU는 낮은데 왜 느릴까: cgroup v2 cpu.max 스로틀링 진단

cgroup v2의 cpu.max는 주기마다 CPU 시간 한도를 겁니다. 평균 사용률이 한도보다 낮아도 멀티스레드 작업이 주기 초반에 할당량을 다 쓰면 멈춥니다. cpu.stat으로 읽기 전용 진단하는 방법을 정리합니다.

linuxcgroupcpuschedulerperformance

컨테이너에 CPU 1코어 한도를 걸었고 대시보드의 평균 사용률은 그보다 한참 낮은데, 요청 지연만 가끔 크게 튀는 경우가 있습니다. 이런 상황에서 확인해 볼 만한 것이 cgroup CPU 대역폭 제어(bandwidth control)의 스로틀링입니다.

이 글은 Linux 커널 문서 7.3.0판의 Control Group v2와 CFS Bandwidth Control을 기준으로, cpu.max가 무엇을 제한하는지, 평균이 낮아도 왜 멈출 수 있는지, cpu.stat을 읽어 그것을 어떻게 확인하는지 다룹니다. 진단 부분은 파일을 읽기만 하며 설정을 바꾸지 않습니다.

cpu.max는 “주기당 CPU 시간”을 제한한다

cgroup v2의 cpu.max는 루트가 아닌 cgroup에 있는 읽기·쓰기 파일이고, 형식은 다음과 같습니다.

$MAX $PERIOD

그룹이 $PERIOD 동안 최대 $MAX만큼 CPU 시간을 쓸 수 있다는 뜻이며, 단위는 마이크로초입니다. 기본값은 max 100000이고, $MAX 자리의 max는 한도가 없다는 뜻입니다. 숫자를 하나만 쓰면 $MAX만 바뀝니다.

중요한 점은 $MAX가 벽시계 시간이 아니라 그룹 안 모든 스레드가 모든 CPU에서 쓴 CPU 시간의 합이라는 것입니다. sched-bwc 문서의 예시도 이렇게 설명합니다. 주기 500ms에 할당량 1000ms를 주면 “2 CPU 분량”이 되고, 주기 50ms에 할당량 10ms를 주면 CPU 1개의 20%가 됩니다. 그래서 cpu.max가 100000 100000이면 흔히 말하는 “1코어 한도”입니다.

sched-bwc 문서에 따르면 할당량은 그룹의 스레드가 실행 가능해질 때 CPU별 런큐로 조금씩(slice) 넘어가고, 전체 할당량이 다 배정된 뒤 더 요청하는 스레드는 스로틀링됩니다. 스로틀링된 스레드는 할당량이 다시 채워지는 다음 주기까지 실행되지 않습니다.

평균이 낮아도 멈추는 이유

위 정의에서 바로 따라 나오는 계산이 있습니다. cpu.max가 50000 100000(0.5 CPU)인 그룹에서 스레드 8개가 CPU 8개에서 동시에 일한다고 해 봅시다. 이상적으로 계산하면 50ms의 할당량은 벽시계로 약 6.25ms 만에 소진되고, 그 주기의 나머지 약 93.75ms 동안 그룹의 스레드는 기다립니다.

이 그룹의 평균 사용률은 여전히 0.5 CPU 이하입니다. 하지만 그 대기 구간에 들어온 요청은 다음 주기까지 처리되지 못합니다. 초 단위로 평균을 내는 대시보드에서는 이런 주기 안의 정지가 보이지 않습니다. 위 숫자는 정의를 그대로 적용한 단순 계산이고, 실제로는 아래에서 설명하는 slice 분배 때문에 시점이 조금 달라질 수 있습니다.

한도는 평균이 아니라 주기마다 적용되고, 동시에 실행되는 스레드가 많을수록 할당량은 벽시계 기준으로 더 빨리 소진됩니다. CPU 시간과 벽시계 시간의 차이가 익숙하지 않다면 real, user, sys 시간 글이 도움이 됩니다.

스로틀링이 걸리는 두 가지 경로

sched-bwc 문서는 그룹이 스로틀링되는 경우를 두 가지로 적고 있습니다.

  1. 그룹이 한 주기 안에 자기 할당량을 다 쓴 경우
  2. 부모의 할당량이 그 주기 안에 다 소진된 경우

두 번째 경우에는 자식에게 할당량이 남아 있어도 부모의 할당량이 채워질 때까지 실행되지 않습니다. 또 계층 안에서 자식들의 한도 합이 부모 한도를 넘는 것은 명시적으로 허용됩니다. 그래서 자식 하나의 cpu.max만 보고 “여유가 있다”고 판단하면 놓치는 경우가 생깁니다.

cpu.stat에서 볼 필드

cpu.stat은 읽기 전용 flat-keyed 파일이고, cpu 컨트롤러가 켜져 있지 않아도 존재합니다. 다만 들어 있는 필드가 달라집니다.

필드 언제 보이나 무엇을 세나
usage_usec, user_usec, system_usec 항상 하위 cgroup을 포함한 모든 프로세스의 CPU 시간
nr_periods 컨트롤러 활성 시 지나간 집행 주기 수
nr_throttled 컨트롤러 활성 시 그룹이 스로틀링된 횟수
throttled_usec 컨트롤러 활성 시 그룹의 엔티티가 스로틀링된 총 시간
nr_bursts, burst_usec 컨트롤러 활성 시 burst가 일어난 주기 수와 할당량을 넘겨 쓴 누적 시간

읽을 때 주의할 점이 몇 가지 있습니다.

  • 아래쪽 다섯 개 대역폭 통계는 fair-class 스케줄러에서 도는 프로세스만 셉니다. 반면 usage_usec 등 위쪽 세 개는 모든 프로세스를 셉니다.
  • 대역폭 통계 다섯 개는 계층적이지 않습니다. 이 cgroup 자신의 한도 때문에 생긴 스로틀링만 세고, 조상 cgroup의 한도 때문에 생긴 스로틀링은 포함하지 않습니다.
  • 조상에게서 물려받은 스로틀링까지 포함해 보려면 cpu.stat.local의 throttled_usec를 봅니다. 7.3.0 문서는 이 값이 그 cgroup 자신의 런큐가 실제로 겪은 스로틀링 시간이며 조상 한도에서 물려받은 스로틀링을 포함할 수 있다고 설명합니다. 이 글에서는 이를 로컬 런큐 스로틀링 시간이라고 부릅니다. 값이 늘었다면 이 cgroup의 로컬 런큐가 스로틀링을 겪었다는 뜻이지만, 런큐 단위로 쌓인 시간이므로 요청 지연을 직접 재는 값도, 한 번의 벽시계 기준 정지 길이도 아닙니다. 이 파일이 없는 커널도 있을 수 있으니 존재 여부부터 확인하세요.
  • cgroup v1의 sched-bwc 통계는 throttled_time을 나노초로 보고하지만, cgroup v2 문서는 cpu 인터페이스 파일의 모든 시간이 마이크로초라고 명시합니다. 두 버전의 숫자를 섞어 비교하면 단위가 1000배 어긋납니다.

nr_throttled는 누적값이라 그 자체로는 판단하기 어렵습니다. 일정 시간 간격으로 두 번 읽어 차이를 보는 편이 낫습니다. sched-bwc 문서는 CPU를 계속 쓰는 작업이 한도에 묶여 있으면 nr_periods와 nr_throttled가 대략 같아질 것으로 예상한다고 적고 있습니다. 반대로 대부분 쉬다가 가끔 몰리는 서비스라면 두 값의 차이가 작아도 throttled_usec가 꾸준히 늘어나는지를 봐야 합니다.

읽기 전용 진단 스크립트

아래 스크립트는 cgroup 파일을 읽기만 합니다. 지정한 PID가 속한 cgroup v2 경로를 /proc/<PID>/cgroup의 0:: 줄에서 찾고, 마운트 루트까지 포함해 조상을 거슬러 올라가며 cpu.max를 출력한 다음, 일정 시간 동안의 cpu.stat 변화량을 계산합니다. 간격이 양의 정수가 아니거나, /proc/<PID>/cgroup을 읽을 수 없거나, 절대 경로인 0:: 줄이 정확히 하나가 아니거나, CG_ROOT나 cgroup 경로가 아래에서 설명할 경계 검사를 통과하지 못하거나, 그 경로의 cpu.stat을 읽을 수 없거나, 측정 구간 동안 cgroup 디렉터리가 사라졌거나 다른 디렉터리로 바뀌었거나, 읽은 값이 비어 있거나 숫자가 아니거나, 필수 카운터가 두 번째 읽기에서 오히려 줄었으면 추측하지 않고 오류로 멈춥니다. 카운터가 줄었다는 것은 cgroup이 초기화되었거나 다시 만들어졌다는 뜻이므로 그 구간은 쓸 수 없습니다.

디렉터리가 바뀌었는지는 첫 번째 읽기 전과 두 번째 읽기 후에 ls -di가 보여 주는 cgroup 디렉터리의 inode 번호를 비교해 확인합니다. -d와 -i는 표준 ls 옵션이지만, inode 번호를 cgroup 디렉터리의 식별자로 쓰는 것은 Linux cgroupfs에 기댄 가정입니다. 다시 만든 디렉터리는 다른 inode 번호를 받는다는 전제인데, 이 글이 인용한 커널 문서는 이 부분을 다루지 않습니다. 그래서 스크립트는 카운터가 줄어든 경우도 따로 거부합니다.

cgroup2 파일시스템이 /sys/fs/cgroup에 마운트되어 있다고 가정합니다. 환경마다 다를 수 있으니 findmnt -t cgroup2로 먼저 확인하고, 다르면 CG_ROOT를 절대 경로로 지정해 실행하세요.

스크립트는 두 경로를 주어진 그대로 믿지 않습니다. CG_ROOT는 파일시스템을 읽기만 하는 cd -P와 pwd -P로 실제 경로를 구한 뒤, 그 결과가 /가 아닌 기존 디렉터리이고 cgroup.controllers가 있을 때만 진행합니다. cgroup v2 문서에 따르면 이 파일은 모든 cgroup에 있습니다. 0:: 경로에 .이나 .. 구성 요소가 있으면 거부하고, cgroup 디렉터리도 같은 방식으로 실제 경로를 구해 존재하지 않거나 CG_ROOT 자신 또는 그 아래가 아니면 멈춥니다. CG_ROOT 밖의 cgroup 파일은 읽지 않습니다.

자체 cgroup 네임스페이스를 가진 컨테이너 안에서는 /proc/<PID>/cgroup이 cgroupns 루트 기준의 경로를 보여 주므로, PID의 cgroup이 0::/로 보일 수 있습니다. 그 네임스페이스 안에서 마운트한 cgroup2는 cgroupns 루트를 파일시스템 루트로 삼습니다. cgroupns 루트는 네임스페이스를 만든 시점의 cgroup이므로 보통 호스트의 루트 cgroup이 아니고, 자기 cpu.max를 가질 수 있습니다. 그래서 스크립트는 CG_ROOT 자체의 cpu.max도 읽을 수 있으면 출력하지만, 그 위로는 올라가지 않습니다. 네임스페이스 루트보다 위에 있는 호스트 cgroup의 한도는 안에서 보이지 않습니다.

#!/bin/sh
# 사용법: sh cpu-throttle.sh [PID] [초]
pid=${1:-self}
secs=${2:-10}
root=${CG_ROOT:-/sys/fs/cgroup}

die() { echo "오류: $*" >&2; exit 1; }
isnum() { case $1 in ''|*[!0-9]*) return 1 ;; esac; return 0; }
field() { awk -v k="$2" '$1 == k { print $2 }' "$1" 2>/dev/null; }
dirid() { ls -di "$1" 2>/dev/null | awk 'NR == 1 { print $1 }'; }
realdir() { (cd -P -- "$1" 2>/dev/null && pwd -P); }

# 0으로 시작하지 않는 양의 정수만 받는다(셸 산술은 010을 8진수로 읽는다).
case $secs in
  ''|0*|*[!0-9]*) die "초는 양의 정수여야 합니다: $secs" ;;
esac

# CG_ROOT: 절대 경로이고, 실제 경로로 풀었을 때 /가 아닌 기존 cgroup v2 디렉터리여야 한다.
case $root in
  /*) ;;
  *) die "CG_ROOT는 절대 경로여야 합니다: $root" ;;
esac
r=$(realdir "$root") || die "CG_ROOT가 존재하는 디렉터리가 아닙니다: $root"
[ "$r" != / ] || die "CG_ROOT는 /일 수 없습니다"
[ -e "$r/cgroup.controllers" ] || die "CG_ROOT가 cgroup v2 디렉터리가 아닙니다: $root"
root=$r

# 비어 있지 않은 절대 경로의 cgroup v2 경로가 정확히 하나여야 하고, . 이나 .. 구성 요소가 없어야 한다.
f=/proc/$pid/cgroup
[ -r "$f" ] || die "$f 를 읽을 수 없습니다"
rel=$(awk 'index($0, "0::") == 1 { n++; p = substr($0, 4) } END { if (n == 1) print p }' "$f")
case $rel in
  /*) ;;
  *) die "$f 에서 cgroup v2 경로(0::/...)를 하나로 특정할 수 없습니다" ;;
esac
case $rel/ in
  */./*|*/../*) die "cgroup 경로에 . 이나 .. 가 있습니다: $rel" ;;
esac
cg=$(realdir "$root$rel") || die "cgroup 디렉터리가 존재하지 않습니다: $root$rel"
# 실제 경로로 푼 cgroup은 CG_ROOT 자신이거나 그 아래여야 한다.
case $cg in
  "$root"|"$root"/*) ;;
  *) die "cgroup $cg 가 CG_ROOT $root 밖에 있습니다" ;;
esac
[ -r "$cg/cpu.stat" ] || die "$cg/cpu.stat 를 읽을 수 없습니다"
echo "cgroup: $cg"

# 디렉터리 식별자(inode 번호). 두 번째 읽기 뒤에 다시 비교한다.
id1=$(dirid "$cg")
isnum "$id1" || die "$cg 의 inode 번호를 읽을 수 없습니다"

# 자신과 조상의 한도. CG_ROOT까지 포함하고, 그 위로는 올라가지 않는다.
# 호스트의 루트 cgroup에는 cpu.max가 없지만, CG_ROOT에 마운트된 cgroup 네임스페이스 루트에는 있을 수 있다.
d=$cg
while :; do
  [ -r "$d/cpu.max" ] && echo "  $(cat "$d/cpu.max")  burst=$(cat "$d/cpu.max.burst" 2>/dev/null)  <- $d"
  if [ "$d" = "$root" ] || [ "$d" = "/" ]; then break; fi
  d=$(dirname "$d")
done

p1=$(field "$cg/cpu.stat" nr_periods)
if [ -z "$p1" ]; then
  if [ "$cg" = "$root" ] && [ ! -e "$cg/cpu.max" ]; then
    echo "nr_periods가 없음: CG_ROOT에 cpu.max가 없으므로(호스트의 루트 cgroup이거나 cpu 컨트롤러가 꺼진 네임스페이스 루트) 여기서 진단할 대역폭 한도가 없습니다."
  else
    echo "nr_periods가 없음: 이 cgroup에는 cpu 컨트롤러가 켜져 있지 않습니다."
  fi
  exit 0
fi
t1=$(field "$cg/cpu.stat" nr_throttled)
u1=$(field "$cg/cpu.stat" throttled_usec)
c1=$(field "$cg/cpu.stat" usage_usec)
l1=$(field "$cg/cpu.stat.local" throttled_usec)

sleep "$secs"

p2=$(field "$cg/cpu.stat" nr_periods)
t2=$(field "$cg/cpu.stat" nr_throttled)
u2=$(field "$cg/cpu.stat" throttled_usec)
c2=$(field "$cg/cpu.stat" usage_usec)
l2=$(field "$cg/cpu.stat.local" throttled_usec)

id2=$(dirid "$cg")
[ "$id2" = "$id1" ] || die "측정 중에 $cg 가 사라졌거나 다른 디렉터리로 바뀌었습니다 (inode $id1 -> ${id2:-없음})"

for v in "$p1" "$p2" "$t1" "$t2" "$u1" "$u2" "$c1" "$c2"; do
  isnum "$v" || die "$cg/cpu.stat 값이 없거나 숫자가 아닙니다 (cgroup이 사라졌을 수 있음)"
done

# 카운터가 줄었다면 초기화나 재생성이 일어난 것이므로 이 구간은 무효다.
if ! { [ "$p2" -ge "$p1" ] && [ "$t2" -ge "$t1" ] && [ "$u2" -ge "$u1" ] && [ "$c2" -ge "$c1" ]; }; then
  die "cpu.stat 카운터가 줄었습니다 (cgroup 초기화 또는 재생성 가능성). 이 구간은 버립니다"
fi

echo "${secs}초 동안:"
echo "  주기 $((p2 - p1))개 중 스로틀링 $((t2 - t1))번"
echo "  스로틀링 시간 $(((u2 - u1) / 1000)) ms (이 cgroup 자신의 한도 기준)"
if isnum "$l1" && isnum "$l2" && [ "$l2" -ge "$l1" ]; then
  echo "  로컬 런큐 스로틀링 시간 $(((l2 - l1) / 1000)) ms (cpu.stat.local, 조상 포함 가능, 벽시계 기준 정지 시간 아님)"
elif isnum "$l1" && isnum "$l2"; then
  echo "  로컬 런큐 스로틀링 시간: 알 수 없음 (cpu.stat.local throttled_usec가 줄었음, 초기화 또는 재생성)"
else
  echo "  로컬 런큐 스로틀링 시간: 알 수 없음 (두 번 모두 숫자인 cpu.stat.local throttled_usec가 없음)"
fi
echo "  CPU 사용 $(((c2 - c1) / 1000)) ms = 평균 $(((c2 - c1) / secs / 10000))% CPU"

마지막 줄의 퍼센트는 CPU 1개를 100%로 본 값이고, 정수 나눗셈이라 소수점 아래는 버려집니다. 출력은 이렇게 읽으면 됩니다.

  • 평균 사용률이 한도보다 한참 낮은데 스로틀링 횟수가 꾸준히 늘면, 구간 평균은 낮아 보여도 일부 주기에서 이 cgroup 자신의 한도 때문에 스로틀링이 일어나고 있다는 뜻입니다. 다만 카운터에는 주기 안의 시각 정보가 없어서, 매 주기 초반에 할당량이 소진된 것인지, 일부 주기의 후반에 소진되고 이후 한가했던 것인지는 구분할 수 없습니다. 주기 초반 소진은 가설로 두고, 더 세밀한 트레이싱이나 요청·작업 부하 시점과의 대조로 확인하세요.
  • 자신의 throttled_usec는 거의 늘지 않는데 cpu.stat.local의 로컬 런큐 스로틀링 시간이 늘면, 조상 cgroup의 한도를 의심합니다. 위에서 출력한 조상 cpu.max 목록과 함께 보세요. 이 값은 로컬 런큐가 스로틀링을 겪었다는 뜻이지 요청 지연 그 자체는 아닙니다.
  • nr_periods 줄이 없다면 대역폭 통계가 없는 것입니다. 루트가 아닌 cgroup이라면 cpu 컨트롤러가 켜져 있지 않다는 뜻입니다. PID가 CG_ROOT에 있고 그곳에 cpu.max가 없으면 스크립트는 그 사실을 대신 알려 줍니다. cpu.max가 없는 호스트의 루트 cgroup이거나 cpu 컨트롤러가 꺼진 네임스페이스 루트인 경우로, 어느 쪽이든 이 진단으로 잴 대역폭 한도 자체가 없습니다.

CPU를 기다리는 시간 전반은 같은 디렉터리의 cpu.pressure에서도 볼 수 있습니다. 이 파일은 cgroup의 모든 프로세스를 기준으로 계산합니다. 읽는 법은 PSI 글에 정리해 두었습니다.

고칠 때 선택지와 그 대가

설정 변경은 진단과 달리 운영 중인 작업에 바로 영향을 줍니다. 그 cgroup을 만든 도구(컨테이너 런타임, 서비스 관리자 등)가 있다면 그 도구의 설정을 통해 바꾸는 편이 안전합니다. 아래는 커널 문서가 설명하는 동작에서 따라 나오는 선택지입니다.

할당량을 늘린다. 가장 단순합니다. 다만 위의 계산에서 보듯 동시에 도는 스레드가 많으면, 평균 사용률에 맞춘 할당량으로는 주기 안의 정지가 남을 수 있습니다.

주기를 바꾼다. sched-bwc 문서의 예시는 주기를 길게 잡으면 burst 여유가 커지고, 짧게 잡으면 burst 여유를 잃는 대신 지연 응답이 더 일정해진다고 설명합니다. 같은 비율이라도 50000 100000과 5000 10000은 동작이 다릅니다. 같은 문서의 v1 인터페이스 설명은 할당량과 주기의 최솟값을 1ms, 주기의 최댓값을 1s로 적고 있습니다.

cpu.max.burst를 쓴다. 기본값은 0이고, 범위는 0부터 $MAX까지입니다. sched-bwc 문서는 burst를 쓰지 않고 남긴 대역폭을 설정값까지 누적해 두었다가 할당량을 넘겨 쓰게 하는 기능으로 설명하고, 0이면 누적하지 않는 기존 동작 그대로라고 적습니다. 그 대가로 시스템의 다른 사용자에게 주는 간섭이 커진다는 점도 밝힙니다. 실제로 burst가 일어났는지는 cpu.stat의 nr_bursts, burst_usec로 확인합니다.

상한 대신 가중치를 쓴다. cpu.weight(기본값 100, 범위 1~10000)는 활성 상태인 형제 cgroup끼리 CPU를 비율대로 나눕니다. 지금 CPU를 쓸 수 있는 자식만 분배에 참여하는 work-conserving 방식이라, 다른 형제가 쓰지 않는 몫은 가져다 쓸 수 있습니다. 경쟁이 있을 때의 비율만 정하고 싶고 절대 상한이 꼭 필요하지 않다면 이쪽이 더 맞을 수 있습니다.

slice와 코어가 많은 머신

sched-bwc 문서에 따르면 커널은 전역 할당량을 CPU별 “silo”로 slice 단위로 옮깁니다. slice 크기는 /proc/sys/kernel/sched_cfs_bandwidth_slice_us이고 기본값은 5ms입니다. 크게 잡으면 옮기는 비용이 줄고, 작게 잡으면 더 잘게 소비할 수 있습니다.

CPU에 배정된 slice는 만료되지 않습니다. 그 CPU의 스레드가 모두 실행 불가능해지면 1ms만 남기고 나머지는 전역 풀로 돌아갈 수 있습니다. 1ms는 컴파일 시점 값인 min_cfs_rq_runtime으로 정해집니다. 그래서 스레드가 많고 CPU를 계속 쓰지는 않는 작업은 CPU마다 남아 있던 slice만큼, 보통 CPU당 최대 1ms 정도 할당량을 잠깐 넘길 수 있습니다. 문서는 그래도 더 긴 시간 창으로 보면 평균 사용량은 할당량으로 엄격히 제한된다고 설명합니다. cpu.stat의 수치가 정의대로 딱 맞아떨어지지 않을 때 이 동작을 떠올리면 됩니다.

범위와 한계

  • cgroup v2 문서는 cpu 컨트롤러가 아직 실시간 프로세스의 대역폭 제어를 지원하지 않는다고 경고합니다. 이 글의 cpu.max 동작은 fair-class 스케줄러에서 도는 프로세스(그리고 해당 콜백을 구현한 BPF 스케줄러)에 해당합니다. SCHED_FIFO, SCHED_RR, SCHED_DEADLINE 프로세스는 다르게 다뤄집니다.
  • 문서에 따르면 CPU 분배는 시간 기준으로만 정의되며 CPU가 어떤 주파수로 돌았는지는 고려하지 않습니다. 같은 할당량으로 할 수 있는 일의 양은 주파수에 따라 달라질 수 있습니다.
  • 이 글은 커널 문서 7.3.0판(2026-10-22 확인)을 기준으로 합니다. cpu.stat.local 같은 파일은 오래된 커널에 없을 수 있습니다. 실행 중인 커널의 문서와 실제 파일 목록을 함께 확인하세요.
  • 본문의 0.5 CPU, 8스레드 예시는 정의를 적용한 계산이지 측정값이 아닙니다.

출처

블로그로