GoでDockerを操る [Docker Engine SDK]

こちらはDocker Advent Calendar 2020 5日目の記事です!
qiita.com

(2020/12/16:Dockerfileを用いたビルドについて追記しました。)

はじめに

DockerはDocker Engine API (旧 Docker Remote API)という仕組みをもっており、Unix Domain Socket の通信によってイメージやコンテナに対する操作ができます。さらに、Docker Engine API をGo言語から扱いやすくするために、Docker Engine SDK というものも提供されています。これはDocker Engine API の呼び出しをラップしてくれる言語パッケージで、これを使うことで比較的簡単にGo言語からDockerを操ることができます。我々が普段叩いてるdocker run ...というようなコマンドも、このDocker Engine SDK を使って実装されています (https://github.com/docker/cli でコードを読むことができます)。
しかしビルドの方法に難があり、私自身諦めかけていたのですが、GitHub Issueを読み込むことでビルド方法が判明しました!この記事では、Docker Engine SDK のビルド方法・各種サンプルコードについて解説します。

環境

この後は以下のような環境で作業しています。

$ uname -a
Linux ik1-429-46507 4.15.0-123-generic #126-Ubuntu SMP Wed Oct 21 09:40:11 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
$ go version
go version go1.15.4 linux/amd64
$ docker version
Client: Docker Engine - Community
 Version:           19.03.13
 API version:       1.40
 Go version:        go1.13.15
 Git commit:        4484c46d9d
 Built:             Wed Sep 16 17:02:36 2020
 OS/Arch:           linux/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          19.03.13
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.13.15
  Git commit:       4484c46d9d
  Built:            Wed Sep 16 17:01:06 2020
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.3.7
  GitCommit:        8fba4e9a7d01810a393d5d25a3621dc101981175
 runc:
  Version:          1.0.0-rc10
  GitCommit:        dc9208a3303feef5b3839f4323d9beb36df0a9dd
 docker-init:
  Version:          0.18.0
  GitCommit:        fec3683

Docker Engine SDK とは

Dockerはサーバークライアントモデルとして設計されており、Dockerデーモン(Docker Engine)がサーバーで、我々が普段叩いているdockerコマンド(Docker CLI)がクライアントです。

github.com

github.com

このサーバー・クライアント間を結ぶものがDocker Engine API であり、それをGo言語から扱いやすくしたものがDocker Engine SDK です。

Docker Engine API のドキュメント : Docker Engine API v1.40 Reference
Docker Engine SDK のドキュメント (GoDoc) : client - GoDoc
※ただし、このGoDocの内容はmasterブランチの最新のものであり、latestより先のバージョンのものだと思われます。client · pkg.go.dev では、バージョンを指定できますが、Docker(moby)のバージョンをGo言語側で読み取れていないらしく、こちらでもlatestのドキュメントはありませんでした。

Docker Engine SDK を使うコードをビルドする

Docker のドキュメントサイトの中に"Develop with Docker Engine SDKs" というページがあります。
docs.docker.com

ここにはgo get github.com/docker/docker/client とすることでDocker Engine SDK を使うことができると書かれていますが、現在はこれでは上手くいきません。以下のようにlatest バージョンを明示する必要があります(2020年12月5日の時点ではv19,02,14)。

$ go mod init sample # go module を使う場合
go: creating new go.mod: module sample
$ go get github.com/docker/docker@v19.03.14 # latest のバージョンを指定してgo get
go: downloading github.com/docker/docker v17.12.0-ce-rc1.0.20201201034508-7d75c1d40d88+incompatible
go: github.com/docker/docker v19.03.14 => v17.12.0-ce-rc1.0.20201201034508-7d75c1d40d88+incompatible

これでビルドの準備が整いました。試しに次のコードをビルドしてみます。

package main

import (
        "context"
        "fmt"

        "github.com/docker/docker/api/types"
        "github.com/docker/docker/client"
)

func main() {
        ctx := context.Background()
        cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
        if err != nil {
                panic(err)
        }
        cli.NegotiateAPIVersion(ctx)

        containers, err := cli.ContainerList(ctx, types.ContainerListOptions{})
        if err != nil {
                panic(err)
        }

        for _, container := range containers {
                fmt.Println(container.ID[:12], container.Image)
        }
}

これをgo buildでビルドします。

$ go build # ビルド
$ ls # sample が生成されている
go.mod  go.sum  main.go  sample

先程のmain.cがビルドされ、実行ファイルsampleが生成されます。これはdocker psのように、起動中のコンテナ一覧を表示するものです。

$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS               NAMES
1a041b31e5c1        ubuntu:latest       "/bin/sh -c 'while :…"   46 hours ago        Up 46 hours                             confident_mendel
4915a43653bb        alpine              "/bin/sh -c 'while :…"   2 days ago          Up 2 days                               condescending_wu
$
$ ./sample 
1a041b31e5c1 ubuntu:latest
4915a43653bb alpine

このように、起動中のコンテナのIDとイメージ名が表示されるはずです。
※コンテナが1つも起動していない場合には何も表示されません。
dockerコマンドの実行にsudoが必要な場合には、このsampleを実行する際にもsudoが必要になります。

このとき、go.modは以下のようになっています。

module sample

go 1.15

require (
        github.com/containerd/containerd v1.4.3 // indirect
        github.com/docker/distribution v2.7.1+incompatible // indirect
        github.com/docker/docker v17.12.0-ce-rc1.0.20201201034508-7d75c1d40d88+incompatible
        github.com/docker/go-connections v0.4.0 // indirect
        github.com/docker/go-units v0.4.0 // indirect
        github.com/gogo/protobuf v1.3.1 // indirect
        github.com/opencontainers/go-digest v1.0.0 // indirect
        github.com/opencontainers/image-spec v1.0.1 // indirect
        github.com/pkg/errors v0.9.1 // indirect
        github.com/sirupsen/logrus v1.7.0 // indirect
        google.golang.org/grpc v1.34.0 // indirect
)

このgo.modを使い回すことで、他のコードもビルドすることができます。

サンプルコード集

以下のDocker のドキュメントサイトにあるサンプルコードに加えて、コンテナ内でコマンドを実行する例・コンテナ内のシェルに接続する例を紹介します。 
docs.docker.com

※解説しやすくするために、一部改変した部分があります。

run

docker run alpine echo hello worldに相当するコードです。

package main

import (
	"context"
	"io"
	"os"

	"github.com/docker/docker/api/types"
	"github.com/docker/docker/api/types/container"
	"github.com/docker/docker/client"
	"github.com/docker/docker/pkg/stdcopy"
)

func main() {
	ctx := context.Background()

	cli, err := client.NewClientWithOpts(client.FromEnv)
	if err != nil {
		panic(err)
	}
	cli.NegotiateAPIVersion(ctx)

	reader, err := cli.ImagePull(ctx, "docker.io/library/alpine", types.ImagePullOptions{})
	if err != nil {
		panic(err)
	}
	io.Copy(os.Stdout, reader)

	resp, err := cli.ContainerCreate(ctx, &container.Config{
		Image: "alpine",
		Cmd:   []string{"echo", "hello world"},
		Tty:   false,
	}, nil, nil, nil, "")
	if err != nil {
		panic(err)
	}

	if err := cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil {
		panic(err)
	}

	statusCh, errCh := cli.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning)
	select {
	case err := <-errCh:
		if err != nil {
			panic(err)
		}
	case <-statusCh:
	}

	out, err := cli.ContainerLogs(ctx, resp.ID, types.ContainerLogsOptions{ShowStdout: true})
	if err != nil {
		panic(err)
	}

	stdcopy.StdCopy(os.Stdout, os.Stderr, out)
}
  • ImagePullでイメージをpull (このときのログを出力)
  • ContainerCreateでイメージからコンテナを作成
  • ContainerStartでコンテナを起動
  • ContainerWaitで処理を待つ
  • ContainerLogsで起動後のログを取得

という流れになっています。REST APIが裏にあるSDKなだけあって、とてもわかり易い構造になっていますね。
ここでNegotiateAPIVersion(ctx)というメソッドを紹介します。これは、その名の通りDocker EngineとDocker Engine APIのバージョンを調整するという便利なメソッドです。

これをビルドして実行すると、以下のようになります。

$ ./sample 
{"status":"Pulling from library/alpine","id":"latest"}
{"status":"Pulling fs layer","progressDetail":{},"id":"188c0c94c7c5"}
{"status":"Downloading","progressDetail":{"current":29128,"total":2796860},"progress":"[\u003e                                                  ]  29.13kB/2.797MB","id":"188c0c94c7c5"}
{"status":"Downloading","progressDetail":{"current":862645,"total":2796860},"progress":"[===============\u003e                                   ]  862.6kB/2.797MB","id":"188c0c94c7c5"}
{"status":"Downloading","progressDetail":{"current":1943989,"total":2796860},"progress":"[==================================\u003e                ]  1.944MB/2.797MB","id":"188c0c94c7c5"}
{"status":"Verifying Checksum","progressDetail":{},"id":"188c0c94c7c5"}
{"status":"Download complete","progressDetail":{},"id":"188c0c94c7c5"}
{"status":"Extracting","progressDetail":{"current":32768,"total":2796860},"progress":"[\u003e                                                  ]  32.77kB/2.797MB","id":"188c0c94c7c5"}
{"status":"Extracting","progressDetail":{"current":1998848,"total":2796860},"progress":"[===================================\u003e               ]  1.999MB/2.797MB","id":"188c0c94c7c5"}
{"status":"Extracting","progressDetail":{"current":2796860,"total":2796860},"progress":"[==================================================\u003e]  2.797MB/2.797MB","id":"188c0c94c7c5"}
{"status":"Pull complete","progressDetail":{},"id":"188c0c94c7c5"}
{"status":"Digest: sha256:c0e9560cda118f9ec63ddefb4a173a2b2a0347082d7dff7dc14272e7841a5b5a"}
{"status":"Status: Downloaded newer image for alpine:latest"}
hello world

実行結果のうち、波括弧( "{ }" )で囲まれた出力はイメージをpullした時のログで、最後の"hello world"はContainerLogsで取得したログです。
たしかに、イメージのpull・コンテナの作成・起動・ログ出力ができていることがわかります。

run -d

docker run -d bfirsh/reticulate-splinesに相当するコードです。

package main

import (
	"context"
	"fmt"
	"io"
	"os"

	"github.com/docker/docker/api/types"
	"github.com/docker/docker/api/types/container"
	"github.com/docker/docker/client"
)

func main() {
	ctx := context.Background()
	cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
	if err != nil {
		panic(err)
	}
	cli.NegotiateAPIVersion(ctx)

	imageName := "bfirsh/reticulate-splines"

	out, err := cli.ImagePull(ctx, imageName, types.ImagePullOptions{})
	if err != nil {
		panic(err)
	}
	io.Copy(os.Stdout, out)

	resp, err := cli.ContainerCreate(ctx, &container.Config{
		Image: imageName,
	}, nil, nil, nil, "")
	if err != nil {
		panic(err)
	}

	if err := cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil {
		panic(err)
	}

	fmt.Println(resp.ID)
}

こちらのコードでは、先程と違ってコンテナの処理を待つような記述をしていません。Docker Engineへコンテナの起動を伝えた後で、このクライアントはすぐに終了するため、結果的にコンテナはバックグラウンドで起動されます。

これをビルドして実行すると、以下のようになります。

$ ./sample 
{"status":"Pulling from bfirsh/reticulate-splines","id":"latest"}
{"status":"Pulling fs layer","progressDetail":{},"id":"e110a4a17941"}
{"status":"Pulling fs layer","progressDetail":{},"id":"9cb73a4f4dd9"}
{"status":"Pulling fs layer","progressDetail":{},"id":"544bc6b7ec68"}
{"status":"Downloading","progressDetail":{"current":252,"total":252},"progress":"[==================================================\u003e]     252B/252B","id":"9cb73a4f4dd9"}
{"status":"Verifying Checksum","progressDetail":{},"id":"9cb73a4f4dd9"}
{"status":"Download complete","progressDetail":{},"id":"9cb73a4f4dd9"}
{"status":"Downloading","progressDetail":{"current":23789,"total":2310286},"progress":"[\u003e                                                  ]  23.79kB/2.31MB","id":"e110a4a17941"}
{"status":"Downloading","progressDetail":{"current":251,"total":251},"progress":"[==================================================\u003e]     251B/251B","id":"544bc6b7ec68"}
{"status":"Verifying Checksum","progressDetail":{},"id":"544bc6b7ec68"}
{"status":"Download complete","progressDetail":{},"id":"544bc6b7ec68"}
{"status":"Downloading","progressDetail":{"current":814295,"total":2310286},"progress":"[=================\u003e                                 ]  814.3kB/2.31MB","id":"e110a4a17941"}
{"status":"Downloading","progressDetail":{"current":1886645,"total":2310286},"progress":"[========================================\u003e          ]  1.887MB/2.31MB","id":"e110a4a17941"}
{"status":"Verifying Checksum","progressDetail":{},"id":"e110a4a17941"}
{"status":"Download complete","progressDetail":{},"id":"e110a4a17941"}
{"status":"Extracting","progressDetail":{"current":32768,"total":2310286},"progress":"[\u003e                                                  ]  32.77kB/2.31MB","id":"e110a4a17941"}
{"status":"Extracting","progressDetail":{"current":720896,"total":2310286},"progress":"[===============\u003e                                   ]  720.9kB/2.31MB","id":"e110a4a17941"}
{"status":"Extracting","progressDetail":{"current":2310286,"total":2310286},"progress":"[==================================================\u003e]   2.31MB/2.31MB","id":"e110a4a17941"}
{"status":"Pull complete","progressDetail":{},"id":"e110a4a17941"}
{"status":"Extracting","progressDetail":{"current":252,"total":252},"progress":"[==================================================\u003e]     252B/252B","id":"9cb73a4f4dd9"}
{"status":"Extracting","progressDetail":{"current":252,"total":252},"progress":"[==================================================\u003e]     252B/252B","id":"9cb73a4f4dd9"}
{"status":"Pull complete","progressDetail":{},"id":"9cb73a4f4dd9"}
{"status":"Extracting","progressDetail":{"current":251,"total":251},"progress":"[==================================================\u003e]     251B/251B","id":"544bc6b7ec68"}
{"status":"Extracting","progressDetail":{"current":251,"total":251},"progress":"[==================================================\u003e]     251B/251B","id":"544bc6b7ec68"}
{"status":"Pull complete","progressDetail":{},"id":"544bc6b7ec68"}
{"status":"Digest: sha256:67cfd7db171e3de3551c209cfa24c7ae3757d54806d6b8191994a917f3e92723"}
{"status":"Status: Downloaded newer image for bfirsh/reticulate-splines:latest"}
1c01e439e468accac069b1ffeb03b98dab8ad33368d0d1b74e5ed9314d7e1cb7

バックグラウンドで起動されたコンテナを確認してみましょう。

$ docker ps # コンテナがバックグラウンドで起動されている
CONTAINER ID        IMAGE                       COMMAND                  CREATED             STATUS              PORTS               NAMES
1c01e439e468        bfirsh/reticulate-splines   "/usr/local/bin/run.…"   9 minutes ago       Up 9 minutes                            gifted_cohen
$ docker container logs gifted_cohen --tail="10" # 起動させたコンテナのログを取得 (末尾10桁)
Reticulating spline 670...
Reticulating spline 671...
Reticulating spline 672...
Reticulating spline 673...
Reticulating spline 674...
Reticulating spline 675...
Reticulating spline 676...
Reticulating spline 677...
Reticulating spline 678...
Reticulating spline 679...

たしかに、バックグラウンドでコンテナが起動しています。

ps

docker psに相当するコードです。

package main

import (
	"context"
	"fmt"

	"github.com/docker/docker/api/types"
	"github.com/docker/docker/client"
)

func main() {
	ctx := context.Background()
	cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
	if err != nil {
		panic(err)
	}
	cli.NegotiateAPIVersion(ctx)

	containers, err := cli.ContainerList(ctx, types.ContainerListOptions{})
	if err != nil {
		panic(err)
	}

	for _, container := range containers {
		fmt.Println(container.ID, container.Image)
	}
}

ContainerListで起動中のコンテナ一覧を取得しています。
これをビルドして実行すると、以下のようになります。

$ ./sample 
1c01e439e468 bfirsh/reticulate-splines
1a041b31e5c1 ubuntu:latest

実際のdocker psの出力と見比べてみると、コンテナの情報を正しく取得できていることがわかります。

$ docker ps
CONTAINER ID        IMAGE                       COMMAND                  CREATED             STATUS              PORTS               NAMES
1c01e439e468        bfirsh/reticulate-splines   "/usr/local/bin/run.…"   50 minutes ago      Up 50 minutes                           gifted_cohen
1a041b31e5c1        ubuntu:latest               "/bin/sh -c 'while :…"   2 days ago          Up 2 hours                              confident_mendel

container stop

docker container stop $(docker ps -q)に相当するコードです。つまり、起動中のコンテナをすべて停止させるコードです。
※実行する前に、停止させてはいけないコンテナが無いか確かめてください。

package main

import (
	"context"
	"fmt"

	"github.com/docker/docker/api/types"
	"github.com/docker/docker/client"
)

func main() {
	ctx := context.Background()
	cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
	if err != nil {
		panic(err)
	}
	cli.NegotiateAPIVersion(ctx)

	containers, err := cli.ContainerList(ctx, types.ContainerListOptions{})
	if err != nil {
		panic(err)
	}

	for _, container := range containers {
		fmt.Print("Stopping container ", container.ID[:10], "... ")
		if err := cli.ContainerStop(ctx, container.ID, nil); err != nil {
			panic(err)
		}
		fmt.Println("Success")
	}
}

先程のContainerListを利用して、起動中のコンテナのIDを取得し、それら1つ1つを停止させています。
これをビルドして実行すると、以下のようになります。

$ docker ps # 2つのコンテナが起動中
CONTAINER ID        IMAGE                       COMMAND                  CREATED             STATUS              PORTS               NAMES
1c01e439e468        bfirsh/reticulate-splines   "/usr/local/bin/run.…"   59 minutes ago      Up 8 seconds                            gifted_cohen
1a041b31e5c1        ubuntu:latest               "/bin/sh -c 'while :…"   2 days ago          Up 8 seconds                            confident_mendel
$ ./sample
Stopping container 1c01e439e4... Success
Stopping container 1a041b31e5... Success
$ docker ps # 起動中のコンテナは無くなった
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
$ docker ps -a # 起動していたコンテナは停止している
CONTAINER ID        IMAGE                       COMMAND                  CREATED             STATUS                       PORTS               NAMES
1c01e439e468        bfirsh/reticulate-splines   "/usr/local/bin/run.…"   About an hour ago   Exited (137) 9 minutes ago                       gifted_cohen
1a041b31e5c1        ubuntu:latest               "/bin/sh -c 'while :…"   2 days ago          Exited (137) 9 minutes ago                       confident_mendel

プログラムを実行する前後にdocker psをしています。たしかに、起動していた2つのコンテナが停止されいてることがわかります。
(STATUSがUp 8 secondsからExited (137) 9 minutes ago となっているように、間に9分程時間が飛んでいるのは、Twitterしてたからです。ごめんなさい。)

container logs

こちらはコンテナのログを取得するサンプルコードです。まずは、下記のようにログを取得するコンテナを作成します。

$ docker run -d alpine /bin/sh -c "while :; do sleep 10; echo hello; done"
1f16dfd46ff0463631d6c218cbdf46409d838fd58c262b8148c89c55262a1451
$ docker ps
CONTAINER ID        IMAGE                       COMMAND                  CREATED             STATUS              PORTS               NAMES
1f16dfd46ff0        alpine                      "/bin/sh -c 'while :…"   13 seconds ago      Up 12 seconds                           determined_kare

作成したコンテナは10秒ごとに"hello"と出力するというものです。また、このコンテナのIDは1f16dfd46ff0で、NAMESはdetermined_kareとなっていることがわかります。

下記のコードは、docker container logs determined_kareに相当するものです。

package main

import (
	"context"
	"os"

	"github.com/docker/docker/api/types"
	"github.com/docker/docker/client"
	"github.com/docker/docker/pkg/stdcopy"
)

var ID string = "1f16dfd46ff0"

func main() {
	ctx := context.Background()
	cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
	if err != nil {
		panic(err)
	}
	cli.NegotiateAPIVersion(ctx)

	options := types.ContainerLogsOptions{
		ShowStdout: true,
		ShowStderr: true,
	}
	out, err := cli.ContainerLogs(ctx, ID, options)
	if err != nil {
		panic(err)
	}
	stdcopy.StdCopy(os.Stdout, os.Stderr, out)
}

これをビルドして実行すると、以下のようになります。

$ ./sample 
hello
hello
hello
hello
$ docker container logs determined_kare 
hello
hello
hello
hello

ログの取得ができています。

iamge list

docker iamge listに相当するコードです。

package main

import (
	"context"
	"fmt"

	"github.com/docker/docker/api/types"
	"github.com/docker/docker/client"
)

func main() {
	ctx := context.Background()
	cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
	if err != nil {
		panic(err)
	}
	cli.NegotiateAPIVersion(ctx)

	images, err := cli.ImageList(ctx, types.ImageListOptions{})
	if err != nil {
		panic(err)
	}

	for _, image := range images {
		fmt.Println(image.ID)
	}
}

これをビルドして実行すると、以下のようになります。

$ ./sample 
sha256:f643c72bc25212974c16f3348b3a898b1ec1eb13ec1539e10a103e6e217eb2f1
sha256:d6e46aa2470df1d32034c6707c8041158b652f38d2a9ae3d7ad7e7532d22ebe0
sha256:b1666055931f332541bda7c425e624764de96c85177a61a0b49238a42b80b7f9
$ docker image list
REPOSITORY                  TAG                 IMAGE ID            CREATED             SIZE
ubuntu                      latest              f643c72bc252        9 days ago          72.9MB
alpine                      latest              d6e46aa2470d        6 weeks ago         5.57MB
bfirsh/reticulate-splines   latest              b1666055931f        4 years ago         4.8MB

それぞれのイメージのIDが出力されていることがわかります。

image pull

docker image pull alpineに相当するコードです。

package main

import (
	"context"
	"io"
	"os"

	"github.com/docker/docker/api/types"
	"github.com/docker/docker/client"
)

func main() {
	ctx := context.Background()
	cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
	if err != nil {
		panic(err)
	}
	cli.NegotiateAPIVersion(ctx)

	out, err := cli.ImagePull(ctx, "alpine", types.ImagePullOptions{})
	if err != nil {
		panic(err)
	}

	defer out.Close()

	io.Copy(os.Stdout, out)
}

これをビルドして実行すると、以下のようになります。

$ docker image list
REPOSITORY                  TAG                 IMAGE ID            CREATED             SIZE
ubuntu                      latest              f643c72bc252        9 days ago          72.9MB
bfirsh/reticulate-splines   latest              b1666055931f        4 years ago         4.8MB
|
$ ./sample
{"status":"Pulling from library/alpine","id":"latest"}
{"status":"Pulling fs layer","progressDetail":{},"id":"188c0c94c7c5"}
{"status":"Downloading","progressDetail":{"current":29128,"total":2796860},"progress":"[\u003e                                                  ]  29.13kB/2.797MB","id":"188c0c94c7c5"}
{"status":"Downloading","progressDetail":{"current":883126,"total":2796860},"progress":"[===============\u003e                                   ]  883.1kB/2.797MB","id":"188c0c94c7c5"}
{"status":"Downloading","progressDetail":{"current":1866166,"total":2796860},"progress":"[=================================\u003e                 ]  1.866MB/2.797MB","id":"188c0c94c7c5"}
{"status":"Verifying Checksum","progressDetail":{},"id":"188c0c94c7c5"}
{"status":"Download complete","progressDetail":{},"id":"188c0c94c7c5"}
{"status":"Extracting","progressDetail":{"current":32768,"total":2796860},"progress":"[\u003e                                                  ]  32.77kB/2.797MB","id":"188c0c94c7c5"}
{"status":"Extracting","progressDetail":{"current":1474560,"total":2796860},"progress":"[==========================\u003e                        ]  1.475MB/2.797MB","id":"188c0c94c7c5"}
{"status":"Extracting","progressDetail":{"current":2796860,"total":2796860},"progress":"[==================================================\u003e]  2.797MB/2.797MB","id":"188c0c94c7c5"}
{"status":"Pull complete","progressDetail":{},"id":"188c0c94c7c5"}
{"status":"Digest: sha256:c0e9560cda118f9ec63ddefb4a173a2b2a0347082d7dff7dc14272e7841a5b5a"}
{"status":"Status: Downloaded newer image for alpine:latest"}
$ docker image list
REPOSITORY                  TAG                 IMAGE ID            CREATED             SIZE
ubuntu                      latest              f643c72bc252        9 days ago          72.9MB
alpine                      latest              d6e46aa2470d        6 weeks ago         5.57MB
bfirsh/reticulate-splines   latest              b1666055931f        4 years ago         4.8MB

プログラムを実行する前には無かったAlipineのイメージが追加されていることがわかります。

container commit

docker container commit <コンテナ名> helloworldに相当するコードです。
ここでは、/以下にhelloworldというファイルを作成したAlipineのコンテナを作成し、それをhelloworldという名前でcommitしています。

package main

import (
	"context"
	"fmt"

	"github.com/docker/docker/api/types"
	"github.com/docker/docker/api/types/container"
	"github.com/docker/docker/client"
)

func main() {
	ctx := context.Background()
	cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
	if err != nil {
		panic(err)
	}
	cli.NegotiateAPIVersion(ctx)

	createResp, err := cli.ContainerCreate(ctx, &container.Config{
		Image: "alpine",
		Cmd:   []string{"touch", "/helloworld"},
	}, nil, nil, nil, "")
	if err != nil {
		panic(err)
	}

	if err := cli.ContainerStart(ctx, createResp.ID, types.ContainerStartOptions{}); err != nil {
		panic(err)
	}

	statusCh, errCh := cli.ContainerWait(ctx, createResp.ID, container.WaitConditionNotRunning)
	select {
	case err := <-errCh:
		if err != nil {
			panic(err)
		}
	case <-statusCh:
	}

	commitResp, err := cli.ContainerCommit(ctx, createResp.ID, types.ContainerCommitOptions{Reference: "helloworld"})
	if err != nil {
		panic(err)
	}

	fmt.Println(commitResp.ID)
}

これをビルドして実行すると、以下のようになります。

$ docker image list
REPOSITORY                  TAG                 IMAGE ID            CREATED             SIZE
ubuntu                      latest              f643c72bc252        9 days ago          72.9MB
alpine                      latest              d6e46aa2470d        6 weeks ago         5.57MB
bfirsh/reticulate-splines   latest              b1666055931f        4 years ago         4.8MB
$ ./sample 
sha256:0d5fd95305dbb347d056e3057a60cacb00fd61788b36cd18adbf6b624da1f999
$ docker image list
REPOSITORY                  TAG                 IMAGE ID            CREATED             SIZE
helloworld                  latest              0d5fd95305db        3 seconds ago       5.57MB
ubuntu                      latest              f643c72bc252        9 days ago          72.9MB
alpine                      latest              d6e46aa2470d        6 weeks ago         5.57MB
bfirsh/reticulate-splines   latest              b1666055931f        4 years ago         4.8MB

プログラムを実行すると、新たにhelloworldというイメージが生成されています。シェルに接続してみると、helloworldというファイルが生成されていることから、たしかにcommitできていることがわかります。

$ docker run -it helloworld /bin/sh
/ # ls
bin         etc         home        media       opt         root        sbin        sys         usr
dev         helloworld  lib         mnt         proc        run         srv         tmp         var
/ # exit

container exec

docker container exec -t <コンテナ名> ls /に相当するコードです。
ここでは接続先のコンテナを以下のように作成しました。

$ docker run -d ubuntu:latest /bin/sh -c "while :; do sleep 10; done"
$ docker ps
CONTAINER ID        IMAGE                       COMMAND                  CREATED             STATUS              PORTS               NAMES
1a041b31e5c1        ubuntu:latest               "/bin/sh -c 'while :…"   2 days ago          Up 51 minutes                           confident_mendel
package main

import (
	"log"
	"os"

	"github.com/docker/docker/api/types"
	"github.com/docker/docker/client"
	"github.com/docker/docker/pkg/stdcopy"
	"golang.org/x/net/context"
)

var (
	ID  string   = "1a041b31e5c1"
	cmd []string = []string{"ls", "/"}
)

func main() {
	ctx := context.Background()

	cli, err := client.NewClientWithOpts(client.FromEnv)
	if err != nil {
		panic(err)
	}
	cli.NegotiateAPIVersion(ctx)

	config := types.ExecConfig{
		AttachStderr: true,
		AttachStdout: true,
		Tty:          true,
		Cmd:          cmd,
	}

	IDResp, err := cli.ContainerExecCreate(ctx, ID, config)
	if err != nil {
		log.Panic(err)
	}

	resp, err := cli.ContainerExecAttach(ctx, IDResp.ID, types.ExecStartCheck{})
	if err != nil {
		log.Panic(err)
	}
	defer func() {
		if err := resp.Conn.Close(); err != nil {
			log.Panic(err)
		}
		log.Println("connection closed")
	}()

	stdcopy.StdCopy(os.Stdout, os.Stderr, resp.Reader)
}

これをビルドして実行すると、以下のようになります。

$ ./sample 
bin   dev  home  lib32  libx32  mnt  proc  run   srv  tmp  var
boot  etc  lib   lib64  media   opt  root  sbin  sys  usr
2020/12/05 23:42:47 connection closed
$ docker container exec -t confident_mendel ls /
bin   dev  home  lib32  libx32  mnt  proc  run   srv  tmp  var
boot  etc  lib   lib64  media   opt  root  sbin  sys  usr

lsコマンドを実行できていることがわかります。

container exec /bin/bash

docker container exec -it confident_mendel /bin/bashに相当するコードです。つまり、コンテナのシェルに接続するコードです。
ここでは先程のcontainer execと同じコンテナに接続しています。

package main

import (
	"io"
	"log"
	"os"

	"github.com/docker/docker/api/types"
	"github.com/docker/docker/client"
	"github.com/docker/docker/pkg/stdcopy"
	"golang.org/x/crypto/ssh/terminal"
	"golang.org/x/net/context"
)

var (
	ID  string   = "1a041b31e5c1"
	cmd []string = []string{"/bin/bash"}
)

func main() {
	ctx := context.Background()

	cli, err := client.NewClientWithOpts(client.FromEnv)
	if err != nil {
		panic(err)
	}
	cli.NegotiateAPIVersion(ctx)

	config := types.ExecConfig{
		AttachStdin:  true,
		AttachStderr: true,
		AttachStdout: true,
		Tty:          true,
		Cmd:          cmd,
	}

	exec, err := cli.ContainerExecCreate(ctx, ID, config)
	if err != nil {
		log.Panic(err)
	}

	resp, err := cli.ContainerExecAttach(ctx, exec.ID, types.ExecStartCheck{})
	if err != nil {
		log.Panic(err)
	}
	defer func() {
		if err := resp.Conn.Close(); err != nil {
			log.Panic()
		}
		log.Println("connection closed")
	}()

	fd := int(os.Stdin.Fd())
	if terminal.IsTerminal(fd) {
		state, err := terminal.MakeRaw(fd)
		if err != nil {
			log.Panic(err)
		}
		defer terminal.Restore(fd, state)

		w, h, err := terminal.GetSize(fd)
		if err != nil {
			log.Panic(err)
		}
		if w < 0 || h < 0 {
			log.Panic("terminal size error", " w: ", w, " h:", h)
		}
		resizeOptions := types.ResizeOptions{
			Height: uint(h),
			Width:  uint(w),
		}
		if err := cli.ContainerExecResize(ctx, exec.ID, resizeOptions); err != nil {
			log.Panic(err)
		}
	}

	go io.Copy(resp.Conn, os.Stdin)
	stdcopy.StdCopy(os.Stdout, os.Stderr, resp.Reader)
}

これをビルドして実行すると、以下のようになります。

$ ./sample 
root@1a041b31e5c1:/# ls
bin   dev  home  lib32  libx32  mnt  proc  run   srv  tmp  var
boot  etc  lib   lib64  media   opt  root  sbin  sys  usr
root@1a041b31e5c1:/# uname -a
Linux 1a041b31e5c1 4.15.0-123-generic #126-Ubuntu SMP Wed Oct 21 09:40:11 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
root@1a041b31e5c1:/# cat /etc/issue
Ubuntu 20.04.1 LTS \n \l

root@1a041b31e5c1:/# 

bashが使えていますね。また、htopvimなどのグラフィカルなCLIアプリも表示することができます。
f:id:w_haibara:20201205235508p:plain
f:id:w_haibara:20201205234929p:plain

Dockerfile

Dockerfileを読み込んでコンテナイメージをビルドするサンプルです。カレントディレクトリにDockerfileがある時、docker image build . -t name:tagと同じような動作をします。

package main

import (
	"archive/tar"
	"bytes"
	"context"
	"io"
	"io/ioutil"
	"log"
	"os"

	"github.com/docker/docker/api/types"
	"github.com/docker/docker/client"
)

var (
	dockerfileName   string   = "Dockerfile"
	imageNameAndTags []string = []string{"name:tag"}
)

func main() {
	ctx := context.Background()

	cli, err := client.NewClientWithOpts(client.FromEnv)
	if err != nil {
		log.Panic(err)
	}
	cli.NegotiateAPIVersion(ctx)

	buildOptions := types.ImageBuildOptions{
		Dockerfile: dockerfileName,
		Remove:     true,
		Tags:       imageNameAndTags,
	}
	res, err := cli.ImageBuild(
		ctx,
		getArchivedDockerfile(dockerfileName),
		buildOptions,
	)
	if err != nil {
		log.Panic(err)
	}
	defer res.Body.Close()

	io.Copy(os.Stdout, res.Body)
}

func getArchivedDockerfile(dockerfile string) *bytes.Reader {
	// read the Dockerfile
	f, err := os.Open(dockerfile)
	if err != nil {
		log.Panic(err)
	}
	defer func() {
		if err := f.Close(); err != nil {
			log.Panic(err)
		}
	}()
	b, err := ioutil.ReadAll(f)
	if err != nil {
		log.Panic(err)
	}

	// archive the Dockerfile
	tarHeader := &tar.Header{
		Name: dockerfile,
		Size: int64(len(b)),
	}
	buf := new(bytes.Buffer)
	tw := tar.NewWriter(buf)
	defer tw.Close()
	err = tw.WriteHeader(tarHeader)
	if err != nil {
		log.Panic(err)
	}
	_, err = tw.Write(b)
	if err != nil {
		log.Panic(err)
	}

	return bytes.NewReader(buf.Bytes())
}

動作の確認のために以下のようなDockerfileを用意しました。

FROM alpine:3.8
RUN /bin/sh -c 'echo "hello world!"'

プログラムをビルドして、このDockerfileと同じディレクトリで実行すると以下のようになります。

$ ./sample 
{"stream":"Step 1/2 : FROM alpine:3.8"}
{"stream":"\n"}
{"status":"Pulling from library/alpine","id":"3.8"}
{"status":"Pulling fs layer","progressDetail":{},"id":"486039affc0a"}
{"status":"Downloading","progressDetail":{"current":22282,"total":2207101},"progress":"[\u003e                                                  ]  22.28kB/2.207MB","id":"486039affc0a"}
{"status":"Downloading","progressDetail":{"current":907702,"total":2207101},"progress":"[====================\u003e                              ]  907.7kB/2.207MB","id":"486039affc0a"}
{"status":"Downloading","progressDetail":{"current":1874358,"total":2207101},"progress":"[==========================================\u003e        ]  1.874MB/2.207MB","id":"486039affc0a"}
{"status":"Verifying Checksum","progressDetail":{},"id":"486039affc0a"}
{"status":"Download complete","progressDetail":{},"id":"486039affc0a"}
{"status":"Extracting","progressDetail":{"current":32768,"total":2207101},"progress":"[\u003e                                                  ]  32.77kB/2.207MB","id":"486039affc0a"}
{"status":"Extracting","progressDetail":{"current":1900544,"total":2207101},"progress":"[===========================================\u003e       ]  1.901MB/2.207MB","id":"486039affc0a"}
{"status":"Extracting","progressDetail":{"current":2207101,"total":2207101},"progress":"[==================================================\u003e]  2.207MB/2.207MB","id":"486039affc0a"}
{"status":"Pull complete","progressDetail":{},"id":"486039affc0a"}
{"status":"Digest: sha256:2bb501e6173d9d006e56de5bce2720eb06396803300fe1687b58a7ff32bf4c14"}
{"status":"Status: Downloaded newer image for alpine:3.8"}
{"stream":" ---\u003e c8bccc0af957\n"}
{"stream":"Step 2/2 : RUN /bin/sh -c 'echo \"hello world!\"'"}
{"stream":"\n"}
{"stream":" ---\u003e Running in a29ade0133a1\n"}
{"stream":"hello world!\n"}
{"stream":"Removing intermediate container a29ade0133a1\n"}
{"stream":" ---\u003e 9cd174e92745\n"}
{"aux":{"ID":"sha256:9cd174e92745d435e66c01687f7b35f13ceffa1dfc60cbcdb6c3b6896abf2715"}}
{"stream":"Successfully built 9cd174e92745\n"}
{"stream":"Successfully tagged name:tag\n"}
$ docker image ls
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
name                tag                 9cd174e92745        45 seconds ago      4.41MB
alpine              3.8                 c8bccc0af957        10 months ago       4.41MB

Dockerfileを用いたイメージのビルドができています。SDKにDockerfileの内容を渡す際、TARで圧縮する必要があることに注意です。

おわりに

Docker Engine SDKは、日本語でも英語でも情報が少ないため、GitHub Issueやgithub.com/docker/cli の実装を読みながらの実装となりました。面白い技術だと思いますので、うまく使い所を見つけたいところですね。