LinuxのUnshareコマンドを使った簡易コンテナの作成
LinuxのコンテナテクノロジーはDockerやPodmanなどの優れたツールが広く使われているが、本質的にはLinuxカーネルの機能である名前空間とcgroupsを活用していることは意外と知られていない。
Linuxカーネルには、名前空間を分離する機能がある。この機能を活用することで、ホストOSのファイルシステムやネットワークなどのリソースを、コンテナ内に分離された環境として提供できる。
このLinuxの基礎的な機能を使えば、コンテナイメージを使わずにホストOSのリソースをそのまま流用した簡易的なコンテナがつくれる。
本稿では、unshareコマンドを使ってホストOSから分離した名前空間を作成して、簡易的なコンテナを作成する手順を説明する。unshareコマンドは名前空間を操作するための基本的なLinuxコマンドで、rootユーザーでなくても使えるので、できるだけホスト側の権限を最小限に抑えた環境を作ることができる。このアプローチは用途が限られるが、コンテナの仕組みを手軽に理解することの一助にもなるだろう。
検討
本稿でのルール
- 名前空間を使った仮想化
- docker, podman, lxc そのものはなるべく使わない
- コンテナイメージを持ってきて使うのではなく、ホスト自身のを流用する
- 可能な限りホスト側は root を使わない
unshare コマンドで「コンテナプロセス」を作る。対話的に使う場合はコマンドを書かないか bash --login
などと書く。
unshare オプション コマンド
root になりきる
root になりきる場合は -r
(--map-root-user
) を使う。
$ unshare -r bash -c 'whoami; id -u; id -g'
root
0
0
しかしアクセス権限は当然引き継がれるので、root でしかできない作業が全部できるわけではない。
$ unshare -r bash -c 'touch /foobar'
touch: '/foobar' に touch できません: 許可がありません
自分に戻る
root になりきったあとで自分に戻りたいときもある。
unshare で root になっていても、実際には root ではないので su や sudo は使えない。
$ unshare -r su $(whoami)
su: cannot set groups: 許可されていない操作です
なので unshare の中で再度 unshare を実行してユーザを偽装しなおす手段で自分に戻るのが無難そうである。
$ id -u; id -g
1000
1000
$ unshare -r unshare --map-user=$(id -u) --map-group=$(id -g) bash -c 'id -u; id -g'
1000
1000
1つ目の unshare -r
で uid=0 を外側の uid=1000 にマッピングする。
そして2つめの unshare --map-user=1000 --map-group=1000
で uid=1000 を外側の uid=0 にマッピングする。
結果として uid=1000 が uid=1000 にマッピングされて自分に戻す。
なお root になりきる必要がないのならば -c
を使えば良い。下記の例では何もしていないが、これにネットワークの仮想化などを増やすと意味がでてくる。
$ unshare -c bash -c 'id -u; id -g'
1000
1000
ファイルシステムをいじる
-m
(--mount
) で新しいマウント名前空間を生成できる。既存の特定のディレクトリを置き換える場合には、 unshare
の中で適当にマウントしてしまえばよい。
例えば個人設定をクリーンな状態で動作させたいなどの理由でホームディレクトリを空にした状態をつくるには、ホームディレクトリを tmpfs でマウントし直せば良い。なお、カレントディレクトリをマウントし直した場合、マウント前のディレクトリが見えたままになるので、移動し直すのが良い。
$ unshare -rm --propagation private bash --login
# mount -t tmpfs tmpfs $HOME
# cd $(pwd)
# ls
#
しかし、ルートディレクトリ /
をすげ替えることはできない。また、他のシステムディレクトリもすげ替えることはできるが、コマンドが実行できなくなるなどの影響がでてしまうので現実的には使えない。
$ unshare -rm --propagation private bash --login
# mount -t tmpfs tmpfs /lib64
# ls /
bash: 行 1: /usr/bin/ls: 実行できません: 必要なファイルがありません
解決策のひとつは、本稿のルールから逸脱するが、 rootfs をどこかに展開してそこに chroot
でルートディレクトリをすげ替えることである。
単に chroot alpine-rootfs /bin/ash --login
を実行するには root 権限が必要であるが、 unshare -r
で root になりすませば実際の root 権限なしでいける。
$ wget -q https://dl-cdn.alpinelinux.org/alpine/v3.19/releases/x86_64/alpine-minirootfs-3.19.1-x86_64.tar.gz
$ mkdir alpine-rootfs
$ tar xf alpine-minirootfs-3.19.1-x86_64.tar.gz -C alpine-rootfs
$ ls -F alpine-rootfs
bin/ dev/ etc/ home/ lib/ media/ mnt/ opt/ proc/ root/ run/ sbin/ srv/ sys/ tmp/ usr/ var/
$ unshare -r chroot alpine-rootfs /bin/ash --login
# ls -F
bin/ dev/ etc/ home/ lib/ media/ mnt/ opt/ proc/ root/ run/ sbin/ srv/ sys/ tmp/ usr/ var/
# touch /foobar
# ls -F
bin/ dev/ etc/ foobar home/ lib/ media/ mnt/ opt/ proc/ root/ run/ sbin/ srv/ sys/ tmp/ usr/ var/
# exit
$ ls -F alpine-rootfs
bin/ dev/ etc/ foobar home/ lib/ media/ mnt/ opt/ proc/ root/ run/ sbin/ srv/ sys/ tmp/ usr/ var/
なお、 chroot を実行するだけではいわゆる脱獄が可能であるため pivot_root
をするのが好ましいとされている。
$ unshare -rm --propagation=private
# mount --bind alpine-rootfs alpine-rootfs
# cd alpine-rootfs
# mkdir -p .put_old
# pivot_root . .put_old
# cd /
# exec /bin/ash --login
# umount -l /.put_old
# rmdir /.put_old
# ls -F
bin/ dev/ etc/ home/ lib/ media/ mnt/ opt/ proc/ root/ run/ sbin/ srv/ sys/ tmp/ usr/ var/
# touch /foobar
# ls -F
bin/ dev/ etc/ foobar home/ lib/ media/ mnt/ opt/ proc/ root/ run/ sbin/ srv/ sys/ tmp/ usr/ var/
いずれにせよ一部を可変する場合と違って、全体を可変する場合は rootfs をつくってそこに chroot もしくは pivot_root でルートディレクトリを変えるのが基本戦略となる。
overlayfs でルートディレクトリ /
をまるごと書き込み可能な状態にできれば便利であると考えたが、これはエラーが出て動作しなかった。いくつか実装を試したり、既存の実装を確認したがかなり骨の折れる作業となりそうで現実的でない。
参考までに rootlesskit は置換先ディレクトリを tmpfs をマウントし、オリジナルを .ro0000000000
のようなサブディレクトリにマウントし、ファイルやディレクトリはシンボリックリンクをする実装となっていた。
$ mkdir -p test/test1 test/test2
$ touch test/test3
$ rootlesskit --copy-up=$(pwd)/test ls -lFa $(pwd)/test
合計 0
drwxrwxrwt. 3 root root 120 3月 31 12:30 ./
drwxr-xr-x. 3 root root 60 3月 31 12:30 ../
drwxr-xr-x. 4 root root 100 3月 31 12:30 .ro2787431005/
lrwxrwxrwx. 1 root root 19 3月 31 12:30 test1 -> .ro2787431005/test1/
lrwxrwxrwx. 1 root root 19 3月 31 12:30 test2 -> .ro2787431005/test2/
lrwxrwxrwx. 1 root root 19 3月 31 12:30 test3 -> .ro2787431005/test3
というわけで、一部のディレクトリを入れ替える隠すといった小さな差分処理以外は素直に docker や podman などのコンテナ仮想化ソフト群を使うのが現実的であろう。
イーサネットを分離するのは簡単だがつなげるのは面倒
イーサネットを分離するネットワーク名前空間を導入すること自体のは簡単で -n
( --net
) を使うだけだ。
$ unshare -cn ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
ループバックアドレスすら定義されず、 127.0.0.1 すら存在しない。イーサネットを一切使わせないときには便利であろう。
この状態からネットワークにつなげていくとなると途端に難しくなる。というのもネットワークトンネリング構築技術と同じ話になるからだ。
ip netns が便利
ip netns
を使うのが実のところ便利である。
unshare だと名前空間に名前がつかないので、ネットワークの配線が非常に難しいからだ。
しかし sudo が必須である。ただ、閉網であるのならば以下の条件下で root 権限不要で使用可能だ
unshare -rnm --propagation=private
でネットワークとマウントを分離した簡易コンテナをつくる/var/run/netns
を分離すること(たとえば tmpfs でマウントしなおすこと)unshare
の外界とのイーサネット接続性がないこと(閉網であること)
例えばネットワーク名前空間 ns1 ns2 を作成し、それらを veth-pair でつなぐ例 秒速でネットワーク作成 [veth peer] - サーバーワークスエンジニアブログ にある一連の処理を、ユーザ権限で実行できる。
$ unshare -rnm --propagation=private
# mount -t tmpfs tmpfs /var/run
# ip netns add ns1
# ip netns add ns2
# ip link add eth0-ns1 type veth peer name eth0-ns2
# ip link set eth0-ns1 netns ns1
# ip link set eth0-ns2 netns ns2
# ip netns exec ns1 ip address add 192.168.0.101/24 dev eth0-ns1
# ip netns exec ns2 ip address add 192.168.0.102/24 dev eth0-ns2
# ip netns exec ns1 ip link set lo up
# ip netns exec ns2 ip link set lo up
# ip netns exec ns1 ip link set eth0-ns1 up
# ip netns exec ns2 ip link set eth0-ns2 up
# ip netns exec ns1 ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host proto kernel_lo
valid_lft forever preferred_lft forever
3: eth0-ns1@if2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
link/ether 8e:11:b1:37:2d:f5 brd ff:ff:ff:ff:ff:ff link-netns ns2
inet 192.168.0.101/24 scope global eth0-ns1
valid_lft forever preferred_lft forever
inet6 fe80::8c11:b1ff:fe37:2df5/64 scope link proto kernel_ll
valid_lft forever preferred_lft forever
# ip netns exec ns2 ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host proto kernel_lo
valid_lft forever preferred_lft forever
2: eth0-ns2@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
link/ether 9e:5c:50:6a:35:bf brd ff:ff:ff:ff:ff:ff link-netns ns1
inet 192.168.0.102/24 scope global eth0-ns2
valid_lft forever preferred_lft forever
inet6 fe80::9c5c:50ff:fe6a:35bf/64 scope link proto kernel_ll
valid_lft forever preferred_lft forever
# ip netns exec ns1 ping 192.168.0.102 -c 3 -i 0
PING 192.168.0.102 (192.168.0.102) 56(84) バイトのデータ
64 バイト応答 送信元 192.168.0.102: icmp_seq=1 ttl=64 時間=0.084ミリ秒
64 バイト応答 送信元 192.168.0.102: icmp_seq=2 ttl=64 時間=0.014ミリ秒
64 バイト応答 送信元 192.168.0.102: icmp_seq=3 ttl=64 時間=0.012ミリ秒
--- 192.168.0.102 ping 統計 ---
送信パケット数 3, 受信パケット数 3, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.012/0.036/0.084/0.033 ms, ipg/ewma 0.091/0.067 ms
作成したネットワークを図示すると下記の通り。
なおこのようなネットワーク関係の作業をするときには、別窓(別プロセスの端末)からアクセスするのが便利である。
その場合は大本の unshare した端末でのプロセスを確認しておき (echo $$
で出力される)、その名前空間に nsenter で入れば良い。
例えばターゲット先のプロセスIDが 54321 であったとするならば、下記のようにすれば良い。
$ nsenter --preserve-credentials -U -n -m -t 54321 ip netns exec ns1 ping 192.168.0.102 -c 3 -i 0
PING 192.168.0.102 (192.168.0.102) 56(84) バイトのデータ
64 バイト応答 送信元 192.168.0.102: icmp_seq=1 ttl=64 時間=0.042ミリ秒
64 バイト応答 送信元 192.168.0.102: icmp_seq=2 ttl=64 時間=0.020ミリ秒
64 バイト応答 送信元 192.168.0.102: icmp_seq=3 ttl=64 時間=0.016ミリ秒
--- 192.168.0.102 ping 統計 ---
送信パケット数 3, 受信パケット数 3, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.016/0.026/0.042/0.011 ms, ipg/ewma 0.091/0.036 ms
大本の unshare のプロセスを閉じればネットワーク名前空間もろともなくなるので、元記事と違ってお片付けの作業は省いてそのまま exit
しても良い。
slirp4netns で外界へのアクセスする
通常のルートレスコンテナは外界への接続性があって、wget
や git clone
などでインターネット上の資産をダウンロードできる。
コンテナ内に tap デバイスが生えていて、この tap を経由して外界に出ている。(podman v4の場合)
$ podman unshare --rootless-netns
# ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host proto kernel_lo
valid_lft forever preferred_lft forever
2: tap0: <BROADCAST,UP,LOWER_UP> mtu 65520 qdisc fq_codel state UNKNOWN group default qlen 1000
link/ether 3e:2b:29:ab:13:fc brd ff:ff:ff:ff:ff:ff
inet 10.0.2.100/24 brd 10.0.2.255 scope global tap0
valid_lft forever preferred_lft forever
inet6 fd00::3c2b:29ff:feab:13fc/64 scope global dynamic mngtmpaddr proto kernel_ra
valid_lft 85937sec preferred_lft 13937sec
inet6 fe80::3c2b:29ff:feab:13fc/64 scope link proto kernel_ll
valid_lft forever preferred_lft forever
# ip route
default via 10.0.2.2 dev tap0
10.0.2.0/24 dev tap0 proto kernel scope link src 10.0.2.100
ユーザ権限で動く bridge が動いていて、 tap0 で受けたものを代理して外界に通信する仕組みになっている。
いくつか手段があるが podman v4 では slirp4netns がデフォルトとして使っている。
Terminal 1: Create user/network/mount namespaces
(host)$ unshare --user --map-root-user --net --mount (namespace)$ echo $$ > /tmp/pid
In this documentation, we use
(host)$
as the prompt of the host shell,(namespace)$
as the prompt of the shell running in the namespaces.(中略)
Terminal 2: Start slirp4netns
(host)$ slirp4netns --configure --mtu=65520 --disable-host-loopback $(cat /tmp/pid) tap0 starting slirp, MTU=65520 ...
Terminal 1: Make sure the
tap0
is configured and connected to the Internet(namespace)$ ip a 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 3: tap0: <BROADCAST,UP,LOWER_UP> mtu 65520 qdisc fq_codel state UNKNOWN group default qlen 1000 link/ether c2:28:0c:0e:29:06 brd ff:ff:ff:ff:ff:ff inet 10.0.2.100/24 brd 10.0.2.255 scope global tap0 valid_lft forever preferred_lft forever inet6 fe80::c028:cff:fe0e:2906/64 scope link valid_lft forever preferred_lft forever (namespace)$ echo "nameserver 10.0.2.3" > /tmp/resolv.conf (namespace)$ mount --bind /tmp/resolv.conf /etc/resolv.conf (namespace)$ curl https://example.com
上記の手順、すなわち簡易コンテナのプロセス番号を渡す形で slirp4netns --configure --mtu=65520 --disable-host-loopback PID tap0
のように実行すれば、 podman と同様のネットワーク構成が手に入る。
また slirp4netns はポートフォワーディング機能も API 経由で対応している。
slirp4netns can provide QMP-like API server over an UNIX socket file:
(host)$ slirp4netns --api-socket /tmp/slirp4netns.sock ...
add_hostfwd
: Expose a port (IPv4 only)(namespace)$ json='{"execute": "add_hostfwd", "arguments": {"proto": "tcp", "host_addr": "0.0.0.0", "host_port": 8080, "guest_addr": "10.0.2.100", "guest_port": 80}}' (namespace)$ echo -n $json | nc -U /tmp/slirp4netns.sock {"return": {"id": 42}}
If
host_addr
is not specified, then it defaults to “0.0.0.0”.If
guest_addr
is not specified, then it will be set to the default address that corresponds to--configure
.
なお、 podman v5 で pasta(passt) をデフォルトに使うようになった。ただ qemu, libvirt, podman とのインテグレーションは対応しているもののコマンドラインで使いやすいものではない。
いずれにせよ slirp4netns や pasta がある環境では docker や podman がインストールされていそうであるし、 そちらを使った方が大幅に楽だ。 たとえば下記のコマンドで、ネットワーク分離しつつ外界との接続性を維持する。
$ podman unshare --rootless-netns
プロセスを分離する
プロセスを分離するには -p
(--pid
) を指定すればいいが、実際のところ /proc
の分離なども必要となってくるので -f
(--fork
) と --mount-proc
を加えた下記を実行することになる。
$ unshare -rfp --mount-proc ps -e
PID TTY TIME CMD
1 pts/2 00:00:00 ps
これで docker コンテナ内のプロセスのように PID=1 がユーザプログラムになる。
/proc
はマウントし直しされる形で分離されていて、 mount
の出力からもそれがわかる。
$ mount | grep 'type proc'
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
$ unshare -rfp --mount-proc mount | grep 'type proc'
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
プロセスの分離はコンテナどおしの分離という観点では非常に重要ではあるが、今回のような単に簡易的なコンテナで十分なシナリオでは必要性は薄いように思える。
使えそうなワンライナー
というわけで、使い勝手が良さそうなワンライナーとしては下記のぐらいだろうか。
ホームディレクトリをクリア(したとみせかけるため tmpfs でマウント)して実行。
unshare -rm --propagation private /bin/bash -c "/bin/mount -t tmpfs tmpfs $HOME && cd $HOME && unshare --map-user=$(id -u) --map-group=$(id -g)
ネットワーク設定練習用(まっさらな状態から)
unshare -cn
ネットワーク設定練習用(外部接続性がある状態から)
podman unshare --rootless-netns
参考文献
- 第2回 コンテナの仕組みとLinuxカーネルのコンテナ機能[1]名前空間とは? | gihyo.jp
- How it works | Rootless Containers
- chrootとunshareを使い、シェル上でコマンド7つで簡易コンテナ - へにゃぺんて@日々勉強のまとめ
- unshare -m とか mount –make-shared とか #Linux - Qiita
- unshare コマンドがマウントプロパゲーションをまともに扱うような変更を入れていた - TenForward
- 【コンテナ要素技術】pivot_rootについて例をまじえて説明します - フラミナル
- 秒速でネットワーク作成 [veth peer] - サーバーワークスエンジニアブログ
- rootless-containers/slirp4netns: User-mode networking for unprivileged network namespaces
- passt - Plug A Simple Socket Transport