Linux のコンテナ仮想化を構成する機能の一つに Namespace (名前空間) がある。 Namespace は、カーネルのリソースを隔離して扱うための仕組みで、リソース毎に色々とある。 今回は、その中でも PID (Process Identifier) を隔離する PID Namespace を扱ってみる。
使った環境は次のとおり。
$ lsb_release -a No LSB modules are available. Distributor ID: Ubuntu Description: Ubuntu 20.04.3 LTS Release: 20.04 Codename: focal $ uname -rm 5.4.0-96-generic aarch64 $ unshare --version unshare from util-linux 2.34 $ gcc --version gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0 Copyright (C) 2019 Free Software Foundation, Inc. This is free software; see the source for copying conditions. There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. $ ps --version ps from procps-ng 3.3.16
PID と PID Namespace
まず、PID の前提について確認しておく。 PID は 1 から始まって、新しいプロセスができるたびにインクリメントされた整数がプロセスの識別子として付与される。 PID の数が割り当てられる上限に達したときは、また 1 に戻って空いている数字が割り当てられる。 たとえば Ubuntu 20.04 LTS であれば、上限は以下に設定されているようだ。
$ cat /proc/sys/kernel/pid_max
4194304
プロセスが一意に識別できなくなるため、同じ PID を持ったプロセスが複数できることはない。 しかし、コンテナ仮想化においては、コンテナの中では独立した PID を見せたい。 そこで、PID Namespace によって、PID のリソースをシステムから隔離して扱うことができる。 言いかえると、コンテナの中で PID が 1 から始まるのは PID Namespace によって実現されている。
下準備
下準備として、あらかじめ unshare(1) のために util-linux をインストールしておく。 また、unshare(2) を呼び出すコードをビルドするために build-essential をインストールする。
$ sudo apt-get update $ sudo apt-get install -y util-linux build-essential
unshare(1) を使って PID Namespace を使ってみる
まずはコマンドラインツールの unshare(1) から PID Namespace を使ってみよう。
unshare(1) を使って PID Namespace を新たに作るには --pid
オプションを使う。
また、同時に --fork
オプションと --mount-proc
オプションもつけた方が良い。
この理由は後ほど説明する。
起動するプログラムとしては bash を指定しておこう。
$ sudo unshare --pid --fork --mount-proc bash
起動した bash では、PID が 1 になっていることがわかる。 また、ps(1) でも PID が 1 から振り直されていることが確認できる。 ちゃんとシステムから独立した PID が利用できているようだ。
# echo $$ 1 # ps PID TTY TIME CMD 1 pts/0 00:00:00 bash 8 pts/0 00:00:00 ps
さて、それでは先ほど --pid
とは別で追加で指定したオプションについて見ていこう。
まずは --fork
オプションから。
このオプションをつけないと何が起こるだろうか。
一旦、先ほど起動した bash は終了した上で、改めて unshare(1) を使おう。
今度は --pid
オプションだけつける。
$ sudo unshare --pid bash
bash: fork: Cannot allocate memory
すると fork: Cannot allocate memory
というエラーになってしまう。
とはいえ、一応エラーにはなりつつも bash は起動しているようだ。
しかし、何をするにしても同じ fork: Cannot allocate memory
というエラーになってしまう。
# ls
bash: fork: Cannot allocate memory
このエラーの原因は、次の stackoverflow の質問に詳しい解説がある。
かいつまんで説明すると、こういうことらしい。 まず、unshare(1) から起動した bash は、新たに作成した PID Namespace には所属しない。 代わりに、bash が最初に (fork(2) によって) 生成するサブプロセスが、新たに作成した PID Namespace に所属することになる。 新たに作成した PID Namespace では、PID が 1 から始まるため、bash のサブプロセスが PID 1 になる。 しかし、bash のサブプロセスは直後に終了するため、PID 1 のプロセスがいなくなる。 Linux において PID 1 のプロセスは特別な意味を持つことから、それがいなくなることで上記のエラーが生じているらしい。
一応、サブプロセスを起動しないタイプのシェルを利用すればエラーは出ない。 たとえば Ubuntu 20.04 LTS の sh は dash を使っているらしいので、指定してみよう。 しかし、その場合はそもそも起動したシェルが新しい PID Namespace に所属していないので何も意味がない。
$ sudo unshare --pid sh # echo $$ 1136
ということで --fork
オプションが必要な理由がわかった。
続いては --mount-proc
オプションについて。
今度はこのオプションを付けないで実行してみよう。
$ sudo unshare --pid --fork bash
一見すると何も問題なさそうに見えるけど、ps(1) なんかを呼び出すと随分と大きな数字が見える。 そもそも、隔離して見えないはずの unshare(1) の PID が見えているのはどうしたことか。
# ps PID TTY TIME CMD 959 pts/0 00:00:00 sudo 960 pts/0 00:00:00 unshare 961 pts/0 00:00:00 bash 968 pts/0 00:00:00 ps
これは、/proc ファイルシステムが、PID Namespace を隔離する前の状態のままであることが原因。 要するにシステムの状態が見えたままということ。 Ubuntu 20.04 LTS の ps (=procps-ng) は /proc ファイルシステムを見ているので、さもありなん。
# ls /proc | egrep ^[0-9] | sort -n | tail -n 5 974 989 990 991 992
この状態は /proc ファイルシステムをマウントし直せば解消できる。
# mount -t proc proc /proc # ls /proc | egrep ^[0-9] | sort -n | tail -n 5 1 20 21 22 23
つまり、これこそが --mount-proc
オプションがやっていたこと、というわけだ。
ちなみに、上記のように新しいプロセスでマウントし直すやり方を取ると、そのままではマウントのプロパゲーションが起こってしまう。 この振る舞いについては以下のエントリで説明している。
要するに、上記のようなことをしたければ --mount
オプションをつける必要がある。
あるいは、次のコマンドを使って事前にプロパゲーションを無効にしても良い。
$ sudo mount --make-private /
unshare(2) を使って PID Namespace を使ってみる
さて、続いては unshare(2) から PID Namespace を扱ってみよう。 ソースコードは、以下のコマンドと等価なものにする。
$ sudo unshare --pid --fork --mount-proc bash
早速だけどサンプルコードを以下に示す。 まず、unshare(2) で Mount Namespace と PID Namespace を新たに作成している。 その上で fork(2) で子プロセスを作っている。 この子プロセスが新しく作った PID Namespace に所属することになる。 その上で、マウントのプロパゲーションを無効にした上で /proc ファイルシステムをマウントし直している。 そして、最後にシェルを起動している。
#define _GNU_SOURCE #include <sched.h> #include <stdlib.h> #include <stdio.h> #include <errno.h> #include <string.h> #include <unistd.h> #include <sys/wait.h> #include <sys/types.h> #include <sys/mount.h> int main(int argc, char *argv[]) { // Mount & PID Namespace を作成する if (unshare(CLONE_NEWPID | CLONE_NEWNS) != 0) { fprintf(stderr, "Failed to create a new PID namespace: %s\n", strerror(errno)); exit(EXIT_FAILURE); } // fork(2) で子プロセスを作る pid_t pid = fork(); if (pid < 0) { fprintf(stderr, "Failed to fork a new process: %s\n", strerror(errno)); exit(EXIT_FAILURE); } if (pid != 0) { // 親プロセスは wait(2) で子プロセスの完了を待つ wait(NULL); exit(EXIT_SUCCESS); } // 以降は子プロセスの処理 // ルート以下のマウントプロパゲーションを再帰的に無効にする if (mount("none", "/", NULL, MS_REC | MS_PRIVATE, NULL) != 0) { fprintf(stderr, "cannot change root filesystem propagation: %s\n", strerror(errno)); exit(EXIT_FAILURE); } // mount(2) で /proc ファイルシステムをマウントする if (mount("proc", "/proc", "proc", MS_NOSUID | MS_NOEXEC | MS_NODEV, NULL) != 0) { fprintf(stderr, "Failed to mount /proc: %s\n", strerror(errno)); exit(EXIT_FAILURE); } // execvp(3) でシェルを起動する char* const args[] = {"bash", NULL}; if (execvp(args[0], args) != 0) { fprintf(stderr, "Failed to exec \"%s\": %s\n", args[0], strerror(errno)); exit(EXIT_FAILURE); } return EXIT_SUCCESS; }
上記に適当な名前をつけて保存したらビルドして実行する。
$ gcc -std=c11 -Wall example.c $ sudo ./a.out
シェルの PID を確認すると、ちゃんと 1 になっている。
# echo $$ 1
ps(1) を実行しても、PID がリセットされていることがわかる。
# ps PID TTY TIME CMD 1 pts/0 00:00:00 bash 8 pts/0 00:00:00 ps
PID Namespace が異なっているのは /proc ファイルシステムの以下を見ても確認できる。
# ls -l /proc/self/ns/pid lrwxrwxrwx 1 root root 0 Jan 23 01:29 /proc/self/ns/pid -> 'pid:[4026532130]' # exit $ ls -l /proc/self/ns/pid lrwxrwxrwx 1 ubuntu ubuntu 0 Jan 23 01:29 /proc/self/ns/pid -> 'pid:[4026531836]'
いじょう。