手を動かして学ぶコンテナ標準 - Container Runtime 編

コンテナ標準化の現状と Kubernetes の立ち位置について というブログではコンテナ標準の現状についてまとめてみました。

また、手を動かして学ぶコンテナ標準 - Container Image と Container Registry 編 というブログでは Container Image と Container Registry について手を動かして学んでみました。

このブログでは、runc, containerd などの Container Runtime について、実際に手を動かして学んでみたいと思います。

なお、前回のブログ 同様、基本的にこのブログ内のコマンドは Linux で実行するものとします(自分は MacOSVagrantUbuntu VM を立てて実験してます)。

runc を動かしてみる

runc は Low-Level Container Runtime と呼ばれるもので、OCI Runtime Specification に準拠した CLI tool となっています。実際に runc を動かしてみることで、Container Runtime に対して理解を深めてみましょう。

まず runc の install ですが、自分が試している ubuntu-20.04 では apt で install することができます。

vagrant@vagrant:~$ sudo apt update -y
vagrant@vagrant:~$ sudo apt install -y runc
vagrant@vagrant:~$ which runc
/usr/sbin/runc

help を見てみると以下のような内容になっていて、container を操作するために必要な各種 subcommand が存在することがわかります。実は、これらの subcommand によって OCI Runtime SpecificationRuntime and Lifecycle で定義された「Operations」 の機能が提供されています。ただし、見比べてみると分かりますが runc 自体はよりリッチな機能を提供しているようです。

vagrant@vagrant:~$ runc --help
NAME:
   runc - Open Container Initiative runtime

runc is a command line client for running applications packaged according to
the Open Container Initiative (OCI) format and is a compliant implementation of the
Open Container Initiative specification.

runc integrates well with existing process supervisors to provide a production
container runtime environment for applications. It can be used with your
existing process monitoring tools and the container will be spawned as a
direct child of the process supervisor.

Containers are configured using bundles. A bundle for a container is a directory
that includes a specification file named "config.json" and a root filesystem.
The root filesystem contains the contents of the container.

To start a new instance of a container:

    # runc run [ -b bundle ] <container-id>

Where "<container-id>" is your name for the instance of the container that you
are starting. The name you provide for the container instance must be unique on
your host. Providing the bundle directory using "-b" is optional. The default
value for "bundle" is the current directory.

USAGE:
   runc [global options] command [command options] [arguments...]

VERSION:
   spec: 1.0.1-dev

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
     init        initialize the namespaces and launch the process (do not call it outside of runc)
     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
     help, h     Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --debug             enable debug output for logging
   --log value         set the log file path where internal debug information is written
   --log-format value  set the format used by logs ('text' (default), or 'json') (default: "text")
   --root value        root directory for storage of container state (this should be located in tmpfs) (default: "/run/user/1000/runc")
   --criu value        path to the criu binary used for checkpoint and restore (default: "criu")
   --systemd-cgroup    enable systemd cgroup support, expects cgroupsPath to be of form "slice:prefix:name" for e.g. "system.slice:runc:434234"
   --rootless value    ignore cgroup permission errors ('true', 'false', or 'auto') (default: "auto")
   --help, -h          show help
   --version, -v       print the version

実際に runc を利用して container を起動してみましょう。runc の README の Using runc というセクションがぴったりの題材なので、これを参考にしてみます。

OCI Runtime Speicification の内容を見てもらうと分かるのですが、runc のような Low-Level Container Runtime は Filesystem Bundle と呼ばれる「Container を起動するのに利用される configuration file と、container にとっての root filesystem となる directory の組み合わせ」を必要とします。まずはこの Filesystem Bundle を用意します。

Filesystem Bundle は config.json と呼ばれる configuration file と、その config.json の中の root.path で path を指定される 「container の root filesystem となる directory」から構成されます。それぞれ順番に作成していきます。

まずは Filesystem Bundle を格納する directory として mycontainer という directory を作っておきます。

vagrant@vagrant:~$ mkdir mycontainer
vagrant@vagrant:~$ cd mycontainer/

次に、config.json についてですが、 $ runc spec を実行すると自動でデフォルト設定の config.json が作成されます。今回はこれを使うことにしましょう。

vagrant@vagrant:~/mycontainer$ runc spec
vagrant@vagrant:~/mycontainer$ ls
config.json

デフォルト設定の config.json は以下のような内容になっています。実行時のコマンドや環境変数、root.path などに加えて、namespace や volume mount, capability の設定なども記載されています。

vagrant@vagrant:~/mycontainer$ cat config.json
{
        "ociVersion": "1.0.1-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"
                        ],
                        "inheritable": [
                                "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"
                ]
        }

次に、container の root filesystem となる directory を用意します。今回は楽をするために、「$ docker export の機能を利用する」ことにします(自分は vagrant で synced_folder した directory で docker export しました)。原理的には、どんな手段で用意しても良いはずです。

$ mkdir rootfs
$ docker export $(docker create busybox) | tar -C rootfs -xvf -

上記コマンドまで実行が終わると、mycontainer directory の中は以下のような状態になっているはずです。

vagrant@vagrant:~/mycontainer$ ls
config.json  rootfs

この状態で $ sudo runc run containerid を実行すると、container が起動します。

vagrant@vagrant:~/oci-playground/runc-playground/mycontainer2$ sudo runc run containerid
/ #

上記で作成した config.json はデフォルトで「sh コマンドを実行する」という設定になっているので、sh が起動しています。

そのため、一度 exit してから config.json を例えば「ls を実行するもの」に書き換えると、ls が container 内で実行されます。

vagrant@vagrant:~/oci-playground/runc-playground/mycontainer2$ vim config.json  # ここで以下のように変更
                "args": [
-                       "sh"
+                       "ls"
                ],

vagrant@vagrant:~/oci-playground/runc-playground/mycontainer2$ sudo runc run containerid
bin   dev   etc   home  proc  root  sys   tmp   usr   var

ということで、runc を利用して無事に container を起動することができました!

config.json + container の root filesystem になる directory という「Filesystem Bundle」を用意さえすれば、「container の起動が簡単に出来る」ということが分かったかと思います。なお、これは OCI Runtime Specification で定められている挙動であるため、この仕様を満たしてさえいれば「runc 以外の Low-Level Container Runtime」においても同様のことができるはずです。

上記の例では runc run を利用しましたが、その他のオペレーション(create, start, delete など)を利用することでより細かな制御も可能です。詳細が気になる方は、ぜひご自分でも試してみてください。

containerd を動かしてみる

Low-Level Container Runtime の runc の次は、High-Level Container Runtime である containerd を動かしてみましょう。runc は「container を起動する」という部分は実行してくれましたが、それ以外の「Container Image の Pull、Container Image から Filesystem Bundle への変換」などはやってくれませんでした。実は、この部分の機能を提供してくれてるのが containerd です。

containerd は、daemon として常駐して、client からの request を受け付けて動作するという振る舞いになっています。containerd の動作のイメージを掴むために、Getting started というページに従って containerd を利用してみましょう。

まずは、containerd downloads の Installing binaries に従って containerd の binary を download します。そして、その binary を /usr/local/bin など PATH の通った場所に配置します。

vagrant@vagrant:~$ wget https://github.com/containerd/containerd/releases/download/v1.4.3/containerd-1.4.3-linux-amd64.tar.gz
vagrant@vagrant:~$ tar xvf containerd-1.4.3-linux-amd64.tar.gz
vagrant@vagrant:~$ sudo mv bin/* /usr/local/bin/

これで、containerd がコマンドとして利用できるようになります。

vagrant@vagrant:~$ which containerd
/usr/local/bin/containerd

vagrant@vagrant:~$ containerd --help
NAME:
   containerd -
                    __        _                     __
  _________  ____  / /_____ _(_)___  ___  _________/ /
 / ___/ __ \/ __ \/ __/ __ `/ / __ \/ _ \/ ___/ __  /
/ /__/ /_/ / / / / /_/ /_/ / / / / /  __/ /  / /_/ /
\___/\____/_/ /_/\__/\__,_/_/_/ /_/\___/_/   \__,_/

high performance container runtime


USAGE:
   containerd [global options] command [command options] [arguments...]

VERSION:
   v1.4.3

DESCRIPTION:

containerd is a high performance container runtime whose daemon can be started
by using this command. If none of the *config*, *publish*, or *help* commands
are specified, the default action of the **containerd** command is to start the
containerd daemon in the foreground.


A default configuration is used if no TOML configuration is specified or located
at the default file location. The *containerd config* command can be used to
generate the default configuration for containerd. The output of that command
can be used and modified as necessary as a custom configuration.

COMMANDS:
   config    information on the containerd config
   publish   binary to publish events to containerd
   oci-hook  provides a base for OCI runtime hooks to allow arguments to be injected.
   help, h   Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --config value, -c value     path to the configuration file (default: "/etc/containerd/config.toml")
   --log-level value, -l value  set the logging level [trace, debug, info, warn, error, fatal, panic]
   --address value, -a value    address for containerd's GRPC server
   --root value                 containerd root directory
   --state value                containerd state directory
   --help, -h                   show help
   --version, -v                print the version

ただし、前述したように containerd は daemon として動作する必要があるので、これだけではまだ利用できません。

Getting started を見ると、どうやら containerd のソースコードに同梱されている containerd.service に言及しています。これを利用して、systemd で containerd を起動することにしてみましょう。

vagrant@vagrant:~$ sudo apt install -y unzip  # unzip を install しておく

vagrant@vagrant:~$ wget https://github.com/containerd/containerd/archive/v1.4.3.zip  # ソースコードを取得
vagrant@vagrant:~$ unzip v1.4.3.zip
vagrant@vagrant:~$ ls
containerd-1.4.3  v1.4.3.zip

vagrant@vagrant:~$ ls containerd-1.4.3/containerd.service  # systemd 向けの service file が存在
containerd-1.4.3/containerd.service

vagrant@vagrant:~$ sudo cp containerd-1.4.3/containerd.service /etc/systemd/system/
vagrant@vagrant:~$ sudo systemctl enable containerd
Created symlink /etc/systemd/system/multi-user.target.wants/containerd.service → /etc/systemd/system/containerd.service.
vagrant@vagrant:~$ sudo systemctl start containerd

これで、無事 containerd が起動しました。

vagrant@vagrant:~$ ps aux | grep containerd
root       13444  1.6  4.5 1344964 45620 ?       Ssl  16:39   0:00 /usr/local/bin/containerd
vagrant    13455  0.0  0.0   9032   664 pts/0    S+   16:39   0:00 grep --color=auto containerd

Getting started の document には /etc/containerd/config.toml の存在も言及されているので、これも用意しておきます。これで準備 OK です。

vagrant@vagrant:~$ sudo mkdir /etc/containerd
vagrant@vagrant:~$ sudo vim /etc/containerd/config.toml  # ここで containerd の設定ファイルである config.toml を作成
vagrant@vagrant:~$ sudo systemctl restart containerd  # ここで、設定ファイルを読み込む形で containerd を再起動
# これが /etc/containerd/config.toml の内容
subreaper = true
oom_score = -999

[debug]
        level = "debug"

[metrics]
        address = "127.0.0.1:1338"

[plugins.linux]
        runtime = "runc"
        shim_debug = true

さて、containerd が daemon として動くようになりました。次は、containerd と通信するコードを書いてみましょう。Getting started を参考に、以下のような Go コードを書いてみます。

package main

import (
    "context"
    "fmt"
    "log"
    "syscall"
    "time"

    "github.com/containerd/containerd"
    "github.com/containerd/containerd/cio"
    "github.com/containerd/containerd/namespaces"
    "github.com/containerd/containerd/oci"
)

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

func redisExample() 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 redis image from DockerHub
    image, err := client.Pull(ctx, "docker.io/library/redis:alpine", containerd.WithPullUnpack)
    if err != nil {
        return err
    }

    // create a container
    container, err := client.NewContainer(
        ctx,
        "redis-server",
        containerd.WithImage(image),
        containerd.WithNewSnapshot("redis-server-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)
    }

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

    // sleep for a lil bit to see the logs
    time.Sleep(3 * time.Second)

    // kill the process and get the exit status
    if err := task.Kill(ctx, syscall.SIGTERM); err != nil {
        return err
    }

    // wait for the process to fully exit and print out the exit status
    status := <-exitStatusC // Block here.
    code, _, err := status.Result()
    if err != nil {
        return err
    }
    fmt.Printf("redis-server exited with status: %d\n", code)

    // For Debug
    fmt.Printf("Client: %v\n", client)
    fmt.Printf("Image: %v\n", image)
    fmt.Printf("Container: %v\n", container)
    fmt.Printf("Task: %v\n", task)

    return nil
}

「Go 環境の構築」は良い感じにやってください。その状態で上記の Go コードを実行すると、以下のような出力が行われます。

vagrant@vagrant:~/containerd-playground$ go build main.go && sudo ./main
1:C 10 Dec 2020 17:11:43.852 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
1:C 10 Dec 2020 17:11:43.852 # Redis version=6.0.9, bits=64, commit=00000000, modified=0, pid=1, just started
1:C 10 Dec 2020 17:11:43.852 # Warning: no config file specified, using the default config. In order to specify a config file use redis-server /path/to/redis.conf
1:M 10 Dec 2020 17:11:43.856 # You requested maxclients of 10000 requiring at least 10032 max file descriptors.
1:M 10 Dec 2020 17:11:43.856 # Server can't set maximum open files to 10032 because of OS error: Operation not permitted.
1:M 10 Dec 2020 17:11:43.856 # Current maximum open files is 1024. maxclients has been reduced to 992 to compensate for low ulimit. If you need higher maxclients increase 'ulimit -n'.
1:M 10 Dec 2020 17:11:43.858 * Running mode=standalone, port=6379.
1:M 10 Dec 2020 17:11:43.858 # Server initialized
1:M 10 Dec 2020 17:11:43.858 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
1:M 10 Dec 2020 17:11:43.858 * Ready to accept connections
1:signal-handler (1607620306) Received SIGTERM scheduling shutdown...
1:M 10 Dec 2020 17:11:46.877 # User requested shutdown...
1:M 10 Dec 2020 17:11:46.877 * Saving the final RDB snapshot before exiting.
1:M 10 Dec 2020 17:11:46.879 * DB saved on disk
1:M 10 Dec 2020 17:11:46.879 # Redis is now ready to exit, bye bye...
redis-server exited with status: 0
Client: &{{<nil> <nil> <nil> <nil> map[] <nil> <nil> <nil> <nil> <nil>} {0 0} 0xc00010ee00 io.containerd.runc.v2  {0xc0002131a0} 0xe6c020}
Image: &{0xc0002f4000 {docker.io/library/redis:alpine map[] {application/vnd.docker.distribution.manifest.list.v2+json sha256:b0e84b6b92149194d99953e44f7d1fa1f470a769529bb05b4164eae60d8aea6c 1645 [] map[] <nil>} {312279631 63742833489 <nil>} {749989962 63743217103 <nil>}} {0xc0002131a0}}
Container: &{0xc0002f4000 redis-server {redis-server map[] docker.io/library/redis:alpine {io.containerd.runc.v2 <nil>} 0xc0003ce1e0 redis-server-snapshot overlayfs {775955650 63743217103 <nil>} {775955650 63743217103 <nil>} map[]}}
Task: &{0xc0002f4000 0xc0001020c0 0xc0003a86c0 redis-server 65235}

上記の Go コードおよび出力について少し説明します。

上記の Go コードは「containerd と /run/containerd/containerd.sock を通じて通信する client の動作」が記述されています。コード内では client の作成、Container Image の pull、Container Image からの Snapshot (= Container 実行時に root filesystem として利用するもの) および Spec (= Container 実行時のメタデータ、Filesystem Bundle における config.json に相当) の作成、Task (= container の中で実際に実行したいコマンド) の作成、開始、終了およびその待ち合わせを行っています。 また、最後にデバッグ用途に様々な struct を print してみています(これは Getting started に掲載されていたコードに自分が後から追加したものです)。

Pull している Container Image は docker.io/library/redis:alpine で、ログの出力としても redis server の起動および終了が行われていることが分かります。

これで、containerd を実際に利用してみる事が出来ました!

なお、containerd は OSS でコードが公開されているので、気になった挙動はコードを読んで理解する事が出来ます。例えば、 containerd.WithNewSnapshotcontainerd.WithNewSpec というのは以下のコードに該当します。

https://github.com/containerd/containerd/blob/v1.4.3/container_opts.go#L144-L169 https://github.com/containerd/containerd/blob/v1.4.3/container_opts.go#L243-L253

前者のコードを読み進めると「Snapshotter によって Snapshot 作成を行って、Container には SnapshotKey を設定して id を通して参照できるようにしている」事が分かりますし、後者のコードを読み進めると「OCI Runtime Specification の Go binding で定義された Spec struct が generate されて利用される」事が分かります。「runc などの Low-Level Container Runtime が必要とする情報を用意する」という部分をしっかりやってくれてるようです(注: これは自分がコードを読んだ理解なので、誤解してる可能もあります。何か気がついた際はコメントいただけるとありがたいです)。

その他、Container Image を Pull している部分や Task の管理部分なども興味深い部分です。気になった箇所はぜひ読み進めて理解を深めてみてください。

まとめ

runccontainerd などの Container Runtime を実際に触ってみる事で、理解を深めました。runc については、OCI Runtime Specification で定義された挙動が CLI tool として提供されている事が分かりました。containerd については、Container Image の Pull や Container Image から Filesystem Bundle 相当の情報(= Container 実行に必要な Snapshot および Spec)への変換、Container や Task の作成・削除・開始などの管理を行っている事が分かりました。

runccontainerd は扱っている領域が異なる一方で、どちらも「Container Runtime」という名前で呼ばれるために混乱してしまいがちです。実際に手を動かしてみる事で、「それぞれの責任範囲」や「レイヤー構造」について理解が深まったように思います。

なお、このブログ自体は自分の理解のために試したことをまとめたものですが、誰か他の人にとっても理解を助けるものになっていればとても幸いです。

Vagrant での実験環境

以下のような Vagrantfile を使ってます。ubuntu-20.04 を使ってます。

# -*- mode: ruby -*-
# vi: set ft=ruby :

VAGRANTFILE_API_VERSION = "2"

Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
  config.vm.box = "bento/ubuntu-20.04"
  config.vm.synced_folder "./", "/home/vagrant/oci-playground"
end