整体流程

系统时序图.jpg

parent process模块设计与实现

当用户使用capsule run $container_id命令时,便启动了parent process,即capsule进程,它将作为容器进程的父进程。parent process模块主要负责容器的部分初始化工作与容器进程的监控。该模块由Factory、Container、ParentProcess三大类组成。
Factory创建Container,Container在启动时会依赖于不同的ParentProcess完成不同的初始化过程,创建容器会使用init类型的ParentProcess,创建新的Namespace;而进入容器执行命令时会使用exec类型的ParentProcess,进入已有的Namespace。
类图:
image.png
我们将启动容器的函数作为程序入口来分析整个模块。
该模块设计的主要类:

分类
工厂接口 Factory
工厂实现 LinuxContainerFactory
容器接口 Container
容器实现 LinuxContainer
容器实现所依赖的父进程接口 ParentProcess
容器实现所依赖的父进程抽象实现 ParentAbstractProcess
用于启动容器的父进程实现 initStartHook

启动容器的整个过程大概可以分为三步:

  • 1) 创建容器
  • 2) 创建Process
  • 3) 启动容器,运行Process

该函数中传入的参数有:

  • runtimeRoot:Capsule运行时文件的根目录,默认值为/var/run/capsule
  • id:容器ID
  • bundle:容器config.json配置文件的存放路径
  • spec:config.json转成的对象
  • network:容器所连接的网络名称
  • portMappings:容器的端口映射,用于将容器内部端口映射到宿主机的端口

这些便是启动容器所必需的所有参数。

  1. func CreateOrRunContainer(runtimeRoot string, id string, bundle string, spec *specs.Spec, action ContainerAction, detach bool, network string, portMappings []string) error {
  2. logrus.Infof("create or run container: %s, action: %s", id, action)
  3. container, err := CreateContainer(runtimeRoot, id, bundle, spec, network, portMappings)
  4. if err != nil {
  5. return err
  6. }
  7. // 将specs.Process转为libcapsule.Process
  8. process, err := newProcess(id, spec.Process, true, detach)
  9. logrus.Infof("new init process complete, libcapsule.Process: %#v", process)
  10. if err != nil {
  11. return err
  12. }
  13. var containerErr error
  14. switch action {
  15. case ContainerActCreate:
  16. // 如果是create,那么不管是否是terminal,都不会删除容器
  17. containerErr = container.Create(process)
  18. case ContainerActRun:
  19. // c.run == c.start + c.exec [+ c.destroy]
  20. containerErr = container.Run(process)
  21. }
  22. if containerErr != nil {
  23. return handleContainerCreateOrRunErr(container, containerErr)
  24. }
  25. // 如果是Run命令运行容器吗,并且是前台运行,那么Run结束,即为容器进程结束,则删除容器
  26. if action == ContainerActRun && !detach {
  27. if err := container.Destroy(); err != nil {
  28. return err
  29. }
  30. }
  31. return nil
  32. }

创建容器

创建容器,这里创建了一个新的容器工厂,使用容器工厂来创建容器:

  1. func CreateContainer(runtimeRoot string, id string, bundle string, spec *specs.Spec, network string, portMappings []string) (libcapsule.Container, error) {
  2. logrus.Infof("creating container: %s", id)
  3. if id == "" {
  4. return nil, fmt.Errorf("container id cannot be empty")
  5. }
  6. // 1、将spec转为容器config
  7. config, err := specutil.CreateContainerConfig(bundle, spec, network, portMappings)
  8. logrus.Infof("convert complete, config: %#v", config)
  9. if err != nil {
  10. return nil, err
  11. }
  12. // 2、创建容器工厂
  13. factory, err := libcapsule.NewFactory(runtimeRoot, true)
  14. if err != nil {
  15. return nil, err
  16. }
  17. // 3、创建容器
  18. container, err := factory.Create(id, config)
  19. if err != nil {
  20. return nil, err
  21. }
  22. return container, nil
  23. }

容器工厂创建容器的实现如下:
首先将容器的运行时目录创建出来,这里会检测ID是否重复,然后构造LinuxContainer对象。

  1. func (factory *LinuxContainerFactory) Create(id string, config *configs.ContainerConfig) (Container, error) {
  2. logrus.Infof("container factory creating container: %s", id)
  3. containerRoot := filepath.Join(factory.root, constant.ContainerDir, id)
  4. // 如果该目录已经存在(err == nil),则报错;如果有其他错误(忽略目录不存在的错,我们希望目录不存在),则报错
  5. if _, err := os.Stat(containerRoot); err == nil {
  6. return nil, exception.NewGenericError(fmt.Errorf("container with id exists: %v", id), exception.ContainerIdExistsError)
  7. } else if !os.IsNotExist(err) {
  8. return nil, exception.NewGenericError(err, exception.ContainerLoadError)
  9. }
  10. logrus.Infof("mkdir root: %s", containerRoot)
  11. if err := os.MkdirAll(containerRoot, 0644); err != nil {
  12. return nil, exception.NewGenericError(err, exception.ContainerRootCreateError)
  13. }
  14. container := &LinuxContainer{
  15. id: id,
  16. runtimeRoot: factory.root,
  17. containerRoot: containerRoot,
  18. config: *config,
  19. cgroupManager: cgroups.NewCroupManager(id, make(map[string]string)),
  20. }
  21. container.statusBehavior = &StoppedStatusBehavior{c: container}
  22. logrus.Infof("create container complete, container: %#v", container)
  23. return container, nil
  24. }

创建Process

这里我们将spec中Process对象转为内部Process对象。
注意,如果是启动容器,那么init为true;如果是进入容器执行命令,则init为false。

  1. func newProcess(id string, p *specs.Process, init, detach bool) (*libcapsule.Process, error) {
  2. logrus.Infof("converting specs.Process to libcapsule.Process")
  3. libcapsuleProcess := &libcapsule.Process{
  4. ID: id,
  5. Args: p.Args,
  6. Env: p.Env,
  7. Cwd: p.Cwd,
  8. Init: init,
  9. Detach: detach,
  10. }
  11. return libcapsuleProcess, nil
  12. }

启动容器

Run方法其实指的是在容器里运行一个进程,这个进程有可能是init进程,即容器的第一个进程,启动这个进程代表启动容器;这个进程也有可能是exec进程,即进入容器执行用户所指定的命令。无论是哪种进程,都会调用LinuxContainer的create方法(容器会阻塞在执行用户命令之前),而如果是init进程,那么会调用LinuxContainer的start方法(唤醒容器,执行用户命令)。

  1. func (c *LinuxContainer) Run(process *Process) error {
  2. c.mutex.Lock()
  3. defer c.mutex.Unlock()
  4. if err := c.create(process); err != nil {
  5. return err
  6. }
  7. if process.Init {
  8. if err := c.start(); err != nil {
  9. return err
  10. }
  11. }
  12. return nil
  13. }

Step1: 容器create

create方法的实现如下,分为三步:

  • 1) 创建ParentProcess
  • 2) 启动ParentProcess
  • 3) 如果是init进程,那么更新容器状态,并持久化容器状态
    1. func (c *LinuxContainer) create(process *Process) error {
    2. logrus.Infof("LinuxContainer starting...")
    3. // 1、创建parent config
    4. parent, err := c.newParentProcess(process)
    5. if err != nil {
    6. return exception.NewGenericErrorWithContext(err, exception.ParentProcessCreateError, "creating new parent process")
    7. }
    8. logrus.Infof("new parent process complete, parent config: %#v", parent)
    9. // 2、启动parent config,直至child表示自己初始化完毕,等待执行命令
    10. if err := parent.start(); err != nil {
    11. // 启动失败,则杀掉init process,如果是已经停止,则忽略。
    12. logrus.Warnf("parent process init/exec failed, killing init/exec process...")
    13. if err := c.ignoreTerminateErrors(parent.terminate()); err != nil {
    14. logrus.Warn(err)
    15. }
    16. return exception.NewGenericErrorWithContext(err, exception.ParentProcessStartError, "starting container process")
    17. }
    18. if process.Init {
    19. // 3、更新容器状态
    20. c.createdTime = time.Now()
    21. c.statusBehavior = &CreatedStatusBehavior{
    22. c: c,
    23. }
    24. // 4、持久化容器状态
    25. if err = c.saveState(); err != nil {
    26. return err
    27. }
    28. // 5、创建标记文件,表示Created
    29. if err := c.createFlagFile(); err != nil {
    30. return err
    31. }
    32. }
    33. logrus.Infof("create/exec container complete!")
    34. return nil
    35. }

1、创建ParentProcess的过程如下:
这个需要构造一个Command对象,即容器init进程。父子进程间通过socket pair双向通信,
下面会将Command加入一系列环境变量,如:

  • config pipe:socket pair的一端,交给子进程使用
  • initializer type:枚举值,取值范围为init或exec,前者表示子进程是容器init进程,创建容器;后者表示子进程是容器exec进程,进入容器执行命令

助理这里是构造了ParentAbstractProcess类型的ParentProcess实现赋给了LinuxContainer,其中startHook是一个函数,这里函数的实现是initStartHook,即用来启动容器的hook。这里是使用到了一个模板方法模式,因为Go支持函数类型,所以持有一个函数类型的变量即可实现多态。

  1. func (c *LinuxContainer) newParentProcess(process *Process) (ParentProcess, error) {
  2. logrus.Infof("new parent process...")
  3. logrus.Infof("creating pipes...")
  4. // socket pair 双方都可以既写又读,而pipe只能一个写,一个读
  5. parentConfigPipe, childConfigPipe, err := util.NewSocketPair("init")
  6. if err != nil {
  7. return nil, err
  8. }
  9. logrus.Infof("create config pipe complete, childConfigPipe: %#v, configPipe: %#v", childConfigPipe, parentConfigPipe)
  10. cmd, err := c.buildCommand(process, parentConfigPipe)
  11. if err != nil {
  12. return nil, err
  13. }
  14. if process.Init {
  15. cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", constant.EnvInitializerType, string(InitInitializer)))
  16. logrus.Infof("build command complete, command: %#v", cmd)
  17. logrus.Infof("new parent init process...")
  18. namepaces := make(map[configs.NamespaceType]string)
  19. for _, ns := range c.config.Namespaces {
  20. if ns.Path != "" {
  21. namepaces[ns.Type] = ns.Path
  22. }
  23. }
  24. initProcess := &ParentAbstractProcess{
  25. processCmd: cmd,
  26. parentConfigPipe: childConfigPipe,
  27. container: c,
  28. process: process,
  29. cloneFlags: c.config.Namespaces.CloneFlagsOfEmptyPath(),
  30. namespacePathMap: namepaces,
  31. startHook: initStartHook,
  32. }
  33. // exec process不会赋到container.parentProcess,因为它的pid,startTime返回的都是exec process的,而非nochild process(反映的是init process的)
  34. c.parentProcess = initProcess
  35. return initProcess, nil
  36. } else {
  37. // ...
  38. }
  39. }

buildCommand构造了一个Command对象,它会执行本进程的init命令,类似于执行了capsule init命令,自己调用(另一个进程中的)自己。

  1. func (c *LinuxContainer) buildCommand(process *Process, childConfigPipe *os.File) (*exec.Cmd, error) {
  2. // 将factory runtime root作为参数传给init/exec进程
  3. cmd := exec.Command(constant.ContainerInitCmd, "--root", c.runtimeRoot, constant.ContainerInitArgs)
  4. cmd.Dir = c.config.Rootfs
  5. cmd.ExtraFiles = append(cmd.ExtraFiles, childConfigPipe)
  6. cmd.Env = append(cmd.Env,
  7. fmt.Sprintf(constant.EnvConfigPipe+"=%d", constant.DefaultStdFdCount+len(cmd.ExtraFiles)-1),
  8. )
  9. // 这里cmd是指init进程,init进程后面还会启动一个进入go runtime的进程,而init进程并不会进入,所以进程的stdin等置为os的
  10. cmd.Stdin = os.Stdin
  11. cmd.Stdout = os.Stdout
  12. cmd.Stderr = os.Stderr
  13. return cmd, nil
  14. }

2、启动ParentProcess
启动的步骤可以分为5步:

  • 1) 启动Command,即子进程
  • 2) 传输需要加入的Namespace列表
  • 3) 传输需要新建的CloneFlags(同样对应Namespace列表,只是将每个NS的编号使用按位与合并起来)
  • 4) 从子进程中读到子进程创建的init-1进程的PID,稍后在nsenter模块实现中会解释为什么init0进程需要再创建一个init1进程。
  • 5) 启动start hook
    1. func (p *ParentAbstractProcess) start() error {
    2. logrus.Infof("ParentAbstractProcess starting...")
    3. if err := p.processCmd.Start(); err != nil {
    4. return exception.NewGenericErrorWithContext(err, exception.CmdStartError, "starting init/exec process command")
    5. }
    6. logrus.Infof("INIT/EXEC PROCESS STARTED, PID: %d", p.pid())
    7. if err := p.sendNamespaces(); err != nil {
    8. return exception.NewGenericErrorWithContext(err, exception.PipeError, "sending namespacePathMap to init/exec process")
    9. }
    10. if err := p.sendCloneFlags(); err != nil {
    11. return exception.NewGenericErrorWithContext(err, exception.PipeError, "sending clone flags to init/exec process")
    12. }
    13. childPid, err := util.ReadIntFromFile(p.parentConfigPipe)
    14. logrus.Infof("read child pid from parent pipe: %d", childPid)
    15. if err != nil {
    16. return exception.NewGenericErrorWithContext(err, exception.PipeError, "reading child pid")
    17. }
    18. process, err := os.FindProcess(childPid)
    19. if err != nil {
    20. return err
    21. }
    22. logrus.Infof("find new child process: %#v", process)
    23. p.processCmd.Process = process
    24. return p.startHook(p)
    25. }

init类型的startHook的实现如下:

  • 1) 对应cgroup模块,将容器加入到cgroup中,应用容器的资源限制配置
  • 2) 对应network模块,初始化容器网络
  • 3) 向子进程传输容器配置信息
  • 4) 等待容器子进程发送来的ready信号,表示容器已经初始化完毕,等待执行用户命令

    1. func initStartHook(p *ParentAbstractProcess) error {
    2. // 将pid加入到cgroup set中
    3. if err := p.container.cgroupManager.JoinCgroupSet(p.pid()); err != nil {
    4. return exception.NewGenericErrorWithContext(err, exception.CgroupsError, "applying cgroup configuration for process")
    5. }
    6. // 设置cgroup config
    7. if err := p.container.cgroupManager.SetConfig(p.container.config.Cgroup); err != nil {
    8. return exception.NewGenericErrorWithContext(err, exception.CgroupsError, "setting cgroup config for procHooks process")
    9. }
    10. // 创建网络接口
    11. if err := createNetworkInterfaces(p); err != nil {
    12. return exception.NewGenericErrorWithContext(err, exception.NetworkError, "creating network interfaces")
    13. }
    14. // init process会在启动后阻塞,直至收到config
    15. if err := p.sendConfigAndClosePipe(); err != nil {
    16. return exception.NewGenericErrorWithContext(err, exception.PipeError, "sending config to init process")
    17. }
    18. // 等待init process到达在初始化之后,执行命令之前的状态
    19. // 使用SIGUSR1信号
    20. logrus.Info("start waiting init process ready(SIGUSR1) or fail(SIGCHLD) signal...")
    21. sig := util.WaitSignal(syscall.SIGUSR1, syscall.SIGCHLD)
    22. if sig == syscall.SIGUSR1 {
    23. logrus.Info("received SIGUSR1 signal")
    24. } else if sig == syscall.SIGCHLD {
    25. logrus.Errorf("received SIGCHLD signal")
    26. return fmt.Errorf("init process init failed")
    27. }
    28. return nil
    29. }

Step2: 容器start

向容器进程发送开始执行用户命令的信号。
如果是前台运行的进程,那么父进程会在此阻塞,监控容器状态,直至容器进程结束,然后执行清理工作。

  1. func (c *LinuxContainer) start() error {
  2. logrus.Infof("container starting...")
  3. logrus.Infof("send SIGUSR2 to child process...")
  4. if err := c.parentProcess.signal(syscall.SIGUSR2); err != nil {
  5. return err
  6. }
  7. // 这里不好判断是否是之前在运行的是否是init process,索性就 有就删,没有就算了
  8. if err := c.deleteFlagFileIfExists(); err != nil {
  9. return err
  10. }
  11. logrus.Infof("refreshing container status...")
  12. if err := c.refreshStatus(); err != nil {
  13. return err
  14. }
  15. // 对于前台进程来说,这里必须wait,否则在仅有容器进程存活情况下,它在输入任何命令后立即退出,并且ssh进程退出/登录用户注销
  16. if !c.parentProcess.detach() {
  17. logrus.Infof("wait child process exit...")
  18. if err := c.parentProcess.wait(); err != nil {
  19. return exception.NewGenericErrorWithContext(err, exception.ParentProcessWaitError, "waiting child process exit")
  20. }
  21. logrus.Infof("child process exited")
  22. }
  23. return nil
  24. }

cgroups模块设计与实现

容器最重要的两个特性是资源隔离与资源限制,资源隔离是由nsenter模块实现的,稍后会进行介绍,而cgroups模块负责实现的资源限制。
在容器启动时是通过这两步实现的:

  • 1) 将容器进程的PID加入到cgroup set中,即JoinCgroupSet
  • 2) 设置cgroup config,即SetConfig

这两步的实现的核心是cgroupManager。
image.png
简言之,CgroupManager的实现是将容器作为hierachy上的一个节点,挂在每个subsystem上,具体如何配置取决于每个subsys自己的逻辑。具体实现细节如下:
image.png

network模块设计与实现

在Docker中容器网络有着四种实现,分别是

  • host模式,与宿主机共享同一网络
  • container模式,与某些容器共享同一网络
  • none模式,不使用网络
  • bridge模式,网桥模式,每个容器独享网络空间,并通过网桥连接到宿主机

网络模块有着几个重要概念,分别是NetworkDriver、Network、Endpoint和IPAM。

类/接口名 描述
NetworkDriver 提供了一类容器网络接口,目前仅有Bridge一种实现,后续可以继续扩展
Network 对应一个网段的网络,Endpoint指的是连接到一个网络上的端口,与容器一一对应
IPAM, IP Adress Management IP分配器,从一个网段中随机分配一个IP给到Endpoint

网络模块的类图如下图所示:
image.png
在parent process模块中,在容器进程启动后,会拿到容器init 进程的PID,并且创建一个新的Network Namespace,然后就可以在parent process中构造一个新的Endpoint,将Endpoint连接到指定的Network上。

在Capsule中,我们会在启动容器时先创建一个网桥capsule_bridge0(类似于Docker中的docker0),每个容器独享一个Network Namespace,然后将每个容器的Net NS都连接到网桥上,以实现容器间、容器与宿主机间,以及基于SNAT、DNAT技术实现容器与外部网络间的网络通信。
capsule_bridge0所对应的网段为192.168.1.0/24。
创建一个容器网络可以分为以下步骤:

  • 1) [初始化]创建一个网桥,为其分配网关地址,并配置路由,将对192.168.1.0/24的网络请求均路由到网桥上。
  • 2) [初始化]设置SNAT规则,将来自192.168.1.0/24的网络请求进行源地址的转换,转为除网桥外的网络设备的地址(通常为eth0)。这一步可以使容器的网络请求可以发送到外部网络。
  • 3) 创建一个容器,与此同时会创建一个新的Network Namespace。
  • 4) 创建一个veth pair,将一端连接到网桥上,将另一端移动到Net NS中。
  • 5) 为Net NS中的veth一端分配IP地址,并配置路由,将所有网络请求均路由到veth一端上。
  • 6) 设置DNAT规则,根据端口映射规则,将目的地址为192.168.1.0/24的指定端口的网络请求进行目的地址转换,转为容器veth一端的对应端口。这一步可以使外部网络的网络请求发送到容器中。

image.png

nsenter模块设计与实现

nsenter模块由C语言编写而成,始于parent process启动子进程(执行的是capsule init命令),终于容器init process进入Go Runtime,执行init process模块。
nsenter模块负责两件事:

  • 1) 将一个进程加入到已有Namespace
  • 2) 创建新的Namespace,并将一个进程加入其中

我们在init命令的实现中使用Go的import _ "github.com/songxinjianqwe/capsule/libcapsule/nsenter"触发nsenter模块的调用。
Go与C间的互相调用基于Cgo技术实现,这里需要解释为什么这部分需要使用C而不是Go来实现:加入已有Namespace需要使用setns系统调用,而这个系统调用在Linux Manual上被描述为:

A process may not be reassociated with a new mount namespace if it is multithreaded.

即多线程环境下无法使用setns进入一个已有的mount namespace。

Cgo代码实现如下,下面的init函数将在Go代码执行前被调用。

  1. /*
  2. #cgo CFLAGS: -Wall
  3. #include <stdio.h>
  4. extern void nsexec();
  5. // __attribute__((constructor)):在main函数之前执行某个函数
  6. // https://stackoverflow.com/questions/25704661/calling-setns-from-go-returns-einval-for-mnt-namespace
  7. // https://lists.linux-foundation.org/pipermail/containers/2013-January/031565.html
  8. __attribute__((constructor)) void init(void) {
  9. nsexec();
  10. }
  11. */
  12. import "C"

nsexec函数实现如下:
init-0进程,即capsule init命令启动的进程,会读取已经存在、待加入的Namespace列表,然后使用setns系统调用来进入NS;然后会使用clone系统调用,来创建init-1进程,同时创建需要新建的Namespace,然后将init-1进程的PID写回给parent process,随后退出。
init-1进程会回到Go Runtime,然后执行init process模块。
这里需要解释下为什么需要再创建一个init-1进程:对于PID Namespace,是无法通过setns来进入新的Namespace。这是因为PID对用户态的函数而言是一个固定值,不存在更换PID Namespace的情况,因此我们想进入一个已有的NS,就需要在父进程中setns,然后clone出子进程,子进程就可以加入已有的PID Namespace了。

  1. void nsexec() {
  2. // init和exec都会进入此段代码
  3. const char* config_pipe_env = getenv(ENV_CONFIG_PIPE);
  4. if (!config_pipe_env) {
  5. return;
  6. }
  7. printf("%s read config pipe env: %s\n", LOG_PREFIX, config_pipe_env);
  8. int config_pipe_fd = atoi(config_pipe_env);
  9. if (config_pipe_fd <= 0) {
  10. printf("%s converting config pipe to int failed\n", LOG_PREFIX);
  11. exit(ERROR);
  12. }
  13. printf("%s config pipe fd: %d\n", LOG_PREFIX, config_pipe_fd);
  14. jmp_buf env;
  15. int status;
  16. switch(setjmp(env)) {
  17. case JUMP_PARENT:
  18. status = join_namespaces(config_pipe_fd);
  19. if (status != 0) {
  20. exit(status);
  21. }
  22. // 最后让child进入go runtime,因为自己setns后无法进入新的PID NS,只有child才能.
  23. status = clone_child(config_pipe_fd, &env);
  24. exit(status);
  25. case JUMP_CHILD:
  26. printf("%s JUMP_CHILD succeeded\n", LOG_PREFIX);
  27. return;
  28. }
  29. }

下图以时序图的方式来描述了主进程与init进程间的交互关系:
image.png

init process模块设计与实现

容器init进程在执行完nsenter模块后,就返回到Go Runtime中,执行init process模块。
该模块会使用Initializer来执行Standard初始化,即容器进程初始化,或者Exec初始化,即进入已经存在的容器,执行用户指定的新的命令。这取决于parent process设置的环境变量_LIBCAPSULE_INITIALIZER_TYPE的取值是init还是exec。

image.png
以standard实现为例:
我们会做以下步骤来进行容器的初始化:

  • 1) 输出重定向,对于后台运行的进程,需要将输出重定向到文件中,而非直接打印出来,方便后续使用capsule log命令来查询
  • 2) 初始化rootfs,包括挂载,创建设备,以及基于pivot_root系统调用来将指定目录来作为容器进程的rootfs
  • 3) 初始化hostname
  • 4) 初始化sysctl系统变量
  • 5) 向parent process发送一个容器进程初始化完毕的信号
  • 6) 等待parent process的信号
  • 7) 在接收到parent process的开始执行用户命令的信号后,执行用户命令
  1. func (initializer *InitializerStandardImpl) Init() (err error) {
  2. logrus.WithField("init", true).Infof("InitializerStandardImpl Init()")
  3. // 如果后台运行,则将stdout输出到日志文件中
  4. if initializer.config.ProcessConfig.Detach {
  5. logrus.Infof("detach -> replace stdout to log file")
  6. // 输出重定向
  7. // /var/run/capsule/containers/$container_id/container.log
  8. logFile, err := os.OpenFile(filepath.Join(initializer.containerRoot, constant.ContainerInitLogFilename), os.O_WRONLY|os.O_CREATE|os.O_SYNC, 0644)
  9. if err != nil {
  10. return err
  11. }
  12. if err := syscall.Dup2(int(logFile.Fd()), 1); err != nil {
  13. return err
  14. }
  15. if err := syscall.Dup2(int(logFile.Fd()), 2); err != nil {
  16. return err
  17. }
  18. }
  19. // 初始化rootfs
  20. if err = initializer.setUpRootfs(); err != nil {
  21. return exception.NewGenericErrorWithContext(err, exception.RootfsError, "init process/prepare rootfs")
  22. }
  23. // 如果有设置Mount的Namespace,则设置rootfs与mount为read only(如果需要的话)
  24. if initializer.config.ContainerConfig.Namespaces.Contains(configs.NEWNS) {
  25. if err := initializer.SetRootfsReadOnlyIfSpecified(); err != nil {
  26. return err
  27. }
  28. }
  29. // 初始化hostname
  30. if hostname := initializer.config.ContainerConfig.Hostname; hostname != "" {
  31. logrus.WithField("init", true).Infof("init process/setting hostname: %s", hostname)
  32. if err = unix.Sethostname([]byte(hostname)); err != nil {
  33. return exception.NewGenericErrorWithContext(err, exception.HostnameError, "init process/set hostname")
  34. }
  35. }
  36. // 初始化sysctl环境变量
  37. for key, value := range initializer.config.ContainerConfig.Sysctl {
  38. if err = writeSystemProperty(key, value); err != nil {
  39. return exception.NewGenericErrorWithContext(err, exception.SysctlError, fmt.Sprintf("init process/write sysctl key %s", key))
  40. }
  41. }
  42. // look path 可以在系统的PATH里面寻找命令的绝对路径
  43. name, err := exec.LookPath(initializer.config.ProcessConfig.Args[0])
  44. if err != nil {
  45. return exception.NewGenericErrorWithContext(err, exception.LookPathError, "init process/look path cmd")
  46. }
  47. // child --------------> parent
  48. // 告诉parent,init process已经初始化完毕,马上要执行命令了
  49. if err := util.SyncSignal(initializer.parentPid, syscall.SIGUSR1); err != nil {
  50. return exception.NewGenericErrorWithContext(err, exception.SignalError, "init process/sync parent ready")
  51. }
  52. // child <-------------- parent
  53. // 等待parent给一个继续执行命令,即exec的信号
  54. util.WaitSignal(syscall.SIGUSR2)
  55. if err := syscall.Exec(name, initializer.config.ProcessConfig.Args, os.Environ()); err != nil {
  56. return exception.NewGenericErrorWithContext(err, exception.SyscallExecuteCmdError, "start init process")
  57. }
  58. return nil
  59. }

image模块设计与实现

镜像模块是对Docker镜像的简化,去掉了镜像构建与远程仓库相关的内容,仅保留了导入本地镜像,管理本地镜像,基于本地镜像运行容器的功能。
Union FS中有着将文件系统堆叠起来形成联合文件系统的能力,而镜像模块利用Union FS,将镜像本身作为read-only layer,将一个新的临时目录作为read-write layer,联合后形成init layer,作为rootfs供容器使用。
镜像的存储功能完全基于文件的方式实现,实现细节如下:
image.png
/sys/fs/cgroup/memory/
├── myc/
│ ├── cgroup.procs
│ ├── memory.limit_in_bytes
├── cgroup.clone_children
├── cgroup.event_control
├── cgroup.procs
├── cgroup.sane_behavior
├── memory.kmem.max_usage_in_bytes
├── memory.limit_in_bytes
└── tasks

  • repositories.json中以键值对的形式存储了镜像名与镜像layer_id之间的映射关系。
  • layers目录下存储镜像内容,子目录名为layer_id,子目录的内容为layer的内容
  • mounts目录下存储容器与layer的映射关系,子目录名为container_id,子目录下固定存放三个文件read_only、read_write和init

在创建一个新的容器myc后,会在mounts目录下创建同名目录,并固定创建三个文件,read_only文件存储了镜像layer_id,read_write文件存储了一个刚创建的layer的layer_id,init文件存储了基于aufs(Union FS的一种实现)将刚才两个layer堆叠起来的layer_id。容器在运行时所用到的rootfs目录便是init layer。
在容器被删除后,我们会取消堆叠(umount),然后删除read_write layer,此时容器对rootfs的一些修改操作将被清除,read_only layer的内容不会被修改,始终保持导入镜像时的状态。

相关的实现均由ImageService完成,接口定义如下:

  1. type ImageService interface {
  2. Create(id string, tarPath string) error
  3. Delete(id string) error
  4. List() ([]Image, error)
  5. Get(id string) (Image, error)
  6. Run(imageRunArgs *ImageRunArgs) error
  7. Destroy(container libcapsule.Container) error
  8. }

以基于镜像启动容器为例来分析代码实现:
该功能分为以下步骤实现:

  • 1) 检测容器ID是否已经存在
  • 2) 创建bundle目录,即config.json所在的目录(在OCI标准中是由用户准备的)
  • 3) 准备/etc/hosts,我们会将hosts文件通过挂载的方式放到rootfs中。这里还实现了link功能,类似于Docker中的link,即容器间的关联,我们可以指定一个hosts解析,将所依赖容器的IP映射为用户自己指定的别名。比如Web应用依赖于DB应用,但不清楚DB应用分配的IP地址,此时可以使用link,通过域名解析的方式来简化用户操作,只需要指定一个别名即可使Web应用连接到DB应用。用法类似于—link $related_container_id:alias。
  • 4) 准备/etc/resolv.conf,这是dns服务实现的关键,我们会将nameserver的地址写入其中
  • 5) 准备volume,volume简言之就是使用容器的mount功能实现的,将宿主机的目录mount到容器rootfs目录,这样就可以实现容器对rootfs的修改不至于在容器销毁后被全部清除
  • 6) 准备rootfs,即Union FS的堆叠操作
  • 7) 准备spec,即根据用户在命令行中提供的参数,生成一份config.json,写到bundle目录下
  • 8) 运行容器
  • 9) 如果是前台运行的容器,那么在容器销毁后,将运行时文件删除,即mounts和containers目录下对应的容器子目录

    1. func (service *imageService) Run(imageRunArgs *ImageRunArgs) (err error) {
    2. // 1. 检查是否已经存在该容器
    3. if exists := service.factory.Exists(imageRunArgs.ContainerId); exists {
    4. return exception.NewGenericError(fmt.Errorf("container already exists: %s", imageRunArgs.ContainerId), exception.ContainerIdExistsError)
    5. }
    6. // 2. 创建bundle目录
    7. // /var/run/capsule/images/containers/$container_id
    8. bundle := filepath.Join(service.imageRoot, constant.ImageContainersDir, imageRunArgs.ContainerId)
    9. if _, err := os.Stat(bundle); err != nil && !os.IsNotExist(err) {
    10. return exception.NewGenericError(err, exception.ContainerIdExistsError)
    11. }
    12. if err := os.MkdirAll(bundle, 0644); err != nil {
    13. return exception.NewGenericError(err, exception.BundleCreateError)
    14. }
    15. defer func() {
    16. if err != nil {
    17. logrus.Warnf("imageService#Run failed, clean data")
    18. if cleanErr := service.cleanContainer(imageRunArgs.ContainerId); cleanErr != nil {
    19. logrus.Warnf(cleanErr.Error())
    20. }
    21. }
    22. }()
    23. var rootfsPath string
    24. var spec *specs.Spec
    25. // 3. 准备/etc/hosts,会在/var/run/capsule/images/containers/$container_id下创建一个hosts
    26. hostsMount, err := service.prepareHosts(imageRunArgs.ContainerId, imageRunArgs.Links)
    27. if err != nil {
    28. return err
    29. }
    30. // 4. 准备/etc/resolv.conf,会在/var/run/capsule/images/containers/$container_id下创建一个resolv.conf
    31. dnsMount, err := service.prepareDns(imageRunArgs.ContainerId)
    32. if err != nil {
    33. return err
    34. }
    35. // 5. 准备volume
    36. volumeMounts, err := service.prepareVolumes(imageRunArgs.Volumes)
    37. if err != nil {
    38. return err
    39. }
    40. // 6. 准备rootfs
    41. if rootfsPath, err = service.prepareUnionFs(imageRunArgs.ContainerId, imageRunArgs.ImageId); err != nil {
    42. return err
    43. }
    44. // 7. 准备spec
    45. specMounts := []specs.Mount{hostsMount, dnsMount}
    46. specMounts = append(specMounts, volumeMounts...)
    47. if spec, err = service.prepareSpec(rootfsPath, bundle, imageRunArgs, specMounts); err != nil {
    48. return err
    49. }
    50. // 8. 运行容器,如果运行出错,或者前台运行正常退出,则清理
    51. if err = facade.CreateOrRunContainer(service.factory.GetRuntimeRoot(), imageRunArgs.ContainerId, bundle, spec, facade.ContainerActRun, imageRunArgs.Detach, imageRunArgs.Network, imageRunArgs.PortMappings); err != nil {
    52. if cleanErr := service.cleanContainer(imageRunArgs.ContainerId); cleanErr != nil {
    53. logrus.Warnf(cleanErr.Error())
    54. }
    55. return err
    56. }
    57. if !imageRunArgs.Detach {
    58. if cleanErr := service.cleanContainer(imageRunArgs.ContainerId); cleanErr != nil {
    59. logrus.Warnf(cleanErr.Error())
    60. }
    61. }
    62. return nil
    63. }

使用Capsule运行Web应用

下面进入实战环节,使用Capsule来运行一个基于Spring Boot的Web应用+MySQL+Redis的服务器集群。
Web应用的名称为capsule-demo-app

Step0 准备镜像

首先我们需要在Docker中pull下mysql和redis镜像,然后使用docker export命令导出镜像为tar包。
capsule-demo-app的Dockerfile为:

  1. FROM java:8
  2. VOLUME /tmp
  3. ADD capsule-demo-app.jar app.jar
  4. EXPOSE 8080
  5. ENTRYPOINT [ "sh", "-c", "java -jar /app.jar"]

同样也要导出tar包,此时我们会有三个tar包。

Step1 导入镜像

capsule image create $image_name $tar_path
image.png

Step2 启动Redis

首先我们需要知道Dockerfile中有CMD或者ENTRYPOINT这样的语句用来指定启动时的命令,capsule为了简化没有做这一步,对capsule来说镜像==rootfs。启动命令需要自己输入。
通过阅读Redis的Dockerfile,可以拿到启动命令,大概就是运行一个脚本,在同目录下可以读到这个docker-entrypoint.sh脚本代码。
因为capsule没有实现user namespace,容器中只能用root权限,所以我们需要手动修改脚本内容,将脚本代码中chown相关的代码去掉,否则运行时会报错权限不足。

然后使用capsule image runc redis /usr/local/bin/docker-entrypoint.sh redis-server --id redis -p 6379:6379 -d来启动redis容器。
我们分析一下这条命令:

  • capsule image runc是根据镜像来启动容器的命令
  • redis是镜像名
  • /usr/local/bin/docker-entrypoint.sh redis-server是启动命令
  • id即容器名,需要唯一,这里是redis
  • p是port的缩写,指定端口映射,即将容器内的6379端口映射到宿主机的6379端口
  • d是detach的缩写,指定后台运行

启动之后如果没有报错,则使用capsule image list命令来查看已经启动的容器。
如果STATUS是Running,则说明容器启动成功。
可以进入redis容器,使用redis-cli来检测是否真正OK,比如命令capsule exec redis bash

Step3 启动MySQL

类似于Redis,同样需要修改脚本文件。
使用这条命令来启动mysql容器:capsule image runc mysql "/usr/local/bin/docker-entrypoint.sh mysqld --user=root" -id=mysql -v /root/mysql/logs:/logs -v /root/mysql/data:/var/lib/mysql -p 3306:3306 -d

我们分析一下这条命令:

  • capsule image runc是根据镜像来启动容器的命令
  • mysql是镜像名
  • “/usr/local/bin/docker-entrypoint.sh mysqld —user=root”是启动命令,因为命令中也包含参数,所以用引号引起来,capsule中对于args数组长度为1的进行了特殊处理,如果包含空格则split后再赋值给args
  • id即容器名,需要唯一,这里是mysql
  • v是volume的缩写,指定volume可以使得容器在销毁后仍然在宿主机上保存部分文件,对于mysql这种需要持久化存储的应用来说volume是必要的,当然宿主机上的目录需要我们先行创建好。
  • p是port的缩写,指定端口映射,即将容器内的6379端口映射到宿主机的6379端口
  • d是detach的缩写,指定后台运行

启动之后我们需要进入mysql容器中,创建一个名为demo的数据库schema,并且将外部访问权限由仅本机修改为任意host。

Step4 启动Web应用

capsule image runc capsule-demo-app "java -jar /app.jar" -id capsule-demo-container -e "SPRING_PROFILES_ACTIVE=prod" -p 8080:8080 -d -link mysql:mysql-container -link redis:redis-container
这里使用link来指定连接的mysql和redis服务器。

如果遇到问题可以使用capsule log $container_name的方式来打印容器的stdout日志。