Skip to Content

7장. 쓰는 양을 나누다 — cgroup v2, 제한을 걸고 파일로 읽기

메모리 20MB로 제한한 그룹 안에서 100MB를 할당했는데 프로그램이 안 죽었습니다. 스왑을 막고 같은 일을 시켰더니 10MB에서 죽었고요. 제한은 “여기서 죽어라”가 아니라 “여기까지만 램에 두라”는 뜻이었습니다.

{/* 삽화 자리: 바닥에 그은 선(namespace) 옆에, 그것과 별개로 서 있는 배관과 계량기. 선 안의 사람이 수도꼭지에서 물을 받는데, 계량기가 정해둔 눈금에 닿자 물줄기가 가늘어진다. 잠기는 게 아니라 가늘어지는 것이 핵심. 벽에 걸린 장부에 눈금을 넘은 횟수가 적히고 있다. 프롬프트: docs/book/illustration-prompts-container.md */}


배경

6장 끝에서 질문을 남겼습니다. 손으로 그은 그 선 안에서 메모리를 무한정 먹는 프로그램을 돌리면, 선 안에서만 죽을까요 아니면 기계 전체가 느려질까요.

namespace는 이 질문에 답하지 않습니다. 6장에서 확인한 대로 가리기만 하고 막지는 않으니까요. 프로세스 목록을 안 보여주는 것과 메모리를 못 쓰게 하는 것은 다른 일입니다.

답하는 물건은 따로 있습니다. 3장에서 본 그 두 번째 축, cgroups요.

6장에서는 선을 그었습니다. 이 장에서는 그 선 안에서 얼마나 쓸지를 정합니다. 그리고 그 제한이 파일 하나에 숫자로 적혀 있다는 걸 보게 됩니다.

명령은 6장과 같은 환경에서 돌립니다. 다만 플래그가 하나 더 붙어요.

docker run --rm -it --privileged --cgroupns=host alpine:3.20 sh

--cgroupns=host가 필요한 이유는 곧 설명하겠습니다. 5장에서 /proc/<pid>/cgroup의 경로가 관찰 방법에 따라 달라 보였던 것과 같은 사정이에요.

리눅스 서버라면 컨테이너 없이 sudo로 바로 하면 됩니다. --privileged도 --cgroupns=host도 맥에서 Docker Desktop의 리눅스 VM 안으로 들어가려는 우회입니다.

스토리

제한을 걸려면 데몬을 띄우고 설정 파일 문법을 외워야 할 것 같죠. 저도 그런 줄 알았습니다.

cgroup 하나를 만드는 명령은 이겁니다.

mkdir /sys/fs/cgroup/ch07demo

끝입니다. cgroup은 디렉터리거든요. (기능 전체를 부를 때는 cgroups, 그 기능으로 만든 그룹 하나는 cgroup입니다.)

=== 1. cgroup 은 디렉터리고, 제한은 파일이다 === cgroup on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime) 쓸 수 있는 컨트롤러: cpuset cpu io memory hugetlb pids rdma mkdir /sys/fs/cgroup/ch07demo ← 이 한 번으로 cgroup 이 생긴다 cpu.max memory.current memory.events memory.max

✅ 실측 (Docker Engine 29.7.2 / Docker Desktop 4.87.0 / alpine 3.20 / Apple M1 / macOS 26.6, 2026-09. 전체 소스: docs/book/code/container/ch07/cgroup-by-hand.sh)

빈 디렉터리를 만들었을 뿐인데 안에 파일이 수십 개 생겼습니다. 커널이 채워 넣은 거예요. cgroup2라는 가상 파일시스템이라 디스크에 있는 게 아니라 커널이 즉석에서 만들어 보여주는 겁니다.

파일 이름을 보면 규칙이 보입니다. 컨트롤러.항목 꼴이에요. memory.max는 메모리 컨트롤러의 최대치, cpu.max는 CPU 컨트롤러의 최대치입니다.

제한을 거는 방법도 그래서 간단합니다. 파일에 쓰면 됩니다.

echo 20M > /sys/fs/cgroup/ch07demo/memory.max
=== 2. 제한을 건다 === echo 20M > memory.max → 20971520 바이트

✅ 실측 (같은 환경, 같은 소스)

API도, 데몬도, 설정 파일 문법도 없습니다. echo와 리다이렉션이면 끝이에요. Docker가 --memory 20m으로 하는 일도 결국 이 파일에 쓰는 것입니다.

핵심

20MB 안에서 100MB를 써봤습니다

이제 6장의 질문에 답할 차례입니다. 20MB로 제한한 그룹 안에 프로세스를 넣고 100MB를 할당시켜봤어요.

프로세스를 그룹에 넣는 것도 파일 쓰기입니다. cgroup.procs에 PID를 쓰면 그 프로세스가 그 그룹 소속이 됩니다.

sh -c 'echo $$ > /sys/fs/cgroup/ch07demo/cgroup.procs awk "BEGIN{s=\"\"; for(i=0;i<100;i++){ s = s sprintf(\"%1000000s\",\"\") }}"'

서브셸로 감싸는 게 중요합니다. echo $$를 그냥 치면 지금 쓰는 셸이 20MB에 갇혀요.

결과가 예상과 달랐습니다.

=== 3. 100MB 를 할당해 본다 (스왑 허용, 기본값) === memory.swap.max = max 99 MB 100 MB → 안 죽었다. memory.peak = 20971520 바이트 (제한에 딱 붙어 있다) events: low 0 high 0 max 107928 oom 0 oom_kill 0 oom_group_kill 0 스왑으로 나간 양: 190226432 바이트 스왑아웃 페이지 : 1233326

✅ 실측 (같은 환경, 같은 소스)

100MB를 다 할당했는데 안 죽었습니다.

memory.peak을 보세요. 20971520, 정확히 제한값입니다. 딱 맞는 건 우연이 아니에요. memory.max는 hard limit이라 넘으려는 순간 회수부터 하거든요. 회수에 성공하는 한 memory.current는 이 값을 못 넘습니다. (넘어가게 두고 대신 속도를 늦추는 제한도 따로 있습니다. memory.high요.)

max가 107,928입니다. 제한에 부딪힌 횟수고, 그때마다 커널이 회수를 돌렸어요. 회수된 페이지가 어디로 갔는지도 찍혀 있습니다. 스왑으로 181MB가 나갔습니다. 램에는 20MB만 두고 나머지를 디스크로 밀어낸 거예요.

oom_kill이 0인 게 그래서입니다. 죽일 필요가 없었거든요.

다만 이 결과는 스왑이 있는 기계에서만 나옵니다. 제 VM은 스왑이 2GB 붙어 있어요. 클라우드 리눅스 서버는 대개 스왑이 0입니다. 거기서는 memory.swap.max가 max여도 내보낼 데가 없어서 다음 절과 같은 결과가 나와요. 변수는 설정이 아니라 회수할 곳이 있느냐입니다.

스왑을 막으면

같은 실험을 하나만 바꿔서 다시 했습니다. memory.swap.max를 0으로요.

rmdir /sys/fs/cgroup/ch07demo && mkdir /sys/fs/cgroup/ch07demo # 카운터 리셋 echo 20M > /sys/fs/cgroup/ch07demo/memory.max echo 0 > /sys/fs/cgroup/ch07demo/memory.swap.max
=== 4. 스왑을 막고 같은 일을 시킨다 === 9 MB 10 MB Killed events: low 0 high 0 max 46 oom 1 oom_kill 1 oom_group_kill 0 memory.peak = 20971520 (제한에 못 닿고 죽었다)

✅ 실측 (같은 환경, 같은 소스)

10MB에서 Killed입니다. oom 1, oom_kill 1.

차이가 하나뿐인데 결과가 완전히 갈립니다. 회수할 곳이 있으면 느려지고, 없으면 죽어요.

그래서 memory.max는 “여기서 죽어라”가 아닙니다. “이 그룹의 페이지를 램에 이만큼 넘게 두지 마라”입니다. 넘으려 하면 먼저 회수하고, 회수할 데가 없을 때 비로소 죽입니다.

그런데 docker run --memory는 다릅니다

여기까지 읽고 “그럼 컨테이너에 메모리 제한을 걸어도 안 죽고 느려지기만 하겠네”라고 생각하셨다면, 한 가지가 더 있습니다.

--memory는 memory.max만 쓰는 게 아니에요.

=== 6. 그런데 docker run --memory 는 무엇을 쓰나 === memory.max = 20971520 memory.swap.max = 20971520 ← 스왑도 같이 막힌다 18 MB OOMKilled=true ExitCode=137

✅ 실측 (같은 환경, 같은 소스)

memory.swap.max가 같은 값으로 들어갑니다. 그래서 3절이 아니라 4절 쪽이에요. 같은 awk를 docker run --memory 20m으로 돌리면 18MB에서 죽고, OOMKilled=true에 종료 코드 137입니다.

여는 글에서 본 그 137이죠. 거기서는 SIGKILL이 시그널로 왔고, 여기서는 메모리 제한 때문에 왔습니다. 같은 숫자가 다른 이유로 나오는 그 이야기, 13장에서 JVM으로 다시 만납니다.

그동안 바깥은

6장 질문의 나머지 절반입니다. 기계 전체가 느려졌을까요.

=== 5. 그동안 바깥은 === VM 의 available 메모리 7429 MB

✅ 실측 (같은 환경, 같은 소스. 여기서 “바깥”은 맥이 아니라 Docker Desktop의 리눅스 VM입니다)

램은 멀쩡합니다. 제한이 그 그룹에만 걸렸으니까요.

다만 “완전히 격리됐다”는 아닙니다. 그 그룹은 호스트 스왑을 181MB 썼고, 페이지를 밀어내고 다시 읽느라 디스크를 두들겼어요. memory.max가 나누는 건 램이지 디스크 대역폭이 아닙니다. 그걸 나누는 건 아래 표의 io.max고요. 선은 여기서도 벽이 아닙니다.

이게 1장과 다른 지점입니다. 1장에서 chroot 안의 프로세스는 바깥 프로세스를 죽였어요. 가리기만 하는 장치는 그런 걸 못 막습니다. cgroups는 다릅니다. 애초에 “얼마나 쓸 수 있는가”를 묻는 물건이고, 그 답이 강제됩니다.

3장에서 두 축을 나눠 설명한 이유가 이겁니다. namespace는 안 보이게 하고, cgroups는 못 하게 합니다.

왜 --cgroupns=host가 필요했나

앞에서 미뤄둔 설명입니다.

5장에서 /proc/<pid>/cgroup의 경로가 관찰 방법에 따라 달라 보였죠. 0::/../b8d9…로 나오기도 하고 0::/docker/b8d9…로 나오기도 했습니다. cgroup namespace 때문입니다.

cgroup namespace는 cgroup 계층의 뿌리를 바꿔 보여줍니다. 컨테이너 안에서는 자기 cgroup이 /로 보여요. 자기 위로 올라갈 수 없고, 형제 그룹도 안 보입니다.

다만 실험이 막히는 직접 원인은 namespace 자체가 아니라 cgroup v2의 규칙 하나입니다. 루트가 아닌 cgroup은 자기 안에 프로세스를 들고 있으면 자식에게 컨트롤러를 넘길 수 없어요. 컨테이너의 cgroup에는 컨테이너 프로세스가 들어 있으니 넘길 수가 없죠. echo +memory > cgroup.subtree_control을 해보면 Resource busy가 납니다. 그 상태에서 만든 자식 디렉터리에는 memory.max가 아예 안 생기고, 없는 파일에 쓰려니 Permission denied가 나요.

--cgroupns=host는 그 선을 한 겹 걷어내 진짜 루트를 보여줍니다. 루트 cgroup은 이 규칙이 면제거든요.

5장에서 본 그 목록에 cgroup -> cgroup:[4026532559]가 있었던 게 이겁니다. 자원 그룹을 나누는 물건과, 그 그룹 계층을 얼마나 보여줄지 정하는 물건이 따로 있는 거죠. 이름이 같아서 헷갈릴 뿐입니다.

메모리 말고 또 무엇을

같은 구조가 다른 자원에도 그대로 적용됩니다.

파일무엇을 정하나이 책에서
memory.max램에 둘 수 있는 최대이 장, 13장
memory.swap.max스왑으로 밀어낼 수 있는 최대이 장
cpu.max주기마다 쓸 수 있는 CPU 시간14장
pids.max만들 수 있는 프로세스·스레드 수—
io.max장치별 읽기·쓰기 대역폭과 IOPS—

읽는 쪽 파일도 짝으로 있습니다. memory.current는 지금 쓰는 양, memory.peak은 최대치, memory.events는 제한에 부딪힌 횟수와 죽인 횟수예요.

제한을 걸었으면 이 파일들을 봐야 합니다. 방금 실험에서 oom_kill이 0이냐 1이냐가 전혀 다른 이야기였잖아요. 겉으로는 “컨테이너가 느리다”와 “컨테이너가 죽었다”로만 보이지만, 원인은 이 파일에 숫자로 적혀 있습니다.

2장에서 PHK가 커밋 메시지에 남긴 문장을 기억하시나요. 자원 독차지를 막을 장치가 없다고요. 그 빈칸을 리눅스가 채우는 데 9년이 걸렸습니다(2008년 커널 2.6.24). 이름이 지금의 memory.max로 정리된 건 그로부터 또 8년 뒤, cgroup v2에서고요.

정리

  • cgroup v2에서 cgroup을 만드는 일은 mkdir입니다. 커널이 그 디렉터리에 제어 파일을 채워 넣어요.
  • 제한을 거는 일은 파일 쓰기입니다. echo 20M > memory.max면 끝이고, Docker의 --memory도 결국 이 파일에 씁니다.
  • 프로세스를 그룹에 넣는 것도 파일 쓰기입니다. cgroup.procs에 PID를 씁니다.
  • memory.max는 “여기서 죽어라”가 아니라 “램에 이만큼 넘게 두지 마라”입니다. 실측에서 20MB 제한에 100MB를 할당했는데 안 죽었고, 스왑으로 181MB가 나갔습니다.
  • 다만 docker run --memory는 memory.swap.max도 같은 값으로 막습니다. 그래서 같은 프로그램이 OOMKilled=true, 종료 코드 137로 죽어요.
  • 스왑을 막으면(memory.swap.max = 0) 같은 프로그램이 10MB에서 죽습니다. 회수할 데가 없으면 그때 죽여요.
  • 그동안 호스트는 멀쩡합니다. 1장의 chroot와 다른 지점이고, namespace와 cgroups를 나눠 부르는 이유입니다.
  • 무슨 일이 있었는지는 memory.events에 숫자로 적힙니다. oom_kill이 0이냐 1이냐가 “느린 것”과 “죽은 것”을 가릅니다.

생각해볼 질문

여기까지 2부의 절반입니다. 커널이 긋는 선 두 종류를 손으로 만들어봤어요. 보이는 것을 나누는 선과, 쓰는 양을 나누는 선이요.

그런데 아직 컨테이너가 아닙니다. 6장 마지막에 말했듯 루트 파일 시스템이 여전히 바깥 것이거든요. unshare로 선을 다 그어도, 그 안에서 ls / 하면 호스트의 /가 보입니다.

컨테이너는 자기만의 /를 갖습니다. 그게 어디서 오냐면 이미지죠. 4장에서 본 그 레이어들이요.

그런데 이상한 점이 있습니다. 4장 실측에서 두 이미지가 레이어 아홉 겹 중 여덟 겹을 공유했어요. 그 이미지로 컨테이너를 띄우면 두 컨테이너가 같은 파일을 보고 있다는 뜻이고요.

그러면 한쪽 컨테이너가 그 공유 파일을 고치면 어떻게 될까요. 옆 컨테이너도 같이 바뀔까요.

▶ 8장. 이미지는 켜켜이 쌓인다 — 레이어·OverlayFS·copy-on-write

Last updated on