containerd系统分析(四)-容器启动流程分析

 containerd系列文章:

containerd系统分析(一)-系统组成-CSDN博客

containerd系统分析(二)-镜像管理-CSDN博客

containerd系统分析(三)-容器创建流程分析-CSDN博客

containerd系统分析(四)-容器启动流程分析-CSDN博客

containerd系统分析(五)-网络分析-CSDN博客

containerd系统分析(六)-CRI接口-CSDN博客

1 启动流程的介绍

在执行完创建容器的过程后,若把容器启动起来,需要执行ctr task start bb-1指令,其中bb-1为在前文系统分析(三)https://mp.csdn.net/mp_blog/creation/editor/145000596创建的容器。

执行ctr task start,实际上是执行了下面的二个步骤:

tasks.NewTask
task.Start(ctx)

若是启动了cni,还要执行网络的安装.

创建任务总结如下:这个过程实际上就是准备了runtime要执行的bundle,然后通过runc-shim通过这个bundle去创建了容器。

2 创建任务

2.1 ctr的执行的内容

  1. 如果设置了tty参数,要先获得stdin\stdout\stderr的句柄con,若同时设置了null-io那就配错了
  2. 获得fifo-dir参数,这个是用来和tty相关的
  3. 获得log-uri
  4. 获得newtask的参数: no-pivot uidmap gidmap
  5. 先看看checkpoint是不是不为空,若不过的话,要去查找下镜像,若获得到的镜像的MediaType是application/vnd.containerd.container.criu.checkpoint.criu.tar,那就是checkpoint的,以获得到的镜像的信息构建这个taskInfo的checkpoint
  6. 创建ioCreator,这个ioCreator的用途是用来创建一个io.若没有设置fifo-dir,那么默认的fifo-dir为/run/containerd/fifo.在fifo-dir目录下创建io绑定以下文件(id为容器的id):
    Stdin:    filepath.Join(dir, id+"-stdin"),
    Stdout:   filepath.Join(dir, id+"-stdout"),
    Stderr:   filepath.Join(dir, id+"-stderr"),
      这里的io用途是将io的内容copy到con句柄,这样tty就可以与容器交互了
    func NewCreator(opts ...Opt) Creator {
       streams := &Streams{}
       for _, opt := range opts {
          opt(streams)
       }
       if streams.FIFODir == "" {
          streams.FIFODir = defaults.DefaultFIFODir
       }
       return func(id string) (IO, error) {
          fifos, err := NewFIFOSetInDir(streams.FIFODir, id, streams.Terminal)
          if err != nil {
             return nil, err
          }
          if streams.Stdin == nil {
             fifos.Stdin = ""
          }
          if streams.Stdout == nil {
             fifos.Stdout = ""
          }
          if streams.Stderr == nil {
             fifos.Stderr = ""
          }
          return copyIO(fifos, streams)
       }
    }
  7. 执行 t, err := container.NewTask(ctx, ioCreator, opts...)去创建task,在这里主要做的事情,是构建创建task的报文CreateTaskRequest和构建返回值taskInfo
  8. 这个createTaskRequest包含以下内容:
    1. 容器的id

    2. 通过ioCreator得到io,再由io得到req需要的参数

      i, err := ioCreate(c.id)
      if err != nil {
         return nil, err
      }
      defer func() {
         if err != nil && i != nil {
            i.Cancel()
            i.Close()
         }
      }()
      cfg := i.Config()
      request := &tasks.CreateTaskRequest{
         ContainerID: c.id,
         Terminal:    cfg.Terminal,
         Stdin:       cfg.Stdin,
         Stdout:      cfg.Stdout,
         Stderr:      cfg.Stderr,
      }

  9.  根据容器的id去请求容器的详情
  10. 若容器的SnapshotKey不为空,去构建snapshotter服务,以便去containerd请求snapshot数据
  11. 获得通过snapshotter获得到容器的mount数据,即overlayfs的upper-dir和lower-dir
  12. 构建req. Rootfs为上述的mounts的值
  13. 若上述的checkpoint不为空,req.checkpoin的值为获得到的checkpoint的值
  14. 构建task对象,
    t := &task{
       client: c.client, //复用clinet,用来和containerd通信的
       io:     i,  //创建的io对象,用来tty输入输出的
       id:     c.id, //容器的id
       c:      c, //容器对象
    }

  15. 调用 response, err := c.client.TaskService().Create(ctx, request)去containerd创建容器了
  16. 在创建成功后,应答,并将应答中的pid,赋值给上面的task的pid,即赋值给t,通过ps 看这个pid, 可以看到当前这个pid对应的进程是runc init
  17. 若是设置了cni参数,这里还要安装网络插件,网络的流程在后面分析
  18. 若是pid-file设置了,要把pid写到这个文件中

2.2 containerd的执行的内容

  1. 根据请求报文中的容器id获得容器的信息
  2. 看看容器参数中有没有checkpoint的参数要求,若有获得checkpointpath
  3. 若有checkpoint,将checkpoint的镜像,解压到checkpointpath所指向的路径
  4. 构建runtime.CreateOpts
    opts := runtime.CreateOpts{
    		Spec: container.Spec, //容器的spec参数
    		IO: runtime.IO{
    			Stdin:    r.Stdin,  //请求报文的输入
    			Stdout:   r.Stdout, //请求报文的输出
    			Stderr:   r.Stderr,  
    			Terminal: r.Terminal, //是否使用了terminal
    		},
    		Checkpoint:     checkpointPath, //checkpoint的路径
    		Runtime:        container.Runtime.Name, //运行时的名称
    		RuntimeOptions: container.Runtime.Options, 
    		TaskOptions:    r.Options, //任务options
    		SandboxID:      container.SandboxID,
    	}

  5. 若请求报文中的runtimepath的路径不为空,则设定opts.Runtime=r.RuntimePath
  6. 若请求报文中的Rootfs不为空,则设置opts.Rootfs
  7. 根据容器中的参数Runtime.Name来查找运行时的插件实例,这里的默认为io.containerd.runc.v2
  8. 根据获得的运行时实例是查找下,容器任务是否存在,若存在就说有问题了。返回错误,说task已经存在
  9. 由runtime实例,根据容器id和上述opts,执行创建任务,调用代码c, err := rtime.Create(ctx, r.ContainerID, opts)。 在create的时候,执行的工作有:
    1. 创建bundle:

      在(m *ShimManager) Start中首先会去创建bundle

      在代码中m.root和m.state的作用是不同的.

      m.root指向的是/var/lib/containerd/io.containerd.runtime.v2.task

      m.state指向的是/run/containerd/io.containerd.runtime.v2.task,实际上这个路径下存的东西是最终runc要用到的

      最终得到的bundle的路径是得到的bundle是目标执行的路径state的目标路径为/run/containerd/io.containerd.runtime.v2.task/{ns}/{id}

      调用的代码是

      func NewBundle(ctx context.Context, root, state, id string, spec typeurl.Any) (b *Bundle, err error) {
      1. 相关的处理逻辑如下:
        1. 先是获得namespace
        2. 构建work路径,filepath.Join(root, ns, id),最终得到的work路径是/var/lib/containerd/io.containerd.runtime.v2.task/{ns}/{id}
        3. 构建budle对象:
          b = &Bundle{
             ID:        id, //容器的id
             Path:      filepath.Join(state, ns, id), //运行时的路径为/run/containerd/io.containerd.runtime.v2.task/{ns}/{id}
             Namespace: ns, //ctr时为default,docker为moby
          }
        4. 创建b.Path,如果不存在的时候
        5. 创建work路径
        6. 在b.path目录下创建rootfs
        7. 将work路径链接到b.Path/work路径
        8. b.Path下,将spec写到config.json中,这是一步非常关键的一步操作
        9. 上述操作过程中,若有失败时,要删除已创建的路径
    2. 创建shim:创建shim也是在(m *ShimManager) Start执行创建shim,会调用(m *ShimManager) startShim去执行创建,过程如下:根据runtime的Name解析出,可以调的shim的插件的名称和路径,对于io.containerd.runtime.v2来说,所用的插件就是containerd-shim-runc-v2了
      b := shimBinary(bundle, shimBinaryConfig{
         runtime:      runtimePath,
         address:      m.containerdAddress, //var/containerd/containerd.sock
         ttrpcAddress: m.containerdTTRPCAddress, //var/containerd/contaierd.sock.ttrpc
         schedCore:    m.schedCore,
      })
    3. 启动shim:
      1. 在shim对象创建好后,就可以启动shim了
      2. shim, err := b.Start(ctx, protobuf.FromAny(topts), func() {

      3. 在这主要是生成可执行的命令,以启动shim
        cmd, err := client.Command(
           ctx,
           &client.CommandConfig{
              Runtime:      b.runtime, //shim插件
              Address:      b.containerdAddress, //监听的端口
              TTRPCAddress: b.containerdTTRPCAddress,
              Path:         b.bundle.Path, //bundle的路径
              Opts:         opts,
              Args:         args,
              SchedCore:    b.schedCore,
           })

      4. 接着执行cmd,shim插件就启动了
      5. out, err := cmd.CombinedOutput(),在这里运行命令并捕获输出的buff,输出的地址, unix:///run/containerd/s/随机数

      6. 向上述的输出的地址创建连接conn
      7. 在bundle路径中,在shim-binary-path中记录shim插件的名称
      8. 创建一个ttrpc client,后面要根据这个连接向shim发送报文

      9. 构建一个shim对象,将shim对象保存起来
        shim{
           bundle: b.bundle,
           client: client,
        }

    4. 创建shimtask:

      以已创建好的shim对象构建shimTask

      shimTask := newShimTask(shim)

      这个shimTask的作用是封装shimtask,这里会通过shim实例取一个client,来封装一个client,用来传递报文,对容器的生命周期的管理,由这个shimtask来完成

    5. shimtask发送创建容器任务到shim
      1. 调用代码(s *shimTask) Create(

      2. 构建的请求报文如下:

        request := &task.CreateTaskRequest{
           ID:         s.ID(),
           Bundle:     s.Bundle(), //bundle路径
           Stdin:      opts.IO.Stdin,//tty的io输入
           Stdout:     opts.IO.Stdout,//输出
           Stderr:     opts.IO.Stderr,
           Terminal:   opts.IO.Terminal,.//是否使用了termial
           Checkpoint: opts.Checkpoint,//checkpoint的路径
           Options:    protobuf.FromAny(topts), .//额外的运行时参数
        }

      3. 若opts中的mount参数不为空,则要添加到rootfs中

        for _, m := range opts.Rootfs {
           request.Rootfs = append(request.Rootfs, &types.Mount{
              Type:    m.Type,
              Source:  m.Source,
              Options: m.Options,
           })
        }

      4. 构建完之后,就可以发送创建任务报文了

2.3 shim执行的动作

这里的shim实际上是根据传来的容器指令,调用runc或crun等最底层的容器运行时去执行容器的相关的指令。在这里实际上是在执行容器的创建指令,实际上创建了一个进程,获得了一个PID,容器的执行环境也就创建好了。具体过程如下:

在shim侧调用创建任务的代码:

func (s *service) Create(ctx context.Context, r *taskAPI.CreateTaskRequest) (_ *taskAPI.CreateTaskResponse, err error) {

在这里主要是通过执行下面的代码进行容器的创建

container, err := runc.NewContainer(ctx, s.platform, r)

创建过程如下:

  1. 先获得namespace
  2. 将req.options解码到opts
  3. 如req.rootfs不为空,则从中取出内容添加到mounts中准备后面执行mount操作
  4. 若mounts不为空,在req.Bundle路径下创建rootfs
  5. 构建process.config对象
    config := &process.CreateConfig{
       ID:               r.ID,
       Bundle:           r.Bundle,
       Runtime:          opts.BinaryName,//这一步是很关键的,这个是说明要使用的runtime具体是什么
       Rootfs:           mounts,
       Terminal:         r.Terminal,
       Stdin:            r.Stdin,
       Stdout:           r.Stdout,
       Stderr:           r.Stderr,
       Checkpoint:       r.Checkpoint,
       ParentCheckpoint: r.ParentCheckpoint,
       Options:          r.Options,
    }

  6. 若opts不为空,将其内容写到req.Bundle目录下的options.json中
  7. 将opts.BinaryName的名称写到req.Bundle的runtime文件中
  8. 将mounts中的路径挂载至rootfs目录下,对于docker来说这一步是没有的,docker传过来的报文中root的路径指向是docker自己设定的绝对路径。
  9. 初始化容器任务的process
    p, err := newInit(
       ctx,
       r.Bundle,
       filepath.Join(r.Bundle, "work"),
       ns,
       platform,
       config,
       opts,
       rootfs,
    )

  10. 在这个newInit函数中,会执行以下事情:构建运行时对象: runtime := process.NewRunc(options.Root, path, namespace, options.BinaryName, options.SystemdCgroup)
    func NewRunc(root, path, namespace, runtime string, systemd bool) *runc.Runc {
       if root == "" {
          root = RuncRoot   //这个默认值是/run/containerd/runc下
       }
       return &runc.Runc{
          Command:       runtime, //这个也就是真实的用的运行时可能是runc,也可以是crun
          Log:           filepath.Join(path, "log.json"),
          LogFormat:     runc.JSON,
          PdeathSignal:  unix.SIGKILL,
          Root:          filepath.Join(root, namespace), ///run/containerd/runc/{ns}存储容器运行的state的数据
          SystemdCgroup: systemd,
       }
    }

  11. 创建一个新的process
    p := process.New(r.ID, runtime, stdio.Stdio{
       Stdin:    r.Stdin,
       Stdout:   r.Stdout,
       Stderr:   r.Stderr,
       Terminal: r.Terminal,
    })
    p.Bundle = r.Bundle
    p.Platform = platform
    p.Rootfs = rootfs
    p.WorkDir = workDir
    p.IoUID = int(options.IoUid)
    p.IoGID = int(options.IoGid)
    p.NoPivotRoot = options.NoPivotRoot
    p.NoNewKeyring = options.NoNewKeyring
    p.CriuWorkPath = options.CriuWorkPath
    if p.CriuWorkPath == "" {
       // if criu work path not set, use container WorkDir
       p.CriuWorkPath = p.WorkDir
    }

  12. 其中process.New的时候,在不同的阶段会设定不同的initState,例如:
    func New(id string, runtime *runc.Runc, stdio stdio.Stdio) *Init {
       p := &Init{
          id:        id,
          runtime:   runtime,
          pausing:   new(atomicBool),
          stdio:     stdio,
          status:    0,
          waitBlock: make(chan struct{}),
       }
       p.initState = &createdState{p: p}//在创建阶段就是createState,后面生命周期管理时会用到
       return p
    }

  13. 准备创建容器了

    func (p *Init) Create(ctx context.Context, r *CreateConfig) error {

  14. 在这里还是有些事情要做的,为最后的创建做准备:在bundle目录下创建一个pidfile文件:init.pid,创建createopt以作runc的参数
    opts := &runc.CreateOpts{
       PidFile:      pidFile.Path(),//pidfile用来记录pid
       NoPivot:      p.NoPivotRoot,
       NoNewKeyring: p.NoNewKeyring,
    }

  15. 调用runtime.Create从bundle创建
    if err := p.runtime.Create(ctx, r.ID, r.Bundle, opts); err != nil {
       return p.runtimeError(err, "OCI runtime create failed")
    }

  16. 在(r *Runc) Create(context context.Context, id, bundle string, opts *CreateOpts)做的事情如下:构建创建命令:
    args := []string{"create", "--bundle", bundle}
    if opts != nil {
      oargs, err := opts.args() //这里会将上面所述的opts转成args作为运行的命令行参数传过去
      if err != nil {
        return err
       }
       args = append(args, oargs...)
    }
    cmd := r.command(context, append(args, id)...)

  17. 执行命令,并等命令执行的结果
    if cmd.Stdout == nil && cmd.Stderr == nil {
       data, err := cmdOutput(cmd, true, nil)
       defer putBuf(data)
       if err != nil {
          return fmt.Errorf("%s: %s", err, data.String())
       }
       return nil
    }
    ….
    status, err := Monitor.Wait(cmd, ec)
    if err == nil && status != 0 {
       err = fmt.Errorf("%s did not terminate successfully: %w", cmd.Args[0], &ExitError{status})
    }
    return err
    
  18. 在容器创建完后,从pidFile中得到pid,然后再把这个pid返回给containderd

3 启动任务

3.1 客户端的动作:

在得到task对象后,通过task对象,执行容器启动运行容器中设定的运行的命令。调用代码:

if err := task.Start(ctx); err != nil

3.2 containerd服务端的动作

服务端的运行比较简单,根据2.3节创建task的shim的对象执行Start指令去执行要在容器中执行的命令,绑定到相关的pid上。

执行stat命令查看容器的状态

func (l *local) Start(ctx context.Context, r *api.StartRequest, _ ...grpc.CallOption) (*api.StartResponse, error) {
	t, err := l.getTask(ctx, r.ContainerID)
	if err != nil {
		return nil, err
	}
	p := runtime.Process(t)
	if r.ExecID != "" {
		if p, err = t.Process(ctx, r.ExecID); err != nil {
			return nil, errdefs.ToGRPC(err)
		}
	}
	//启动容器
	if err := p.Start(ctx); err != nil {
		return nil, errdefs.ToGRPC(err)
	}
	//检查容器的状态
	state, err := p.State(ctx)
	if err != nil {
		return nil, errdefs.ToGRPC(err)
	}
	return &api.StartResponse{
		Pid: state.Pid,
	}, nil
}

3.3 shim执行的动作

根据接收的指令执行相关的容器的命令

4 总结

容器启动的命令,从runc这类运行时来看,实际上是对应了容器的启动和命令的执行两者的合体。

在这里要说明的是,容器的启动实际上分成了两阶段:(1)创建了一个进程的执行环境 (2)执行要在容器执行的命令。

再者,容器的具体的执行是containerd的核心程序通过调用shim去调用runc这类runtime去执行容器。因此创建了多少个容器,实际上也就创建了多少个shim。从这点上来看,似乎并不高效。

你可能感兴趣的:(容器,技术分析,容器,云原生,linux)