컨테이너가 “격리”된다고 할 때 그 격리를 실제로 만드는 건 커널의 네임스페이스다. 도커나 podman 없이도 unshare 명령 하나로 같은 격리를 만들 수 있고, 그렇게 해보면 컨테이너 런타임이 무엇을 조립하고 있는지가 훨씬 선명해진다. 이 글에서는 Ubuntu 24.04(커널 6.8, util-linux 2.39)에서 네임스페이스 종류별로 직접 만들어보고, 이미 돌아가는 프로세스의 네임스페이스에 들어가는 방법과 네임스페이스의 수명까지 확인한다.
프로세스가 속한 네임스페이스 보기
ls -l /proc/self/nslrwxrwxrwx 1 noble noble 0 Aug 23 16:46 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 noble noble 0 Aug 23 16:46 ipc -> ipc:[4026531839]
lrwxrwxrwx 1 noble noble 0 Aug 23 16:46 mnt -> mnt:[4026531841]
lrwxrwxrwx 1 noble noble 0 Aug 23 16:46 net -> net:[4026531840]
lrwxrwxrwx 1 noble noble 0 Aug 23 16:46 pid -> pid:[4026531836]
lrwxrwxrwx 1 noble noble 0 Aug 23 16:46 pid_for_children -> pid:[4026531836]
lrwxrwxrwx 1 noble noble 0 Aug 23 16:46 time -> time:[4026531834]
lrwxrwxrwx 1 noble noble 0 Aug 23 16:46 time_for_children -> time:[4026531834]
lrwxrwxrwx 1 noble noble 0 Aug 23 16:46 user -> user:[4026531837]
lrwxrwxrwx 1 noble noble 0 Aug 23 16:46 uts -> uts:[4026531838]
대괄호 안 숫자는 nsfs의 inode 번호다. 두 프로세스가 같은 네임스페이스에 있는지는 이 값을 비교하면 바로 알 수 있다. pid_for_children과 time_for_children은 지금 프로세스가 아니라 앞으로 만들 자식이 들어갈 네임스페이스를 가리킨다.
| 종류 | 격리 대상 | unshare 옵션 |
|---|---|---|
| mnt | 마운트 트리 | -m |
| pid | PID 번호 공간 | -p |
| net | 네트워크 인터페이스·라우팅·소켓 | -n |
| ipc | System V IPC, POSIX 메시지 큐 | -i |
| uts | hostname, domainname | -u |
| user | UID/GID 매핑과 capability | -U |
| cgroup | cgroup 루트 경로 | -C |
| time | CLOCK_MONOTONIC / CLOCK_BOOTTIME 오프셋 | --time |
user 네임스페이스가 먼저다
일반 사용자로 UTS 네임스페이스만 만들려고 하면 실패한다. 네임스페이스 생성에는 CAP_SYS_ADMIN이 필요하기 때문이다.
unshare -u hostnameunshare: unshare failed: Operation not permitted
user 네임스페이스를 같이 만들면 그 안에서 root가 되고, 그 권한으로 나머지 네임스페이스를 만들 수 있다. -r은 현재 uid를 새 네임스페이스의 0번으로 매핑하는 옵션이다.
unshare -Ur id
unshare -Ur sh -c 'cat /proc/self/uid_map; id -u'uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
0 1000 1
0
uid_map의 세 값은 각각 네임스페이스 안 uid, 바깥 uid, 매핑 개수다. 안에서 0번인 root는 바깥에서는 여전히 1000번 사용자이며, 권한도 이 네임스페이스가 소유한 자원에만 미친다.
unshare -Uru sh -c 'hostname ns-demo; hostname; readlink /proc/self/ns/uts'
readlink /proc/self/ns/utsns-demo
uts:[4026532372]
uts:[4026531838]
마운트·PID·IPC·cgroup·time
마운트 네임스페이스 안에서 tmpfs를 올리면 바깥에서는 보이지 않는다.
unshare -Urm sh -c 'mkdir -p /tmp/nsdemo; mount -t tmpfs none /tmp/nsdemo; \
echo hello > /tmp/nsdemo/file; ls /tmp/nsdemo; findmnt -n /tmp/nsdemo'
echo "--- 바깥에서:"
ls /tmp/nsdemo; findmnt -n /tmp/nsdemo || echo "(마운트 없음)"file
/tmp/nsdemo none tmpfs rw,relatime,uid=1000,gid=1000,inode64
--- 바깥에서:
(마운트 없음)
디렉터리 생성은 바깥 파일시스템에 그대로 반영되지만 마운트만 격리된다는 점에 주의한다. 네임스페이스가 복제하는 것은 마운트 테이블이지 파일시스템이 아니다.
PID 네임스페이스는 --fork와 --mount-proc를 같이 써야 의미가 있다. 새 /proc를 올려야 ps가 격리된 PID를 본다.
unshare -Urpf --mount-proc sh -c 'echo $$; ps -ef'1
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 16:46 ? 00:00:00 sh -c echo $$; ps -ef
root 2 1 0 16:46 ? 00:00:00 ps -ef
IPC 네임스페이스 안에서 만든 메시지 큐는 바깥 ipcs에 잡히지 않는다.
unshare -Ur --ipc sh -c 'ipcmk -Q; ipcs -q'
echo "--- 바깥:"; ipcs -qMessage queue id: 0
------ Message Queues --------
key msqid owner perms used-bytes messages
0x9c6456b7 0 root 644 0 0
--- 바깥:
------ Message Queues --------
key msqid owner perms used-bytes messages
cgroup 네임스페이스는 프로세스가 보는 cgroup 경로의 루트를 바꾼다. 컨테이너 안에서 /proc/self/cgroup이 짧게 보이는 이유다.
cat /proc/self/cgroup
unshare -Ur --cgroup cat /proc/self/cgroup0::/user.slice/user-1000.slice/session-102.scope
0::/
time 네임스페이스는 CLOCK_BOOTTIME과 CLOCK_MONOTONIC에 오프셋을 준다. 부팅 시각에 의존하는 프로그램을 시험할 때 쓸 만하다.
uptime -p
unshare -Ur --time --boottime 86400 --fork uptime -pup 2 hours, 32 minutes
up 1 day, 2 hours, 32 minutes
네트워크 네임스페이스와 veth
네트워크 네임스페이스는 ip netns로 다루는 편이 편하다. 새 네임스페이스는 lo 하나뿐이므로 veth 쌍으로 호스트와 연결한다.
sudo ip netns add ns1
sudo ip link add veth0 type veth peer name veth1
sudo ip link set veth1 netns ns1
sudo ip addr add 10.10.0.1/24 dev veth0
sudo ip link set veth0 up
sudo ip netns exec ns1 ip addr add 10.10.0.2/24 dev veth1
sudo ip netns exec ns1 ip link set veth1 up
sudo ip netns exec ns1 ip link set lo up
sudo ip netns exec ns1 ip -br addr
ping -c 2 -W 1 10.10.0.2lo UNKNOWN 127.0.0.1/8 ::1/128
veth1@if4 UP 10.10.0.2/24 fe80::b8c4:72ff:fe43:9bf7/64
PING 10.10.0.2 (10.10.0.2) 56(84) bytes of data.
64 bytes from 10.10.0.2: icmp_seq=1 ttl=64 time=0.117 ms
64 bytes from 10.10.0.2: icmp_seq=2 ttl=64 time=0.033 ms
--- 10.10.0.2 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1083ms
rtt min/avg/max/mdev = 0.033/0.075/0.117/0.042 ms
라우팅 테이블도 완전히 별개다. ns1 안에는 veth1로 나가는 경로 하나뿐이라 외부로 나가려면 호스트에서 NAT나 브리지를 따로 붙여야 한다.
sudo ip netns exec ns1 ip route10.10.0.0/24 dev veth1 proto kernel scope link src 10.10.0.2
nsenter로 들어가기, 그리고 수명
nsenter는 이미 존재하는 네임스페이스에 들어간다. 대상은 PID(-t)로 지정하거나 nsfs 경로로 직접 지정할 수 있다.
sudo nsenter --net=/var/run/netns/ns1 ip -br addr show veth1
sudo nsenter --net=/var/run/netns/ns1 readlink /proc/self/ns/net
readlink /proc/self/ns/netveth1@if4 UP 10.10.0.2/24 fe80::b8c4:72ff:fe43:9bf7/64
net:[4026532371]
net:[4026531840]
네임스페이스는 참조가 하나도 없으면 사라진다. unshare로 만든 익명 네임스페이스는 프로세스가 죽는 순간 없어지고, ip netns가 만든 것은 /run/netns/ 아래 bind mount가 참조를 잡고 있어 남는다.
unshare -Ur --net --fork sleep 30 &
sleep 1
lsns -t net NS TYPE NPROCS PID USER NETNSID NSFS COMMAND
4026531840 net 7 25843 noble unassigned /usr/bin/pipewire
4026532371 net 0 root /run/netns/ns1
4026532429 net 2 25917 noble unassigned unshare -Ur --net --fork sleep 30
프로세스를 죽이면 익명 네임스페이스(4026532429)만 사라진다. NPROCS가 0인데도 남아 있는 ns1이 bind mount의 효과를 그대로 보여준다.
NS TYPE NPROCS PID USER NETNSID NSFS COMMAND
4026531840 net 7 25843 noble unassigned /usr/bin/pipewire
4026532371 net 0 root /run/netns/ns1
clone()으로 직접 만들기
명령행 도구가 하는 일은 결국 clone()에 플래그를 넘기는 것이다. 아래는 user·uts·pid 세 개를 한 번에 만드는 최소 예제다.
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
static char stack[1024 * 1024];
static int child(void *arg)
{
char host[64], link[64] = {0};
sethostname("clone-ns", 8);
gethostname(host, sizeof(host));
readlink("/proc/self/ns/uts", link, sizeof(link) - 1);
printf("[child ] pid=%d hostname=%s %s\n", getpid(), host, link);
fflush(stdout);
return 0;
}
int main(void)
{
char host[64], link[64] = {0};
int pid = clone(child, stack + sizeof(stack),
CLONE_NEWUSER | CLONE_NEWUTS | CLONE_NEWPID | SIGCHLD, NULL);
if (pid < 0) { perror("clone"); exit(1); }
waitpid(pid, NULL, 0);
gethostname(host, sizeof(host));
readlink("/proc/self/ns/uts", link, sizeof(link) - 1);
printf("[parent] pid=%d hostname=%s %s\n", getpid(), host, link);
return 0;
}gcc -Wall -o nsdemo nsdemo.c && ./nsdemo[child ] pid=1 hostname=clone-ns uts:[4026532429]
[parent] pid=26133 hostname=noble uts:[4026531838]
자식은 자기 PID 네임스페이스에서 1번이고 hostname 변경도 부모에 영향을 주지 않는다. CLONE_NEWUSER 덕분에 root가 아니어도 실행된다.
주의사항
| 항목 | 내용 |
|---|---|
| 권한 | 일반 사용자는 -U와 함께 써야 한다. 배포판이 kernel.apparmor_restrict_unprivileged_userns를 1로 두면 비특권 user 네임스페이스 자체가 막히므로 값부터 확인한다. |
| PID 네임스페이스 | -p 단독으로는 현재 셸의 PID가 바뀌지 않는다. --fork로 자식을 만들고 --mount-proc로 /proc을 다시 올려야 ps 결과가 격리된다. |
| 1번 프로세스 | PID 네임스페이스의 1번이 죽으면 그 안의 모든 프로세스가 SIGKILL로 정리된다. 컨테이너 init을 아무 프로그램으로나 쓰면 안 되는 이유다. |
| 마운트 전파 | 마운트 네임스페이스를 만들어도 전파(propagation) 설정에 따라 바깥으로 새어 나갈 수 있다. 완전히 끊으려면 mount --make-rprivate /를 먼저 건다. |
| 네임스페이스 수명 | 익명 네임스페이스는 마지막 프로세스가 죽으면 사라진다. 유지하려면 ip netns처럼 nsfs 경로에 bind mount를 걸어야 한다. |
clone() 사용 시 | 자식 함수에서 return하면 _exit()로 끝나 stdio 버퍼가 flush되지 않는다. 위 예제에서 fflush()를 넣은 이유이며, 빠뜨리면 출력이 통째로 사라진다. |
마무리
네임스페이스는 종류마다 격리하는 대상이 다르고 조합해야 쓸 만해진다. 컨테이너 런타임은 여기에 cgroup으로 자원 제한을, pivot_root로 루트 파일시스템 교체를, seccomp로 시스템 콜 필터를 얹은 것에 가깝다. 문제가 생겼을 때 lsns로 어디에 속한 프로세스인지 확인하고 nsenter로 들어가 보는 것만 익혀둬도 컨테이너 디버깅이 훨씬 쉬워진다.
실습에 쓴 네트워크 네임스페이스는 아래로 정리한다.
sudo ip netns del ns1
sudo ip link del veth0 2>/dev/null