レトリバのCTO 武井です。
今回は過去にセミナーでお話した内容を振り返りつつ、当時いれられなかったこぼれ話や補足などを加え、ブログで紹介しようと思います。 今後、レトリバセミナーで話した内容はこのような形でブログとしても公開する予定です。 また、過去のセミナーも随時ブログ化される予定ですのでお楽しみに!
YouTubeのレトリバチャンネルもぜひよろしくお願いします!
今回の過去セミナーはこちら
PFIセミナー2015/10/22:コマンドを叩いて遊ぶ〜コンテナ仮想、その裏側〜
「コマンドを叩いて遊ぶ 〜コンテナ仮想、その裏側〜」というタイトルで、Preferred Infrastructure時代に、PFIセミナーで話した内容です。 レトリバ創業前、3年以上前のセミナーでして、PFIセミナーの配信がYouTubeに移行したはじめてのセミナーだったりもします。
3年も経っているでの情報も古く……、と普通はなるところですが、そこは「裏側」。 コンテナ仮想の裏で起こっていることは、今もあまり大きな違いはなく、今でも通じる話も多いのです。 ぜひ、手を動かしてコマンドを叩きつつ、dockerをはじめとするコンテナ仮想の裏側で何が起きているのか、一緒に学びましょう!
そして、RHEL8で採用との噂のpodmanについては、4月のセミナーで話す予定です、セミナーもぜひよろしくお願いします!
はじめに
このあたり、Dockerが普及した2019年現在、そこら中で同じような図が出てますし、ほぼほぼ説明は不要でしょうか?
仮想マシンをエミュレートして、その上にカーネル・プロセスを動かすVM仮想化に対して、コンテナ型仮想化は同一のカーネル上に名前空間を作って分離する仮想化です。 VM仮想化にくらべ、仮想マシンのオーバーヘッドがない分、軽量かつ高速でありますが、一方で異なるOS/カーネルを動かせないという制約が掛かります。
このセミナー当時は、Windows・Mac向けDockerでLinuxを動かすには、VM(VirtualBox)でCoreOSを動かして、そのなかでコンテナを動かして繋ぐ、みたいな構成でしたが、その後Hyper-Vやxhyveなどのハイパーバイザ型になり、オーバーヘッドがだいぶ少なくなって使い勝手がよくなりましたね。
コンテナ仮想あれこれ
2015年当時、私がリストアップしたコンテナ仮想はこんな感じでした。
- LXC (Linux Containers)
- Docker以前から名を馳せていたコンテナ仮想
- cgroupsの産みの親
- Docker
- Go言語製
- LXCベースだったが途中からlibcontainerを使うように
- root権限の中央集権あたりが問題視されている
- CoreOS/Rocket (rkt) (※スライドではCentOSと書かれていますが、CoreOSですね……)
- 当時は正式名称がRocketでしたが、現在はrktという名前になっているようです
- Dockerの諸問題を解決するべく作られたコンテナ仮想
- systemd-nspawnを使っている
- MINCS
- Shell scriptで書かれている
- スライド作るのにだいぶお世話になった
また、スライドには書いていないですが、口頭では、Linux以外のコンテナ仮想として、 FreeBSDのJailや、SolarisのSolaris Containers(2005)にも触れています。
このあたりは、今と当時とでだいぶ状況が変わっていますね。 セミナーをした2015年当時は、Dockerがちょうど大ブームの真っ只中、標準化が始まりつつ、といった状態だったでしょうか?
記憶があやふやですので、2017年の終わりごろに書かれた記事(Dockerコンテナ時代の第一章の終わり、そして第二章の展望など)を読んでみますと、 2016年にKubernetesが独自のコンテナランタイムcri-oの開発を表明すると、Dockerもコアランタイムのcontainerdを分離、翌2017年にコンテナ標準のOCIが発表、という感じのようです。 ということで、2015年当時はまだDockerがエンジンとの分離もされていない頃、ただ世間ではDockerがどんどん使われはじめつつも、rktをはじめ規格化の話が裏では進みつつ、という感じの様ですね。
また、2019年頭にpodmanがリリースされrootlessコンテナが話題になっていますが、2015年当時から「root権限の中央集権」が問題視されていたことが覗えますますね。 rootlessコンテナに関しては4月のセミナーで触れようと思いますので、この場は割愛します。
Linux namespaceの話
で、コンテナ仮想って何をしているのか?というのがこのスライドのメインテーマ。 まずは、 Linux namespaceの話。
同一のカーネル上に複数の環境を用意するために、OS上の一意な名前/id(例えばpidとか)を分離し、環境ごと、同じ名前/idでも別のものを指すことができるようにする仕組みです。 プログラミング言語でもnamespaceという概念がありますが、似た様な感じですね。
このスライドではuser名前空間に触れつつも、RHEL7ではまだエンタープライズ用途で使うには検証がたりないため有効にしていない(意訳) ということで、スキップしています。 これがrootlessコンテナの一つの要になっているのですが、これに関しては4月のセミナーで……。
さて、Linux namespaceを分離するとどんなことが起こるのでしょうか? ぜひ手元に環境を作って、コマンドを打ちながら、一緒に試してみましょう!
システムを結構いじるので、できればVMなど使い捨てできる環境がよいかもです。 以下のコマンドの実行結果は、CentOS 7.6.1810で実行しています。
プロセスの名前空間
まずは、プロセスの名前空間についてです。
各プロセスはなにかしらの名前空間に属しています。
procファイルシステム上の /proc/${PID}/ns/
ディレクトリを見ると、そのプロセスの名前空間を確認することが出来ます。
ファイルシステム上はシンボリックリンクに見えており、リンク先が名前空間を示しています *1 。
まず、カレントシェル(bashとかzshとか)の名前空間を確認してみます。
$ ls -l /proc/$$/ns # $$ はカレントシェルのPID total 0 lrwxrwxrwx. 1 alice alice 0 Apr 1 20:01 ipc -> ipc:[4026531839] lrwxrwxrwx. 1 alice alice 0 Apr 1 20:01 mnt -> mnt:[4026531840] lrwxrwxrwx. 1 alice alice 0 Apr 1 20:01 net -> net:[4026531956] lrwxrwxrwx. 1 alice alice 0 Apr 1 20:01 pid -> pid:[4026531836] lrwxrwxrwx. 1 alice alice 0 Apr 1 20:01 user -> user:[4026531837] lrwxrwxrwx. 1 alice alice 0 Apr 1 20:01 uts -> uts:[4026531838]
これら名前空間は、基本的に親プロセスのものを引き継ぎます。
ためしに sleep(1)
をバックグラウンドで起動してみて、その名前空間を確認してみます。
$ sleep 60 & [1] 10168 $ ls -l /proc/$!/ns # $! は直前のバックグラウンドプロセスのPID total 0 lrwxrwxrwx. 1 alice alice 0 Apr 1 20:02 ipc -> ipc:[4026531839] lrwxrwxrwx. 1 alice alice 0 Apr 1 20:02 mnt -> mnt:[4026531840] lrwxrwxrwx. 1 alice alice 0 Apr 1 20:02 net -> net:[4026531956] lrwxrwxrwx. 1 alice alice 0 Apr 1 20:02 pid -> pid:[4026531836] lrwxrwxrwx. 1 alice alice 0 Apr 1 20:02 user -> user:[4026531837] lrwxrwxrwx. 1 alice alice 0 Apr 1 20:02 uts -> uts:[4026531838]
シェルのものと同じ結果ですね!
名前空間を変更する場合、システムコールでは clone(2)
や unshare(2)
setns(2)
を使います。
また、コマンドラインでも unshare(1)
というコマンドがありますので、そちらで遊ぶことができます。
$ unshare --help Usage: unshare [options] <program> [<argument>...] Run a program with some namespaces unshared from the parent. Options: -m, --mount unshare mounts namespace -u, --uts unshare UTS namespace (hostname etc) -i, --ipc unshare System V IPC namespace -n, --net unshare network namespace -p, --pid unshare pid namespace -U, --user unshare user namespace :
それぞれ名前空間を作って挙動をみてみましょう!
mnt namespace
まずは分かりやすい mnt 名前空間。
通常マウントポイントはシステムで一つ、全部のプロセスが同じファイルパスで同じファイルを見られるはずです。 mnt名前空間を変えると、プロセスごと異なるデバイスをマウントし、別のファイルシステムを見せることができます。
分かりやすい応用例の一つがSystemdのPrivateTmp機能です。(※スライドだとPrivateTempとなっていますが、PrivateTmpが正しいです)
プロセスごとに、異なる /tmp
をマウントすることで、他のプロセスに作業用の一時ファイルを見せない、ということが可能になります。
他のプロセスが /tmp
を監視して一時ファイルから重要情報を盗み見する、みたいな攻撃を防げます。
実際にやってみます。
$ readlink /proc/$$/ns/mnt # 今のmnt名前空間を確認 mnt:[4026531840] $ sudo unshare --mount /bin/bash # 新しいmnt名前空間でbashを起動 # readlink /proc/$$/ns/mnt # 新しいmnt空間になっていることを確認 mnt:[4026532216] # mkdir mnt # マウントポイントを作って # mount -t tmpfs tmpfs mnt # tmpfsをマウント # findmnt mnt/ # マウントされているかチェック! TARGET SOURCE FSTYPE OPTIONS /home/alice/mnt tmpfs tmpfs rw,relatime,seclabel
さて、これで新しいmnt名前空間の中でマウントポイントを変更したわけですが、元のmnt名前空間ではどうなっているでしょう?
ということで、別のシェルを開いて確かめてみます。
$ readlink /proc/$$/ns/mnt # シェルを開くと元の名前空間で開くことを確認 mnt:[4026531840] $ findmnt mnt/ # 元のmnt名前空間ではマウントされていない! $
ということで、プロセスごと、別のマウントポイントが見えていることが分かります。
マウント先を /tmp
に変えると、SystemdのPrivateTmp相当のことが実現できそうですね。
コンテナ仮想では、後述するchrootやpivot_rootと合わせ、コンテナの中と外とで異なるファイルシステムを見るようにする役割を担っています。
作った名前空間は、その名前空間に属するプロセスが無くなると消滅します。
先ほど unshare(1)
で起動したシェルを exit(1)
で抜けておきましょう。
# exit $
余談1: Mount Propagationについて
さて、セミナーではこのmnt名前空間に関連して、しょうもない小芝居と共にMount Propagationについても触れています。
実は、セミナー当時、先の手順をそのままやると、元のmnt名前空間にも tmpfs
がマウントされてしまいました。
これは、mnt名前空間をまたいでマウント命令(mountやunmount)を伝播させる機能、Mount Propagationとうい機能によるものです。
なんで、名前空間分けたのに、マウント命令を伝播するの? と思われるかも知れませんが、この機能がないと、例えばCDやUSBメモリなど自動でマウントされるディレクトリが、PrivateTmpを使いたいがためにmnt名前空間を分けたデーモンから見えなくなる、みたいなことが起きてしまいます *2 。
Mount Propagationはマウントポイントごと指定可能で、 Shared
(マウント命令を伝播させる)や、Slave
(bindマウントの「マウント元」へのマウントを「マウント先」に伝播させ、「先」から「元」へは伝播させない)、Private
(伝播させない)などいくつかモードがあります。
カーネルのデフォルト値は Private
なのですが、RHEL/CentOS7では、というかSystemdが、 /
のMount Propagationを Shared
にするようになっています。
そのため、新しいmnt名前空間の中で実行したマウント命令が、元のmnt名前空間にも伝播し tmpfs
がマウントされてしまう、ということが起きていました。
一方で、新しめの unshare(1)
では、mnt名前空間を分離すると自動でMount Propagationが Private
にセットされるようになりました *3 。
そのため、 Shared
がデフォルトになっているRHEL/CentOS7でも、セミナー当時のような現象が起きなくなっています。
また、mnt名前空間を分けた際のMount Propagationを設定できる --propagationオプション
も追加されてます。
$ unshare --help Usage: unshare [options] <program> [<argument>...] : --propagation <slave|shared|private|unchanged> modify mount propagation in mount namespace :
セミナー当時の状態を再現するには --propagation=shared
を使うとできそうです。
やってみましょう。
$ readlink /proc/$$/ns/mnt mnt:[4026531840] $ sudo unshare --mount --propagation=shared /bin/bash # readlink /proc/$$/ns/mnt # mnt名前空間は新しくなっている mnt:[4026532216] # mount -t tmpfs tmpfs mnt/ # mntにtmpfsをマウント # findmnt mnt/ # マウントされてることを確認 TARGET SOURCE FSTYPE OPTIONS /home/alice/mnt tmpfs tmpfs rw,relatime,seclabel # exit # 名前空間を抜ける $ findmnt mnt/ # 元のmnt名前空間でもマウントされている TARGET SOURCE FSTYPE OPTIONS /home/alice/mnt tmpfs tmpfs rw,relatime,seclabel
無事(?)、セミナー当時の「mnt名前空間を分けたのにマウントされている!?」を再現できました。
再現を確認できたら、元のmnt名前空間に伝播してしまったマウントポイントをアンマウントしておきましょう。
$ sudo umount mnt/
ちなみに、今のMount Propagationは findmount(1)
に -o
オプションに PROPAGATION
を足すと確認できます。
$ findmnt -o TARGET,PROPAGATION TARGET PROPAGATION / shared |-/sys shared | |-/sys/kernel/security shared | |-/sys/fs/cgroup shared | | |-/sys/fs/cgroup/systemd shared :
ちょっと蛇足、Mount Propagationの話でした。
pid namespace
続いてpid名前空間です。 こちらもわかりやすいですね。プロセスのid、pidを環境ごと分離する機能です。 ファイルシステムに次いで、コンテナで分離・隔離したい筆頭ですね。
unshare(1)
でpid名前空間を分離すると、そのプロセスから最初に作られたプロセスが、そのpid名前空間での pid=1
なプロセスになります。
dockerの RUN
で起動したプロセスが pid=1
になっているのをよく見かけると思いますが、まさにこのpid名前空間によるものです。
pid名前空間は、他の名前空間と違い、指定したプロセスが直接新しい名前空間に移動/発生するのではなく、プロセスのpid名前空間はそのままに、そのプロセスから作られる子プロセスが新しい名前空間に発生するようになります。
unshare(1)
では、指定したプロセスが新しいpid名前空間で pid=1
なプロセスになるよう、 unshare(2)
で新しいpid名前空間を作ったあと、一回 fork(2)
してから execvp(2)
する、 --fork
オプションが用意されています。
プロセスの起動タイミングが制御しづらいシェルスクリプトでは、ほぼほぼ必須なオプションになります *4 。
また、pid名前空間は非対称な性質を持っており、親のpid名前空間では子のpid名前空間のプロセスをみることができます。 このあたりもLinuxのdockerを使ってるとよくみる性質ですね。
前置きが長くなりましたが、実際にやってみましょう。
$ readlink /proc/$$/ns/pid pid:[4026531836] $ sudo unshare --pid --fork /bin/bash # --forkが必要 # readlink /proc/$$/ns/pid # あれ?pid名前空間が元と同じ??? pid:[4026531836] # ps -el # 親のpid名前空間のプロセスが見えてしまっている??? F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD 4 S 0 1 0 0 80 0 - 31362 ep_pol ? 00:00:04 systemd 1 S 0 2 0 0 80 0 - 0 kthrea ? 00:00:00 kthreadd 1 S 0 3 2 0 80 0 - 0 smpboo ? 00:00:01 ksoftirqd/0 :
おっと、pid名前空間を分けたはずなのに元のpid名前空間の情報が見えてしまっています。
これはpid名前空間の問題ではなく、 ps(1)
が /proc
ファイルシステムを見ていること、そして、 /proc
ファイルシステムが元の名前空間と同じことに起因します。
/proc
関係はいったん置いておいて、本当にpid名前空間が分かれているか確認するため、元のpid名前空間で sleep(1)
を起動して、 新しいpid名前空間で kill(1)
してみましょう。
# exit # pid名前空間を抜ける $ sleep 60 & # 親のpid名前空間でsleepをバックグランド実行 $ echo $! # sleepのpidをチェック 16963 $ sudo unshare --pid --fork /bin/bash # また新しいpid名前空間を作る # ps -p 16963 # psコマンドでは元のpidで見えている PID TTY TIME CMD 16963 pts/0 00:00:00 sleep # kill 16963 # killしょうとすると No such process bash: kill: (16963) - No such process
ps(1)
で見えてしまってはいますが、 kill(1)
でプロセスが見つからないことから、ちゃんと隔離はできているようです。
では、 ps(1)
の謎をちょっと追ってみましょう。
さきほどからちらほらと出てきている /proc
は、procファイルシステムというカーネルが提供する特殊なファイルシステムになっており、プロセスの情報をはじめ、プロセスの様々な情報が置かれています。
pid名前空間はあくまでpidのみを対象とした名前空間であるため、 /proc
ファイルシステム上の情報は分離されず、/proc
以下には元のpid名前空間の情報が置かれたままになります。
ps(1)
は、この /proc
を参照して情報を出力するため、親のpid名前空間の情報を出力してしまいます。
これを解決するには新しいpid名前空間で /proc
をマウントしなおせばよいのですが、単にマウントし直してしまうと、親の名前空間のプロセスにまで影響を与えてしまいます。
そこで、先ほどのmnt名前空間の出番です。 mnt名前空間は、通常のファイルを保存するファイルシステムだけでなく、procファイルシステムのような特殊なファイルシステムの分離にも一役買っているのです。
unshare(1)
コマンドには、 mnt名前空間を分離しつつ /proc
を再マウントしてくれる --mount-proc
オプションが用意されています。
$ unshare --help Usage: unshare [options] <program> [<argument>...] : --mount-proc[=<dir>] mount proc filesystem first (implies --mount) :
--mount-proc
オプションを付けて、もう一回pid名前空間を作ってみましょう!
$ readlink /proc/$$/ns/pid pid:[4026531836] $ sudo unshare --pid --fork --mount-proc /bin/bash # readlink /proc/$$/ns/pid # ちゃんと新しいpid名前空間の情報が見える! pid:[4026532217] # ps -el # ps(1)も、bashとps自身しか見えない! F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD 4 S 0 1 0 0 80 0 - 28860 do_wai pts/0 00:00:00 bash 0 R 0 13 1 0 80 0 - 38309 - pts/0 00:00:00 ps
無事、 /proc/$$/ns/pid
が新しいpid名前空間に、また、 ps(1)
の結果も起動したbash(pid=1)とps自身だけになっていますね。
また、親pid名前空間からの見え方を確認するため、新しいpid名前空間で sleep(1)
を起動して別のシェルからの見え方を比べてみます。
# readlink /proc/$$/ns/pid # 子pid名前空間 pid:[4026532217] # sleep 60 & # 新しい名前空間でsleepをバックグラウンド起動 # ps -el --forest # プロセスツリーを確認 F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD 4 S 0 1 0 0 80 0 - 28860 do_wai pts/0 00:00:00 bash 4 S 0 15 1 0 80 0 - 26988 hrtime pts/0 00:00:00 sleep 0 R 0 16 1 0 80 0 - 38301 - pts/0 00:00:00 ps
$ readlink /proc/$$/ns/pid # 親pid名前空間 pid:[4026531836] $ ps -el --forest # プロセスツリーを確認 F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD : 0 S 1865 18295 18294 0 80 0 - 28896 do_wai pts/0 00:00:00 \_ bash 4 S 0 18315 18295 0 80 0 - 59796 poll_s pts/0 00:00:00 \_ sudo 4 S 0 18319 18315 0 80 0 - 26985 do_wai pts/0 00:00:00 \_ unshare 4 S 0 18320 18319 0 80 0 - 28860 n_tty_ pts/0 00:00:00 \_ bash 4 S 0 18341 18320 0 80 0 - 26988 hrtime pts/0 00:00:00 \_ sleep :
新しいpid名前空間ではそのpid名前空間のプロセスしか見えず、親のpid名前空間からは子のpid名前空間のプロセスが見える、という非対称性な性質も確認できました *5 。
また、pidを見てみると、新しいpid名前空間では、bashがpid=1、sleepがpid=16、となっていますが、親のpid名前空間では、bashがpid=18320、sleepがpid=18341、となっています。 単純にいくつか数字が足し引きされてマッピングされているのではなく、それぞれpid名前空間内で連番が振られていくため、pidのマッピングは若干ずれがでています。
ということで、pid名前空間でした。
uts namespace
続いてはuts名前空間。 これは、ホスト名・ドメイン名を分離します。
ホスト名やドメイン名は、カーネルが今現在認識していいるもののほか、 /etc/hosts
などにも書かれていますので、こちらもmnt名前空間を使ってファイルシステムを差し替えるなどして、ファイルそのものを分ける必要があります。
ファイルシステムの差し替えの話は後述するとしまして、 hostname(1)
コマンドを使ってホスト名を確認してみましょう。
$ readlink /proc/$$/ns/uts uts:[4026531838] $ hostname # 元のuts名前空間。味気ないホスト名 centos7-box $ sudo unshare --uts /bin/bash # uts名前空間を分離して # readlink /proc/$$/ns/uts uts:[4026532173] # hostname "wonderland" # 素敵な名前に変更 # hostname # 変更されたのを確認 wonderland # exit $ hostname # 味気ない世界に戻ってしまった centos7-box
シンプルですね。 ホスト名は変えられましたが、ネットワークまわりはnet名前空間を使います。
net namespace
さて、net名前空間です。
unshare(1)
でもnet名前空間を作れるのですが、 ip(1)
でも作ることができます。
ip(1)
の方は、net名前を名前を付けて管理してくれるため、ip(1)
の方がおすすめです。
というのも、net名前空間を分離すると、lo
(ループバックインタフェース)しか見られないため、外部はもとよりホスト(親pid名前空間)とも通信ができません。
$ sudo unshare --net /bin/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
そこで、名前空間同士を繋ぐ仮想インターフェースを作ったりするのですが、コマンドで操作する場合、名前付きの方が楽ですので、net名前空間の作成から ip(1)
を使っていきます。
まずはnet名前空間の作成。
今までprocファイルシステム上のシンボリックリンク先で見分けていましたが、 ip netns
では名前付きで名前空間を管理できます
$ sudo ip netns add testns # testnsという名前でnet名前空間を作成 $ sudo ip netns list # net名前空間を一覧 testns $ ls -l /var/run/netns/ # /var/run/netns/ にもファイルが発生 total 0 -r--r--r-- 1 root root 0 2019-03-11 18:29 testns
この時点ではまだこのnet名前空間に属するプロセスはいません。
net名前空間に属するプロセスを起動するには ip netns exec
を使います。
$ readlink /proc/$$/ns/net net:[4026531956] $ sudo ip netns exec testns /bin/bash # netns名 コマンドを指定して実行 # readlink /proc/$$/ns/net # 新しいnet名前空間になっている net:[4026532522] # ls -li /var/run/netns/ # 実はこのリンク先の数字は、/var/run/netns/以下のファイルのiノード番号と同じ total 0 4026532522 -r--r--r-- 1 root root 0 Mar 11 18:40 testns # mount | grep testns # そしてこのファイルはproc fsからマウントされているのでmnt名前空間で分離できる proc on /run/netns/testns type proc (rw,nosuid,nodev,noexec,relatime) proc on /run/netns/testns type proc (rw,nosuid,nodev,noexec,relatime) testns on /sys type sysfs (rw,relatime) # ip a # 新しい名前空間にはlo(ループバックインタフェース)しかない 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
unshare(1)
と同く、net名前空間を分けることができました。
この環境から表のネットワークに出るにはどうすればいいでしょうか?
名前空間の話からちょっと脱線して、ネットワークをいじってみます。
余談2: vethを使ってコンテナをネットワークに繋ぐ
コンテナから表のネットワークに出る方法はいくつかありますが、今回はvethというものを使い、net名前空間空間と元の名前空間を繋ぎ、IPマスカレードで表に出られるようにしてみます。
ステップは3ステップ。 1) vethペアを作って、 2) IPを振ってリンクアップして、 3) ホスト側でIPマスカレードの設定をします。
まずはveth(仮想ネットワークインターフェース)ペアを作って、片方を元のnet名前空間に、もう片方を作ったnet名前空間に繋ぎます。
$ sudo ip link add name master type veth peer name slave # vethペア(master/slave)を作成 $ ip a # slave@master / master@slave が発生 (TODO: @の後ろは接続先っぽい。昔はこんなのなかった……。) 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 : 4: slave@master: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN group default qlen 1000 link/ether 1a:d7:b7:fd:45:22 brd ff:ff:ff:ff:ff:ff 5: master@slave: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN group default qlen 1000 link/ether fe:1b:b7:6c:50:b7 brd ff:ff:ff:ff:ff:ff $ sudo ip link set dev slave netns testns # デバイス `slave` を netns `testns` に移動 $ ip a # slaveがこのnet名前空間から消える 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 : 5: master@if4: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000 link/ether fe:1b:b7:6c:50:b7 brd ff:ff:ff:ff:ff:ff link-netnsid 0 $ sudo ip netns exec testns /bin/bash # tesnsの中に移動すると # ip a # slave移動してきている 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 4: slave@if5: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000 link/ether 1a:d7:b7:fd:45:22 brd ff:ff:ff:ff:ff:ff link-netnsid 0
これで、元のnet名前空間と新しいnet名前空間が繋がれました。 ただ、vethは作ったばかりではIPも振られずリンクダウンしている状態ですので、ipの割り当てとリンクアップが必要になります。
新しいnet名前空間側と、元net名前空間側でそれぞれ作業します。
# readlink /proc/$$/ns/net # testns側 net:[4026532522] # ip addr add 192.168.50.102/24 dev slave # slave側には192.168.50.102を振ってみる # ip link set dev slave up # リンクアップ # ip addr show dev slave # slaveにIPが振られ、UP状態に 4: slave@if5: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state LOWERLAYERDOWN group default qlen 1000 link/ether 1a:d7:b7:fd:45:22 brd ff:ff:ff:ff:ff:ff link-netnsid 0 inet 192.168.50.102/24 scope global slave valid_lft forever preferred_lft forever
$ readlink /proc/$$/ns/net # 元net名前空間側 net:[4026531956] $ sudo ip addr add 192.168.50.101/24 dev master # master側には192.16.50.101を振ってみる $ sudo ip link set dev master up # リンクアップ $ ip addr show dev master # master側もIPが振られ、UP状態に 5: master@if4: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000 link/ether fe:1b:b7:6c:50:b7 brd ff:ff:ff:ff:ff:ff link-netnsid 0 inet 192.168.50.101/24 scope global master valid_lft forever preferred_lft forever inet6 fe80::fc1b:b7ff:fe6c:50b7/64 scope link valid_lft forever preferred_lft forever $ ping -c1 192.168.50.102 # slave側のIPに対して導通確認 PING 192.168.50.102 (192.168.50.102) 56(84) bytes of data. 64 bytes from 192.168.50.102: icmp_seq=1 ttl=64 time=0.014 ms --- 192.168.50.102 ping statistics --- 1 packets transmitted, 1 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 0.014/0.014/0.014/0.000 ms
これでveth対の両端にIPが振られ、導通が確認できましたが、この時点ではまだmasterとslaveが繋がっているだけで、slave側から外部のネットワークに出られません。
そこで、最後の手順として、外部へのネットワークを持っている、親net名前空間側でIPマスカレードの設定をして、slave側でデフォルトゲートウェイの設定をします。
セミナーの時には iptables(1)
をいじっていましたが、CentOS7ではfirewaldがデフォルトなので、今風に(?) firewall-cmd(1)
で設定してみます。
$ sudo systemctl start firewalld # firewalld が起動しているか確認 $ sudo firewall-cmd --add-masquerade # 定常的な設定にするなら--permanentを付与。とりあえず一次的に設定 $ sudo firewall-cmd --query-masquerade # 有効になってるか確認 yes
# ip route add default via 192.168.50.101 dev slave # デフォルトゲートウェイの設定 # ip route default via 192.168.50.101 dev slave 192.168.50.0/24 dev slave proto kernel scope link src 192.168.50.102 # ping -c1 8.8.8.8 # 外部ネットワークへの導通確認 PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data. 64 bytes from 8.8.8.8: icmp_seq=1 ttl=61 time=3.08 ms --- 8.8.8.8 ping statistics --- 1 packets transmitted, 1 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 3.082/3.082/3.082/0.000 ms
ということで、無事、新しいnet名前空間から外部ネットワークに繋げるようになりました。
dockerでは、単純なマスカレードだけでなくコンテナ用のネットワークをつくったりしていますが、 ip (1)
コマンドや brctl (1)
などを駆使するとコマンドラインでも同じようなことができます。
最近ですと、veth以外に、slirp4netnsやtapなどを使った、rootlessなネットワーク配管が出てきているようです。 こちらもrootlessコンテナと合わせてセミナーで話せればと思っております!
そんなわけでnet名前空間と、ネットワークの繋ぎ方でした
user namespace
最後の名前空間としてuser名前空間です。 こちらは、RHEL7で無効化されていたため、このセミナーではスキップしましたが、次回の4月セミナーでお話する予定です!
chroot / pivot_root
(スライドには「MINCSはpivot_rootを二回」と、書いていますがmount propagationの変更とlazy unmountをすることで一回減ったようです)
さて、mnt名前空間を使うことで、マウントポイントを環境ごと変えられることが分かりましたが、 /
を変えたい時はどうすればいいでしょうか?
マウントポイントが分離されたところで、プロセスがファイルを開き続けている限り /
はアンマウントできませんし、アンマウントしないことにはコンテナからホスト環境を隠すことが出来ません。
そこで出てくるのが chroot
と pivot_root
です。
chroot
chrootは古くからあるルートファイルシステムの張り替え機構で、ルートをサブディレクトリに変えた状態でプロセスを起動できます。 元々はクリーンな環境でビルドシステムを動かす目的で作られた仕組みですが、今ではシステムファイルを隠すことができるため、セキュリティ目的で使われることが多いイメージがあります。
サブディレクトリがルートな状態で新たなプロセスが起動するため、そのプロセスが利用するライブラリやシステムファイル類がサブディレクトリ以下に一通り揃っている必要があります。
Gentooであればよくstage3のお世話になりますが、RHEL/CentOSでは yum install --releasever=7 --installroot=.... @base @core vim
と --installroot
オプションを指定して @base
などをインストールすると揃えることができます *6 。
やってみます!
$ mkdir -p new_root # 新しいルート $ sudo yum install -y --releasever=7 --installroot="${PWD}/new_root" @base @core vim-enhanced # 必要なシステムファイルの用意 $ sudo chroot new_root/ /bin/bash # chroot!! # ls -l total 12 lrwxrwxrwx 1 root root 7 Mar 14 09:33 bin -> usr/bin dr-xr-xr-x 3 root root 281 Mar 14 09:35 boot : # ls -l home/ # homeがからっぽ。元の環境ではない total 0 #
mnt名前空間を分けたうえで、システムシステムの入ったイメージをマウント、マウントしたディレクトリに exec chroot
、 とすればコンテナっぽいことが出来そうですね。
ただ、chrootは仕様上、chrootの外に移動できてしまう、所謂 jailbreak ができてしまうため、この隔離ではまだ完璧ではありません。
chrootだけで環境を分けるためには、chrootする権限(cap_sys_chroot
)を奪ったrootを使うか、rootじゃないユーザに降格させる必要があります *7 。
pivot_root
そこでもう一つ、chrootと同じくプロセスのルートを差し替える仕組み、pivot_rootも見てみます。 pivot_rootは、プロセスを一部のサブディレクトリに「閉じ込める」chrootと違い、プロセスのルートのマウントポイントを「入れ替える」ことで、プロセスのルートを変えます。 所謂CDブートLinuxが、CD上のreadonlyな仮のルートからセットアップの終わったルートに切り替える際などにも使われる機構ですね。
こちらもやってみましょう。 chrootと違い、入れ替える先のルートが別のマウントポイントのである必要があるため、システムファイルが格納されたファイルシステムイメージを作る必要があります。
$ truncate -s 10G fs.img # 10Gの容量のファイルを作成。実際に10G確保すると大変なのでスパースファルで用意 $ mkfs.ext4 fs.img # ext4でフォーマット。ブロックデバイスじゃないけどok?と聞かれるので y で続行 $ mkdir -p pivot # マウントポイントを作成 $ sudo mount -o loop fs.img pivot # イメージファイルをループバックマウント $ sudo yum install -y --releasever=7 --installroot="${PWD}/pivot" @base @core vim-enhanced # システムファイルをインストール
さて、これで fs.img
にシステムファイル一式が入りました。
pivot_root (1)
はマウントポイントをいじるので mnt名前空間を切ってから実行します。
マウントポイントを確認しつつ、 pivot_root
してみます
$ sudo unshare -m /bin/bash # まずはmnt名前空間の切り離し # findmnt -o TARGET,SOURCE / # 今のルートにはLVMの論理ボリュームがマウントされている TARGET SOURCE / /dev/mapper/centos_centos7--box-root # findmnt -o TARGET,SOURCE pivot # pivotにはloopbackデバイス経由で fs.imgがマウントされている TARGET SOURCE /home/alice/pivot /dev/loop0 # cd pivot # mkdir -p .old # pivotマウントポイントの内側に、古いルートの移動先を作る # pivot_root . .old # 新しいルートを `.`(`pivot/`) に、元のルートはその直下の `.old` になるようpivot! # cd / # ls -l /proc # procが空 # mount -t proc proc /proc # /procが空だとfindmntが使えないので、今のmnt名前空間の情報を読めるようprocをマウント # findmnt -o TARGET,SOURCE / # ルートがloopbackデバイス、つまり fs.img に TARGET SOURCE / /dev/loop0 # findmnt -o TARGET,SOURCE .old # もとのルート(LVMの論理ボリューム)は .old に TARGET SOURCE /.old /dev/mapper/centos_centos7--box-root
無事、ルートをイメージに差し替えることができました!
ところで、この .old
こと、昔のルートは何故残っているのでしょう?
pivot_root
は chroot
と違い、新しいルートの中のバイナリを使ってプロセスを起動するのではなく、今現在のプロセスのルートを差し替えをしています。
そして、今のプロセスは、元のルートファイルシステムで起動したプロセスですので、実は昔のルートファイルシステム、 .old
への依存が残ってしまっているのです。
今動いているシェルの情報を確かめてみます
# ls -l /proc/$$/exe # シェルのexe(実行しているバイナリ)を確認。 .old以下のバイナリを指している lrwxrwxrwx 1 root root 0 3月 15 02:11 /proc/16755/exe -> /.old/usr/bin/bash # lsof -p $$ # 同様にライブラリ類も /.old/ 以下、元のルートのファイルを参照している COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME bash 16755 root cwd DIR 7,0 4096 2 / bash 16755 root rtd DIR 7,0 4096 2 / bash 16755 root txt REG 253,0 964544 100664425 /.old/usr/bin/bash bash 16755 root mem REG 253,0 106075056 100664815 /.old/usr/lib/locale/locale-archive bash 16755 root mem REG 253,0 61624 97025 /.old/usr/lib64/libnss_files-2.17.so bash 16755 root mem REG 253,0 2151672 64049 /.old/usr/lib64/libc-2.17.so bash 16755 root mem REG 253,0 19288 64055 /.old/usr/lib64/libdl-2.17.so bash 16755 root mem REG 253,0 174576 103459 /.old/usr/lib64/libtinfo.so.5.9 bash 16755 root mem REG 253,0 163400 63727 /.old/usr/lib64/ld-2.17.so bash 16755 root mem REG 253,0 26254 40650493 /.old/usr/lib64/gconv/gconv-modules.cache bash 16755 root 0u CHR 136,1 0t0 4 /dev/pts/1 bash 16755 root 1u CHR 136,1 0t0 4 /dev/pts/1 bash 16755 root 2u CHR 136,1 0t0 4 /dev/pts/1 bash 16755 root 255u CHR 136,1 0t0 4 /dev/pts/1
シェルのバイナリやライブラリは、元のルートの移動先 /.old
以下、つまり元々のルートファイルシステムのものを指していることが分かります。
そのため、このmnt名前空間上で元々のルートファイルシステムを切り離す(アンマウント)するためには、新しいルートのファイル群で動くプロセスを起動するために chroot
が必要となります。
システムコールを直接呼べるのであれば(つまり自分で書いたプログラムの中とかであれば)、新しいルートは最悪空っぽであっても chroot(2)
システムコールを呼べますが、コマンドで試す際には、新しいルートファイルシステムで chroot(1)
を動かせる必要があります *8 。
スライドには無い話ですが、古いルートの切り離しまでやってみましょう
# umount -R .old/ # シェルが古いルートに依存しているためそのままではアンマウントできない umount: /.old: target is busy. (In some cases useful info about processes that use the device is found by lsof(8) or fuser(1)) # exec chroot / # ルートのchrootする。execすることで古いルートに依存しているシェルを、新しいルートで起動したプロセスに置き換える # umount -R .old/ # 無事アンマウント
この chroot
は、既にルートファイルシステムが別ファイルシステムに入れ替わっているため、jailbreakで元のファイルシステムにアクセスされる心配はありません。
実際にはこれらに加え、例えば、生のデバイス(上の例では /dev/mapper/centos_centos7--box-root
)にアクセスできてしまうと、結局元のルートファイルシステムをいじり放題になってしまうため、 /dev
を提供する devtmpfs
ファイルシステムへの権限を遮断するためrootユーザの権限をいじる必要があります。
また、 /dev
や /sys
など、プロセスを動かすために必要なシステムファイル類の準備なども必要になりますが、脱線しすぎてしまうためこのあたりまで。
といいますか、ブログの分量自体もかなり大きくなってしまいましたので、いったんこのあたりで切り上げましょう
まとめ
今回のエントリは、3年前、PFIセミナーで話しました「コマンドを叩いて遊ぶ 〜コンテナ仮想、その裏側〜」というセミナーの書き起こしの記事(前半)となります。 後半はDockerのStorage Driverにからめて、dm_thinなどの話をしたのですが、そのあたりはまた今度。
それはさておき、ITの世界で3年といえば大昔。RHELのメジャーバージョンも変わろうかいうぐらいの年月です。
が、今回この記事を書くにあたり、実際にコマンドを叩いて、そして補足のために調べ直して、と追い直してみましたが、根っこのところはそんなに大きくは変わってないのだなぁ、という印象を受けました。 もちろん、たとえばコマンドのデフォルト挙動が変わってたり、それこそuser名前空間が使われはじめたりと、大なり小なり変わったところもありますが、おおよそ当時のセミナーがある程度そのまま使えそうという感じです。
使い方などの表層は結構簡単に廃れてしまいますが、その「裏側」、根っこの部分は意外に朽ちないものでして、皆様も是非、いろんなものの「裏側」、潜ってみてはいかがでしょうか? コンテナ界隈もまだまだ潜る場所はありそうです(それこそkernelのソースとか!)。 4月のセミナーではrootlessコンテナあたりを紹介しようと思っております。ぜひセミナーもよろしくお願いいたします!
youtubeのレトリバ チャンネル https://www.youtube.com/c/Retrieva
チャンネル登録・このブログの購読、ぜひお願いします!
参考リンク
セミナー資料の載せていたリンクのほか、今回ブログで加筆あたって参考にさせていただいたリンクを加えております。
- セミナー資料
- ブログ資料
*1:Kernel3.7以前ではシンボリックリンクではなく、ハードリンクとして見えていたようです
*2:https://www.kernel.org/doc/Documentation/filesystems/sharedsubtree.txt
*3:https://github.com/karelzak/util-linux/commit/f0f22e9c6f109f8c1234caa3173368ef43b023eb v2.33.1タグっぽいけど、手元のutil-linux 2.23.2 でも--propagateあるなぁ……
*4:付けないとシェルの起動中に起動したプロセスがpid=1を奪ったあげく即終了、 pid名前空間はpid=1なプロセスが終了すると、forkがNOMEMで失敗するようになってしまうため、結果、シェルは元の名前空間にいつつも、forkできない、という使い物にならないシェルが起動することになります
*5:ちなみに、前者のps --forestの表示が違いますが、pid=1の時だけ特別処理しているようです。 https://gitlab.com/procps-ng/procps/blob/master/ps/display.c#L509
*6:インストール先にcentos-releaseパッケージ や redhat-release-serverパッケージが入っていないので--releaseverオプションでバージョンを明示してインストールする必要があります
*7:https://qiita.com/mhiramat/items/5edd7eb479f9dca45b9c#chrootによるrootfsの変更 https://filippo.io/escaping-a-chroot-jail-slash-1/
*8:chrootなしに、exec /bin/bashでも行けそうな気もするのですが、 man 8 pivot_root をみるとchrootしてね、とう感じなんですよね。ここがchrootである必要がある理由、ちょっと調べ切れなったので詳しい諸兄諸姉のみなさまお知恵を頂ければ幸いです……