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以及配置等信息。删除容器所做的就是删除指定容器相关的目录,在主机系统中删除该容器的相关目录及文件。