コマンドを叩いて遊ぶ 〜コンテナ仮想、その裏側〜

レトリバのCTO 武井です。

今回は過去にセミナーでお話した内容を振り返りつつ、当時いれられなかったこぼれ話や補足などを加え、ブログで紹介しようと思います。 今後、レトリバセミナーで話した内容はこのような形でブログとしても公開する予定です。 また、過去のセミナーも随時ブログ化される予定ですのでお楽しみに!

YouTubeのレトリバチャンネルもぜひよろしくお願いします!

今回の過去セミナーはこちら


PFIセミナー2015/10/22:コマンドを叩いて遊ぶ〜コンテナ仮想、その裏側〜

「コマンドを叩いて遊ぶ 〜コンテナ仮想、その裏側〜」というタイトルで、Preferred Infrastructure時代に、PFIセミナーで話した内容です。 レトリバ創業前、3年以上前のセミナーでして、PFIセミナーの配信がYouTubeに移行したはじめてのセミナーだったりもします。

3年も経っているでの情報も古く……、と普通はなるところですが、そこは「裏側」。 コンテナ仮想の裏で起こっていることは、今もあまり大きな違いはなく、今でも通じる話も多いのです。 ぜひ、手を動かしてコマンドを叩きつつ、dockerをはじめとするコンテナ仮想の裏側で何が起きているのか、一緒に学びましょう!

そして、RHEL8で採用との噂のpodmanについては、4月のセミナーで話す予定です、セミナーもぜひよろしくお願いします!

はじめに

f:id:goth_wrist_cut:20190409144124p:plain:w333

このあたり、Dockerが普及した2019年現在、そこら中で同じような図が出てますし、ほぼほぼ説明は不要でしょうか?

仮想マシンをエミュレートして、その上にカーネル・プロセスを動かすVM仮想化に対して、コンテナ型仮想化は同一のカーネル上に名前空間を作って分離する仮想化です。 VM仮想化にくらべ、仮想マシンのオーバーヘッドがない分、軽量かつ高速でありますが、一方で異なるOS/カーネルを動かせないという制約が掛かります。

このセミナー当時は、WindowsMac向けDockerでLinuxを動かすには、VM(VirtualBox)でCoreOSを動かして、そのなかでコンテナを動かして繋ぐ、みたいな構成でしたが、その後Hyper-Vやxhyveなどのハイパーバイザ型になり、オーバーヘッドがだいぶ少なくなって使い勝手がよくなりましたね。

コンテナ仮想あれこれ

f:id:goth_wrist_cut:20190409145629p:plain:w333 f:id:goth_wrist_cut:20190409145634p:plain:w333

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や、SolarisSolaris 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の話。

f:id:goth_wrist_cut:20190409145859p:plain:w333

同一のカーネル上に複数の環境を用意するために、OS上の一意な名前/id(例えばpidとか)を分離し、環境ごと、同じ名前/idでも別のものを指すことができるようにする仕組みです。 プログラミング言語でもnamespaceという概念がありますが、似た様な感じですね。

このスライドではuser名前空間に触れつつも、RHEL7ではまだエンタープライズ用途で使うには検証がたりないため有効にしていない(意訳) ということで、スキップしています。 これがrootlessコンテナの一つの要になっているのですが、これに関しては4月のセミナーで……。

さて、Linux namespaceを分離するとどんなことが起こるのでしょうか? ぜひ手元に環境を作って、コマンドを打ちながら、一緒に試してみましょう!

システムを結構いじるので、できればVMなど使い捨てできる環境がよいかもです。 以下のコマンドの実行結果は、CentOS 7.6.1810で実行しています。

プロセスの名前空間

f:id:goth_wrist_cut:20190409151218p:plain:w333

まずは、プロセスの名前空間についてです。

各プロセスはなにかしらの名前空間に属しています。 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

f:id:goth_wrist_cut:20190409152616p:plain:w333

まずは分かりやすい 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について

f:id:goth_wrist_cut:20190409152658p:plain:w333 f:id:goth_wrist_cut:20190409152709p:plain:w333 f:id:goth_wrist_cut:20190409152713p:plain:w333

さて、セミナーではこの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

f:id:goth_wrist_cut:20190409152921p:plain:w333

続いて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

f:id:goth_wrist_cut:20190409153034p:plain:w333

続いては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

f:id:goth_wrist_cut:20190409153102p:plain:w333

さて、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を使ってコンテナをネットワークに繋ぐ

f:id:goth_wrist_cut:20190409153420p:plain:w333 f:id:goth_wrist_cut:20190409153420p:plain:w333 f:id:goth_wrist_cut:20190409153432p:plain:w333

コンテナから表のネットワークに出る方法はいくつかありますが、今回は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

f:id:goth_wrist_cut:20190409153642p:plain:w333 f:id:goth_wrist_cut:20190409153645p:plain:w333 f:id:goth_wrist_cut:20190409153649p:plain:w333

(スライドには「MINCSはpivot_rootを二回」と、書いていますがmount propagationの変更とlazy unmountをすることで一回減ったようです)

さて、mnt名前空間を使うことで、マウントポイントを環境ごと変えられることが分かりましたが、 / を変えたい時はどうすればいいでしょうか? マウントポイントが分離されたところで、プロセスがファイルを開き続けている限り / はアンマウントできませんし、アンマウントしないことにはコンテナからホスト環境を隠すことが出来ません。

そこで出てくるのが chrootpivot_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_rootchroot と違い、新しいルートの中のバイナリを使ってプロセスを起動するのではなく、今現在のプロセスのルートを差し替えをしています。 そして、今のプロセスは、元のルートファイルシステムで起動したプロセスですので、実は昔のルートファイルシステム.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である必要がある理由、ちょっと調べ切れなったので詳しい諸兄諸姉のみなさまお知恵を頂ければ幸いです……