Explore cs in depth!

情報系の専門学校生.compiler/assembler/linker/loader/os/any lowlevel implementations

runcのcreateコマンドを読む。

目次

概要

DockerでLow-Level runtimeとして現在も活躍している opencontainers/runc
コードを読んで行きたいとおもいます。

Cookpad 1Day Container Internに参加した時にコンテナランタイムに興味を持ったので、
コードリーディングをすることで実装を理解していきたいと思います。

今回はrunc createコマンドに着目して読んでいきます。

runner.Run()とか読めてない部分もありますし、
自分が読んで満足しちゃってる部分もあると思うので、
わかりやすく解説!という記事ではないです。

気になる方はご自身でゆっくり読んでみるのをおすすめします。

ライセンス

Apache License 2.0


前提:createコマンドとは

めちゃくちゃ簡単に言えばコンテナを作成するコマンドですね。
使い方としては、

等がありますね。
今回はそのお話は省くので、
気になった方は個人的にやってみてください。

今回は createコマンドがどうやってコンテナを作成しているのかについて見ていきます。

main.go

runcは urfave/cliを基盤に CLIツールを作成しています。
おそらく GoでCLIツールを作る上で デファクトといってもいいぐらい使いやすく、
また機能も豊富です。

全体のコードを紹介した後、
特筆すべき点のみ詳しく見ていきます。

package main

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

    "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      = `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.`
)

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")

    root := "/run/runc"
    if shouldHonorXDGRuntimeDir() {
        if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
            root = runtimeDir + "/runc"
            // 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 {
                fatal(err)
            }
            if err := os.Chmod(root, 0700|os.ModeSticky); err != nil {
                fatal(err)
            }
        }
    }

    app.Flags = []cli.Flag{
        cli.BoolFlag{
            Name:  "debug",
            Usage: "enable debug output for logging",
        },
        cli.StringFlag{
            Name:  "log",
            Value: "/dev/null",
            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.GlobalBool("debug") {
            logrus.SetLevel(logrus.DebugLevel)
        }
        if path := context.GlobalString("log"); path != "" {
            f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND|os.O_SYNC, 0666)
            if err != nil {
                return err
            }
            logrus.SetOutput(f)
        }
        switch context.GlobalString("log-format") {
        case "text":
            // retain logrus's default.
        case "json":
            logrus.SetFormatter(new(logrus.JSONFormatter))
        default:
            return fmt.Errorf("unknown log-format %q", context.GlobalString("log-format"))
        }
        return nil
    }
    // 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))
    return f.cliErrWriter.Write(p)
}

定数

specConfig = "config.json"
usage      = `...` //本当はめっちゃ長いUsageが記載されている

これはイメージの内容・コンテナ起動時の設定を読み込むファイル名config.jsonと、
runc helpとした時に表示される文字列を記載していますね。

重要なのはspecConfigで、
後述するutils.goでも用いられています。

コマンド定義

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

これらはrunc <cmd>に使えるコマンド達の定義です。
今日はcreateなのでcreateCommandについて見ていくということですね。

create.go

package main

import (
    "os"

    "github.com/urfave/cli"
)

var createCommand = cli.Command{
    Name:  "create",
    Usage: "create a container",
    ArgsUsage: `<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.`,
    Description: `The create command creates an instance of a container for a bundle. The bundle
is a directory with a specification file named "` + specConfig + `" and a root
filesystem.
The specification file includes an args parameter. The args parameter is used
to specify command(s) that get run when the container is started. To change the
command(s) that get executed on start, edit the args parameter of the spec. See
"runc spec --help" for more explanation.`,
    Flags: []cli.Flag{
        cli.StringFlag{
            Name:  "bundle, b",
            Value: "",
            Usage: `path to the root of the bundle directory, defaults to the current directory`,
        },
        cli.StringFlag{
            Name:  "console-socket",
            Value: "",
            Usage: "path to an AF_UNIX socket which will receive a file descriptor referencing the master end of the console's pseudoterminal",
        },
        cli.StringFlag{
            Name:  "pid-file",
            Value: "",
            Usage: "specify the file to write the process id to",
        },
        cli.BoolFlag{
            Name:  "no-pivot",
            Usage: "do not use pivot root to jail process inside rootfs.  This should be used whenever the rootfs is on top of a ramdisk",
        },
        cli.BoolFlag{
            Name:  "no-new-keyring",
            Usage: "do not create a new session keyring for the container.  This will cause the container to inherit the calling processes session key",
        },
        cli.IntFlag{
            Name:  "preserve-fds",
            Usage: "Pass N additional file descriptors to the container (stdio + $LISTEN_FDS + N in total)",
        },
    },
    Action: func(context *cli.Context) error {
        if err := checkArgs(context, 1, exactArgs); err != nil {
            return err
        }
        if err := revisePidFile(context); err != nil {
            return err
        }
        spec, err := setupSpec(context)
        if err != nil {
            return err
        }
        status, err := startContainer(context, spec, CT_ACT_CREATE, nil)
        if err != nil {
            return err
        }
        // exit with the container's exit status so any external supervisor is
        // notified of the exit with the correct exit status.
        os.Exit(status)
        return nil
    },
}

runc craeteが実行されると、

  • 引数の数を調べるcheckArgs()が呼ばれる
    • 今回の場合はrunc create <containerid>が欲しいのでidが無ければエラー
    • より詳しくいうとrunc create0としてから数え始めているので1期待している
  • revisePidFile()はフラグpid-fileを渡さなければエラーを返す
    • pid-fileはその名の通りプロセスIDを書き込むファイルパスのこと
    • おそらくcgroupsの機能を利用するために用いるのだと思う
  • setupSpec()Filesystem bundleとして利用するディレクトリを指定する
    • 指定が無ければカレントディレクトリに
    • その後specConfig(config.json)からSpecを読み込むloadSpec()(後述)を呼び、結果を返す
  • startContainer()今回のキモ と言ってもいいので後で

loadSpec()

// loadSpec loads the specification from the provided path.
func loadSpec(cPath string) (spec *specs.Spec, err error) {
    cf, err := os.Open(cPath)
    if err != nil {
        if os.IsNotExist(err) {
            return nil, fmt.Errorf("JSON specification file %s not found", cPath)
        }
        return nil, err
    }
    defer cf.Close()

    if err = json.NewDecoder(cf).Decode(&spec); err != nil {
        return nil, err
    }
    return spec, validateProcessSpec(spec.Process)
}

特に難しい事はやっていないですね。

  • cPath( config.json )を読み出し
    • 見つからなければエラー
  • jsonをパースして、runtime-specの形に整える
    • *specs.Specはここ
    • oci準拠のconfig.jsonを読み込むための構造体が定義されている
  • validateProcessSpec()spec.Processに対して
    • カレントワーキングディレクトリがセットされているか
    • それは絶対パスであるか
    • 引数が一つ以上あるかをチェック

startContainer()

func startContainer(context *cli.Context, spec *specs.Spec, action CtAct, criuOpts *libcontainer.CriuOpts) (int, error) {
    id := context.Args().First()
    if id == "" {
        return -1, errEmptyID
    }

    notifySocket := newNotifySocket(context, os.Getenv("NOTIFY_SOCKET"), id)
    if notifySocket != nil {
        notifySocket.setupSpec(context, spec)
    }

    container, err := createContainer(context, id, spec)
    if err != nil {
        return -1, err
    }

    if notifySocket != nil {
        err := notifySocket.setupSocket()
        if err != nil {
            return -1, err
        }
    }

    // Support on-demand socket activation by passing file descriptors into the container init process.
    listenFDs := []*os.File{}
    if os.Getenv("LISTEN_FDS") != "" {
        listenFDs = activation.Files(false)
    }
    r := &runner{
        enableSubreaper: !context.Bool("no-subreaper"),
        shouldDestroy:   true,
        container:       container,
        listenFDs:       listenFDs,
        notifySocket:    notifySocket,
        consoleSocket:   context.String("console-socket"),
        detach:          context.Bool("detach"),
        pidFile:         context.String("pid-file"),
        preserveFDs:     context.Int("preserve-fds"),
        action:          action,
        criuOpts:        criuOpts,
        init:            true,
    }
    return r.run(spec.Process)
}

ゆっくり見ていった後まとめましょう。

notifySocket

newNotifySocket(context, os.Getenv("NOTIFY_SOCKET"), id)のように定義されていますね。
idrunc create <containerid>なので問題ないと思います。
newNotifySocket()関数を見てみましょう。

func newNotifySocket(context *cli.Context, notifySocketHost string, id string) *notifySocket {
    if notifySocketHost == "" {
        return nil
    }

    root := filepath.Join(context.GlobalString("root"), id)
    path := filepath.Join(root, "notify.sock")

    notifySocket := &notifySocket{
        socket:     nil,
        host:       notifySocketHost,
        socketPath: path,
    }

    return notifySocket
}

GlobalString("root")はコンテナの状態を格納するディレクトリです。
runcは tmpfsがマウントされたディレクトリを指定することを推奨しています。

notify Socket structは、

type notifySocket struct {
    socket     *net.UnixConn
    host       string
    socketPath string
}

のようにUnixコネクションをラップする構造体ですね。

Unixのソケット通信についてはこちらの記事が分かりやすいです。

https://ascii.jp/elem/000/001/415/1415088/ascii.jp

簡単にまとめると、

  • ホスト内部で通信する時に用いられる高速なストリーム通信の事
  • .sockが該当する ソケットファイル をインタフェースに通信を行う

みたいな感じでしょうか。
実際に通信する時にはもっと詳しい理解が必要だと思いますが、
今はこの程度の理解で問題ないと思います。


無事notifySocketが定義できると、今度はsetupSpec()メソッドを呼び出します。

func (s *notifySocket) setupSpec(context *cli.Context, spec *specs.Spec) {
    mount := specs.Mount{Destination: s.host, Source: s.socketPath, Options: []string{"bind"}}
    spec.Mounts = append(spec.Mounts, mount)
    spec.Process.Env = append(spec.Process.Env, fmt.Sprintf("NOTIFY_SOCKET=%s", s.host))
}

specs.Mountは文字通りコンテナのマウントについてのruntime-specですね。

spec.Process.Envはプロセスの環境変数を設定するメンバです。

ここらへんを厳密に詳しく知りたい人はopencontainers/runtime-specを参照すると良いと思います。

先程からちょくちょく登場している環境変数NOTIFY_SOCKETは、
ソケット通信時に起動・補完通知を受け付ける相手を指定するもののようですね。
ここらへんあまり詳しくないのでもっと調べなければなりませんね。

setupSpec()が終わるといよいよ createContainer()です。

func createContainer(context *cli.Context, id string, spec *specs.Spec) (libcontainer.Container, error) {
    rootlessCg, err := shouldUseRootlessCgroupManager(context)
    if err != nil {
        return nil, err
    }
    config, err := specconv.CreateLibcontainerConfig(&specconv.CreateOpts{
        CgroupName:       id,
        UseSystemdCgroup: context.GlobalBool("systemd-cgroup"),
        NoPivotRoot:      context.Bool("no-pivot"),
        NoNewKeyring:     context.Bool("no-new-keyring"),
        Spec:             spec,
        RootlessEUID:     os.Geteuid() != 0,
        RootlessCgroups:  rootlessCg,
    })
    if err != nil {
        return nil, err
    }

    factory, err := loadFactory(context)
    if err != nil {
        return nil, err
    }
    return factory.Create(id, config)
}

コンテナIDconfig.jsonに定義されたruntime-specから、
libcontainer.Containerを定義しています。

ゆっくり見ていきましょう。

shouldUseRootlessCgroupManager()は、

  • rootlessフラグ(cgroupsの権限エラーを無視するかどうか)が立っていなければエラー
  • systemd-cgroup…cgroupsについてsystemdのサポートを受けるかのフラグ…が立っていなければエラー
  • プロセスの名前空間(Linux Namespaceを参照すると厳密に書かれています)を検証
    • proc/self/uid_mapの読み出しにエラーがあればエラー
    • プロセスのPIDが0ならエラー
      • つまりプロセス名前空間がIsolateできて無ければエラー
    • PPIDが0でもエラー
    • 名前空間内で使用するPIDの個数( 範囲 )が4294967295ならエラー(これは何?)
    • それ以外ならTrue

をかえすような関数です。
ここで問題が無ければ
specconv.CreateLibcontainerConfigによって
コンテナを定義していきます。

具体的には

  • cgroupsの管理に使う識別子(コンテナID)
  • PivotRootするか
  • Spec

等を定義していきます。

めちゃくちゃ詳しい例を公式が用意していました。

loadFactory()Factoryを返します。
これはCreate()メソッドを用いてコンテナを生成します。

Create()を見てみましょう。

func (l *LinuxFactory) Create(id string, config *configs.Config) (Container, error) {
    if l.Root == "" {
        return nil, newGenericError(fmt.Errorf("invalid root"), ConfigInvalid)
    }
    if err := l.validateID(id); err != nil {
        return nil, err
    }
    if err := l.Validator.Validate(config); err != nil {
        return nil, newGenericError(err, ConfigInvalid)
    }
    containerRoot, err := securejoin.SecureJoin(l.Root, id)
    if err != nil {
        return nil, err
    }
    if _, err := os.Stat(containerRoot); err == nil {
        return nil, newGenericError(fmt.Errorf("container with id exists: %v", id), IdInUse)
    } else if !os.IsNotExist(err) {
        return nil, newGenericError(err, SystemError)
    }
    if err := os.MkdirAll(containerRoot, 0711); err != nil {
        return nil, newGenericError(err, SystemError)
    }
    if err := os.Chown(containerRoot, unix.Geteuid(), unix.Getegid()); err != nil {
        return nil, newGenericError(err, SystemError)
    }
    c := &linuxContainer{
        id:            id,
        root:          containerRoot,
        config:        config,
        initPath:      l.InitPath,
        initArgs:      l.InitArgs,
        criuPath:      l.CriuPath,
        newuidmapPath: l.NewuidmapPath,
        newgidmapPath: l.NewgidmapPath,
        cgroupManager: l.NewCgroupsManager(config.Cgroups, nil),
    }
    if intelrdt.IsCatEnabled() || intelrdt.IsMbaEnabled() {
        c.intelRdtManager = l.NewIntelRdtManager(config, id, "")
    }
    c.state = &stoppedState{c: c}
    return c, nil
}

linuxContainerはコンテナの実体になりますね


総評

フローをまとめます。

  • config.jsonからSpecを読み出す
  • Specからコンテナを作る

長かったですが簡単にまとまって良かったです。
いくつか端折った部分があるので今度また見返そうと思います。

  • run()
  • socket通信の詳細

等ですね。