LoginSignup
32
16

More than 3 years have passed since last update.

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

Posted at

概要

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

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

なお、完成したコードは GitHub レポジトリに置かれています。

コンテナとは何か

コンテナと仮想マシン (VM) の違いを説明する際に、よく次のような図が使われます。
docker-containerized-and-vm-transparent-bg.png
Docker 公式サイトより引用)

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

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

Linux カーネルとは何か

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

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

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

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

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

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

ゼロからのコンテナ実装

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

コードを実行するためには、 Go コンパイラがインストールされた Linux 環境が必要です。 GitHub レポジトリに含まれている 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 は、現在実行中のプロセス(と子プロセス)のルートディレクトリを変更します。そのディレクトリより上の階層にはアクセスできず存在を認識することもできない状態になるため、俗に chroot 監獄と呼ばれます。

chroot.png

GitHub レポジトリの chroot ブランチ に、 chroot を使ったコンテナもどきのコード例があります。これはルートディレクトリを ./rootfs に変更した上で、与えられた引数をコマンドとして実行します。

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 です。下記のコマンドを打ち込むと、任意の 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 は、マウントファイルシステムや 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, setns, unshare などでフラッグを指定します。

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

Go 言語で Linux namespace を設定するには、 Cmd 構造体の SysProcAttrCloneflags をセットします。実際に Mount, PID, UTS namespace を使ってコンテナを作成する例が GitHub レポジトリの namespace ブランチ にあります。

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 ブランチ です。

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

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

コンテナの標準仕様

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

仕様 代表的な実装
OCI Runtime Specification runc
OCI Image Format Specification containerd

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

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

docker.png

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

まとめ

  • コンテナは Linux カーネルの機能によって隔離された特殊なプロセス
  • chroot: ルートファイルシステムを隔離
  • namespace: PID、ファイルマウント、ホスト名など様々なグローバルリソースを隔離
  • コンテナに関する重要な標準仕様

参考リンク

32
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
32
16