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 にマッピングされて自分に戻す。

unshare -r unshare --map-user=1000 --map-group=1000 uid=0 を uid=1000 に map uid=1000 を uid=0 に map
入れ子になったユーザ名前空間のイメージ

なお 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

作成したネットワークを図示すると下記の通り。

    ns1 ns2 192.168.0.101/24 192.168.0.102/24 eth0-ns1 eth0-ns2
作成したネットワークのイメージ

なおこのようなネットワーク関係の作業をするときには、別窓(別プロセスの端末)からアクセスするのが便利である。 その場合は大本の 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 で外界へのアクセスする

通常のルートレスコンテナは外界への接続性があって、wgetgit 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 で受けたものを代理して外界に通信する仕組みになっている。

  rootless_bridge 10.0.2.0/24 10.0.2.2 10.0.2.3 10.0.2.100 internet gateway DNS tap0
rootless bridge を介したネットワーク接続のイメージ

いくつか手段があるが 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

参考文献