Docker の仕組み 〜 コンテナを 60 行で実装する

# 概要
本記事では、普段 Docker をブラックボックスとして扱っている方々を対象として、コンテナが動く仕組みを低レイヤーから解説します。

そのために Go 言語を使って、ゼロからコンテナを実装し動かしながら学ぶアプローチを取ります。コンテナの基礎的な原理は意外にも簡単で、この記事の最後に出来上がるコードは僅か 60 行ほどです。

なお、完成したコードは [GitHub レポジトリ](https://github.com/swen128/minimum-container/)に置かれています。

# コンテナとは何か
コンテナと仮想マシン (VM) の違いを説明する際に、よく次のような図が使われます。
![docker-containerized-and-vm-transparent-bg.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142066/15445f7d-de50-7639-9ec6-2dc181ad8946.png)
（[Docker 公式サイト](https://www.docker.com/resources/what-container#/package_software)より引用）

VM とコンテナを比較した時の最大の特徴は、一つ一つのコンテナを作る際にゲスト OS を起動しないことです。
コンテナは全て、同じホスト OS の中で動くプロセスとして存在します。

しかし当然ながら、通常のプロセスはファイルなどのリソースを他のプロセスと共有しており、環境依存性を強く持ちます。
そこで、プロセスを論理的に隔離された状態で動かすために、 **Linux カーネル** の持つ chroot や namespace などの機能を利用します。これにより **隔離されたプロセス** のことをコンテナと呼びます。


# Linux カーネルとは何か
カーネルとは、文字通り OS の中核に当たる重要な部分です。
Linux マシンを次のような 3 層構造と捉えた時、カーネルはちょうど中間に位置します。

- **ハードウェア** : メモリや CPU などの物理デバイス
- **Linux カーネル**
- **ユーザープロセス** : シェルやエディターなど、ほぼ全てのプログラム

カーネルはハードウェアを直接操作できる特権を持ち、メモリーやプロセスの管理、デバイスドライバーなどの仕事を行います。

一方で、ユーザープロセスはハードウェアに対するアクセスが大きく制限されています。そのため、ファイル操作やプロセス作成などを実行するには、**システムコール**を通じてカーネルに依頼しなければなりません。

コンテナを作成するプログラムを実装する際にも、 chroot や namespace などを利用するためにシステムコールを多用します。

特に Go 言語のコードでシステムコールを行う場合には、公式パッケージ [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) を使うのが標準的です。

# ゼロからのコンテナ実装
以降では Go 言語のプログラムで実際にコンテナを作成します。

コードを実行するためには、 Go コンパイラがインストールされた Linux 環境が必要です。 [GitHub レポジトリ](https://github.com/swen128/minimum-container/)に含まれている `docker-compose.yml` ファイルを使えば、環境構築の手間無しですぐに試すことができます。

```
$ git clone 
$ cd minimum-container
$ docker-compose run app

root@linux-env:/work_dir# go run main.go run sh
```


## chroot
[chroot](https://linux.die.net/man/2/chroot) は、現在実行中のプロセス（と子プロセス）のルートディレクトリを変更します。そのディレクトリより上の階層にはアクセスできず存在を認識することもできない状態になるため、俗に **chroot 監獄**と呼ばれます。

![chroot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142066/89b695e4-7e70-71b3-421c-694ae95a947a.png)


[GitHub レポジトリの chroot ブランチ](https://github.com/swen128/minimum-container/blob/chroot/main.go) に、 chroot を使ったコンテナもどきのコード例があります。これはルートディレクトリを `./rootfs` に変更した上で、与えられた引数をコマンドとして実行します。

```go:main.go
// 隔離されたプロセスの中で cmd を引数 arg と共に実行
func execute(cmd string, args ...string) {
	// ルートディレクトリとカレントディレクトリを ./rootfs に設定
	unix.Chroot("./rootfs")
	unix.Chdir("/")

	command := exec.Command(cmd, args...)
	command.Stdin = os.Stdin
	command.Stdout = os.Stdout
	command.Stderr = os.Stderr

	command.Run()
}
```

早速この `main.go` を実行してみると、次のようなエラーが発生するはずです。

```
$ go run main.go run sh
panic: exec: "sh": executable file not found in $PATH
```

これは、まだ `./rootfs` に何もファイルが入っていないために起きるエラーです。 chroot 実行後のコンテナ内では、ルートディレクトリが空の状態と同然のため、 `sh` のバイナリすら有りません。

そこで便利なのが [docker export](https://docs.docker.com/engine/reference/commandline/export/) です。下記のコマンドを打ち込むと、任意の Docker イメージに含まれる全ファイルを `./rootfs` の下に展開することができます。

```
$ docker export $(docker create <イメージ>) | tar -C rootfs -xvf -
```

`.rootfs` にファイルを用意した状態で、改めてコンテナを実行してみましょう。 `ls` コマンドを使ったり、ファイルを作成したりして、コンテナ内の `/` ディレクトリがホストの `rootfs` ディレクトリとリンクしていることを確かめてみてください。

```
root@linux-env:/work_dir# go run main.go run sh

/ # ls /
bin   dev   etc   home  proc  root  sys   tmp   usr   var
/ # touch /tmp/hoge
/ # exit

root@linux-env:/work_dir# ls rootfs/tmp
hoge
```

## namespace
[Linux namespace](https://man7.org/linux/man-pages/man7/namespaces.7.html) は、マウントファイルシステムや PID など諸々のリソースを隔離できる機能です。

この機能の必要性を理解するために、前節で作成したコンテナもどきの中で `ps` コマンドを実行してみましょう。

```
root@linux-host:/work_dir# go run main.go run ps
PID   USER     TIME  COMMAND
```

結果は何も表示されないはずです。その原因は `ps` コマンドが `/proc` ディレクトリを参照していることにあります。通常 `/proc` ディレクトリには、プロセス情報などを取得できる特殊な擬似ファイルシステムがマウントされていますが、コンテナもどきの中ではルートディレクトリを変更しているので `/proc` にはまだ何も有りません。

事前に `/proc` ディレクトリをマウントして `ps` を再度実行してみましょう。

```
root@linux-host:/work_dir# go run main.go run sh

/ # mount proc /proc -t proc
/ # ps
PID   USER     TIME  COMMAND
    1 root      0:00 bash
  100 root      0:00 go run main.go run sh
  154 root      0:00 /tmp/go-build474892034/b001/exe/main run sh
  160 root      0:00 sh
  163 root      0:00 ps
```

ここで問題が二つ生じます。一つはコンテナ外で動いているプロセス (PID 1, 100, 154) が見えている点、もう一つは、コンテナ内で設定したマウントがホストにも反映される点です。これでは外部環境からの隔離が充分とは言えません。

```
root@linux-host:/work_dir# cat /proc/mounts | grep proc
proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0
proc /work_dir/rootfs/proc proc rw,relatime 0 0        <- コンテナ内で追加した proc マウント
```

Linux namespace を使うと、リソースの名前空間をプロセス単位で別々に設定することができます。異なる名前空間に属するリソースは見ることも操作することもできないため、前述の問題が解決されます。

記事執筆時点で Linux namespace は 8 種類存在し、システムコールの [clone](https://man7.org/linux/man-pages/man2/clone.2.html), [setns](https://man7.org/linux/man-pages/man2/setns.2.html), [unshare](https://man7.org/linux/man-pages/man2/unshare.2.html) などでフラッグを指定します。


| 名前空間 | フラッグ            | 隔離されるリソース |
|-----------|-----------------|----------|
| Mount     | CLONE_NEWNS     | ファイルシステムのマウントポイント         |
| PID       | CLONE_NEWPID    | PID         |
| UTS       | CLONE_NEWUTS    | ホスト名      |
| Network   | CLONE_NEWNET    | ネットワークデバイスやポートなど |
| Time      | CLONE_NEWTIME   | [clock_gettime](https://linux.die.net/man/2/clock_gettime) で取得できる時刻 (monotonic, boot) |
| IPC       | CLONE_NEWIPC    | プロセス間通信 |
| Cgroup    | CLONE_NEWCGROUP | cgroup ルートディレクトリ |
| User      | CLONE_NEWUSER   | UID, GID |

Go 言語で Linux namespace を設定するには、 `Cmd` 構造体の `SysProcAttr` に `Cloneflags` をセットします。実際に Mount, PID, UTS namespace を使ってコンテナを作成する例が [GitHub レポジトリの namespace ブランチ](https://github.com/swen128/minimum-container/blob/namespace/main.go) にあります。

```go:main.go
func execute(cmd string, args ...string) {
	unix.Chroot("./rootfs")
	unix.Chdir("/")

	command := exec.Command(cmd, args...)
	command.Stdin = os.Stdin
	command.Stdout = os.Stdout
	command.Stderr = os.Stderr

	// Linux namespace を設定
	command.SysProcAttr = &unix.SysProcAttr{
		Cloneflags: unix.CLONE_NEWNS | unix.CLONE_NEWPID | unix.CLONE_NEWUTS,
	}

	command.Run()
}
```

このコードで改めてコンテナを作成し、先程と同様に `ps` を実行すると、コンテナ内のプロセスだけが表示されることを確認できます。

```
root@linux-host:/work_dir# go run main.go run sh

/ # mount proc /proc -t proc
/ # ps
PID   USER     TIME  COMMAND
    1 root      0:00 sh
    4 root      0:00 ps
```

また、 UTS namespace によって、コンテナ内でホスト名を変更しても外部に影響しなくなりました。

```
root@linux-host:/work_dir# go run main.go run sh

/ # hostname my-container
/ # hostname
my-container
/ # exit

root@linux-host:/work_dir# hostname
linux-host
```

## コンテナの初期化
前節では、コンテナを立ち上げた後に手動で `/proc` のマウントやホスト名の設定をしていました。このままでは不便なので、コンテナ作成と同時にこれらの初期化処理も行うようにプログラムを変更しましょう。

ここで問題となるのが、初期化を実行するタイミングです。コンテナ作成は

1. namespace を設定した子プロセスを作成
2. 子プロセスを初期化 (`/proc` マウントなど)
3. ユーザー指定のコマンド (`sh` など) を実行

という順序で行う必要がありますが、 1. と 3. の間に割り込めるフックなどは存在しません。そこで、 2. と 3. を両方とも実行するコードを書き、 namespace を設定したプロセス上でそのコードを実行します。

実装例は [GitHub レポジトリの reexec ブランチ](https://github.com/swen128/minimum-container/blob/reexec/main.go) です。

```go:main.go
// コマンドライン引数の処理
// go run main.go run <cmd> <args>
func main() {
	switch os.Args[1] {
	case "run":
		initialize(os.Args[2:]...)
	case "child":
		execute(os.Args[2], os.Args[3:]...)
	default:
		panic("コマンドライン引数が正しくありません。")
	}
}

// Linux namespace を設定した子プロセスで、execute 関数を実行する
func initialize(args ...string) {
	// このプログラム自身に引数 child <cmd> <args> を渡す
	arg := append([]string{"child"}, args...)
	command := exec.Command("/proc/self/exe", arg...)

	command.Stdin = os.Stdin
	command.Stdout = os.Stdout
	command.Stderr = os.Stderr

	command.SysProcAttr = &unix.SysProcAttr{
		Cloneflags: unix.CLONE_NEWNS | unix.CLONE_NEWPID | unix.CLONE_NEWUTS,
	}

	command.Run()
}

// namespace 設定後の初期化処理と、ユーザー指定のコマンドを実行する
func execute(cmd string, args ...string) {
	// ルートディレクトリとカレントディレクトリを ./rootfs に設定
	unix.Chroot("./rootfs")
	unix.Chdir("/")

	unix.Mount("proc", "proc", "proc", 0, "")
	unix.Sethostname([]byte("my-container"))

	command := exec.Command(cmd, args...)
	command.Stdin = os.Stdin
	command.Stdout = os.Stdout
	command.Stderr = os.Stderr

	command.Run()
}
```

一つの実行ファイルで完結させるために、少しトリッキーな方法を使っています。ポイントは  `initialize` 関数の中で `/proc/self/exe` をコマンドとして実行している部分です。`/proc/self/exe` も proc ファイルシステムの一部で、現在のプロセスの実行ファイルのパスを返します。これを利用して、プログラムが自分自身を再帰的に実行することができます。


上記コード実行時の流れを順に追っていくと

1. コマンド `go run main.go run <cmd> <args>` を実行
1. main.go が実行され `initialize` 関数に分岐
1. namespace を設定したプロセスを作成
    1. コマンド `/proc/self/exe init <cmd> <args>` を実行
    1. main.go が実行され `execute` 関数に分岐
    1.  `/proc` マウントなどの初期化処理を実行
    1. プロセスを作成
        1. ユーザー指定のコマンドを実行

この時、ユーザーコマンド実行のために作られる孫プロセスにも ルートディレクトリと namespace の設定が継承され、コンテナとして機能します。

# コンテナの標準仕様
以上でコンテナの基礎に当たる機能を実装できましたが、まだ欠けている部分がたくさんあります。この記事で全てを詳細に説明することはできませんが、大まかな全体像を伝えるために重要な標準仕様を 2 つ紹介します。

| 仕様 | 代表的な実装 |
|-----|------------|
| [OCI Runtime Specification](https://github.com/opencontainers/runtime-spec) | [runc](https://github.com/opencontainers/runc) |
| [OCI Image Format Specification](https://github.com/opencontainers/image-spec) | [containerd](https://containerd.io/) |

OCI Runtime Spec はコンテナのライフサイクルと **filesystem bundle** のフォーマットを規定します。filesystem bundle とは、コンテナの各種設定値を記載した `config.json` と、ルートファイルシステムとなる `rootfs` ディレクトリをまとめて tar アーカイブにしたものです。

一方で OCI Image Spec は、コンテナイメージのフォーマットと、イメージを filesystem bundle に変換する方法を規定します。イメージとは、 Dockerfile をビルドして得られるお馴染みのあのイメージのことです。

![docker.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142066/00572c13-f9e5-eca8-2c9a-817818606eac.png)

filesystem bundle に `rootfs` ディレクトリが含まれることから推測できるように、この記事で実装したのは OCI Runtime Spec の触りの部分に当たります。 OCI Image Spec やその他の要素にはノータッチなので、興味のある方はさらに詳しく調べてみることをお勧めします。

# まとめ
- コンテナは Linux カーネルの機能によって隔離された特殊なプロセス
- chroot: ルートファイルシステムを隔離
- namespace: PID、ファイルマウント、ホスト名など様々なグローバルリソースを隔離
- コンテナに関する重要な標準仕様
    - [OCI Runtime Specification](https://github.com/opencontainers/runtime-spec)
    - [OCI Image Format Specification](https://github.com/opencontainers/image-spec)
    - 本記事と関連が深いのは runtime spec

# 参考リンク
- [Building a container from scratch in Go](https://youtu.be/Utf-A4rODH8)
- [Namespaces in Go](https://medium.com/@teddyking/linux-namespaces-850489d3ccf)
- [Docker components explained](http://alexander.holbreich.org/docker-components-explained/)
- [An Introduction to Container Runtimes](https://www.ianlewis.org/en/container-runtimes-part-1-introduction-container-r)