Linuxカーネル Docker関連 namespaceのメモ

Linux_Containers_logo_150

最近はやっとまともにDockerを使い始めたということもあり、基盤技術を追う必要性も強く感じてきました。Linuxカーネルのコンテナ技術周りのコードリーディングを再開しているのですが楽しいです。

環境

* CentOS 7.2 (kernel-3.10.0-327.4.5.el7.x86_64)
* Ubuntu 14.04 (3.13.0-77-generic)

読んでいるのはほとんどnamespaceとcgroup周りですけれど、この2つは違う機能なのでごっちゃにして覚えないようにしたいところです。このエントリーではnamespaceについて、利用方法やカーネルの実装なども含めてメモしておきます。

namespace (名前空間)

Linuxにおける namespace(名前空間) はプロセスに対して以下の6種類のリソースを分離するための機能として提供されています。

名前空間 定数 概要
IPC名前空間 CLONE_NEWIPC IPC(Inter-Process Communication:プロセス間通信)リソースであるSystem V IPCオブジェクト、POSIXメッセージキューを分離する。異なる名前空間の共有メモリやセマフォにアクセスできないようにする。
マウント名前空間 CLONE_NEWNS ファイルシステムツリーを分離する。異なる名前空間のファイルシステムにアクセスできないようにする。全てのユーザースペースはDockerイメージからマウントされる。chroot は使用しない。
ネットワーク名前空間 CLONE_NEWNET ネットワークデバイスやIPアドレス、ルーティングテーブルなどのネットワークインタフェースを分離する。異なる名前空間でそれぞれ仮想ネットワークを構築することができる。
PID名前空間 CLONE_NEWPID PID(プロセスID)空間を分離する。異なる名前空間で同じPIDのプロセスを作ることができる。
ユーザー名前空間 CLONE_NEWUSER UID/GIDを分離する。異なる名前空間で同じUIDのユーザーを作ることができ、root(UID=0)を名前空間外で操作の特権(root権限)を持たないようにセキュリティを設定する。
UTS名前空間 CLONE_NEWUTS uname() システムコールから返される2つのシステム識別子(nodename および domainname)を分離する。これにより各コンテナはそれぞれ独自のホスト名とNISドメイン名を持つことができる。

これらのリソースを分離することで、Dockerはコンテナ内外で別々の権限、リソース体系を構築することができるようになっています。ちなみにUTS名前空間のUTSという名前、元々は Unix Time-sharing System の略とのことで、現在は既にその意味は失われているようです。

/proc/{pid}/ns/ 以下で名前空間の一覧が確認できます。これらは特別なシンボリックリンクになっていて直接操作することはできません。
[bash]
$ ls -l /proc/$$/ns/
lrwxrwxrwx 1 ryo ryo 0 2月 6 23:21 ipc -> ipc:[4026531839] ## IPC名前空間
lrwxrwxrwx 1 ryo ryo 0 2月 6 23:21 mnt -> mnt:[4026531840] ## マウント名前空間
lrwxrwxrwx 1 ryo ryo 0 2月 6 23:21 net -> net:[4026531956] ## ネットワーク名前空間
lrwxrwxrwx 1 ryo ryo 0 2月 6 23:21 pid -> pid:[4026531836] ## PID名前空間
lrwxrwxrwx 1 ryo ryo 0 2月 6 23:21 user -> user:[4026531837] ## ユーザー名前空間
lrwxrwxrwx 1 ryo ryo 0 2月 6 23:21 uts -> uts:[4026531838] ## UTS名前空間
[/bash]

関連するシステムコール

名前空間をプロセス(プログラム)から利用する方法を整理しておきます。関連するシステムコールは以下の3つ。

clone(2) 新しいプロセスを生成する。 プロセス生成と同時に子プロセスを異なる名前空間に所属ことができる。新しく名前空間を作成するには clone(2) 呼び出し時に flags 引数で CLONE_NEW* のフラグを一つ以上指定する(上記の名前空間の表を参照)。例えばUTF名前空間を分離する際は CLONE_NEWUTS 、マウント名前空間を分離するには CLONE_NEWNS のように指定する。
unshare(2) 新しい名前空間を作成する。flags引数はclone(2)と同様だが新しいプロセスは作成しない。
setns(2) 既存の名前空間に呼び出したプロセスをアタッチする(参加させる)。clone(2)やunshare(2)のように新しい名前空間を作るわけではない。

clone(2)は名前空間を扱う以外にもスレッドの実装など様々な場所で使われていますね。他の2つのシステムコールは今回初めて知りました。

上記のシステムコール clone(2) を使ってPID名前空間の確認をしてみます。
[c]
#define _GNU_SOURCE
#include
#include
#include
#include
#include

#define STACK_SIZE 1024*1024
static char child_stack[STACK_SIZE];

static int child_fn() {
printf(“Child PID: %ld\n”, (long)getpid());
printf(“Parent PID: %ld\n”, (long)getppid());
return 0;
}

int main() {
// int clone(int (*fn)(void *), void *child_stack,
// int flags, void *arg, …
// /* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );
pid_t child_pid = clone(child_fn, child_stack+STACK_SIZE, CLONE_NEWPID | SIGCHLD, NULL);
printf(“clone() = %ld\n”, (long)child_pid);
printf(“My PID: %ld\n”, (long)getpid());
waitpid(child_pid, NULL, 0);
return 0;
}
[/c]
通常、clone(2)のchild_stack引数は子プロセスのために用意したスタック(メモリ空間)の一番大きいアドレスを指定します(スタックはアドレスが小さい方向へと伸びるため)。

## 上記プログラムをroot(特権ユーザー)で実行
clone() = 3063
My PID: 3062
Child PID: 1  ## 子プロセスからは自分のPIDは1に見える
Parent PID: 0  ## 子プロセスからは親プロセスのPIDは0に見える

確認のため、PID名前空間を作成せずに(CLONE_NEWPIDフラグを指定せずに)実行してみます。

clone() = 3093
My PID: 3092
Child PID: 3093
Parent PID: 3092

なるほど、PID名前空間はわかりやすいですね。次はネットワーク名前空間も加えて試してみます(※動作確認を簡単に行うためにsystem()関数を使っています)。
[c]
#define _GNU_SOURCE
#include
#include
#include
#include
#include

#define STACK_SIZE 1024*1024
static char child_stack[STACK_SIZE];

static int child_fn() {
printf(“New `net` Namespace:\n”);
system(“ip link”);
printf(“\n\n”);
return 0;
}

int main() {
printf(“Original `net` Namespace:\n”);
system(“ip link”);
printf(“\n\n”);
pid_t child_pid = clone(child_fn, child_stack+STACK_SIZE, CLONE_NEWPID | CLONE_NEWNET | SIGCHLD, NULL);
waitpid(child_pid, NULL, 0);
return 0;
}
[/c]
上述のようにclone(2)のflags引数は複数指定することができます。

Original `net` Namespace:
1: lo:  mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0:  mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
    link/ether c8:60:00:6e:87:02 brd ff:ff:ff:ff:ff:ff
3: wlan0:  mtu 1500 qdisc mq state UP mode DORMANT group default qlen 1000
    link/ether cc:e1:d5:3f:41:ba brd ff:ff:ff:ff:ff:ff


New `net` Namespace:
1: lo:  mtu 65536 qdisc noop state DOWN mode DEFAULT group default
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

新しく作成されるネットワーク名前空間はloだけ設定されるようです。ついでにUTS名前空間も確認。
[c]
#define _GNU_SOURCE
#include
#include
#include
#include
#include
#include

#define STACK_SIZE 1024*1024
static char child_stack[STACK_SIZE];

static void print_nodename() {
struct utsname utsname;
uname(&utsname);
printf(“%s\n”, utsname.nodename);
}

static int child_fn() {
printf(“New UTS namespace nodename: “);
print_nodename();
printf(“Changing nodename inside new UTS namespace\n”);
sethostname(“aquarius”, 8);
printf(“New UTS namespace nodename: “);
print_nodename();
return 0;
}

int main() {
printf(“Original UTS namespace nodename: “);
print_nodename();
pid_t child_pid = clone(child_fn, child_stack+STACK_SIZE, CLONE_NEWUTS | SIGCHLD, NULL);
sleep(1);
printf(“Original UTS namespace nodename: “);
print_nodename();
waitpid(child_pid, NULL, 0);
return 0;
}
[/c]

Original UTS namespace nodename: pisces
New UTS namespace nodename: pisces
Changing nodename inside new UTS namespace
New UTS namespace nodename: aquarius
Original UTS namespace nodename: pisces

ホスト名を変更しています。UTS名前空間もわかりやすいですね。

前述のCプログラムからのネットワーク名前空間の確認だけではわかりにくいので、実際に2つのネットワーク名前空間を作って相互に通信できるか試してみたいと思います。ip コマンドを使うと便利です。

$ man ip-netns
IP-NETNS(8)                                                                                    Linux                                                                                    IP-NETNS(8)

NAME
       ip-netns - process network namespace management

SYNOPSIS
       ip [ OPTIONS ] netns  { COMMAND | help }

       ip netns { list }

       ip netns { add | delete } NETNSNAME

       ip netns identify PID

       ip netns pids NETNSNAME

       ip netns exec NETNSNAME command ...

       ip netns monitor
... 省略
$ sudo ip netns add netns1  ## netns1 という名前のネットワーク名前空間を追加

$ ip netns  ## 確認
netns1

$ sudo ip netns exec netns1 ip link  ## netns1で ip link コマンドを実行
1: lo:  mtu 65536 qdisc noop state DOWN mode DEFAULT group default
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

$ ip netns exec netns1 ip link set lo up  ## loを起動

$ sudo ip netns exec netns1 ping 127.0.0.1  ## loにping確認
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.022 ms
... 省略

$ sudo ip link add veth0 type veth peer name veth1  ## 相互に接続された仮想的なEthernetデバイスのペアを作る

$ sudo ip link set veth1 netns netns1  ## veth1 を netns1 名前空間に割り当てる

$ ip link  ## 確認
1: lo:  mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0:  mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
    link/ether c8:60:00:6e:87:02 brd ff:ff:ff:ff:ff:ff
3: wlan0:  mtu 1500 qdisc mq state UP mode DORMANT group default qlen 1000
    link/ether cc:e1:d5:3f:41:ba brd ff:ff:ff:ff:ff:ff
7: veth0:  mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
    link/ether fe:52:5d:9a:1e:ab brd ff:ff:ff:ff:ff:ff  ## veth0デバイスが追加されている

$ sudo ip netns exec netns1 ip link  ## 確認
1: lo:  mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
6: veth1:  mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 76:0e:83:6c:e4:af brd ff:ff:ff:ff:ff:ff  ## veth1 デバイスが追加されている

$ sudo ifconfig veth0 192.168.111.1/24 up  ## veth0にIPアドレスを設定

$ sudo ip netns exec netns1 ifconfig veth1 192.168.111.2/24 up  ## veth1にIPアドレスを設定

$ ping 192.168.111.2  ## 相互にping送信
PING 192.168.111.2 (192.168.111.2) 56(84) bytes of data.
64 bytes from 192.168.111.2: icmp_seq=1 ttl=64 time=0.036 ms
...

$ sudo ip netns exec netns1 ping 192.168.111.1
PING 192.168.111.1 (192.168.111.1) 56(84) bytes of data.
64 bytes from 192.168.111.1: icmp_seq=1 ttl=64 time=0.023 ms
...

$ sudo ip netns delete netns1  ## ネットワーク名前空間を削除

仮想ネットワークを作ってネットワーク名前空間を跨いだ通信を確認できました。上記のように毎回 sudo ip netns exec netns1 ~ と打つのが面倒な場合、作成した名前空間で最初にシェルを立ち上げておけば、そのシェル上でいろいろ操作できるので便利です(sudo ip netns exec netns1 /bin/bash)。

次にDockerコンテナに対して名前空間の切り替えを確認したいと思います。事前にDockerでコンテナを作っておきます。
[bash]
$ sudo docker ps ## CentOSのコンテナを作成しておく
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a4557c514ddc centos “/bin/bash” 3 minutes ago Up 3 minutes clever_hawking

$ pgrep -l docker
3145 docker
$ sudo ls -l /proc/3145/ns ## ホストの名前空間を確認
合計 0
lrwxrwxrwx 1 root root 0 2月 21 14:06 ipc -> ipc:[4026531839]
lrwxrwxrwx 1 root root 0 2月 21 14:06 mnt -> mnt:[4026531840]
lrwxrwxrwx 1 root root 0 2月 21 14:06 net -> net:[4026531956]
lrwxrwxrwx 1 root root 0 2月 21 14:06 pid -> pid:[4026531836]
lrwxrwxrwx 1 root root 0 2月 21 14:06 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 2月 21 14:06 uts -> uts:[4026531838]

$ pgrep -l bash
7826 bash
$ sudo ls -l /proc/7826/ns ## Dockerコンテナの名前空間を確認
合計 0
lrwxrwxrwx 1 root root 0 2月 21 14:06 ipc -> ipc:[4026532382]
lrwxrwxrwx 1 root root 0 2月 21 14:06 mnt -> mnt:[4026532380]
lrwxrwxrwx 1 root root 0 2月 21 13:58 net -> net:[4026532385]
lrwxrwxrwx 1 root root 0 2月 21 14:06 pid -> pid:[4026532383]
lrwxrwxrwx 1 root root 0 2月 21 14:06 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 2月 21 14:06 uts -> uts:[4026532381]
[/bash]
各名前空間(ユーザー名前空間以外)の番号が異なっていますので、異なる名前空間上でDockerコンテナが動作していることを確認できます。このコンテナに対してsetns(2)を使って名前空間の切り替えを行います。
* ns_exec.c
[c]
/* ns_exec.c

Copyright 2013, Michael Kerrisk
Licensed under GNU General Public License v2 or later

Join a namespace and execute a command in the namespace
*/
#define _GNU_SOURCE
#include
#include
#include
#include
#include

/* A simple error-handling function: print an error message based
on the value in ‘errno’ and terminate the calling process */

#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE); \
} while (0)

int
main(int argc, char *argv[])
{
int fd;

if (argc < 3) { fprintf(stderr, "%s /proc/PID/ns/FILE cmd [arg...]\n", argv[0]); exit(EXIT_FAILURE); } fd = open(argv[1], O_RDONLY); /* Get descriptor for namespace */ if (fd == -1) errExit("open"); if (setns(fd, 0) == -1) /* Join that namespace */ errExit("setns"); execvp(argv[2], &argv[2]); /* Execute a command in namespace */ errExit("execvp"); } [/c] [bash] ## ホストはUbuntu $ cat /etc/lsb-release DISTRIB_ID=Ubuntu DISTRIB_RELEASE=14.04 DISTRIB_CODENAME=trusty DISTRIB_DESCRIPTION="Ubuntu 14.04.2 LTS" ## Dockerコンテナ(CentOS)のマウント名前空間に切り替え $ ./ns_exec ./ns_exec /proc/PID/ns/FILE cmd [arg...] $ sudo ./ns_exec /proc/7826/ns/mnt /bin/bash # cat /etc/redhat-release CentOS Linux release 7.2.1511 (Core) [/bash] 実行中のDockerコンテナ内のファイルシステムでBashが起動していることがわかります。

カーネルの実装

名前空間関連のカーネルの実装を一部紹介します。
* include/kernel/nsproxy.h
[c]
/*
* A structure to contain pointers to all per-process
* namespaces – fs (mount), uts, network, sysvipc, etc.
*
* The pid namespace is an exception — it’s accessed using
* task_active_pid_ns. The pid namespace here is the
* namespace that children will use.
*
* ‘count’ is the number of tasks holding a reference.
* The count for each namespace, then, will be the number
* of nsproxies pointing to it, not the number of tasks.
*
* The nsproxy is shared by tasks which share all namespaces.
* As soon as a single namespace is cloned or unshared, the
* nsproxy is copied.
*/
struct nsproxy {
atomic_t count;
struct uts_namespace *uts_ns;
struct ipc_namespace *ipc_ns;
struct mnt_namespace *mnt_ns;
struct pid_namespace *pid_ns_for_children;
struct net *net_ns;
};
extern struct nsproxy init_nsproxy;
[/c]
名前空間は nsproxy 構造体で管理されています。ここでは5つの名前空間が定義されています。ユーザー名前空間は他の名前空間と異なり独立していません。

sethostname(2) の実装を見てみます。
* kernel/sys.c
[c]
/*
* Only setdomainname; getdomainname can be implemented by calling
* uname()
*/
SYSCALL_DEFINE2(setdomainname, char __user *, name, int, len)
{
int errno;
char tmp[__NEW_UTS_LEN];

if (!ns_capable(current->nsproxy->uts_ns->user_ns, CAP_SYS_ADMIN))
return -EPERM;
if (len < 0 || len > __NEW_UTS_LEN)
return -EINVAL;

down_write(&uts_sem);
errno = -EFAULT;
if (!copy_from_user(tmp, name, len)) {
struct new_utsname *u = utsname();

memcpy(u->domainname, tmp, len);
memset(u->domainname + len, 0, sizeof(u->domainname) – len);
errno = 0;
uts_proc_notify(UTS_PROC_DOMAINNAME);
}
up_write(&uts_sem);
return errno;
}
[/c]
ns_capable関数でケーパビリティ(capability)のチェック、ここではCAP_SYS_ADMIN(root)権限で実行されているか確認しています。引数のcurrentは現在のプロセスのtask_struct構造体で、前述のnsproxy構造体をメンバに持っています。ここではUTS名前空間内のユーザー名前空間を参照してケーパビリティのチェックしています。また、utsname()関数でホスト名を管理するnew_utsname構造体へのポインタを返しています。task_struct構造体というのはLinuxのプロセスを表現する大きな構造体で、プロセスの実行状態やメモリ、ソケット、ファイル情報、他プロセス(親や子)との関係など多くの情報を管理しています。定義は include/linux/sched.h にありますが長いためここでは割愛します。

* include/uapi/linux/utsname.h, include/linux/utsname.h
[c]
struct new_utsname {
char sysname[__NEW_UTS_LEN + 1];
char nodename[__NEW_UTS_LEN + 1];
char release[__NEW_UTS_LEN + 1];
char version[__NEW_UTS_LEN + 1];
char machine[__NEW_UTS_LEN + 1];
char domainname[__NEW_UTS_LEN + 1];
};

struct uts_namespace {
struct kref kref;
struct new_utsname name;
struct user_namespace *user_ns;
struct ns_common ns;
};

static inline struct new_utsname *utsname(void)
{
return &current->nsproxy->uts_ns->name; ## 現在のプロセスに紐付くUTS名前空間内のホスト名を返却
}
[/c]
このように名前空間の利用を前提とした処理になっています。UTS名前空間はシンプルなため比較的読みやすいですが、他の名前空間の実装も確認したら後で追記します。コンテナ技術の支えるもうひとつの重要機能である cgroup についても整理・メモしておこうと思います。

ここ1, 2年くらいは技術の現場から離れていたこともあり、Linuxカーネル読書会・勉強会などにも参加していなかったので効率良くコードリーディングできていません、。やはりもうしばらくリハビリ期間が必要です。

参考

あわせて読む:

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です