컨테이너 하나에 메모리 제한 512Mi를 걸어놓고 배포했는데, 실제 트래픽이 몰리면 그 한도를 넘길 때 서비스가 어떻게 반응할지 로컬에서 미리 확인해보고 싶을 때가 있다. 스왑이 있으면 조용히 스왑아웃되다 끝날 수도 있고, 스왑이 없으면 프로세스가 통째로 죽을 수도 있어서 실제로 재현해보지 않으면 장애 시나리오를 예측하기 어렵다. Docker나 Kubernetes의 컨테이너 메모리 제한은 결국 커널의 cgroups v2 memory 컨트롤러를 그대로 쓰는 것이므로, 이 글에서는 cgroup을 직접 만들어 메모리 고갈 상황을 재현하고 OOM kill이 실제로 발생하는 과정을 정리한다.
cgroups v2 memory 컨트롤러 기본
Ubuntu 24.04은 기본으로 cgroup v2 통합 계층(/sys/fs/cgroup)을 쓴다. 메모리 관련 주요 파일은 다음과 같다.
| 파일 | 역할 |
|---|---|
| memory.max | 하드 리밋(바이트). 초과 시 회수(reclaim) 시도 후 실패하면 OOM kill |
| memory.current | 현재 사용량(바이트) |
| memory.swap.max | 이 cgroup이 쓸 수 있는 스왑 상한. 0이면 스왑 없이 순수 메모리 한도만 적용 |
| memory.events | max(한도 도달 횟수), oom/oom_kill(OOM 발생·kill 횟수) 등 누적 카운터 |
| cgroup.procs | 이 cgroup에 속한 PID 목록. PID를 쓰면 해당 프로세스가 즉시 이 cgroup으로 이동 |
메모리 고갈 재현하기
테스트용 cgroup을 만들고 100MiB로 제한한 뒤, stress-ng로 200MB를 계속 점유하는 프로세스를 그 안에서 실행했다. 프로세스를 cgroup에 넣을 때는 이미 실행 중인 PID를 옮기는 대신, 서브셸에서 자신의 PID를 먼저 cgroup.procs에 기록한 뒤 exec로 대상 프로그램을 실행해야 한다. 그래야 이후 fork되는 워커 프로세스까지 같은 cgroup을 상속받는다.
sudo mkdir /sys/fs/cgroup/memtest
echo 100M | sudo tee /sys/fs/cgroup/memtest/memory.max
sudo bash -c '
bash -c "echo \$\$ > /sys/fs/cgroup/memtest/cgroup.procs; \
exec stress-ng --vm 1 --vm-bytes 200M --vm-keep --timeout 15s"
'
이 상태로 첫 실행에서는 15초 뒤 successful run completed로 정상 종료됐고 memory.events의 oom_kill은 0이었다.
$ cat /sys/fs/cgroup/memtest/memory.events
low 0
high 0
max 2761
oom 0
oom_kill 0
oom_group_kill 0
sock_throttled 0
max 카운터(한도 도달 횟수)는 2761로 올라갔지만 kill은 없었다. VM에 4GB 스왑이 붙어 있어서, 100MB 한도를 넘는 익명 페이지를 커널이 스왑아웃해 회수하는 것만으로 한도를 지켰기 때문이다. 스왑 없이 진짜 OOM kill을 보려면 이 cgroup의 스왑 사용을 0으로 막아야 한다.
echo 0 | sudo tee /sys/fs/cgroup/memtest/memory.swap.max
같은 명령을 다시 실행하자 이번엔 실제로 OOM kill이 반복됐다.
$ cat /sys/fs/cgroup/memtest/memory.events
low 0
high 0
max 12629
oom 255
oom_kill 255
oom_group_kill 0
sock_throttled 0
$ dmesg | tail -3
Memory cgroup out of memory: Killed process 5637 (stress-ng-vm) total-vm:251244kB, anon-rss:98596kB, file-rss:1264kB, shmem-rss:16kB, UID:0 pgtables:276kB oom_score_adj:1000
Memory cgroup out of memory: Killed process 5638 (stress-ng-vm) total-vm:251244kB, anon-rss:98604kB, file-rss:1264kB, shmem-rss:16kB, UID:0 pgtables:276kB oom_score_adj:1000
Memory cgroup out of memory: Killed process 5639 (stress-ng-vm) total-vm:251244kB, anon-rss:98600kB, file-rss:1280kB, shmem-rss:80kB, UID:0 pgtables:276kB oom_score_adj:1000
stress-ng가 --vm-keep 옵션으로 죽은 워커를 계속 재생성하다 보니 15초 동안 255번 죽고 다시 뜨는 게 반복됐다. 매번 anon-rss가 100MiB 한도(104857600바이트) 바로 아래인 98MB 근처에서 죽은 것도 확인할 수 있다 — 한도를 살짝 넘기려는 순간 커널이 개입한다는 뜻이다.
주의사항
| 항목 | 내용 |
|---|---|
| 스왑이 결과를 바꾼다 | 스왑이 있으면 한도 초과 시 kill 대신 스왑아웃으로 넘어가는 경우가 많다. 프로덕션과 동일하게 재현하려면 대상 환경의 스왑 설정도 맞춰야 함 |
| 대상 프로세스 선정 기준 | cgroup 내에 여러 프로세스가 있으면 OOM killer는 oom_score_adj와 메모리 사용량을 기준으로 희생자를 고른다. 컨테이너 런타임이 이 값을 조정하는 경우도 있으니 실제 배포 환경의 설정을 확인할 것 |
| cgroup.procs로 이미 실행 중인 프로세스 이동 | fork 전에 자신의 PID를 먼저 등록해야 자식까지 상속된다. 이미 fork된 자식 프로세스가 있는 부모만 옮기면 자식은 원래 cgroup에 남는다 |
| 테스트 후 정리 | 빈 cgroup 디렉터리는 rmdir로 제거한다. 프로세스가 남아있으면 제거되지 않으므로 먼저 종료 확인 필요 |
마무리
cgroup을 직접 만들어 memory.max와 memory.swap.max를 조합하면 컨테이너 메모리 제한이 실제로 초과됐을 때의 동작(스왑아웃으로 버티는지, OOM kill로 끝나는지)을 배포 전에 재현해볼 수 있다. Docker --memory나 Kubernetes resources.limits.memory도 내부적으로 같은 인터페이스를 쓰므로, 이 방식으로 먼저 로컬에서 한도를 검증해두면 실제 장애 상황에서 memory.events와 dmesg를 어디서 봐야 할지 미리 알고 대응할 수 있다.