【runC】03-runC-源码分析-exec

3,359 阅读5分钟

回顾

预热

关键技术点

  • syscall.exec exec是操作系统的功能,该功能在现有进程的上下文中运行可执行文件,以替换先前的可执行文件。此操作也称为覆盖。尽管在其他类Unix系统中,它尤其重要。由于未创建新进程,因此进程标识符(PID)不会更改,但是进程的机器代码,数据,堆和堆栈将被新程序的机器代码,数据,堆和堆栈所替代。

    runc 的exec最终也是会调用该系统调用呢

    exec: en.wikipedia.org/wiki/Exec_(…

命令参数

# runc exec -h

从命令参数上看,可以设置当前程序是否需要获得tty, 是需要指定user甚至可以设置安全相关的参数apparmor, cap;这个看起来和在容器建立的config.json 文件里面能指定的内容有点相似;后面深入代码看看是否进入容器和启动容器有相似之处;

源码分析

runc exec

  • runc/exec.go

runc exec 的命令入口

var execCommand = cli.Command{
	Name:  "exec",
	Usage: "execute new process inside the container",
	...
	Action: func(context *cli.Context) error {
		...
		// 执行exec 的函数入口
		status, err := execProcess(context)
		if err == nil {
			os.Exit(status)
		}
		return fmt.Errorf("exec failed: %v", err)
	},
	SkipArgReorder: true,
}
  • runc/exec.go execProcess

    这里出现了一个在前一章的容器创建过程中提到过的函数,getContainer 该函数主要是获取一个container 的方式,是通过 libcontainer.Factory 容器工厂类加载containerID 及其配置获取一个可运行的容器(里面会调用runc init来准备容器环境)。最后也是通过前面提到的runner进行容器的启动,但这次不同于创建容器流程,不需要进行环境大规模的配置初始化(env,cgroups,namespace,rootfs, mounts等);后面进入 r.run(p)

func execProcess(context *cli.Context) (int, error) {
	// 获取指定<containerID> 的容器对象
	container, err := getContainer(context)
	if err != nil {
		return -1, err
	}
	...
	// 获取一个符合OCI规范在容器内的 process 配置
	p, err := getProcess(context, bundle)
	if err != nil {
		return -1, err
	}
	...
	 // runner 结构体
	r := &runner{
		enableSubreaper: false,
		shouldDestroy:   false,
		container:       container,
		consoleSocket:   context.String("console-socket"),
		detach:          detach,
		pidFile:         context.String("pid-file"),
		// 动作变为run
		action:          CT_ACT_RUN,
 		// 不需要初始化
		init:            false,
		preserveFDs:     context.Int("preserve-fds"),
		logLevel:        logLevel,
	}
	// 进入该方法
	return r.run(p)
}
  • runc/utils_linux.go runner.run

    在上一章中也是通过这个入口进入了容器的创建流程,下面只上重点代码(需要详解请看回前一章),解析留意一下代码注释

func (r *runner) run(config *specs.Process) (int, error) {
	...
	// 创建一个libcontainer 的 process 结构体对象, 该对象是一个容器进程的抽象结构,主要统一配置应用
	process, err := newProcess(*config, r.init, r.logLevel)
	if err != nil {
		return -1, err
	...
	tty, err := setupIO(process, rootuid, rootgid, config.Terminal, detach, r.consoleSocket)
	if err != nil {
		return -1, err
	}
	defer tty.Close()

	switch r.action {
	case CT_ACT_CREATE:
		err = r.container.Start(process)
	case CT_ACT_RESTORE:
		err = r.container.Restore(process, r.criuOpts)
	// 本次执行,会跑run 动作
	case CT_ACT_RUN:
		err = r.container.Run(process)
	default:
		panic("Unknown action")
	}
	...
}
  • 下面继续看看 r.container.Run(process) 留意代码注释
func (c *linuxContainer) Run(process *Process) error {
	// linux container 去启动容器的process, 进入看看
	if err := c.Start(process); err != nil {
		return err
	}
    	// 这段代码是不会跑的,因为我们这次启动是不需要初始化的
	if process.Init {
		return c.exec()
	}
	return nil
}
  • 下面继续看看 c.Start(process) 留意代码注释
func (c *linuxContainer) Start(process *Process) error {
	...
	// 这段代码是不会跑的, 同样只有在创建完整容器环境的时候才会跑
	if process.Init {
		if err := c.createExecFifo(); err != nil {
			return err
		}
	}
	// 进入该代码段
	if err := c.start(process); err != nil {
		...
		return err
	}
	return nil
}
  • 下面继续看看 c.start(process) 留意代码注释

    经历了几次跳转终于来到核心的调用, c.newParentProcess 会提供一个pipe作为和子进程的通信通道,使用socketpair建立,最后组装到 runc init 的命令里面(如未指定cwd,命令会在容器的rootfs下执行), 以及返回一个setnsProcess(核心的启动命令)。这里省略了代码展示,因为前一章已经用了大量篇幅解析了

    socketpair: man7.org/linux/man-p…

func (c *linuxContainer) start(process *Process) error {
	// 返回一个setnsProcess
 	// 设置进程env _LIBCONTAINER_INITTYPE=setns
	parent, err := c.newParentProcess(process)
	if err != nil {
		return newSystemErrorWithCause(err, "creating new parent process")
	}
	// 读取parent 日志文件管道
	parent.forwardChildLogs()
	// 启动进程
	if err := parent.start(); err != nil {
		return newSystemErrorWithCause(err, "starting container process")
	}
	...
	...
	return nil
}

  • 下面继续看看 parent.start

    cmd终于的开始执行了,执行runc initinit 程序会调用 nsexec.c 的代码(这也是一个子进程,而且是在init进程前启动,设置ns后就会退出);通过 _LIBCONTAINER_INITTYPE=setns 这个环境变量判别用什么模式进行setns,如果是standard 则是使用clone namespace 为容器建立新的namespace,这里我们是setns所以是为容器指定了我们需要进入的进程namespace。这一步的信息传递是通过socket与nsexec.c 这个程序交互,进程间通信技术完成信息传递;容器启动的需要的namespace 数据放到setnsProcess.bootstrapData内;

func (p *setnsProcess) start() (retErr error) {
	...
	// 执行命令 runc init
	err := p.cmd.Start()
	if err != nil {
		return newSystemErrorWithCause(err, "starting setns process")
	}
	...
	...
 	// 将namespace 数据传输到nsenter 上
	if p.bootstrapData != nil {
		if _, err := io.Copy(p.messageSockPair.parent, p.bootstrapData); err != nil {
			return newSystemErrorWithCause(err, "copying bootstrap data to pipe")
		}
	}
	// 等待exec 设置namespace 数据, 并且通过消息管道返回父进程号,然后设置process属性为runc init 的进程号,方便后面对process的操作;
	if err := p.execSetns(); err != nil {
		return newSystemErrorWithCause(err, "executing setns process")
	}
	if len(p.cgroupPaths) > 0 {
		// 把runc init进程加入到, 已有容器的cgroup中
		if err := cgroups.EnterPid(p.cgroupPaths, p.pid()); err != nil && !p.rootlessCgroups {
			// On cgroup v2 + nesting + domain controllers, EnterPid may fail with EBUSY.
			// https://github.com/opencontainers/runc/issues/2356#issuecomment-621277643
			// Try to join the cgroup of InitProcessPid.
			...
			...
		}
	}
	...
	...
    // 关闭通信的socket
	if err := unix.Shutdown(int(p.messageSockPair.parent.Fd()), unix.SHUT_WR); err != nil {
		return newSystemErrorWithCause(err, "calling shutdown on init pipe")
	}
	// 等待命令执行结束,即exec结束
	if ierr != nil {
		p.wait()
		return ierr
	}
	return nil
}

至此 exec 的第一阶段完成了,下面进入第二阶段 init 过程

runc init

  • runc/libcontainer/setns_init_linux.go

    我们直接看核心代码块

func (l *linuxSetnsInit) Init() error {
	runtime.LockOSThread()
	defer runtime.UnlockOSThread()
	...
	// 省略了大量代码
	...
    // 最终通过exec对容器进程空间执行命令
	return system.Execv(l.config.Args[0], l.config.Args[0:], os.Environ())
}

总结

  • exec 调用时序图

  • 结语

    终于又写完一篇分析笔记,人长大了时间就不是自己的了;时间紧......

    加油吧~~~少年 !!!

    On the road ~~~

    点赞 | 收藏

系列