A container is just a process with a few private views. I built one in thirty lines.
컨테이너는 몇 가지 비공개 뷰를 가진 프로세스일 뿐입니다 — 30줄로 직접 만들어 봤습니다
컨테이너는 별도의 커널 객체나 가상 머신이 아니라, Linux 프로세스에 namespace와 cgroup을 적용한 구조입니다. 글에서는 Docker 없이 약 30줄의 코드로 PID·mount·network·hostname 격리와 20MB 메모리 제한을 직접 구현하며, 컨테이너와 VM의 차이를 설명합니다.
- 주제
AI 요약
컨테이너를 실행하는 방법은 익숙하지만, 커널이 컨테이너를 실제로 무엇으로 다루는지 설명하기는 어렵습니다. 이 글은 컨테이너가 독립적인 커널 객체나 작은 가상 머신이 아니라, 일반 프로세스에 자신만의 리소스 뷰를 몇 가지 제공한 것이라는 점을 직접 구현으로 확인합니다. 작성자는 Docker를 사용하지 않고 약 30줄의 코드와 Linux 커널 기능만으로 프로세스를 PID 1로 만들고, 다른 호스트 프로세스를 보지 못하게 하며, 별도의 네트워크와 root filesystem을 제공하고, 메모리 사용량을 20MB로 제한합니다.
■ namespace로 프로세스의 세계를 나눕니다
컨테이너 격리의 핵심은 namespace입니다. namespace는 시스템 전체가 공유하던 전역 리소스의 특정 종류를 프로세스만의 뷰로 분리합니다. Linux에는 PID, mount, network, UTS(hostname), IPC, user namespace 등이 있습니다. 일반 프로세스는 이 리소스를 호스트의 다른 프로세스와 함께 보지만, `unshare` 명령을 사용하면 새로운 namespace를 요청할 수 있습니다.
작성자가 사용한 격리 명령은 다음과 같습니다.
`unshare --user --map-root-user --mount --uts --ipc --pid --fork --net python3 enter.py rootfs`
각 옵션은 하나의 격리만 담당합니다. `--uts`는 컨테이너 안에서 hostname을 `container`로 바꿔도 호스트에는 영향을 주지 않게 합니다. `--pid --fork`는 별도의 프로세스 테이블을 만들며, `--fork` 때문에 `unshare` 자체가 아니라 자식 프로세스가 새 PID namespace의 PID 1이 됩니다. `--net`은 비어 있는 network stack을 제공하므로 내부에는 down 상태의 loopback 인터페이스만 남습니다. `--mount`는 독립적인 mount 목록을 만들어 나중에 filesystem을 교체할 수 있게 합니다.
`--user --map-root-user`는 특히 중요합니다. 이 옵션은 user namespace를 만들고, 호스트에서 사용 중인 일반 로그인 사용자를 namespace 내부의 root로 매핑합니다. 따라서 호스트에서는 특별한 권한이 없는 사용자로 남아 있으면서도, 컨테이너 내부에서는 mount나 `pivot_root` 같은 작업을 수행할 수 있습니다. 실행 결과 컨테이너 내부의 hostname은 `container`, PID 1은 `/bin/sh`가 됩니다. 호스트에서는 87개의 프로세스가 보이더라도 컨테이너 안에서는 셸과 `ps` 프로세스 정도만 보이며, network namespace에는 loopback 하나만 존재합니다. 작성자는 이 결과를 통해 격리가 별도의 복잡한 시스템을 만드는 일이 아니라, 커널에 namespace를 하나씩 요청하는 일이라고 설명합니다.
■ chroot가 아니라 pivot_root를 사용합니다
컨테이너를 장난감 수준과 실제 컨테이너로 나누는 부분은 root filesystem 교체입니다. `chroot`는 프로세스가 볼 수 있는 `/`를 바꿀 수 있지만, 충분한 권한을 가진 프로세스가 기존 root filesystem으로 되돌아갈 수 있다는 한계가 있습니다. 기존 root가 완전히 사라진 것이 아니라 새 root 아래에 숨겨져 있기 때문입니다.
실제 runtime은 이 문제를 피하기 위해 `pivot_root`를 사용합니다. `pivot_root`는 root mount를 새 filesystem으로 교체한 뒤 기존 root를 별도 디렉터리로 이동시키고, 그 디렉터리를 unmount할 수 있게 합니다. 작성자의 `enter.py`는 시스템에 편리한 wrapper가 없어서 libc를 통해 syscall을 직접 호출합니다. 먼저 `/`를 private mount로 다시 설정해야 하며, 새 root가 mount point가 되도록 rootfs 자체에 bind mount를 걸어야 합니다. 이후 작업은 다음 순서로 진행됩니다.
`mount(None, "/", None, MS_REC | MS_PRIVATE)`
`mount(rootfs, rootfs, None, MS_BIND | MS_REC)`
`os.chdir(rootfs)`
`sc("pivot_root", b".", b"oldroot")`
`os.chroot("/")`
`sc("umount2", b"/oldroot", MNT_DETACH)`
마지막 unmount가 끝나면 호스트 filesystem은 단순히 숨겨진 상태가 아니라 컨테이너의 mount namespace에서 제거됩니다. 따라서 `/oldroot`를 조회하면 `No such file or directory`가 반환되고, 남은 mount는 새 root와 새 `/proc` 정도가 됩니다. 글은 이 지점이 단순한 `chroot`와 컨테이너의 차이를 만드는 부분이라고 설명합니다.
■ /proc를 잘못 마운트하면 격리가 된 것처럼 보입니다
작성자는 처음에 `unshare --mount-proc`를 사용해 `/proc`를 쉽게 마운트했습니다. 이후 컨테이너 안에서 `ps`를 실행하자 프로세스가 하나도 나오지 않았고, 매우 강력한 격리가 완성됐다고 생각했습니다. 그러나 실제로는 PID namespace가 비어 있었던 것이 아니라, `ps`가 빈 디렉터리를 보고 있었습니다.
`--mount-proc`가 root swap과 `chroot`보다 먼저 `/proc`에 마운트되었기 때문에, rootfs를 바꾼 뒤 프로세스는 새 rootfs 아래의 다른 빈 `proc` 디렉터리를 보고 있었습니다. 해결 방법은 `pivot_root`를 수행한 뒤 새 root 내부에서 `/proc`를 마운트하는 것입니다. 그러면 `/proc`가 컨테이너의 PID namespace를 반영하고, `ps`에는 실제로 컨테이너 안에 존재하는 두 프로세스가 표시됩니다. 작성자는 프로세스가 0개로 보이는 결과보다 2개로 보이는 결과가 PID namespace가 제대로 작동한다는 증거라고 설명합니다.
■ cgroup으로 보이는 범위가 아니라 사용할 수 있는 양을 제한합니다
namespace는 프로세스가 무엇을 볼 수 있는지를 제어하지만, 프로세스가 호스트 자원을 얼마나 사용할 수 있는지는 제한하지 않습니다. 이 역할은 cgroup이 맡습니다. 글에서는 namespace를 “프로세스가 볼 수 있는 것”을 관리하는 기능으로, cgroup을 “프로세스가 가져갈 수 있는 것”을 관리하는 기능으로 구분합니다.
cgroup v2의 메모리 제한은 `memory.max` 파일에 바이트 수를 기록하는 방식으로 적용할 수 있습니다. 작성자는 정적 링크한 작은 메모리 할당 프로그램을 rootfs에 넣고, 1MB씩 메모리를 할당하면서 각 페이지를 실제로 건드리도록 했습니다. 메모리 한도를 20MB로 설정한 뒤 실행한 결과는 다음과 같습니다.
`cap is 20MB. starting the memory hog as a child...`
`allocated 5 MB`
`allocated 10 MB`
`allocated 15 MB`
`Killed`
`hog exited with code 137`
종료 코드 137은 128에 신호 번호 9를 더한 값이므로 `SIGKILL`을 의미합니다. cgroup의 메모리 한도를 넘는 순간 커널의 OOM 처리로 메모리 소비 프로세스가 종료되며, 컨테이너의 PID 1인 셸은 계속 실행됩니다. 처음에는 anonymous memory가 아니라 tmpfs에 데이터를 기록했기 때문에 문제가 발생했습니다. tmpfs 페이지는 기록한 프로세스가 종료되어도 즉시 해제되지 않아, 메모리 제한을 넘은 프로세스와 PID 1이 차례로 종료되고 컨테이너 전체가 무너졌습니다. 메모리 제한을 보여주려면 프로세스 종료 시 해제되는 anonymous memory를 사용해야 한다는 점을 확인한 것입니다.
또한 cgroup에 프로세스를 직접 옮기는 작업도 권한 문제를 일으켰습니다. 작성자의 셸은 자신이 소유하지 않은 `/init.scope`에 있었고, systemd가 사용자에게 위임한 트리 바깥에 있었기 때문에 이동 시도가 `EIO`로 실패했습니다. `systemd-run --user --scope`로 위임된 트리 안에서 프로세스를 시작하자 제한을 설정할 수 있었습니다. 이 사례는 namespace는 비특권 방식으로 사용할 수 있지만, cgroup은 root 권한이나 관리자가 위임한 slice가 필요하다는 차이를 보여줍니다.
■ 컨테이너는 호스트 커널을 공유합니다
컨테이너 안에서 `uname -r`을 실행하면 호스트와 동일한 `6.6.114.1-microsoft-standard-WSL2`가 출력됩니다. 컨테이너가 자체 커널을 가진 것이 아니라 호스트 커널 위에서 실행되기 때문입니다. 작성자가 time namespace를 만들지 않았으므로 `/proc/uptime` 역시 호스트와 동일한 258,345초를 보고합니다.
이 점이 컨테이너와 virtual machine의 본질적인 차이입니다. VM은 자체 커널을 가지고 하드웨어 수준까지 격리하지만, 컨테이너는 같은 커널 위에서 실행되는 프로세스에 namespace와 cgroup을 적용합니다. 따라서 컨테이너는 가볍고 빠르게 만들 수 있지만, 커널의 버그나 취약점은 컨테이너와 호스트가 공유하는 위험이 됩니다.
■ Docker는 컨테이너 위에 더 많은 기능을 쌓습니다
실험용 root filesystem은 이미 존재하는 Docker image에서 가져올 수 있습니다. 글에서는 다음 명령으로 `alpine:3.20` image를 컨테이너로 생성한 뒤 export하고, tar 파일을 `rootfs` 디렉터리에 풉니다.
`mkdir rootfs`
`docker export $(docker create alpine:3.20) | tar -C rootfs -xf -`
이후에는 앞서의 `unshare` 명령과 `pivot_root`, `mount`, `sethostname`을 libc를 통해 호출하는 27줄짜리 `enter.py`를 사용합니다. 동작 여부는 세 가지로 확인할 수 있습니다. hostname이 설정한 값인지 확인하고, `ps`에 호스트의 프로세스 수가 아니라 한두 개의 컨테이너 프로세스만 보이는지 확인하며, `/oldroot`를 조회했을 때 실패하는지 확인합니다. `ps`가 아무것도 보여주지 않는다면 PID namespace가 비어 있는 것이 아니라 `/proc`를 잘못된 위치에 마운트했을 가능성이 있습니다.
글의 결론은 Docker의 핵심 격리 기능 자체는 `unshare`의 몇 가지 옵션과 `pivot_root`로 구성되고, 자원 제한은 cgroup의 파일 하나에 기록하는 값으로 시작된다는 것입니다. Docker는 그 위에 image, layer, networking, registry 같은 운영 기능을 더해 실제 사용 가능한 플랫폼으로 만들지만, 컨테이너의 기본 구조는 커널에 private view를 요청한 프로세스입니다. 이를 직접 만들어 보면 “컨테이너는 그저 프로세스입니다”라는 표현이 추상적인 구호가 아니라 실제 커널 동작으로 이해됩니다.
원문: dev.to / 번역·요약: Trawling