runc 内部实现深入解读
最近把runc的实现代码仔细的看了一遍,有点复杂,特别是nsenter那块反反复复看了不下三遍才搞懂是个什么机制,不过确实写得不错,防止日后遗忘,另外也给其他朋友一点参考(网上有一些关于runc的介绍文章,但是没有nsenter这块的介绍)
概要设计
runc的实现分为两个大阶段:
- 第一个阶段叫bootstrap, 设置一些环境以及配置信息, 创建匿名管道, 把容器的配置信息通过管道发送给第二阶段。
- 第二个阶段代码里没有明确的名称,就把它叫做worker阶段吧, 主要就是创建子进程并根据管道传过来的配置信息,设置进程的namespace信息。
bootstrap和worker主要通过socketpair这种全双工管道交互,这么实现的主要考虑是worker的大部分时间处于自己的namespace中,所以有些事情自己做不了,必须让bootstrap协助完成。
另外bootstrap用GO语言实现,worker的开始的最重要的部分用C语言实现,后面的部分也是以GO语言实现,中间的切换非常美妙。
另外值得说一下的是runc支持linux,windows,solaris等多种操作系统,所以代码的一个通用规则是对于某个功能是定一个一个接口,然后针对各种平台的实现定义一个独立的文件,比如
container.go
container_linux.go
container_widnows.go
container_solaris.go
这样可以方便理解实现,也不要被代码量吓倒。
bootstrap代码分析
下面结合runc create <nn>
来一起看一下runc具体执行流程:
create.go:
spec, err := setupSpec(context)
进入到中执行,先解析config.json文件,下载bundle,然后
status, err := startContainer(context, spec, CT_ACT_CREATE, nil)
开始了utils_linux.go,
func startContainer(context *cli.Context, spec *specs.Spec, action CtAct, criuOpts *libcontainer.CriuOpts) (int, error) {
container, err := createContainer(context, id, spec)
.....
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,
}
return r.run(spec.Process)
}
看一下createContainer的实现
func createContainer(context *cli.Context, id string, spec *specs.Spec) (libcontainer.Container, error) {
....
factory, err := loadFactory(context)
return factory.Create(id, config)
}
loadFactory最重会走到factory_linux.go
func New(root string, options ...func(*LinuxFactory) error) (Factory, error) {
if root != "" {
if err := os.MkdirAll(root, 0700); err != nil {
return nil, newGenericError(err, SystemError)
}
}
l := &LinuxFactory{
Root: root,
InitArgs: []string{"/proc/self/exe", "init"},
Validator: validate.New(),
CriuPath: "criu",
}
Cgroupfs(l)
for _, opt := range options {
if err := opt(l); err != nil {
return nil, err
}
}
return l, nil
}
到目前为止都很简单,两个值得注意下的
InitArgs: []string{"/proc/self/exe", “init”}, /proc/self/exe
是指向自身,也就是runc
,这个很重要,后面会涉及到
options里面定义了具体采用哪种方式的cgroups,给予文件的还是systemd的方式
好了,有了factory,那就用这个factory创建container结构体,
func (l *LinuxFactory) Create(id string, config *configs.Config) (Container, error) {
containerRoot := filepath.Join(l.Root, id)
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)
}
if config.Rootless {
RootlessCgroups(l)
}
c := &linuxContainer{
id: id,
root: containerRoot,
config: config,
initArgs: l.InitArgs,
criuPath: l.CriuPath,
cgroupManager: l.NewCgroupsManager(config.Cgroups, nil),
}
c.state = &stoppedState{c: c}
return c, nil
}
看到创建了一个linuxContainer结构体,并设置状态为Stopped, 最后开始run这个container,注意这个run不适真正的run,只是初始化container的许多信息而已。
func (r *runner) run(config *specs.Process) (int, error) {
process, err := newProcess(*config)
if len(r.listenFDs) > 0 {
process.Env = append(process.Env, fmt.Sprintf("LISTEN_FDS=%d", len(r.listenFDs)), "LISTEN_PID=1")
process.ExtraFiles = append(process.ExtraFiles, r.listenFDs...)
}
baseFd := 3 + len(process.ExtraFiles)
for i := baseFd; i < baseFd+r.preserveFDs; i++ {
process.ExtraFiles = append(process.ExtraFiles, os.NewFile(uintptr(i), "PreserveFD:"+strconv.Itoa(i)))
}
...
switch r.action {
case CT_ACT_CREATE:
err = r.container.Start(process)
case CT_ACT_RESTORE:
err = r.container.Restore(process, r.criuOpts)
case CT_ACT_RUN:
err = r.container.Run(process)
default:
panic("Unknown action")
}
最重进入container_linux.go : linuxContainer.Start
func (c *linuxContainer) start(process *Process, isInit bool) error {
parent, err := c.newParentProcess(process, isInit)
if err != nil {
return newSystemErrorWithCause(err, "creating new parent process")
}
if err := parent.start(); err != nil {
// terminate the process to ensure that it properly is reaped.
if err := parent.terminate(); err != nil {
logrus.Warn(err)
}
return newSystemErrorWithCause(err, "starting container process")
}
...
func (c *linuxContainer) newParentProcess(p *Process, doInit bool) (parentProcess, error) {
parentPipe, childPipe, err := utils.NewSockPair("init")
if err != nil {
return nil, newSystemErrorWithCause(err, "creating new init pipe")
}
cmd, err := c.commandTemplate(p, childPipe)
if err != nil {
return nil, newSystemErrorWithCause(err, "creating new command template")
}
if !doInit {
return c.newSetnsProcess(p, cmd, parentPipe, childPipe)
}
// We only set up fifoFd if we're not doing a `runc exec`. The historic
// reason for this is that previously we would pass a dirfd that allowed
// for container rootfs escape (and not doing it in `runc exec` avoided
// that problem), but we no longer do that. However, there's no need to do
// this for `runc exec` so we just keep it this way to be safe.
if err := c.includeExecFifo(cmd); err != nil {
return nil, newSystemErrorWithCause(err, "including execfifo in cmd.Exec setup")
}
return c.newInitProcess(p, cmd, parentPipe, childPipe)
}
主要做了三件事:
- 创建SockPair管道
- 创建cmd, 并把管道的一端给cmd
- 根据状态创建initProcess 或者 setnsProcess
侧重看一下cmd:
func (c *linuxContainer) commandTemplate(p *Process, childPipe *os.File) (*exec.Cmd, error) {
cmd := exec.Command(c.initArgs[0], c.initArgs[1:]...)
cmd.Stdin = p.Stdin
cmd.Stdout = p.Stdout
cmd.Stderr = p.Stderr
cmd.Dir = c.config.Rootfs
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.ExtraFiles = append(cmd.ExtraFiles, p.ExtraFiles...)
if p.ConsoleSocket != nil {
cmd.ExtraFiles = append(cmd.ExtraFiles, p.ConsoleSocket)
cmd.Env = append(cmd.Env,
fmt.Sprintf("_LIBCONTAINER_CONSOLE=%d", stdioFdCount+len(cmd.ExtraFiles)-1),
)
}
cmd.ExtraFiles = append(cmd.ExtraFiles, childPipe)
cmd.Env = append(cmd.Env,
fmt.Sprintf("_LIBCONTAINER_INITPIPE=%d", stdioFdCount+len(cmd.ExtraFiles)-1),
)
}
秘密都在这个cmd的设置上:
- initArgs =[]string{"/proc/self/exe", “init”}
- 给cmd设置了_LIBCONTAINER_INITPIPE环境变量指向管道,可以想到的是后面fork出来的字进程会对这个管道进行读写来与别人通信
都准备好后,就调用initProcess或者setnsProcess的Start方法,这两个的处理方法比较类似,如果处于Stopped状态就是调用initProcess的方法
func (c *linuxContainer) newInitProcess(p *Process, cmd *exec.Cmd, parentPipe, childPipe *os.File) (*initProcess, error) {
cmd.Env = append(cmd.Env, "_LIBCONTAINER_INITTYPE="+string(initStandard))
nsMaps := make(map[configs.NamespaceType]string)
for _, ns := range c.config.Namespaces {
if ns.Path != "" {
nsMaps[ns.Type] = ns.Path
}
}
_, sharePidns := nsMaps[configs.NEWPID]
data, err := c.bootstrapData(c.config.Namespaces.CloneFlags(), nsMaps)
if err != nil {
return nil, err
}
return &initProcess{
cmd: cmd,
childPipe: childPipe,
parentPipe: parentPipe,
manager: c.cgroupManager,
config: c.newInitConfig(p),
container: c,
process: p,
bootstrapData: data,
sharePidns: sharePidns,
}, nil
}
先是根据配置中的namespaces,创建bootstrapData,然后把管道信息,cmd之类的信息都赋值给initProcess,start
func (p *initProcess) start() error {
defer p.parentPipe.Close()
err := p.cmd.Start()
p.process.ops = p
p.childPipe.Close()
if _, err := io.Copy(p.parentPipe, p.bootstrapData); err != nil {
return newSystemErrorWithCause(err, "copying bootstrap data to pipe")
}
if err := p.execSetns(); err != nil {
return newSystemErrorWithCause(err, "running exec setns process for init")
}
...
}
这里做了好多事:
- 关闭childPipe
- cmd.Start就是fork字进程去启动container
- 往parentPipe写入bootstrapData
- 调用execSetns,这理会在parentPipe等待读取信息,返回的信息里包含后子进程以及container init进程的进程号
- 后面还有很多事情,比如对返回的pid的进程进行cgroup的设置,创建veth,通过管道发送完整的config.json的配置信息给worker,以及与worker部分的procReady,procRun, preStart hook的状态机运作
这些bootstrap剩下来的工作先不急着看,下面看看worker部分的工作,不了解worker部分的实现就不能完全理解bootstrap为什么要这么互动的
worker C部分代码实现
上一步p.cmd.start就是fork子进程执行cmd里的参数,之前的部分我也两次提到了这个cmd的设置非常重要,下面就来具体看看
exec.Command(c.initArgs[0], c.initArgs[1:]...)
其实就是exec.Command("/proc/self/exe", "init")
,也就是fork一个子进程执行‘runc init’的动作
init.go
factory, _ := libcontainer.New("")
if err := factory.StartInitialization(); err != nil {
}
别急在这之前还有很重要的事情要介绍
import (
_ "github.com/opencontainers/runc/libcontainer/nsenter"
)
nsenter.go
/*
#cgo CFLAGS: -Wall
extern void nsexec();
void __attribute__((constructor)) init(void) {
nsexec();
}
*/
import "C"
这是GO语言调用C代码的做法,叫做preamble,也就是说只要import这个nsenter模块就会在GO的runtime启动前先执行这个先导代码块,最终会执行nsexec这段亲切的C代码。
void nsexec(void)
{
/*
* If we don't have an init pipe, just return to the go routine.
* We'll only get an init pipe for start or exec.
*/
pipenum = initpipe();
if (pipenum == -1)
return;
/* Parse all of the netlink configuration. */
nl_parse(pipenum, &config);
update_oom_score_adj(config.oom_score_adj, config.oom_score_adj_len);
....
}
- initPipe检查有没有设置环境变量_LIBCONTAINER_INITPIPE,如果没有就直接退出了,我们的case在前面已经设置为bootstrap创建的管道的childPipe
- 调用nl_parse来获取bootstrap发送的namespace的配置信息, 在initProcess.start的第一步我们就看到bootstrap通过调用io.Copy(p.parentPipe, p.bootstrapData)往管道里写入了这些信息, 这边借用了netlink payload实现,正常netlink用于userspace和kernelspace的进程通信,这边只是借助这个消息编解码格式来在不同进程之间的传递多种消息。
到这里都比较直观,代码注释也都写得蛮清晰的,下面的逻辑稍微复杂点。
先说下大体思路方便理解,分为三个部分JUMP_PARENT,JUMP_CHILD,JUMP_INIT, 其中JUMP_PARENT 创建 儿子JUMP_CHILD,JUMP_CHILD再创建JUMP_INIT孙子进程,另外JUMP_PARENT进程与儿子和孙子进程之间也是通过管道来通信,为什么要这么做?主要是因为马上就要通过setns或者unshare来进入新的namespace,和原来的namespace脱离后,有些事情的操作就没那么方便了,比如pid,user空间已经隔离,相互之间的就不是一套语言了,还是通过发一个指示该负责这个事情的人做更方便。
所有三个进程的代码都写在一个函数里的,这里我画个时序图可以看得更清楚点
为什么要这么复杂fork这么多子进程,搞这么复杂?
-
JUMP_CHILD, 根据代码注释我是这样理解的,CLONE_NEWUSER 与其它的namespace不能一起做因为Selinux的原因,所以在JUMP_CHILD中先调用setns来加入明确指定的namespace,unshare其它希望隔离的namespace,如果希望与host进行uid/gid mapping的话,自己已经没有权限了,所以发送SYNC_USERMAP_PLS给JUMP_PARENT, 让其代劳。
-
JUMP_INIT: 在JUMP_CHILD中已经调用setns和unshare了,但是PID Namespace有些特殊性,调用之后并不改变调用进程自身的PID namespace,为什么这个行为?也好理解,如果改变了,调用者自己就不知道自己是谁了(比如通过getpid),这会导致很多应用程序和代码库出问题,所以只能在这个进程里fork一个新的进程它就会加入新的PID Namespace, 并在当前进程里返回孙子进程的pid给JUMP_PARENT
下面是setns的man page里关于PID Namespace的描述:
If fd refers to a PID namespaces, the semantics are somewhat different from other namespace types: reassociating the calling thread with a PID namespace changes only the PID namespace that subsequently created child processes of the caller will be placed in; it does not change the PID namespace of the caller itself. Reassociating with a PID namespace is allowed only if the PID namespace specified by fd is a descendant (child, grandchild, etc.) of the PID namespace of the caller
worker GO部分代码实现
终于进入GO世界,重新回来看一下 runc init
的部分
factory, _ := libcontainer.New("")
if err := factory.StartInitialization(); err != nil {
// as the error is sent back to the parent there is no need to log
// or write it to stderr because the parent process will handle this
os.Exit(1)
}
func (l *LinuxFactory) StartInitialization() (err error) {
var (
pipefd, fifofd int
consoleSocket *os.File
envInitPipe = os.Getenv("_LIBCONTAINER_INITPIPE")
envFifoFd = os.Getenv("_LIBCONTAINER_FIFOFD")
envConsole = os.Getenv("_LIBCONTAINER_CONSOLE")
)
pipefd, err = strconv.Atoi(envInitPipe)
var (
pipe = os.NewFile(uintptr(pipefd), "pipe")
it = initType(os.Getenv("_LIBCONTAINER_INITTYPE"))
)
if fifofd, err = strconv.Atoi(envFifoFd)
i, err := newContainerInit(it, pipe, consoleSocket, fifofd)
return i.Init()
}
func newContainerInit(t initType, pipe *os.File, consoleSocket *os.File, fifoFd int) (initer, error) {
var config *initConfig
if err := json.NewDecoder(pipe).Decode(&config); err != nil {
return nil, err
}
...
return &linuxStandardInit{
pipe: pipe,
consoleSocket: consoleSocket,
parentPid: unix.Getppid(),
config: config,
fifoFd: fifoFd,
}, nil
}
这里与bootstrap又发生一次交互,从pipe(_LIBCONTAINER_INITPIPE) 管道中获取container的完整配置信息,然后根据配置信息Init容器了
func (l *linuxStandardInit) Init() error {
if err := setupNetwork(l.config); err != nil {
return err
}
if err := setupRoute(l.config.Config); err != nil {
return err
}
label.Init()
// prepareRootfs() can be executed only for a new mount namespace.
if l.config.Config.Namespaces.Contains(configs.NEWNS) {
if err := prepareRootfs(l.pipe, l.config.Config); err != nil {
return err
}
}
// Set up the console. This has to be done *before* we finalize the rootfs,
// but *after* we've given the user the chance to set up all of the mounts
// they wanted.
if l.config.CreateConsole {
if err := setupConsole(l.consoleSocket, l.config, true); err != nil {
return err
}
if err := system.Setctty(); err != nil {
return err
}
}
// Finish the rootfs setup.
if l.config.Config.Namespaces.Contains(configs.NEWNS) {
if err := finalizeRootfs(l.config.Config); err != nil {
return err
}
}
if hostname := l.config.Config.Hostname; hostname != "" {
if err := unix.Sethostname([]byte(hostname)); err != nil {
return err
}
}
if err := apparmor.ApplyProfile(l.config.AppArmorProfile); err != nil {
return err
}
if err := label.SetProcessLabel(l.config.ProcessLabel); err != nil {
return err
}
for key, value := range l.config.Config.Sysctl {
if err := writeSystemProperty(key, value); err != nil {
return err
}
}
for _, path := range l.config.Config.ReadonlyPaths {
if err := readonlyPath(path); err != nil {
return err
}
}
for _, path := range l.config.Config.MaskPaths {
if err := maskPath(path); err != nil {
return err
}
}
pdeath, err := system.GetParentDeathSignal()
if err != nil {
return err
}
if l.config.NoNewPrivileges {
if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
return err
}
}
// Tell our parent that we're ready to Execv. This must be done before the
// Seccomp rules have been applied, because we need to be able to read and
// write to a socket.
if err := syncParentReady(l.pipe); err != nil {
return err
}
...
}
这个逻辑看起来还算清晰,syncParentReady这边又涉及到与bootstrap的交互,主要是让bootstrap协助再做一些事情,比如执行preStartHook, 下面的流程图大致描述下bootstrap也Worker之间交互。
结束语
这样一个runc的完整过程就执行完毕了,create 和run稍微有点区别,基本流程一样,另外还有一块借助criu实现容器的checkpoint和restore,以后有时间再看看