[TIL] cgroup과 스레드

2026. 06. 24.Yeji Kim
CS

libuv/OS 메모리 글에서 cgroup이 메모리를 제한하는 이야기를 했느ㄴ데, 메모리를 넘기면 OOM Killer가 프로세스를 죽인다는 내용이었다. 그런데 cgroup은 메모리만 막는 게 아니라, CPU도 영향을 준ㅏ.

예ㄹ 들ㅓ, 컨테이너에 올린 Node.js 서버가 이유 없이 느려진다. CPU 사용률 그래프를 보면 한참 여유가 있는데, 응답 속도(latency)만 가끔 툭툭 튄다. 메모리도 멀쩡하다. cgroup의 CPU 제한 때문일 가능성이 있는데, 관련 내용을 살펴보자.


cgroup이 뭔가

cgroup(control group) 은 리눅스가 프로세스 여러 개를 한 묶음으로 보고, 그 묶음에 "너희는 CPU 0.5개, 메모리 512MB까지만 써"라고 한도를 거는 기능이다. 우리가 Docker나 쿠버네티스(K8s)에서 정하는 리소스 limit이 실제로는 전부 이 cgroup으로 만들어진다.

쉽게 말하면 공용 사무실의 부서별 예산 같은 거다. 각 부서(cgroup)에 월 예산(CPU·메모리 한도)을 정해두고, 부서원들이 그 안에서 나눠 쓴다. 한 부서가 예산을 다 써도 다른 부서엔 영향이 없다(격리). 그리고 누가 얼마 썼는지 항상 집계된다(측정).

cgroup은 버전이 둘인데, 요즘 시스템은 대부분 cgroup v2를 쓴다. 이 글도 v2 기준이다. v2에서 cgroup은 그냥 폴더다. /sys/fs/cgroup 아래 폴더 하나가 그룹 하나고, 그 안의 파일들을 읽고 쓰면서 한도를 정하거나 현재 상태를 본다.

text
/sys/fs/cgroup/
└── (컨테이너 하나당 폴더 하나)
    ├── cpu.max       ← "CPU를 얼마나 쓸 수 있나" (한도)
    ├── cpu.stat      ← "지금까지 얼마나 썼고, 몇 번 막혔나" (통계)
    ├── memory.max    ← 메모리 한도
    └── cgroup.procs  ← 이 그룹에 속한 프로세스 목록

cgroup은 프로세스 단위로 본다

여기서 한 가지를 짚고 가야 하ㄴㅔ, cgroup이 한도를 거는 단위는 프로세스일까, 스레드일까?

기본은 프로세스 단위인데, 그런데 함정이 여기 있다. 우리가 Node는 싱글 스레드라고 부르지만, 실제 Node.js 프로세스 안에는 스레드가 여러 개 돌고 있다.

text
Node.js 프로세스 하나 = 스레드 여러 개
 
- 메인 스레드        ← 우리가 아는 이벤트 루프 (JS 실행)
- libuv 스레드풀     ← 파일 I/O, 암호화 등 (기본 4개)
- V8 GC 스레드       ← 가비지 컬렉션을 백그라운드에서
- JIT 컴파일러 스레드 ← 코드 최적화

cgroup은 이 프로세스를 한 그룹에 넣으면 안에 있는 모든 스레드를 다 같이 그 그룹에 넣는다. 그리고 CPU 한도는 이 스레드들이 다 같이 합쳐서 소비한다.

"CPU 0.2개"라는 한도는 메인 스레드 하나가 아니라, 그 안의 모든 스레드가 나눠 갖는 한 덩어리다. 이 사실이 곧 뒤에 나오는 느려지는 이유로 이어진다.

스레드도 따로 떼어낼 수 있긴 하다 (threaded mode)

cgroup v2에는 한 프로세스의 스레드들을 서로 다른 그룹에 쪼개 넣는 threaded mode도 있다. 예를 들어 "실시간 처리 스레드"와 "백그라운드 배치 스레드"에 다른 CPU 정책을 주고 싶을 때 쓴다. 다만 오디오·실시간 시스템 같은 특수한 경우에나 쓰고, 일반적인 컨테이너(Docker/K8s)는 거의 항상 프로세스 단위로 동작한다. 그러니 "보통은 한 프로세스의 모든 스레드가 한 덩어리의 CPU 한도를 나눠 쓴다"만 기억하면 된다.


CPU 한도와 쓰로틀링 - 느려지는 진짜 이유

cgroup의 CPU 한도는 cpu.max라는 파일에 들어 있다.

bash
cat /sys/fs/cgroup/.../cpu.max
# 20000 100000
#   ↑      ↑
#  quota  period   (단위: ms)

읽는 법은 > period(여기선 100ms)마다 quota(20ms)만큼 CPU를 쓸 수 있다. 20 / 100 = 0.2니까 "CPU 0.2개"라는 뜻이다. K8s에서 cpu: limit을 정하면 이 값으로 번역된다.

비유하자면 100분짜리에서 한 텀(period)마다 쓸 수 있는 한도가 20분어치(quota) 인 셈이다. 한도를 다 쓰면 다음 시간이 올 때까지 아무것도 못 한다.

여기서 아까 말한 "모든 스레드가 합쳐서 사용한다"는게 문제를 일으킨다. 한도가 CPU 2개(period 100ms당 200ms)인데 스레드 8개가 동시에 일한다고 해보자.

text
한도 = CPU 2개 (100ms당 200ms 사용 가능), 스레드 8개가 동시에 일할 때
 
100ms짜리 한 구간(period):
├─ 0 ~ 25ms   : 스레드 8개가 동시에 달림 → 8 × 25ms = 200ms 어치를 다 써버림
└─ 25 ~ 100ms : 🛑 멈춤 (throttled) - 한도를 다 썼으니 다음 구간까지 강제 대기
                  ↑ 이 75ms 동안 들어온 요청은 그냥 줄 서서 기다린다

이렇게 한도를 다 써서 강제로 멈추는 걸 CPU 쓰로틀링(throttling) 이라고 한다. 그리고 이게 CPU 사용률은 낮은데 느린 현상의 정체다. 평균만 보면 "100ms 중 25ms만 일했으니 25%밖에 안 쓰네?"처럼 보이지만, 실제로는 나머지 75ms를 멈춰 있느라 응답이 늦어진 것이다.

특히 Node가 위험하다. 평소엔 메인 스레드만 살살 돌다가, GC가 도는 순간 여러 스레드가 동시에 CPU를 확 먹는다. 그 순간 한도를 순식간에 다 써버리고 → 쓰로틀에 걸리고 → 이벤트 루프까지 멈춰서 → latency가 튄다.

쓰로틀링은 느낌이 아니라 숫자로 확인할 수 있다. cpu.stat 파일을 보면 된다.

bash
cat /sys/fs/cgroup/.../cpu.stat
# nr_periods 4210         ← 지나간 구간(period) 수
# nr_throttled 318        ← 그중 "멈춤"이 걸린 구간 수      ★
# throttled_usec 920500   ← 멈춰 있던 시간의 총합           ★

nr_throttled가 전체 nr_periods 대비 많거나, throttled_usec가 계속 늘어나면 그 컨테이너는 쓰로틀에 걸리고 있는 것이다. (K8s 모니터링에선 container_cpu_cfs_throttled_periods_total 메트릭이 같은 값이다.)

그래서 어떻게 하나

가장 흔한 처방은 CPU limit을 너무 빡빡하게 잡지 않는 것이다. 컨테이너 안에서 도는 스레드(libuv 스레드풀 + GC + JIT)가 생각보다 많아서, 한도가 낮으면 버스트 순간 쉽게 쓰로틀에 걸린다. 한도를 조금 여유 있게 주거나, 경우에 따라 limit을 빼고 request(최소 보장량)만 쓰는 전략도 고려한다. 그리고 UV_THREADPOOL_SIZE를 무작정 키우면 동시에 도는 스레드가 늘어 쓰로틀이 더 심해질 수 있으니 주의한다.


한 가지 더 - Node가 코어 수를 착각한다

CPU 한도에는 종류가 하나 더 있다. cpu.max가 "시간"을 제한한다면, cpuset은 "어느 코어에서 돌지(공간)"를 제한한다. "너는 0~3번 코어에서만 돌아"처럼 코어를 콕 집어주는 방식이다.

여기서 자주 착각하는 함정이 있는데, 컨테이너에 코어를 2개만 줬는데도, 그 안의 Node는 호스트의 전체 코어 수(예: 16개) 를 보고 있을 수 있다.

js
const os = require('os');
 
console.log(os.cpus().length);          // 16  ← 호스트 기준일 수 있음 (착각!)
console.log(os.availableParallelism()); // Node 18.14+ - 좀 더 정확하지만 환경 따라 다름

Node가 코어 16개네? 하고 스레드풀이나 워커 수를 16개에 맞춰 잡으면, 실제론 2코어밖에 없으니 스레드들이 서로 자리를 뺏느라(컨텍스트 스위칭) 오히려 느려진다. 그래서 컨테이너에서는 cgroup이 정한 실제 한도를 직접 읽어서 워커 수를 맞춰주는 게 안전하다.

js
const fs = require('fs');
 
// cgroup v2가 정한 실제 CPU 한도 읽기
function cgroupCpuLimit() {
  try {
    const [quota, period] = fs
      .readFileSync('/sys/fs/cgroup/cpu.max', 'utf8')
      .trim()
      .split(' ');
    if (quota === 'max') return null;       // 한도 없음
    return Number(quota) / Number(period);  // "200000 100000" → 2
  } catch {
    return null;
  }
}
 
console.log(cgroupCpuLimit()); // 예: 2  → 이 값을 기준으로 워커 수를 잡는다

정리

text
컨테이너 CPU limit
   → cpu.max ("쓸 수 있는 시간 / 전체 구간")
   → 프로세스 안의 "모든 스레드"가 그 시간을 합쳐서 소비
   → 시간을 다 쓰면 → 그룹 전체가 다음 구간까지 멈춤 (= 쓰로틀)
   → 멈춘 시간만큼 응답이 늦어짐 → cpu.stat의 nr_throttled로 확인
개념한 줄 요약
cgroup프로세스 묶음에 CPU·메모리 한도를 거는 기능. 컨테이너 limit의 정체
프로세스 단위한 프로세스의 모든 스레드가 한 덩어리 한도를 나눠 쓴다
cpu.max"구간(period)마다 쓸 수 있는 CPU 시간(quota)". 200000 100000 = CPU 2개
쓰로틀링한도를 다 쓰면 다음 구간까지 멈추는 것. 평균 사용률 낮아도 발생 → latency 튐
cpu.statnr_throttled, throttled_usec로 쓰로틀 여부를 숫자로 확인
cpuset코어 자체를 제한. Node가 코어 수를 착각하는 함정 주의

메모리 한도가 OOM Killer로 이어졌듯, CPU 한도는 쓰로틀링으로 이어진다. 그리고 그 한도를 프로세스 안 모든 스레드가 나눠 쓴다는 점이, 싱글 스레드라던 Node가 컨테이너에서 가끔 느려지는 현상의 원인이 될 수도 있겠다.


레퍼런스