infra
Platform

모듈 맵

[Docker] CPU/메모리 리밋 설정으로 서버 먹통 방지하는 cgroups

0 / 27 완료

펼치기
0 / 27 완료0%

도커 & 컨테이너 · 13 / 27

[Docker] CPU/메모리 리밋 설정으로 서버 먹통 방지하는 cgroups

cgroups 기반의 CPU/Memory 제한으로 Noisy Neighbor 문제를 시스템적으로 방지합니다

🚨INCIDENT ALERT
HIGH

같은 서버에 올려둔 배치 컨테이너 하나가 메모리를 계속 먹더니 API 컨테이너까지 느려졌습니다. Docker에는 컨테이너가 떠 있었지만, 자원 제한이 없어서 한 컨테이너가 호스트 전체를 사실상 독점했습니다.

컨테이너 격리는 파일시스템과 네트워크만의 문제가 아닙니다. CPU와 메모리 한도를 정하지 않으면 “시끄러운 이웃” 하나가 같은 서버의 모든 서비스를 흔듭니다. 이 모듈에서는 cgroups 기반 제한을 실험으로 확인합니다.

컨테이너 자원 격리와 과점유 방지

컨테이너는 기본적으로 호스트의 CPU와 메모리를 무제한으로 사용할 수 있습니다. 자원 제한 없이 여러 컨테이너를 실행하면 메모리 누수가 있는 컨테이너 하나가 서버 전체를 다운시킬 수 있습니다. 리눅스 cgroups 원리를 이해하고 자원 제한을 설정해 안정적인 멀티 컨테이너 환경을 구성합니다.


이번 챕터에서 배울 것

컨테이너 하나가 호스트 전체 CPU와 메모리를 독점하는 상황을 방지하는 방법을 직접 실험하며 이해합니다.

  • 1cgroups(Control Groups)가 CPU와 메모리를 격리하는 원리를 설명할 수 있다
  • 2--cpus / --cpu-shares로 CPU 할당량을 제한할 수 있다
  • 3--memory / --memory-swap로 메모리 상한을 설정하고 OOM Killer 동작을 이해할 수 있다
  • 4docker stats로 실시간 자원 사용량을 모니터링할 수 있다
  • 5자원 과점유 시나리오를 재현하고 --oom-kill-disable 옵션을 적용할 수 있다
실습 환경 준비

stress 도구는 컨테이너 이미지에서도 설치 가능합니다. 호스트에 설치하기 어려운 환경이라면 실습 단계에서 컨테이너 내부에 설치합니다.

Docker 실행 중인지 확인
docker ps
stress 도구 설치 (CPU/메모리 부하 발생용)
sudo apt-get install -y stress
호스트 CPU 코어 수 확인
nproc
호스트 메모리 확인
free -h
💡개념

리눅스 cgroups와 Docker 자원 격리의 원리

docker run으로 컨테이너를 실행하면 기본적으로 호스트 CPU와 메모리를 무제한으로 사용합니다. 같은 서버에 여러 컨테이너가 있고 하나에 메모리 누수가 있다면, 그 컨테이너가 점점 메모리를 잠식하다 결국 다른 서비스까지 OOM Kill됩니다. Docker가 컨테이너마다 자원 제한을 강제할 수 있는 이유는 Linux 커널의 cgroups 기능을 사용하기 때문입니다. --cpus 0.5-m 256m을 입력하면 Docker는 해당 컨테이너 ID로 cgroup을 만들고 커널에 한도값을 기록합니다. 커널이 직접 이 한도를 강제하기 때문에 컨테이너 안의 프로세스는 제한을 우회할 수 없습니다. 이 ConceptBlock에서는 cgroups가 CPU와 메모리를 격리하는 원리와 Docker 자원 제한 옵션의 동작 방식을 다룹니다.

cgroups와 Docker 자원 격리 — Docker는 리눅스 cgroups로 컨테이너별 CPU·메모리·I/O 사용량을 제한·격리. --memory는 cgroup memory 한도(초과 시 OOMKilled), --cpus는 CPU 시간 할당. 커널의 cgroups가 실제 강제 수단이며, docker run 플래그가 이를 설정확대

cgroups(Control Groups)란?

cgroups는 2008년 리눅스 커널 2.6.24에 통합된 기능으로, 프로세스 그룹의 시스템 자원 사용량을 제한, 측정, 격리하는 역할을 합니다. Docker는 컨테이너마다 cgroup을 생성해 자원을 격리합니다.

리눅스 커널의 cgroups가 컨테이너별 자원을 제한합니다.

  • /sys/fs/cgroup/cpu/docker/[container_id]/cpu.shares, cpu.cfs_quota_us
  • /sys/fs/cgroup/memory/docker/[container_id]/memory.limit_in_bytes
  • /sys/fs/cgroup/blkio/ → 디스크 I/O

컨테이너를 실행하면 Docker는 해당 컨테이너 ID를 이름으로 하는 cgroup을 생성하고, 거기에 자원 제한값을 기록합니다. 커널이 직접 이 값을 읽어서 제한을 강제합니다.

cgroups v1 vs v2

특징cgroups v1cgroups v2
구조자원 유형별 별도 계층단일 통합 계층
지원 시작커널 2.6.24 (2008)커널 4.5 (2016)
주요 배포판CentOS 7, Ubuntu 18.04Ubuntu 22.04, RHEL 9

Docker는 두 버전 모두 지원하며, 현대 리눅스 배포판은 대부분 v2를 사용합니다.

Docker 자원 격리의 세 가지 핵심 축

1. CPU 제한 (--cpus, --cpu-shares)

  • --cpus: 사용 가능한 CPU 코어 수 (실수 지정 가능)
  • --cpu-shares: 상대적 CPU 가중치 (기본값 1024)

2. 메모리 제한 (-m, --memory-swap)

  • -m 또는 --memory: 최대 메모리 사용량
  • --memory-swap: 스왑 포함 최대 메모리 (기본값: memory의 2배)

3. 블록 I/O 제한 (--blkio-weight)

  • 디스크 읽기/쓰기 속도 제어
로컬 터미널
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part4/exam_13 && cd /tmp/docker/part4/exam_13

# 종합 자원 제한 예시
docker run \
  --cpus 0.5 \          # CPU: 0.5코어 분량
  -m 256m \             # 메모리: 최대 256MB
  --memory-swap 512m \  # 스왑 포함 최대 512MB
  --blkio-weight 300 \  # I/O 가중치 (기본 500)
  nginx
💡개념

--memory·--cpus 한 줄이 강제되기까지 — 플래그에서 throttle·OOM까지 5단계

docker run --cpus 0.5 -m 256m nginx 한 줄을 치면 어디선가 이 한도가 실제로 강제됩니다. 그런데 "제한을 걸었다"는 플래그 한 줄이, 컨테이너 안 프로세스가 우회할 수 없는 실제 강제력으로 바뀌기까지는 몇 단계를 거칩니다 — 플래그가 숫자로 변환되고, cgroup에 기록되고, 커널이 매 순간 감시하다가, 초과하면 CPU는 throttle로·메모리는 OOM Killer로 대응합니다. 이 흐름을 알면 "제한을 안 걸었더니 호스트가 잠식됐다", "137로 죽었다(OOMKilled)", "요청이 느닷없이 느려졌다(throttle)", "예약을 걸었는데 왜 안 지켜지지"를 어느 단계 문제인지로 구분할 수 있습니다.

TEXT
docker run --cpus 0.5 -m 256m nginx
   │
   ① 플래그 파싱 → NanoCpus·Memory 값으로 변환
   │      0.5 → 500000000 nanocpus,   256m → 268435456 bytes
   │
   ② cgroup 생성 + 커널에 한도 기록 (컨테이너 ID 이름의 cgroup)
   │      · cpu.max     ← --cpus (quota/period)
   │      · memory.max  ← -m / --memory
   │
   ③ 커널이 실행 내내 한도를 강제 (컨테이너 안에서는 우회 불가)
   │      · CPU:    quota를 다 쓰면 다음 주기까지 throttle(강제 대기)
   │      · 메모리: 한도 초과 시 먼저 페이지 캐시 회수 시도
   │
   ④ docker stats에 관측: CPU% · MEM USAGE/LIMIT · MEM%
   │
   ⑤ 메모리 회수 실패 → OOM Killer가 SIGKILL → ExitCode 137     → Exited (OOMKilled=true)

각 단계에서 무슨 일이 일어나고, 막히면 어떤 증상인가:

단계하는 일여기서 막히면 / 증상
① 파싱docker run 플래그를 커널이 쓰는 숫자(NanoCpus·바이트)로 변환플래그를 아예 안 주면 한도 없음 → 컨테이너가 호스트 전체 자원 잠식(Noisy Neighbor)
② cgroup 기록컨테이너 ID로 cgroup을 만들고 cpu.max·memory.max에 한도 기입reservations(예약)는 최소 보장 힌트일 뿐 상한이 아님 — limits와 혼동 주의
③ 강제커널이 매 순간 감시: CPU 초과는 throttle, 메모리 초과는 회수 시도--cpus가 너무 낮으면 정상 요청도 throttle에 걸려 타임아웃
④ 관측docker stats가 CPU%·MEM USAGE/LIMIT·MEM%로 실시간 표시MEM%가 회수 가능한 페이지 캐시로 부풀어 보임 → 누수로 오진
⑤ OOM메모리 회수도 실패하면 OOM Killer가 SIGKILL → 137, OOMKilled=truerestart 정책이 있으면 되살아나 같은 한도에 다시 부딪혀 재시작 루프

한도는 docker run 플래그에서 시작하지만 실제로 강제하는 것은 커널의 cgroup이라, 컨테이너 안에서는 우회할 수 없습니다. 네 가지 상황이 여기서 갈립니다. 플래그를 아예 안 주면 ②에서 한도가 기록되지 않아 컨테이너가 호스트 전체 자원을 잠식할 수 있고(Noisy Neighbor), 그래서 멀티 테넌트 호스트에서는 모든 컨테이너에 상한을 주는 것이 원칙입니다. --cpus를 너무 낮게 주면 ③의 CPU throttle이 정상 요청까지 지연시켜 타임아웃이 납니다 — cgroup의 cpu.stat에서 nr_throttled가 증가하는지로 확인합니다. 메모리가 한도를 넘어 회수도 실패하면 ⑤에서 OOM Killer가 137로 죽이며, docker inspectState.OOMKilled가 true이고 호스트 dmesg에 oom-killer 로그가 함께 남습니다. 마지막으로 Compose의 reservations(예약)를 상한으로 착각하지 마세요 — 예약은 스케줄링 시 최소 보장 힌트일 뿐이고, 실제 상한은 limits(=--cpus·-m)만 강제합니다.

CPU/메모리 제한 설정 기준
웹 API 서버처럼 응답 지연에 민감한 서비스--cpus 값을 여유 있게, -m 제한은 피크 사용량의 1.5배로CPU 스로틀 발생 시 레이턴시 급증, 메모리는 OOM 방지를 위해 여유 확보
배치 작업처럼 오래 걸려도 되는 백그라운드 작업--cpus를 낮게, --memory는 작업 크기에 맞게배치가 CPU를 독점하면 API 응답 지연 발생, cpu-shares로 낮은 우선순위 부여
메모리 사용량을 예측하기 어려운 신규 서비스제한 없이 실행 후 docker stats로 실측 후 설정실측 없이 너무 낮게 잡으면 OOM Kill 반복, 너무 높게 잡으면 제한 의미 없음
같은 호스트에 여러 서비스가 공존하는 멀티 테넌트 환경모든 컨테이너에 반드시 CPU와 메모리 상한 설정제한이 없으면 한 서비스가 폭주할 때 전체 서버 영향, Noisy Neighbor 방지 필수
Kubernetes에서 자원 보장이 필요한 서비스requests와 limits 모두 설정, 가능하면 같은 값으로requests=limits이면 Guaranteed QoS 클래스, 리소스 보장과 스케줄링 예측성 향상

💡개념

Noisy Neighbor 문제와 자원 제한의 필요성

같은 서버에 API 서버, 배치 작업, 모니터링 에이전트가 모두 올라가 있습니다. 어느 날 배치 작업이 대용량 파일 처리를 시작하면서 CPU를 거의 다 점유합니다. API 응답 시간이 갑자기 치솟고 알람이 옵니다. 서버가 죽은 게 아닌데 API가 느려진 이유를 처음엔 찾기 어렵습니다. 자원 제한 없이 컨테이너를 운영하면 이런 Noisy Neighbor 상황이 언제든 발생할 수 있습니다. 각 서비스에 CPU와 메모리 상한을 설정하면 한 컨테이너가 폭주해도 다른 서비스는 보호됩니다. 이 ConceptBlock에서는 자원 제한이 없을 때 실제로 어떤 장애 시나리오가 펼쳐지는지, 그리고 제한값을 얼마로 설정해야 하는지 다룹니다.

Noisy Neighbor 문제 — 한 호스트에 자원 제한 없는 컨테이너가 메모리 누수·CPU 폭주를 일으키면 같은 호스트의 다른 컨테이너까지 느려지거나 죽음(시끄러운 이웃). requests/limits로 각 컨테이너 자원을 격리·보장하면 한 컨테이너의 폭주가 이웃에 번지지 않음확대

Noisy Neighbor: 조용한 재앙

Noisy Neighbor(시끄러운 이웃) 문제는 클라우드와 컨테이너 환경에서 가장 흔한 장애 원인 중 하나입니다. 자원 제한이 없는 환경에서 한 컨테이너가 자원을 독점하면 같은 호스트의 모든 서비스가 영향을 받습니다.

자원 제한 없는 서버 — 재앙 시나리오 (서버 스펙: 4 CPU, 8GB RAM)

  • web-service — 정상, 예상 사용: 0.2 CPU, 200MB
  • api-service — 정상, 예상 사용: 0.3 CPU, 300MB
  • batch-job — 메모리 누수 버그 존재
    • 처음: 100MB → 1시간 후: 2GB → 3시간 후: 7GB ← 서버 전체 RAM 잠식!
    • → web-service 응답 불가, api-service OOM Kill → 서비스 전체 다운!

메모리 누수 컨테이너의 실제 영향

로컬 터미널
# 자원 제한 없는 컨테이너가 메모리를 계속 점유하는 상황
docker stats --no-stream
# CONTAINER   CPU %   MEM USAGE / LIMIT   MEM %
# batch-job   5.2%    6.8GiB / 7.5GiB     90%  ← 위험!
# web-srv     45.3%   OOM Kill             --   ← 피해자
# api-srv     0.0%    EXIT                 --   ← 피해자

자원 제한으로 격리 보장

Docker
# 자원 제한 적용 후
docker run -m 512m --cpus 0.5 batch-job

# 결과: batch-job이 512MB를 초과하면 OOMKilled로 자기 자신만 종료
# web-service와 api-service는 영향 없음

자원 제한 설계 원칙

1. 측정 먼저: docker stats로 정상 부하 시 사용량 파악
2. 여유 있게: 평균 사용량의 1.5~2배로 제한 설정
3. 단계적 적용: 개발 → 스테이징 → 운영 순서로 적용
4. 알림 연동: 자원 사용률 80% 도달 시 경보 발송

docker stats로 실시간 자원 사용률 모니터링

실행 중인 컨테이너들의 자원 사용률을 실시간으로 확인하는 방법을 익힙니다.

1단계: 테스트용 컨테이너 실행

실습 전 디렉토리와 예제 파일을 먼저 준비합니다.

로컬 터미널
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part2/exam_3 && cd /tmp/docker/part2/exam_3

# 부하 테스트용 Dockerfile 생성
cat > Dockerfile << 'EOF'
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y stress-ng
EOF

이제 실습을 진행합니다.

Docker
# CPU 부하를 일으키는 컨테이너
docker run -d --name cpu-load alpine sh -c "while true; do :; done"

# 메모리를 점진적으로 사용하는 컨테이너
docker run -d --name mem-load nginx

# 정상 컨테이너
docker run -d --name normal redis:alpine

2단계: 실시간 자원 사용률 모니터링

로컬 터미널
# 모든 컨테이너 실시간 모니터링 (1초 간격 갱신)
docker stats

# 출력 예시:
# CONTAINER   CPU %  MEM USAGE/LIMIT   MEM %  NET I/O    BLOCK I/O
# cpu-load    99.8%  1.2MiB/7.5GiB     0.0%   ...        ...
# mem-load     0.1%  23.5MiB/7.5GiB    0.3%   ...        ...
# normal       0.0%  5.8MiB/7.5GiB     0.1%   ...        ...

3단계: 스냅샷 모드 (스크립트에 유용)

로컬 터미널
# 갱신 없이 현재 상태 한 번만 출력
docker stats --no-stream

# 특정 컨테이너만
docker stats --no-stream cpu-load

# JSON 형식으로 출력 (파싱에 편리)
docker stats --no-stream --format "{{json .}}" cpu-load

4단계: 커스텀 출력 형식

로컬 터미널
docker stats --no-stream \
  --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}"
# NAME       CPU %    MEM USAGE / LIMIT    MEM %
# cpu-load   99.8%    1.2MiB / 7.5GiB     0.0%
# mem-load   0.1%     23.5MiB / 7.5GiB    0.3%

5단계: 정리

위험 명령어실행 중인 실습 컨테이너를 즉시 종료하고 삭제합니다.

자원 사용률 실습 컨테이너 강제 삭제

안전한 실행 조건: cpu-load, mem-load, normal이 이 실습에서 만든 임시 컨테이너인 경우에만 실행합니다.

실행 전 반드시 확인

  • docker ps -a에서 세 컨테이너 이름을 확인했다
  • 컨테이너 내부에 보존할 데이터가 없음을 확인했다
  • 자원 사용률 관찰을 마친 뒤 정리하는 단계임을 확인했다
docker rm -f cpu-load mem-load normal

위 항목을 모두 확인한 후 복사할 수 있습니다

🔍실행 후 확인할 것
  • docker stats 출력에서 cpu-load의 CPU 사용률이 높게 표시되는가?
  • mem-load와 normal 컨테이너의 메모리 사용량을 같은 표에서 비교했는가?
  • docker stats --no-stream으로 한 번만 출력하는 스냅샷을 확인했는가?
  • 정리 후 docker ps -a에서 실습 컨테이너가 남아있지 않은가?
  • [읽기 순서 — 원칙 A] docker stats에서 MEM USAGE/LIMIT 컬럼을 먼저 읽고, MEM%를 기준으로 판단한다. CPU%는 두 번째로 본다.
  • [수치 판단 기준 — 원칙 B] MEM% 50% 이하: 여유 있음. 50~80%: 정상 운영 범위. 80~95%: 모니터링 강화 필요. 95% 이상: OOM 위험, 즉시 증설 또는 컨테이너 리스타트.
  • [조합 해석 — 원칙 C] --cpus 제한을 걸었는데 CPU%가 100% 근처에서 지속되면 스로틀링으로 응답 지연이 발생 중이다. `docker inspect --format "{{.HostConfig.CpuQuota}}"` 로 설정값을 확인하고 --cpus 값을 높인다.
  • [메모리 swap 해석] --memory=512m --memory-swap=512m으로 설정하면 swap 크기는 0이 된다(memory-swap = memory + swap). swap을 512MB 허용하려면 --memory-swap=1g로 설정해야 한다.

CPU와 메모리 제한 설정 및 효과 확인

실제 자원 제한을 적용하고 제한이 동작하는 것을 확인합니다.

1단계: 제한 없이 CPU 부하 테스트

Docker
# 제한 없는 CPU 집중 컨테이너
docker run -d --name no-limit alpine sh -c "while true; do :; done"
docker stats --no-stream no-limit
# CPU: 거의 100% 사용

2단계: CPU 제한 적용

Docker
# CPU 0.3코어로 제한
docker run -d --name limited-cpu --cpus 0.3 \
  alpine sh -c "while true; do :; done"

docker stats --no-stream limited-cpu
# CPU %: 약 30% 수준으로 제한됨 ← 효과 확인!

3단계: 메모리 제한 및 OOMKill 확인

Docker
# 메모리 100MB 제한으로 컨테이너 실행
docker run -d --name limited-mem -m 100m \
  alpine sh -c "
    # dd로 150MB 메모리 할당 시도
    dd if=/dev/zero of=/tmp/bigfile bs=1M count=150
    sleep 3600
  "

# 잠시 후 상태 확인
docker ps -a --filter name=limited-mem
# STATUS: Exited (137) ← OOM Kill로 종료됨!

4단계: OOMKilled 상태 상세 확인

로컬 터미널
docker inspect limited-mem --format '
  컨테이너 상태: {{.State.Status}}
  OOMKilled: {{.State.OOMKilled}}
  종료코드: {{.State.ExitCode}}
'
# 컨테이너 상태: exited
# OOMKilled: true   ← 메모리 초과로 종료 확인!
# 종료코드: 137

5단계: 제한 내에서 정상 동작 확인

Docker
# 50MB만 사용 — 100MB 제한 내이므로 정상 동작
docker run -d --name ok-mem -m 100m \
  alpine sh -c "dd if=/dev/zero of=/tmp/file bs=1M count=50; sleep 3600"

docker stats --no-stream ok-mem
# MEM USAGE: ~50MiB — 정상 동작

위험 명령어실행 중이거나 종료된 실습 컨테이너를 즉시 삭제합니다.

메모리 제한 실습 컨테이너 강제 삭제

안전한 실행 조건: 메모리 제한 실습 결과 확인이 끝났고 삭제 대상이 실습 컨테이너뿐인 경우에만 실행합니다.

실행 전 반드시 확인

  • docker ps -a --filter name=mem으로 대상을 확인했다
  • OOMKilled 상태와 종료 코드를 기록했다
  • 삭제 후 다시 실습을 재현할 수 있다
docker rm -f ok-mem limited-mem

위 항목을 모두 확인한 후 복사할 수 있습니다


docker inspect로 자원 설정과 OOMKilled 상태 확인

컨테이너의 자원 설정값과 종료 원인을 docker inspect로 분석하는 방법을 익힙니다.

1단계: 자원 제한이 설정된 컨테이너 실행

Docker
docker run -d --name inspect-demo \
  --cpus 1.5 \
  -m 256m \
  --memory-swap 512m \
  nginx:alpine

2단계: 자원 설정값 확인

로컬 터미널
docker inspect inspect-demo --format '
메모리 제한: {{.HostConfig.Memory}} bytes
스왑 제한: {{.HostConfig.MemorySwap}} bytes
CPU 제한: {{.HostConfig.NanoCpus}} nanocpus
'
# 메모리 제한: 268435456 bytes (= 256MB)
# 스왑 제한: 536870912 bytes (= 512MB)
# CPU 제한: 1500000000 nanocpus (= 1.5 CPU)

3단계: OOMKill 이력 있는 컨테이너 분석

Docker
# OOMKill 유발
docker run --name oom-demo -m 50m \
  alpine sh -c "dd if=/dev/zero of=/tmp/big bs=1M count=100"

# 종합 상태 분석
docker inspect oom-demo --format '
=== 컨테이너 상태 분석 ===
상태: {{.State.Status}}
시작시각: {{.State.StartedAt}}
종료시각: {{.State.FinishedAt}}
종료코드: {{.State.ExitCode}}
OOMKilled: {{.State.OOMKilled}}
재시작횟수: {{.RestartCount}}
메모리제한: {{.HostConfig.Memory}}
'

4단계: 이벤트 로그에서 OOM 이벤트 확인

로컬 터미널
# Docker 이벤트 스트림에서 OOM 이벤트 확인
docker events --filter type=container --filter event=oom --since 10m
# 2024-01-01T12:00:05 container oom oom-demo (...)

5단계: 정리

위험 명령어OOM 진단에 필요한 컨테이너 상태 정보가 삭제됩니다.

inspect/OOM 실습 컨테이너 강제 삭제

안전한 실행 조건: docker inspect와 docker events 확인을 마친 뒤 실습 컨테이너를 정리할 때만 실행합니다.

실행 전 반드시 확인

  • OOMKilled와 ExitCode 확인을 완료했다
  • docker events 결과를 필요한 만큼 확인했다
  • 삭제 대상이 inspect-demo와 oom-demo뿐임을 확인했다
docker rm -f inspect-demo oom-demo

위 항목을 모두 확인한 후 복사할 수 있습니다


증상

로컬 터미널
docker ps -a
# NAME        STATUS
# my-app      Exited (137) 5 minutes ago ← 갑자기 종료!

docker logs my-app
# (마지막 정상 로그 이후 갑자기 끊김 — 종료 메시지 없음)

원인 분석

ExitCode 137 = 128 + 9 (SIGKILL 시그널 번호) = OOM Killer가 프로세스를 강제 종료한 것입니다.

로컬 터미널
# OOMKilled 여부 직접 확인
docker inspect my-app --format '{{.State.OOMKilled}}'
# true ← 메모리 초과로 OOM Kill 발생

# 호스트 시스템 로그에서도 확인 가능 (리눅스)
sudo dmesg | grep -i "oom\|kill"
# [12345.678] Out of memory: Kill process 9876 (node) ...
# [12345.679] Killed process 9876 (node) total-vm:524288kB ...

해결 방법

1. 현재 메모리 사용량 파악 (제한 설정 전)

Docker
# 메모리 제한 없이 실행하고 정상 부하 시 사용량 측정
docker run -d --name my-app-test my_app_image
docker stats my-app-test --no-stream
# MEM USAGE: 180MiB ← 정상 부하 시 사용량

2. 여유 있는 제한값 설정

Docker
# 정상 사용량(180MB)의 약 1.5배인 300MB로 설정
docker run -d --name my-app -m 300m --restart unless-stopped my_app_image

3. 메모리 누수 근본 원인 해결

로컬 터미널
# 메모리 사용량 추이 모니터링 (메모리 누수 탐지)
docker stats my-app --format "{{.MemUsage}}"
# 지속적으로 증가한다면 → 애플리케이션 메모리 누수 버그 수정 필요

4. 알람 설정 (사전 탐지)

로컬 터미널
# 간단한 모니터링 스크립트 (실제로는 Prometheus/Grafana 활용)
#!/bin/bash
MEM_PERCENT=$(docker stats --no-stream --format "{{.MemPerc}}" my-app | tr -d '%')
if (( $(echo "$MEM_PERCENT > 80" | bc -l) )); then
  echo "경고: my-app 메모리 사용률 ${MEM_PERCENT}% — OOM Kill 위험!"
fi

증상

--cpus 0.1로 제한을 설정했더니 정상 요청도 타임아웃이 발생합니다.

Docker
docker run --cpus 0.1 -p 8080:80 my_api
curl http://localhost:8080/api/data
# curl: (28) Operation timed out after 5000 milliseconds

원인

CPU 제한값이 실제 처리에 필요한 CPU 량보다 너무 낮게 설정되어 있습니다. CPU 스로틀링(throttling)이 발생해 요청 처리 시간이 극도로 길어집니다.

로컬 터미널
# CPU 스로틀링 확인
docker inspect my_api_container \
  --format '{{.HostConfig.NanoCpus}}'
# 100000000 nanocpus = 0.1 CPU ← 너무 낮음

# cgroup에서 직접 확인 (리눅스)
cat /sys/fs/cgroup/cpu/docker/[CONTAINER_ID]/cpu.stat
# throttled_time 12345678  ← 스로틀링 발생 시간(나노초)

해결 방법

1. 실제 CPU 사용량 측정

Docker
# 제한 없이 실행하고 부하 시 CPU 사용량 파악
docker run -d --name api-test my_api
# 실제 요청을 보내며 모니터링
docker stats api-test --no-stream
# CPU %: 35% (4코어 서버 기준 = 약 1.4 CPU 사용)

2. 적정 제한값으로 조정

로컬 터미널
docker rm api-test
# 측정값(1.4 CPU)의 1.5배인 2.0으로 설정
docker run --cpus 2.0 -p 8080:80 my_api

3. cpu-shares로 상대적 우선순위 방식 사용

절대적 제한 대신 상대적 우선순위로 자원을 배분하는 방법도 있습니다.

Docker
# 중요도 높은 서비스: 높은 가중치
docker run --cpu-shares 1024 -d my_api      # 기본값
docker run --cpu-shares 512  -d batch_job   # 절반 우선순위

# 유휴 시간에는 batch_job도 CPU를 최대한 사용하지만,
# 경합이 생기면 my_api가 2배 우선권을 가짐

심화 — 메모리 사용률 숫자 뒤의 진실

💡개념

심화: cgroup 메모리 회계 — RSS와 페이지 캐시는 다르게 다뤄진다

앞서 '메모리 제한을 넘으면 OOM'이라고 배웠습니다. 하지만 cgroup이 '사용량'을 무엇으로 세는지를 모르면, docker stats의 MEM%를 잘못 읽고 멀쩡한 컨테이너를 누수로 오진하기 쉽습니다.

  • 메모리 사용량 = RSS(익명) + 페이지 캐시(파일): cgroup은 앱이 직접 할당한 익명 메모리(RSS)뿐 아니라, 파일을 읽을 때 커널이 채우는 페이지 캐시까지 한도에 포함해 셉니다. 그래서 파일 I/O가 많은 컨테이너는 힙이 작아도 사용량이 한도 근처까지 올라갑니다.
  • 페이지 캐시는 회수 가능(reclaimable)합니다: 메모리 압박이 오면 커널은 OOM으로 죽이기 전에 깨끗한 페이지 캐시를 먼저 비웁니다. 그래서 MEM%가 95%여도 대부분이 캐시라면 OOM은 안 나고 앱도 정상입니다. 반면 RSS(익명)는 스왑이 없으면 회수가 안 돼, 한도까지 차오르면 OOM으로 이어집니다.
  • docker stats의 숫자는 오해를 부릅니다: Docker 버전·cgroup 세대에 따라 MEM USAGE에 회수 가능한 캐시가 섞여 부풀려 보일 수 있습니다. 정확한 구분은 docker stats가 아니라 cgroup의 memory.stat(v2의 anon/file, v1의 rss/cache)에서 나옵니다.
  • 런타임이 한도를 못 볼 때: 오래된 JVM·Node는 컨테이너의 -m 한도가 아니라 호스트 전체 RAM을 기준으로 힙을 잡습니다. 그래서 -m 512m인데 힙을 그보다 크게 잡아 OOM이 납니다 — -Xmx·--max-old-space-size로 명시하거나 컨테이너 인식이 되는 최신 런타임을 씁니다.

정리하면 '메모리 제한'은 하나의 숫자지만, 그 안은 회수 가능한 캐시와 회수 불가능한 워킹셋으로 나뉩니다. 이 둘을 나눠 봐야 '진짜 누수'와 '정상적인 캐시'를 가릅니다.

상황: 로그·업로드 파일을 많이 읽고 쓰는 컨테이너입니다. docker stats에서 MEM USAGE가 한도의 95% 언저리로 오래 유지돼 누수를 의심했지만, OOMKilled도 없고 응답도 정상입니다. 한도를 올려봐도 며칠 뒤 다시 95%에 붙습니다.

원인: 이 사용량의 대부분은 앱이 쓴 메모리가 아니라 파일 I/O로 채워진 페이지 캐시입니다. cgroup은 페이지 캐시도 한도에 포함해 세지만, 캐시는 회수 가능해 커널이 압박 시 먼저 비웁니다. 그래서 MEM%는 높아 보여도 실제 워킹셋(RSS)은 훨씬 작고, 한도를 올려도 캐시가 다시 그만큼 채워질 뿐입니다. TC#1의 '진짜 할당 초과 OOM'과는 성격이 다릅니다.

진단: docker stats 숫자 대신 cgroup의 memory.stat에서 익명 메모리와 캐시를 나눠 봅니다.

Docker
# 컨테이너 cgroup의 메모리 내역 (cgroup v2)
docker exec myapp cat /sys/fs/cgroup/memory.stat | grep -E '^(anon|file) '
# anon 84520960      ← 실제 워킹셋(RSS): 약 80MB, 안정적
# file 402653184     ← 페이지 캐시: 약 384MB, MEM%를 밀어올린 주범

# cgroup v1이면 필드 이름이 다르다
# cat /sys/fs/cgroup/memory/.../memory.stat | grep -E '^(rss|cache) '

anon(RSS)이 시간에 따라 완만하고, file(캐시)이 큰 값이면 정상입니다.

해결: 캐시가 원인이면 한도를 올릴 필요가 없습니다 — 페이지 캐시는 다른 프로세스가 메모리를 필요로 하면 자동 회수됩니다. 모니터링·알람은 MEM% 대신 anon(워킹셋) 추세로 겁니다. 만약 anon이 시간에 따라 계속 우상향한다면 그때가 진짜 누수이므로, 힙 스냅샷으로 원인을 찾고 런타임 힙 옵션(-Xmx·--max-old-space-size)을 한도 안쪽으로 조정합니다.


💼
실무 맥락
현업 패턴

실무에서 자원 제한은 단순한 보호막이 아니라 서비스 안정성 SLA를 보장하는 핵심 설계입니다. 클라우드 인스턴스 비용 최적화와도 직결됩니다.

환경별 자원 제한 설계 접근법

Docker
# 1단계: 기준선(Baseline) 측정
# 프로덕션과 동일한 부하를 줘서 정상 사용량 파악
docker run -d my_service
# 부하 테스트 후 stats 확인
docker stats --no-stream my_service
# CPU: 0.3 CPU, MEM: 180MB

# 2단계: 제한값 설정 (기준선의 1.5~2배)
docker run --cpus 0.5 -m 300m my_service

# 3단계: 스테이징에서 부하 테스트
# 4단계: 프로덕션 적용 + 알림 설정

Docker Compose에서 자원 제한 설정

YAML
version: "3.9"

services:
  api:
    image: mycompany/api:latest
    deploy:
      resources:
        limits:
          cpus: "0.50"
          memory: 256M
        reservations:     # 최소 보장 자원 (스케줄링 힌트)
          cpus: "0.25"
          memory: 128M

  worker:
    image: mycompany/worker:latest
    deploy:
      resources:
        limits:
          cpus: "1.00"
          memory: 512M
        reservations:
          cpus: "0.50"
          memory: 256M

자원 제한과 Kubernetes 연계

Docker의 --cpus-m은 Kubernetes의 resources.limits와 개념적으로 연결되지만, Kubernetes의 requests는 스케줄러가 용량을 예약하는 기준으로 Docker --cpu-shares와 1:1 대응하지 않습니다.

YAML
# Kubernetes Pod 자원 설정 (Docker limits와 개념 비교; requests는 스케줄링 기준)
resources:
  limits:
    cpu: "500m"      # = --cpus 0.5
    memory: "256Mi"  # = -m 256m
  requests:
    cpu: "250m"      # 스케줄러가 예약할 CPU 양( --cpu-shares와 동일하지 않음 )
    memory: "128Mi"

Docker에서 자원 제한에 익숙해지면 Kubernetes 자원 관리도 자연스럽게 이해됩니다. 이 개념은 DevOps/SRE 포지션 면접에서도 자주 출제됩니다.


💡개념

메모리 swap과 OOMKilled — 진단과 대응

Node.js 서비스가 매일 새벽에 갑자기 내려갑니다. 로그는 정상 요청을 처리하다가 중간에 끊깁니다. 에러 메시지도 없습니다. docker ps -a를 보면 Exited (137)입니다. 137은 SIGKILL 시그널로 종료됐다는 의미입니다. docker inspectOOMKilled: true를 확인하면 원인이 명확해집니다. 메모리 제한을 초과하자 Linux 커널의 OOM Killer가 프로세스를 강제 종료한 것입니다. 메모리 누수 버그일 수도 있고, 제한값이 너무 낮게 설정된 것일 수도 있습니다. 이 ConceptBlock에서는 OOMKilled 상태를 진단하는 방법과 메모리 swap 옵션 활용 패턴을 다룹니다.

메모리 swap과 OOMKilled — 진단과 대응확대

OOMKilled란?

컨테이너가 메모리 제한을 초과하면 Linux 커널의 OOM Killer가 컨테이너 프로세스를 강제 종료합니다. 종료 코드는 137(SIGKILL)입니다.

로컬 터미널
# OOMKilled 발생 확인
docker inspect my-container --format '{{.State.OOMKilled}}'
# true ← 메모리 부족으로 강제 종료됨

docker inspect my-container --format '{{.State.ExitCode}}'
# 137 ← SIGKILL

# 실시간 메모리 사용량 모니터링
docker stats my-container --no-stream
# CONTAINER      MEM USAGE / LIMIT     MEM %
# my-container   498MiB / 512MiB       97.3%  ← 한계치 근접!

# 모든 컨테이너 실시간 통계
docker stats

메모리 swap 옵션

Docker
# -m / --memory: 메모리 제한
docker run -m 512m myapp

# --memory-swap: 메모리 + swap 합산 제한
# 주의: --memory-swap은 메모리+swap 합산값이지, swap 크기가 아님!

docker run -m 512m --memory-swap 512m myapp  # swap 비활성화 (메모리만 512MB)
docker run -m 512m --memory-swap 1g myapp    # 메모리 512MB + swap 512MB
docker run -m 512m --memory-swap -1 myapp    # swap 무제한
# --memory-swap 미지정                        # swap = 메모리 × 2 (기본)
--memory-swap 값 계산:
  메모리 512m + swap 512m = --memory-swap 1g
  (swap 크기 = --memory-swap - --memory)

OOMKilled 대응 방법

로컬 터미널
# 1. 메모리 제한 증가 (실행 중에도 적용 가능)
docker update --memory 1g my-container

# 2. 메모리 + swap 모두 증가
docker update --memory 1g --memory-swap 2g my-container

# 3. JVM 기반 앱: JVM 힙 크기 제한
docker run -m 1g \
  -e JAVA_OPTS="-Xmx800m -Xms400m" \
  java-app

# 4. Node.js: max-old-space-size 설정
docker run -m 512m \
  -e NODE_OPTIONS="--max-old-space-size=400" \
  node-app

OOM 발생 전 경보 설정

로컬 터미널
# 간단한 셸 스크립트로 모니터링
watch -n 5 'docker stats --no-stream --format "table {{.Name}}\t{{.MemPerc}}"'

💡개념

CPU 제한 심화 — cpus vs cpu-shares vs cpuset

같은 서버에 API 서버와 데이터 집계 배치 작업이 올라가 있습니다. 배치가 실행될 때마다 API 응답이 느려집니다. --cpus로 배치의 CPU 한도를 고정하면 배치가 빨리 끝나지 않더라도 API가 느려지지 않습니다. 반면 낮 시간에는 배치 실행 빈도가 낮아 CPU 여유가 있는데도 --cpus로 묶어두면 낭비가 됩니다. 이런 상황에서는 --cpu-shares로 경합 시 우선순위만 설정하고 평소에는 자유롭게 쓰게 하는 것이 더 효율적입니다. --cpuset-cpus는 특정 코어에 컨테이너를 고정해야 하는 저지연 서비스에 씁니다. 세 옵션은 각자 다른 문제를 해결합니다. 이 ConceptBlock에서는 세 가지 CPU 제한 방식의 동작 원리와 선택 기준을 다룹니다.

CPU 제한 심화 — cpus vs cpu-shares vs cpuset확대

세 가지 CPU 제한 방식

CPU를 제한하는 옵션이 세 가지 있으며 각자 역할이 다릅니다.

--cpus: 절대적 사용량 제한

컨테이너가 사용할 수 있는 CPU 코어의 최대량을 고정합니다.

Docker
# 최대 1.5개 코어 분량 사용
docker run --cpus 1.5 myapp

# 4코어 서버에서:
# --cpus 1.0 → 전체 CPU의 25% 사용 가능
# --cpus 2.0 → 전체 CPU의 50% 사용 가능
# --cpus 4.0 → 제한 없음 (전체 사용)

--cpu-shares: 경합 시 상대적 우선순위

CPU가 여유 있을 때는 제한이 없고, 경합이 생겼을 때 비율대로 분배합니다.

Docker
# 기본값: 1024
# high-priority: 2048 (기본의 2배)
# low-priority: 512 (기본의 절반)

docker run --cpu-shares 2048 --name critical-service myapp   # 고우선순위
docker run --cpu-shares 512  --name batch-worker myapp       # 저우선순위

# 경합 없을 때: 두 컨테이너 모두 필요한 만큼 CPU 사용
# 경합 있을 때: critical-service가 batch-worker의 4배 CPU 받음

--cpuset-cpus: 특정 코어에 고정

컨테이너를 지정한 CPU 코어에만 실행되도록 고정합니다.

Docker
# CPU 0번, 1번만 사용 (나머지 코어는 다른 컨테이너용)
docker run --cpuset-cpus "0,1" nginx

# CPU 2번부터 3번 (범위 표기)
docker run --cpuset-cpus "2-3" worker

# CPU 0번 하나만 (단일 코어 격리)
docker run --cpuset-cpus "0" realtime-app

세 옵션 비교표

옵션제한 방식CPU 여유 시CPU 경합 시주요 사용 사례
--cpus절대 상한선상한선까지만상한선까지만일반적인 격리
--cpu-shares상대 비율무제한비율대로 분배중요도 차등
--cpuset-cpus코어 고정해당 코어만해당 코어만NUMA 최적화, 저지연

실전 조합 예시

Docker Compose 버전에 따른 컨테이너 자원 제한 설정 필드 계보

여러 서비스를 유기적으로 연결하고 자원을 안정적으로 통제하기 위해, SRE는 Docker Compose YAML 파일 상에 리소스 상한선(Limits)과 예약 수준(Reservations)을 명세해야 합니다. 이때 Docker Compose 버전에 따라 사용하는 스펙 명세법이 상이하므로 아래의 계보 정리 표를 참조하여 혼란을 방지해야 합니다.

Docker Compose 자원 제한 설정 계보:

Compose 버전메모리 제한 키CPU 제한 키구동 형태 및 제약
v2 스펙 (Legacy)mem_limit: 512mcpus: 0.5docker-compose up을 통한 로컬 개발 환경에서 즉시 제어 작동
v3 스펙 (Modern)deploy.resources.limits.memory: 512Mdeploy.resources.limits.cpus: '0.5'deploy 객체 아래 선언. 기본적으로 Swarm/K8s 스택 배포용이나 Compose v2 엔진에서도 호환 가능
YAML
version: '3.8'

services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    deploy:
      resources:
        limits:
          cpus: '0.5'           # 컨테이너가 사용할 수 있는 최대 CPU 코어 수 (0.5개)
          memory: 512M          # 컨테이너가 쓸 수 있는 최대 메모리 임계치 (mem_limit)
        reservations:
          memory: 256M          # 컨테이너가 보장받을 수 있는 최소 물리 메모리
  • 리소스 적용 및 가동: 상위 명세서가 담긴 디렉토리에서 docker-compose up 명령어를 통해 환경을 기동하면, Docker Engine은 cgroups API를 자동으로 호출하여 각 컨테이너 데몬에 물리적 메모리 상한과 CPU 할당 주기를 강제 제어합니다.
Docker
# 중요한 API 서버: 최소 2코어 보장 + CPU 0,1에 고정
docker run -d \
  --name api-server \
  --cpus 2.0 \
  --cpuset-cpus "0,1" \
  api:latest

# 배치 작업: CPU 2,3 사용 + 낮은 우선순위
docker run -d \
  --name batch-job \
  --cpus 2.0 \
  --cpuset-cpus "2,3" \
  --cpu-shares 512 \
  batch:latest

정리

컨테이너 자원 제한의 핵심 명령어를 요약합니다.

명령의미
--cpus 0.5CPU 0.5코어 분량으로 제한
-m 256m메모리 최대 256MB
--memory-swap 512m스왑 포함 최대 512MB
docker stats실시간 자원 사용률 모니터링
docker inspect ... OOMKilledOOM 종료 여부 확인

자원 제한 없이 운영하는 것은 안전벨트 없이 운전하는 것과 같습니다. 모든 컨테이너에 적절한 CPU와 메모리 제한을 설정해 Noisy Neighbor 문제를 예방하세요.


명령어·단축키 빠른 참조

자원 제한 설정과 모니터링·진단에 실제로 쓴 명령을 실전 옵션과 함께 모았습니다.

명령어/단축키용도자주 쓰는 예
docker run --cpusCPU 절대 상한 지정docker run --cpus 0.5 nginx (0.5코어)
docker run -m / --memory-swap메모리·스왑 상한 지정docker run -m 512m --memory-swap 512m myapp (스왑 0)
docker run --cpu-shares경합 시 상대 우선순위docker run --cpu-shares 512 batch (기본 1024의 절반)
docker run --cpuset-cpus특정 코어에 고정docker run --cpuset-cpus "0,1" nginx / "2-3"
docker stats실시간 자원 사용률docker stats --no-stream --format "table {{.Name}}\t{{.MemPerc}}"
docker inspect(OOM)OOM 종료·종료코드 확인docker inspect ctr --format '{{.State.OOMKilled}} {{.State.ExitCode}}' (137=OOM)
docker inspect(설정)적용된 제한값 확인docker inspect ctr --format '{{.HostConfig.NanoCpus}} {{.HostConfig.Memory}}'
docker update실행 중 제한 변경(재시작 불필요)docker update --memory 1g --memory-swap 2g ctr / --cpus 2.0
docker eventsOOM 이벤트 스트림 확인docker events --filter type=container --filter event=oom --since 10m
cat memory.stat캐시 vs RSS 구분(누수 오진 방지)docker exec ctr cat /sys/fs/cgroup/memory.stat | grep -E '^(anon|file) '
cat cpu.statCPU 스로틀 발생 확인cat /sys/fs/cgroup/cpu.statnr_throttled 증가 여부

관련 모듈로 더 깊이:

다음 모듈에서는 컨테이너 로그를 확인하고, 로그 로테이션으로 디스크 100% 장애를 예방하는 방법을 다룹니다.

지식 확인

퀴즈 — 8문제

Q1

`docker run` 명령으로 컨테이너를 실행하면 CPU와 메모리를 얼마나 사용해도 호스트가 허용합니다. 하지만 컨테이너 하나가 메모리를 무한히 사용하면 다른 컨테이너까지 영향을 받습니다. Docker가 컨테이너별로 자원을 격리할 수 있는 리눅스 커널 기능은 무엇인가요?

Q2

`docker run --cpus 0.5` 옵션의 의미는 무엇인가요?

Q3

컨테이너가 메모리 제한을 초과했을 때 발생하는 이벤트는 무엇인가요?

Q4

같은 서버에서 실행 중인 API 서버의 응답 시간이 갑자기 3배 느려졌습니다. API 코드 변경은 없었는데 배치 처리 컨테이너가 새로 배포됐습니다. `docker stats`로 보니 배치 컨테이너가 CPU를 85% 사용 중입니다. 이 상황을 설명하는 용어와 예방 방법은?

Q5

Docker Compose에서 컨테이너 자원 제한을 설정하는 올바른 방법은?

Q6

CPU 제한 옵션 --cpus, --cpu-shares, --cpuset-cpus의 차이로 가장 정확한 것은?

Q7

[심화] -m 512m로 제한한 컨테이너의 docker stats MEM%가 종일 90%를 웃도는데 OOM은 나지 않고 앱도 정상이다. cgroup이 메모리 사용량을 세는 방식으로 가장 정확한 설명은?

Q8

[심화] 파일 I/O가 많은 컨테이너의 MEM%가 며칠째 95%인데 OOMKilled도 없고 응답도 정상이다. 진짜 메모리 누수인지 정상 캐시인지 가리는 가장 확실한 방법은?

0 / 8 답변

🧪 실습으로 확인하기

Docker 자원 제한·OOMKilled — noisy neighbor 격리

중급

자원 제한 없는 컨테이너가 호스트 메모리를 독식해 이웃을 죽인다. 컨테이너별 메모리/CPU 제한(--memory, --cpus)을 걸어 격리하고, OOMKilled(exit 137)를 cgroup·docker stats로 진단하고, 제한과 앱 실제 사용량의 균형을 잡는다. 제한이 너무 낮아 정상 앱이 OOM되는 함정도 다룬다.

40📋 3단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요