最近、趣味で低レイヤコンテナランタイムを実装しているので、 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 list
や runc state
コマンドで、現在のコンテナの状態を確認できる。
- creating
- created
- running
- stopped
また、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.fifo
を cat
などで読み出すと、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)
}
}
処理や実行は次のような流れになる。
- このプログラムを
create
サブコマンドで実行するとinit
サブコマンドが実行される。 init
サブコマンドでは、(本来はコンテナプロセスとして動かす) sleep コマンドを実行するが、その直前で名前付きパイプの作成をして書き込みモードで開いている。読み手がいないので、ここで処理はブロックする。- このプログラムを
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
}