crun是Redhat的工程师Giuseppe开发的一款低层级的OCI runtime,与runc一样都全面兼容OCI标准规范,负责执行容器生命周期等各种管理功能。但与runc使用go语言不同,crun使用C语言开发,使其具有优异的性能与较小的内存开销,同时其程序的二进制文件更小,而且支持更多OCI不支持的新特性,被众多Linux发行版作为了默认的OCI runtime,广泛应用于生产环境。 本文将基于当前社区的最新主干代码,通过分析几个主要的容器管理命令的代码,全面深入地了解crun的实现。

main函数

crun的主函数较为简单,负责解析命令行参数,根据不同的command调用对应的处理函数。

struct commands_s commands[] = { { COMMAND_CREATE, "create", crun_command_create },
                                 { COMMAND_DELETE, "delete", crun_command_delete },
                                 { COMMAND_EXEC, "exec", crun_command_exec },
                                 { COMMAND_LIST, "list", crun_command_list },
                                 { COMMAND_KILL, "kill", crun_command_kill },
                                 { COMMAND_PS, "ps", crun_command_ps },
                                 { COMMAND_RUN, "run", crun_command_run },
                                 { COMMAND_SPEC, "spec", crun_command_spec },
                                 { COMMAND_START, "start", crun_command_start },
                                 { COMMAND_STATE, "state", crun_command_state },
                                 { COMMAND_UPDATE, "update", crun_command_update },
                                 { COMMAND_PAUSE, "pause", crun_command_pause },
                                 { COMMAND_UNPAUSE, "resume", crun_command_unpause },
                                 { COMMAND_FEATURES, "features", crun_command_features },
#if HAVE_CRIU && HAVE_DLOPEN
                                 { COMMAND_CHECKPOINT, "checkpoint", crun_command_checkpoint },
                                 { COMMAND_RESTORE, "restore", crun_command_restore },
#endif
                                 {
                                     0,
                                 } };

crun当前支持针对容器的创建、启动、杀停、查询、暂停等一系列功能,在src目录下针对各个命令有对应的.c与.h文件。

核心数据结构

  • libcrun_context_s:记录命令行参数配置、socket句柄等容器管理需要的一些信息。
    struct libcrun_context_s
    {
    const char *state_root;              // 容器状态文件存放的目录,关联全局配置选项--root
    const char *id;                      // 容器名称
    const char *bundle;                  // 容器标准包路径,关联选项--bundle
    const char *console_socket;
    const char *pid_file;
    const char *notify_socket;           // 由systemd的环境变量NOTIFY_SOCKET配置
    const char *handler;                 // 关联程序二进制文件名,详见fill_handler_from_argv0()
    int preserve_fds;
    // For some use-cases we need differentiation between preserve_fds and listen_fds.
    // Following context variable makes sure we get exact value of listen_fds irrespective of preserve_fds.
    int listen_fds;                      // 由环境变量LISTEN_FDS配置
    
    crun_output_handler output_handler;  // 日志输出接口
    void *output_handler_arg;            // 日志输出的文件句柄
    
    int fifo_exec_wait_fd;               // exec.fifo命名管道的文件句柄
    
    bool systemd_cgroup;                 // 是否使用systemd设置cgroup,关联全局配置选项--systemd-cgroup
    bool detach;
    bool no_new_keyring;
    bool force_no_cgroup;                // 关联全局配置选项--cgroup-manager
    bool no_pivot;
    
    char **argv;
    int argc;
    
    // 除了相关特性的默认处理回调,还会加载CRUN_LIBDIR/handlers目录下的*.so中run_oci_handler_get_handler返回的用户自定义处理回调。
    struct custom_handler_manager_s *handler_manager;
    };
    
  • libcrun_container_s:记录容器的静态配置及动态信息(如uid、gid等)。
    struct libcrun_container_s
    {
    /* Container parsed from the runtime json file.  */
    runtime_spec_schema_config_schema *container_def;
    
    uid_t host_uid;
    gid_t host_gid;
    
    uid_t container_uid;
    gid_t container_gid;
    
    char *config_file;
    char *config_file_content;
    
    void *private_data;
    void (*cleanup_private_data) (void *private_data);
    struct libcrun_context_s *context;
    };
    

创建/定义容器:crun_command_create()

首先,根据create命令的参数格式解析命令行参数,初始化全局变量crun_context。 接下来,通过yajl库提供的接口解析配置文件config.json中的配置并构造libcrun_container_s结构体。 libcrun_container_create()负责实际的容器创建,其首先会对命令行参数、配置文件config.json及state_root目录做检查。通过检查后,为后续的进程间通信分别使用系统调用mkfifo()创建命名管道exec.fifo(在state_root目录下会创建一个exec.fifo的特殊文件),这里exec.fifo命名管道文件是为了后续调用start命令启动容器所创建,适用于非父子进程间的进程间通信。 最后,调用libcrun_container_run_internal()做具体的容器创建, 以下代码片段隐去了一些非关键流程。

// crun支持的扩展选项run.oci.hooks.stdout以及run.oci.hoos.stderr
if (def->hooks
    && (def->hooks->prestart_len || def->hooks->poststart_len || def->hooks->create_runtime_len
        || def->hooks->create_container_len || def->hooks->start_container_len))
  {
    ret = open_hooks_output (container, &hooks_out_fd, &hooks_err_fd, err);
    if (UNLIKELY (ret < 0))
      return ret;
    container_args.hooks_out_fd = hooks_out_fd;
    container_args.hooks_err_fd = hooks_err_fd;
  }

container->context = context;

if (! detach || context->notify_socket)
  {
    // 设置当前进程的child subreaper属性,替代1号进程成为孤儿进程的父进程
    ret = prctl (PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0);
    if (UNLIKELY (ret < 0))
      return crun_make_error (err, errno, "set child subreaper");
  }

......

// 屏蔽当前进程的所有信号
ret = block_signals (err);
if (UNLIKELY (ret < 0))
  return ret;

// 重置文件/目录的创建权限掩码
umask (0);
if (def->linux && (def->linux->seccomp || seccomp_bpf_data))
  {
    unsigned int seccomp_gen_options = 0;
    const char *annotation;
    annotation = find_annotation (container, "run.oci.seccomp_fail_unknown_syscall");
    if (annotation && strcmp (annotation, "0") != 0)
      seccomp_gen_options = LIBCRUN_SECCOMP_FAIL_UNKNOWN_SYSCALL;
    if (seccomp_bpf_data)
      seccomp_gen_options |= LIBCRUN_SECCOMP_SKIP_CACHE;
    libcrun_seccomp_gen_ctx_init (&seccomp_gen_ctx, container, true, seccomp_gen_options);
    // 创建或加载seccomp的配置数据文件
    ret = libcrun_open_seccomp_bpf (&seccomp_gen_ctx, &seccomp_fd, err);
    if (UNLIKELY (ret < 0))
      return ret;
  }
container_args.seccomp_fd = seccomp_fd;

if (seccomp_fd >= 0)
  {
    // 设置监听seccomp的句柄
    ret = get_seccomp_receiver_fd (container, &container_args.seccomp_receiver_fd, &own_seccomp_receiver_fd,
                                   &seccomp_notify_plugins, err);
    if (UNLIKELY (ret < 0))
      return ret;
  }

......

/* If we are root (either on the host or in a namespace), then hown the cgroup to root
   in the container user namespace.  */
get_root_in_the_userns (def, container->host_uid, ontainer->host_gid, &root_uid, &root_gid);

......

/*
 * 1. 获取系统使用的cgroup版本(v1,v2或者混合)
 * 2. 使能所有的cgroup controller
 * 3. 为容器创建cgroup目录
 */
ret = libcrun_cgroup_preenter (&cg, &cgroup_dirfd, err);

......

pid = libcrun_run_linux_container (container, container_init, &container_args, &sync_socket, &cgroup_dirfd_s, err);
if (UNLIKELY (pid < 0))
  return pid;

这里我们展开看一下libcrun_run_linux_container()具体做了什么。

// 检查config.jason中配置的namespace是否有效,若配置了/proc路径则打开,否则后面需创建指定类型的命名空间。如果host_uid为0,则必须创建user namespace。
ret = configure_init_status (&init_status, container, err);
if (UNLIKELY (ret < 0))
  return ret;

......

// 创建当前进程与后面创建的容器进程之间的同步通信通道。
ret = socketpair (AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, sync_socket);
if (UNLIKELY (ret < 0))
  return crun_make_error (err, errno, "socketpair");

sync_socket_host = sync_socket[0];
sync_socket_container = sync_socket[1];

在设置完rlimit及oom_score_adj后,就会clone一个新的进程。

pid = -1;
if (cgroup_dirfd && *cgroup_dirfd->dirfd >= 0)
  {
    struct _clone3_args clone3_args;
    memset (&clone3_args, 0, sizeof (clone3_args));
    clone3_args.exit_signal = SIGCHLD;
    clone3_args.flags = first_clone_args;
    clone3_args.flags |= CLONE_INTO_CGROUP;
    clone3_args.cgroup = *cgroup_dirfd->dirfd;
    pid = syscall_clone3 (&clone3_args);
    if (pid >= 0)
      cgroup_dirfd->joined = true;
    close_and_reset (cgroup_dirfd->dirfd);
  }
/* fallback to clone() for any error.  */
if (pid < 0)
  {
    pid = syscall_clone (first_clone_args | SIGCHLD, NULL);
    if (UNLIKELY (pid < 0))
      return crun_make_error (err, errno, "clone");
  }

当前crun进程作为父进程,需要与子进程通过前面创建的sync_socket进行状态与信息同步。同步状态接受端调用expect_success_from_sync_socket(),发送端调用send_success_to_sync_socket()。 新创建的子进程(即容器进程)调用init_container()初始化容器进程并为其创建或加入命名空间。当需要将容器进程加入新的PID namespace或者time namespace时,在调用setns()后需要fork一个新的进程替代当前进程成为容器中的1号进程,这是由这两个命名空间内核实现本身限制决定的。另外,在创建新的user namespace前需将容器进程加入其他命名空间。init_container()最后通过sync_socket从父进程那接收挂载点、设备的文件句柄,根据配置将容器进程的用户加入容器内的指定用户组。

/* Receive the mounts sent by `prepare_and_send_mounts`.  */
ret = receive_mounts (get_fd_map (container), sync_socket_container, err);
if (UNLIKELY (ret < 0))
  return ret;

ret = receive_mounts (get_devices_fd_map (container), sync_socket_container, err);
if (UNLIKELY (ret < 0))
  return ret;

ret = libcrun_container_setgroups (container, container->container_def->process, err);
  if (UNLIKELY (ret < 0))
    return ret;

接着,子进程调用container_init()继续初始化容器进程。从父进程收到本进程的pid后,在container_init_setup()中初始化容器进程的执行环境。

/*
 * 1. 检查apparmor是否使能
 * 2. 检查selinux是否使能
 * 3. 获取capabilities的配置
 */
ret = initialize_security (def->process, err);
if (UNLIKELY (ret < 0))
  return ret;

// 设置本地回环lo
ret = libcrun_configure_network (container, err);
if (UNLIKELY (ret < 0))
  return ret;

......

ret = libcrun_set_sysctl (container, err);
if (UNLIKELY (ret < 0))
  return ret;

// 调用自定义的配置容器接口
ret = libcrun_container_notify_handler (entrypoint_args, HANDLER_CONFIGURE_BEFORE_MOUNTS, container, rootfs, err);
if (UNLIKELY (ret < 0))
  return ret;

/* sync 2 and 3 are sent as part of libcrun_set_mounts.  */
// 挂载文件系统分区,包括cgroup文件系统及设备文件
ret = libcrun_set_mounts (entrypoint_args, container, rootfs, send_sync_cb, &sync_socket, err);
if (UNLIKELY (ret < 0))
  return ret;

......

if (def->process)
  {
    ret = libcrun_set_selinux_label (def->process, false, err);
    if (UNLIKELY (ret < 0))
      return ret;

    ret = libcrun_set_apparmor_profile (def->process, false, err);
    if (UNLIKELY (ret < 0))
      return ret;
  }

// 关闭大于preserve_fds + 3的所有句柄
ret = mark_or_close_fds_ge_than (entrypoint_args->context->preserve_fds + 3, false, err);
if (UNLIKELY (ret < 0))
  crun_error_write_warning_and_release (entrypoint_args->context->output_handler_arg, &err);

if (rootfs)
  {
    // chroot
    ret = libcrun_do_pivot_root (container, entrypoint_args->context->no_pivot, rootfs, err);
    if (UNLIKELY (ret < 0))
      return ret;
  }

container_init_setup()最后还会重新打开/dev/null,调整stdio、stdin、stderr的属主,设置环境变量、主机名、域名,这里就不一一展开分析了。返回container_init()后,容器进程unblock所有信号,等待exec.fifo管道发过来的后续信号。

if (entrypoint_args->context->fifo_exec_wait_fd >= 0)
  {
    char buffer[1];
    fd_set read_set;
    cleanup_close int fd = entrypoint_args->context->fifo_exec_wait_fd;
    entrypoint_args->context->fifo_exec_wait_fd = -1;
    FD_ZERO (&read_set);
    FD_SET (fd, &read_set);
    do
      {
        ret = select (fd + 1, &read_set, NULL, NULL, NULL);
        if (UNLIKELY (ret < 0))
          return crun_make_error (err, errno, "select");
        ret = TEMP_FAILURE_RETRY (read (fd, buffer, sizeof (buffer)));
        if (UNLIKELY (ret < 0))
          return crun_make_error (err, errno, "read from the exec fifo");
    } while (ret == 0);
    close_and_reset (&entrypoint_args->context->fifo_exec_wait_fd);
  }

再回到父进程,父进程里做的工作相对简单。除了和容器进程有一些同步的通信之外,主要包括设置uid和gid映射,以及创建mount命名空间和设备节点并向容器进程发送挂载点与设备文件的句柄。值得一提的是,这里进程间传递文件句柄是通过sendmsg提供的传递控制信息的功能来实现的。

if ((init_status.all_namespaces & CLONE_NEWUSER) && init_status.userns_index < 0)
  {
    ret = libcrun_set_usernamespace (container, pid, err);
    if (UNLIKELY (ret < 0))
      return ret;

    ret = send_success_to_sync_socket (sync_socket_host, err);
    if (UNLIKELY (ret < 0))
      return ret;
  }

......

  /* They are received by `receive_mounts`.  */
  ret = prepare_and_send_mounts (container, pid, sync_socket_host, err);
  if (UNLIKELY (ret < 0))
    return ret;

至此,父进程等待容器进程返回状态后就基本完成了容器的定义/创建。命令执行进程退出后,可以看到系统后台运行了一个名为crun的后台进程,父进程为1号进程,它就是后面要执行指定应用的容器进程。

启动容器:crun_command_start()

启动容器的实现逻辑非常简单,从上一节对创建容器的流程分析可以看到,容器进程最后调用select在exec.fifo管道上等待唤醒。因此,启动容器需要做的就是写exec.fifo这个管道,唤醒容器进程执行指定的应用。

ret = libcrun_status_write_exec_fifo (context->state_root, id, err);
if (UNLIKELY (ret < 0))
  return ret;

def = container->container_def;

if (context->notify_socket)
  {
    if (fd >= 0)
      {
        fd_set read_set;
        while (1)
          {
            struct timeval timeout = {
              .tv_sec = 0,
              .tv_usec = 10000,
            };
            FD_ZERO (&read_set);
            FD_SET (fd, &read_set);
            ret = select (fd + 1, &read_set, NULL, NULL, &timeout);
            if (UNLIKELY (ret < 0))
              return ret;
            if (ret)
              {
                ret = handle_notify_socket (fd, err);
                if (UNLIKELY (ret < 0))
                  return ret;
                if (ret)
                  break;
              }
            else
              {
                ret = libcrun_is_container_running (&status, err);
                if (UNLIKELY (ret < 0))
                  return ret;
                if (! ret)
                  return 0;
              }
          }
      }
  }

容器进程在select()返回后,完成一些安全特性的设置,关闭或屏蔽部分句柄,最后调用execv加载指定应用程序替换当前进程上下文。

/* Attempt to close all the files that are not needed to prevent execv to have access to them.
   This is a best effort operation since the seccomp profile is already in place now and might block
   some of the syscalls needed by mark_or_close_fds_ge_than.  */
ret = mark_or_close_fds_ge_than (entrypoint_args->context->preserve_fds + 3, true, err);
if (UNLIKELY (ret < 0))
  crun_error_release (err);

TEMP_FAILURE_RETRY (execv (exec_path, def->process->args));

创建并启动容器:crun_command_run()

实现与创建容器流程基本一致,不同的就是不需要创建exec.fifo这个管道,容器进程不需要阻塞在这个管道上,直接调用execv执行指定的应用程序。

容器中启动进程:crun_command_exec()

当容器已经创建成功运行后,我们通常会通过exec命令在容器中再启动额外的进程。exec命令支持的参数项较多,crun_command_exec()首先解析命令行参数,然后调用libcrun_container_exec_with_options()。libcrun_container_exec_with_options()先会读取解析该容器的状态文件,检查容器进程是否正在运行。如果容器运行状态查询正常,则调用libcrun_container_load_from_file()加载读取容器的配置。最后调用libcrun_join_process()创建子进程,子进程会加入容器进程的各个命名空间,并执行具体的应用命令,实质上实现逻辑和创建容器过程中的libcrun_run_linux_container()类似,这里不再详述。

销毁容器:crun_command_kill()

命令行中可以指定信号去销毁容器进程,如果不指定,默认向容器进程发送SIGTERM信号。在发送信号前,crun会查询容器状态,获取容器进程的PID。

删除容器:crun_command_delete()

从前面几个容器操作可以看到,在容器创建后会在root目录下持久化记录容器的状态、进程pid以及配置等信息。删除容器所做的就是删除指定容器相关的目录,在主机系统中删除该容器的相关目录及文件。

Categories:

Updated: