2024年11月のruncの実装
December 8, 2024runcは抽象度の低いコンテナのランタイムであると同時に、そのランタイムを操作するCLIでもある。 もともとruncはdockerの一部だったが、2015年にdockerからのスピンアウトされた。 containerdがruncでコンテナの操作するので、今でもdockerはruncでコンテナを操作していることになる。 この記事は、runcの実装に使われているLinuxの機能を紹介する。
runcの使い方
runc
でコンテナを起動する場合、イメージはいらず、コンテナの/
以下がホストにあればよい。
たとえば、次のコマンドでコンテナを起動できる。
mkdir rootfs
# copy the / in the busybox to rootfs
docker export $(docker create busybox) | tar -C rootfs -xvf -
# generate ./config.json
runc spec
# create the container and attach to the launched /bin/sh in the container
runc --root /tmp/runc run
コンテナを起動する前にbusyboxから/
をローカルにコピーし、コンテナ内の/bin/sh
にアタッチしている。
runcのrunの実装
runc
には複数のサブコマンドがあり、run
はコンテナを作り、そのコンテナを起動する。
前述のコマンドを実行するときのrunc
の様子を以下の図示する。
run
は非同期にrunc init
コマンドを実行し、このプロセスがexecve
でコンテナのPID 1のプロセスになる。
ホストとコンテナ間の標準IO
ホストとコンテナ間の標準IOの通信は疑似端末(pty
)で実装されている。
pty
の実体は、一方の書き込みが他方の読み込みになる双方向通信可能なキャラクターデバイスのペアであり、ペアの要素はマスターとスレーブとよばれる。
マスターは/dev/ptmx
であり、このファイルを開くと/dev/pts
の下にキャラクターデバイスがスレーブとして作られる。
init
が、マスターとスレーブを作り、socketpair
で作られたソケットの組を介してマスターをホストに送る。
ソケットの作るのはホストであり、setupIO
関数の以下の箇所でソケットの組を作る。
parent, child, err := utils.NewSockPair("console")
if err != nil {
return nil, err
}
child
ソケットをinit
に渡すために、run
はソケットをinit
を実行するCmd
構造体のExtraFiles
の配列に入れる。
Cmd
で呼びだされたプロセスはExtraFiles
で指定されたファイルを開いた状態で開始し、ExtraFiles
のi
番目の要素のファイル記述子は3+i
になる。
newParentProcess関数の以下の箇所で、child
ソケットに束縛されたp.ConsoleSocket
をExtraFiles
に入れ、環境変数_LIBCONTAINER_CONSOLE
でinit
プロセスにファイル記述子を伝える。
cmd.ExtraFiles = append(cmd.ExtraFiles, p.ExtraFiles...)
if p.ConsoleSocket != nil {
cmd.ExtraFiles = append(cmd.ExtraFiles, p.ConsoleSocket)
cmd.Env = append(cmd.Env,
"_LIBCONTAINER_CONSOLE="+strconv.Itoa(stdioFdCount+len(cmd.ExtraFiles)-1),
)
}
init
は、/dev/ptmx
を開き、以下のSendRawFd
関数の中で、マスターのファイル記述子をchild
ソケットに書き込む。
// SendRawFd sends a specific file descriptor over the given AF_UNIX socket.
func SendRawFd(socket *os.File, msg string, fd uintptr) error {
oob := unix.UnixRights(int(fd))
return unix.Sendmsg(int(socket.Fd()), []byte(msg), oob, nil, 0)
}
init
はスレーブを以下のdupStdio
関数で標準IOとエラー出力に複製する。
// dupStdio opens the slavePath for the console and dups the fds to the current
// processes stdio, fd 0,1,2.
func dupStdio(slavePath string) error {
fd, err := unix.Open(slavePath, unix.O_RDWR, 0)
if err != nil {
return &os.PathError{
Op: "open",
Path: slavePath,
Err: err,
}
}
for _, i := range []int{0, 1, 2} {
if err := unix.Dup3(fd, i, 0); err != nil {
return err
}
}
return nil
}
また、init
は次のSetctty
関数でスレーブを制御端末にする。
func Setctty() error {
if err := unix.IoctlSetInt(0, unix.TIOCSCTTY, 0); err != nil {
return err
}
return nil
}
他方、run
プロセスはrecvtty
関数で
recvmsg
で受け取ったマスターをepoll
で監視し、recvtty
関数の以下のコードで、マスターから読みとったストリームを標準出力にコピーし、ホストの標準出力をマスターにコピーする。
go func() { _ = epoller.Wait() }()
go func() { _, _ = io.Copy(epollConsole, os.Stdin) }()
t.wg.Add(1)
go t.copyIO(os.Stdout, epollConsole)
名前空間
コンテナ同士やホストとコンテナ間でネットワークやマウントポイントなどのリソースを隔離、共有する機能は、名前空間で実装されている。 おなじ名前空間にあるプロセス同士で端末のリソースを共有し、プロセスは別の名前空間で消費されるリソースの影響を受けない。 プロセスからは、おなじ名前空間にあるプロセスだけで端末のリソースを占有しているようにみえる。 たとえば、2つの名前空間のプロセスに同じPIDが割りあてることができる。
プロセスの名前空間を変更するAPIにclone
, setns
, unshare
がある。
clone
は新しいプロセスを作る関数であり、新しい名前空間を作り、子プロセスをその名前空間の中に生成する。
setns
は、setns
を呼びだしたスレッドを既存の名前空間に移動する。
unshare
は、新しく名前空間を作って呼出したプロセスをその名前空間に移す。
Cのnsexec
関数でinit
プロセスの名前空間が変更される。
nsexec
は、以下のnsenter.go
でGoのランタイムが始まる前に呼び出される。
//go:build linux && !gccgo
package nsenter
/*
#cgo CFLAGS: -Wall
extern void nsexec();
void __attribute__((constructor)) init(void) {
nsexec();
}
*/
import "C"
README.mdにはGoがマルチスレッドだからCで名前空間を切り換えると書かれてある。
これは、setns
は呼出元のスレッドの名前空間を切り換えるが、スレッドが複数あると、すべてのスレッドでsetns
を呼ぶ必要があるということだろう。
nsexec
は、2度clone
を呼出し、init
の名前空間を変更する。
以下にnsexec
の処理を図示する。
最後に生成されたプロセスのみreturn
文を実行し、Goのmain
関数が続き、残るプロセスはGoのランタイムを開始することなく終了する。
nsexec
は、ある名前空間の中でinit
プロセスにPID1を割り当てるためにclone
を実行する。
unshare
も新しい名前空間を作成する関数であるが、unshare
は呼出元のプロセス名前空間を変更できないためにclone
が使わているのだろう。
clone
にはCLONE_PARENT
フラグを渡し、新しい子init
の親プロセスをclone
を呼出したプロセスの親プロセスにする。
左のinit
プロセスの親はrun
なので、return
文を実行し、Goのmain
関数に続く最後のinit
プロセスの親もrun
になる。
最後のinit
の親をrun
にすることで、init
のSIGCHLD
をrun
に届けることができる。
最初にclone
された図の真ん中にあるinit
プロセスは、setns
で既存の名前空間に参加したり、unshare
で新しく作った名前空間に移動したりする。
/proc/<pid>/uid_map
, /proc/<pid>/gid_map
はinit
のユーザID, グループIDに対応するホストのユーザIDグループIDを定義したファイルであり、init
のユーザーやグループは、ホストからはファイルが対応づけたホストのユーザやグループのようにみえる。
ルートファイルシステム
pivot_root
でinit
のルートファイルシステムをコンテナのディレクトリのルートに置き換える。
以下のコードはpivot_root
の実行例であり、rootfs
のトップディレクトリをルートファイルシステムに設定している。
cd rootfs && mkdir put_old
# run /bin/sh in a a new namespace
unshare -mpfr /bin/sh
# the first argument of pivot_root must be a mount point
mount --bind $(pwd) $(pwd)
# Place the original root in put_old. Can be unmounted later
pivot_root $(pwd) $(pwd)/put_old
ホストのプロセスとコンテナの違い
ここまでで、ほかのプロセスとコンテナを隔離するために使われた技術は名前空間とpivot_root
があった。
ほかにもcgroupなどのコンテナを隔離するために使わている技術があるが、cgroupも名前空間やpivot_root
と同様にコンテナに用途を限定した技術ではない。
runc
の実装をみれば、コンテナの実体は、通常のプロセスとは全く違う特殊なプロセスではなく、cgroup, 名前空間, pivot_root
でほかのプロセスから隔離されたプロセスにすぎないと思えてくるだろう。