How Docker Works Under the Hood: Watch Namespaces Isolate a Container and cgroups Stop a Fork Bomb
Docker는 내부에서 어떻게 동작할까: 네임스페이스로 컨테이너를 격리하고 cgroups로 포크 폭주를 막는 방법
컨테이너는 별도 커널을 부팅하는 가상 머신이 아니라 Linux 프로세스입니다. namespaces가 프로세스가 보는 환경을 분리하고 cgroups가 자원 사용량을 제한하는 과정을 시뮬레이터와 Linux 명령으로 보여주며, pids 제한이 실제 포크 폭주를 막는 사례도 설명합니다.
- 주제
에디터 노트
네임스페이스와 cgroup을 서로 다른 역할로 구분한 설명이 이 글의 가장 설득력 있는 부분입니다. 네임스페이스는 프로세스가 보는 범위를 바꾸지만 프로세스 생성량을 막지 않으며, pids.max는 cgroup 안의 task 수를 제한합니다. 10 task 제한에서 부모와 자식 9개가 채워진 뒤 EAGAIN이 발생한 예시는 이 차이를 명령과 결과로 확인해 줍니다. 다만 댓글에서 제기한 microVM 비교는 격리 경계가 다르다는 보완이지, 컨테이너가 안전하지 않다는 판정은 아닙니다. 원문도 컨테이너와 별도 커널을 부팅하는 VM의 보안 수준을 비교하지 않고 Linux 프로세스의 구성 요소를 설명합니다. 또한 제한은 controller가 활성화되고 실제 cgroup에 적용돼야 작동합니다. 메모리 controller가 꺼진 Pi 사례와 pids.max 확인 명령은 설정값만 보고 제한이 적용됐다고 단정할 수 없는 이유를 구체적으로 보여줍니다.
AI 요약
컨테이너 안에서 실행한 nginx는 호스트의 ps aux 목록에 일반 프로세스와 같은 PID로 나타납니다. 별도 게스트 커널이나 하이퍼바이저 없이 Linux 커널의 기능으로 컨테이너를 구성하기 때문입니다. namespaces는 프로세스가 보는 세계를 분리하고 cgroups는 사용할 자원을 제한합니다. 글은 이 과정을 브라우저 시뮬레이터 두 개와 Docker 없이 실행할 수 있는 Linux 명령으로 설명합니다. 작성자는 시뮬레이터 개발에 참여한다고 밝힙니다.
docker run에서 컨테이너까지
첫 번째 시뮬레이터는 docker run -p 8080:80 nginx 명령을 CLI, Docker daemon, 이미지 가져오기, containerd, OCI bundle, runc, 실행 중인 컨테이너, Linux 커널의 8단계로 나눕니다. 이미지를 내려받은 상태로 설정하면 registry 단계가 빠집니다. 이미지가 캐시에 없을 때만 가져오며 누락된 layer만 내려받는다는 설명입니다.
Docker CLI는 /var/run/docker.sock을 통해 dockerd에 HTTP 요청을 보냅니다. dockerd가 직접 프로세스를 시작하는 대신 containerd에 작업을 넘기면 containerd가 이미지 layer를 snapshot으로 풀고 컨테이너 수명주기를 관리합니다. OCI bundle에는 config.json과 root filesystem이 들어갑니다. 설정 예시에는 pid, network, mount, uts, ipc 네임스페이스가 나옵니다. 커널의 /proc/<pid>/ns와 lsns에서는 각각 net, mnt라는 이름도 확인할 수 있습니다.
runc는 네임스페이스와 cgroup을 만들고 root filesystem으로 전환한 뒤 capability를 줄입니다. 이어 nginx를 컨테이너 내부 PID 1로 실행하고 종료합니다. 이후 shim이 컨테이너와 containerd의 연결을 유지합니다. 글은 이를 namespaces, cgroups, networking, mounts 네 요소로 정리합니다.
네임스페이스는 격리하고 제한하지 않습니다
Linux 프로세스마다 /proc/self/ns 아래 네임스페이스 링크가 있습니다. 링크의 번호가 같으면 두 프로세스가 같은 네임스페이스를 공유합니다. util-linux의 unshare 명령으로 새 네임스페이스를 만들 수도 있습니다. 예를 들어 Debian 12에서 사용자 네임스페이스와 PID 네임스페이스를 함께 만들면 ps는 프로세스가 PID 1이고 root로 실행 중이라고 표시합니다. 실제로는 PID 네임스페이스가 번호를 1부터 다시 매기고 사용자 네임스페이스가 일반 UID를 그 안에서만 root로 대응합니다. UTS 네임스페이스에서 hostname을 바꾸면 네임스페이스 안에서는 demo-box로 보이지만 호스트에서는 raspberrypi로 유지됩니다. 배포판이 비특권 사용자 네임스페이스를 제한하면 sudo를 쓰고 --user와 --map-root-user 옵션을 빼라고 안내합니다.
네임스페이스는 보이는 대상을 숨기지만 프로세스 수를 제한하지 않습니다. PID 네임스페이스 안의 프로세스도 호스트의 프로세스 테이블이 가득 찰 때까지 새 프로세스를 만들 수 있습니다. 글은 실제 fork bomb을 실행하지 말라고 명시하고, 프로세스 트리만 그리는 시뮬레이터를 사용합니다. PID 한도를 64에서 1024 사이로 설정할 수 있고 기본값은 512입니다. 한도에 도달하면 fork()가 실패하는 상황을 보여줍니다.
pids.max로 포크 폭주 제한하기
시뮬레이터는 ulimit -u, cgroups의 pids.max, systemd unit의 TasksMax를 보호 수단으로 제시합니다. 시뮬레이션에서는 각각 프로세스 수를 100으로 제한합니다. 다만 전체 PID 한도가 64라면 보호 설정이 100이어도 시스템이 먼저 한도에 도달합니다.
실제 시스템에서는 세 설정이 세는 범위가 다릅니다. ulimit -u는 머신 전체에서 해당 사용자 ID가 소유한 프로세스와 스레드를 세며 root에는 커널이 이 제한을 적용하지 않습니다. 작성자는 Raspberry Pi에서 ulimit -u 50을 설정했을 때 해당 사용자가 이미 다른 곳에서 191개 task를 소유해 첫 fork()부터 실패했다고 설명합니다. 반면 pids.max와 TasksMax는 소유자와 관계없이 한 cgroup 안의 task 수를 제한합니다.
cgroup v2에서 사용자 systemd manager에 pids controller가 위임됐다면 systemd-run --user --scope -p TasksMax=10으로 범위를 제한할 수 있습니다. 글의 Python 예제는 자식 프로세스 50개를 만들려 하지만 부모를 포함해 task가 10개가 되자 9개 자식만 만든 뒤 fork()에서 EAGAIN 오류를 받습니다. pids.events의 max 값은 제한으로 거부된 fork 횟수를 기록하므로 글은 이를 감시 대상으로 제시합니다.
Docker는 --pids-limit 100 옵션으로 컨테이너 cgroup의 프로세스 수를 제한합니다. 옵션이 없으면 호스트에 이미 적용된 제한만 남습니다. 기본값을 추측하는 대신 cgroup v2 호스트에서 docker run --rm alpine cat /sys/fs/cgroup/pids.max로 컨테이너 한도를 확인하라고 설명합니다. 출력이 max면 제한이 없습니다. 또한 cgroup에 제한이 설정되지 않았으면 보호하지 못하며 PID 제한은 CPU나 메모리 사용량을 제한하지 않습니다. 메모리 제한도 해당 controller가 활성화돼야 적용됩니다. 작성자의 Pi에서는 cgroup_disable=memory 설정 때문에 MemoryMax=50M을 지정해도 프로세스가 100 MiB를 할당했습니다.
dev.to 반응
- @dhruonweb — "커널이 속아 주는 일반 Linux 프로세스"는 제가 읽은 컨테이너 설명 중 한 줄로 가장 잘 표현한 문장입니다. ps aux에서 nginx가 일반 호스트 PID로 보이는 장면이 증거로 와닿습니다. microVM 모델과 비교하기에도 좋습니다. Krova Cloud의 A Cube는 Firecracker microVM이라 자체 커널을 부팅합니다. 그래서 호스트 PID를 찾을 수 없고 공유 syscall 표면도 없습니다. 실제 부팅 시간과 메모리 비용을 치릅니다. 글에 추가할 만한 점은 trade-off가 "컨테이너는 안전하지 않다"가 아니라는 겁니다. 네임스페이스는 보이는 대상을 격리하고 하이퍼바이저는 커널을 격리합니다. 대부분의 작업에는 전자가 필요합니다.
- @amorizz — Compose 글을 시작할 때 네임스페이스 시연부터 보여주는 경우가 더 많았으면 합니다. VPS에서 겪는 문제 하나는 디버깅을 단순하게 하려고 docker run --network host를 쓰면 네트워크 네임스페이스에서 벗어난다는 점입니다. 그러면 포트 바인딩이 bridge를 쓰는 운영 Compose 환경과 달라집니다. 저는 컨테이너 안과 호스트에서 ls -l /proc/$$/ns/net을 실행합니다. inode가 다르면 여전히 격리된 상태입니다. cgroup이 fork bomb을 멈추는 사례와 같은 방식으로 healthcheck가 초록색인지 보는 대신 stat으로 경계를 확인합니다. 글에서는 ns inode도 보여주나요, 아니면 unshare 명령만 보여주나요?
- @argumentmoney8117 — ulimit -u의 함정을 실제로 겪어야 배우는 경우가 많은데, Pi에서 사용자가 이미 다른 곳에 191개 task를 가지고 있어서 첫 fork부터 실패한 사례가 구체적입니다. 마지막의 pids.events 팁은 운영 runbook에 넣고 싶습니다. task 수가 아니라 거부된 fork를 세세요. 좋은 글입니다.
원문: dev.to / 번역·요약: Trawling