Edited at

Docker内部で利用されているLinuxカーネルの機能 (namespace/cgroups)

More than 3 years have passed since last update.


Docker内部で利用されているLinuxカーネルの機能 (namespace/cgroups)

Dockerで内部的に利用されているLinuxカーネルの機能について整理しています。

個人ブログの以下の2つのエントリーを1つにまとめたものになります。

勉強メモ程度の内容なので間違いを含む可能性が大いにあります、ご注意ください。


環境


  • CentOS 7.2 (kernel-3.10.0-327.4.5.el7.x86_64)

  • Ubuntu 14.04 (3.13.0-77-generic)

  • Docker 1.9.1


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/ 以下で名前空間の一覧が確認できます。これらは特別なシンボリックリンクになっていて直接操作することはできません。

$ 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名前空間


関連するシステムコール

名前空間をプロセス(プログラム)から利用する方法を整理しておきます。関連するシステムコールは以下の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名前空間の確認をしてみます。

#define _GNU_SOURCE

#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

#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;
}

通常、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()関数を使っています)。

#define _GNU_SOURCE

#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

#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;
}

上述のようにclone(2)のflags引数は複数指定することができます。

Original `net` Namespace:

1: lo: <LOOPBACK,UP,LOWER_UP> 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: <BROADCAST,MULTICAST,UP,LOWER_UP> 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: <BROADCAST,MULTICAST,UP,LOWER_UP> 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: <LOOPBACK> 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名前空間も確認。

#define _GNU_SOURCE

#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/utsname.h>
#include <sys/wait.h>
#include <unistd.h>

#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;
}

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: <LOOPBACK> 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: <LOOPBACK,UP,LOWER_UP> 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: <BROADCAST,MULTICAST,UP,LOWER_UP> 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: <BROADCAST,MULTICAST,UP,LOWER_UP> 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: <BROADCAST,MULTICAST,UP,LOWER_UP> 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: <LOOPBACK,UP,LOWER_UP> 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: <BROADCAST,MULTICAST> 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でコンテナを作っておきます。

$ 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]

各名前空間(ユーザー名前空間以外)の番号が異なっていますので、異なる名前空間上でDockerコンテナが動作していることを確認できます。このコンテナに対してsetns(2)を使って名前空間の切り替えを行います。

/* 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 <fcntl.h>
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

/* 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");
}

## ホストは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)

実行中のDockerコンテナ内のファイルシステムでBashが起動していることがわかります。


カーネルの実装

名前空間関連のカーネルの実装を一部紹介します。


  • include/kernel/nsproxy.h

/*

* 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;

名前空間は nsproxy 構造体で管理されています。ここでは5つの名前空間が定義されています。ユーザー名前空間は他の名前空間と異なり独立していません。

sethostname(2) の実装を見てみます。


  • kernel/sys.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;
}

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

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名前空間内のホスト名を返却
}

このように名前空間の利用を前提とした処理になっています。UTS名前空間はシンプルなため比較的読みやすいですが、他の名前空間の実装も確認したら後で追記します。


cgroups


Control Groups provide a mechanism for aggregating/partitioning sets of

tasks, and all their future children, into hierarchical groups with

specialized behaviour.


cgroupsは control groups の略でタスクをグループ化したり、そのグループ内のタスクに対して様々なリソース制御を行うための仕組みです。namespaceではホスト名やPID空間などのカーネル/OSが扱うリソースを制御(隔離)しますが、cgroupsで制御するのはCPUやメモリといった物理的なリソースです。

/sys/fs/cgroup 以下に仮想的なファイルシステムとしてのインタフェースが提供されています。今回の作業環境も上述の通りCentOSとUbuntuの両方で確認していますが、CentOSの場合はsystemd経由でcgroupsを操作するのに対して、Ubuntuの場合は直接 /sys/fs/cgroup を書き換えています。Ubuntu 15.04からはUpstartからsystemdに置き換わっているらしいですが、ディレクトリ以下の構造は基本的には変わりません。

実際に /sys/fs/cgroup の中身を見てみるとcpuやmemoryといったわかりやすい名前のサブディレクトリが見えます。

# CentOSの場合

$ ls -l /sys/fs/cgroup
drwxr-xr-x 2 root root 0 2月 6 16:07 blkio/
lrwxrwxrwx 1 root root 11 2月 6 16:07 cpu -> cpu,cpuacct/
drwxr-xr-x 2 root root 0 2月 6 16:07 cpu,cpuacct/
lrwxrwxrwx 1 root root 11 2月 6 16:07 cpuacct -> cpu,cpuacct/
drwxr-xr-x 2 root root 0 2月 6 16:07 cpuset/
drwxr-xr-x 2 root root 0 2月 6 16:07 devices/
drwxr-xr-x 2 root root 0 2月 6 16:07 freezer/
drwxr-xr-x 2 root root 0 2月 6 16:07 hugetlb/
drwxr-xr-x 2 root root 0 2月 6 16:07 memory/
drwxr-xr-x 2 root root 0 2月 6 16:07 net_cls/
drwxr-xr-x 2 root root 0 2月 6 16:07 perf_event/
drwxr-xr-x 4 root root 0 2月 6 16:07 systemd/

# Ubuntuの場合
$ ls -l /sys/fs/cgroup
drwxr-xr-x 3 root root 0 4月 28 11:02 blkio/
drwxr-xr-x 3 root root 0 4月 28 11:02 cpu/
drwxr-xr-x 3 root root 0 4月 28 11:02 cpuacct/
drwxr-xr-x 3 root root 0 4月 28 11:02 cpuset/
drwxr-xr-x 3 root root 0 4月 28 11:02 devices/
drwxr-xr-x 3 root root 0 4月 28 11:02 freezer/
drwxr-xr-x 3 root root 0 4月 28 11:02 hugetlb/
drwxr-xr-x 3 root root 0 4月 28 11:02 memory/
drwxr-xr-x 3 root root 0 4月 28 11:02 perf_event/
drwxr-xr-x 3 root root 0 4月 28 11:02 systemd/

仮想的なファイルシステムと前述した通り、ここでファイル/ディレクトリ操作をすることで様々なリソース制御を行います。これらはサブシステムと呼ばれています。

## 使用可能なサブシステムは/proc/cgroupsで確認できる

$ cat /proc/cgroups
#subsys_name hierarchy num_cgroups enabled
cpuset 2 10 1
cpu 3 10 1
cpuacct 4 10 1
memory 5 10 1
devices 6 10 1
freezer 7 10 1
blkio 8 10 1
perf_event 9 10 1
hugetlb 10 10 1

## OSブート時にcgroupが初期化されていることを確認
$ dmesg | grep cgroup
[ 0.000000] Initializing cgroup subsys cpuset
[ 0.000000] Initializing cgroup subsys cpu
[ 0.000000] Initializing cgroup subsys cpuacct
[ 0.000000] allocated 33554432 bytes of page_cgroup
[ 0.000000] please try 'cgroup_disable=memory' option if you don\'t want memory cgroups
[ 0.002301] Initializing cgroup subsys memory
[ 0.002305] Initializing cgroup subsys devices
[ 0.002306] Initializing cgroup subsys freezer
[ 0.002307] Initializing cgroup subsys blkio
[ 0.002308] Initializing cgroup subsys perf_event
[ 0.002310] Initializing cgroup subsys hugetlb

## include/linux/cgroup_subsys.h 内にマクロで列挙されている
$ cat include/linux/cgroup_subsys.h | grep 'SUBSYS('
SUBSYS(cpuset)
SUBSYS(debug)
SUBSYS(cpu_cgroup)
SUBSYS(cpuacct)
SUBSYS(mem_cgroup)
SUBSYS(devices)
SUBSYS(freezer)
SUBSYS(net_cls)
SUBSYS(blkio)
SUBSYS(perf)
SUBSYS(net_prio)
SUBSYS(hugetlb)

サブシステム
概要

blkio
ブロックデバイスの入出力

cpu
CPUリソースの割り当て・制限

cpuacct
タスクが消費するCPU時間をレポート

cpuset
グループへのCPU,メモリノードの割り当て

devices
デバイスへのアクセス制限

freezer
グループに属するプロセスの一時停止/再開

hugetlb
cgroupからのhugetlbの使用

memory
タスクが消費するメモリリソースのレポートと制限

perf_event
cgroup単位でのperfツールの使用

cgroupsによってタスクをグループ化しますが、そのグループは各々ヒエラルキー(hierarchy:階層)構造を持ち、上記のサブシステムによるリソース制限を受けます。そのヒエラルキー構造は仮想ファイルシステム上で表現されます。

ここではいくつかのサブシステムをピックアップしていろいろ調べてみます。


cpuset/cpu/memoryサブシステム

まずは挙動がわかりやすいcpuset/cpu/memoryサブシステムについて。dockerコンテナを作成する際の以下のオプションに関係してきます。オプション名はDockerのバージョンによって変わることがあるので注意してください(その際はdeprecatedメッセージが出力されます)。

オプション
概要

--cpuset-cpus
使用するCPUコアを指定 (cpusetサブシステム)

--cpu-shares
CPU時間の割り当てを相対比率で指定、デフォルト 1024 (cpuサブシステム)

-m
--memory

上記dockerオプションはcpuset,cpu,memoryサブシステムの以下のファイルを操作してリソース制限を行います。ここではUbuntu上で動作確認していますが、/sys/fs/cgourps/cpuset,cpu,memoryディレクトリに以下のファイルがあります。


  • cpuset.cpus

  • cpu.shares

  • memory.limit_in_bytes

まずはcpusetサブシステムの動作確認のために適当なdockerコンテナを作って起動し、CPU1のみを利用するように --cpuset-cpus オプションで指定してddコマンドを実行し続けます。

$ sudo docker run -itd --cpuset-cpus 1 centos dd if=/dev/zero of=/dev/null

46532188bbd17503d9ceca0d57d72c5783d25e2f5648af4c89c5111e3a847a10

## topコマンドでCPU使用率を確認
$ top
top - 23:11:40 up 9:56, 1 user, load average: 0.99, 0.58, 0.27
Tasks: 158 total, 2 running, 156 sleeping, 0 stopped, 0 zombie
%Cpu0 : 0.0 us, 0.0 sy, 0.0 ni,100.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu1 : 26.3 us, 73.7 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st ## 1コアのみ利用されている
%Cpu2 : 0.0 us, 0.0 sy, 0.0 ni,100.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu3 : 0.3 us, 0.0 sy, 0.0 ni, 99.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem: 8071336 total, 1041000 used, 7030336 free, 124388 buffers
KiB Swap: 16670716 total, 0 used, 16670716 free. 599068 cached Mem

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
7146 root 20 0 4356 360 284 R 99.8 0.0 4:13.35 dd
... 省略

dockerではコンテナを起動すると各サブシステムに docker/{コンテナID} 名のディレクトリを作成します。上記のコンテナで指定した --cpuset-cpus の値を確認してみます。

## cpuset.cpusファイルに1が書き込まれている

$ cat /sys/fs/cgroup/cpuset/docker/46532188bbd17503d9ceca0d57d72c5783d25e2f5648af4c89c5111e3a847a10/cpuset.cpus
1

次はcpuサブシステムが関係する --cpu-shares の挙動を確認してみます。2つめのコンテナを以下のオプションで起動します。--cpu-shares 2048 と指定していますが、これは同じCPUコアで複数のコンテナのプロセスが実行される場合に、どちらのコンテナを優先的に実行するかを相対的な重みで指定するオプションです。デフォルト値が1024なので、ここではその2倍の値を指定しています。その結果、2つめのコンテナは2倍の優先度でCPU時間が割り当てられることになります。

$ sudo docker run -itd --cpuset-cpus 1 --cpu-shares 2048 centos dd if=/dev/zero of=/dev/null

2b8096a07c04892d9882615a70f5cf58882de3af1434e45a931bf587ba9f2015

## 同様にtopコマンドでCPU使用率を確認
top - 23:38:30 up 10:23, 1 user, load average: 1.99, 1.32, 0.79
Tasks: 159 total, 3 running, 156 sleeping, 0 stopped, 0 zombie
%Cpu0 : 0.0 us, 0.0 sy, 0.0 ni,100.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu1 : 25.0 us, 75.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st ## 1コアのみ利用
%Cpu2 : 0.0 us, 0.3 sy, 0.0 ni, 99.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu3 : 0.0 us, 0.0 sy, 0.0 ni,100.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem: 8071336 total, 1050296 used, 7021040 free, 125476 buffers
KiB Swap: 16670716 total, 0 used, 16670716 free. 600564 cached Mem

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
7794 root 20 0 4356 356 284 R 66.6 0.0 2:02.05 dd ## 2つめのコンテナでのdd
7670 root 20 0 4356 360 284 R 33.6 0.0 2:05.72 dd ## 1つめのコンテナでのdd
... 省略

2つのコンテナでのddコマンドのCPU使用率が綺麗に1対2に分かれています。確認のため cpu.shares ファイルの中身も見てみます。

## cpu.sharesファイルに2048が書き込まれている

$ cat /sys/fs/cgroup/cpu/docker/2b8096a07c04892d9882615a70f5cf58882de3af1434e45a931bf587ba9f2015/cpu.shares
2048

次はmemoryサブシステムが関係する --memory オプションの挙動を確認します。コンテナから使用できるメモリの上限値を指定するオプションで、数値の後ろにmを付ければMB(メガバイト)指定ができます。

$ sudo docker run -itd --memory 128m centos /bin/bash

6077df1a5a396b729256871482942d033aa9f0897f3bf9753e1e90e23029eb48

## memory.limit_in_bytesファイルに指定した値がバイト単位で書き込まれている
$ cat /sys/fs/cgroup/memory/docker/6077df1a5a396b729256871482942d033aa9f0897f3bf9753e1e90e23029eb48/memory.limit_in_bytes
134217728 ## 128MB

--memory オプションを指定しない場合(デフォルト)だと、memory.limit_in_bytesファイルには 18446744073709551615 (bytes)と書かれていました。スワップ領域の容量も同じサイズに制限されるため、128mと指定した場合はプロセスは物理メモリ128MB + スワップ128MBまで使用できます。そのサイズを超えるとOOM Killerが発動してコンテナ内のプロセスが強制終了させられます。

また、以下のファイルの中身を見るとコンテナ内でのメモリ使用状況を確認することができます。

## コンテナ内のプロセスの現在のメモリ使用量

$ cat /sys/fs/cgroup/memory/docker/6077df1a5a396b729256871482942d033aa9f0897f3bf9753e1e90e23029eb48/memory.usage_in_bytes
2453504

## コンテナ内のプロセスのメモリ使用量の最大値
$ cat /sys/fs/cgroup/memory/docker/6077df1a5a396b729256871482942d033aa9f0897f3bf9753e1e90e23029eb48/memory.max_usage_in_bytes
134217728 ## コンテナ作成時にオプションで指定した値 128MB

## コンテナ内のプロセスのメモリ使用量が上限値に達した回数
$ cat /sys/fs/cgroup/memory/docker/6077df1a5a396b729256871482942d033aa9f0897f3bf9753e1e90e23029eb48/memory.failcnt
84491

ここまで紹介した各ファイル内の値を直接書き換えることでもコンテナ内のリソースを任意に制限をすることができます。まぁdockerを使っているなら素直にdockerのコマンドから指定した方が良いですね。


devicesサブシステム

次にdevicesサブシステムについていろいろ確認してみます。devicesはグループに所属するプロセスがアクセス可能なデバイスを制限するサブシステムです。例えばコンテナ内から直接ネットワークやディスクのデバイスにアクセスさせないように設定できます。

$ ls -l /sys/fs/cgroup/devices

-rw-r--r-- 1 root root 0 4月 28 11:02 cgroup.clone_children
--w--w--w- 1 root root 0 4月 28 11:02 cgroup.event_control
-rw-r--r-- 1 root root 0 4月 28 11:02 cgroup.procs
-r--r--r-- 1 root root 0 4月 28 11:02 cgroup.sane_behavior
--w------- 1 root root 0 4月 28 11:02 devices.allow
--w------- 1 root root 0 4月 28 11:02 devices.deny
-r--r--r-- 1 root root 0 4月 28 11:02 devices.list
drwxr-xr-x 3 root root 0 4月 28 11:09 docker/
-rw-r--r-- 1 root root 0 4月 28 11:02 notify_on_release
-rw-r--r-- 1 root root 0 4月 28 11:02 release_agent
-rw-r--r-- 1 root root 0 4月 28 11:02 tasks
drwxr-xr-x 4 root root 0 4月 28 11:02 user/

cgroupから始まっているファイルは他のサブシステムと共通のファイル、devicesから始まっているファイルはdevicesサブシステム専用のファイルとなっています。それ以外の tasks、notify_on_release、release_agent の3つはどうやら歴史的な理由でプレフィックスが付いていないようです。これらに頼るのなら焼かれる覚悟をしておけって書いてありますけど、怖すぎるんですが。

  /*

* Historical crazy stuff. These don't have "cgroup." prefix and
* don't exist if sane_behavior. If you're depending on these, be
* prepared to be burned.
*/

{
.name = "tasks",
.flags = CFTYPE_INSANE, /* use "procs" instead */
.seq_start = cgroup_pidlist_start,
.seq_next = cgroup_pidlist_next,
.seq_stop = cgroup_pidlist_stop,
.seq_show = cgroup_pidlist_show,
.private = CGROUP_FILE_TASKS,
.write_u64 = cgroup_tasks_write,
.mode = S_IRUGO | S_IWUSR,
},
{
.name = "notify_on_release",
.flags = CFTYPE_INSANE,
.read_u64 = cgroup_read_notify_on_release,
.write_u64 = cgroup_write_notify_on_release,
},
{
.name = "release_agent",
.flags = CFTYPE_INSANE | CFTYPE_ONLY_ON_ROOT,
.seq_show = cgroup_release_agent_show,
.write_string = cgroup_release_agent_write,
.max_write_len = PATH_MAX,
},
{ } /* terminate */
};

前述のdevicesプレフィックスが付いた3つのファイルを使って各種アクセス制御を行います。

ファイル名
概要

devices.allow
アクセスを許可するデバイスを追加

devices.deny
アクセスを禁止するデバイスを追加

devices.list
現在のアクセス許可されているデバイスの状態を表示

Linux Kernel Updates Vol 2014.08の内容に沿ってこちらの環境でも動作確認してみます。まずはdevices.listの中身を見て現在のデバイスの状態を確認しておきます。

$ cat /sys/fs/cgroup/devices/devices.list

a *:* rwm ## 全てのデバイスにアクセス可能であることを示している、具体的な読み方は後述

dockerコンテナの内部ではどうなっているでしょう。

## dockerコンテナ起動前

$ ls /sys/fs/cgroup/devices/docker/
cgroup.clone_children cgroup.event_control cgroup.procs devices.allow devices.deny devices.list notify_on_release tasks
## コンテナ起動
$ sudo docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a4557c514ddc centos "/bin/bash" 9 weeks ago Exited (137) 4 seconds ago clever_hawking
$ sudo docker start clever_hawking
clever_hawking
## dockerコンテナ起動時に /sys/fs/cgroup/{サブシステム}/docker/{コンテナID} ディレクトリが作成される
$ ls /sys/fs/cgroup/devices/docker/
a4557c514ddc3c3668030fe9b1aac13b7ba8c5ea3fdaed70e345eb7991df364c/ cgroup.clone_children cgroup.event_control cgroup.procs devices.allow devices.deny devices.list notify_on_release tasks
## 同様にdevices.listの中身を見る
$ cat /sys/fs/cgroup/devices/docker/a4557c514ddc3c3668030fe9b1aac13b7ba8c5ea3fdaed70e345eb7991df364c/devices.list
c *:* m
b *:* m
c 5:1 rwm
c 4:0 rwm
c 4:1 rwm
c 136:* rwm
c 5:2 rwm
c 10:200 rwm
c 1:3 rwm
c 1:5 rwm
c 1:7 rwm
c 5:0 rwm
c 1:9 rwm
c 1:8 rwm

なにやらいろいろと制限されているようです。以下に読み方を整理します。


  • 先頭のアルファベット a: 全てのデバイス、b: ブロックデバイス、c: キャラクタデバイス

  • n:m (n:mはメジャー番号:マイナー番号 or ワイルドカード)は/devのノード番号

  • 末尾のアルファベット3つ r: 読み込み可能、w: 書き込み可能、m: 新規作成可能

となっています。つまり a : rwm は全てのデバイスへの操作が可能であることを示しています。そして上記の内容を読み解くと、まずは全てのキャラクタデバイスとブロックデバイスへの読み書きを禁止した後に、個々のキャラクタデバイスに必要に応じて権限を与えていることが読み取れます。/devのノード番号だけみてもわかりにくいですが、例えば 1:3 は /dev/null 、1:8 は /dev/random を差しています。このように各デバイスへの操作を制限することによってdockerコンテナ内のセキュリティを担保しているのですが、ここでは試しにその制限を解除してみます。

## 適当なdockerコンテナを作る

$ sudo docker run -it centos /bin/bash
## dockerコンテナ内のcgroupsの設定を確認
$ cat /sys/fs/cgroup/devices/docker/a3cdbc51ba62665e056fc438390dcf7e0ee3deae6e39312dfa41071d9cde2946/cgroup.procs
7811 ## 起動しているプロセスIDが表示される
$ ps 7811
PID TTY STAT TIME COMMAND
7811 pts/20 Ss+ 0:00 /bin/bash ## PID 7811はbashプロセス
## コンテナ内のbashプロセスから/dev/kmsgへの書き込みが制限されていることを確認
# mknod /dev/kmsg c 1 11
# echo aiueo > /dev/kmsg
bash: /dev/kmsg: Operation not permitted ## この時点では書き込みできない
## 制限を解除するには、ホストから別のcgroupにコンテナ内のプロセスを移す
$ cd /sys/fs/cgroup/devices
$ echo 7811 | sudo tee cgroup.procs
7811
## もう一度コンテナ内から/dev/kmsgに書き込みを試すと
# echo aiueo > /dev/kmsg ## 今度は成功
## ホストからカーネルバッファを確認
$ dmesg | tail -n 1
[ 6563.293512] aiueo ## コンテナ側から書き込んだ文字列が記録されている

また、devices.allowに明示的に/dev/kmsgへの読み書きを許可することもできます。

$ echo 'c 1:11 rwm' | sudo tee /sys/fs/cgroup/devices/docker/a3cdbc51ba62665e056fc438390dcf7e0ee3deae6e39312dfa41071d9cde2946/devices.allow

c 1:11 rwm

devicesサブシステムの動作確認ができました。他にもサブシステムはありますが動作確認はこれくらいにして、カーネルのソースコードも少しだけ見ておきたいと思います。


カーネルの実装

task_struct 構造体(Linuxプロセスを表現するデータ構造)のメンバに css_set というcgroupを管理するための構造体があります。

## include/linux/sched.h

#ifdef CONFIG_CGROUPS
/* Control Group info protected by css_set_lock */
struct css_set __rcu *cgroups;
/* cg_list protected by css_set_lock and tsk->alloc_lock */
struct list_head cg_list;
#endif

また、list_head 構造体はわかりやすいのでカーネルを読むときはこの辺りから慣れていくとと良いかもしれません。Doubly linked listの実装でカーネル内のあちこちに登場してきますので。css_set の中身は以下のようになっており、全てのタスクはこの css_set に対する参照カウント(のポインタ)を保持しています。

struct css_set {

/* Reference count */
atomic_t refcount;

/*
* List running through all cgroup groups in the same hash
* slot. Protected by css_set_lock
*/

struct hlist_node hlist;

/*
* List running through all tasks using this cgroup
* group. Protected by css_set_lock
*/

struct list_head tasks;

/*
* List of cg_cgroup_link objects on link chains from
* cgroups referenced from this css_set. Protected by
* css_set_lock
*/

struct list_head cg_links;

/*
* Set of subsystem states, one for each subsystem. This array
* is immutable after creation apart from the init_css_set
* during subsystem registration (at boot time) and modular subsystem
* loading/unloading.
*/

struct cgroup_subsys_state *subsys[CGROUP_SUBSYS_COUNT];

/* For RCU-protected deletion */
struct rcu_head rcu_head;
};

css_set はcgroup_subsys_stateという構造体の集合を保持していることがわかります。cssというのはcgroup_subysys_stateを略なんでしょう。tasks は前述の task_struct 構造体メンバの cg_list と(連結リストとして)繋がっており、これを辿ればcgroupsに属する全てタスクにアクセスできるようになっています。

/* Per-subsystem/per-cgroup state maintained by the system. */

struct cgroup_subsys_state {
/* the cgroup that this css is attached to */
struct cgroup *cgroup;

/* the cgroup subsystem that this css is attached to */
struct cgroup_subsys *ss;

/* reference count - access via css_[try]get() and css_put() */
struct percpu_ref refcnt;

/* the parent css */
struct cgroup_subsys_state *parent;

unsigned long flags;

/* percpu_ref killing and RCU release */
struct rcu_head rcu_head;
struct work_struct destroy_work;
};

// cgroup 構造体の一部
struct cgroup {
unsigned long flags; /* "unsigned long" so bitops work */

/*
* count users of this cgroup. >0 means busy, but doesn't
* necessarily indicate the number of tasks in the cgroup
*/

atomic_t count;

int id; /* ida allocated in-hierarchy ID */

/*
* We link our 'sibling' struct into our parent's 'children'.
* Our children link their 'sibling' into our 'children'.
*/

struct list_head sibling; /* my parent's children */
struct list_head children; /* my children */
struct list_head files; /* my files */

struct cgroup *parent; /* my parent */
struct dentry *dentry; /* cgroup fs entry, RCU protected */
... 長いので省略

cgroup_subsys_state 単体だけを見てもよくわかりませんが、css_set からコメント文も読みつつ cgroups 構造体まで辿ってくるとなんとなく構造が見えてきます。ここまでに前述した list_head がたくさん登場していますが、これを使ってcgroupsのヒエラルキー構造を表現していることが読めます。ちなみにRCUというのは排他制御機構 Read-Copy-Update の実装ですが、cgroupsでも多くの場所で活用されているようです。詳細はWikipediaが詳しいです。

ここまではcgroups自体の実装に関する部分で、サブシステムの実装はどうなっているのかわかりません。ここでは例としてdevicesサブシステムを少し覗いてみます。

/* security/device_cgroup.c */

// デバイスへのアクセス可否(ファイルパーミッション)をチェックする関数
static int __devcgroup_check_permission(short type, u32 major, u32 minor,
short access)
{
struct dev_cgroup *dev_cgroup;
struct dev_exception_item ex;
int rc;

memset(&ex, 0, sizeof(ex));
ex.type = type;
ex.major = major;
ex.minor = minor;
ex.access = access;

rcu_read_lock();
dev_cgroup = task_devcgroup(current);
rc = may_access(dev_cgroup, &ex, dev_cgroup->behavior);
rcu_read_unlock();

if (!rc)
return -EPERM;

return 0;
}

// dev_cgroup 構造体
struct dev_cgroup {
struct cgroup_subsys_state css;
struct list_head exceptions;
enum devcg_behavior behavior;
/* temporary list for pending propagation operations */
struct list_head propagate_pending;
};

// dev_exception_item 構造体
struct dev_exception_item {
u32 major, minor;
short type;
short access;
struct list_head list;
struct rcu_head rcu;
};

現在のタスク(プロセス)が所属するcgroupを取得(task_devcgroup関数)、前述したデバイスタイプと番号(c 5:1 とか)を指定してパーミッションのチェックを行っています(may_access関数)。cgroup.h/cにはサブシステム自体の実装は含まれておらず、cpuサブシステムならkernel/sched/ 、memoryサブシステムならmm/以下にあり、タスクスケジューラやメモリ管理などカーネルの主機能の中にサブシステムの実装も含まれている形です。

長くなったので今回はこの辺で。サブシステムの実装詳細については今後も興味のあるものから少しずつ読み進めていくつもりです。


参考


おまけ: Cscopeについて

効率良くLinuxカーネルのコードリーディングするためにCscopeというツールを利用しています。


Cscope is a developer's tool for browsing source code. It has an impeccable Unix pedigree, having been originally developed at Bell Labs back in the days of the PDP-11. Cscope was part of the official AT&T Unix distribution for many years, and has been used to manage projects involving 20 million lines of code!


## installation

## CentOS
$ sudo yum install cscope
## Ubuntu
$ sudo apt-get install cscope

使い方は以下のエントリーを参考にさせてもらいました。Linuxカーネルのソースコードは規模が大きいので、cscopeのデータベースは必ず転置インデックス付きで作成しておきましょう。また、Emacs使いであればhelmインタフェースからも利用できます。