SWEet

A Software Engineer Is Eating Technologies

gVisorのコード読んでみた

gVisorとOCIコンテナランタイムのアーキテクチャを探る

動機

これからGKE上でデプロイする時にもしかしたらランタイムを選択するような時が来るかもしれないので適切に取捨選択できるようにしたい.あと自作コンテナランタイムしたい

OCI って何

Open Container Initiativeの略. 各企業が参画してる団体でコンテナ技術の標準化をしてる.

OCI Runtime

コンテナランタイムと言っても色々意味がある. その広さはgccコンパイラと一括りにしてしまうぐらい広い

例えばgccも内側では cc(compiler), gas(assembler), ld(linker) という風にレイヤ分けされている.

コンテナランタイムも同じ. dockerコマンドも内側では以下の図のように containerd(CRI), runc(OCI) という風にレイヤ分けされている

スクリーンショット 2019-05-17 11.06.59.png (349.5 kB)

最終的にコンテナイメージ(ファイルシステムの塊)を実際に読み込んでプロセスとして起動するのが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のようにコンテナを隔離しようとした. その結果が以下の図に現れている.

スクリーンショット 2019-05-17 11.17.47.png (119.2 kB)

スクリーンショット 2019-05-17 11.40.14.png (73.8 kB)

gVisorは主に2個目の図の紫で描かれたコンポーネントを提供する.

実際の処理の流れとしては,

  1. containerdなどのハイレベルコンテナランタイムがgVisor(runsc)を起動
  2. gVisorはPOSIXゲストカーネルをユーザ空間に展開.
  3. アプリケーションプロセスを実行し,システムコールkvmかptraceでフックし,sentryにリダイレクトする.
  4. 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

主に関係あるのが runscpkg/sentry 以下. runscはOCI RuntimeのCLIを実装しているのでruncと比較するとほぼ同じコードの構造なのがよくわかる.

一番重要なシステムコールのフック,実装を行なっているのが sentry

適当なシステムコールの実装を見てみる

pkg/sentry/syscalls/linux 以下に各システムコールの実装がある. 今回はglibclinux 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_*.golinux 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

ここまで辿った後のフローはご親切に公式ドキュメントに次のフロー図が書いてある

スクリーンショット 2019-05-17 14.43.52.png (389.8 kB)

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が一切動かないレベル)なのでこれからのパフォーマンス改善と使いやすさ向上に期待.

引用

おまけ: gVisorのデバッグの仕方

環境

Vagrant: Ubuntu18.04

  1. bazelインストール
  2. go1.11 >= インストール
  3. dockerインストール
  4. mkdir ~/go/src//home/vagrant/go/src/gvisor.googlesource.com
  5. git clone https://gvisor.googlesource.com/gvisor gvisor
  6. 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.jsonvimで開いて以下のように編集します(最初は存在しないはず)

{
    "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ログがアプリケーション実行ログなのでアプリからのシステムコール実行の形跡はこのログで確認できる.