gVisorのコード読んでみた
gVisorとOCIコンテナランタイムのアーキテクチャを探る
動機
これからGKE上でデプロイする時にもしかしたらランタイムを選択するような時が来るかもしれないので適切に取捨選択できるようにしたい.あと自作コンテナランタイムしたい
OCI って何
Open Container Initiativeの略. 各企業が参画してる団体でコンテナ技術の標準化をしてる.
OCI Runtime
コンテナランタイムと言っても色々意味がある. その広さはgccをコンパイラと一括りにしてしまうぐらい広い
例えばgccも内側では cc(compiler)
, gas(assembler)
, ld(linker)
という風にレイヤ分けされている.
コンテナランタイムも同じ.
dockerコマンドも内側では以下の図のように containerd(CRI)
, runc(OCI)
という風にレイヤ分けされている
最終的にコンテナイメージ(ファイルシステムの塊)を実際に読み込んでプロセスとして起動するのがOCI Runtimeの役割.実態としてはAPIサーバでもなんでもなく普通のCLIバイナリ.
OCI RuntimeはOCIが定めたコマンドラインのインタフェースを実装しているCLIツールのことを指している(多分). Runtimeの仕様は https://github.com/opencontainers/runtime-spec ここ参照. Go言語での実装方針についても書いてくれてる.優しい
最近のOCI RuntimeとgVisor
参考: https://qiita.com/mamomamo/items/ed5db2ab1555078f8a24
gVisorはGoogleがオープンソースとしてリリースしたOCI Runtime. まだプロダクション環境での利用はオススメされていない(AppEngineのnodejsアプリはこのgVisor上で動いてるとかなんとか)
既存のコンテナエンジンはセキュリティ的に不安なのでより安全にアプリを実行できるよというもの
何が安全じゃないのか
dockerはデフォルトにOCI Runtimeでrunc(https://github.com/opencontainers/runc) を使う. コンテナとは基本的にOSが管理するプロセスの一個として動くのでホストカーネルに対して直接システムコールを発行して動作する.
システムコールの脆弱性を付いて1プロセスがroot権限を奪取するなんてこともできる(https://ipsj.ixsq.nii.ac.jp/ej/?action=repository_action_common_download&item_id=175781&item_no=1&attribute_id=1&file_no=1) のでまぁ危ない.システムコール 脆弱性でググると事例はたくさん出てくる.
なのでgVisorのアプローチは既存のVMのようにコンテナを隔離しようとした. その結果が以下の図に現れている.
gVisorは主に2個目の図の紫で描かれたコンポーネントを提供する.
実際の処理の流れとしては,
- containerdなどのハイレベルコンテナランタイムがgVisor(runsc)を起動
- gVisorはPOSIXゲストカーネルをユーザ空間に展開.
- アプリケーションプロセスを実行し,システムコールをkvmかptraceでフックし,sentryにリダイレクトする.
- gVisorはユーザカーネル上でそのシステムコールを実行する
コードのアーキテクチャ
. ├── g3doc ├── kokoro ├── pkg │ ├── abi │ │ └── linux │ ├── amutex │ ├── atomicbitops │ ├── binary │ ├── bits │ ├── bpf │ ├── compressio │ ├── control │ │ ├── client │ │ └── server │ ├── cpuid │ ├── eventchannel │ ├── fd │ ├── fdnotifier │ ├── gate │ ├── ilist │ ├── linewriter │ ├── log │ ├── metric │ ├── p9 │ │ ├── local_server │ │ └── p9test │ ├── rand │ ├── refs │ ├── seccomp │ ├── secio │ ├── segment │ │ └── test │ ├── sentry │ │ ├── arch │ │ ├── context │ │ ├── control │ │ ├── device │ │ ├── fs │ │ ├── hostcpu │ │ ├── inet │ │ ├── kernel │ │ ├── limits │ │ ├── loader │ │ ├── memmap │ │ ├── memutil │ │ ├── mm │ │ ├── pgalloc │ │ ├── platform │ │ ├── safemem │ │ ├── sighandling │ │ ├── socket │ │ ├── state │ │ ├── strace │ │ ├── syscalls │ │ ├── time │ │ ├── unimpl │ │ ├── uniqueid │ │ ├── usage │ │ ├── usermem │ │ └── watchdog │ ├── sleep │ ├── state │ │ └── statefile │ ├── syserr │ ├── syserror │ ├── tcpip │ │ ├── adapters │ │ ├── buffer │ │ ├── checker │ │ ├── hash │ │ ├── header │ │ ├── link │ │ ├── network │ │ ├── ports │ │ ├── sample │ │ ├── seqnum │ │ ├── stack │ │ └── transport │ ├── tmutex │ ├── unet │ ├── urpc │ └── waiter ├── runsc │ ├── boot │ │ └── filter │ ├── cgroup │ ├── cmd │ ├── console │ ├── container │ │ └── test_app │ ├── debian │ ├── fsgofer │ │ └── filter │ ├── sandbox │ ├── specutils │ ├── test │ │ ├── image │ │ ├── integration │ │ ├── root │ │ └── testutil │ └── tools │ └── dockercfg ├── test │ ├── syscalls │ │ ├── gtest │ │ └── linux │ └── util ├── third_party │ └── gvsync │ ├── atomicptrtest │ └── seqatomictest ├── tools │ ├── go_generics │ │ ├── generics_tests │ │ ├── globals │ │ ├── go_merge │ │ └── rules_tests │ └── go_stateify └── vdso 119 directories
主に関係あるのが runsc
と pkg/sentry
以下.
runscはOCI RuntimeのCLIを実装しているのでruncと比較するとほぼ同じコードの構造なのがよくわかる.
一番重要なシステムコールのフック,実装を行なっているのが sentry
.
適当なシステムコールの実装を見てみる
pkg/sentry/syscalls/linux
以下に各システムコールの実装がある.
今回はglibcやlinux kernelで実際に調査したことのあるfork周りを追ってみる
forkシステムコールは pkg/sentry/syscalls/linux/sys_thread.go
の176行目に定義されていた
// Fork implements Linux syscall fork(2). func Fork(t *kernel.Task, args arch.SyscallArguments) (uintptr, *kernel.SyscallControl, error) { // "A call to fork() is equivalent to a call to clone(2) specifying flags // as just SIGCHLD." - fork(2) return clone(t, int(syscall.SIGCHLD), 0, 0, 0, 0) }
内部ではclone関数に変換されているのがわかる. glibcのソースをみるとわかるがこの実装はglibcと同じ(厳密にはここから更にインラインアセンブラに変換される)
というわけでclone関数に飛んでみる
func clone(t *kernel.Task, flags int, stack usermem.Addr, parentTID usermem.Addr, childTID usermem.Addr, tls usermem.Addr) (uintptr, *kernel.SyscallControl, error) { opts := kernel.CloneOptions{ SharingOptions: kernel.SharingOptions{ NewAddressSpace: flags&syscall.CLONE_VM == 0, NewSignalHandlers: flags&syscall.CLONE_SIGHAND == 0, NewThreadGroup: flags&syscall.CLONE_THREAD == 0, TerminationSignal: linux.Signal(flags & exitSignalMask), NewPIDNamespace: flags&syscall.CLONE_NEWPID == syscall.CLONE_NEWPID, NewUserNamespace: flags&syscall.CLONE_NEWUSER == syscall.CLONE_NEWUSER, NewNetworkNamespace: flags&syscall.CLONE_NEWNET == syscall.CLONE_NEWNET, NewFiles: flags&syscall.CLONE_FILES == 0, NewFSContext: flags&syscall.CLONE_FS == 0, NewUTSNamespace: flags&syscall.CLONE_NEWUTS == syscall.CLONE_NEWUTS, NewIPCNamespace: flags&syscall.CLONE_NEWIPC == syscall.CLONE_NEWIPC, }, Stack: stack, SetTLS: flags&syscall.CLONE_SETTLS == syscall.CLONE_SETTLS, TLS: tls, ChildClearTID: flags&syscall.CLONE_CHILD_CLEARTID == syscall.CLONE_CHILD_CLEARTID, ChildSetTID: flags&syscall.CLONE_CHILD_SETTID == syscall.CLONE_CHILD_SETTID, ChildTID: childTID, ParentSetTID: flags&syscall.CLONE_PARENT_SETTID == syscall.CLONE_PARENT_SETTID, ParentTID: parentTID, Vfork: flags&syscall.CLONE_VFORK == syscall.CLONE_VFORK, Untraced: flags&syscall.CLONE_UNTRACED == syscall.CLONE_UNTRACED, InheritTracer: flags&syscall.CLONE_PTRACE == syscall.CLONE_PTRACE, } ntid, ctrl, err := t.Clone(&opts) return uintptr(ntid), ctrl, err }
各種フラグの設定をしたあと kernel.Task
の関数 t.Clone()
を読んでいるので今度
はこちらにjump
そうすると今度はファイルが変わり pkg/sentry/kernel/task_clone.go
に飛ぶ.
恐らくこの task_*.go
がlinux kernel側のシステムコール実装に対応していると思う
中身は150行ぐらいあるので省略するが,ユーザネームスペースのコピー,メモリ空間の共有,プロセスファイルの複製などlinux kernelでの処理とほぼ一致していたので間違いないはず.
さて,このシステムコールがどうやってptraceからフックされ,呼び出されるかをもう少し深く探ってみる
ユーザ空間からユーザカーネルへの移動
ユーザーのアプリはdockerの場合containerdから runsc --hoge
のように呼び出されるわけだがその時のエントリーポイントは runsc/cmd/boot.go
にある
こちらもすごい長い関数なので一部抜粋すると
// Create the loader. bootArgs := boot.Args{ ID: f.Arg(0), Spec: spec, Conf: conf, ControllerFD: b.controllerFD, DeviceFD: b.deviceFD, GoferFDs: b.ioFDs.GetArray(), StdioFDs: b.stdioFDs.GetArray(), Console: b.console, NumCPU: b.cpuNum, TotalMem: b.totalMem, UserLogFD: b.userLogFD, } l, err := boot.New(bootArgs) if err != nil { Fatalf("creating loader: %v", err) } // Fatalf exits the process and doesn't run defers. // 'l' must be destroyed explicitly after this point! // Notify the parent process the sandbox has booted (and that the controller // is up). startSyncFile := os.NewFile(uintptr(b.startSyncFD), "start-sync file") buf := make([]byte, 1) if w, err := startSyncFile.Write(buf); err != nil || w != 1 { l.Destroy() Fatalf("unable to write into the start-sync descriptor: %v", err) } // Closes startSyncFile because 'l.Run()' only returns when the sandbox exits. startSyncFile.Close() // Wait for the start signal from runsc. l.WaitForStartSignal() // Run the application and wait for it to finish. if err := l.Run(); err != nil { l.Destroy() Fatalf("running sandbox: %v", err) } ws := l.WaitExit() log.Infof("application exiting with %+v", ws) *waitStatus = syscall.WaitStatus(ws.Status()) l.Destroy() return subcommands.ExitSuccess
l.Run()
という処理がアプリの実行部分である(これは実際にデバッグしてログを確かめたので間違いなし).
l
という変数はloaderというオブジェクトで runsc/boot/loader.go
に実体が定義されてる.
Run()
は run()
をラップしているので run()
の一部を覗くと
log.Infof("Process should have started...") l.watchdog.Start() return l.k.Start()
という処理が見える.l.k
は以下のようにgVisorが実装したユーザスペースカーネルの実体.
type Loader struct { // k is the kernel. k *kernel.Kernel
ここまで辿った後のフローはご親切に公式ドキュメントに次のフロー図が書いてある
kernel.Startによってアプリケーションのコードがgoroutine上で起動する.またptrace,kvmなどのアプリケーションからのシステムコールをフックするものもここらへんで起動してアプリケーションにアタッチする.
kernel.Start関数は内部でtask.Start関数を呼び出す.
task構造体はアプリケーション,システムコール実行その他諸々の責任を担うgVisorにおける処理実行の本体だと思えばいい.
そして上記の図の通りアプリケーションからくるシステムコールの実行部分は pkg/sentry/kernel/task_run.go
に定義されている次の部分
func (*runApp) execute(t *Task) taskRunState { ... switch err { case nil: // Handle application system call. return t.doSyscall() ... }
doSyscallは同じ階層の pkg/sentry/kernel/task_syscall.go
で定義されている
// doSyscall is the entry point for an invocation of a system call specified by // the current state of t's registers. // // The syscall path is very hot; avoid defer. func (t *Task) doSyscall() taskRunState { sysno := t.Arch().SyscallNo() args := t.Arch().SyscallArgs() // Tracers expect to see this between when the task traps into the kernel // to perform a syscall and when the syscall is actually invoked. // This useless-looking temporary is needed because Go. tmp := uintptr(syscall.ENOSYS) t.Arch().SetReturn(-tmp) // Check seccomp filters. The nil check is for performance (as seccomp use // is rare), not needed for correctness. if t.syscallFilters.Load() != nil { switch r := t.checkSeccompSyscall(int32(sysno), args, usermem.Addr(t.Arch().IP())); r { case linux.SECCOMP_RET_ERRNO, linux.SECCOMP_RET_TRAP: t.Debugf("Syscall %d: denied by seccomp", sysno) return (*runSyscallExit)(nil) case linux.SECCOMP_RET_ALLOW: // ok case linux.SECCOMP_RET_KILL_THREAD: t.Debugf("Syscall %d: killed by seccomp", sysno) t.PrepareExit(ExitStatus{Signo: int(linux.SIGSYS)}) return (*runExit)(nil) case linux.SECCOMP_RET_TRACE: t.Debugf("Syscall %d: stopping for PTRACE_EVENT_SECCOMP", sysno) return (*runSyscallAfterPtraceEventSeccomp)(nil) default: panic(fmt.Sprintf("Unknown seccomp result %d", r)) } } return t.doSyscallEnter(sysno, args) }
処理としてはseccompパッケージで定義されているフィルターでチェックしてから実行しているのがわかる.
doSyscallEnterから少し省略するがいくつかのラッパーを経由した後 executeSyscall関数を呼び出す
func (t *Task) executeSyscall(sysno uintptr, args arch.SyscallArguments) (rval uintptr, ctrl *SyscallControl, err error) { s := t.SyscallTable() fe := s.FeatureEnable.Word(sysno) var straceContext interface{} if bits.IsAnyOn32(fe, StraceEnableBits) { straceContext = s.Stracer.SyscallEnter(t, sysno, args, fe) } if bits.IsOn32(fe, ExternalBeforeEnable) && (s.ExternalFilterBefore == nil || s.ExternalFilterBefore(t, sysno, args)) { t.invokeExternal() // Ensure we check for stops, then invoke the syscall again. ctrl = ctrlStopAndReinvokeSyscall } else { fn := s.Lookup(sysno) if fn != nil { // Call our syscall implementation. rval, ctrl, err = fn(t, args) } else { // Use the missing function if not found. rval, err = t.SyscallTable().Missing(t, sysno, args) } } if bits.IsOn32(fe, ExternalAfterEnable) && (s.ExternalFilterAfter == nil || s.ExternalFilterAfter(t, sysno, args)) { t.invokeExternal() // Don't reinvoke the syscall. } if bits.IsAnyOn32(fe, StraceEnableBits) { s.Stracer.SyscallExit(straceContext, t, sysno, rval, err) } return }
システムコールのテーブルを生成し,システムコール番号(システムコールは内部では全て一意な番号が振られている)をキーに上記のforkシステムコールのように pkg/sentry/syscalls/sys_*.go
で実装されているシステムコールの関数ポインタを引っ張ってきて実行しているのがわかる
結果
なんとなくシステムコールフックから実行までの最短距離は見えた気がする. ただいくつかなんのためにやってるかわからない処理があったりしたのでもう少し深く突っ込みたい
所感
一般的にgVisorの処理が遅いと言われているのはこのシステムコールフックとユーザカーネルのせいであるのは自明だった.またissueに上がっている点で言えばネットワーク周りが非常に弱い(ubuntuのイメージ動かしてapt-getが一切動かないレベル)なのでこれからのパフォーマンス改善と使いやすさ向上に期待.
引用
- https://qiita.com/mamomamo/items/ed5db2ab1555078f8a24
- https://thinkit.co.jp/article/14032
- https://github.com/google/gvisor/tree/master/pkg/sentry/kernel
- https://gvisor.dev/docs/architecture_guide/
おまけ: gVisorのデバッグの仕方
環境
Vagrant: Ubuntu18.04
- bazelインストール
- go1.11 >= インストール
- dockerインストール
mkdir ~/go/src//home/vagrant/go/src/gvisor.googlesource.com
git clone https://gvisor.googlesource.com/gvisor gvisor
cd gvisor
ここからビルドしていく. 初回は結構時間かかる
bazel build runsc sudo cp ./bazel-bin/runsc/linux_amd64_pure_stripped/runsc /usr/local/bin
バイナリをコピーしたので次にdocker daemonで動くOCI Runtimeをrunscに切り替える
/etc/docker/daermon.json
をvimで開いて以下のように編集します(最初は存在しないはず)
{ "runtimes": { "runsc": { "path": "/usr/local/bin/runsc", "runtimeArgs": [ "--debug-log=/tmp/runsc/", "--debug", "--strace" ] } } }
debug-logのパスは自由.好きなとこにどうぞ.
--debugフラグオンでlogがいい感じにみれます.
書いたら sudo systemctl restart docker
でデーモンを再起動します.
再起動が終わったらhello worldしてみる.
docker run --runtime=runsc --rm hello-world
これで /tmp/runsc/
以下に
runsc.log.20190517-062853.940057.create runsc.log.20190517-062853.947028.boot runsc.log.20190517-062853.979842.start runsc.log.20190517-062854.139347.state runsc.log.20190517-062853.946004.gofer runsc.log.20190517-062853.973704.state runsc.log.20190517-062854.079995.state runsc.log.20190517-062854.145467.delete
のようにログが吐かれる.*.bootログがアプリケーション実行ログなのでアプリからのシステムコール実行の形跡はこのログで確認できる.