Table of Contents
本文是对个人项目 Containers Hooks Toolkit 的详细介绍。
Overview
container-hooks-toolkit用于向容器的配置文件(config.json)中插入自定义的oci hooks,以实现在容器的不同生命周期进行细粒度的操作,组件包括:
container-hooks-runtimecontainer-hooks-ctkcontainer-hooks
container-hooks-runtime
container-hooks-runtime是对主机上安装的runc的轻量级包装器,通过将指定的oci hooks注入容器的运行时规范,然后调用主机本地的runc,并传递修改后的带有钩子设置的容器运行时规范。runc在启动容器时,会自动运行注入的oci hooks。
container-hooks-runtime的配置文件包含了container-hooks-runtime的配置选项,路径为/etc/container-hooks/config.toml,支持对其修改从而定义container-hooks-runtime的日志文件路径、日志级别以及底层运行时:
[container-hooks-runtime]debug = "/etc/container-hooks/container-hooks-runtime.log"log-level = "info"runtimes = ["runc", "docker-runc"]container-hooks-runtime日志记录了容器生命周期的相关记录,默认路径为/etc/container-hooks/container-hooks-runtime.log。
container-hooks-ctk
container-hooks-ctk 是一个命令行工具,主要用于配置各容器运行时支持container hooks runtime,以向符合OCI规范的容器中插入OCI Hooks。
container-hooks-runtime的完成介绍和用法见README of container-hooks-ctk。
container-hooks-ctk所有操作均需要root权限。
container-hooks-ctk [global options] command [command options] [arguments...]container-hooks-ctk支持的主要命令(每个命令又包含若干子命令)包括:
runtime:runtime命令用于配置各容器运行时支持/移除container-hooks-runtime。config:config命令用于生成container hooks toolkit的配置文件。install:install命令用于将container hooks toolkit复制到/usr/bin目录。
container-hooks-ctk生成的默认配置文件路径为/etc/container-hooks/config.toml,默认内容如下:
[container-hooks]# 插入的oci hooks文件的路径path = "/etc/container-hooks/hooks.json"
[container-hooks-ctk]# container-hooks-ctk工具的路径path = "/usr/bin/container-hooks-ctk"
[container-hooks-runtime]# container hooks runtime日志文件路径debug = "/etc/container-hooks/container-hooks-runtime.log"# 日志文件记录级别log-level = "info"# 默认底层容器运行时runtimes = ["runc", "docker-runc"]container-hooks
container-hooks是一个空程序,仅用于判断当前容器是否已经添加自定义hooks,避免重复添加。
Usecase
container-toolkit支持docker,containerd和cri-o,简单用例介绍如下。
1.下载
git clone https://github.com/peng-yq/container-hooks-toolkit.git2.编译
make all下面的所有操作均需要
root权限
3.复制到/usr/bin
cd bin./container-hooks-ctk install --toolkit-root=$(pwd)4.生成配置文件
container-hooks-ctk config5.以docker为例,进行配置
container-hooks-ctk runtime configure --runtime=docker --defaultsystemctl restart docker配置完成后的docker配置文件:
{ "default-runtime": "container-hooks-runtime", "registry-mirrors": [ "https://yxzrazem.mirror.aliyuncs.com" ], "runtimes": { "container-hooks-runtime": { "path": "/usr/bin/container-hooks-runtime", "runtimeArgs": [] } }}6.编写自定义oci hooks,格式如下,必须添加第一个prestart hook中的container-hooks用于避免重复添加定义hooks,需要写入至/etc/container-hooks/hooks.json文件中(此路径可在配置文件中修改,请注意json格式问题)
{ "prestart": [ { "path": "/usr/bin/container-hooks" }, { "path": "/etc/container-hooks/test.sh" } ]}这里用一个简单的脚本文件做为实例,脚本内容如下,指向脚本会往/etc/container-hooks/test.txt中追加Hello World:
#!/bin/bash
echo "Hello World" >> /etc/container-hooks/test.txt7.运行容器,执行自定义hook
sudo docker run hello-world
Container Runtime
Runtime在中文博客中一般被翻译为运行时,比如会经常看到docker、containerd、cri-o和runc等都被称为runtime。但实际上他们的定义是不一样的,OCI规范中的原文介绍如下:
OCI对容器的定义也很直接了当,这里也介绍一下。
container
An environment for executing processes with configurable isolation and resource limitations. For example, namespaces, resource limits, and mounts are all part of the container environment.
我们知道容器是一种轻量级的虚拟化技术,与宿主机共用操作系统内核。容器其实就是可配置隔离和资源限制的进程执行环境,通过namespace技术(将系统资源:如网络接口、进程ID列表、挂载点虚拟化)来确保容器内的应用程序进程与系统上的其他进程相隔离,容器中的进程会感觉它们是在一个独立的系统中运行。并通过cgroup技术控制和限制容器可以使用的物理资源(如 CPU 时间、内存用量等)的能力。
runtime
An implementation of this specification. It reads the configuration files from a bundle, uses that information to create a container, launches a process inside the container, and performs other lifecycle actions.
OCI对runtime的定义就是根据容器配置文件创建容器进程,控制容器生命周期的工具,即低级运行时,例如runc和kata-container。
runtime caller
An external program to execute a runtime, directly or indirectly.
Examples of direct callers include containerd, CRI-O, and Podman. Examples of indirect callers include Docker/Moby and Kubernetes.
Runtime callers often execute a runtime via runc-compatible command line interface, however, its interaction interface is currently out of the scope of the Open Container Initiative Runtime Specification.
中文博客对运行时的描述其实是模糊的,极其容易让人混淆。OCI对此做了一个很好的定义,运行时 (runtime)只是最底层的低级的直接和容器交互的程序;而高级的面向用户的,一般直接或间接调用运行时的程序则称为runtime caller。runtime caller也可根据runtime的关系,再细分为直接和间接的,直接的例如containerd、cri-o等;间接的比如k8s和docker。一般来说用户直接接触的都是indirect runtime caller。
Container Shim
在Overview中提到container-hooks-runtime是shim,那shim是什么呢?
每一个Containerd或Docker容器(实际上Docker也是调用Containerd来创建容器,具体关系见下图)都有一个相应的 “shim” 守护进程,这个守护进程会提供一个API,Containerd使用该API来管理容器基本的生命周期(启动/停止),在容器中执行新的进程、调整TTY的大小以及与特定平台相关的其他操作。shim还有一个作用是向Containerd报告容器的退出状态,在容器退出状态被Containerd收集之前,shim会一直存在。这一点和僵尸进程很像,僵尸进程在被父进程回收之前会一直存在,只不过僵尸进程不会占用资源,而shim会占用资源。
Shim将Containerd进程从容器的生命周期中分离出来,具体的做法是runc在创建和运行容器之后退出,并将shim作为容器的父进程,即使Containerd进程挂掉或者重启,也不会对容器造成任何影响。这样做的好处很明显,你可以高枕无忧地升级或者重启Containerd,不会对运行中的容器产生任何影响。
Container Bundle
从runtime的定义“It reads the configuration files from a bundle, uses that information to create a container, launches a process inside the container”中可以知道runtime读取bundle中的配置文件来创建容器。
Bundle只涉及如何将容器及其配置数据存储在本地文件系统中,以便兼容OCI规范的任何运行时加载。Bundle包含加载和运行容器所需的全部信息:
config.json:命名必须为config.json,且必须在bundle目录的根目录- 容器的根文件系统:在
config.json中的root.path字段指定
Bundle目录中的内容才是容器必须,其目录本身非必须。
在实际调用过程中,containerd等runtime caller会将准备好的bundle目录等参数传递给runc等runtime,由runc来根据配置创建或控制容器的生命周期。
Config.json
容器的配置文件包含了构建标准容器的元数据,包括用户指定进程、运行环境和环境变量等。内容比较多,详细介绍可见博客。其中和本工具直接相关的为POSIX-platform Hooks部分的内容。
RootFS
RootFS是容器在启动时内部进程可见的文件系统,即容器的根目录。当我们运行docker exec命令进入容器的时候看到的文件系统就是rootfs。RootFS通常包含一个操作系统运行所需的文件系统,例如可能包含典型的类Unix操作系统中的目录系统,如/dev、/proc、/bin、/etc、/lib、/usr、/tmp及运行 容器所需的配置文件、工具等。
就像Linux启动会先用只读模式挂载rootfs,运行完完整性检查之后,再切换成读写模式一样。容器挂载rootfs时,也会先挂载为只读模式,但是与Linux做法不同的是,在挂载完只读的rootfs之后,会利用联合挂载技术(Union Mount)在已有的rootfs上再挂一个读写层。容器在运行过程中文件系统发生的变化只会写到读写层,并通过whiteout技术隐藏只读层中的旧版本文件。
关于
overlay、联合挂载等技术的详细描述见手撕docker文件结构 —— overlayFS,image,container文件结构详解
How runc create, start and delete container?
以docker run ubuntu:latest为例,containerd传递给runc会经历四个阶段:
createstartdelete:因为基础的ubuntu容器没有设置entrypoint,因此容器启动后会马上退出delete –force
runc create时的调用参数如下,准确来说应该是global options。
–-root指定了用于存储容器状态的根目录,这个根目录是tmpfs(类Unix系统上暂存档存储空间的常见名称,通常以挂载文件系统方式实现,并将资料存储在易失性存储器即内存而非永久存储设备中),这里是/var/run/docker/runtime-runc/moby。关于moby的介绍--log指定了runc日志文件。-–log-format指定了runc日志文件格式为json。–-systemd-cgroup开启systemd cgroup支持,这也是容器资源隔离机制的关键。--bundle指定的目录和runc日志的目录一致。-–pid-file指定了容器进程的pid路径。
--root /var/run/docker/runtime-runc/moby --log/run/containerd/io.containerd.runtime.v2.task/moby/6dbd69e6f9e0c7efe3026ae83624012fce6917e2f7e2c73958b8530d369fef05/log.json --log-format json --systemd-cgroupcreate --bundle/run/containerd/io.containerd.runtime.v2.task/moby/6dbd69e6f9e0c7efe3026ae83624012fce6917e2f7e2c73958b8530d369fef05 --pid-file/run/containerd/io.containerd.runtime.v2.task/moby/6dbd69e6f9e0c7efe3026ae83624012fce6917e2f7e2c73958b8530d369fef05/init.pid6dbd69e6f9e0c7efe3026ae83624012fce6917e2f7e2c73958b8530d369fef05日志输出的runc start时的调用参数如下,相比于create参数少了一些,基本参数保持一致,6dbd69e…为容器id。
--root /var/run/docker/runtime-runc/moby --log/run/containerd/io.containerd.runtime.v2.task/moby/6dbd69e6f9e0c7efe3026ae83624012fce6917e2f7e2c73958b8530d369fef05/log.json --log-format json --systemd-cgroupstart 6dbd69e6f9e0c7efe3026ae83624012fce6917e2f7e2c73958b8530d369fef05日志输出的runc delete时的调用参数如下,和start的参数一致。
--root /var/run/docker/runtime-runc/moby --log/run/containerd/io.containerd.runtime.v2.task/moby/6dbd69e6f9e0c7efe3026ae83624012fce6917e2f7e2c73958b8530d369fef05/log.json --log-format json --systemd-cgroupdelete 6dbd69e6f9e0c7efe3026ae83624012fce6917e2f7e2c73958b8530d369fef05在runc delete后,还有个runc delete –-force的操作,参数如下。
--root /var/run/docker/runtime-runc/moby --log/run/containerd/io.containerd.runtime.v2.task/moby/6dbd69e6f9e0c7efe3026ae83624012fce6917e2f7e2c73958b8530d369fef05/log.json --log-format json delete --force6dbd69e6f9e0c7efe3026ae83624012fce6917e2f7e2c73958b8530d369fef05Oci Hooks
什么是钩子呢?钩子主要有两个关键点,一个是导致钩子调用的事件(某个事件发生前或者发生后),一个是钩子的具体代码。因此,钩子实际上就是在某个时间点或事件点触发的一系列函数或代码。
容器中的钩子和容器的生命周期息息相关,钩子能使容器感知其生命周期内的事件,并且当相应的生命周期钩子被调用时运行指定的代码。OCI Runtime Spec对容器生命周期的描述如下(单纯从低级运行时创建容器开始,不包括镜像)。
Lifecycle定义了容器从创建到退出之间的时间轴:
- 容器开始创建:通常为
OCI规范运行时(runc)调用create命令 +bundle+container id - 容器运行时环境创建中: 根据容器的
config.json中的配置进行创建,此时用户指定程序还未运行,这一步后所有对config.json的更改均不会影响容器 prestart hookscreateRuntime hookscreateContainer hooks- 容器启动:通常为
OCI规范运行时(runc)调用create命令 +bundle+container id startContainer hooks- 容器执行用户指定程序
poststart hooks:任何poststart钩子执行失败只会log a warning,不影响其他生命周期(操作继续执行)就好像钩子成功执行一样- 容器进程退出:
error、正常退出和运行时调用kill命令均会导致 - 容器删除:通常为
OCI规范运行时(runc)调用delete命令 +container id - 容器摧毁:区别于容器删除,
3、4、5、7的钩子执行失败除了生成一个error外,会直接跳到这一步。撤销第二步创建阶段执行的操作。 poststop hooks:执行失败后的操作和poststart一致
可以看到oci定义的容器生命周期中,如果在容器的config.json中定义了钩子,runc必须执行钩子,并且时间节点在前的钩子执行成功后才能执行下一个钩子;若有一个钩子执行失败,则会报错并摧毁容器(在容器创建后执行的钩子失败,并不会删除容器,而是启动失败)。
OCI规定单个钩子的字段如下:
path (string, REQUIRED):绝对路径args (array of strings, OPTIONAL)env (array of strings, OPTIONAL)timeout (int, OPTIONAL):终止钩子的秒数
Prestart
Prestart钩子必须作为创建操作的一部分,在运行时环境创建完成后(根据 config.json 中的配置),但在执行 pivot_root 或任何同等操作之前调用。
即将废弃,被后面三个钩子所取代,注意后面三个钩子在较老版本的runc中可能不支持。
CreateRuntime Hooks
createRuntime钩子必须作为创建操作的一部分,在运行时环境创建完成后(根据config.json中的配置),但在执行 pivot_root 或任何同等操作之前调用。
在容器命名空间被创建后调用。
createRuntime钩子的定义目前未作明确规定,钩子作者只能期望运行时创建挂载命名空间并执行挂载操作。运行时可能尚未执行其他操作,如cgroups和SELinux/AppArmor标签
CreateContainer Hooks
createContainer钩子必须作为创建操作的一部分,在运行时环境创建完成后(根据 config.json 中的配置),但在执行 pivot_root 或任何同等操作之前调用。
在执行 pivot_root 操作之前,但在创建和设置挂载命名空间之后调用。
StartContainer Hooks
StartContainer钩子作为启动操作的一部分,必须在执行用户指定的进程之前调用startContainer挂钩。此钩子可用于在容器中执行某些操作,例如在容器进程生成之前在linux上运行ldconfig二进制文件。
Poststart
Poststart钩子必须在用户指定的进程执行后、启动操作返回前调用。例如,此钩子可以通知用户容器进程已生成。
Poststop
Poststart钩子必须在容器删除后、删除操作返回前调用。清理或调试函数就是此类钩子的例子。
summary
namespace是指path以及钩子必须在指定的namespace中解析或调用,比如runtime命名空间表示访问的钩子path是宿主机上的路径;而container为容器中的路径。
| Name | Namespace | When |
|---|---|---|
prestart (Deprecated) | runtime | After the start operation is called but before the user-specified program command is executed. |
createRuntime | runtime | During the create operation, after the runtime environment has been created and before the pivot root or any equivalent operation. |
createContainer | container | During the create operation, after the runtime environment has been created and before the pivot root or any equivalent operation. |
startContainer | container | After the start operation is called but before the user-specified program command is executed. |
poststart | runtime | After the user-specified process is executed but before the start operation returns. |
poststop | runtime | After the container is deleted but before the delete operation returns. |
How Container Hooks Toolkit Works
有了前面的铺垫,最后对container hooks toolkit是如何工作的进行介绍:
- 当我们执行
docker run命令创建并启动一个新的容器时,这个命令会被发送到Docker守护进程 (dockerd)。 - 接收到来自
Docker客户端(docker cli)的命令后,dockerd解析这些命令。dockerd根据参数检查本地是否存在指定的镜像,如果不存在,它会从配置的Docker镜像仓库(默认是Docker Hub)下载镜像。dockerd创建一个容器配置,并将其传递给容器引擎 (containerd)。 containerd接收来自dockerd的请求,并负责进一步处理这些请求,如创建、启动、停止容器等。containerd会生成一个容器规范(如OCI规范,也就是前面提到的config.json),并将其传递给较低级别的容器运行时,这里是我们配置的container-hooks-runtime。container-hooks-runtime对传递来的参数进行解析,如果是create/start,就解析出/etc/container-hooks/hooks.json中钩子并插入到容器的bundle路径下的config.json中,并传递给runc。- 如果是非
create/start命令,container-hooks-runtime就直接将参数传递给runc。 runc根据传递而来的OCI规范设置必要的命名空间、控制组、根文件系统等,如果设置了容器钩子,则在特定的生命周期节点运行,然后启动容器的入口点进程(PID 1)。
但在执行docker run命令时,对runc先传递create,再传递start,这样不就添加了两次自定义钩子吗?是的,但我们提供了container-hooks这个空程序,用于避免重复添加自定义钩子,但container-hooks-runtime解析到容器的配置文件config.json中的prestart钩子中存在container-hooks,则不会再次添加自定义钩子。
Customized
提供一些更加定制化的思路:
案例1:在容器启动前自动对容器进行签名验证和完整性校验
此时直接编写hooks至/etc/container-hooks/hooks.json就行不通了,因为我们无法提前预知每个容器的启动镜像信息。可以对项目进行二次开发,不采用读取文件中的钩子的形式,而是直接在代码中进行插入并根据容器的配置进行参数调整。
需要修改的代码部分:
/internel/runtime/internel/modifier