DockerコマンドのGo言語ラッパーを作った

この記事はSecHack365 アドベントカレンダー2022の2日目です

25日の夜にコーディングから始めて日付が変わってしまいましたが、空いていたのでねじ込ませてもらいます。 qiita.com

はじめに

SecHack365 2019年度の修了生で、2021年度・2022年度のアシスタントを担当している灰原です。 この記事では、思い付きで作ったDockerコマンドのGo言語ラッパーを紹介します。 github.com 下記のような形式で、指定したDockerコマンドを実行するためのexec.Cmdを生成してくれるものです。

cmd := docker.DockerVersionCmd(docker.DockerVersionOption{}, []string{})
out, err := cmd.CombinedOutput()
if err != nil {
    log.Fatal(err)
}

fmt.Println(string(out))

開発の背景

このライブラリはGoプログラムからDockerを楽に使うために作成しました。プログラムからDockerを操作する他の方法に、Docker Engine SDKを使う手もあります。これについては過去のいくつかブログで触れました。

GoでDockerを操る [Docker Engine SDK] - はいばらのブログ

Docker Engine SDKでリモートのDocker daemonにSSH経由で接続する - はいばらのブログ

DockerコマンドはこのDocker Engine SDKを叩いているため、理論的にはDcokerコマンドでできることはDocker Engine SDKを使えばできるはずです。しかし、これが結構厳しそうなことがわかりました。

例えばdocker pullコマンドのコードを追ってみましょう。GoのCLIツールを作る際の定番のライブラリであるspf13/cobraが使われています。cobraを使ったことがある人にとってはスッと読めるコードだと思います。

https://github.com/docker/cli/blob/master/cli/command/image/pull.go

メインの処理をしているRunPullメソッドを見ると、いろいろと前処理をした後、trustedPullメソッドかimagePullPrivileagedメソッドのどちらかを叩いているようです。 truestePullメソッドの実装を見ると、前後にまたいろいろと処理がありますが、imagePullPrivilegedメソッドを呼び出しています。 imagePullPrivilegedメソッドの実装を見ると、ここでDocker Engine SDKImagePullメソッドを叩いていました。

実際にコードを追ってみると、Dockerコマンドが持っているロジックが意外と多いことに気が付きます。Docker Engine SDKが低レベルのAPI呼び出しを行う層であるのに対して、Dockerコマンドはより高位の抽象化層である訳です。

こんな便利なDockerコマンドをプログラムからも使いたい、でもexec.Cmdの引数を文字列で組み立てるのは大変だからどうにかしたい、というモチベーションでこのライブラリは開発されました。

実装

Dockerコマンドはサブコマンドも、各コマンドのフラグ・引数もたくさんあります。これを一つ一つ型定義していくのは大変です。しかもDockerコマンドのアップデートで仕様が変わった場合、該当箇所を探し出して型定義を修正しなければならず、これもまた大変そうです。

Web APIのクライアントを作る場合、SwaggerやProtocol BuffersなどでAPI仕様が定義されていれば、そこからコードの自動生成をすることで実装/管理コストがかなり抑えられます。しかし今回のようにCLIツールのラッパーを作る場合はどうすれば良いでしょうか。

先ほども紹介したように、Dockerコマンドはcobraを使ったCLIツールです。cobraを使ったCLIツールでは、1つのcobra.Command構造体に対してツリー状にサブコマンドの情報を紐付けて、その木構造の根にあたる構造体をmain関数から呼び出すパターンが使われることが多く、Dockerコマンドもこれに従っていました。つまり、大元のcobra.Command構造体を探索すれば、全てのサブコマンドのフラグの情報を得られるはずです。

下記のコードは、Dockerコマンドのソースコードに挿入してビルド・実行することで、Dockerkコマンドのラッパーとなるコードを生成するものです。

https://github.com/w-haibara/docker-wrapper/blob/main/_generate/generate.go

Dockerコマンドのレポジトリにcmd/docker/generate.goとしてこのコードをコピーして、下記の辺りでGenerateCodeメソッドを呼び出すようにします。

$ git diff
diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go
index 90945f0c47..5e78c52d9d 100644
--- a/cmd/docker/docker.go
+++ b/cmd/docker/docker.go
@@ -260,6 +260,8 @@ func runDocker(dockerCli *command.DockerCli) error {
                return err
        }
 
+       GenerateCode(cmd)
+
        if err := tcmd.Initialize(); err != nil {
                return err
        }

そのうえでdocker buildx bakeによってDockerコマンドをビルドし、dockerを実行すると生成されたコードが標準出力に流れます。そのコードがこちらです。12185行あります。自分で書いたコード生成のプログラムが大量のコードを吐いてくれると、なんか気持ちいいですよね。

https://github.com/w-haibara/docker-wrapper/blob/main/docker/docker.go

生成されたコードの中から、先ほど実装を追ってみたdocker pullに相当するコードを見てみましょう。exec.Cmdを生成するDockerImagePullCmdメソッドと、そのフラグが定義されたDockerImagePullOption構造体です。ここに登場する変数名や型、コメントなどはcobra.Commandに詰まっていた情報です。

type DockerImagePullOption struct {
    /*
       Download all tagged images in the repository
   */
    AllTags *bool

    /*
       Skip image verification
   */
    DisableContentTrust *bool

    /*
       Set platform if server is multi-platform capable
   */
    Platform *string

    /*
       Suppress verbose output
   */
    Quiet *bool
}

/*
DockerImagePullCmd is wrapper of 'docker image pull'
------------------------------
pull [OPTIONS] NAME[:TAG|@DIGEST]
Pull an image or a repository from a registry
------------------------------
*/
func DockerImagePullCmd(opt DockerImagePullOption, args []string) *exec.Cmd {
    cargs := []string{"image", "pull"}
    if opt.AllTags != nil {
        cargs = append(cargs, "--all-tags="+fmt.Sprint(*opt.AllTags))
    }
    if opt.DisableContentTrust != nil {
        cargs = append(cargs, "--disable-content-trust="+fmt.Sprint(*opt.DisableContentTrust))
    }
    if opt.Platform != nil {
        cargs = append(cargs, "--platform="+fmt.Sprint(*opt.Platform))
    }
    if opt.Quiet != nil {
        cargs = append(cargs, "--quiet="+fmt.Sprint(*opt.Quiet))
    }
    cargs = append(cargs, args...)
    return exec.Command("docker", cargs...)
}

コメントも引っ張ってこれたのは思わぬ収穫でした。IDEでコーディングすると良い感じに表示してくれて捗りますね。

おわりに

Dockerコマンドをラッパーという形で、Dockerをプログラムから操作するためのライブラリを作りました。もしプログラムからDockerを使いたいな〜と思うことがあればお試しください。