5장. 컨테이너는 프로세스다 — VM과 무엇이 다른가
컨테이너 안에서 PID 1이던 프로세스가 호스트에서는 594번이었습니다. 숨어 있지도, 감싸여 있지도 않아요. 호스트 ps에 한 줄로 그냥 찍힙니다.
{/* 삽화 자리: 커다란 명부(호스트 프로세스 목록)가 펼쳐져 있고 그 위에 수많은 줄이 적혀 있다. 그중 한 줄에만 바닥에서 올라온 얇은 선이 둘러쳐져 있는데, 그 선 안에 선 사람은 자기 줄을 “1번”이라고 읽고 있다. 명부 자체에는 그 줄이 594번으로 적혀 있다. 두 숫자가 같은 한 줄을 가리킨다. 프롬프트: docs/book/illustration-prompts-container.md */}
배경
1부를 끝내며 남긴 질문이 있었습니다. 여는 글부터 제가 “컨테이너 안”이라는 말을 계속 썼는데, 자바 프로세스는 정말 어딘가 안에 들어가 있느냐는 것이었죠.
호스트에서 프로세스 목록을 뽑아보면 끝나는 질문이었어요. 미룬 건 그 전에 볼 게 있어서였고요.
이 질문은 다른 질문과 붙어 있습니다. 컨테이너와 가상머신은 뭐가 다르냐는 거요. 둘 다 “안에서 리눅스가 도는 것처럼 보이는” 물건이고, 설명할 때도 나란히 놓고 비교하니까요.
두 질문의 답이 같습니다. 둘 다 호스트 ps 한 줄로 끝나요.
그리고 여기서부터 2부입니다. 1부는 누가 무엇을 언제 만들었는지를 봤어요. 2부는 그게 지금 내 기계 안에서 어떻게 생겼는지를 봅니다. 연표 대신 /proc 아래 파일을 열고, 인용 대신 출력을 읽습니다.
스토리
sleep 3600 하나만 도는 컨테이너를 띄우고, 안에서 자기 번호를 물어봤습니다.
docker run -d --name ch05 alpine:3.20 sleep 3600
PID=$(docker inspect ch05 --format '{{.State.Pid}}')
ls -l /proc/$PID/ns/=== 컨테이너 안에서 본 자기 자신 ===
PID 1 : sleep1번입니다. 3장에서 본 그대로예요. 새 PID namespace에서 처음 뜬 프로세스라서 1번이 됐습니다.
같은 순간 호스트에서 프로세스 목록을 뽑았습니다.
=== 같은 순간, 호스트에서 본 같은 프로세스 ===
594 571 sleep 3600
=== Docker 가 알려주는 호스트 PID ===
docker inspect .State.Pid = 594✅ 실측 (Docker Engine 29.7.2 / Docker Desktop 4.87.0 / alpine 3.20 / Apple M1 / macOS 26.6, 2026-09. 전체 소스: docs/book/code/container/ch05/just-a-process.sh)
맥에는 /proc이 없어서 위 세 번째 줄이 그대로는 안 돕니다. 호스트가 Docker Desktop의 리눅스 VM 안에 있거든요. 맥이라면 docker run --rm --privileged --pid=host alpine ls -l /proc/<PID>/ns/로 들어가서 보고, 리눅스 서버라면 그냥 셸에서 치면 됩니다.
같은 프로세스가 두 개의 번호를 갖고 있습니다. 안에서는 1번, 밖에서는 594번이요. docker inspect가 알려주는 .State.Pid도 594입니다.
594라는 숫자 자체는 의미가 없습니다. 여러분이 따라 하면 다른 번호가 나와요. 중요한 건 그 번호가 1이 아니라는 것, 그리고 docker inspect가 말하는 번호와 같다는 것입니다.
호스트의 ps -eo pid,ppid,args에 다른 프로세스들과 똑같은 형식으로 한 줄 찍힙니다. 부모(571)도 보이고, 실행 명령도 그대로 보여요.
여는 글의 질문에 답하면 이렇습니다. 자바 프로세스는 어딘가 안에 들어가 있지 않습니다. 호스트 프로세스 목록의 한 줄로 그냥 거기 있어요. 다만 그 줄이 자기 번호를 다르게 읽을 뿐입니다.
핵심
선은 어디에 그어져 있나
그러면 무엇이 그 프로세스를 “컨테이너 안”으로 만드는 걸까요. 594번의 /proc 항목을 열어봤습니다.
실행 파일 : sleep
cgroup : 0::/docker/648d1f407d486a6befaab9d8d23d702ab30a83bb0cd8c49141adf2fc7890e688
namespace 링크:
cgroup -> cgroup:[4026532559]
ipc -> ipc:[4026532557]
mnt -> mnt:[4026532555]
net -> net:[4026532560]
pid -> pid:[4026532558]
pid_for_children -> pid:[4026532558]
time -> time:[4026532688]
time_for_children -> time:[4026532688]
user -> user:[4026531837]
uts -> uts:[4026532556]✅ 실측 (같은 환경, 같은 소스)
/proc/594/ns/ 아래 심볼릭 링크들이 이 프로세스가 속한 namespace 목록입니다.
대괄호 안은 그 namespace의 inode 번호예요. 커널이 namespace마다 파일을 하나씩 두고 그 파일 번호로 구분합니다. 그래서 번호가 같으면 같은 namespace입니다. 다음 절이 이 성질을 그대로 씁니다.
_for_children이 붙은 두 줄은 이 프로세스 자신이 아니라 앞으로 만들 자식이 들어갈 namespace입니다. 3장에서 unshare --pid에 --fork가 왜 필요했는지가 여기 적혀 있어요. 자기 자신은 안 옮겨지고 자식부터 새 namespace에 들어가니까요.
이게 “선”의 실체입니다. 프로세스를 감싼 상자가 아니라, 프로세스가 들고 있는 소속 목록이에요. 커널은 프로세스마다 이 소속을 들고 있다가, 그 프로세스가 무언가를 물어볼 때마다 소속을 보고 답합니다. 프로세스 목록을 달라고 하면 pid:[4026532558]에 속한 것만 보여주고, 호스트명을 물으면 uts:[4026532556]이 가진 값을 돌려주는 식이죠. /proc/<pid>/ns/는 그 소속을 밖에서 읽으라고 열어둔 창이고요.
namespace 목록 위의 cgroup : 한 줄은 다른 물건입니다. 이 프로세스가 어느 자원 그룹에 속하는지예요. 목록 안의 cgroup -> cgroup:[...]가 “그룹 계층을 어디까지 보여줄까”라면, 이 줄은 “어느 그룹에 속했나”입니다. 앞의 0::은 cgroup v2가 계층 하나로 통합됐다는 뜻이고 뒤는 컨테이너 ID예요. 리눅스 서버에서는 앞에 /system.slice/가 더 붙기도 합니다. 이 줄을 직접 만지는 건 7장입니다.
그런데 하나는 호스트와 같습니다
호스트 PID 1의 namespace와 하나씩 대조해봤습니다.
pid 호스트=pid:[4026531836] 컨테이너=pid:[4026532558] 다름 (분리)
mnt 호스트=mnt:[4026531832] 컨테이너=mnt:[4026532555] 다름 (분리)
net 호스트=net:[4026531833] 컨테이너=net:[4026532560] 다름 (분리)
uts 호스트=uts:[4026531838] 컨테이너=uts:[4026532556] 다름 (분리)
ipc 호스트=ipc:[4026531839] 컨테이너=ipc:[4026532557] 다름 (분리)
cgroup 호스트=cgroup:[4026531835] 컨테이너=cgroup:[4026532559] 다름 (분리)
time 호스트=time:[4026531834] 컨테이너=time:[4026532688] 다름 (분리)
user 호스트=user:[4026531837] 컨테이너=user:[4026531837] 같음 (공유)✅ 실측 (같은 환경, 같은 소스. time 행은 이 환경의 Docker가 컨테이너마다 time namespace를 만들어서 나온 결과입니다. 리눅스 서버의 Docker에서는 이 줄이 “같음”으로 나올 수 있어요)
일곱 개는 다르고 하나가 같습니다. user요.
3장에서 user namespace 이야기를 했습니다. 커널 3.8이 2013년 2월에 나왔고 Docker 데모가 3월이라 한 달 빨랐는데, Docker는 한참 뒤에야 썼다고요. 그 “한참 뒤”가 2016년 2월 Docker 1.10의 userns-remap이고, 10년이 지난 지금도 옵트인입니다. 켜지 않으면 컨테이너는 호스트와 같은 user namespace를 써요. (rootless Docker나 Podman은 기본으로 user namespace를 씁니다. Docker가 안 쓰는 것이지 아무도 안 쓰는 건 아니에요.)
무슨 뜻이냐면, 컨테이너 안의 root와 호스트의 root가 커널 눈에는 같은 사용자라는 겁니다.
자물쇠가 아예 없는 건 아닙니다. 기본 Docker는 컨테이너 root에게서 capability를 대부분 떼고, seccomp로 시스템 콜 일부를 막고, 보안 프로파일을 걸어둡니다. 다만 그건 전부 “root인데 이건 하지 마”라는 덧문이에요. 신원 자체는 그대로 root입니다.
이게 추상적인 얘기가 아니라는 건 볼륨에서 바로 드러납니다. 컨테이너 안에서 root로 만든 파일을 바인드 마운트한 디렉터리에서 보면, 호스트에서도 소유자가 root예요. 안의 root와 밖의 root가 같은 사용자라서 그렇습니다. 그 이야기는 10장입니다.
2장에서 PHK가 붙들었던 문제를 기억하시나요. root를 어떻게 잘게 쪼갤 것인가. user namespace가 그 답에 가장 가까운 물건인데, 기본값에서는 안 쓰고 있습니다.
여덟 개 중 일곱을 그어놓고 딱 하나를 안 그은 겁니다. 어느 선이 그어졌고 어느 선이 안 그어졌는지 모르면, 여는 글에서 본 것과 같은 종류의 사고가 납니다.
VM과 무엇이 다른가
이제 처음 질문으로 돌아갑니다.
가상머신은 게스트에게 기계 한 대를 통째로 내줍니다. CPU 명령은 대부분 진짜 CPU에서 그대로 돌고, 디스크나 네트워크 카드 같은 장치는 하이퍼바이저가 흉내 내요. 그 위에 게스트 운영체제가 올라가고, 그 운영체제가 자기 커널을 부팅합니다. 그래서 VM 안에는 진짜로 “안”이 있습니다. 게스트 커널이 관리하는 별도의 세계가요.
컨테이너에는 그런 게 없습니다. 4장에서 확인했죠. 컨테이너 안의 uname -r과 호스트의 uname -r이 7.0.12-linuxkit으로 한 글자도 다르지 않았습니다. 커널이 하나니까요.
그래서 부팅할 것도 없습니다. 방금 만든 컨테이너에게 자기 나이를 물어보면 바로 드러나요.
호스트 uptime 249.82 1980.10
방금 띄운 컨테이너 249.99 1981.27
호스트 boot_id 7bb02806-2911-4aa9-b5d1-71e7b2b37113
방금 띄운 컨테이너 7bb02806-2911-4aa9-b5d1-71e7b2b37113✅ 실측 (같은 환경, 같은 소스)
1초 전에 만든 컨테이너가 자기가 249초 됐다고 답합니다. 호스트 커널의 나이를 그대로 읽은 거예요. boot_id도 한 글자가 다르지 않습니다.
이건 “빠르다”가 아니라 “부팅한 적이 없다”입니다. 4장에서 uname -r이 같았던 것과 같은 이야기고요.
띄우는 데 걸리는 시간도 그래서 이렇습니다.
0.23초
0.22초
0.30초✅ 실측 (같은 환경, 같은 소스. docker run --rm alpine:3.20 true 3회)
이 시간에 커널은 부팅하지 않습니다. 이미지 레이어를 겹쳐 얹고, namespace와 cgroup을 걸고, 프로세스를 하나 실행하는 시간이에요(앞의 둘은 8장과 9장입니다).
정리하면 이렇습니다.
| 가상머신 | 컨테이너 | |
|---|---|---|
| 커널 | 게스트마다 하나씩 | 호스트 것 하나를 공유 |
| 안이라는 게 있나 | 있음 (게스트 커널의 세계) | 없음. 호스트 프로세스에 표시가 붙은 것 |
| 시작할 때 하는 일 | 커널 부팅 (보통 수십 초, 부팅을 줄인 microVM도 100ms대 📄 문서 기반) | 프로세스 실행 + 표시 붙이기 (실측 0.2초) |
호스트 ps에서 | VM 프로세스 하나(QEMU 같은). 안의 프로세스는 한 줄도 안 보임 | 안의 프로세스가 그대로 다 보임 |
| 격리의 근거 | 하드웨어 경계 | 커널의 검사 |
마지막 줄이 이 책 전체의 문장입니다.
VM의 경계는 벽에 가깝습니다. 게스트의 메모리 접근은 CPU가 한 번 더 걸러서, 게스트가 호스트 주소를 곧바로 가리킬 수 없어요. 그래도 이 벽에 구멍이 난 적은 있습니다. 대개 메모리가 아니라 하이퍼바이저가 흉내 내는 장치 코드의 버그를 통해서요. 2015년의 VENOM이 가상 플로피 컨트롤러였습니다(📄 문서 기반).
컨테이너의 경계는 벽이 아니라 선입니다. 같은 커널이 매번 “이 프로세스가 어느 namespace지?”를 확인해주는 동안만 유지돼요.
1장에서 chroot 안의 프로세스가 바깥 프로세스를 죽인 게 그래서 가능했습니다. 검사가 없는 쪽으로는 아무것도 막히지 않거든요.
정리
- 컨테이너 안의 프로세스는 호스트 프로세스 목록에 그대로 한 줄로 있습니다. 실측에서 안의 PID 1이 호스트에서는 594였고,
docker inspect의.State.Pid도 같은 값이었습니다. - 무엇이 그 프로세스를 “안”으로 만드느냐면
/proc/<pid>/ns/아래의 소속 목록입니다. 상자가 아니라 프로세스가 들고 있는 표시예요. - 실측에서 여덟 종류 중 일곱이 호스트와 달랐고
user만 같았습니다. 기본값에서 컨테이너의 root는 호스트와 같은 user namespace를 씁니다. capability·seccomp 같은 덧문은 걸려 있지만 신원 자체는 호스트 root와 같아요. - VM은 게스트 커널을 부팅하고, 컨테이너는 이미 떠 있는 커널에 표시를 붙입니다. 방금 만든 컨테이너가 호스트 커널의
uptime과boot_id를 그대로 답하는 게 그 증거예요. 시작도 0.2초입니다. - VM의 경계는 하드웨어가 만들고, 컨테이너의 경계는 커널의 검사가 만듭니다. 검사가 없는 축으로는 경계도 없어요.
생각해볼 질문
/proc/594/ns/ 목록을 다시 보세요. 그 줄 하나하나가 “이 프로세스에게 무엇을 다르게 보여줄 것인가”를 정합니다.
그런데 목록을 보는 것과 만드는 것은 다릅니다. 3장에서 unshare 한 줄로 PID namespace를 만들어봤지만, 그때는 PID 하나만 봤어요. 나머지는 무엇을 가리는지, 그중 손으로 만들어볼 수 있는 건 어디까지인지 봐야 합니다.
특히 궁금한 게 하나 있습니다. 호스트명을 바꾸면 어떻게 될까요. 컨테이너 안에서 hostname을 바꾸면 호스트 이름도 바뀔까요, 아니면 안 바뀔까요. 안 바뀐다면 그 이름은 어디에 따로 적혀 있는 걸까요.
다음 장에서는 namespace를 하나씩 손으로 만들어봅니다.