🐳

「コンテナセキュリティ」を読みながらミニ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