🎃

Docker内部の仕組みについて手を動かして理解してみた

2023/10/10に公開

SREホールディングス株式会社 でソフトウェアエンジニアをやっている釜田です。

近年、Webアプリケーション開発者にとって、コンテナの起動や停止のためにDockerコマンドを用いることが一般的になりましたが、その背後の仕組みについてご存知でしょうか?
実は、コンソールからDockerコマンドを実行した裏側で、コンテナランタイムがコンテナの作成や起動を行っています。

今回はそのようなDockerの裏側について紹介するとともにコンテナランタイムを直接動かしてコンテナを作成、起動してみようと思います。

今回紹介するコンテナランタイムの役割と機能についての理解を深めることで、今後より適切なコンテナランタイムを選択して、コンテナのセキュリティレベルを向上させることが可能になります。

対象読者

  • 業務でDockerを利用している方
  • Dockerがどのような仕組みでコンテナを実行しているか気になる方
  • コンテナランタイムやruncという用語を聞いたことがある方

扱う内容

  • Dockerの仕組み
  • コンテナランタイムについて

扱わない内容

  • コンテナ基礎技術であるcapabilityやnamespaceについて

Dockerの内部的な話

Dockerコマンド(docker run等)を実行すると裏側でdockerd(docker deamon)に対してserver-client方式でREST APIが実行されます。
その後、高レベルランタイムであるcontainerd(実態はデーモン)が低レベルランタイムのrunc(実態はバイナリ)を利用してコンテナを作成、起動します。

高レベルランタイム

dockerdからの指示に応じて、イメージの管理からコンテナの作成、起動まで行うデーモンです。
イメージの管理については高レベルランタイム自身が実施していますが、
コンテナの作成、起動に関しては、低レベルランタイム(バイナリ)を実行して低レベルランタイムがコンテナを作成、起動しています。
代表的な高レベルランタイムととしては、Dockerで利用されているcontainerdやkubernates用に最適化されたcri-oなどがあります。

低レベルランタイム

高レベルランタイムに呼ばれて実際にコンテナを起動、実行するバイナリです。
Linuxカーネルの機能であるnamespaceやcgroupを利用してコンテナを起動、実行しています。
OCIランタイム仕様に沿っていれば、別のランタイムに置き換えることも可能です。
代表的なものとして、Dockerで利用されているruncやGoogleが開発したセキュアなコンテナランタイムであるgVisorなどがあります。

実際に触ってみた

今回はDockerのデフォルトランタイムであるruncとcontainerdを実際に動かしてコンテナを実行してみました。

動作環境

  • Ubuntu 20.04
  • docker 20.10.24
  • runc 1.1.9
  • containerd 1.6.23
  • golang 1.20.7
# uname -a
Linux ip-172-28-6-194 5.15.0-1041-aws #46~20.04.1-Ubuntu SMP Wed Jul 19 15:40:00 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux

runcを使ってコンテナを起動してみる

runcのusing-runcを参考にコンテナを起動してみます。

runcをインストールします。

# install -m 755 runc.amd64 /usr/local/sbin/runc

runcのコマンドを確認してみると、普段dockerで利用しているコマンドと同じようなコマンドがありますね。

# runc -help
...
COMMANDS:
   checkpoint  checkpoint a running container
   create      create a container
   delete      delete any resources held by the container often used with detached container
   events      display container events such as OOM notifications, cpu, memory, and IO usage statistics
   exec        execute new process inside the container
   kill        kill sends the specified signal (default: SIGTERM) to the container's init process
   list        lists containers started by runc with the given root
   pause       pause suspends all processes inside the container
   ps          ps displays the processes running inside a container
   restore     restore a container from a previous checkpoint
   resume      resumes all processes that have been previously paused
   run         create and run a container
   spec        create a new specification file
   start       executes the user defined process in a created container
   state       output the state of a container
   update      update container resource constraints
   features    show the enabled features
   help, h     Shows a list of commands or help for one command
...

低レベルランタイムでコンテナを作成する際にコンテナの元になるOCIバンドル(ルートファイルシステム、specファイル)を作成します。

// OCIバンドルを格納するディレクトリの作成
# mkdir /mycontainer
# cd /mycontainer
# mkdir rootfs
// ルートファイルシステムをdocker exportを利用して作成
// specファイルをrunc specで作成
# docker export $(docker create busybox) | tar -C rootfs -xvf -
# runc spec

config.jsonの中身を見ると、コンテナが起動される際の設定(capabilityやnamespaceなど)が書いています。

config.json
config.json
{
	"ociVersion": "1.0.2-dev",
	"process": {
		"terminal": true,
		"user": {
			"uid": 0,
			"gid": 0
		},
		"args": [
			"sh"
		],
		"env": [
			"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
			"TERM=xterm"
		],
		"cwd": "/",
		"capabilities": {
			"bounding": [
				"CAP_AUDIT_WRITE",
				"CAP_KILL",
				"CAP_NET_BIND_SERVICE"
			],
			"effective": [
				"CAP_AUDIT_WRITE",
				"CAP_KILL",
				"CAP_NET_BIND_SERVICE"
			],
			"permitted": [
				"CAP_AUDIT_WRITE",
				"CAP_KILL",
				"CAP_NET_BIND_SERVICE"
			],
			"ambient": [
				"CAP_AUDIT_WRITE",
				"CAP_KILL",
				"CAP_NET_BIND_SERVICE"
			]
		},
		"rlimits": [
			{
				"type": "RLIMIT_NOFILE",
				"hard": 1024,
				"soft": 1024
			}
		],
		"noNewPrivileges": true
	},
	"root": {
		"path": "rootfs",
		"readonly": true
	},
	"hostname": "runc",
	"mounts": [
		{
			"destination": "/proc",
			"type": "proc",
			"source": "proc"
		},
		{
			"destination": "/dev",
			"type": "tmpfs",
			"source": "tmpfs",
			"options": [
				"nosuid",
				"strictatime",
				"mode=755",
				"size=65536k"
			]
		},
		{
			"destination": "/dev/pts",
			"type": "devpts",
			"source": "devpts",
			"options": [
				"nosuid",
				"noexec",
				"newinstance",
				"ptmxmode=0666",
				"mode=0620",
				"gid=5"
			]
		},
		{
			"destination": "/dev/shm",
			"type": "tmpfs",
			"source": "shm",
			"options": [
				"nosuid",
				"noexec",
				"nodev",
				"mode=1777",
				"size=65536k"
			]
		},
		{
			"destination": "/dev/mqueue",
			"type": "mqueue",
			"source": "mqueue",
			"options": [
				"nosuid",
				"noexec",
				"nodev"
			]
		},
		{
			"destination": "/sys",
			"type": "sysfs",
			"source": "sysfs",
			"options": [
				"nosuid",
				"noexec",
				"nodev",
				"ro"
			]
		},
		{
			"destination": "/sys/fs/cgroup",
			"type": "cgroup",
			"source": "cgroup",
			"options": [
				"nosuid",
				"noexec",
				"nodev",
				"relatime",
				"ro"
			]
		}
	],
	"linux": {
		"resources": {
			"devices": [
				{
					"allow": false,
					"access": "rwm"
				}
			]
		},
		"namespaces": [
			{
				"type": "pid"
			},
			{
				"type": "network"
			},
			{
				"type": "ipc"
			},
			{
				"type": "uts"
			},
			{
				"type": "mount"
			}
		],
		"maskedPaths": [
			"/proc/acpi",
			"/proc/asound",
			"/proc/kcore",
			"/proc/keys",
			"/proc/latency_stats",
			"/proc/timer_list",
			"/proc/timer_stats",
			"/proc/sched_debug",
			"/sys/firmware",
			"/proc/scsi"
		],
		"readonlyPaths": [
			"/proc/bus",
			"/proc/fs",
			"/proc/irq",
			"/proc/sys",
			"/proc/sysrq-trigger"
		]
	}
}

runcで先程作成したrootfsとconfig.jsonをもとにコンテナを作成します。

# ls
config.json  rootfs
# runc run mycontainerid
/ # ps
PID   USER     TIME  COMMAND
    1 root      0:00 sh
    7 root      0:00 ps
/ # hostname
runc

runcを利用してコンテナを実行することができました!!🎉

containerdを使ってhelloworldコンテナを起動してみる

getting-startedを参考に進めます。

containerdをインストールします。

# tar Cxzvf /usr/local containerd-1.6.2-linux-amd64.tar.gz

インストールしたcontainerdをデーモン化します。

# systemctl daemon-reload
# systemctl enable --now containerd

containerdを扱うclientコードを書きます。

hello.go
hello.go
package main

import (
	"context"
	"fmt"
	"log"
	"github.com/containerd/containerd"
	"github.com/containerd/containerd/cio"
	"github.com/containerd/containerd/namespaces"
	"github.com/containerd/containerd/oci"
)

func main() {
	if err := helloWorldExample(); err != nil {
		log.Fatal(err)
	}
}

func helloWorldExample() error {
	// create a new client connected to the default socket path for containerd
	client, err := containerd.New("/run/containerd/containerd.sock")
	if err != nil {
		return err
	}
	defer client.Close()

	// create a new context with an "example" namespace
	ctx := namespaces.WithNamespace(context.Background(), "example")

	// pull the hello-world image from DockerHub
	image, err := client.Pull(ctx, "docker.io/library/hello-world:latest", containerd.WithPullUnpack)
	if err != nil {
		return err
	}

	// create a container
	container, err := client.NewContainer(
		ctx,
		"hello-world",
		containerd.WithImage(image),
		containerd.WithNewSnapshot("hello-world-snapshot", image),
		containerd.WithNewSpec(oci.WithImageConfig(image)),
	)
	if err != nil {
		return err
	}
	defer container.Delete(ctx, containerd.WithSnapshotCleanup)

	// create a task from the container
	task, err := container.NewTask(ctx, cio.NewCreator(cio.WithStdio))
	if err != nil {
		return err
	}
	defer task.Delete(ctx)

	// make sure we wait before calling start
	exitStatusC, err := task.Wait(ctx)
	if err != nil {
			fmt.Println(err)
			fmt.Println(exitStatusC)
	}

	// call start on the task to execute the hello-world
	if err := task.Start(ctx); err != nil {
		return err
	}
	return nil
}

実際に実行してみます。

# go build hello.go
# ./hello

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
    (amd64)
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.

To try something more ambitious, you can run an Ubuntu container with:
 $ docker run -it ubuntu bash

Share images, automate workflows, and more with a free Docker ID:
 https://hub.docker.com/

For more examples and ideas, visit:
 https://docs.docker.com/get-started/

hello-worldコンテナをcontainerdを利用して実行することができました!!🎉

まとめ

コンテナランタイムを直接用いてコンテナを実行することで、通常Dockerコマンドを使用する際にバックグラウンドでどのようにコンテナランタイムが動作しているのかを具体的に理解することができました。
コンテナランタイムやDockerの内部構造についてより詳細に知ることができ、今後Dockerのトラブルシューティングやコンテナのセキュリティ向上に今回の知見が役立ちそうです。

次のステップとしては、runcのソースコードを調査し、コンテナがどのようなプロセスを経て実行されているのかを探求したいと考えています。

参考

コンテナランタイムの仕組みと、Firecracker、gVisor、Unikernelが注目されている理由。 Container Runtime Meetup #2
手を動かして学ぶコンテナ標準 - Container Runtime 編
https://zenn.dev/nokute/articles/0a2cfe8ebcd6c7636a0d

SRE Holdings 株式会社

Discussion