runcのmain.goを読んでみる

runcのmain.goだけ読んでみる

自分の勉強ついでにruncの記事書きたいなと思い、まずはコードリーディングから始めようと思った。 まず起点となるmain.goの中身を見て、そこから各コマンド郡を見渡してみたい。

https://github.com/opencontainers/runc/blob/master/main.go

TL;DR

  • func main() ではunfave/cliを使ってApp構造体にコマンド実行に必要な情報を定義していく。
  • XDG_RUNTIME_DIRの存在有無とユーザ名前空間での実行かどうかによってrootディレクトリをどこに設定するか制御している。

main.go

まずコード全体。

package main

import (
    "fmt"
    "io"
    "os"
    "strings"

    "github.com/opencontainers/runc/libcontainer/logs"

    "github.com/opencontainers/runtime-spec/specs-go"

    "github.com/sirupsen/logrus"
    "github.com/urfave/cli"
)

// version will be populated by the Makefile, read from
// VERSION file of the source code.
var version = ""

// gitCommit will be the hash that the binary was built from
// and will be populated by the Makefile
var gitCommit = ""

const (
    specConfig = "config.json"
    usage      = `長いので略`
)

func main() {
    app := cli.NewApp()
    app.Name = "runc"
    app.Usage = usage

    var v []string
    if version != "" {
        v = append(v, version)
    }
    if gitCommit != "" {
        v = append(v, fmt.Sprintf("commit: %s", gitCommit))
    }
    v = append(v, fmt.Sprintf("spec: %s", specs.Version))
    app.Version = strings.Join(v, "\n")

    xdgRuntimeDir := ""
    root := "/run/runc"
    if shouldHonorXDGRuntimeDir() {
        if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
            root = runtimeDir + "/runc"
            xdgRuntimeDir = root
        }
    }

    app.Flags = []cli.Flag{
        cli.BoolFlag{
            Name:  "debug",
            Usage: "enable debug output for logging",
        },
        cli.StringFlag{
            Name:  "log",
            Value: "",
            Usage: "set the log file path where internal debug information is written",
        },
        cli.StringFlag{
            Name:  "log-format",
            Value: "text",
            Usage: "set the format used by logs ('text' (default), or 'json')",
        },
        cli.StringFlag{
            Name:  "root",
            Value: root,
            Usage: "root directory for storage of container state (this should be located in tmpfs)",
        },
        cli.StringFlag{
            Name:  "criu",
            Value: "criu",
            Usage: "path to the criu binary used for checkpoint and restore",
        },
        cli.BoolFlag{
            Name:  "systemd-cgroup",
            Usage: "enable systemd cgroup support, expects cgroupsPath to be of form \"slice:prefix:name\" for e.g. \"system.slice:runc:434234\"",
        },
        cli.StringFlag{
            Name:  "rootless",
            Value: "auto",
            Usage: "ignore cgroup permission errors ('true', 'false', or 'auto')",
        },
    }
    app.Commands = []cli.Command{
        checkpointCommand,
        createCommand,
        deleteCommand,
        eventsCommand,
        execCommand,
        initCommand,
        killCommand,
        listCommand,
        pauseCommand,
        psCommand,
        restoreCommand,
        resumeCommand,
        runCommand,
        specCommand,
        startCommand,
        stateCommand,
        updateCommand,
    }
    app.Before = func(context *cli.Context) error {
        if !context.IsSet("root") && xdgRuntimeDir != "" {
            // According to the XDG specification, we need to set anything in
            // XDG_RUNTIME_DIR to have a sticky bit if we don't want it to get
            // auto-pruned.
            if err := os.MkdirAll(root, 0700); err != nil {
                fmt.Fprintln(os.Stderr, "the path in $XDG_RUNTIME_DIR must be writable by the user")
                fatal(err)
            }
            if err := os.Chmod(root, 0700|os.ModeSticky); err != nil {
                fmt.Fprintln(os.Stderr, "you should check permission of the path in $XDG_RUNTIME_DIR")
                fatal(err)
            }
        }
        return logs.ConfigureLogging(createLogConfig(context))
    }

    // If the command returns an error, cli takes upon itself to print
    // the error on cli.ErrWriter and exit.
    // Use our own writer here to ensure the log gets sent to the right location.
    cli.ErrWriter = &FatalWriter{cli.ErrWriter}
    if err := app.Run(os.Args); err != nil {
        fatal(err)
    }
}

type FatalWriter struct {
    cliErrWriter io.Writer
}

func (f *FatalWriter) Write(p []byte) (n int, err error) {
    logrus.Error(string(p))
    if !logrusToStderr() {
        return f.cliErrWriter.Write(p)
    }
    return len(p), nil
}

func createLogConfig(context *cli.Context) logs.Config {
    logFilePath := context.GlobalString("log")
    logPipeFd := ""
    if logFilePath == "" {
        logPipeFd = "2"
    }
    config := logs.Config{
        LogPipeFd:   logPipeFd,
        LogLevel:    logrus.InfoLevel,
        LogFilePath: logFilePath,
        LogFormat:   context.GlobalString("log-format"),
    }
    if context.GlobalBool("debug") {
        config.LogLevel = logrus.DebugLevel
    }

    return config
}

importの中身

import (
    "fmt"
    "io"
    "os"
    "strings"

    "github.com/opencontainers/runc/libcontainer/logs"

    "github.com/opencontainers/runtime-spec/specs-go"

    "github.com/sirupsen/logrus"
    "github.com/urfave/cli"
)

こっから見ていくのかよと思いつつ。 urfave/cliがベースになっている。

opencontainers/runtime-spec/specs-goには3つのgoのファイルがあって、

  1. config.go
  2. state.go
  3. version.go

3ファイルとも実装はほとんどが構造体。どこで使用しているか調べるのを諦めた。

runc/libcontainer/logsはlog生成用。 app.Beforeを定義する際の即時関数の返り値のところで使っている(L144あたり)

constのusageに唐突のrunc説明文

宣言部にあたる部分。 コンテナランタイムの実装を見るのはruncが初めてなのですが、なんとも不思議なところにruncの説明文が、、 と思ったら全部usageだった。

// version will be populated by the Makefile, read from
// VERSION file of the source code.
var version = ""

// gitCommit will be the hash that the binary was built from
// and will be populated by the Makefile
var gitCommit = ""

const (
    specConfig = "config.json"
    usage      = `Open Container Initiative runtime
runc is a command line client for running applications packaged according to
the Open Container Initiative (OCI) format and is a compliant implementation of the
Open Container Initiative specification.
runc integrates well with existing process supervisors to provide a production
container runtime environment for applications. It can be used with your
existing process monitoring tools and the container will be spawned as a
direct child of the process supervisor.
Containers are configured using bundles. A bundle for a container is a directory
that includes a specification file named "` + specConfig + `" and a root filesystem.
The root filesystem contains the contents of the container.
To start a new instance of a container:
    # runc run [ -b bundle ] <container-id>
Where "<container-id>" is your name for the instance of the container that you
are starting. The name you provide for the container instance must be unique on
your host. Providing the bundle directory using "-b" is optional. The default
value for "bundle" is the current directory.`
)

usageに入る文字列を要約すると

  • runcはコンテナを実行するためのCLIツールです。
  • OCI仕様に準拠しています。
  • runcは既存のプロセススーパーバイザーと統合して、アプリケーションのためのプロダクションコンテナランタイム環境を提供します。
  • コンテナはbundle使って設定されます
  • bundleとは、ディレクトリ(仕様ファイル)とルートファイルシステムを含むディレクトリを指します。
  • ルートファイルシステムにはコンテナの中身が入ります。
  • コンテナを起動するときは、runc run [-b bundle] [container-id] と打ってください。
  • container-idはコンテナのインスタンスの名前です
  • コンテナのインスタンスの名前は一意にしてください。
  • bundleは、デフォルトではカレントディレクトリになります。

cli実行用基盤の作成

ここからfunc main() cli.NewAppで構成したアプリケーション情報に値を詰めていく作業がメイン。

app := cli.NewApp()
app.Name = "runc"
app.Usage = usage

ここで、appのインスタンス(?)を生成。 appに値をどんどん埋めていく。まずはアプリケーションの名前と先程の長いusage

var v []string
if version != "" {
    v = append(v, version)
}
if gitCommit != "" {
    v = append(v, fmt.Sprintf("commit: %s", gitCommit))
}
v = append(v, fmt.Sprintf("spec: %s", specs.Version))
app.Version = strings.Join(v, "\n")

versionとgitCommitの値を結合していく。 versionとgitCommitの値は、Makefileから生成される。

どこをファイルシステムのrootディレクトリにするか

つまるところ、環境変数で定義したXDG_RUNTIME_DIRの配下にruncのディレクトリを置くか、runの配下に置くかを制御する。

xdgRuntimeDir := ""
root := "/run/runc"
if shouldHonorXDGRuntimeDir() {
    if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
        root = runtimeDir + "/runc"
        xdgRuntimeDir = root
    }
}

ここが困惑した箇所。OSについてはてんで素人のため、このような処理がなぜ必要とされているのかがピンと来ていない。

  1. shouldHonorXDGRuntimeDir()
  2. os.Getenv("XDG_RUNTIME_DIR")

この2つの関数の返り値を確認する。

shouldHonorXDGRuntimeDir()

shouldHonorXDGRuntimeDir()はrunc/rootless_linux.go内に存在しているので見に行く。

https://github.com/opencontainers/runc/blob/0fa097fc37c5d89e4cea4fda4663d1239e12a6fe/rootless_linux.go#L37

はじめの分岐
if os.Getenv("XDG_RUNTIME_DIR") == "" {
    return false
}

XDG_RUNTIME_DIRは一時ファイルの保存場所として、ログイン時に自動的に充てがわれるディレクトリの環境変数https://askubuntu.com/questions/872792/what-is-xdg-runtime-dir この環境変数が設定されていない場合はfalseを返す。

euidのチェック
if os.Geteuid() != 0 {
    return true
}

os.Geteuid() != 0から見ていく。ドキュメントをみると以下のような説明になっている。

func Geteuid func Geteuid() int Geteuid returns the numeric effective user id of the caller. On Windows, it returns -1. https://golang.org/pkg/os/#Geteuid

そもそもeffective user id ってなんや。 と思って調べたらここにかいてありました。

https://imokuri123.com/blog/2016/01/linux-ids/

Effective User ID(EUID) - プロセスやユーザが何かオブジェクトにアクセスしたり、作成しようとした時、カーネルは、そのプロセスやユーザがアクションに必要な権限を持っているか、EUIDでチェックします。

euidが0になるときは、ユーザが本当のroot(本当のrootってなんだという話ではあるが)の場合。

link.springer.com

ここに説明が書いてありそうなので後で調べる。

改めて確認だが、やりたいことは環境変数で定義したXDG_RUNTIME_DIRの配下にruncのディレクトリを置くか、runの配下に置くかを決めること。

実行時の名前空間のチェック
if !system.RunningInUserNS() {
    // euid == 0 , in the initial ns (i.e. the real root)
    // in this case, we should use /run/runc and ignore
    // $XDG_RUNTIME_DIR (e.g. /run/user/0) for backward
    // compatibility.
    return false
}

ここに至る条件は、

  1. rootユーザによる実行である場合
  2. XDG_RUNTIME_DIRが存在する場合

である。 ここでは単純に、usernamespaceでの実行かどうかをチェックしている。system.RunningInUserNS()もruncの中にあり実装を確認した。

func RunningInUserNS() bool {
    nsOnce.Do(func() {
        uidmap, err := user.CurrentProcessUIDMap()
        if err != nil {
            // This kernel-provided file only exists if user namespaces are supported
            return
        }
        inUserNS = UIDMapInUserNS(uidmap)
    })
    return inUserNS
}

すこし気になったのが、nsOnce.Doのところ。1回だけ実行させたい理由を考えたがわからなかったのでこれも宿題。

環境変数USERに定義された値をチェック

// euid = 0, in a userns.
u, ok := os.LookupEnv("USER")
return !ok || u != "root"

環境変数"USER"に定義された値がrootではない場合はtrueを返し、rootだった場合はfalseを返す。 そもそもここに至るための条件が、

  1. rootユーザによる実行である場合
  2. XDG_RUNTIME_DIRが存在する場合
  3. usernamespaceでの実行である場合

であるため、いよいよどのような状況かわからなくなってきた。

今一度確認だが、やりたいことは環境変数で定義したXDG_RUNTIME_DIRの配下にruncのディレクトリを置くか、runの配下に置くかを決めること。

フラグの定義をする

Flagsには解析対象となるフラグを定義する。

app.Flags = []cli.Flag{
        cli.BoolFlag{
            Name:  "debug",
            Usage: "enable debug output for logging",
        },
        cli.StringFlag{
            Name:  "log",
            Value: "",
            Usage: "set the log file path where internal debug information is written",
        },
        cli.StringFlag{
            Name:  "log-format",
            Value: "text",
            Usage: "set the format used by logs ('text' (default), or 'json')",
        },
        cli.StringFlag{
            Name:  "root",
            Value: root,
            Usage: "root directory for storage of container state (this should be located in tmpfs)",
        },
        cli.StringFlag{
            Name:  "criu",
            Value: "criu",
            Usage: "path to the criu binary used for checkpoint and restore",
        },
        cli.BoolFlag{
            Name:  "systemd-cgroup",
            Usage: "enable systemd cgroup support, expects cgroupsPath to be of form \"slice:prefix:name\" for e.g. \"system.slice:runc:434234\"",
        },
        cli.StringFlag{
            Name:  "rootless",
            Value: "auto",
            Usage: "ignore cgroup permission errors ('true', 'false', or 'auto')",
        },
    }

フラグは以下の7つ。それぞれの使いみちはいずれ。

  • debug
  • log
  • log-format
  • root
  • criu
  • systemd-cgroup
  • rootless

コマンドを定義する

Commandは実行できるコマンド群。ここで定義しておく。

app.Commands = []cli.Command{
    checkpointCommand,
    createCommand,
    deleteCommand,
    eventsCommand,
    execCommand,
    initCommand,
    killCommand,
    listCommand,
    pauseCommand,
    psCommand,
    restoreCommand,
    resumeCommand,
    runCommand,
    specCommand,
    startCommand,
    stateCommand,
    updateCommand,
}

多い。多いので、それぞれのコマンドの動作を調べていく際にそれぞれ記事にしたい。

サブコマンド実行前の処理を行う。

Beforeはサブコマンドが実行される前に実行されるアクションで、コンテキストの準備ができた後に実行される。 以下の即時関数の中身を見ていくことで、サブコマンド実行前の挙動を見ることができる。

app.Before = func(context *cli.Context) error {
    if !context.IsSet("root") && xdgRuntimeDir != "" {
        // According to the XDG specification, we need to set anything in
        // XDG_RUNTIME_DIR to have a sticky bit if we don't want it to get
        // auto-pruned.
        if err := os.MkdirAll(root, 0700); err != nil {
            fmt.Fprintln(os.Stderr, "the path in $XDG_RUNTIME_DIR must be writable by the user")
            fatal(err)
        }
        if err := os.Chmod(root, 0700|os.ModeSticky); err != nil {
            fmt.Fprintln(os.Stderr, "you should check permission of the path in $XDG_RUNTIME_DIR")
            fatal(err)
        }
    }
    return logs.ConfigureLogging(createLogConfig(context))
}

コメントを読む

According to the XDG specification, we need to set anything in XDG_RUNTIME_DIR to have a sticky bit if we don't want it to get auto-pruned.

訳:XDG の仕様によると、自動的にpruneされないよう、XDG_RUNTIME_DIR にはスティッキービットを設定する必要がある。

スティッキービット(Sticky Bit)とは、ディレクトリに対する特殊なアクセス権である。具体的には、すべてのユーザがこのディレクトリに対して書き込み可能になるが、root権限ないしディレクトリの所有者でしか消せない、という権限である。端的に言えば、ルートディレクトリの保護のために権限の付与が必要。

コマンドを実行する

app.Runが一見見えづらいところにある。 Runがコマンド実行のエントリーポイントになっており、Runの中でargsの文字列操作などを実施する。

// If the command returns an error, cli takes upon itself to print
// the error on cli.ErrWriter and exit.
// Use our own writer here to ensure the log gets sent to the right location.
cli.ErrWriter = &FatalWriter{cli.ErrWriter}
if err := app.Run(os.Args); err != nil {
    fatal(err)
}

コメント部分も読んでいく。

If the command returns an error, cli takes upon itself to print the error on cli.ErrWriter and exit. Use our own writer here to ensure the log gets sent to the right location.

訳:コマンドがエラーを返した場合は cli.ErrWriter にエラーを表示して終了します。 ここでは独自のライターを使ってログを適切な場所に送るようにします。

まとめ

  • 細かく見ていくと、なぜその実装なのか解釈すると面白そうなところが多い。rootディレクトリの処理など。
  • 全体の流れは比較的わかりやすく、unfave/cliの使い方の参考になった。

以降、各コマンド群を見ていこうと思う。