패키지 설치 스크립트를 깨끗한 배포판에서 검증하거나, systemd 유닛이 여러 개 도는 환경을 통째로 재현해야 할 때가 있다. 도커는 컨테이너당 프로세스 하나를 전제로 설계돼 있어 systemd를 PID 1로 올리려면 우회가 필요하고, LXD는 별도 데몬과 이미지 저장소를 깔아야 한다. systemd가 이미 돌고 있는 호스트라면 systemd-nspawn으로 디렉터리 하나를 그대로 부팅해 쓰는 쪽이 훨씬 간단하다. 이 글에서는 Ubuntu 24.04(systemd 255)에서 rootfs 생성부터 부팅, 네트워크 연결, 메모리 제한, 사용자 네임스페이스 격리까지 실제 명령과 출력으로 정리한다.
rootfs 만들기
nspawn이 요구하는 것은 이미지가 아니라 배포판 디렉터리 트리 하나뿐이다. debootstrap으로 최소 구성에 systemd와 dbus만 얹어 만든다.
sudo apt install -y systemd-container debootstrap
time sudo debootstrap --variant=minbase \
--include=systemd,systemd-sysv,dbus,iproute2,ca-certificates \
noble /var/lib/machines/demo http://kr.archive.ubuntu.com/ubuntu/real 1m10.295s
user 0m11.502s
sys 0m22.128s
$ sudo du -sh /var/lib/machines/demo
175M /var/lib/machines/demo
/var/lib/machines 아래에 두면 machinectl이 이름만으로 컨테이너를 찾는다. 다른 경로에 만들어도 -D로 지정하면 그만이다.
명령 한 번만 실행하기
부팅 없이 컨테이너 안에서 명령만 돌릴 때는 --pipe를 쓴다. chroot와 달리 PID·마운트·UTS 네임스페이스가 분리된다.
sudo systemd-nspawn -q -D /var/lib/machines/demo --timezone=off --pipe -- \
/bin/bash -c 'echo "hostname=$(hostname)"; grep PRETTY /etc/os-release; ps -eo pid,comm | head -4; id -u'hostname=demo
PRETTY_NAME="Ubuntu 24.04 LTS"
PID COMMAND
1 bash
4 ps
5 head
0
컨테이너 안에서 bash가 PID 1이고 호스트 프로세스는 하나도 안 보인다. --timezone=off를 빼면 minbase에 tzdata가 없어 아래 오류가 뜬다 — 동작에는 지장이 없지만 매 실행마다 출력을 더럽힌다.
Failed to mount /etc/localtime (type n/a) on /var/lib/machines/demo/usr/share/zoneinfo/UTC (MS_BIND ""): No such file or directory
커널은 호스트와 공유한다. 가상머신이 아니므로 다른 커널 버전을 시험할 수는 없다.
$ sudo systemd-nspawn -q -D /var/lib/machines/demo --pipe -- uname -r
7.0.0-28-generic
$ uname -r
7.0.0-28-generic
컨테이너로 부팅하기
실행 옵션은 명령줄에 매번 쓰지 말고 /etc/systemd/nspawn/<이름>.nspawn에 둔다. machinectl이 이 파일을 읽는다.
[Exec]
Timezone=off
PrivateUsers=no
[Network]
VirtualEthernet=yessudo machinectl start demo
machinectl listMACHINE CLASS SERVICE OS VERSION ADDRESSES
demo container systemd-nspawn ubuntu 24.04 -
1 machines listed.
부팅된 컨테이너 안에서 명령을 돌릴 때는 systemd-run -M이 편하다. machinectl shell과 달리 PTY를 붙이지 않아 스크립트에서 출력을 그대로 받을 수 있다.
sudo systemd-run -M demo --pipe --quiet /bin/bash -c 'ps -eo pid,comm | head -4; systemctl is-system-running'
sudo systemd-run -M demo --pipe --quiet systemd-analyze timePID COMMAND
1 systemd
16 systemd-journal
46 systemd-logind
running
Startup finished in 554ms (userspace)
graphical.target reached after 528ms in userspace.
컨테이너 안에서 systemd가 정상적으로 PID 1을 잡고 0.5초 만에 부팅을 끝냈다. 호스트와 비교하면 도는 서비스 수 차이가 그대로 자원 차이가 된다.
| 항목 | 컨테이너 | 호스트 |
|---|---|---|
| running 상태 서비스 | 5개 | 38개 |
| 부팅 시간 | 554ms | – |
| rootfs 크기 | 175MB | – |
| cgroup 메모리 사용량 | 약 17MB | – |
machinectl status는 컨테이너의 cgroup 트리를 그대로 보여준다.
$ machinectl status demo --no-pager
demo(3de17ca7967e438b98d26160fd229269)
Since: Sun 2026-08-09 23:44:06 KST; 3s ago
Leader: 24193 (systemd)
Service: systemd-nspawn; class container
Root: /var/lib/machines/demo
Iface: ve-demo
OS: Ubuntu 24.04 LTS
Unit: systemd-nspawn@demo.service
├─payload
│ ├─init.scope
│ │ └─24193 /usr/lib/systemd/systemd
│ └─system.slice
│ ├─console-getty.service
네트워크 연결하기
VirtualEthernet=yes는 veth 쌍을 만들어 호스트 쪽에 ve-<이름>, 컨테이너 쪽에 host0을 붙인다. 호스트에서 systemd-networkd를 쓰지 않는다면 주소는 직접 준다.
# 호스트 쪽
sudo ip addr add 10.100.0.1/24 dev ve-demo
sudo ip link set ve-demo up
# 컨테이너 쪽
sudo systemd-run -M demo --pipe --quiet /bin/bash -c \
'ip addr add 10.100.0.2/24 dev host0; ip link set host0 up; ip route add default via 10.100.0.1'$ ping -c 2 -W 1 10.100.0.2 | tail -2
2 packets transmitted, 2 received, 0% packet loss, time 1058ms
rtt min/avg/max/mdev = 0.033/0.046/0.060/0.013 ms
$ sudo systemd-run -M demo --pipe --quiet ip -br addr
lo UNKNOWN 127.0.0.1/8 ::1/128
host0@if6 UP 10.100.0.2/24 fe80::2c39:ffff:fe0e:5d5b/64
컨테이너에는 host0만 보인다. 호스트의 물리 NIC은 네트워크 네임스페이스가 분리돼 접근할 수 없다.
외부로 나가려면 호스트에서 마스커레이딩을 걸어야 한다. 컨테이너에서 외부 TCP 연결을 시도해 보면 NAT 유무 차이가 그대로 드러난다.
# 호스트에 NAT 테이블 추가
sudo nft add table ip nspawnnat
sudo nft 'add chain ip nspawnnat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
sudo nft add rule ip nspawnnat postrouting ip saddr 10.100.0.0/24 oifname "enp0s3" counter masquerade
# 컨테이너에서 확인 (minbase에는 curl이 없어 bash의 /dev/tcp 사용)
sudo systemd-run -M demo --pipe --quiet /bin/bash -c \
"timeout 5 bash -c 'exec 3<>/dev/tcp/210.117.237.2/80' 2>/dev/null && echo 접속성공 || echo 접속실패"접속실패
$ sudo nft list table ip nspawnnat | grep masquerade
ip saddr 10.100.0.0/24 oifname "enp0s3" counter packets 0 bytes 0 masquerade
규칙은 넣었는데 카운터가 0이다. 패킷이 postrouting까지 오지도 못했다는 뜻이라, 앞단의 forward 훅을 보면 원인이 나온다.
$ sudo nft list chain ip filter FORWARD | grep policy
type filter hook forward priority filter; policy drop;
도커가 설치된 호스트는 FORWARD 기본 정책을 drop으로 바꿔 놓는다. 도커의 테이블을 직접 고치는 대신, 사용자 규칙용으로 비워둔 DOCKER-USER 체인에 허용 규칙을 넣는다.
sudo iptables -I DOCKER-USER -s 10.100.0.0/24 -j ACCEPT
sudo iptables -I DOCKER-USER -d 10.100.0.0/24 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT접속성공
$ sudo nft list table ip nspawnnat | grep masquerade
ip saddr 10.100.0.0/24 oifname "enp0s3" counter packets 1 bytes 60 masquerade
메모리 제한 걸기
컨테이너는 systemd-nspawn@<이름>.service 유닛으로 뜨므로 cgroup 제한은 systemd 속성으로 그대로 걸린다.
sudo systemctl set-property systemd-nspawn@demo.service MemoryMax=256M
systemctl show systemd-nspawn@demo.service -p MemoryMaxMemoryMax=268435456
컨테이너 안에서 한도를 넘는 400MB를 tmpfs에 써보고, 호스트에서 cgroup 파일을 직접 읽어 확인한다.
sudo systemd-run -M demo --pipe --quiet /bin/bash -c \
'dd if=/dev/zero of=/dev/shm/big bs=1M count=400'
CG=/sys/fs/cgroup/machine.slice/systemd-nspawn@demo.service
cat $CG/memory.current; cat $CG/memory.events; cat $CG/memory.swap.current419430400 bytes (419 MB, 400 MiB) copied, 0.23094 s, 1.8 GB/s
267776000
low 0
high 0
max 1089
oom 0
oom_kill 0
oom_group_kill 0
sock_throttled 0
170487808
쓰기 자체는 성공했지만 memory.current는 267,776,000바이트(256MB 한도)에서 멈췄고 한도에 부딪힌 횟수(max)가 1089회로 기록됐다. 초과분 170MB는 스왑으로 밀려났다 — tmpfs 페이지는 회수 가능해 OOM 킬 없이 처리되므로, 스왑이 없는 호스트에서는 같은 작업이 OOM으로 끝난다.
사용자 네임스페이스로 root 분리하기
기본 설정에서 컨테이너의 root는 호스트의 root와 같은 UID 0이다. 컨테이너를 탈출당하면 곧바로 호스트 root가 된다.
$ sudo systemd-nspawn -q -D /var/lib/machines/demo --timezone=off --pipe -- \
/bin/bash -c 'id -u; cat /proc/self/uid_map'
0
0 0 4294967295
--private-users=pick을 주면 사용 중이지 않은 UID 대역 65536개를 골라 매핑한다. --private-users-ownership=map은 idmapped mount를 써서 디스크상 소유권을 건드리지 않고 매핑만 적용한다.
sudo systemd-nspawn -q -D /var/lib/machines/demo --timezone=off \
--private-users=pick --private-users-ownership=map --pipe -- \
/bin/bash -c 'id -u; cat /proc/self/uid_map'0
0 1011482624 65536
$ stat -c '%u' /var/lib/machines/demo/etc/hostname
0
컨테이너 안에서는 여전히 root(0)지만 호스트 커널이 보는 실제 UID는 1011482624다. rootfs의 파일 소유자는 0으로 그대로 남아 다른 도구로 그 디렉터리를 다뤄도 문제가 없다.
정리와 삭제
sudo machinectl poweroff demo # 컨테이너 안에서 정상 종료
sudo machinectl terminate demo # 응답 없을 때 강제 종료
sudo rm -rf /var/lib/machines/demo /etc/systemd/nspawn/demo.nspawn주의사항
- rootfs 신뢰: nspawn은 커널을 공유한다. 신뢰할 수 없는 이미지를 부팅할 때는
--private-users를 반드시 켜고, 그래도 커널 취약점 앞에서는 VM 수준의 격리가 아니라는 점을 전제한다. /var/lib/machines는 btrfs일 때 이점이 있다: btrfs 서브볼륨이면machinectl clone이 CoW 스냅샷으로 즉시 끝난다. ext4에서는 전체 복사라 175MB를 그대로 다시 쓴다.- 도커와 같은 호스트: 위에서 본 대로 도커가
FORWARD정책을 drop으로 바꾼다. 컨테이너 NAT가 안 되면 방화벽부터 확인한다. machinectl shell은 PTY 세션: 스크립트에서 출력을 파싱하려면systemd-run -M ... --pipe를 쓴다.- 호스트네임: 부팅형 컨테이너는 rootfs의
/etc/hostname을 그대로 쓴다. debootstrap으로 만들면 빌드한 호스트 이름이 박히므로 직접 덮어써야 machinectl 이름과 맞는다. - 부팅 없이 쓰는 경우: 단순히 격리된 셸이 필요하면
--pipe로 충분하다.machinectl start는 컨테이너 안에서 systemd 서비스를 돌려야 할 때만 쓴다.
마무리
systemd-nspawn은 이미지 포맷도, 데몬도, 레지스트리도 요구하지 않는다. 디렉터리 하나를 부팅해 systemd가 도는 작은 배포판을 얻고, cgroup 제한과 사용자 네임스페이스는 systemd의 기존 메커니즘을 그대로 쓴다. 패키지 스크립트 검증이나 유닛 파일 테스트처럼 “깨끗한 우분투 한 대”가 필요한 작업이라면 도커보다 손이 덜 간다.