乐于分享
好东西不私藏

Containerd 源码解析:(五)Containerd 启动容器进程

Containerd 源码解析:(五)Containerd 启动容器进程

大家好,我是费益洲。上一篇文章介绍了 Task 的创建过程,本文则阐述 Containerd 启动容器进程的整体流程。

前置条件

Containerd 源码版本:v1.6.33

Containerd 项目地址:https://github.com/containerd/containerd

启动 Task

Docker 和 ctr 在创建完 Task 后,都会调用 Containerd 的接口和服务去启动 Task,我们继续以 ctr run 命令启动容器的逻辑梳理 Task 的启动过程,逻辑入口如下:

cmd/ctr/commands/run/run.go

// line 92 运行容器子命令var Command = cli.Command{    Name:           "run", Usage:          "run a container",    ...// line 133    Action: func(context *cli.Context)error {        ...// step 3:line 230 启动taskif err := task.Start(ctx); err != nil {return err  }        ...    }}

继续查看 tasks.Start 函数:

task.go

// line 214func(t *task)Start(ctx context.Context)error { r, err := t.client.TaskService().Start(ctx, &tasks.StartRequest{  ContainerID: t.id, }) ... t.pid = r.Pidreturnnil}

继续往下调用 Containerd 启动 Task 的 GRPC 接口:

api/services/tasks/v1/tasks.pb.go

// line 1316func(c *tasksClient)Start(ctx context.Context, in *StartRequest, opts ...grpc.CallOption)(*StartResponse, error) { out := new(StartResponse) err := c.cc.Invoke(ctx, "/containerd.services.tasks.v1.Tasks/Start", in, out, opts...)if err != nil {returnnil, err }return out, nil}

关于 Task 的 GRPC 插件服务的注册已在之前的文章中详细介绍过,本文直接从启动 Task 的 Handler 开始:

api/services/tasks/v1/tasks.pb.go

// line 1563func _Tasks_Start_Handler(srv interface{}, ctx context.Context, dec func(interface{})errorinterceptorgrpc.UnaryServerInterceptor(interface{}, error) { in := new(StartRequest)if err := dec(in); err != nil {returnnil, err }if interceptor == nil {return srv.(TasksServer).Start(ctx, in) } info := &grpc.UnaryServerInfo{  Server:     srv,  FullMethod: "/containerd.services.tasks.v1.Tasks/Start", } handler := func(ctx context.Context, req interface{})(interface{}, error) {return srv.(TasksServer).Start(ctx, req.(*StartRequest)) }return interceptor(ctx, in, info, handler)}

ctr 调用 Contaienrd 的 /containerd.services.tasks.v1.Tasks/Start 接口启动 Task。查看 Containerd 中提供该服务的插件:

services/tasks/service.go

// line 72func(s *service)Start(ctx context.Context, r *api.StartRequest)(*api.StartResponse, error) {return s.local.Start(ctx, r)}

service 会调用 local 实例的 Start 函数,启动 Task:

services/tasks/local.go

// line 262func(l *local)Start(ctx context.Context, r *api.StartRequest, _ ...grpc.CallOption)(*api.StartResponse, error) {// line 263 从runtime中查询Task信息 t, err := l.getTask(ctx, r.ContainerID)if err != nil {returnnil, err } p := runtime.Process(t)if r.ExecID != "" {if p, err = t.Process(ctx, r.ExecID); err != nil {returnnil, errdefs.ToGRPC(err)  } }// line 273 启动 taskif err := p.Start(ctx); err != nil {returnnil, errdefs.ToGRPC(err) } state, err := p.State(ctx)if err != nil {returnnil, errdefs.ToGRPC(err) }return &api.StartResponse{  Pid: state.Pid, }, nil}

上述代码中,263 行就是从具体的底层运行时中,通过容器 ID 查询具体的 Task 信息,上篇文章中,最后是通过 runc create 命令创建了容器的 Task 信息,此处其实就是从 runc 获取 Task 信息。而 273 行,则继续调用 Shim 层的函数启动 Task:

runtime/v2/shim.go

// line 374func(s *shimTask)Start(ctx context.Context)error { _, err := s.task.Start(ctx, &task.StartRequest{  ID: s.ID(), })if err != nil {return errdefs.FromGRPC(err) }returnnil}

这里会继续调用 Shim 层的接口去启动 Task:

runtime/v2/task/shim.pb.go

// line 3599func(c *taskClient)Start(ctx context.Context, req *StartRequest)(*StartResponse, error) {var resp StartResponseif err := c.client.Call(ctx, "containerd.task.v2.Task""Start", req, &resp); err != nil {returnnil, err }return &resp, nil}

经过上述代码的调用,最终会通过 TTRPC 接口containerd.task.v2.Task/Start去实际启动 Task 进程,此处不再赘述 TTRPC 插件服务的注册流程,直接从提供服务的插件开始梳理逻辑:

runtime/v2/runc/task/service.go

// line 295func(s *service)Start(ctx context.Context, r *taskAPI.StartRequest)(*taskAPI.StartResponse, error) {// line 296 查询容器信息    container, err := s.getContainer(r.ID)    ...// line 312    p, err := container.Start(ctx, r)    ...}

继续往下通过调用 container.Start 来启动容器进程:

runtime/v2/runc/container.go

// line 354func(c *Container)Start(ctx context.Context, r *task.StartRequest)(process.Process, error) {// line 355 获取容器进程    p, err := c.Process(r.ExecID)    ...// line 359 启动容器进程if err := p.Start(ctx); err != nil {return p, err }    ...}

这里需要注意一下获取进程的函数:

runtime/v2/runc/container.go

// line 298func(c *Container)Process(id string)(process.Process, error) { c.mu.Lock()defer c.mu.Unlock()if id == "" {if c.process == nil {returnnil, fmt.Errorf("container must be created: %w", errdefs.ErrFailedPrecondition)  }return c.process, nil } p, ok := c.processes[id]if !ok {returnnil, fmt.Errorf("process does not exist %s: %w", id, errdefs.ErrNotFound) }return p, nil}

本文的主线是通过 docker run 或者 ctr run 启动容器作为主线流程,那么获取 Process 这个函数里面,由于 id(execID)为空,所以这里的 process 其实指的是上篇文章中讲到的在创建 Task 过程中完成初始化的 Init 进程,那么后续的 p.Start 函数其实就是调用的 Init 对象的 Start 函数来启动容器进程:

pkg/process/init.go

// line 256func(p *Init)Start(ctx context.Context)error { p.mu.Lock()defer p.mu.Unlock()return p.initState.Start(ctx)}

继续向下查看,此时的容器状态是 created,所以接下来会继续调用 createdState 的 Start 函数:

pkg/process/init_state.go

// line 79func(s *createdState)Start(ctx context.Context)error {if err := s.p.start(ctx); err != nil {return err }return s.transition("running")}

start 的具体实现如下:

pkg/process/init.go

// line 263func(p *Init)start(ctx context.Context)error { err := p.runtime.Start(ctx, p.id)return p.runtimeError(err, "OCI runtime start failed")}

而 runtime 的 Start 函数,则会执行 runc 的 start 命令来启动容器进程:

vendor/github.com/containerd/go-runc/runc.go

// line 170func(r *Runc)Start(context context.Context, id string)error {return r.runOrError(r.command(context, "start", id))}

需要注意的是,在 runc 收到启动容器进程的命令之后,会将容器的主进程变更为 runc init 的父进程 Shim 进程,变更完成后 runc init 进程则会自动退出,进程的关系体现如下:

[root@feiyizhou ~]# ps -ef | grep nginx | grep -v greproot     3508675       1  0 Mar17 ?        00:00:35 /usr/bin/containerd-shim-runc-v2 -namespace default -id nginx -address /run/containerd/containerd.sockroot     3508696 3508675  0 Mar17 ?        00:00:00 nginx: master process nginx -g daemon off;101      3508731 3508696  0 Mar17 ?        00:00:00 nginx: worker process101      3508732 3508696  0 Mar17 ?        00:00:00 nginx: worker process

至此容器进程完成了启动,对应的就是容器完成了配置和启动。在这个过程中,runc init 进程和 Shim 进程来共同完成容器进程的启动。Shim 进程隔离容器进程和 Containerd 的守护进程,使得容器不再受 Containerd 的影响。除此之外,runc init 进程的引入,也降低了容器进程的权限,加强了容器和宿主机的安全性和隔离性。这部分逻辑较为重要,是 Containerd 启动容器的关键流程。而 runc init 进程启动容器进程的逻辑也会在后续文章会继续阐述,感兴趣的同志可以持续关注~☕