Skip to Content

6장. 보이는 것을 나누다 — namespace를 unshare로 손수 만들기

unshare --uts 안에서 호스트명을 바꿨더니, 선 밖으로 나오자 원래 이름이 그대로 있었습니다. 같은 커널이 같은 질문에 두 가지로 답한 겁니다. 그 답이 어디에 따로 적혀 있는지가 이 장입니다.

{/* 삽화 자리: 같은 방 안에 사람 둘이 서 있고, 각자 앞에 같은 질문이 적힌 팻말이 놓여 있다(“여기 이름이 뭐죠?”). 두 사람이 서로 다른 답을 적고 있는데, 둘 사이에는 벽이 없고 바닥에 얇은 선 하나만 그어져 있다. 방 천장의 같은 전구가 두 사람을 함께 비춘다. 프롬프트: docs/book/illustration-prompts-container.md */}


배경

5장에서 컨테이너 프로세스의 /proc/<pid>/ns/를 열어봤습니다. 소속 목록이 열 줄 있었고, 그중 여덟 종류가 실제 namespace였죠. 호스트와 대조해보니 일곱은 다르고 user 하나만 같았습니다.

목록을 읽는 건 거기까지입니다. 이번엔 만들어볼 차례예요.

만드는 도구는 3장에서 이미 한 번 썼습니다. unshare요. 이름 그대로 “공유를 끊어달라”는 뜻이고, 커널에게 “지금 이 프로세스를 부모가 속한 namespace에서 떼어내 새로 하나 만들어달라”고 요청합니다.

이 장에서는 종류별로 하나씩 끊어봅니다. 각 실험은 같은 형식이에요. 선을 긋기 전에 한 번 묻고, 선 안에서 한 번 묻고, 선 밖으로 나와서 다시 한 번 묻습니다. 세 번의 답이 어떻게 갈리는지가 그 namespace가 하는 일입니다.

명령은 전부 루트 권한이 필요합니다. --privileged가 붙는 이유는 5장에서 본 그 capability 때문이에요. 기본 컨테이너는 CAP_SYS_ADMIN이 없어서 unshare가 안 돕니다. 실험용 플래그지 운영에서 쓰는 옵션이 아닙니다.

docker run --rm -it --privileged alpine:3.20 sh apk add util-linux # 뒤쪽 IPC 실험의 ipcmk 용. unshare 는 busybox 것으로도 됩니다

리눅스 서버라면 sudo로 바로 하면 됩니다.

스토리

5장이 남긴 질문부터 풀어봅시다. 컨테이너 안에서 호스트명을 바꾸면 바깥도 바뀔까요.

답을 정하고 넘어가세요. 바뀐다에 걸지, 안 바뀐다에 걸지.

unshare --uts sh -c 'hostname inside-the-line; hostname' hostname # 선 밖으로 나와서 다시
=== UTS — 호스트명 === 선 긋기 전 : uts:[4026532556] 호스트명=b1c23b3fc365 선 안에서 : uts:[4026532691] 호스트명=inside-the-line 다시 바깥 : uts:[4026532556] 호스트명=b1c23b3fc365 ← 안 바뀌었다 unshare 없이 : changed-right-here ← 이 컨테이너의 uts 값만 바뀐다

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

처음 세 줄이 이 장의 형식을 다 보여줍니다.

첫 줄과 셋째 줄의 inode가 같습니다. 4026532556이요. 같은 namespace라는 뜻이고, 그래서 호스트명도 같습니다. 가운데 줄만 4026532691로 다르고, 거기서만 이름이 inside-the-line이에요.

unshare --uts 안에서 hostname을 바꾸는 건 진짜로 바꾸는 겁니다. 흉내가 아니에요. 다만 바뀐 값이 새로 만든 UTS namespace에 적히고, 바깥은 예전 namespace를 계속 보고 있을 뿐입니다.

번호를 읽을 때 한 가지 주의할 게 있습니다. 실험이 순서대로 돌기 때문에, 앞 실험이 끝나 namespace가 사라지면 그 번호가 다음에게 재사용돼요. 아래 절들에서 4026532691이 반복해서 보이는 건 그 때문이고, 같은 namespace가 아닙니다. 번호 비교는 같은 블록 안에서만 의미가 있어요.

이름이 하는 일을 안 알려주는 것도 짚고 갑시다. 커널 문서는 UTS namespace가 호스트명과 NIS 도메인명 두 가지를 격리한다고 적어둡니다. 둘 다 uname()으로 읽을 수 있고요(📄 문서 기반 — man 7 uts_namespaces). UTS라는 약자 자체는 uname()이 채우는 utsname 구조체에서 왔다고 알려져 있는데, man page에는 그 설명이 없습니다(📄 미검증). 약자만 봐서는 호스트명을 나누는 물건인지 알 수 없어요.

마지막 줄이 그 반대쪽입니다. unshare 없이 그냥 hostname을 바꾸면 이 컨테이너 전체의 이름이 바뀝니다. 호스트 이름은 그대로예요. 컨테이너에는 이미 자기 UTS namespace가 있으니까요.

바뀌는 범위는 언제나 “지금 속한 namespace”까지입니다. 같은 명령인데 어느 선 안에서 치느냐가 결과를 가릅니다.

핵심

마운트는 따라오지 않습니다

마운트 namespace는 프로세스마다 다른 마운트 지점 목록을 갖게 합니다. 1장에서 chroot가 경로 기준점 하나만 바꿨던 걸 기억하시죠. 이건 훨씬 넓습니다. 무엇이 어디에 붙어 있는지를 나눠요.

새 마운트 namespace 안에서 tmpfs를 하나 붙이고 파일을 만들어봤습니다.

unshare --mount sh -c 'mkdir -p /mnt/private && mount -t tmpfs none /mnt/private echo hello > /mnt/private/secret.txt; cat /mnt/private/secret.txt' cat /mnt/private/secret.txt # 선 밖으로 나와서
=== MNT — 마운트 지점 === 선 긋기 전 : mnt:[4026532555] 선 안에서 : mnt:[4026532691] secret.txt=hello 다시 바깥 : mnt:[4026532555] secret.txt=(없음) ← 마운트가 안 따라왔다

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

안에서는 /mnt/private/secret.txt가 읽히고 밖에서는 없습니다. 파일이 지워진 게 아니라 그 파일이 올라가 있던 tmpfs가 바깥 목록에 없는 거예요.

정확히는 마운트 namespace 혼자 한 일이 아닙니다. unshare는 새 마운트 namespace의 전파 설정을 기본 private로 깔아줘요. 일부러 --propagation shared로 바꾸면 안에서 붙인 tmpfs가 바깥에도 나타납니다. 선은 여기서도 벽이 아니라, 옵션 하나로 뚫리는 선입니다.

이게 왜 중요하냐면, 컨테이너의 파일 시스템이 바로 이 위에 서 있기 때문입니다. 컨테이너가 자기만의 /를 갖는 건 이미지 레이어를 겹쳐 얹은 결과를 자기 마운트 namespace에 붙여놓기 때문이고요. 그 겹치는 방식이 8장입니다.

eth0이 사라집니다

네트워크 namespace는 인터페이스, 주소, 라우팅 테이블, 포트 번호 공간을 나눕니다.

ip -o link | awk '{print $2}' # 선 긋기 전 unshare --net sh -c "ip -o link | awk '{print \$2}'" # 선 안에서
=== NET — 네트워크 === 선 긋기 전 : net:[4026532560] lo tunl0@NONE gre0@NONE gretap0@NONE erspan0@NONE ip_vti0@NONE ip6_vti0@NONE sit0@NONE ip6tnl0@NONE ip6gre0@NONE eth0@if139 선 안에서 : net:[4026532691] lo tunl0@NONE gre0@NONE gretap0@NONE erspan0@NONE ip_vti0@NONE ip6_vti0@NONE sit0@NONE ip6tnl0@NONE ip6gre0@NONE

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

eth0@if139 하나가 없어졌습니다. 나머지는 그대로예요.

@ 뒤를 보세요. 남은 것들은 전부 @NONE, 짝이 없다는 뜻입니다. 짝이 있던 건 eth0 하나뿐이었고 그게 없어졌어요. @if139는 “호스트 쪽 139번 인터페이스와 짝”이라는 뜻이고, 그게 컨테이너를 바깥과 잇는 다리입니다. 번호는 따라 하면 다르게 나옵니다.

남아 있는 것들은 터널 모듈(ipip·gre·sit 같은 것)이 로드돼 있을 때 커널이 net namespace마다 하나씩 만들어두는 fallback 장치입니다. 전부 꺼져 있고 주소도 없어요. 이 모듈들이 안 올라간 리눅스 서버에서는 lo 한 줄만 나옵니다. 그래도 결론은 같아요. eth0이 없다는 것.

그러니까 네트워크 namespace를 만드는 것만으로는 격리된 네트워크가 완성되지 않아요. 만들고 나서 다리를 놓아줘야 합니다. 그 작업이 9장입니다.

남이 만든 공유 메모리가 안 보입니다

IPC namespace는 System V 공유 메모리와 메시지 큐, 세마포어를 나눕니다. 프로세스끼리 데이터를 주고받는 통로예요.

바깥에서 공유 메모리 세그먼트를 하나 만들어두고 들어가봤습니다.

ipcmk -M 1024 # 바깥에 하나 만들어두고 ipcs -m | grep -c '^0x' # 선 긋기 전 unshare --ipc sh -c "ipcs -m | grep -c '^0x'" # 선 안에서
=== IPC — 공유 메모리·메시지 큐 === 선 긋기 전 : ipc:[4026532557] 공유 메모리 세그먼트 1개 선 안에서 : ipc:[4026532819] 공유 메모리 세그먼트 0개 다시 바깥 : ipc:[4026532557] 공유 메모리 세그먼트 1개

✅ 실측 (같은 환경, 같은 소스. ipcmk가 alpine 기본 이미지에 없어 apk add util-linux를 먼저 합니다)

밖에 하나, 안에 영, 나와서 다시 하나입니다.

이 namespace가 왜 필요한지는 이름 충돌을 생각하면 보입니다. System V IPC는 숫자 키로 통로를 찾아요. 같은 이미지를 두 벌 띄우면 둘이 같은 키를 씁니다. IPC namespace가 그 키 공간을 나눠서 서로의 데이터를 열지 못하게 막습니다.

같은 프로세스가 번호를 두 개 갖습니다

3장에서 한 번 봤지만, 이번엔 5장처럼 해봅니다. 선 안에서 sleep을 띄우고 그 하나를 안팎에서 각각 세어봤어요.

unshare --pid --fork --mount-proc sh -c 'sleep 297 & echo $!' # 안에서 본 번호 ps -eo pid,args | grep '[s]leep 297' # 밖에서 본 번호
=== PID — 프로세스 번호 === 선 긋기 전 : pid:[4026532558] 선 안에서 : pid:[4026532693] 안에서 띄운 sleep 의 번호 = 3 같은 프로세스를 밖에서 보면 = 64

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

하나의 sleep인데 안에서는 3번, 밖에서는 64번입니다. 5장에서 본 그 구조예요. 거기서는 안 1번, 밖 594번이었죠.

번호가 두 개인 게 아니라 번호를 매기는 장부가 두 개입니다.

3번인 이유도 세어보면 나옵니다. unshare가 만든 sh가 1번, 그 위 줄에서 readlink를 부르느라 잠깐 생긴 서브셸이 2번, 그다음이 이 sleep이에요. 두 숫자 다 따라 하면 다르게 나옵니다. 중요한 건 값이 아니라 하나의 프로세스가 두 번호로 읽힌다는 것입니다.

--fork와 --mount-proc이 왜 붙는지도 이제 설명할 수 있습니다. 5장의 /proc/<pid>/ns/ 목록에 pid_for_children이라는 줄이 있었죠. PID namespace는 자기 자신에게 적용되지 않고 앞으로 만들 자식부터 적용됩니다. 그래서 --fork로 자식을 하나 만들어야 그 자식이 새 장부의 1번이 돼요.

--mount-proc은 3장에서 본 그대로입니다. /proc을 새로 붙이지 않으면 PID namespace를 만들어놓고도 바깥 장부를 읽습니다.

한 줄로 전부 긋기

지금까지 하나씩 끊은 걸 한꺼번에 해봤습니다.

unshare --uts --mount --net --ipc --pid --fork --mount-proc sh
=== 한 줄로 전부 긋기 === 호스트명 : my-own-box PID 1 : sh 프로세스 : 4개 인터페이스 : lo tunl0@NONE gre0@NONE gretap0@NONE erspan0@NONE ip_vti0@NONE ip6_vti0@NONE sit0@NONE ip6tnl0@NONE ip6gre0@NONE

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

자기 이름이 있고, PID 1이 있고, 프로세스가 네 개뿐이고, 바깥으로 나가는 인터페이스가 없습니다.

컨테이너처럼 보이시나요. 컨테이너의 “보이는 것” 축은 거의 다 이 한 줄입니다. 5장에서 본 여덟 종류 중 다섯을 여기서 손으로 만들었어요. UTS·MNT·NET·IPC·PID요.

나머지 셋 중 cgroup은 7장에서 다루고, time은 이 책이 건드리지 않고, user는 5장에서 본 대로 안 그어져 있습니다.

빠진 게 뭔지도 말해둬야겠습니다. 이미지가 없으니 루트 파일 시스템은 여전히 바깥 것을 보고 있고, 네트워크는 끊겨만 있지 이어지지 않았고, 자원 제한은 아무것도 안 걸려 있어요.

컨테이너 런타임이 하는 일은 이 한 줄에 그 나머지를 붙이는 것입니다.

그래서 namespace란 무엇인가

5장에서 본 여덟 종류를 한 문장으로 묶으면 이렇습니다. namespace는 커널이 같은 질문에 다르게 답하도록 만드는 장치입니다.

“내 호스트명이 뭐지”라고 물으면 UTS namespace를 보고 답하고, “프로세스 목록 줘”라고 하면 PID namespace를 보고 답하고, “인터페이스 목록 줘”라고 하면 net namespace를 보고 답해요. 질문은 같고 답이 다릅니다.

그래서 namespace는 막는 장치가 아닙니다. 1장에서 확인한 그대로예요. 안 보이게 할 뿐 못 하게 하지는 않습니다.

무엇을 얼마나 쓸 수 있는지는 다른 물건이 정합니다. 3장에서 예고한 두 번째 축이죠.

정리

  • unshare는 “부모가 속한 namespace에서 떼어내 새로 하나 만들어달라”고 커널에 요청하는 명령입니다. 루트 권한이 필요해요.
  • UTS는 호스트명을 나눕니다. 실측에서 선 안의 이름 변경이 선 밖에 영향을 주지 않았고, inode가 갈렸다가 돌아오는 걸로 확인됩니다.
  • MNT는 마운트 지점 목록을 나눕니다. 안에서 붙인 tmpfs와 그 위의 파일이 밖에서는 없습니다.
  • NET은 인터페이스를 나눕니다. 새 namespace에는 eth0이 없어요. 끊기기만 하고 이어지지는 않으므로, 다리를 놓는 작업이 따로 필요합니다(9장).
  • IPC는 System V 공유 메모리·메시지 큐·세마포어를 나눕니다. 밖에 하나 있던 세그먼트가 안에서는 0개였습니다.
  • PID는 번호 장부를 나눕니다. 실측에서 하나의 sleep이 안에서 3번, 밖에서 64번으로 읽혔습니다. --fork가 필요한 이유는 PID namespace가 자기 자신이 아니라 자식부터 적용되기 때문입니다.
  • 한 줄로 다섯 개를 동시에 끊으면 컨테이너의 “보이는 것” 축은 거의 완성됩니다. 빠진 건 이미지, 네트워크 연결, 자원 제한, 그리고 user입니다.
  • namespace는 커널이 같은 질문에 다르게 답하게 만드는 장치입니다. 가릴 뿐 막지 않습니다.

생각해볼 질문

지금까지 그은 선은 전부 “보이는 것”에 관한 것이었습니다. 호스트명이 보이고, 프로세스가 보이고, 인터페이스가 보이는 범위를 나눴어요.

그런데 2장에서 Solaris가 Zone에 자원 관리를 붙여 Containers라고 팔았던 걸 떠올려 보세요. PHK도 커밋 메시지에 적었죠. 자원 독차지를 막을 장치가 없다고요.

방금 만든 그 선 안에서, 메모리를 무한정 먹는 프로그램을 돌리면 어떻게 될까요. 선 안에 있으니 선 안에서만 죽을까요, 아니면 기계 전체가 느려질까요.

namespace는 그 질문에 답하지 않습니다. 애초에 그 질문을 받는 물건이 아니거든요.

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

Last updated on