「コンテナセキュリティ」を読みながらミニDockerをつくる
コンテナセキュリティ コンテナ化されたアプリケーションを保護する要素技術 - インプレスブックス
本書をある程度読み進めてみて、コンテナをホストから分離する仕組みであるcgroupとnamespaceを学んだ。
cgroupはリソースの利用を制限する仕組みで、例えばコンテナが利用できるメモリの容量や、プロセスの数を制限する、といった事ができる。
対して、namespaceはリソースを分離する仕組みで、namespaceの外にあるプロセスや、マウントされたデバイスを見えなくする。
ここまで来ると、「ミニDocker」を自分で作れるんじゃないか、と思ったので試してみた。
実際、著者もGitHubで同じことをやっている。
lizrice/containers-from-scratch: Writing a container in a few lines of Go code, as seen at DockerCon 2017 and on O'Reilly Safari
とりあえず下記ができる状態を目指すことにした。
- UTS namespaceを使って、ホスト名を分離する
- PID namespaceを使って、プロセスを分離する
- NS 名前空間を使って、マウントされたデバイスを分離する
- シェルが使える
手順としては、たぶんこんな感じ。
-
unshare
を呼び出してnamespaceを作る -
chroot
を呼び出して、ルートディレクトリを変更する - シェルを起動する
といわけで、こういうコードを書いていたんだけど、これはうまくいかなかった。
func main() {
must(syscall.Unshare(syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS))
must(syscall.Chroot("./rootfs"))
must(os.Chdir("/"))
// /proc ファイルシステムをマウント
must(syscall.Mount("proc", "proc", "proc", 0, ""))
fmt.Println("Entering new namespace with PID 1...")
// bash を起動 (PID 1として動作)
cmd := exec.Command("/bin/bash")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
must(cmd.Run())
// 終了後 /proc をアンマウント (クリーンアップ)
must(syscall.Unmount("/proc", 0))
fmt.Println("Exiting...")
}
ちなみに、procファイルシステムをマウントするとき、
mount
の第一引数は何でも良いらしい。
エラー内容はこんな感じ。
Entering new namespace with PID 1...
panic: fork/exec /bin/bash: cannot allocate memory
goroutine 1 [running]:
main.must(...)
/home/musou1500/code/mydocker/main.go:12
main.main()
/home/musou1500/code/mydocker/main.go:61 +0x1ee
詳細はわかっていないけど、これはPID namespaceの仕様によるものらしい。
というのも、unshare
したプロセス自体は新しいPID namespaceには属さず、そのプロセスで最初にclone、あるいはforkされたプロセス(要は最初に作られたプロセス)が新しいPID namespaceに属する、という仕様がある。
というわけで、下記の呼び出しでマウントされるprocファイルシステムは、新しいPID namespaceを反映していない。
must(syscall.Mount("proc", "/proc", "proc", 0, ""))
これを回避するためには、新しいプロセスを作って、その中でprocファイルシステムをマウントする必要がある。
この処理を書くのにはC言語の方が楽だったので、C言語で書き直した。
これはうまく動いた。
#define _GNU_SOURCE
#include <err.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/mount.h>
int main(int argc, char *argv[])
{
if (chroot("./rootfs_alpine") == -1)
err(EXIT_FAILURE, "chroot");
if (chdir("/") == -1)
err(EXIT_FAILURE, "chdir");
if (unshare(CLONE_NEWPID | CLONE_NEWNS) == -1)
err(EXIT_FAILURE, "unshare");
int pid = fork();
if (pid == -1)
err(EXIT_FAILURE, "fork");
if (pid == 0) {
if (mount("proc", "proc", "proc", 0, NULL) == -1) {
err(EXIT_FAILURE, "mount");
}
if (execvp(argv[1], &argv[1]) == -1) {
err(EXIT_FAILURE, "execvp");
}
} else {
int status;
wait(&status);
exit(WEXITSTATUS(status));
}
}
このコードだと、 unshare
を呼び出して、 プロセスをフォークした後に、子プロセスで mount
を呼び出しており、ここでマウントされるprocプロセスは新しいPID namespaceを反映しているのでうまくいく。
$ sudo ./a.out /bin/ash
/ # ps
PID USER TIME COMMAND
1 root 0:00 /bin/ash
3 root 0:00 ps
と、ここまで書いておいて何だけど、実はこの処理、シェルスクリプト2行で同じことができる。
$ chroot ./rootfs
$ unshare -mpf --mount-proc /bin/bash
Discussion