レトリバのCTO 武井です。
やあ (´・ω・`) うん、「また」コンテナの記事なんだ。済まない。
技術ブログの開設と新セミナー運用の開始にあたって、「前に話した内容をブログにしつつ、新しい差分をセミナーにすれば、一回の調べ物でどっちのネタもできて一石二鳥じゃないか」と思っていたのですが、
前のセミナーが情報詰め込みすぎでブログの文量がとんでもないことになって、
→ それが前提条件になってしまっているのでセミナー資料の文量も膨れ上がって、
→ 差分だけと思っていたUser名前空間も思った以上のボリュームで、
→ やっと一息かと思ったら、フォローアップ記事が残っていることを思い出すなど ←いまここ
一石二鳥作戦のはずが、どうしてこうなった……。
計画大事。
そんなわけで、今回は4/17にお話ししました「コンテナ仮想、その裏側 〜user namespaceとrootlessコンテナ〜」というセミナーのフォローアップ記事となります。 セミナーでは喋れなかったことなどを加筆しつつ、改めてコンテナ仮想の裏側で動いているUser名前空間について説明していこうと思います。
また、セミナーでは時間切れしてしまった、「手作りrootlessコンテナ」をシェルスクリプトで実装してみようと思います。
冒頭でもちらりと触れましたが、このセミナーは、前に話したセミナーを前提に話しております。 こちらはちょっと前にフォローアップ記事を挙げておりますので、先に↓の記事に目を通していただければと思います! (タイトルの付け方ミスったなぁ……)
- はじめに: rootlessコンテナとは?
- user名前空間の歴史と背景
- いざ実践: 準備
- いざ実践: とりあえずrootになってみる
- いざ実践: unshare -U
- いざ実践: uid_map/gid_map
- いざ実践: 井の中のrootはなにができるのか
- いざ実践: 井戸の中の王国建設
- いざ実践: が、ダメッ
- podmanを見てみる
- できた!手作りrootlessコンテナ
- 参考文献
※ 文中にて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権限すら持ち合わせていない一般ユーザ alice
で unshare -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っぽいシェルの中では sleep
は root
のプロセスですが、別のシェルからは 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.
Through the Looking Glass, by Lewis Carroll
‘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)が見えるなんて!それもこんな距離から!私にはこの明るさで実在する人間を見るのが精一杯だよ」
いざ実践: unshare -U
コマンドラインでUser名前空間を作るには、前回の記事 でも出てきた unshare(1)
を使います。
user名前空間はさきほどもちらっとでてきた -U
オプションを使います。
-r
は root
になるオプションなのですが、今回は付けずに実行してみます。 -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. ファイルシステムの用意
今回は、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-overlayfs
も slirp4userns
同様、ユーザランドで動くため、プロセスとして見つけることができます。
[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_root
と chroot
によるルートの張り替えをした上で、メインプロセスの起動をしています。
ルートの張り替えのまえに、コンテナのなかで最低限欲しそうなデバイス類をホスト環境からbind マウントしたり、 /proc
や /sys
などの特殊なファイルシステム類をマウントしています。
resolv.conf
はどうも各種コンテナの実装を見るとbindマウントしているようなので、倣ってbindマウントしてみています。
これらの他に、 cgroups関係の制御ですとか、コンテナの管理、execなどの操作やらをまじめに作り込んでいくと、 mincs のようなそこそこボリューミーな実装となるわけですが、 最低限名前空間で遊んでみる分には200行ちょいのコードでコンテナっぽいのができてしまいます。
ぜひぜひ、皆さんもコマンドを叩きつつ、名前空間と戯れてみましょう!
参考文献
- Rootlessコンテナ
- Namespaces in operation, part 1: namespaces overview [LWN.net]
- Namespaces in operation, part 5: User namespaces [LWN.net]
- Filesystem mounts in user namespaces [LWN.net]
- Anatomy of a user namespaces vulnerability [LWN.net]
- Man page of USER_NAMESPACES
- util-linux/unshare.c at master · karelzak/util-linux · GitHub
- shadow/newuidmap.c at master · shadow-maint/shadow · GitHub
- hnakamur’s blog: QEMU WikiのSlirpやTapについての解説がわかりやすい
- slirp4netns/main.c at master · rootless-containers/slirp4netns · GitHub
- What’s Next for Containers? User Namespaces
- Working with the Container Storage library and tools in Red Hat Enterprise Linux
- The State of Rootless Containers
- CVE-2019-5736に関して - レガシーガジェット研究所
*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名前空間を先に作ってしまえば、実はこのあたりは解決できる問題だったりします