Docker Engine APIを調査する

Docker CLI は、内部で Docker Engine API を呼び出して動作しています。
例えば `docker ps` は、実際には `GET /containers/json` という API を叩いています。

本記事では、この **API 呼び出しの流れを徹底解析し、CLI とデーモンの関係を明らかにします**。

1. **Docker Engine APIの概要** ： CLI とデーモンの関係を整理
1. **ソースコード解析** ： CLI がどのように API を呼び出すかを調査
1. **パケットキャプチャ** ： 実際の API リクエストを可視化
1. **Dockerデーモンのログ解析** ： デーモン側の処理を確認
1. **APIの直接呼び出し** : CLI を介さず API を叩いてみる

この調査を通じて、**Docker CLI の仕組みや API の動作原理を理解し、より高度な Docker の活用方法につなげること** を目的とします。

# 1. Docker Engine APIの概要

普段、私たちは `docker run` や `docker build`、`docker ps` などのコマンドをCLIで実行していますが、各コマンドがそのままDockerデーモンに送信されているわけではありません。
例えば`docker ps` と入力すると、[docker/cliリポジトリ](https://github.com/docker/cli) のコードに基づいて、REST APIである `GET /containers/json` に変換されます。

![docker_API_概要.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3561216/8469d834-ba23-444b-acf4-a999e56dde5d.png)

上記の図は、`docker ps` がどのようにデーモンにリクエストを送り、最終的にデーモンが情報を返すかを示しています。

docker ps の処理は、以下の流れで進みます：

1. **CLI の入力**：ユーザーが `docker ps` を実行
1. **API 変換**：CLI 内部 (`docker/cli`) で `GET /containers/json` に変換
1. **リクエスト送信**：API リクエストが UNIX ドメインソケット `/var/run/docker.sock` 経由でデーモンに送信される
1. **デーモン側の処理**：デーモン (dockerd) がコンテナ情報を取得し、JSON 形式でレスポンスを返す
1. **CLI の出力処理**：CLI がレスポンスを受け取り、ユーザーに表示する

参考
[Docker Engine API について](https://qiita.com/doz13189/items/9ee8fda335605567b832) 
[Docker概要](https://docs.docker.jp/v17.06/engine/docker-overview.html)

# 2. ソースコード解析

[docker/cliリポジトリ](https://github.com/docker/cli) に記載されている内容の一部を見ていきます。  
概略のみ説明しますが、詳細はソースコードを参照してください。
大きく分けて、cobraコマンドの作成、作成したコマンドの実行の２つに分離されます。

実際のソースコード
### 1. docker コマンドのエントリーポイント
```/cli/cmd/docker/docker.go
func main() {
	err := dockerMain(context.Background())
	if errors.As(err, &errCtxSignalTerminated{}) {
		os.Exit(getExitCode(err))
	}

	if err != nil && !errdefs.IsCancelled(err) {
		_, _ = fmt.Fprintln(os.Stderr, err)
		os.Exit(getExitCode(err))
	}
}

func dockerMain(ctx context.Context) error {
	ctx, cancelNotify := notifyContext(ctx, platformsignals.TerminationSignals...)
	defer cancelNotify()

	dockerCli, err := command.NewDockerCli(command.WithBaseContext(ctx))
	if err != nil {
		return err
	}
	logrus.SetOutput(dockerCli.Err())
	otel.SetErrorHandler(debug.OTELErrorHandler)

	return runDocker(ctx, dockerCli)
}

func runDocker(ctx context.Context, dockerCli *command.DockerCli) error {
	tcmd := newDockerCommand(dockerCli)
    
    略

    err = cmd.ExecuteContext(ctx)

    略

    return err
}

func newDockerCommand(dockerCli *command.DockerCli) *cli.TopLevelCommand {
	var (
		opts    *cliflags.ClientOptions
		helpCmd *cobra.Command
	)

	cmd := &cobra.Command{
		Use:              "docker [OPTIONS] COMMAND [ARG...]",
		Short:            "A self-sufficient runtime for containers",
		SilenceUsage:     true,
		SilenceErrors:    true,
		TraverseChildren: true,
		RunE: func(cmd *cobra.Command, args []string) error {
			if len(args) == 0 {
				return command.ShowHelp(dockerCli.Err())(cmd, args)
			}
			return fmt.Errorf("docker: unknown command: docker %s\n\nRun 'docker --help' for more information", args[0])
		},
		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
			return isSupported(cmd, dockerCli)
		},
		Version:               fmt.Sprintf("%s, build %s", version.Version, version.GitCommit),
		DisableFlagsInUseLine: true,
		CompletionOptions: cobra.CompletionOptions{
			DisableDefaultCmd:   false,
			HiddenDefaultCmd:    true,
			DisableDescriptions: os.Getenv("DOCKER_CLI_DISABLE_COMPLETION_DESCRIPTION") != "",
		},
	}

    略
    
	commands.AddCommands(cmd, dockerCli)

	cli.DisableFlagsInUseLine(cmd)
	setValidateArgs(dockerCli, cmd)

	// flags must be the top-level command flags, not cmd.Flags()
	return cli.NewTopLevelCommand(cmd, dockerCli, opts, cmd.Flags())
}
```

- `main()` がエントリーポイントになります
- `newDockerCommand` で `cobra` でCLIが作成されます
- `cmd.ExecuteContext(ctx)`でコマンドが実行されます


### 2. docker ps のコマンド登録
```/cli/cli/command/commands/commands.go
func AddCommands(cmd *cobra.Command, dockerCli command.Cli) {
	cmd.AddCommand(
		// commonly used shorthands
		container.NewRunCommand(dockerCli),
		container.NewExecCommand(dockerCli),
		container.NewPsCommand(dockerCli),
		image.NewBuildCommand(dockerCli),
		image.NewPullCommand(dockerCli),
		image.NewPushCommand(dockerCli),
		image.NewImagesCommand(dockerCli),
		registry.NewLoginCommand(dockerCli),
		registry.NewLogoutCommand(dockerCli),
		registry.NewSearchCommand(dockerCli),
		system.NewVersionCommand(dockerCli),
		system.NewInfoCommand(dockerCli),

		// management commands
		builder.NewBuilderCommand(dockerCli),
		checkpoint.NewCheckpointCommand(dockerCli),
		container.NewContainerCommand(dockerCli),
		context.NewContextCommand(dockerCli),
		image.NewImageCommand(dockerCli),
		manifest.NewManifestCommand(dockerCli),
		network.NewNetworkCommand(dockerCli),
		plugin.NewPluginCommand(dockerCli),
		system.NewSystemCommand(dockerCli),
		trust.NewTrustCommand(dockerCli),
		volume.NewVolumeCommand(dockerCli),

        略
	)
}
```

```/cli/cli/command/container/list.go
// NewPsCommand creates a new cobra.Command for `docker ps`
func NewPsCommand(dockerCLI command.Cli) *cobra.Command {
	options := psOptions{filter: opts.NewFilterOpt()}

	cmd := &cobra.Command{
		Use:   "ps [OPTIONS]",
		Short: "List containers",
		Args:  cli.NoArgs,
		RunE: func(cmd *cobra.Command, args []string) error {
			options.sizeChanged = cmd.Flags().Changed("size")
			return runPs(cmd.Context(), dockerCLI, &options)
		},
		Annotations: map[string]string{
			"category-top": "3",
			"aliases":      "docker container ls, docker container list, docker container ps, docker ps",
		},
		ValidArgsFunction: completion.NoComplete,
	}

    略
	return cmd    
}

func runPs(ctx context.Context, dockerCLI command.Cli, options *psOptions) error {

    略

    listOptions, err := buildContainerListOptions(options)
	if err != nil {
		return err
	}

	containers, err := dockerCLI.Client().ContainerList(ctx, *listOptions)
	if err != nil {
		return err
	}

	containerCtx := formatter.Context{
		Output: dockerCLI.Out(),
		Format: formatter.NewContainerFormat(options.format, options.quiet, listOptions.Size),
		Trunc:  !options.noTrunc,
	}
	return formatter.ContainerWrite(containerCtx, containers)
}
```

- `NewPsCommand(dockerCLI)` により、`docker ps` コマンドが `cobra.Command` に登録されます
- `RunE` に定義された `runPs()` が、実際の処理を担当します
- レスポンス (`resp.Body`) を `json.NewDecoder(resp.Body).Decode(&containers)` でデコードし、コンテナの一覧を取得します

```docker/client/container_list.go
func (cli *Client) ContainerList(ctx context.Context, options container.ListOptions) ([]container.Summary, error) {

    略

    resp, err := cli.get(ctx, "/containers/json", query, nil)
	defer ensureReaderClosed(resp)
	if err != nil {
		return nil, err
	}

	var containers []container.Summary
	err = json.NewDecoder(resp.Body).Decode(&containers)
	return containers, err
}
```
- `cli.get(ctx, "/containers/json", query, nil)` を実行することで、API `GET /containers/json`を呼び出します
- そのレスポンス (`resp.Body`) を `json.NewDecoder(resp.Body).Decode(&containers)` でデコードし、コンテナの一覧を取得します


# 3. docker psのパケットキャプチャ
通常、Wireshark や tcpdump は **ネットワーク上のパケット** をキャプチャするツールです。
しかし、Docker CLI は UNIX ドメインソケット `/var/run/docker.sock` を使い、ローカルプロセス間で通信します。
この **UNIX ドメインソケットの通信はカーネル内で処理されるため、通常のネットワークキャプチャツールでは見えません**。

そこで、TCPでリクエストするようにしてキャプチャできるようにした後、`socat` を利用して**UNIX ドメインソケットに変換** し、dockerデーモンへリクエストできるようにします。


大まかな手順は次の通りです。
1. `tcpdump` で TCP のパケットをキャプチャ
2. `socat` で TCPをUNIX ドメインソケットをに変換
3. 向き先をTCPにした上で`docker ps` を実行し、Wireshark でリクエストを確認


![3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3561216/5fc76077-6173-4bd4-bcab-89835cf96568.png) 
![4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3561216/ffd8f7d9-03ec-4994-8828-c6c25b452ac4.png)

実際に Wireshark でキャプチャを取得するため、次の手順を実行します。

ターミナル1: socat を起動し、転送を設定
TCP ポート 12345 で通信を待ち受け、`/var/run/docker.sock` に転送します。
```terminal:ターミナル１
socat -v TCP-LISTEN:12345,fork UNIX-CONNECT:/var/run/docker.sock
```

ターミナル2: tcpdump で通信をキャプチャ
TCP ポート 12345 への通信をキャプチャし、`docker_capture.pcap` に保存します。
```terminal:ターミナル2
sudo tcpdump -i lo0 -nn -X port 12345 -w docker_capture.pcap
```

ターミナル3: Docker CLI を実行
Docker CLI の接続先を 12345 ポートに向けた上で、`docker ps` を実行します。
（※ `docker run nginx` は省略可）
```terminal:ターミナル３
docker run nginx
export DOCKER_HOST=tcp://localhost:12345
docker ps
```

ターミナル2 で `tcpdump` を停止
`docker ps` 実行後、ターミナル2で `Ctrl + C` を押し、`tcpdump` を終了します。

`docker_capture.pcap` を Wireshark で開くと、実際の通信内容を確認できます。

リクエスト
![request.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3561216/347ed6b1-a7d8-4b35-bbb9-9845b5700063.png)

レスポンス
![response.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3561216/270a60b3-bd79-4dc5-a43e-73441913d7f6.png)

参考
[「UNIX ドメインソケット」と「ソケット」について比較する](https://qiita.com/toshihirock/items/b643ed0edd30e6fd1f14)

# 4. Dockerデーモンのログの確認
Docker のデーモン側でどのようにリクエストを受け取っているかを確認するには、ログレベルを `debug` に設定する必要があります。

### 1. ログレベルを debug に変更
Docker の設定ファイル (Docker Engine の設定) に `debug: true` を追加します。

設定方法
1. Docker Desktop を開く
2. Settings (設定) → Docker Engine を選択
3. 以下のように debug: true を追加
    ```:例
    {
      "builder": {
        "gc": {
          "defaultKeepStorage": "20GB",
          "enabled": true
        }
      },
      "debug": true,
      "experimental": false
    }
    ```
4. 「Apply & Restart」ボタンを押し、Docker を再起動

### 2. Docker デーモンのログを確認
`docker ps` を実行すると、デーモン側のログは次のように記録されます。
```:~/Library/Containers/com.docker.docker/Data/log/vm/dockerd.log
time="2025-02-16T06:47:20.818742722Z" level=debug msg="Calling HEAD /_ping"
time="2025-02-16T06:47:20.820766263Z" level=debug msg="Calling GET /v1.47/containers/json"
```

このログから、
`HEAD /_ping` が最初に呼ばれている（Docker デーモンのヘルスチェック）
`GET /v1.47/containers/json` が `docker ps` のリクエストである
ことが確認できます。

また、`tail -f` を使用すれば、Docker デーモンのログをリアルタイムで確認できます。
```terminal
tail -f ~/Library/Containers/com.docker.docker/Data/log/vm/dockerd.log
```

# 5. APIの直接呼び出し
[Docker Engine API](https://docs.docker.com/reference/api/engine/) には、バージョンごとに利用可能な API の一覧が記載されています。
Docker CLI (```docker ps``` など) は、内部的にこの API を呼び出して動作しています。

```terminal
curl --unix-socket /var/run/docker.sock http:/v1.41/containers/json
[{"Id":"ec2fd5900c370fa1f3d4fc3bbe21d8b8cb1e4593dbad1dbd06fab0f3ecfdc024","Names":["/gracious_babbage"],"Image":"nginx","ImageID":"sha256:9b1b7be1ffa607d40d545607d3fdf441f08553468adec5588fb58499ad77fe58","Command":"/docker-entrypoint.sh nginx -g 'daemon off;'","Created":1739616018,"Ports":[{"PrivatePort":80,"Type":"tcp"}],"Labels":{"maintainer":"NGINX Docker Maintainers \u003cdocker-maint@nginx.com\u003e"},"State":"running","Status":"Up 11 minutes","HostConfig":{"NetworkMode":"bridge"},"NetworkSettings":{"Networks":{"bridge":{"IPAMConfig":null,"Links":null,"Aliases":null,"MacAddress":"02:42:ac:11:00:02","DriverOpts":null,"NetworkID":"d5012daf5e2c6d9da8e5a111297163736f3189e6c4ba79b83aec229db467f679","EndpointID":"e626e73227d777d7878e71bee967d652e5aea86c48e2f4a83ed6fcff8e9600ab","Gateway":"172.17.0.1","IPAddress":"172.17.0.2","IPPrefixLen":16,"IPv6Gateway":"","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"DNSNames":null}}},"Mounts":[]}]
```

もし `socat` を利用して /var/run/docker.sock を TCP に転送している場合は、
`DOCKER_HOST` を指定して API を呼び出せます。

```terminal:ターミナル１
socat -v TCP-LISTEN:12345,fork UNIX-CONNECT:/var/run/docker.sock
```
```terminal:ターミナル２
curl -X GET http://localhost:12345/v1.41/containers/json
[{"Id":"ec2fd5900c370fa1f3d4fc3bbe21d8b8cb1e4593dbad1dbd06fab0f3ecfdc024","Names":["/gracious_babbage"],"Image":"nginx","ImageID":"sha256:9b1b7be1ffa607d40d545607d3fdf441f08553468adec5588fb58499ad77fe58","Command":"/docker-entrypoint.sh nginx -g 'daemon off;'","Created":1739616018,"Ports":[{"PrivatePort":80,"Type":"tcp"}],"Labels":{"maintainer":"NGINX Docker Maintainers \u003cdocker-maint@nginx.com\u003e"},"State":"running","Status":"Up 16 minutes","HostConfig":{"NetworkMode":"bridge"},"NetworkSettings":{"Networks":{"bridge":{"IPAMConfig":null,"Links":null,"Aliases":null,"MacAddress":"02:42:ac:11:00:02","DriverOpts":null,"NetworkID":"d5012daf5e2c6d9da8e5a111297163736f3189e6c4ba79b83aec229db467f679","EndpointID":"e626e73227d777d7878e71bee967d652e5aea86c48e2f4a83ed6fcff8e9600ab","Gateway":"172.17.0.1","IPAddress":"172.17.0.2","IPPrefixLen":16,"IPv6Gateway":"","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"DNSNames":null}}},"Mounts":[]}]
```

# 終わりに
`docker ps` の内部動作を解析することで、Docker CLI がどのように API を利用しているかが分かりました。
この知識を活かすことで、以下のような応用が可能ではないかと思います：

- **API を直接操作し、カスタムツールを開発**
- **デバッグ時に API を確認し、異常動作を特定**
- **Docker CLI なしでスクリプトからコンテナ管理**