1. 前言

最开始开发容器产品时还是使用Docker,因为它文档相对齐全,大多数的坑都踩平了,还有很多代码实践参考。

本来已经基于官方的IPAM和bridge插件修改出接入VXLAN的方案,后来因为各种原因切换到更底层的CRI+Containerd方案。因为早期的开发经验为后来的实现提供了许多思路,这里记录一下,以便未来有需要时可以再做参考。

2. Docker插件

我们都知道Docker有API,本身就是一个C/S模式的程序,docker命令行工具就是通过API与daemon交互,理论上所有可以通过docker命令使用的功能(如container、image、volume、network)都能够通过API调用,自行开发容器编排程序。

K8S设计的Pod,实际上就是通过创建一个pause容器作为资源管理的最小单位,其他用户定义的容器加入到pause容器的命名空间中,由于共享了网络、挂载、IPC、UTS等,提供了近似虚拟机的使用环境。

而Docker插件恰恰相反,是实现了插件接口,会被daemon调用的程序。

什么是Docker插件? 搜索docker plugin首先冒出的是下面两个链接

但看完还是云里雾里,下面提几个要点:

  1. 版本:Docker 1.13及以上版本支持插件,对应Docker API版本1.25+
  2. API:基于HTTP的RPC风格的JSON,类似于webhooks
  3. 生命周期:应在Docker启动之前启动,在Docker停止之后停止
  4. 发现机制:
    • .sock:UNIX域套接字,必须运行在同一个docker宿主机上,套接字存放在 /run/docker/plugins 目录下,文件名同插件名
    • .spec:包含URL的文本文件,例如:unix:///other.socktcp://localhost:8080,可存放在 /etc/docker/plugins/usr/lib/docker/plugins 目录下,文件名同插件名
    • .json:包含插件完整定义的JSON文件,可定义插件名、地址以及TLS配置,存放位置同spec文件

docker提供了一个库来简化插件开发:go-plugins-helpers,这个库主要实现了插件的注册,并定义了相关接口和结构体。

以网络插件为例,我们只需要引用这个库,可以简单实现一个空的 test_network 插件:

1
2
3
4
5
6
7
import "github.com/docker/go-plugins-helpers/network"

func main() {
  d := MyNetworkDriver{}
  h := network.NewHandler(d)
  h.ServeUnix("test_network", 0)
}

其中MyNetworkDriver需要实现以下的接口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Driver represent the interface a driver must fulfill.
type Driver interface {
	GetCapabilities() (*CapabilitiesResponse, error)
	CreateNetwork(*CreateNetworkRequest) error
	AllocateNetwork(*AllocateNetworkRequest) (*AllocateNetworkResponse, error)
	DeleteNetwork(*DeleteNetworkRequest) error
	FreeNetwork(*FreeNetworkRequest) error
	CreateEndpoint(*CreateEndpointRequest) (*CreateEndpointResponse, error)
	DeleteEndpoint(*DeleteEndpointRequest) error
	EndpointInfo(*InfoRequest) (*InfoResponse, error)
	Join(*JoinRequest) (*JoinResponse, error)
	Leave(*LeaveRequest) error
	DiscoverNew(*DiscoveryNotification) error
	DiscoverDelete(*DiscoveryNotification) error
	ProgramExternalConnectivity(*ProgramExternalConnectivityRequest) error
	RevokeExternalConnectivity(*RevokeExternalConnectivityRequest) error
}

3. 开发难点

理论很美好,现实很骨感,难点只会随着需求和开发进度被发掘,我们还是以网络插件开发为例来讲解。

3.1 参数传递

默认的bridge驱动是通过接口被daemon间接调用的,由于传参发生在同一个进程内,没有网络传输过程,未定义类型的参数(interface类型)都可以直接通过类型推断获得,不需要反复编码和解码。

但在多次尝试过直接修改docker-ce源码并编译不成功后,决定采用插件的方式,于是参数传递的过程如下:

  1. docker命令或API调用通过网络传输将用户参数传递给docker daemon
  2. docker daemon校验解析参数,通过网络传输将用户参数传递给插件
  3. 插件接收参数,创建veth pair,持久化相关网络配置,然后返回参数给daemon
  4. 容器的创建、启动、停止、删除,都会涉及到daemon与插件的多次交互

由于多了一层网络传输,整个过程中最需要注意的就是对参数:

  1. daemon接口接收的参数如何映射到插件库提供的参数?
  2. map[string]interface{}类型的字典,value如何解析为所需的类型?
  3. 返回值为map[string]interface{}类型的字典,需要填充哪些类型的value?

插件库代码比较简单,我们还需要看网络驱动库的代码,确定网络驱动对应的 libnetwork 库需会传递和接收哪些参数。

3.2 接口调用顺序

没有接口调用顺序说明文档。

最快的方法就是先实现所有的接口,一边debug一边开发,先用docker命令行在debug模式下摸清接口的功能和调用顺序,熟悉了之后再根据接口定义找调用方的代码。

因此需要不断翻docker、containerd、libnetwork等等相关代码,最好先搭建开发环境,以便通过日志和代码跳转追踪调用流程。

3.3 其他插件依赖

我们的插件基于bridge驱动修改,通过Veth Pair和网桥接入VXLAN网络,网络插件的主要功能就是创建和维护EndPoint,并不涉及到IP地址管理,当网络插件开发完毕后,才发现还需要修改IPAM插件,万幸的是按已有的产品逻辑,我们自行管理所有IP地址和MAC地址分配,因此只要实现一个空的IPAM插件即可。

虽然Docker一直在演进,代码和架构不断的被重构,但摸清容器生命周期后,就能有一个思路来确定开发方向,这里推荐阅读 《Docker容器与容器云(第二版)》,里面有许多流程相关的代码说明。

3.4 第三方库依赖

由于bridge驱动已经合并到libnetwork,它依赖了许多libnetwork内部的库,这些库提供了虚拟网卡和网桥创建、配置持久化等功能,十分方便。

但为了防止受到上游依赖库中断的影响,几乎所有docker相关项目还是使用vendor工具缓存依赖库,默认vendor工具为 vndr,说实话比较难用,但是不得不用,因为vendor目录中的结构体类型与直接引用时是不一致的。

4. 总结

虽然看了很久 《Docker容器与容器云(第二版)》,也做了很多读书笔记,但还是那句老话说得好:Talk is cheap,show me the code。

代码写起来,程序跑起来,多多debug。