Sat, 22 Jun
Docker を読む
Docker はひとつの Linux システムの上で、複数の Linux システムを動かすためのソフトウェアだ。システムの分離には Linux Containers (LXC) を、ファイルシステムまわりには Advanced multi layered unification filesystem (Aufs) をつかっている。
Docker は Go で書かれている。ソースコードは全体でだいたい15,000行で、そのうちおよそ 2/3 が本体、1/3 がテストとなっている。
% cat **/*.go | wc -l
14976
% cat $(ls **/*.go | grep -vi test.go) | wc -l
9797
% cat $(ls **/*.go | grep -i test.go) | wc -l
5179
%
Docker Init, Docker Daemon, Docker CLI
Docker が提供するコマンドは docker
ひとつだけだ。ただ実際には、docker コマンドは3つのコマンドが合体したものと呼べると思う。まずはじめに docker/docker.go
にある main
をみてみよう。
このように docker
は
- 自分自身が
/sbin/init
として呼び出されていたらdocker.SysInit (docker/sysinit.go)
- 引数に
-d
が指定されていたらdocker.daemon (docker/docker.go)
からdocker.NewServer (server.go)
とdocker.ListenAndServe (api.go)
- それ以外の場合は
docker.ParseCommands (commands.go)
を呼び出し、それぞれが全く別のことをしている。ここではこの3つを Docker Init, Docker Daemon, Docker CLI と呼ぶことにする。
Docker Init
docker が起動する LXC 環境 (ゲスト側) では、/sbin/init
がホスト側の /usr/bin/docker
にさしかわっている。
vagrant@precise64:~$ md5sum /usr/bin/docker
99fcfac50ead81bbd7937bd2655a248a /usr/bin/docker
vagrant@precise64:~$ docker run -i -t base /bin/bash
root@050875b37888:/# md5sum /sbin/init
99fcfac50ead81bbd7937bd2655a248a /sbin/init
root@050875b37888:/#
この環境にログインした状態のまま、ホスト側でプロセス一覧をみると
vagrant@precise64:~$ ps faxwww
...
1039 ? Ss 0:00 /bin/sh -e -c /usr/bin/docker -d /bin/sh
1040 ? Sl 1:07 \_ /usr/bin/docker -d
2952 pts/0 Ss 0:00 \_ lxc-start -n 050875b3788899738aedb3b7cb90a79b6927e8980e84fa933e1bd4973fa17f56 -f /var/lib/docker/containers/050875b3788899738aedb3b7cb90a79b6927e8980e84fa933e1bd4973fa17f56/config.lxc -- /sbin/init -g 172.16.42.1 -e TERM=xterm -e HOME=/ -e PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -- /bin/bash
2956 pts/0 S+ 0:00 \_ /bin/bash
...
vagrant@precise64:~$
lxc-start
経由で /sbin/init
が実行されている。lxc-start
が参照している config.lxc
には以下のようなエントリがあり、ゲストの /sbin/init
がホストの /usr/bin/docker
を指しているのがわかる。
# root filesystem
lxc.rootfs = /var/lib/docker/containers/050875b3788899738aedb3b7cb90a79b6927e8980e84fa933e1bd4973fa17f56/rootfs
...
# Inject docker-init
lxc.mount.entry = /usr/bin/docker /var/lib/docker/containers/050875b3788899738aedb3b7cb90a79b6927e8980e84fa933e1bd4973fa17f56/rootfs/sbin/init none bind,ro 0 0
/sbin/init
として呼び出されたときの docker
は docker.SysInit (sysinit.go)
を呼び出して
- 環境変数の設定
- ゲスト側で
ip(8)
を実行してゲートウェイを指定 - ユーザーが指定されていれば
syscall.Setuid
,syscall.Setgid
して/sbin/init
プロセスの権限を root より弱いものに変更する
といったお膳立てをした後に syscall.Exec
して指定されたプロセスに変身する。
というわけで、さきほどの /bin/bash
を指定したゲスト側でプロセス一覧をみると
root@050875b37888:/# ps ax
PID TTY STAT TIME COMMAND
1 ? S 0:00 /bin/bash
16 ? R+ 0:00 ps ax
root@050875b37888:/#
PID=1 が /bin/bash
になっているのがわかる。
Docker Daemon
-d
フラグつきで起動した docker
は、TCP ソケットか Unix ドメインソケットで待ち構えて、実際に lxc-start
などを実行するデーモンになる。
TCP ソケットでも、Unix ドメインソケット上でも、その上では HTTP を使う。Go の net.http.Server には TCP ソケット限定の ListenAndServe()
と、net.Listener
を引数にとる Serve(l net.Listener)
がある。docker.ListenAndServe (api.go)
では後者をつかっていて、二種類のソケットを同じようにあつかっている。
docker
の HTTP API はやや RESTful だ。参照系の動作は GET にあり、更新系の動作は POST, DELETE にある。ただ、全てを GET, POST, PUT, DELETE でモデリングすることはあまりがんばっていない。例えばコンテナまわりは kill, restart, start, stop, wait, resize, swap などの動詞をパスにふくめた POST リクエストを送ることで済ませている。なお、ルーティングには Gorilla の github.com/gorilla/mux を使っている。
api.go
では、リクエストのクエリパラメータやリクエストボディを読み込んで、docker.Server
のメソッド (Go らしくいうと docker.Server
型のメソッド群) を呼び出し、それを JSON で書き込む、ということをしている。
例えば GET /images/json
から呼び出される getImagesJSON (api.go)
はこんな感じ。
ひとつ面白いのが、読み込む部分ではいろいろ型を指定している一方で、書き込む部分では outs
を直接 json.Marshal
に渡せているところだ。
Go の struct には literal tag という、フィールドに文字列でアノテーションをつけられる機能がある。outs
は docker.APIImages (api_params.go)
の配列で、定義にはこんなタグがついている。
json.Marshal
はこれを読んで、APIImages
から JSON をつくっている。
docker の型
HTTP API から docker.Server
までたどり着いたところで、docker
内の型同士の関係をみてみよう。
Docker Daemon は起動すると docker.NewServer (server.go)
を呼び出す。この関数は docker.NewRuntime (runtime.go)
を呼び出して、docker.Runtime
を生成し、それを docker.Server
にいれて返す。docker.Server
と docker.Runtime
は 1:1 対応で、Docker Daemon ひとつにつきひとつしか存在しない。
docker.NewRuntime (runtime.go)
は docker.NewRuntimeFromDirectory (runtime.go)
を呼び出して、これが /var/lib/docker
に保存されているまざまな情報を読み込んでいる。
docker.Runtime
型のメンバである containers, graph, repositories, volumes は、それぞれ /var/lib/docker の下の containers ディレクトリ、graph ディレクトリ、repositories ファイル、volumes ディレクトリに対応している。
/var/lib/docker
ここで /var/lib/docker
以下のファイルの動きをみてみよう。例えば、こんなふうにゲスト環境で新しいファイルを作ると
vagrant@precise64:~$ docker run -i -t base /bin/bash root@9d243a1f805c:/# echo hello > /tmp/hello
root@9d243a1f805c:/#
ホスト側の /var/lib/docker/containers
にこんなファイルが出来る。ゲスト側のホスト名と 9d243a1f805c...
というのは対応している。
root@precise64:/home/vagrant# cat /var/lib/docker/containers/9d243a1f805c5d1ad4db5f4ea5c45832e9e8c958491b561e14560af41c62d8bf/rw/tmp/hello
hello
root@precise64:/home/vagrant#
さらに、この状態を「コミット」してみよう。
vagrant@precise64:~$ docker commit -m 'hello' 9d243a1f805c
655c847e5ea9
vagrant@precise64:~$
コミットすると、ホスト側の /var/lib/docker/graph
に 655c847e5ea9...
というディレクトリが出来ている。
root@precise64:/home/vagrant# ls /var/lib/docker/graph/655c847e5ea941e308db147a5d3ff390d48f88a6c25dcef18454dc6cadb8134f/
json layer layer.tar.xz
root@precise64:/home/vagrant# cat /var/lib/docker/graph/655c847e5ea941e308db147a5d3ff390d48f88a6c25dcef18454dc6cadb8134f/layer/tmp/hello
hello
root@precise64:/home/vagrant#
さらにゲスト側でファイルを変更してみる。
root@9d243a1f805c:/# echo hello world > /tmp/hello
root@9d243a1f805c:/#
containers 以下のファイルは更新されるが、先ほどコミットしたものは更新されない。
root@precise64:/home/vagrant# cat /var/lib/docker/containers/9d243a1f805c5d1ad4db5f4ea5c45832e9e8c958491b561e14560af41c62d8bf/rw/tmp/hello
hello world
root@precise64:/home/vagrant# cat /var/lib/docker/graph/655c847e5ea941e308db147a5d3ff390d48f88a6c25dcef18454dc6cadb8134f/layer/tmp/hello
hello
root@precise64:/home/vagrant#
さらにコミットしてみると
vagrant@precise64:~$ docker commit -m 'hello again' 9d243a1f805c
088dac52fd34
vagrant@precise64:~$
また、新しいコミット 088dac52fd34...
に対応したディレクトリができている。
root@precise64:/home/vagrant# cat /var/lib/docker/graph/088dac52fd34549c27ee5a42c10f1b5801acd54fd884da9cee064eff1017a6c9/layer/tmp/hello
hello world
root@precise64:/home/vagrant#
なお、この場合、二番目のコミットの親は一番目のコミットではなくて、ふたつのコミットは兄弟関係になってしまっているので注意が必要だ。
vagrant@precise64:~$ docker history 655c847e5ea9
ID CREATED CREATED BY
655c847e5ea9 4 hours ago /bin/bash
base:latest 12 weeks ago /bin/bash
27cf78414709 3 months ago
vagrant@precise64:~$ docker history 088dac52fd34
ID CREATED CREATED BY
088dac52fd34 4 hours ago /bin/bash
base:latest 12 weeks ago /bin/bash
27cf78414709 3 months ago
vagrant@precise64:~$
たとえば以下のように Dockerfile
を使えば、きちんとしたコミットグラフがつくられる。
vagrant@precise64:~$ cat Dockerfile
FROM base
RUN /bin/echo hello > /tmp/hello
RUN /bin/echo hello world > /tmp/hello
vagrant@precise64:~$ docker build .
Caching Context 6804/? (n/a)
Step 1 : FROM base
---> b750fe79269d
Step 2 : RUN /bin/echo hello > /tmp/hello
---> Running in 256cec7fd1f2
---> bd7b6baabec7
Step 3 : RUN /bin/echo hello world > /tmp/hello
---> Running in f3667d95f561
---> 30c63d3296c1
Successfully built 30c63d3296c1
vagrant@precise64:~$ docker history 30c63d3296c1
ID CREATED CREATED BY
30c63d3296c1 8 seconds ago /bin/sh -c /bin/echo hello world > /tmp/hello
bd7b6baabec7 8 seconds ago /bin/sh -c /bin/echo hello > /tmp/hello
base:latest 12 weeks ago /bin/bash
27cf78414709 3 months ago
vagrant@precise64:~$
docker commmit
それでは docker commit
の実際の実装をみてみよう。Docker CLI から commit を実行すると Docker Daemon 側では docker.Server
の ContainerCommit (server.go)
を呼び出し、これが docker.Builder
の Commit (builder.go)
を呼び出している。
rwTar
は Archive
型、といっても underlying type (基礎型) は io.Reader
で、tar -f - -C /var/lib/docker/containers/対応するコンテナ/rw
の標準出力がつながっている。
docker.Graph
の Create (graph.go)
はコミット ID を決めて、おなじメソッド群の Register
を呼び出す。ここでは、コミットに対応するディレクトリを作ったり、tar の標準出力から /var/lib/docker/graph/対応するコミット/layer
を作ったり、といったことをしている。
docker.Image
の Checksum
は、名前に似合わず tar -f - -C /var/lib/docker/graph/対応するコミット/layer -cJ
したり、その結果と /var/lib/docker/graph/対応するコミット/json
をつなげて、それの SHA256 メッセージダイジェストを計算したり、それを /var/lib/docker/graph/checksums
に書き込んだりと、いろいろ仕事をする重いメソッドだ。なのでここでは別の goroutine として実行して、その終了を待たずに return している。
Git を知っているひとだと、ここで img.Checksum()
の結果を待たないことを不思議に思うかもしれない。実は Docker のコミット ID は単なる乱数で、Git のように内容に基づいて計算されるわけではない。なので、この SHA256 メッセージダイジェストが決まる前に、コミット ID を決めて、それを Docker CLI に返すことができる。
Dockerfile の場合
docker.Graph
の Create (graph.go)
では img.Parent = container.Image
としていた。docker run
で立ち上げたコンテナで作業し続けた際に、コミットが兄弟関係になってしまったのは、このせいだ。
Dockerfile
の RUN
を使った場合は docker.buildFile
の CmdRun (buildfile.go)
が実行されて run (buildfile.go)
と commit (buildfile.go)
を順番に呼び出している。
run
は b.image
に対応するコンテナを作り
commit
は b.builder.Commit
が返した image
を b.image = image.ID
と設定する。
これで b.image
がつくる片方向リンクリストの始点 (Lisp でいうところの car) が書き変わるので、次の RUN
はこの新しいコミットを正しく指すようになる。
Docker CLI
/sbin/init
でも -d
フラグも無い場合は、docker
は Docker Daemon に HTTP リクエストを投げるクライアント、Docker CLI になる。
docker.ParseCommands (commands.go)
は引数から、リフレクションを使ってメソッドをとりだして、それを実行する。
コマンドのなかには、Docker Daemon 側の出力を読みながら結果を随時表示する必要があるもの (docker logs) や、Docker CLI 側で入力を随時 Docker Daemon に送り、結果も随時表示する必要があるもの (docker attach, docker run に -i を指定した場合) がある。
これに対応するため、Docker CLI には HTTP のプロトコルを一時的に抜ける方法が用意されている。
DockerCli
の hijack (commands.go)
は、net.http.httputil.ServerCon の Hijack を呼び出して、いままで HTTP で話すのにつかっていたコネクションから、生のソケットをとりだす。
とりだせたら、ソケットからの出力を標準出力に書き込む reciveStdout
, ホスト側の端末の標準入力をソケットに送り込む sendStdin
をそれぞれ goroutine として動かして、この二つの終了を待つ。
並列処理を goroutine で扱っているので、入出力を待って、それを読み書きして、ループをまわして、みたいな処理は必要ない。EOF までブロックする io.Copy
をそのまま呼び出せている。
まとめ
Docker の一部を読んでみました。
- Docker はゲスト側の
/sbin/init
, ホスト側でlxc-start
などを実行するデーモン、そのデーモンに指示を出す CLI の3つで構成されていて、すべての実装がdocker
コマンドにまとまっている。 - デーモンと CLI は (TCP ソケットでも Unix ドメインソケットでも) REST 風な HTTP で通信しているので、自分でクライアントを書くのも楽そう。すでに クライアントライブラリ はいろいろ開発されている。
- Aufs を使ったファイルシステムのレイヤリングを、Git のコミットグラフ風に管理しようとしているのが一番のキラーアイデアだと思う。
LXC と Aufs を叩くだけのラッパーに Go を使う理由があるのかは少し疑問だったんだけど
- /sbin/init としてさしこむときに1ファイルにまとめられる (Ruby などより良い)
- 起動が速いので CLI としても使える (Java より良い)
- 処理のなかで「これはこの操作に付随して実行しなくてはいけないけど、べつに結果を待つ必要はないな」「これは同時に実行して、両方が終わるのを待とう」みたいなことを、goroutine できれいに書ける
など、ちょっと読んだだけでも Go の利点を活かせている部分がいくつかあった。変数に型もつけられるし (List はキャスト必須でむかしの Java みたいだけど) 結構よい言語なのかもしれない。