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

3つの構成要素

Docker が提供するコマンドは docker ひとつだけだ。ただ実際には、docker コマンドは3つのコマンドが合体したものと呼べると思う。まずはじめに docker/docker.go にある main をみてみよう。

func main() {
    if utils.SelfPath() == "/sbin/init" {
        // Running in init mode
        docker.SysInit()
        return
    }
    // FIXME: Switch d and D ? (to be more sshd like)
    flDaemon := flag.Bool("d", false, "Daemon mode")
    flDebug := flag.Bool("D", false, "Debug mode")

    ...

    if *flDaemon {
        if flag.NArg() != 0 {
            flag.Usage()
            return
        }
        if err := daemon(*pidfile, flHosts, *flAutoRestart, *flEnableCors, *flDns); err != nil {
            log.Fatal(err)
            os.Exit(-1)
        }
    } else {
        if len(flHosts) > 1 {
            log.Fatal("Please specify only one -H")
            return
        }
        protoAddrParts := strings.SplitN(flHosts[0], "://", 2)
        if err := docker.ParseCommands(protoAddrParts[0], protoAddrParts[1], flag.Args()...); err != nil {
            log.Fatal(err)
            os.Exit(-1)
        }
    }
}

このように 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 として呼び出されたときの dockerdocker.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) では後者をつかっていて、二種類のソケットを同じようにあつかっている。

func ListenAndServe(proto, addr string, srv *Server, logging bool) error {
    log.Printf("Listening for HTTP on %s (%s)\n", addr, proto)

    r, err := createRouter(srv, logging)
    if err != nil {
        return err
    }
    l, e := net.Listen(proto, addr)
    if e != nil {
        return e
    }
    //as the daemon is launched as root, change to permission of the socket to allow non-root to connect
    if proto == "unix" {
        os.Chmod(addr, 0777)
    }
    httpSrv := http.Server{Addr: addr, Handler: r}
    return httpSrv.Serve(l)
}

docker の HTTP API はやや RESTful だ。参照系の動作は GET にあり、更新系の動作は POST, DELETE にある。ただ、全てを GET, POST, PUT, DELETE でモデリングすることはあまりがんばっていない。例えばコンテナまわりは kill, restart, start, stop, wait, resize, swap などの動詞をパスにふくめた POST リクエストを送ることで済ませている。なお、ルーティングには Gorillagithub.com/gorilla/mux を使っている。

api.go では、リクエストのクエリパラメータやリクエストボディを読み込んで、docker.Server のメソッド (Go らしくいうと docker.Server 型のメソッド群) を呼び出し、それを JSON で書き込む、ということをしている。

例えば GET /images/json から呼び出される getImagesJSON (api.go) はこんな感じ。

func getImagesJSON(srv *Server, version float64, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
    if err := parseForm(r); err != nil {
        return err
    }

    all, err := getBoolParam(r.Form.Get("all"))
    if err != nil {
        return err
    }
    filter := r.Form.Get("filter")

    outs, err := srv.Images(all, filter)
    if err != nil {
        return err
    }
    b, err := json.Marshal(outs)
    if err != nil {
        return err
    }
    writeJSON(w, b)
    return nil
}

ひとつ面白いのが、読み込む部分ではいろいろ型を指定している一方で、書き込む部分では outs を直接 json.Marshal に渡せているところだ。

Go の struct には literal tag という、フィールドに文字列でアノテーションをつけられる機能がある。outsdocker.APIImages (api_params.go) の配列で、定義にはこんなタグがついている。

type APIImages struct {
    Repository  string `json:",omitempty"`
    Tag         string `json:",omitempty"`
    ID          string `json:"Id"`
    Created     int64
    Size        int64
    VirtualSize int64
}

json.Marshal はこれを読んで、APIImages から JSON をつくっている。

docker の型

HTTP API から docker.Server までたどり着いたところで、docker 内の型同士の関係をみてみよう。

docker の型

Docker Daemon は起動すると docker.NewServer (server.go) を呼び出す。この関数は docker.NewRuntime (runtime.go) を呼び出して、docker.Runtime を生成し、それを docker.Server にいれて返す。docker.Serverdocker.Runtime は 1:1 対応で、Docker Daemon ひとつにつきひとつしか存在しない。

docker.NewRuntime (runtime.go)docker.NewRuntimeFromDirectory (runtime.go) を呼び出して、これが /var/lib/docker に保存されているまざまな情報を読み込んでいる。

func NewRuntime(autoRestart bool, dns []string) (*Runtime, error) {
    runtime, err := NewRuntimeFromDirectory("/var/lib/docker", autoRestart)
    ...
    return runtime, nil
}

func NewRuntimeFromDirectory(root string, autoRestart bool) (*Runtime, error) {
    runtimeRepo := path.Join(root, "containers")

    if err := os.MkdirAll(runtimeRepo, 0700); err != nil && !os.IsExist(err) {
        return nil, err
    }

    g, err := NewGraph(path.Join(root, "graph"))
    if err != nil {
        return nil, err
    }
    volumes, err := NewGraph(path.Join(root, "volumes"))
    if err != nil {
        return nil, err
    }
    repositories, err := NewTagStore(path.Join(root, "repositories"), g)
    if err != nil {
        return nil, fmt.Errorf("Couldn't create Tag store: %s", err)
    }
    ...
    return runtime, nil
}

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/graph655c847e5ea9... というディレクトリが出来ている。

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.ServerContainerCommit (server.go) を呼び出し、これが docker.BuilderCommit (builder.go) を呼び出している。

func (builder *Builder) Commit(container *Container, repository, tag, comment, author string, config *Config) (*Image, error) {
    // FIXME: freeze the container before copying it to avoid data corruption?
    // FIXME: this shouldn't be in commands.
    rwTar, err := container.ExportRw()
    if err != nil {
        return nil, err
    }
    // Create a new image from the container's base layers + a new layer from container changes
    img, err := builder.graph.Create(rwTar, container, comment, author, config)
    if err != nil {
        return nil, err
    }
    ...

rwTarArchive 型、といっても underlying type (基礎型) は io.Reader で、tar -f - -C /var/lib/docker/containers/対応するコンテナ/rw の標準出力がつながっている。

docker.GraphCreate (graph.go) はコミット ID を決めて、おなじメソッド群の Register を呼び出す。ここでは、コミットに対応するディレクトリを作ったり、tar の標準出力から /var/lib/docker/graph/対応するコミット/layer を作ったり、といったことをしている。

// Create creates a new image and registers it in the graph.
func (graph *Graph) Create(layerData Archive, container *Container, comment, author string, config *Config) (*Image, error) {
    img := &Image{
        ID:            GenerateID(),
        Comment:       comment,
        Created:       time.Now(),
        DockerVersion: VERSION,
        Author:        author,
        Config:        config,
        Architecture:  "x86_64",
    }
    if container != nil {
        img.Parent = container.Image
        img.Container = container.ID
        img.ContainerConfig = *container.Config
    }
    if err := graph.Register(layerData, layerData != nil, img); err != nil {
        return nil, err
    }
    go img.Checksum()
    return img, nil
}

docker.ImageChecksum は、名前に似合わず 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.GraphCreate (graph.go) では img.Parent = container.Image としていた。docker run で立ち上げたコンテナで作業し続けた際に、コミットが兄弟関係になってしまったのは、このせいだ。

DockerfileRUN を使った場合は docker.buildFileCmdRun (buildfile.go) が実行されて run (buildfile.go)commit (buildfile.go) を順番に呼び出している。

func (b *buildFile) CmdRun(args string) error {
    ...
    cid, err := b.run()
    if err != nil {
        return err
    }
    if err := b.commit(cid, cmd, "run"); err != nil {
        return err
    }
    b.config.Cmd = cmd
    return nil
}

runb.image に対応するコンテナを作り

func (b *buildFile) run() (string, error) {
    if b.image == "" {
        return "", fmt.Errorf("Please provide a source image with `from` prior to run")
    }
    b.config.Image = b.image

    // Create the container and start it
    c, err := b.builder.Create(b.config)

    ...

    return c.ID, nil
}

commitb.builder.Commit が返した imageb.image = image.ID と設定する。

// Commit the container <id> with the autorun command <autoCmd>
func (b *buildFile) commit(id string, autoCmd []string, comment string) error {
    ...

    container := b.runtime.Get(id)
    if container == nil {
        return fmt.Errorf("An error occured while creating the container")
    }

    // Note: Actually copy the struct
    autoConfig := *b.config
    autoConfig.Cmd = autoCmd
    // Commit the container
    image, err := b.builder.Commit(container, "", "", "", b.maintainer, &autoConfig)
    if err != nil {
        return err
    }
    b.tmpImages[image.ID] = struct{}{}
    b.image = image.ID
    return nil
}

これで b.image がつくる片方向リンクリストの始点 (Lisp でいうところの car) が書き変わるので、次の RUN はこの新しいコミットを正しく指すようになる。

Docker CLI

/sbin/init でも -d フラグも無い場合は、docker は Docker Daemon に HTTP リクエストを投げるクライアント、Docker CLI になる。

docker.ParseCommands (commands.go) は引数から、リフレクションを使ってメソッドをとりだして、それを実行する。

func (cli *DockerCli) getMethod(name string) (reflect.Method, bool) {
    methodName := "Cmd" + strings.ToUpper(name[:1]) + strings.ToLower(name[1:])
    return reflect.TypeOf(cli).MethodByName(methodName)
}

func ParseCommands(proto, addr string, args ...string) error {
    cli := NewDockerCli(proto, addr)

    if len(args) > 0 {
        method, exists := cli.getMethod(args[0])
        if !exists {
            fmt.Println("Error: Command not found:", args[0])
            return cli.CmdHelp(args[1:]...)
        }
        ret := method.Func.CallSlice([]reflect.Value{
            reflect.ValueOf(cli),
            reflect.ValueOf(args[1:]),
        })[0].Interface()
        if ret == nil {
            return nil
        }
        return ret.(error)
    }
    return cli.CmdHelp(args...)
}

コマンドのなかには、Docker Daemon 側の出力を読みながら結果を随時表示する必要があるもの (docker logs) や、Docker CLI 側で入力を随時 Docker Daemon に送り、結果も随時表示する必要があるもの (docker attach, docker run に -i を指定した場合) がある。

これに対応するため、Docker CLI には HTTP のプロトコルを一時的に抜ける方法が用意されている。

func (cli *DockerCli) CmdRun(args ...string) error {
    ...

    if !config.AttachStdout && !config.AttachStderr {
        fmt.Println(out.ID)
    } else {
        if config.Tty {
            cli.monitorTtySize(out.ID)
        }

        v := url.Values{}
        v.Set("logs", "1")
        v.Set("stream", "1")

        if config.AttachStdin {
            v.Set("stdin", "1")
        }
        if config.AttachStdout {
            v.Set("stdout", "1")
        }
        if config.AttachStderr {
            v.Set("stderr", "1")
        }
        if err := cli.hijack("POST", "/containers/"+out.ID+"/attach?"+v.Encode(), config.Tty, os.Stdin, os.Stdout); err != nil {
            utils.Debugf("Error hijack: %s", err)
            return err
        }
    }
    return nil

DockerClihijack (commands.go) は、net.http.httputil.ServerCon の Hijack を呼び出して、いままで HTTP で話すのにつかっていたコネクションから、生のソケットをとりだす。

func (cli *DockerCli) hijack(method, path string, setRawTerminal bool, in *os.File, out io.Writer) error {

    req, err := http.NewRequest(method, fmt.Sprintf("/v%g%s", APIVERSION, path), nil)
    if err != nil {
        return err
    }
    req.Header.Set("User-Agent", "Docker-Client/"+VERSION)
    req.Header.Set("Content-Type", "plain/text")

    dial, err := net.Dial(cli.proto, cli.addr)
    if err != nil {
        return err
    }
    clientconn := httputil.NewClientConn(dial, nil)
    defer clientconn.Close()

    // Server hijacks the connection, error 'connection closed' expected
    clientconn.Do(req)

    rwc, br := clientconn.Hijack()
    defer rwc.Close()

    receiveStdout := utils.Go(func() error {
        _, err := io.Copy(out, br)
        return err
    })

    if in != nil && setRawTerminal && term.IsTerminal(in.Fd()) && os.Getenv("NORAW") == "" {
        oldState, err := term.SetRawTerminal()
        if err != nil {
            return err
        }
        defer term.RestoreTerminal(oldState)
    }
    sendStdin := utils.Go(func() error {
        io.Copy(rwc, in)
        if err := rwc.(*net.TCPConn).CloseWrite(); err != nil {
            utils.Debugf("Couldn't send EOF: %s\n", err)
        }
        // Discard errors due to pipe interruption
        return nil
    })

    if err := <-receiveStdout; err != nil {
        utils.Debugf("Error receiveStdout: %s", err)
        return err
    }

    if !term.IsTerminal(in.Fd()) {
        if err := <-sendStdin; err != nil {
            utils.Debugf("Error sendStdin: %s", err)
            return err
        }
    }
    return nil

}

とりだせたら、ソケットからの出力を標準出力に書き込む 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 みたいだけど) 結構よい言語なのかもしれない。