Bun, 컨테이너 CPU 제한 이제 제대로 읽는다

제목: Bun, 이제 리눅스 컨테이너 CPU 제한을 ‘제대로’ 읽어요 (cgroup-aware availableParallelism/hardwareConcurrency)
도커나 쿠버네티스에서 --cpus=2로 제한했는데도, 런타임이 호스트의 96코어를 믿고 스레드를 우르르 만들던 경험 있으신가요?
Bun이 바로 그 함정을 크게 줄이는 업데이트를 머지했습니다.
1) 문제의 핵심: 컨테이너 CPU 제한과 “코어 개수”는 다를 수 있어요
요약하면, 리눅스 컨테이너에서 CPU 제한은 흔히 **cpuset(코어 마스크)**가 아니라 **CFS quota(대역폭 쿼터)**로 걸립니다.
이때 sysconf(_SC_NPROCESSORS_ONLN)나 sched_getaffinity() 같은 전통적인 방법은 여전히 호스트 코어 수를 반환할 수 있어요. 그래서 Bun이 호스트가 96코어면 스레드를 96개 수준으로 스폰해버리고, 컨테이너는 실제론 2CPU만 배정되어 있으니 쿼터를 몇 ms 만에 다 써서 주기적으로 스로틀링(throttling) 됩니다.
결과는 명확합니다. sawtooth 형태의 지연 시간, nr_throttled 증가, 그리고 GC(가비지 컬렉션) 중간 멈춤 같은 성능 이슈가 실제로 관측됐다고 해요.
2) 무엇이 바뀌었나: WTF::numberOfProcessorCores() 하나로 통합
요약하면 navigator.hardwareConcurrency, os.availableParallelism(), bun.getThreadCount()가 같은 CPU 코어 감지 로직을 보게 됐어요.
이번 PR은 위 세 값을 기존의 제각각 구현에서 빼내서, WebKit 쪽의 WTF::numberOfProcessorCores()를 기준으로 라우팅합니다. 그리고 리눅스에서 이 함수가 min(sysconf, sched_getaffinity, cgroup CPU quota) 형태로 동작하도록 확장됐어요.
즉, 컨테이너에서 CPU가 2로 제한되면 Bun도 이제 2를 반환하는 흐름에 가까워집니다. Node.js가 libuv에서 cpu.max(cgroup v2)를 읽어 2를 내던 것과 정합성을 맞추는 방향이에요.

3) 왜 성능에 치명적이었나: 스레드 과다 생성 → 쿼터 즉시 소진
요약하면 “스레드를 많이 쓰면 빨라진다”가 아니라, 컨테이너에선 스케줄링 쿼터를 먼저 태워서 더 느려질 수 있어요.
Bun은 코어 수를 바탕으로 스레드 풀, JSC(JavaScriptCore) 병렬 GC 마커, JIT 워커 등을 결정합니다. 그런데 컨테이너 CPU가 2인데 호스트 96을 기준으로 잡으면, 실제로는 2CPU가 96개 워커를 번갈아 태우느라 오버헤드가 폭증하죠.
게다가 CFS bandwidth quota 환경에서는 “100ms 중 200ms 쓸 수 있음” 같은 식의 주기가 존재할 수 있는데, 스레드가 몰리면 벽시계 시간 몇 ms 만에 예산을 다 써서 남은 기간 동안 통째로 deschedule 되는 상황(스로틀링)이 생깁니다. 애플리케이션 관점에선 쭉 가다가 갑자기 멈추는 느낌이라 tail latency가 매우 나빠져요.
4) 구현 포인트: cgroup v1/v2 파싱 + 계층에서 ‘가장 빡센 제한’ 선택
요약하면 Bun이 이제 /proc/self/cgroup를 읽고, cgroup v1/v2를 구분해 실효 CPU 제한을 계산합니다.
PR 설명에 따르면 리눅스에서 새로운 uv_get_constrained_cpu()가 추가되어 /proc/self/cgroup를 파싱하고, v2에서는 계층을 따라 올라가며 가장 타이트한 quota/period를 찾아 ceil(quota/period)로 코어 수를 추정합니다. 또한 결과는 첫 호출 이후 캐시돼 런타임 비용을 줄였어요.
덤으로 메모리(memory.max)도 v2에서 leaf만 보던 방식에서 계층 전체를 walk하도록 바뀌었다고 언급됩니다. 컨테이너 환경에서 “진짜 제한은 상위 계층에 있다”가 흔해서, 이런 처리도 품질을 올리는 포인트예요.

5) 개발자가 체감하는 변화: 라이브러리 병렬도/스레드 튜닝이 안정적
요약하면 이제 “컨테이너에서만 폭주하는” 병렬 처리 버그를 줄이고, Bun/Node 간 동작 차이도 좁힙니다.
실제로 웹/노드 생태계에서는 병렬도 결정에 os.availableParallelism()(또는 브라우저 쪽 navigator.hardwareConcurrency)를 많이 참고해요. 예를 들어 이미지 처리, 압축, 청크 병렬 업로드 같은 라이브러리는 “코어 수만큼 워커 생성” 패턴이 흔합니다. 그런데 런타임이 호스트 코어를 뱉어버리면, 컨테이너에서 워커 수가 폭발해 CPU 스로틀링 + 메모리 증가로 이어질 수 있죠.
이번 변경으로 Bun 내부 스레드 풀뿐 아니라, 서드파티 라이브러리의 병렬도 결정도 컨테이너 제한에 맞춰질 가능성이 커졌습니다. 특히 docker run --cpus=2나 k8s resources.limits.cpu를 쓰는 팀에선 “Bun으로 바꾸니 컨테이너가 터진다” 류의 이슈가 줄어들 여지가 있어요(실제로 관련 이슈 #17723이 언급됨).
6) 알아둘 제한: 런타임 중 cgroup 리사이즈는 반영되지 않아요
요약하면 k8s의 in-place vertical scaling처럼 “실행 중 리밋 변경”은 바로 따라가지 못합니다.
PR 노트에 따르면 런타임 중 cgroup 리사이즈는 감지되지 않으며, 이는 Node도 동일하다고 해요. 즉, 컨테이너 CPU 제한을 실행 중에 바꿀 수 있는 환경이라면, 프로세스 레벨에서 병렬도/스레드 수를 동적으로 재조정하는 기대는 하면 안 됩니다.
또한 댓글에서는 “우리는 CPU limits를 아예 안 걸고(request만) 운영한다” 같은 케이스도 나왔어요. 이런 경우 cgroup quota가 무제한이면 여전히 호스트 코어가 반환될 수 있는데, 팀의 운영 철학에 따라선 “실제 원하는 병렬도”와 다를 수 있습니다. 그래서 UV_THREADPOOL_SIZE 외에 ACTIVE_PROCESSOR_COUNT 같은 명시적 오버라이드 환경변수 아이디어도 제안되었고, 앞으로가 관전 포인트예요.
마무리하며: 컨테이너에서의 성능 문제는 “코드가 느리다”보다 “런타임이 환경을 오해한다”에서 시작하는 경우가 많아요.
Bun을 컨테이너에서 쓰고 있다면, 이번 변경 이후 os.availableParallelism() / navigator.hardwareConcurrency 값이 기대대로 나오는지 먼저 확인해보고, 워커 수가 과도하던 작업(빌드, 이미지 처리, 배치 병렬 작업)부터 다시 튜닝해보시면 효과를 빠르게 체감할 수 있을 거예요.






