一、先搞懂两个核心角色:Containerd和OCI规范
很多刚接触容器的朋友,可能会把Containerd和Docker混为一谈,或者觉得OCI规范是个看不懂的“天书”,其实这俩都是容器生态里的“基础工具”,只是分工完全不一样。咱们先把这俩拆透,再讲它们的适配关系,最后再搞自定义运行时的开发实践。
1.1 什么是OCI规范?
先讲OCI规范,它本质上是一套“容器界的行业标准”,就像手机充电的Type-C标准——所有手机厂商都按这个做,充电器就能通用。OCI规范把容器拆成了几个独立的部分,每个部分都有明确的“规则”:
- runtime-spec(运行时规范):规定容器运行时必须实现的核心功能,比如启动容器、停止容器、管理容器的进程等;
- image-spec(镜像规范):规定容器镜像的格式,比如镜像由哪些文件组成、怎么压缩、怎么校验完整性;
- distribution-spec(分发规范):规定镜像怎么在不同仓库之间传输,比如Docker Hub、Harbor这些镜像仓库的传输规则。
简单说,OCI规范就是给容器生态定的“规矩”,所有符合这个规矩的工具,都能互相配合用。
1.2 什么是Containerd?
Containerd是一个“容器管理的核心组件”,它的定位很纯粹:不做Docker那种给用户用的命令行界面,只做容器的底层管理。它的核心能力有三个:
- 拉取、存储容器镜像(符合OCI镜像规范);
- 管理容器的生命周期(启动、停止、删除等);
- 调度容器的运行时(比如用runc、crun或者自定义的运行时)。
现在很多云原生项目(比如Kubernetes)都用Containerd来替代Docker作为底层容器管理组件,因为它更轻量、更稳定。
二、Containerd和OCI规范的适配关系
Containerd从设计之初就是严格遵循OCI规范的,这种适配关系可以用“三层架构”来理解:
2.1 适配的核心逻辑
Containerd的三个核心模块,分别对应OCI规范的三个子规范:
- 镜像管理模块 → 适配OCI镜像规范:Containerd拉取的镜像,必须符合OCI镜像的格式,比如镜像的层结构、配置文件的JSON格式等;
- 生命周期管理模块 → 适配OCI运行时规范:Containerd调用运行时的方式,必须符合OCI运行时规范的要求,比如启动容器时要传递符合规范的配置;
- 运行时调度模块 → 适配OCI运行时规范:Containerd可以调度任何符合OCI运行时规范的运行时,不管是官方的runc,还是自己开发的自定义运行时。
2.2 适配的具体体现
举个例子,OCI运行时规范规定,所有符合要求的运行时,必须实现一个create命令,用来创建容器的命名空间、Cgroups等资源,并且这个命令的参数必须是一个符合规范的JSON配置文件。Containerd在启动容器时,会严格按照这个要求来调用运行时:
- Containerd先把容器的配置(比如镜像、资源限制、环境变量等)转换成OCI规范要求的JSON配置;
- 然后调用运行时的
create命令,把这个JSON配置传进去; - 运行时按照配置创建容器的基础环境;
- 最后Containerd再调用运行时的
start命令,启动容器的主进程。
三、自定义容器运行时的开发实践
当官方的运行时(比如runc)不能满足需求时,就需要开发自定义的容器运行时。比如,你想给容器加个特殊的安全隔离规则,或者想让容器直接运行在硬件加速的环境里,这时候就可以自己写一个符合OCI规范的运行时。
3.1 技术栈选择
本次开发实践统一使用Go语言作为技术栈,因为Go语言的跨平台特性好、内存管理方便,而且OCI规范的官方实现(比如runc)就是用Go写的,参考资料很多。
3.2 开发前的准备
在开发之前,我们需要先明确自定义运行时的核心要求:必须符合OCI运行时规范,也就是必须实现以下几个子命令:
create:创建容器的基础环境(命名空间、Cgroups等);start:启动容器的主进程;delete:删除容器的基础环境;state:获取容器的当前状态。
为了简化开发,我们可以先基于官方的runc做二次开发,只修改我们需要的部分,而不是从零开始写。
3.3 具体开发步骤
3.3.1 第一步:克隆runc源码并修改
首先,克隆runc的官方源码:
# 克隆runc源码,指定一个稳定的版本(比如v1.1.12)
git clone --branch v1.1.12 https://github.com/opencontainers/runc.git
cd runc
然后,我们要修改create命令的逻辑,给容器加一个自定义的环境变量。找到create命令的实现文件cmd/create.go,在里面添加以下代码(注释会详细说明每一步的作用):
// 引入需要的包
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/opencontainers/runc/libcontainer"
"github.com/opencontainers/runc/libcontainer/configs"
"github.com/opencontainers/runc/libcontainer/specconv"
"github.com/opencontainers/runc/libcontainer/utils"
"github.com/opencontainers/runtime-spec/specs-go"
)
// 找到Create函数的实现,在创建容器配置的位置添加代码
func Create(context *cli.Context) error {
// 原来的代码:解析OCI规范的配置文件
spec, err := utils.ReadSpec(context.String("bundle"))
if err != nil {
return err
}
// ****************** 自定义代码开始 ******************
// 给容器添加一个自定义的环境变量:CUSTOM_RUNTIME=MY_CUSTOM_RUNTIME
// 首先判断进程配置是否存在,不存在就初始化一个
if spec.Process == nil {
spec.Process = &specs.Process{}
}
// 把自定义环境变量追加到原有环境变量的后面
spec.Process.Env = append(spec.Process.Env, "CUSTOM_RUNTIME=MY_CUSTOM_RUNTIME")
// ****************** 自定义代码结束 ******************
// 原来的代码:把OCI配置转换成libcontainer内部的配置
config, err := specconv.CreateLibcontainerConfig(&specconv.CreateOpts{
CgroupName: context.String("bundle"),
NoPivotRoot: context.Bool("no-pivot"),
Spec: spec,
Rootless: utils.Rootless(),
RootlessCgroups: context.Bool("rootless-cgroups"),
SystemdCgroup: context.Bool("systemd-cgroup"),
})
if err != nil {
return err
}
// 后面的原有代码不变,继续执行创建容器的逻辑
...
}
3.3.2 第二步:编译自定义运行时
修改完代码后,我们需要把它编译成可执行文件:
# 编译自定义运行时,命名为my-runc
make && mv runc my-runc
3.3.3 第三步:配置Containerd使用自定义运行时
接下来,我们需要告诉Containerd,以后要用我们自己的my-runc来运行容器。修改Containerd的配置文件(默认路径是/etc/containerd/config.toml),找到plugins."io.containerd.grpc.v1.cri".containerd.runtimes部分,添加我们的自定义运行时配置:
# 原来的runc配置保留,再添加一个自定义运行时的配置
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes]
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
runtime_type = "io.containerd.runc.v2"
options = { BinaryName = "runc" }
# ****************** 自定义运行时配置开始 ******************
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.my-runc]
runtime_type = "io.containerd.runc.v2"
options = { BinaryName = "/path/to/my-runc" } # 这里要填你编译好的my-runc的绝对路径
# ****************** 自定义运行时配置结束 ******************
修改完配置后,重启Containerd让配置生效:
systemctl restart containerd
3.3.4 第四步:测试自定义运行时
现在我们来测试一下自定义运行时是否生效。创建一个测试用的Pod YAML文件test-pod.yaml:
apiVersion: v1
kind: Pod
metadata:
name: test-custom-runtime
spec:
runtimeClassName: my-runc # 指定使用我们的自定义运行时
containers:
- name: test-container
image: busybox:latest
command: ["env"] # 启动容器后执行env命令,打印所有环境变量
然后用kubectl创建这个Pod:
kubectl apply -f test-pod.yaml
等Pod创建完成后,查看Pod的日志:
kubectl logs test-custom-runtime
你会在日志里看到我们自定义的环境变量CUSTOM_RUNTIME=MY_CUSTOM_RUNTIME,这说明我们的自定义运行时已经成功集成到Containerd里了。
四、相关内容的详细分析
4.1 应用场景
自定义容器运行时的应用场景主要有以下几种:
- 特殊安全需求:比如需要给容器加额外的隔离规则,或者需要集成特殊的安全扫描工具;
- 硬件加速需求:比如让容器直接使用GPU、FPGA等硬件资源,或者运行在轻量虚拟化环境(比如Kata Containers);
- 特殊业务需求:比如需要给容器加自定义的资源限制规则,或者需要修改容器的网络配置逻辑;
- 兼容性需求:比如需要让Containerd支持旧版本的容器格式,或者支持特定厂商的容器标准。
4.2 技术优缺点
优点
- 灵活性高:可以根据自己的需求修改容器运行时的逻辑,满足特殊场景的需求;
- 兼容性好:只要符合OCI规范,自定义运行时可以无缝集成到任何支持OCI的容器管理组件(比如Containerd、Kubernetes);
- 性能可控:可以根据业务需求优化运行时的性能,比如减少不必要的安全检查,提高容器的启动速度。
缺点
- 开发维护成本高:需要深入理解OCI规范和容器运行时的底层原理,开发和维护的难度比较大;
- 兼容性风险:如果自定义运行时没有严格符合OCI规范,可能会出现和其他容器组件不兼容的问题;
- 安全风险:如果自定义运行时的代码有漏洞,可能会影响整个容器集群的安全。
4.3 注意事项
- 严格遵循OCI规范:自定义运行时必须严格符合OCI运行时规范的要求,否则会出现和Containerd不兼容的问题;
- 做好安全测试:自定义运行时的代码必须经过严格的安全测试,避免出现漏洞;
- 做好版本管理:OCI规范和Containerd都会不断更新,自定义运行时需要及时跟进更新,避免出现兼容性问题;
- 优先使用成熟方案:如果官方的运行时已经能满足需求,尽量不要自己开发自定义运行时,避免不必要的开发维护成本。
五、文章总结
本文先从基础概念入手,讲解了Containerd和OCI规范的核心定义,然后分析了两者的适配关系——Containerd是严格遵循OCI规范的容器管理组件,其镜像、生命周期、运行时调度模块分别对应OCI规范的三个子规范。接着,我们通过一个完整的开发实践,详细讲解了如何基于runc开发自定义容器运行时,并将其集成到Containerd中。最后,我们分析了自定义容器运行时的应用场景、技术优缺点和注意事项。
自定义容器运行时虽然开发维护成本较高,但在特殊场景下能提供极高的灵活性,是容器生态中非常重要的扩展能力。对于开发者来说,深入理解Containerd和OCI规范的适配关系,掌握自定义运行时的开发能力,能帮助我们更好地应对复杂的容器场景需求。
评论
围绕“Containerd与OCI规范的适配关系解析及自定义容器运行时集成的开发实践”参与讨论