도커 고급 강좌 #5 리소스 제한과 cgroups
지금까지 컨테이너의 자원 사용량은 거의 호스트가 알아서 해주는 것처럼 다뤘습니다. 운영으로 가면 그게 더 이상 통하지 않습니다 — 한 컨테이너가 호스트 메모리를 먹어 다른 서비스가 죽거나, CPU를 점유해 응답 지연을 만드는 상황이 흔합니다. 이번 글은 자원 제한을 본격적으로 정리합니다.
도커 고급 강좌 시리즈에서 이번 글의 위치:
- #1 BuildKit과 buildx
- #2 멀티 아키텍처 이미지
- #3 이미지 보안 — non-root, distroless, scan(Trivy)
- #4 SBOM과 서명(cosign)
- #5 리소스 제한과 cgroups ← 이번 글
- #6 프로덕션 운영 — restart 정책, healthcheck, graceful shutdown
cgroups — 컨테이너 격리의 한 축 #
#1에서 짧게 짚은 cgroups (control groups). 리눅스 커널의 자원 회계 / 제한 기능입니다. 컨테이너가 가벼운 이유가 namespace라면, 컨테이너의 안전한 운영을 가능하게 하는 기반이 cgroups입니다.
cgroups는 두 세대가 있습니다.
| cgroups v1 | cgroups v2 | |
|---|---|---|
| 출시 | 2007 | 2016 |
| 구조 | 리소스별로 별도 계층 | 단일 통합 계층 |
| 메모리 회계 | 부분적 | 정확 |
| 도커 지원 | 오래 | 20.10+ 안정 |
최근 리눅스 배포판은 거의 v2가 기본 입니다. Docker Desktop도 v2. 이번 글은 v2가정으로 갑니다.
확인:
stat -fc %T /sys/fs/cgroup
# cgroup2fs ← v2
# tmpfs ← v1 (옛날 시스템)
docker info | grep Cgroup
# Cgroup Driver: systemd
# Cgroup Version: 2메모리 제한 — --memory
#
가장 자주 쓰는 설정입니다.
docker run -d --memory 512m myapp
docker run -d -m 512m myapp # 단축services:
web:
image: myapp
mem_limit: 512m # 또는 deploy.resources.limits.memory (Swarm)
mem_reservation: 256m # soft limitmem_limit vs mem_reservation
#
| 옵션 | 의미 |
|---|---|
mem_limit | 하드 한계 — 넘으면 OOMKill |
mem_reservation | 소프트 한계 — 호스트가 메모리 부족할 때 우선 회수 대상이 안 되도록 |
운영에선 mem_limit만 명시하는 게 보통. mem_reservation은 한 호스트에 여러 컨테이너를 묶어 띄우는 멀티테넌트 환경에서 의미가 살아납니다.
단위 표기 #
512 # 바이트 (기본)
512b # 바이트
512k # 킬로바이트 (1024 바이트)
512m # 메가바이트
2g # 기가바이트m은 메가바이트입니다. K8s의 500m (0.5 cpu)와 헷갈리지 마세요.
스왑(swap) #
docker run -m 512m --memory-swap 1g myapp
# RAM 512m + 스왑 (1g - 512m = 512m) = 합 1g
docker run -m 512m --memory-swap -1 myapp
# 무제한 스왑 (호스트 한계까지)
docker run -m 512m --memory-swap 512m myapp
# 스왑 사용 금지 (RAM 한계가 곧 전체 한계)운영에선 보통 스왑 없음 또는 호스트 자체가 스왑 비활성화. 스왑이 끼면 성능 예측이 어려워집니다.
OOMKilled — 한계를 넘기면 무슨 일이 #
메모리 한계를 넘긴 컨테이너는 OOMKilled 상태로 끝납니다.
docker inspect myapp --format '{{.State.OOMKilled}}'
# true
docker inspect myapp --format '{{.State.ExitCode}}'
# 137 ← SIGKILL (128 + 9)exit code 137이 거의 OOMKilled의 시그니처입니다. 호스트의 dmesg에서도 확인:
sudo dmesg | grep -i 'killed process'
# Memory cgroup out of memory: Killed process 12345 (python) ...운영에서 OOMKilled가 자주 발생한다면:
- 한계가 너무 작음 — 측정해서 늘리기
- 앱의 메모리 누수 — 시간이 지날수록 증가하는지 추적
- 런타임이 한계를 인식 못 함 — 다음 절
컨테이너의 메모리 인식 — 런타임의 함정 #
컨테이너 안의 앱이 free / /proc/meminfo를 읽으면 호스트의 메모리를 봅니다. cgroups의 한계는 다른 계층에 있습니다.
docker run --rm -m 512m ubuntu free -m
# total used free
# Mem: 15920 542 14253 ← 호스트 메모리이게 왜 문제가 되냐면 — 일부 런타임이 free 또는 Runtime.maxMemory 같은 호출로 호스트 크기 기준으로 동작해서, 한계를 넘는 메모리를 사용하다 OOMKilled 됩니다.
자바 (JVM) #
# 옛날 (JVM 8 초기): 호스트 메모리 기준 → 자주 OOMKill
java -Xmx2g app.jar
# JVM 10+ : -XX:+UseContainerSupport (기본) → cgroups 한계 인식
java -XX:MaxRAMPercentage=75.0 app.jarJVM 10+ 는 UseContainerSupport가 기본으로 켜져 있습니다. -Xmx를 명시하기보다 MaxRAMPercentage로 한계 비율을 주는 게 컨테이너 친화적입니다.
Node.js #
Node도 비슷한 문제가 있습니다. V8의 old-space 한계가 기본 1.5~4GB 정도라 컨테이너 한계와 어긋날 수 있습니다.
node --max-old-space-size=512 app.js컨테이너 한계가 512m이라면 Node의 old-space 한계도 그 근처로 맞추는 편이 안전합니다.
Python #
CPython은 가비지 컬렉터가 단순해서 한계를 명시적으로 줄 게 없습니다. 다만 multiprocessing 같은 경우에 worker 수를 자동으로 정할 때 — os.cpu_count()가 호스트의 코어 수를 반환합니다. 컨테이너의 CPU 한계를 인식 못 하니, worker 수는 환경변수로 직접 주는 게 안전.
CPU 제한 — --cpus / --cpu-shares
#
CPU도 두 가지 형태로 제한할 수 있습니다.
# 1) 절대 한계 — 1 코어 사용 가능
docker run --cpus 1.0 myapp
# 2) 절대 한계 — 1.5 코어 (1 코어 100% + 다른 코어 50%)
docker run --cpus 1.5 myapp
# 3) 상대 가중치 — 다른 컨테이너 대비
docker run --cpu-shares 512 myapp| 옵션 | 의미 |
|---|---|
--cpus N | 한 컨테이너가 사용할 수 있는 CPU의 절대량 (N 코어 분량) |
--cpu-shares | 상대 가중치 (기본 1024). 호스트가 바쁠 때 분배 비율 |
--cpuset-cpus 0-2 | 사용 가능한 코어를 명시 (예: 0,1,2 번 코어만) |
운영에선 거의 항상 **--cpus**로 절대 한계를 명시하는 편이 예측 가능. --cpu-shares는 한 호스트 안에서 우선순위가 다른 컨테이너들을 띄울 때 의미가 살아납니다.
CFS quota의 동작 #
--cpus 1.0은 내부적으로 CFS (Completely Fair Scheduler) quota로 구현됩니다. 100ms마다 100ms 분량의 CPU 시간을 받는 식입니다. 이게 가끔 의도치 않은 throttling을 만듭니다 — 순간적인 burst가 막히는 경우가 있습니다.
K8s에선 cpu.cfs_period_us / cpu.cfs_quota_us의 동작이 비현실적이라는 의견이 있어, 일부 환경에서는 CPU 한계를 의도적으로 안 두기도 합니다(메모리 한계는 항상). 도커 단독 환경에선 보통 --cpus로 한계를 두는 게 일반적입니다.
컨테이너의 CPU 인식 #
JVM / Node / Go 같은 런타임은 GC 스레드 / worker 수를 코어 수에 따라 정합니다. os.cpu_count()가 호스트 코어 수를 반환하면 컨테이너 한계와 어긋납니다.
docker run --rm --cpus 0.5 alpine nproc
# 8 ← 호스트 코어 수, 한계 무시해결:
- JVM 10+ :
UseContainerSupport가 자동 처리 - Node :
process.env.UV_THREADPOOL_SIZE환경변수로 thread pool 크기를 명시 - Go :
runtime.GOMAXPROCS가 컨테이너 한계를 인식하도록automaxprocs라이브러리 사용 - Python : worker 수를 환경변수로
# go.mod
require go.uber.org/automaxprocs v1.5.3
# main.go
import _ "go.uber.org/automaxprocs"import 한 줄만 추가하면 GOMAXPROCS가 cgroups 한계로 자동 설정됩니다.
compose.yaml의 자원 정의
#
Compose v2에선 두 가지 형식이 보입니다.
services:
web:
image: myapp
mem_limit: 512m
mem_reservation: 256m
cpus: 1.5
pids_limit: 100services:
web:
image: myapp
deploy:
resources:
limits:
memory: 512M
cpus: '1.5'
reservations:
memory: 256M
cpus: '0.5'일반 docker compose up에선 mem_limit / cpus 같은 간단 형식이 동작합니다. deploy.resources는 Swarm 모드에서만 풀 효과인데, 최근 Compose는 단일 호스트에서도 일부 인식합니다. 단일 호스트 운영이라면 간단 형식을 쓰는 편이 헷갈리지 않습니다.
pids_limit — 프로세스 폭주 방지
#
fork bomb / 좀비 프로세스 누적 방지에 효과적입니다.
docker run --pids-limit 100 myappservices:
web:
pids_limit: 100웹 앱 한 개가 100 개 프로세스를 띄울 일은 거의 없습니다. 한계를 두면 의도치 않은 폭주를 컨테이너 단위에서 막아줍니다.
ulimit — 파일 디스크립터 등
#
리눅스 ulimit도 컨테이너 단위로 줄 수 있습니다. 가장 흔한 항목은 열 수 있는 파일 디스크립터 (nofile).
docker run --ulimit nofile=65536:65536 myappservices:
web:
ulimits:
nofile:
soft: 65536
hard: 65536
nproc: 4096큰 트래픽을 받는 서버 / 많은 연결을 유지하는 워커는 기본 1024가 부족합니다. 운영 환경에선 한 번 늘려두는 편.
IO 제한 — --device-write-bps 등
#
블록 IO도 cgroups가 제한할 수 있습니다. 자주 쓰는 설정은 아니지만 — 한 컨테이너가 디스크 IO를 점유해 다른 서비스에 영향을 주는 멀티테넌트 호스트 환경에서 의미가 있습니다.
docker run --device-write-bps /dev/sda:10mb myapp
# 이 컨테이너의 /dev/sda 쓰기 속도를 10MB/s로 제한운영 컨테이너 한 개의 자원 정의에 자주 들어가는 옵션은 아닙니다.
자원 측정 — docker stats 다시
#
중급 #6에서 짚은 명령. 자원 한계의 효과를 보고 싶을 때 자주 돌리는 명령입니다.
docker stats myapp
# CONTAINER CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O
# myapp-web-1 24.5% 312MiB / 512MiB 60.93% 12kB / 8kB ...MEM %가 한계의 70~80% 를 자주 넘는다면 한계가 작거나 누수가 있는 것. OOMKill은 갑자기 일어나니, 평소에 stats를 보며 마진을 확인 하는 흐름이 안전합니다.
prometheus / cAdvisor
#
운영 환경에서는 stats를 사람 눈으로 보는 게 아니라 시계열 DB에 쌓아 추적합니다. cAdvisor가 도커의 cgroups 회계를 Prometheus 메트릭으로 노출해줍니다.
services:
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.49.1
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
ports:
- "8080:8080"Prometheus + Grafana와 연결하면 컨테이너별 CPU / 메모리 / IO 그래프가 한곳에 모입니다. 도커 단독 운영의 첫 모니터링 셋업입니다.
OOMKilled의 진단 흐름 #
OOMKilled가 보이면 한 번 돌릴 흐름:
# 1) 정말 OOMKilled 인지
docker inspect <c> --format '{{.State.OOMKilled}} {{.State.ExitCode}}'
# 2) 호스트 dmesg에 기록
sudo dmesg -T | grep -i oom
# 3) 한계 확인
docker inspect <c> --format '{{.HostConfig.Memory}}'
# 4) 평상시 사용량 (운영 중이면 stats 또는 모니터링)
docker stats <c> --no-stream
# 5) 런타임이 한계를 인식하는지 (JVM 같은 경우)
docker exec <c> java -XshowSettings:vm -version 2>&1 | grep MaxHeapSize이 흐름이 손에 붙으면 메모리 사고의 90% 는 빠르게 좁혀집니다.
정리 #
이번 글에서 잡은 그림:
- 컨테이너 자원 제한은 cgroups v2 위에서 도는 메커니즘 — namespace와 함께 격리의 두 축
- **
--memory/mem_limit**가 가장 중요. 운영 컨테이너에 한계 명시는 거의 필수 - OOMKilled의 시그니처는 exit code 137 +
State.OOMKilled: true - 런타임이 컨테이너 한계를 인식하는지 별도 점검 — JVM
MaxRAMPercentage, Node--max-old-space-size, Goautomaxprocs - CPU는 **
--cpus**로 절대 한계, 또는--cpu-shares로 상대 가중치 pids_limit, **ulimit nofile**도 운영 안정성에 자주 들어가는 설정- 측정은
docker stats→ cAdvisor + Prometheus + Grafana로 확장
다음 글(#6 프로덕션 운영)에서는 도커 고급 시리즈를 마무리합니다. PID 1의 신호 처리, SIGTERM 그레이스풀 종료, restart 정책 깊이, healthcheck의 운영 시각, liveness vs readiness의 개념까지 — 한 컨테이너를 프로덕션에서 안정적으로 운영할 때 필요한 잔주름들입니다.