skip to content
Logo PYQ's Blog

Container Hooks Toolkit

/ 22 min read

Updated:

本文是对个人项目 Containers Hooks Toolkit 的详细介绍

Overview

container-hooks-toolkit用于向容器的配置文件(config.json)中插入自定义的oci hooks,以实现在容器的不同生命周期进行细粒度的操作,组件包括:

  • container-hooks-runtime
  • container-hooks-ctk
  • container-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权限

Terminal window
container-hooks-ctk [global options] command [command options] [arguments...]

container-hooks-ctk支持的主要命令(每个命令又包含若干子命令)包括:

  1. runtimeruntime命令用于配置各容器运行时支持/移除container-hooks-runtime
  2. configconfig命令用于生成container hooks toolkit的配置文件。
  3. installinstall命令用于将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支持dockercontainerdcri-o,简单用例介绍如下。

1.下载

Terminal window
git clone https://github.com/peng-yq/container-hooks-toolkit.git

2.编译

Terminal window
make all

下面的所有操作均需要root权限

3.复制到/usr/bin

Terminal window
cd bin
./container-hooks-ctk install --toolkit-root=$(pwd)

4.生成配置文件

Terminal window
container-hooks-ctk config

5.以docker为例,进行配置

Terminal window
container-hooks-ctk runtime configure --runtime=docker --default
systemctl 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格式问题)

Terminal window
{
"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.txt

7.运行容器,执行自定义hook

Terminal window
sudo docker run hello-world

Container Runtime

Runtime在中文博客中一般被翻译为运行时,比如会经常看到dockercontainerdcri-orunc等都被称为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.

OCIruntime的定义就是根据容器配置文件创建容器进程,控制容器生命周期的工具,即低级运行时,例如runckata-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 callerruntime caller也可根据runtime的关系,再细分为直接和间接的,直接的例如containerdcri-o等;间接的比如k8sdocker。一般来说用户直接接触的都是indirect runtime caller

Container Shim

Overview中提到container-hooks-runtimeshim,那shim是什么呢?

每一个ContainerdDocker容器(实际上Docker也是调用Containerd来创建容器,具体关系见下图)都有一个相应的 “shim” 守护进程,这个守护进程会提供一个APIContainerd使用该API来管理容器基本的生命周期(启动/停止),在容器中执行新的进程、调整TTY的大小以及与特定平台相关的其他操作。shim还有一个作用是向Containerd报告容器的退出状态,在容器退出状态被Containerd收集之前,shim会一直存在。这一点和僵尸进程很像,僵尸进程在被父进程回收之前会一直存在,只不过僵尸进程不会占用资源,而shim会占用资源。

ShimContainerd进程从容器的生命周期中分离出来,具体的做法是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包含加载和运行容器所需的全部信息:

  1. config.json:命名必须config.json,且必须bundle目录的根目录
  2. 容器的根文件系统:在config.json中的root.path字段指定

Bundle目录中的内容才是容器必须,其目录本身非必须。

在实际调用过程中,containerdruntime caller会将准备好的bundle目录等参数传递给runcruntime,由runc来根据配置创建或控制容器的生命周期。

Config.json

容器的配置文件包含了构建标准容器的元数据,包括用户指定进程、运行环境和环境变量等。内容比较多,详细介绍可见博客。其中和本工具直接相关的为POSIX-platform Hooks部分的内容。

RootFS

RootFS是容器在启动时内部进程可见的文件系统,即容器的根目录。当我们运行docker exec命令进入容器的时候看到的文件系统就是rootfsRootFS通常包含一个操作系统运行所需的文件系统,例如可能包含典型的类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会经历四个阶段:

  1. create
  2. start
  3. delete:因为基础的ubuntu容器没有设置entrypoint,因此容器启动后会马上退出
  4. delete –force

runc create时的调用参数如下,准确来说应该是global options

  1. –-root指定了用于存储容器状态的根目录,这个根目录是tmpfs(类Unix系统上暂存档存储空间的常见名称,通常以挂载文件系统方式实现,并将资料存储在易失性存储器即内存而非永久存储设备中),这里是/var/run/docker/runtime-runc/moby关于moby的介绍
  2. --log指定了runc日志文件。
  3. -–log-format指定了runc日志文件格式为json
  4. –-systemd-cgroup开启systemd cgroup支持,这也是容器资源隔离机制的关键。
  5. --bundle指定的目录和runc日志的目录一致。
  6. -–pid-file指定了容器进程的pid路径。
Terminal window
--root /var/run/docker/runtime-runc/moby --log
/run/containerd/io.containerd.runtime.v2.task/moby/6dbd69e6f9e0c7efe3026ae83624012fce6917e2f7e2c73958b8530d369fef05/log.json --log-format json --systemd-cgroup
create --bundle
/run/containerd/io.containerd.runtime.v2.task/moby/6dbd69e6f9e0c7efe3026ae83624012
fce6917e2f7e2c73958b8530d369fef05 --pid-file
/run/containerd/io.containerd.runtime.v2.task/moby/6dbd69e6f9e0c7efe3026ae83624012
fce6917e2f7e2c73958b8530d369fef05/init.pid
6dbd69e6f9e0c7efe3026ae83624012fce6917e2f7e2c73958b8530d369fef05

日志输出的runc start时的调用参数如下,相比于create参数少了一些,基本参数保持一致,6dbd69e…为容器id。

Terminal window
--root /var/run/docker/runtime-runc/moby --log
/run/containerd/io.containerd.runtime.v2.task/moby/6dbd69e6f9e0c7efe3026ae83624012
fce6917e2f7e2c73958b8530d369fef05/log.json --log-format json --systemd-cgroup
start 6dbd69e6f9e0c7efe3026ae83624012fce6917e2f7e2c73958b8530d369fef05

日志输出的runc delete时的调用参数如下,和start的参数一致。

Terminal window
--root /var/run/docker/runtime-runc/moby --log
/run/containerd/io.containerd.runtime.v2.task/moby/6dbd69e6f9e0c7efe3026ae83624012
fce6917e2f7e2c73958b8530d369fef05/log.json --log-format json --systemd-cgroup
delete 6dbd69e6f9e0c7efe3026ae83624012fce6917e2f7e2c73958b8530d369fef05

runc delete后,还有个runc delete –-force的操作,参数如下。

Terminal window
--root /var/run/docker/runtime-runc/moby --log
/run/containerd/io.containerd.runtime.v2.task/moby/6dbd69e6f9e0c7efe3026ae83624012
fce6917e2f7e2c73958b8530d369fef05/log.json --log-format json delete --force
6dbd69e6f9e0c7efe3026ae83624012fce6917e2f7e2c73958b8530d369fef05

Oci Hooks

什么是钩子呢?钩子主要有两个关键点,一个是导致钩子调用的事件(某个事件发生前或者发生后),一个是钩子的具体代码。因此,钩子实际上就是在某个时间点或事件点触发的一系列函数或代码

容器中的钩子和容器的生命周期息息相关,钩子能使容器感知其生命周期内的事件,并且当相应的生命周期钩子被调用时运行指定的代码OCI Runtime Spec对容器生命周期的描述如下(单纯从低级运行时创建容器开始,不包括镜像)。

Lifecycle定义了容器从创建到退出之间的时间轴:

  1. 容器开始创建:通常为OCI规范运行时(runc)调用create命令 + bundle + container id
  2. 容器运行时环境创建中: 根据容器的config.json中的配置进行创建,此时用户指定程序还未运行,这一步后所有对config.json的更改均不会影响容器
  3. prestart hooks
  4. createRuntime hooks
  5. createContainer hooks
  6. 容器启动:通常为OCI规范运行时(runc)调用create命令 + bundle + container id
  7. startContainer hooks
  8. 容器执行用户指定程序
  9. poststart hooks:任何poststart钩子执行失败只会log a warning,不影响其他生命周期(操作继续执行)就好像钩子成功执行一样
  10. 容器进程退出:error、正常退出和运行时调用kill命令均会导致
  11. 容器删除:通常为OCI规范运行时(runc)调用delete命令 + container id
  12. 容器摧毁:区别于容器删除,3457的钩子执行失败除了生成一个error外,会直接跳到这一步。撤销第二步创建阶段执行的操作。
  13. 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为容器中的路径

NameNamespaceWhen
prestart (Deprecated)runtimeAfter the start operation is called but before the user-specified program command is executed.
createRuntimeruntimeDuring the create operation, after the runtime environment has been created and before the pivot root or any equivalent operation.
createContainercontainerDuring the create operation, after the runtime environment has been created and before the pivot root or any equivalent operation.
startContainercontainerAfter the start operation is called but before the user-specified program command is executed.
poststartruntimeAfter the user-specified process is executed but before the start operation returns.
poststopruntimeAfter the container is deleted but before the delete operation returns.

How Container Hooks Toolkit Works

有了前面的铺垫,最后对container hooks toolkit是如何工作的进行介绍:

  1. 当我们执行docker run命令创建并启动一个新的容器时,这个命令会被发送到 Docker守护进程 (dockerd)。
  2. 接收到来自Docker客户端(docker cli)的命令后,dockerd解析这些命令。dockerd根据参数检查本地是否存在指定的镜像,如果不存在,它会从配置的Docker镜像仓库(默认是Docker Hub)下载镜像。dockerd创建一个容器配置,并将其传递给容器引擎 (containerd)。
  3. containerd接收来自 dockerd 的请求,并负责进一步处理这些请求,如创建、启动、停止容器等。containerd 会生成一个容器规范(如OCI规范,也就是前面提到的config.json),并将其传递给较低级别的容器运行时,这里是我们配置的container-hooks-runtime
  4. container-hooks-runtime对传递来的参数进行解析,如果是create/start,就解析出/etc/container-hooks/hooks.json中钩子并插入到容器的bundle路径下的config.json中,并传递给runc
  5. 如果是非create/start命令,container-hooks-runtime就直接将参数传递给runc
  6. 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就行不通了,因为我们无法提前预知每个容器的启动镜像信息。可以对项目进行二次开发,不采用读取文件中的钩子的形式,而是直接在代码中进行插入并根据容器的配置进行参数调整。

需要修改的代码部分:

  1. /internel/runtime
  2. /internel/modifier