最近、趣味で低レイヤコンテナランタイムを実装しているので、 runc のコードを読んだり、挙動を確認している。コンテナのステータスの変化を確認していたとき、 runc create して runc init コマンドが実行されると、それが runc start するまで残り続けていることが分かった。
今回はこれがどうやって実現されているのか調べたので、そのメモになります。

$ runc -v
runc version 1.1.2
commit: v1.1.2-0-ga916309
spec: 1.0.2-dev
go: go1.17.11
libseccomp: 2.5.3

$ sudo runc create test
$ sudo runc list
ID          PID         STATUS      BUNDLE                                             CREATED                          OWNER
test        69401       created     /home/mrtc0/go/src/github.com/mrtc0/noic/tmp/oci   2022-10-01T04:13:10.491649536Z   root
# runc init が残り続けている
$ ps aux | grep runc
root       69401  0.0  0.0 1009912 9868 ?        Ssl  13:13   0:00 runc init

コンテナのライフサイクル

前提として、OCI Runtime Spec には次のステータスが定義されている。runc listrunc state コマンドで、現在のコンテナの状態を確認できる。

また、Spec にあるように追加のステータスを定義してもよく、runc では paused が存在する。

runc create したときの挙動

ここからが今回調べたことの本題。冒頭で書いたように、runc create すると runc init プロセスが生存し続けている。
runc create のプロセスが終了したあとに、このプロセスがどう生き続けているのか気になったので調べた。

$ sudo runc create test
$ sudo runc list
ID          PID         STATUS      BUNDLE                                             CREATED                          OWNER
test        69401       created     /home/mrtc0/go/src/github.com/mrtc0/noic/tmp/oci   2022-10-01T04:13:10.491649536Z   root
# runc init が残り続けている
$ ps aux | grep runc
root       69401  0.0  0.0 1009912 9868 ?        Ssl  13:13   0:00 runc init

結論としては https://groups.google.com/a/opencontainers.org/g/dev/c/ZKIFytzvilE にあるように、名前付きパイプ(FIFO)を使っているようだった。

runc が作成する名前付きパイプ

名前付きパイプについては udzura さんのブログ記事 に分かりやすくまとまっている。
この記事に書かれているように、名前付きパイプは次のような特徴を持つ。

つまり、名前付きパイプを Read / Write で open する場合は、相手が open するまでブロックされる。この性質を利用して、プロセスが親子関係になくとも「あるプロセスが何らかの処理を完了するまで、別のプロセスで待つ」ことができる。
このあたりは、「詳解 LINUX カーネル 第3版」のプロセス間通信の章にも書いてある。

runc でもこの仕組みを利用して、「runc start するまで runc init (コンテナプロセスの実行) を待機する」を実現している。

実際に runc create すると /run/runc/<container-id>/ 配下に exec.fifo という名前付きパイプが作成される。

$ sudo ls -al /run/runc/test/exec.fifo
prw--w--w- 1 root root 0 Oct  1 13:13 /run/runc/test/exec.fifo

$ sudo file /run/runc/test/exec.fifo
/run/runc/test/exec.fifo: fifo (named pipe)

exec.fifocat などで読み出すと、runc init プロセスは処理を開始して、コンテナプロセスを開始し、終了する。

$ sudo cat /run/runc/test/exec.fifo
0

$ sudo runc list
ID          PID         STATUS      BUNDLE                                             CREATED                          OWNER
test        0           stopped     /home/mrtc0/go/src/github.com/mrtc0/noic/tmp/oci   2022-10-01T04:13:10.491649536Z   root

実装

この名前付きプロセスを使った処理の待機を実装すると次のようになる。

package main

import (
	"log"
	"os"
	"os/exec"

	"golang.org/x/sys/unix"
	"github.com/urfave/cli/v2"
)

// 名前付きパイプのパス
const path = "/tmp/exec.fifo"

func createFifoFile() error {
	if err := unix.Mkfifo(path, 0o622); err != nil {
		return err
	}

	return nil
}

func openFifoFile() error {
	_, err := unix.Open(path, unix.O_WRONLY|unix.O_CLOEXEC, 0)
	if err != nil {
		return err
	}

	return nil
}

func main() {
	app := &cli.App{
		Commands: []*cli.Command{
			{
				Name:    "init",
				Aliases: []string{"i"},
				Usage:   "init",
				Action: func(cCtx *cli.Context) error {
                    // 名前付きパイプ(FIFO)の作成
					err := createFifoFile()
					if err != nil {
						return err
					}

                    // 書き込み専用で開くため、ここでブロックする
					err = openFifoFile()
					if err != nil {
						return err
					}

					command := []string{"/usr/bin/sleep", "10"}

					for {
						err := unix.Exec(command[0], command[0:], []string{})
						if err != unix.EINTR {
							return err
						}
					}
				},
			},
			{
				Name:    "create",
				Aliases: []string{"c"},
				Usage:   "create",
				Action: func(cCtx *cli.Context) error {
					initCmd, err := os.Readlink("/proc/self/exe")
					if err != nil {
						return err
					}
					cmd := exec.Command(initCmd, "init")
					cmd.Stdin = os.Stdin
					cmd.Stdout = os.Stdout
					cmd.Stderr = os.Stderr

					cmd.Start()

					return nil
				},
			},
			{
				Name:    "start",
				Aliases: []string{"s"},
				Usage:   "start",
				Action: func(cCtx *cli.Context) error {
                    // 読み込み専用で開くため、ブロックが解除される
					_, err := os.OpenFile(path, os.O_RDONLY, 0)
					if err != nil {
						return err
					}
					return nil
				},
			},
		},
	}

	if err := app.Run(os.Args); err != nil {
		log.Fatal(err)
	}
}

処理や実行は次のような流れになる。

  1. このプログラムを create サブコマンドで実行すると init サブコマンドが実行される。
  2. init サブコマンドでは、(本来はコンテナプロセスとして動かす) sleep コマンドを実行するが、その直前で名前付きパイプの作成をして書き込みモードで開いている。読み手がいないので、ここで処理はブロックする。
  3. このプログラムを start サブコマンドで実行すると、init サブコマンドで作成された名前付きパイプを読み込みモードで開く。そのタイミングで init の処理が再開されて、sleep コマンドが実行される

実際に試すと runc と同じ挙動が確認できる。

$ go build main.go
$ ./main create
$ file /tmp/exec.fifo
/tmp/exec.fifo: fifo (named pipe)

$ ps auxf | grep 'main init'
mrtc0     128051  0.0  0.0 704676  3852 pts/2    Sl   13:58   0:00  \_ /home/mrtc0/tmp/go/container/pipe/main init

$ ps auxf | grep 'main init'
$ ps auxf | grep 'sleep'
mrtc0     128051  0.0  0.0   2788  1008 pts/2    S    13:58   0:00  \_ /usr/bin/sleep 10

コンテナのステータスはどこにあるのか

ついでに、コンテナのステータスがどこから判定されているのかについてもメモしておく。当初は /run/runc/<container-id>/state.json に含まれているのかと思っていたが、プロセスの状態から判定していた。
initProcess が存在しなければ Stopped 扱い、exec.fifo ファイルがあれば Created 扱い、それ以外は Running 扱いになる。つまり、runc start したあとは exec.fifo ファイルは削除される。

// from: https://github.com/opencontainers/runc/blob/1c3b8dbaf440d16653d834b612258bbb28268730/libcontainer/container_linux.go#L1971-L1989
func (c *Container) runType() Status {
	if c.initProcess == nil {
		return Stopped
	}
	pid := c.initProcess.pid()
	stat, err := system.Stat(pid)
	if err != nil {
		return Stopped
	}
	if stat.StartTime != c.initProcessStartTime || stat.State == system.Zombie || stat.State == system.Dead {
		return Stopped
	}
	// We'll create exec fifo and blocking on it after container is created,
	// and delete it after start container.
	if _, err := os.Stat(filepath.Join(c.root, execFifoFilename)); err == nil {
		return Created
	}
	return Running
}