Docker内部の仕組みについて手を動かして理解してみた
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
{
"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
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 編
Discussion