도커 고급 강좌 #5 리소스 제한과 cgroups

8 분 소요

지금까지 컨테이너의 자원 사용량은 거의 호스트가 알아서 해주는 것처럼 다뤘습니다. 운영으로 가면 그게 더 이상 통하지 않습니다 — 한 컨테이너가 호스트 메모리를 먹어 다른 서비스가 죽거나, CPU를 점유해 응답 지연을 만드는 상황이 흔합니다. 이번 글은 자원 제한을 본격적으로 정리합니다.

도커 고급 강좌 시리즈에서 이번 글의 위치:

cgroups — 컨테이너 격리의 한 축 #

#1에서 짧게 짚은 cgroups (control groups). 리눅스 커널의 자원 회계 / 제한 기능입니다. 컨테이너가 가벼운 이유가 namespace라면, 컨테이너의 안전한 운영을 가능하게 하는 기반이 cgroups입니다.

cgroups는 두 세대가 있습니다.

cgroups v1cgroups v2
출시20072016
구조리소스별로 별도 계층단일 통합 계층
메모리 회계부분적정확
도커 지원오래20.10+ 안정

최근 리눅스 배포판은 거의 v2가 기본 입니다. Docker Desktop도 v2. 이번 글은 v2가정으로 갑니다.

확인:

cgroups 버전
stat -fc %T /sys/fs/cgroup
# cgroup2fs   ← v2
# tmpfs       ← v1 (옛날 시스템)

docker info | grep Cgroup
# Cgroup Driver: systemd
# Cgroup Version: 2

메모리 제한 — --memory #

가장 자주 쓰는 설정입니다.

docker run
docker run -d --memory 512m myapp
docker run -d -m 512m myapp        # 단축
compose.yaml
services:
  web:
    image: myapp
    mem_limit: 512m         # 또는 deploy.resources.limits.memory (Swarm)
    mem_reservation: 256m   # soft limit

mem_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) #

--memory-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 상태로 끝납니다.

OOMKilled 진단
docker inspect myapp --format '{{.State.OOMKilled}}'
# true

docker inspect myapp --format '{{.State.ExitCode}}'
# 137   ← SIGKILL (128 + 9)

exit code 137이 거의 OOMKilled의 시그니처입니다. 호스트의 dmesg에서도 확인:

dmesg 로그
sudo dmesg | grep -i 'killed process'
# Memory cgroup out of memory: Killed process 12345 (python) ...

운영에서 OOMKilled가 자주 발생한다면:

  1. 한계가 너무 작음 — 측정해서 늘리기
  2. 앱의 메모리 누수 — 시간이 지날수록 증가하는지 추적
  3. 런타임이 한계를 인식 못 함 — 다음 절

컨테이너의 메모리 인식 — 런타임의 함정 #

컨테이너 안의 앱이 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 한계 인식
# 옛날 (JVM 8 초기): 호스트 메모리 기준 → 자주 OOMKill
java -Xmx2g app.jar

# JVM 10+ : -XX:+UseContainerSupport (기본) → cgroups 한계 인식
java -XX:MaxRAMPercentage=75.0 app.jar

JVM 10+ 는 UseContainerSupport가 기본으로 켜져 있습니다. -Xmx를 명시하기보다 MaxRAMPercentage로 한계 비율을 주는 게 컨테이너 친화적입니다.

Node.js #

Node도 비슷한 문제가 있습니다. V8의 old-space 한계가 기본 1.5~4GB 정도라 컨테이너 한계와 어긋날 수 있습니다.

Node — 메모리 한계 명시
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도 두 가지 형태로 제한할 수 있습니다.

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의 automaxprocs
# 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: 100
deploy 형식 (Swarm 호환)
services:
  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 / 좀비 프로세스 누적 방지에 효과적입니다.

PID 한계
docker run --pids-limit 100 myapp
compose
services:
  web:
    pids_limit: 100

웹 앱 한 개가 100 개 프로세스를 띄울 일은 거의 없습니다. 한계를 두면 의도치 않은 폭주를 컨테이너 단위에서 막아줍니다.

ulimit — 파일 디스크립터 등 #

리눅스 ulimit도 컨테이너 단위로 줄 수 있습니다. 가장 흔한 항목은 열 수 있는 파일 디스크립터 (nofile).

docker run
docker run --ulimit nofile=65536:65536 myapp
compose
services:
  web:
    ulimits:
      nofile:
        soft: 65536
        hard: 65536
      nproc: 4096

큰 트래픽을 받는 서버 / 많은 연결을 유지하는 워커는 기본 1024가 부족합니다. 운영 환경에선 한 번 늘려두는 편.

IO 제한 — --device-write-bps#

블록 IO도 cgroups가 제한할 수 있습니다. 자주 쓰는 설정은 아니지만 — 한 컨테이너가 디스크 IO를 점유해 다른 서비스에 영향을 주는 멀티테넌트 호스트 환경에서 의미가 있습니다.

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 메트릭으로 노출해줍니다.

compose에 cadvisor 추가
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, Go automaxprocs
  • CPU는 **--cpus**로 절대 한계, 또는 --cpu-shares로 상대 가중치
  • pids_limit, **ulimit nofile**도 운영 안정성에 자주 들어가는 설정
  • 측정은 docker stats → cAdvisor + Prometheus + Grafana로 확장

다음 글(#6 프로덕션 운영)에서는 도커 고급 시리즈를 마무리합니다. PID 1의 신호 처리, SIGTERM 그레이스풀 종료, restart 정책 깊이, healthcheck의 운영 시각, liveness vs readiness의 개념까지 — 한 컨테이너를 프로덕션에서 안정적으로 운영할 때 필요한 잔주름들입니다.

X