コンテナ仮想、その裏側 〜user namespaceとrootlessコンテナ〜

レトリバのCTO 武井です。

やあ (´・ω・`) うん、「また」コンテナの記事なんだ。済まない。

技術ブログの開設と新セミナー運用の開始にあたって、「前に話した内容をブログにしつつ、新しい差分をセミナーにすれば、一回の調べ物でどっちのネタもできて一石二鳥じゃないか」と思っていたのですが、

前のセミナーが情報詰め込みすぎでブログの文量がとんでもないことになって、
→ それが前提条件になってしまっているのでセミナー資料の文量も膨れ上がって、
→ 差分だけと思っていたUser名前空間も思った以上のボリュームで、
→ やっと一息かと思ったら、フォローアップ記事が残っていることを思い出すなど ←いまここ

一石二鳥作戦のはずが、どうしてこうなった……。
計画大事。


そんなわけで、今回は4/17にお話ししました「コンテナ仮想、その裏側 〜user namespaceとrootlessコンテナ〜」というセミナーのフォローアップ記事となります。 セミナーでは喋れなかったことなどを加筆しつつ、改めてコンテナ仮想の裏側で動いているUser名前空間について説明していこうと思います。

また、セミナーでは時間切れしてしまった、「手作りrootlessコンテナ」をシェルスクリプトで実装してみようと思います。

冒頭でもちらりと触れましたが、このセミナーは、前に話したセミナーを前提に話しております。 こちらはちょっと前にフォローアップ記事を挙げておりますので、先に↓の記事に目を通していただければと思います! (タイトルの付け方ミスったなぁ……)

tech.retrieva.jp

※ 文中にてRHEL8(beta)という文字列がでてきますが、セミナー当時と記事を書き始めた時はまだRHEL8のリリース前でした。ちんたら記事を書いているうちにリリースされちゃいました……。 https://www.redhat.com/en/enterprise-linux-8

はじめに: rootlessコンテナとは?

まずはじめにタイトルにもあります「rootlessコンテナ」。 これは読んで字の通り、root権限がいらないコンテナです。

Dockerなど従来のコンテナは実行にあたりroot権限が必要でした。 また、Dockerはroot権限で動くデーモンが動いており、Dockerの機能全体として、原則的にroot権限が必須になっています*1

docker グループを作れば、コンテナ作れるよ! という声もあるかもですが、それはどちらかといえば「root権限がいらない」ではなく「一般ユーザが(docker グループを経由して)root権限を持てる」、 rootfullな運用になります。

このroot権限のデーモンが動いていることは前々から問題視されていた、というのは前の記事でも書きましたが、その解決策の一つとして Podman というコンテナ仮想エンジンが登場しました。 これは、Red Hatが開発しているrootless運用可能なコンテナエンジンであり、RHEL8からDockerに代わってPodmanが採用されるようです。

rootlessにすることのメリットとしては利便性という観点もありますが、セキュリティー的な観点が大きいです。
コンテナ仮想という関係上、様々なコマンドが実行できることから脆弱性の幅が広く、root権限という強い権限も合わせると、非常にリスクの高いプロセスといえます。

実際にあった脆弱性の例としては、 /proc/self/exe を利用し、コンテナエンジンの中核となるコンテナランタム docker-runc を書き換える、という脆弱性があったようです(CVE-2019-5736)。 docker-runc は権限付きで実行されるため、この書き換えにより、任意のコードをroot権限で実行できてしまう、という脆弱性となります。

デーモンの権限がrootでなくなれば、もちろん脆弱性脆弱性で危険ではありますが、影響度・危険度を抑えることができます。

このrootlessコンテナを実現するための一つの技術が、User名前空間です。 今回の記事では、User名前空間を掘り下げつつ、rootlessコンテナの裏側を見ていこうと思います。

user名前空間の歴史と背景

前の記事ではスキップしてしまった6個目の名前空間です。

6個目と書いたのですが、 clone(2) のオプションとしてUser名前空間を作成する CLONE_NEWUSER フラグが追加されたのが Linux 2.6.23 (2007-10-09) で、これはpid名前空間CLONE_NEWPID とnet名前空間CLONE_NEWNET (いずれも Linux 2.6.24 (2008-01-28) 以降)よりも、早く用意されていました。
のですが、 clone(2) に現在の動作が取り込まれたのが Linux 3.5 (2013-07-21) 、user名前空間自体がちゃんと動作するようになったのは Linux 3.8 (2013-02-18) と、実に5年以上の歳月が掛かっての完成となりました。

RHELでは、さらに、RHEL7.1(2015-03-05)の時点でLinux 3.10を採用しつつもカーネルのビルドオプションで無効化、RHEL7.4(2017-07-31)*2でやっと有効化されたものの、カーネルオプション側でデフォルトでは制限、RHEL8(beta / 2018-11-16)でやっとデフォルト有効、とさらにさらに待つこと5年、標準で使える様になりました。

なんでこんなに時間が掛かったのでしょう?
というと、このuser名前空間という機能は、すごく雑にいえば「一般ユーザが(見かけ上)rootになれる」機能だったりします。 もちろん、ただ単にrootになる機能だとただの sudo になってしまいますが、ポイントは「(見かけ上)」ということ。 特定のプロセスからは、自分自身はrootに見えつつ、「外」からは元のユーザにしか見えない、元のユーザの権限でしかアクセス・操作ができない、という、要はコンテナの中でのrootユーザ・ユーザ管理の要になる機能となります。
この「見かけ上」が本当に「見かけ上」だけになってくれているのか、コンテナの中のrootがコンテナの外のroot権限として振る舞えてしまわないのか、このあたりがuser名前空間が実装・採用されるまでの長い道のりの焦点だったようです。
実際、Linux 3.8でちゃんと動作するようになったと書きましたが、3.8以降もUser名前空間に起因する脆弱性がいくつか見つかっており、まだまだUser名前空間を取り巻く開発は進んでいきそうです。

さて、そんな「一般ユーザが(見かけ上)rootになれる」というUser名前空間、実際どんなことが起こるのでしょう?
コマンドを実行しながら挙動を追ってみましょう!

是非、皆さんも手元に環境を作って試してみてください! RHELのアカウントを作って、RHEL 8 Beta Programに参加すればRHEL8(beta)が試用可能です: https://developers.redhat.com/rhel8/getrhel8
また、Ubuntuなどの他のディストリビューションでも既にUser名前空間が使えるものがありますので(というか、RHELが一番遅い感)、是非実際にコマンドを叩いて実践してみましょう!

いざ実践: 準備

まずは、実験用のユーザを用意します。 既存のユーザでも大丈夫ですが、このあとuid/gidがぽこじゃかでてきますので、読み替えが面倒であれば作ってしまったほうが楽かもです。

$ sudo useradd -m -U -u 2001 alice
$ sudo useradd -m -U -u 2002 bill
$ sudo useradd -m -U -u 2003 -G wheel cheshire
$ sudo passwd cheshire

この記事では、rootless、ということをはっきりさせるため、メインの作業ユーザの alice はsudoなどの権限を与えないようにしています。

いざ実践: とりあえずrootになってみる

さて、色々な御託はともかく、実際にrootユーザに(見かけ上)なってみましょう! sudo権限すら持ち合わせていない一般ユーザ aliceunshare -U -r を実行してみます。

[alice@rutledge ~]$ unshare -U -r
[root@rutledge ~]# id
uid=0(root) gid=0(root) groups=0(root) ...

プロンプトのユーザ名は root に、プロンプトは # に、id (1) の結果も uid=0(root) gid=0(root) と、rootになれてしまっているように見えます。 簡単ですね!

さて、この「rootっぽいシェル」で sleep (1) とかを立ち上げて、このシェルから、そして他のシェルから見てみましょう。

[root@rutledge ~]# sleep 3600 &  # rootっぽいシェルでsleepをバックグラウンド実行
[1] 27265
[root@rutledge ~]# ps aux --forest  # 同じシェルでsleepを確認
:
nobody    27175  0.0  1.1 159392  9468 ?        Ss   23:01   0:00  \_ sshd: alice [priv]
root      27179  0.0  0.6 159392  5224 ?        S    23:02   0:00  |   \_ sshd: alice@pts/0
root      27180  0.0  0.6  28392  5536 pts/0    Ss   23:02   0:00  |       \_ -bash
root      27205  0.0  0.6  29344  5284 pts/0    S    23:02   0:00  |           \_ -bash
root      27265  0.0  0.1   7284   820 pts/0    S    23:07   0:00  |               \_ sleep 3600
root      27266  0.0  0.5  57500  4364 pts/0    R+   23:07   0:00  |               \_ ps aux --forest
:
[alice@rutledge ~]$ ps aux --forest  # 別のシェルからsleepを確認
:
root      27175  0.0  1.1 159392  9468 ?        Ss   23:01   0:00  \_ sshd: alice [priv]
alice     27179  0.0  0.6 159392  5224 ?        S    23:02   0:00  |   \_ sshd: alice@pts/0
alice     27180  0.0  0.6  28392  5536 pts/0    Ss   23:02   0:00  |       \_ -bash
alice     27205  0.0  0.6  29344  5284 pts/0    S+   23:02   0:00  |           \_ -bash
alice     27265  0.0  0.1   7284   820 pts/0    S    23:07   0:00  |               \_ sleep 3600
:

おやや? rootっぽいシェルの中では sleeproot のプロセスですが、別のシェルからは alice のプロセスになっています。

これが、冒頭ででてきた「一般ユーザが(見かけ上)rootになれる」の「(見かけ上)」という部分になります。 unshare したプロセスの中では、自分はrootに見え、一方で、表からただの一般ユーザの権限のまま。 実際、ファイルやプロセスの権限だけでなく、ネットワークの設定をいじる、マウント/アンマウントする、マシンの電源を落とす、 などの、root権限が必要な操作もまた、元のaliceの権限通り、できないことが確認できます。

[root@rutledge ~]# ip link set down dev lo
RTNETLINK answers: Operation not permitted

[root@rutledge ~]# mount -t tmpfs tmpfs /bin/
mount: /usr/bin: permission denied.

[root@rutledge ~]# poweroff
Failed to connect to bus: Operation not permitted
Failed to open initctl fifo: Permission denied
Failed to talk to init daemon.

そして、もう一点、表の世界では root のプロセスである sshd: alice [priv] が、「rootっぽいシェル」のなかでは nobody という謎のユーザになっています。 一体なにが起きているのでしょうか?

順を追って見てみましょう

……それにしても「 nobody というユーザが "いる"」というのは不思議な世界ですね。

‘I see nobody on the road,’ said Alice.
‘I only wish I had such eyes,’ the King remarked in a fretful tone.
‘To be able to see Nobody! And at that distance, too! Why, it’s as much as I can do to see real people, by this light!’

Through the Looking Glass, by Lewis Carroll

「道には誰もいないのが見えるわ」とアリス。
「私にそんな目があればなぁ」と王様は不機嫌に言う
「"誰もいない"(Nobody)が見えるなんて!それもこんな距離から!私にはこの明るさで実在する人間を見るのが精一杯だよ」

Through the Looking Glass, by Lewis Carroll

いざ実践: unshare -U

コマンドラインでUser名前空間を作るには、前回の記事 でも出てきた unshare(1) を使います。 user名前空間はさきほどもちらっとでてきた -U オプションを使います。 -rroot になるオプションなのですが、今回は付けずに実行してみます。 -r が何をやっているのかは後ほど説明します。

[alice@rutledge ~]$ unshare --help

Usage:
 unshare [options] [<program> [<argument>...]]

Run a program with some namespaces unshared from the parent.
:
 -U, --user[=<file>]       unshare user namespace
:

他の名前空間の作成にはroot権限が必要でしたが、(Linux 3.8以降では)ユーザ権限で実行することができます。

[alice@rutledge ~]$ unshare -U
[nobody@rutledge ~]$ id
uid=65534(nobody) gid=65534(nobody) groups=65534(nobody) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
[nobody@rutledge ~]$ ls -l -d /home/* /root/
drwx------. 10 nobody nobody 4096 Apr 23 17:00 /home/alice
drwx------.  2 nobody nobody   62 Apr 16 12:31 /home/bill
drwx------.  2 nobody nobody   83 Apr 18 10:02 /home/cheshire
dr-xr-x---.  2 nobody nobody  151 Apr 17 09:28 /root/
[nobody@rutledge ~]$ ls -l --numeric-uid-gid -d /home/* /root/
drwx------. 10 65534 65534 4096 Apr 23 17:00 /home/alice
drwx------.  2 65534 65534   62 Apr 16 12:31 /home/bill
drwx------.  2 65534 65534   83 Apr 18 10:02 /home/cheshire
dr-xr-x---.  2 65534 65534  151 Apr 17 09:28 /root/
[nobody@rutledge ~]$ ps aux --forest
:
nobody    27229  0.0  1.1 159392  9604 ?        Ss   May09   0:00  \_ sshd: alice [priv]
nobody    27233  0.0  0.6 159392  5448 ?        S    May09   0:00      \_ sshd: alice@pts/2
nobody    27234  0.0  0.7  28524  5832 pts/2    Ss   May09   0:00          \_ -bash
nobody    27501  0.6  0.6  27220  5188 pts/2    S    00:31   0:00              \_ -bash
nobody    27526  0.0  0.5  55376  4120 pts/2    R+   00:31   0:00                  \_ ps aux --forest
:

さて、 -r なしに unshare -U すると、ユーザ/グループが 65534(nobody) になってしまいました。 また、ファイルのユーザ/グループやプロセスの所有者もすべて 65534(nobody) の持ち物になっています。 これは一体、どんな状況なのでしょうか?

もう少し、この nobody が出来ることを探ってみます。

[nobody@rutledge ~]$ touch /home/alice/hello
[nobody@rutledge ~]$ touch /home/bill/hello
touch: cannot touch '/home/bill/hello': Permission denied
[nobody@rutledge ~]$ test -r /home/alice ; echo $?
0
[nobody@rutledge ~]$ test -r /home/bill ; echo $?
1

おなじ drwx------ nobody:nobody なのに、 /home/alice/hello は作れるけど、 /home/bill/hello は作れない。
ファイルのuid/gidが一致して、かつ、 u+rwxパーミッションが付いているのに test -r (読み込み権限のチェック)の結果が違う。 どうも摩訶不思議なことが起きているようです。
が、どうも、実行結果を見てみると、uid/gidこそ代わってはいるものの、元のunshareする前のaliceが出来ることと、全く同じことができて、全く同じことができない、様に見えます。

User名前空間の外から挙動を見てみましょう。 別のシェルを開いて、先ほどnobodyが作った /home/alice/hello を見てみます。

[alice@rutledge ~]$ ls -l hello  # 名前空間の外
-rw-rw-r--. 1 alice alice 0 Apr 23 17:00 hello

作ったファイルは元の名前空間ではaliceユーザのものになっています。 やはり、名前空間の中でやった作業はaliceの操作として行われているようです。

さて、名前空間の中の 65534(nobody) 。どこから出てきた数字/名前なのでしょうか?

nobody の名前の方は /etc/passwd で見つけることができます。

[alice@rutledge ~]$ cat /etc/passwd | grep nobody
nobody:x:65534:65534:Kernel Overflow User:/:/sbin/nologin
[alice@rutledge ~]$ cat /etc/group | grep nobody
nobody:x:65534:

一方、uid/gidの65534はカーネルオプションの kernel.overflowuid kernel.overflowgid の値になります。

[alice@rutledge ~]$ sysctl kernel.overflowuid
kernel.overflowuid = 65534
[alice@rutledge ~]$ sysctl kernel.overflowgid
kernel.overflowgid = 65534

これは元々は、各所に overflow とあるとおり、32bitのuid/gidが扱えない16bitのアーキテクチャで、65535以上のuid/gidがアプリケーションから返ってきてしまった場合に、オーバフローさせないために使われるuid/gidのようです *3 。 これが、User名前空間では、「マップされていないuid/gid」として使われています。

いざ実践: uid_map/gid_map

「マップされていない」とはどういう事でしょう?

User名前空間は、ユーザやグループを元の名前空間から切り離して管理できる仕組みですが、既存の名前空間のファイルやプロセスとの関係のため、元の名前空間のユーザ/グループと対応付けをすることができます。

作ったばかりのUser名前空間にはこのuid/gidの対応付け、「uid_map」/「gid_map」がありません。

子user名前空間の中のuid   親user名前空間でのuid
-                         0    (root)
-                         2001 (alice) <- カレントプロセスの権限
-                         2002 (bill)
-                         2003 (cheshire)
↑ 割り当てがない場合には65534(overflowuid/gid)が使われる

図にするとこんなイメージです。親の名前空間でのuid/gidは、子User名前空間の中では、マップされたuid/gidに変換され、対応がないものが nobody と扱われます。 対応がないものは全部一緒くたに nobody になっていますが、権限そのものは実際には区別されています。 これが、前節で出てきた、「同じnobodyなのに挙動が違う」の原因です。

uid_map/gid_mapを設定するには、そのUser名前空間の属しているプロセス(のどれか一つ)の、 /proc/${pid}/uid_map ファイルにマップ 情報を書き込みます。 このuid_mapには色々制約があったりするのですが、とりあえずやってみましょう。

まずは、User名前空間を作ってpidを確認します。

[alice@rutledge ~]$ unshare -U
[nobody@rutledge ~]$ id
uid=65534(nobody) gid=65534(nobody) groups=65534(nobody) ...
[nobody@rutledge ~]$ echo $$
2749

pidを確認したら、別のシェルを起動し、元の名前空間からuid_mapを書き込みます。今回はとりあえず 0 2001 1 という値を書き込みます。

[alice@rutledge ~]$ echo '0 2001 1' > /proc/2749/uid_map

uid_mapを書き込んだら、先ほどのUser名前空間のシェルに戻ってuidを確認してみます。

[nobody@rutledge ~]$ id
uid=0(root) gid=65534(nobody) groups=65534(nobody) ...
[nobody@rutledge ~]$ ls -ld /home/* /root/
drwx------. 10 root   nobody 4096 Apr 23 17:00 /home/alice
drwx------.  2 nobody nobody   62 Apr 16 12:31 /home/bill
drwx------.  2 nobody nobody   83 Apr 18 10:02 /home/cheshire
dr-xr-x---.  2 nobody nobody  151 Apr 17 09:28 /root/

いつのまにかカレントユーザのuidが 0(root) に変わっています。 (プロンプトのユーザー名がnobodyのままなのはbashが起動時に情報を集めているからの様です *4 )

先ほど書き込んだ 0 2001 1 は、「子user名前空間の uid 0」から始まる連番uidを、「親の名前空間の uid 2001」から「1 件分」にマップする、ことを意味しています。

子user名前空間の中のuid   親user名前空間でのuid
-                         0    (root)
0                         2001 (alice) <- カレントプロセスの権限
-                         2002 (bill)
-                         2003 (cheshire)
↑ 割り当てがない場合には65534(overflowuid/gid)が使われる

これで、親のUser名前空間でのuid 2001(=alice) は、User名前空間の中では 0(=root) として見え、それ以外のマップのないuid/gidは 65534(=nobody) となります。

gid_mapについても同様ですが、 Linux 3.19 からプロセスの補助グループを設定する setgroups の権限と、gid_mapの設定権限が排他となったため、 setgroups が有効な場合には、先にそちらを無効にする必要があります。
プロセスに対する setgroups 権限は、同様に /proc ファイルシステム上のファイルを使い、確認/設定することができます。

[alice@rutledge ~]$ cat /proc/2749/setgroups
allow
[alice@rutledge ~]$ echo "0 2001 1" > /proc/2749/gid_map  # setgroupsがallowの時は書き込めない
-bash: echo: write error: Operation not permitted

[alice@rutledge ~]$ echo deny > /proc/2749/setgroups
[alice@rutledge ~]$ cat /proc/2749/setgroups
deny
[alice@rutledge ~]$ echo '0 2001 1' > /proc/2749/gid_map  # setgroupsをdenyにすると書き込める

これでgidに関してもuidと同様に、親のUser名前空間でのgid 2001(=alice) は、User名前空間の中では 0(=root) として見えるようになりました。

冒頭で試した unshare -U -r ではuid/gidが共に0(=root)になっていましたので、これでおなじ状態になったように見えます。 実際、 unshare (1)-r を付けた際のソースコードの当該部分 を読むと、 setgroups の無効化と、 uid_map/gid_map に 0 (UID) 1 のエントリを書き込むだけ、と言うのが確認できます。

余談ですが、 unshare (1)ソースコードは、システムコールunshare (2) の呼び出し + 付随したあれこれだけと、シンプルなので一回読んでみると色々理解が進みます💪

ちょっと寄り道: uid_map/gid_mapをもうすこし詳しく

このuid_map/gid_mapですが、いくつかの制限があります。

まず、書き込むことができるのは一回のみです。複数行のマップを書き込むことができますが、追加でマップを書き込むことはできません。 一回で全部のマップを書き込む必要があります。たとえ同じ内容でも二度目の書き込みは失敗してしまいます。

[alice@rutledge ~]$ echo "0 2001 1" > /proc/28843/uid_map  # 初回は成功しても
[alice@rutledge ~]$ echo "0 2001 1" > /proc/28843/uid_map  # 二回目は失敗する
-bash: echo: write error: Operation not permitted

また、User名前空間の中のプロセスが自身のuid_map/gid_mapに書き込むことはできず、かならず親User名前空間のプロセスから書き込む必要があります。
C++などで実装しているときには、 fork (2) 後に子プロセス、親プロセスそれぞれに制御が移るため実装は楽ですが、シェルスクリプトだと別のシェルを開いたり、バックグラウンドプロセスを作ってpidをやりとりする、などの工夫が必要になります。

[alice@rutledge ~]$ unshare -U  # unshareしたそのシェルの中から
[nobody@rutledge ~]$ echo '0 2001 1' > /proc/$$/uid_map  # uid_mapに書き込めない
-bash: echo: write error: Operation not permitted

書き込むマップ先uid(=親の名前空間の uid)は、自分の権限内である必要があります。 言い換えれば、勝手に他のユーザのuidをマップできない、ということです。
User名前空間で、自分をrootユーザに、他のユーザを一般ユーザにマップできてしまうと、元の権限を越す権限を持ってしまうため、当たり前と言えば当たり前ですね。
当然、root権限であれば、任意のユーザをマップすることができます。

[alice@rutledge ~]$ unshare -U &  # バックグラウンドでUser名前空間を作成
[1] 29164
[alice@rutledge ~]$ echo "0 2002 1" > /proc/29164/uid_map  # 2002(=bill)を勝手にマップしようとしてみても失敗する
-bash: echo: write error: Operation not permitted

[alice@rutledge ~]$ su - cheshire  # sudo権限を持つcheshireにswitch userして
Password:
[cheshire@rutledge ~]$ { echo "0 2003 1"; echo "1 2001 1"; } | sudo tee /proc/29164/uid_map  # root権限で、 2003(=cheshire)を0に、2001(=alice)を1にマップ
[sudo] password for cheshire:
0 2003 1
1 2001 1
[cheshire@rutledge ~]$ exit
logout
[alice@rutledge ~]$ fg  # unshareに戻る
unshare -U
[nobody@rutledge ~]$ ls -l -n /home/  # 各ユーザのhomeディレクトリを見ると、cheshireが0に、aliceが1に、マップのないbillは65534のまま
total 4
drwx------. 11     1 65534 4096 May 10 16:48 alice
drwx------.  2 65534 65534   62 Apr 16 12:31 bill
drwx------.  2     0 65534   83 Apr 18 10:02 cheshire
子user名前空間の中のuid   親user名前空間でのuid
-                         0    (root)
1                         2001 (alice) <- カレントプロセスの権限
-                         2002 (bill)
0                         2003 (cheshire)
↑ 割り当てがない場合には65534(overflowuid/gid)が使われる

rootであれば任意のuidをマップすることができますが、範囲が被るようなマップは設定できません。 つまり、かならず、User名前空間の中のuidと、親の名前空間のuidは1対1になる必要があります

いざ実践: 井の中のrootはなにができるのか

さて、ちょっと脱線しましたが、一般ユーザが「見かけ上」rootになれることがわかりました。 ただ、この「rootユーザ」は、元の一般ユーザと同じだけの権限だけしかないように見えます。
見た目がrootになっただけなのでしょうか?

答えは否、ちゃんとrootとしてできることがあります。

rootのように振る舞えない一番の理由は、User名前空間の外のリソースを触ろうとしているからなのです。 たとえば、ファイルやプロセス、あるいはネットワークインターフェース、マウントポイント、バスなどなど。
User名前空間の中に収まるrootの権限の操作、たとえばchrootやUser名前空間以外のunshareなどは実行することができます。

前の記事 で出てきた名前空間はいずれもroot権限が必要でしたが、 User名前空間の中のrootでも実行可能であり、かつ、作られた各種名前空間は、そのUser名前空間内のものとして扱われます。

「User名前空間内のもの」になってしまえば、その名前空間(mntやnetやpidなど)はそのUser名前空間のrootが特権権限を持って触ることができるようになります。 つまり、
mnt名前空間を分ければ、mountもできる
net名前空間を分ければ、ネットワークインターフェースが作れる
pid名前空間を分ければ、その中のプロセスならなんでも殺せる
今までできなかったrootっぽい作業ができるようになるのです!!!
……さすがにハードウェアは名前空間の外なのでPCの電源は落とせないですが……。

User名前空間を起点に、各種名前空間を分離することができれば、ユーザ権限で隔離環境、つまり、rootlessコンテナができそうですね。

と、いうことで、やってみましょう!!

いざ実践: 井戸の中の王国建設

1) ファイルシステムを用意して、 2) User名前空間と各種名前空間を分離、3) pivot_root + chrootで、ファイルシステムにルートを移動、すればコンテナっぽいのができそうですね。

コマンドを叩きつつやってみます。

1. ファイルシステムの用意

どこからか、Linuxファイルシステムを入手しましょう。

今回は、docker hubからイメージを落とスクリプトを書いたので、それで落としてきます。 (jsonシェルスクリプトで扱うの大変なので jq を使っています)

gistfccd77c66709b910f41bc2a2f182abac

dockerのイメージは、レイヤーごとの差分を固めたファイルになっているので、それぞれ持ってきて保存しています。 また、レイヤー間の親子関係を保持するために parent というsymlinkを貼るようにしています。

展開する場合は以下のような感じで、parentをたどりきって、ベースを見つけて、遡りながら展開していきます。 途中chmodが入っているのは、 /bin など、 w パーミションがないディレクトリがあり、ファイルの追加ができないための苦肉の策だったりします*5

[alice@rutledge ~]$ ./pull_image.sh takei/centos centos7
[alice@rutledge ~]$ mkdir rootfs
[alice@rutledge ~]$ extract_images() { local layer="$1"; [ -d "${layer}/parent" ] && extract_images "${layer}/parent"; chmod u+w -R rootfs; tar -C rootfs -xf "${layer}/blob.tar-split.gz"; }
[alice@rutledge ~]$ extract_images images/layers/takei%2Fcentos%3Acentos7

対象のDockerリポジトリは、私のリポジトリtakei/centos:centos7 という謎イメージを使ってみています。
https://cloud.docker.com/repository/docker/takei/centos/general

2. User名前空間とmnt名前空間を分離

unshare (1) は(というか、unshare (2) も)、User名前空間と一緒に他の名前空間を作ると、User名前空間を作ってから、そのUser名前空間の中に他の名前空間を作成してくれます。 ので、まとめてオプションを指定することができます。

[alice@rutledge ~]$ unshare -U -r -m -n -p -f -i -u

3. pivot_root + chrootで、ファイルシステムにルートを移動

pivot_root + chroot で、ルートを切り替えます。 このあたりの詳細は、 前回の記事 に詳しく書いております。

[root@rutledge ~]# mkdir -p mnt
[root@rutledge ~]# mount --bind rootfs mnt  # pivot_rootw使うために、bindマウントする
[root@rutledge ~]# cd mnt
[root@rutledge mnt]# mkdir -p .old
[root@rutledge mnt]# pivot_root . .old
[root@rutledge mnt]# exec chroot .
bash# umount -l .old/
bash# mkdir -p /proc
bash# mount -t proc proc /proc

最低限マウントポイントや ps(1) の確認をしたいため、 /proc だけマウントしました。

さて、これでrootless コンテナ=モドキができました。

プロセスも分離され、ネットワークも分離され、マウントポイントも分離されています。

bash# ps aux --forest
USER        PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root          1  0.0  0.4  15212  3408 ?        S    10:27   0:00 /bin/bash -l
root         53  0.0  0.4  50876  3652 ?        R+   10:30   0:00 ps aux --forest
bash# 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
bash# findmnt
TARGET  SOURCE                               FSTYPE OPTIONS
/       /dev/mapper/rhel-home[/alice/rootfs] xfs    rw,relatime,seclabel,attr2,inode64,noquota
`-/proc proc                                 proc   rw,relatime

めでたし、めでたし……。

いざ実践: が、ダメッ

と、言いたいところですが、まだ厄介なポイントが残っています。

ユーザが作れない

まずは、ユーザまわり。試しにこのコンテナ=モドキ環境に一般作業ユーザを作って作業してみます。

bash# useradd knave -u 1000 -g 1000
Setting mailbox file permissions: Invalid argument
bash# su - knave
su: cannot set groups: Operation not permitted

エラーになってしまいました。 そもそも、uid_map/gid_mapが一つだけ、 0 2001 1 しか用意されていないため、User名前空間の中の 1000 のマップ先がありません。 複数の、uidをマップしようにも、一般ユーザでは、自分のuidしかマップすることしかできませんし、複数のuidを一つのuidにマップすることもできません。

どうしましょう🤔

ネットワークが繋がらない

前回の記事 では、vethを使ってネットワークに繋いでいました。 しかし、vethの作成には、元のUser名前空間でのroot権限が必要になります。一般ユーザのaliceでは作成できません。

どうしましょう🤔

ファイルシステムイメージの管理がダサい

今回、一個のrootlessコンテナ=モドキを実行するために、OSのファイルシステムをまるごとコピーが必要でした。 DockerみたいにCoWで管理したいところですが、 CoWの実現に必要なもの、dm-thinやoverlayfsなどは、軒並みUser名前空間のrootでは動かせません*6

どうしましょう🤔

podmanを見てみる

「ユーザが作れない」「ネットワークが繋がらない」「ファイルシステムイメージの管理がダサい」
この三つ問題は、独学ではちょっと太刀打ちできなさそうなのでカンニングしました。

rootlessコンテナの実装である podman の登場です。 RHEL8からは docker にかわり、標準コンテナの座につくであろうコンテナ仮想化エンジンです。

RHEL8では dnf でインストールすることができます(RHEL8からは yum じゃなくて dnf を使おう!)。

[cheshire@rutledge ~]$ sudo dnf install podman

このpodmanで、rootlessコンテナとして sleep inf を動かして、どんなことをしているのかチェックしてみましょう。

[alice@rutledge ~]$ podman run -d takei/centos:centos7 sleep inf  # sudoはいらない!

ちなみに、 podman のコマンド類は、 docker とまったく同じものが使えます。

podmanのuid_map/gid_map

まずは、「ユーザが作れない」問題です。

uid_map/gid_mapを見てみます。

[alice@rutledge ~]$ cat /proc/$(pgrep sleep)/uid_map
         0       2001          1
         1     100000      65536
[alice@rutledge ~]$ cat /proc/$(pgrep sleep)/gid_map
         0       2001          1
         1     100000      65536

おやおや、aliceをrootにマップする 0 2001 1 だけでなく、 1 100000 65536 というエントリが増えています。 これは何でしょう?というか、aliceの権限では、こんなマップは作れないはずです。

なんだこれ?

podmanのuid_map/gid_map: newuidmap/newguidmap

ここで出てくるのが newuidmap(1) newgidmap(1) コマンドです。 このコマンドは、 useradd(1) などのコマンドを提供する shadow-utils パッケージに追加されたコマンドです。

[alice@rutledge ~]$ rpm -qf $(which newuidmap)
shadow-utils-4.6-4.el8.x86_64
[alice@rutledge ~]$ newuidmap --help
usage: newuidmap <pid> <uid> <loweruid> <count> [ <uid> <loweruid> <count> ] ...
[alice@rutledge ~]$ ls -l $(which newuidmap)
-rwsr-xr-x. 1 root root 46976 Oct 12  2018 /usr/bin/newuidmap

このコマンドのポイントは、パーミッション -rwsr-xr-x.s 、setuidビットを使うことで、一般ユーザでもroot権限でファイルに書ける、というところです。
rootlessコンテナって言っているのに、setuidビットって……、という感じではありますが、dockerのroot権限デーモンと比べると、 やっていることは値のチェックとファイルに書くだけ、ソースコードも200行程度、とバグが混入しづらく*7ライフスパンも短い、 というとで、最低限のroot権限だけを押し込めた、という形になります、

書き込める値は、ユーザごとに許可されている範囲が決まっており、 /etc/subuid(/etc/subgid) というファイルで設定されます。

[alice@rutledge ~]$ cat /etc/subuid
alice:100000:65536
bill:165536:65536
cheshire:231072:65536

このエントリは、 useradd(1) 時に自動で追加されていきます(useradd(1)shadow-utils パッケージです!)。 各ユーザごと、 65536個のuid/gidの範囲が許可され、 alice の場合には 100000 から 165536 番までのuidをuid_mapに使うことができます。

使い方は簡単で、pidと、uid/gidマップの3つ組を並べて指定するだけです。 ためしに、 2001 (alice)0 (root) にマップしつつ、以降のpidを、aliceに許可されている 100000 番から連番でマップしてみます。

[alice@rutledge ~]$ unshare -U &
[1] 9233
[alice@rutledge ~]$ newuidmap $! 0 2001 1 1 100000 65536
[alice@rutledge ~]$ fg
子user名前空間の中のuid   親user名前空間でのuid
-                         0    (root)
0                         2001 (alice) <- カレントプロセスの権限
-                         2002 (bill)
-                         2003 (cheshire)
1                         100000 (ユーザ無し)
2                         100001 (ユーザ無し)
3                         100002 (ユーザ無し)
4                         100003 (ユーザ無し)
5                         100004 (ユーザ無し)
:                         :
↑ 割り当てがない場合には65534(overflowuid/gid)が使われる

これで、コンテナの中でユーザやグループを作れるようになりました。 コンテナの中で作ったユーザ/グループのファイルやプロセスは、コンテナの外では100000番以降のuid/gidとして見えるようになります。

これで「ユーザが作れない」問題は解決できそうです。

podmanのネットワーク

続いて「ネットワークが繋がらない」問題です。

podmanの中のネットワークインターフェースを調べてみます。

[alice@rutledge ~]$ podman exec -l 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
       valid_lft forever preferred_lft forever
2: tap0: <BROADCAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UNKNOWN group default qlen 1000
    link/ether da:1a:2a:b4:77:47 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::d81a:2aff:feb4:7747/64 scope link
       valid_lft forever preferred_lft forever

lo (ループバック)以外に tap0 というインターフェースが発生しています。 この tap0 というキーワードで、コンテナの外でプロセスをgrepってみると、 slirp4netns というプロセスが見つかります。

[alice@rutledge ~]$ ps aux --forest | grep -C1 [t]ap0
alice      9456  0.0  0.0   4316    72 ?        Ss   20:36   0:00  \_ sleep inf
alice      9465  0.0  0.1   4396   836 pts/1    S    20:36   0:00 /usr/bin/slirp4netns -c -e 3 -r 4 9456 tap0

ついでに、引数の 9456 はコンテナの中で起動している sleep inf のpidの様です。 どうもこのプロセスが怪しいですね。

なんでしょう、これ。

podmanのネットワーク: slirp4netns

slirp4netns は、その昔、ダイアルアップの時代、SLIP接続(Serial Line Internet Protocol)をソケットに見せかけるために作られたもの、らしいです。 ユーザ権限で動くネットワークデバイスの実装ということで、net名前空間向けに改造されて、ユーザランドレベルでのネットワークとして使われているようです。 slirp 自体は、 podmanだけでなく、QEMUなどでも使われているようです。

vethの場合、表のnet名前空間にvethの片端を出す必要があるため、表のUser名前空間でのroot権限が必要ですが、 slirpの場合、名前空間の中にはインターフェースが発生しますが、表の名前空間にはプロセスだけが発生し、 このプロセスが、ネットワークインタフェースの代わりに通信をすることとなります。
RHEL8(beta)で入る slirp4netns-0.1-1.dev.git... では、ポートのlistenができないようですが、 slirp4netns-0.1-2 からはlistenできるようになるようです(当然ですが、一般ユーザではwell-known portのlistenはできません)。

ちなみに、このプロセスを殺すと、コンテナの中からネットワークインターフェースが消え、ネットワークに繋がらなくなります。

[alice@rutledge ~]$ kill 9465
[alice@rutledge ~]$ podman exec -l 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
       valid_lft forever preferred_lft forever

slirp4netns の起動は名前空間の外で、net名前空間にいるプロセスのpidと、ネットワークインタフェース名を指定して実行すればokです。 IPアドレスなどに、ルールがあったりしますが、 -c オプションを付けると、アドレスの割り当てなどはやってくれるので、DNSの設定(resolv.conf)のみ必要です。

slirpのIPアドレスルール(一部)
default route: 10.0.2.2/24
DNS forward: 10.0.2.3
DHCP addresses: 10.0.2.15 - 10.0.2.31

「ネットワークが繋がらない」問題も無事解決です。

podmanのイメージ管理

最後に「ファイルシステムイメージの管理がダサい」問題です。

podmanはどのようにCoWなイメージ管理をしているのでしょうか、ということで、 findmnt / で調べて見ます。

[alice@rutledge ~]$ podman exec -l findmnt /
TARGET SOURCE                                                                                                                                 FSTYPE OPTIONS
/      /dev/mapper/rhel-home[/alice/.local/share/containers/storage/vfs/dir/e4a9534abb7728857754212e85179984718193e832465e29233634320bcba91c] xfs    rw,relatime,seclabel,attr2,inode64,noquota

どうやら /home/alice/.local 以下のbindマウントになっているようです ( /alice/.local/... というパスが見えますが、 /home にマウントされているのでフルパスとしては /home/alice/.local になります)。

コンテナの表からファイルを見てみます

[alice@rutledge ~]$ ll /home/alice/.local/share/containers/storage/vfs/dir/e4a9534abb7728857754212e85179984718193e832465e29233634320bcba91c
total 327700
-rw-r--r--.  1 alice alice     15759 Jul  6  2017 anaconda-post.log
lrwxrwxrwx.  1 alice alice         7 Jul  6  2017 bin -> usr/bin
drwxr-xr-x.  2 alice alice         6 May 14 21:00 dev
drwxr-xr-x. 48 alice alice      4096 May  8 18:15 etc
drwxr-xr-x.  2 alice alice         6 Nov  6  2016 home
-rw-r--r--.  1 alice alice 335544310 May 14 21:00 hugefile
lrwxrwxrwx.  1 alice alice         7 Jul  6  2017 lib -> usr/lib
lrwxrwxrwx.  1 alice alice         9 Jul  6  2017 lib64 -> usr/lib64
drwx------.  2 alice alice         6 Jul  6  2017 lost+found
drwxr-xr-x.  2 alice alice         6 Nov  6  2016 media
drwxr-xr-x.  2 alice alice         6 Nov  6  2016 mnt
drwxr-xr-x.  2 alice alice         6 Nov  6  2016 opt
drwxr-xr-x.  2 alice alice         6 May 14 21:00 proc
dr-xr-x---.  2 alice alice       137 Jul  6  2017 root
drwxr-xr-x. 10 alice alice       151 May 14 21:03 run
lrwxrwxrwx.  1 alice alice         8 Jul  6  2017 sbin -> usr/sbin
drwxr-xr-x.  2 alice alice         6 Nov  6  2016 srv
drwxr-xr-x.  2 alice alice         6 May 14 21:00 sys
drwxrwxrwt.  7 alice alice       132 May  8 18:15 tmp
drwxr-xr-x. 13 alice alice       155 Jul  6  2017 usr
drwxr-xr-x. 18 alice alice       238 Jul  6  2017 var

おや?普通に見えています。 overlayfsなど、なにか小洒落たことをやっているのであれば、mnt名前空間の中にマウントが閉じていそうな気がしますが、表のmnt名前空間からも見えています。

……これってもしかして単純コピーなのでは?

いやいや、さすがにそれはあるまい。

takei/centos:centos7 は、200Mちょっと、10個コンテナを起動してみると、単純コピーならその10倍の2Gぐらい、 そうで無くてCoWが効くのであれば、それよりも少ないディスク使用量で起動できるはずです。
ためしに、コンテナ10個起動してみて、ディスクの消費量を見てみます。

[alice@rutledge ~]$ df -h /home/; for (( i = 0; i < 10; ++i )); do podman run -d takei/centos:centos7 sleep inf; done; df -h /home/
Filesystem             Size  Used Avail Use% Mounted on
/dev/mapper/rhel-home   20G  3.1G   17G  16% /home
1758f4c1e9fe5383bb157b0011edeaa09835da05eaf775163ec454329f32e151
d61d7207bc008c8a1456adf8c6b284e8728902f91c14c0658060370366ec6039
1bc17340781789c7422b5e2b9b695a9603d5444d68573adca95a43ef4fa5f0a2
cbc9f78c4fba0771253b1fc951b30bd327323708889a692043f214cb447f3343
29a749812e1a91eaa6e98732f04b325df13f76a909e7f102fc048dd1c890ffed
7f24311850340be069c70e2db662946f7a9c5c4f368ebe1bc29377d97c31860c
726b60b0923483050000b028c2829c1f8e66dd3bcb72cf28492fe89d9bf4d8a6
d9b9c836584d5341de9755a927deb7e02c7c6a54edf247116722f024b2846bf5
e5881aa53872c40cf49554139cd2562ccd65826fff86e094ffd4e3d26e65dcda
a280f2fc37a999248eb5262fe98b69881775816dd30649bd0f39f0d30e1dd69e
Filesystem             Size  Used Avail Use% Mounted on
/dev/mapper/rhel-home   20G  5.7G   15G  29% /home

……。2G増えてる……。

CoWなんてなかったんやーーーー!!!!

podmanのイメージ管理: fuse-overlayfs

というのは、overlayfsなどの実装が使えないときのフォールバック、 vfs というイメージ管理(store)の挙動の様です。

調べると overlayfs を FUSE(Filesystem in Userspace)で実装した、 fuse-overlayfs がrootless運用で使える様です。

fuse-overlayfs をインストール、作ったコンテナやイメージを一回まっさらにして、 fuse-overlayfs を使うように設定してみます。

[alice@rutledge ~]$ su - cheshire
Password:
[cheshire@rutledge ~]$ sudo dnf install fuse-overlayfs
[sudo] password for cheshire:
[cheshire@rutledge ~]$ exit
logout
[alice@rutledge ~]$
[alice@rutledge ~]$ podman rm -f -a
[alice@rutledge ~]$ podman rmi -f -a
[alice@rutledge ~]$ rm -rf .local/
[alice@rutledge ~]$ mkdir -p .config/containers/
[alice@rutledge ~]$ cat .config/containers/storage.conf  # 設定を変更
[storage]
driver = "overlay"

[storage.options]
mount_program = "/usr/bin/fuse-overlayfs"

podmanが使うコンテナのイメージ管理(storage)の設定は ~/.config/containers/storage.conf で行います。
[storage] driver として overlay[storage.options] mount_program として fuse-overlayfs のパスを書けばokです。

あらためてコンテナを起動して、マウントポイントを確認すると、 fuse-overlayfs になってるのがわかります。

[alice@rutledge ~]$ podman run -d takei/centos:centos7 sleep inf
[alice@rutledge ~]$ podman exec -l findmnt /
TARGET SOURCE         FSTYPE              OPTIONS
/      fuse-overlayfs fuse.fuse-overlayfs rw,nosuid,nodev,relatime,user_id=0,group_id=0,default_permissions,allow_other

また、10個コンテナを起動してもディスク使用量は、ほぼ変化なし! ついでに、ファイルのコピーが無くなるため、起動も速くなります!素敵!

[alice@rutledge ~]$ df -h /home/; for (( i = 0; i < 10; ++i )); do podman run -d takei/centos:centos7 sleep inf; done; df -h /home/
Filesystem             Size  Used Avail Use% Mounted on
/dev/mapper/rhel-home   20G  1.8G   19G   9% /home
6fd5d37d06b3dab4e2da9bdb59a9ac72c8d34a8bab90c1b444f030c484bf20da
1231f2a6394239994501e0c381e7d48f9fdf39d64e2890c5a0af49b83b176ada
4092bbb54bb30af056e5401640a795d0654c3a4948008c8992d27e52597d59e0
2cd828f6bb0b204231074cefd57db81ed31ab6b28603adfbb395fe8c5fceda28
32e96ee5a041e86b786f2a15ea66cddcc04e3b203ff119a80767302dfe50a984
8c7f4cfaed989e22490002454753eecac0d29a51ae6a6f619fdf41462da53bc4
2dc68fd077e6a5d202c2cf3723e5151e5fd8b6bb8ef05a6aa25d5d207049626b
11e718b330b60ea90bb4fed3db37edbe2881c7db8bb5b4f83549b935ee6ec410
a97df471d1f39f315f40bcfe04038b273f4b53525ca47309255f6283a2a7bf88
2e283a471de21339431cbb457592b4fbb1f87926b0c941548b10a1c34c6c89c7
Filesystem             Size  Used Avail Use% Mounted on
/dev/mapper/rhel-home   20G  1.8G   19G   9% /home

fuse-overlayfsslirp4userns 同様、ユーザランドで動くため、プロセスとして見つけることができます。

[alice@rutledge ~]$ ps aux | grep fuse
alice      3649  0.0  0.2  14208  1744 ?        Ss   21:22   0:00 /usr/bin/fuse-overlayfs -o lowerdir=/home/alice/.local/share/containers/storage/overlay/l/O5T2Q7TBISUUPQ5KZKNBDASTR2:/home/alice/.local/share/containers/storage/overlay/l/FWIGHPYACX4IWGIVHICASOB4EB,upperdir=/home/alice/.local/share/containers/storage/overlay/ffcd34550b756ab315d35d81d08073ae86b80a56cca774433e6c317f4927ab34/diff,workdir=/home/alice/.local/share/containers/storage/overlay/ffcd34550b756ab315d35d81d08073ae86b80a56cca774433e6c317f4927ab34/work,context="system_u:object_r:container_file_t:s0:c310,c680" /home/alice/.local/share/containers/storage/overlay/ffcd34550b756ab315d35d81d08073ae86b80a56cca774433e6c317f4927ab34/merged

指定するオプションは、 overlayfs と同じく、lowerdir、workdir、upperdirと、マウント先を指定すればokです(SELinuxが有効であればcontextなども必要です)。 素の overlayfs については、前回の記事ではちょっとまとめきれず、まだブログで記事になっていませんが、前回の発表(youtube)の最後の方でちらっと触れていますので、是非ご参照ください。

一点だけ fuse-overlayfs の注意点としては、カーネルモジュールではなくプロセスなので、 pivot_root をするさい、同じmnt名前空間にいると、 fuse-overlayfs のルートまで変わってしまいます。
fuse-overlayfs のルートが変わると、提供されるファイルシステムも狂ってしまうため、 pivot_root の前にmnt名前空間を分ける必要があります。

その上、 fuse-overlayfs をマウントするには、 一番表のmnt名前空間では権限がたりないので、
1. User名前空間+mnt名前空間を作って、 fuse-overlayfs をマウントできるようにする、
2. fuse-overlayfs を起動、
3. fuse-overlayfs のルートが狂わないように、もう一回mnt名前空間を作成・分離、
4. pivot_root して、コンテナのルートの差し替え。
と、二回mnt名前空間を作る必要があります。

ここだけ注意すれば、こちらも fuse-overlayfs にオプションの指定して実行するだけです。

これで最後の「ファイルシステムイメージの管理がダサい」問題も無事解決です。

できた!手作りrootlessコンテナ

ということで、ここまでお話してきた内容を詰め込んだスクリプトがこちらです。

gist4e1ab24f14a8a2f5498b7849c1e85f00

コードの流れとしては main() -> Mounter() -> Runner() と進みます。 unshare(1) がプロセスを起動させる必要があるため、都度、別関数を呼ぶためにディスパッチしています。 一個のスクリプトに詰め込む必要はないのですが、管理が面倒で1スクリプトに詰め込んでいます。 (ディスパッチが ${@} とかいう雑実装なので、実用的にはあかんですね……)

main() では、User名前空間、mnt名前空間、net名前空間を分離しつつ、 User名前空間のuid_map/gid_mapの書き込みと、 slirp4netns の実行をしています。
いずれも親名前空間で行う必要があるため、バックグランド実行しつつ、パイプ越しに子プロセスのpidを子プロセス自身からもらうようにしています。
また、uid_map/gid_mapの書き換え前に処理が進むと困るので、uid_map/gid_mapの書き込み完了も同様にパイプを使って同期を取っています。 このあたりはシェルスクリプトでやるとなかなかめんどうですね。

Mounter() では、必要なディレクトリを用意して fuse-overlayfs を起動。 その後、残る名前空間(uts名前空間、ipc名前空間、pid名前空間)と、 fuse-overlayfs のルートが狂わないように、mnt名前空間を改めて作成してます。
また、コンテナ終了後に fuse-overlayfs が勝手に終了してくれるとうれしいので、 メインプロセスの起動を待って(sleep以外の方法にしたい……)、 -l (lazy) オプション付きで umount しています。

最後に Runner()pivot_rootchroot によるルートの張り替えをした上で、メインプロセスの起動をしています。 ルートの張り替えのまえに、コンテナのなかで最低限欲しそうなデバイス類をホスト環境からbind マウントしたり、 /proc/sys などの特殊なファイルシステム類をマウントしています。 resolv.conf はどうも各種コンテナの実装を見るとbindマウントしているようなので、倣ってbindマウントしてみています。

これらの他に、 cgroups関係の制御ですとか、コンテナの管理、execなどの操作やらをまじめに作り込んでいくと、 mincs のようなそこそこボリューミーな実装となるわけですが、 最低限名前空間で遊んでみる分には200行ちょいのコードでコンテナっぽいのができてしまいます。

ぜひぜひ、皆さんもコマンドを叩きつつ、名前空間と戯れてみましょう!

参考文献

*1:https://github.com/moby/moby/pull/38050/commits/ec87479b7e2bf6f1b5bcc657a377c6e6a847574f 2019-02-04 にマージされたPRでDocker(moby)にもrootless機能が入ったようです 🎉 が、まだRHEL7には来ておらず……。おそらくこのままdockerのrootlessモードが入る前にpodmanに乗り換えっぽい気がしています……。

*2:Technology Preview的には7.2からあるっぽい? https://www.redhat.com/en/blog/whats-next-containers-user-namespaces

*3:ちなみに、fs.overflow[ug]id というのもあるようです。こちらはファイルシステム上の32bitなuid/gidを16bitなアーキテクチャでマップする用途で使われるようで、kernel.overflow[ug]idとは別物……。と思うのですが、 turbolinuxの説明 を見ると kernel.overflow[ug]idはfs.overflow[ug]idのコピーと書かれているように見えます。ソースを斜め読みしたかぎり別物だと思うのだけどなぁ……。カーネルに強い諸兄諸姉の皆様でご存じの方がおりましたら……

*4:http://git.savannah.gnu.org/cgit/bash.git/tree/shell.c#n409

*5:uid=0だとこの辺のチェックがスキップされるため、User名前空間を先に作ってしまえば、実はこのあたりは解決できる問題だったりします

*6:Ubuntuのみ、overlayfsにパッチをあてて、User名前空間で動くようにしているようです

*7:バグが混入しないとは言っていない: 実際uidのオーバーフローまわりで一回脆弱性をやらかしているらしいです。