【编者的话】本文介绍了通过插件扩展Docker的功能,介绍创建一个新插件的步骤以及详细API,鼓励Docker爱好者加入到插件的编写队伍中去。
Docker吸引我的,同时也是促发其成功的一个重要方面,是其开箱即用的特性。
"开箱即用"是指什么呢?简单来说,安装好Docker就可以马上使用。不需要任何额外的操作,诸如网络/进程/文件系统隔离等繁杂事情也不是你在你担心的范围内。
不过,经过一段时间的使用,你可能开始会考虑更多 - 诸如自定义网络,自定义IP地址保留,分布式文件系统等等。这些需求会在你将docker融入产品或者做进一步准备时候浮现而出。
幸运的是,Docker不仅仅是开箱即用,其中的功能点也是可以进行调整。如何调整呢?通过Docker的插件!
“即使@Docker开箱即用,最终你还是想要更多。”—— @fntln
什么是Docker插件?
官方文档 的描述:
Docker插件是增加Docker引擎功能的进程外扩展。
这就表示,插件不会运行在Docker daemon中。你可以随时随地(如果需要可以在另一台主机上)启动你的插件。你只需要通过Plugin Discovery(我们后面会深入讨论)通知Docker daemon这儿有一个新的插件可用即可。
进程外体系的另一个有点就是你甚至可以不用重新建立一个Docker daemon来增加一个插件。
“你不需要重新编译@Docker的守护进程来增加一个插件。” ——fntlnz
你可以创建依赖于如下功能的各种插件:
授权(authz)
这个功能允许你的插件接管Docker守护进程和其远程调用接口提供认证和授权的功能。权限管理的插件在你需要进行权限管理或者进一步细粒度的控制可以用户对于守护进程的操作时候非常有用。
卷驱动(VolumnDriver)
基本而言,卷驱动功能使得插件可以掌管每一个卷(volumn)的生命周期。 这样的一个插件将自己注册成一个卷驱动,并且在一个主机指明这个卷驱动的名字,希望通过其分配卷时候会被启用。 卷驱动插件作为主机上的卷的一个挂载点(Mountpoint)而存在。
卷驱动插件在管理分布式文件系统和有状态的卷时候非常有用。
网络驱动(NetworkDriver)
网络驱动作为libnetwork的一个驱动来拓展了Docker引擎。 这意味着插件本身可以通过接入不同的终端(veth pairs等)或者沙盒(网络命名空间, FreeBSD Jails等),扮演网络中的各种角色。
Ipam驱动(IpamDrvier)
IPAM全称是IP地址管理(IP Address Management). IPAM是libnetwork的一个负责管理网络和终端IP地址分配的接口。 Ipam驱动在你需要引入自定义容器IP地址分配规则时候非常有用。
在创建插件之前我们需要做什么?
Docker 1.7之前的版本不支持插件机制,唯一可以控制守护进程的方式是通过封装其一系列的远程调用接口。 有许多的供应商提供这样的服务,基本而言,他们封装Docker原有的远程调用接口,暴露出和Docker守护进程类似自定义的接口来完成特定的自定义功能。
这么做带来的问题在于,接口的互相组合会变成一场灾难。举个最简单的例子,如果你需要同时运行两个插件,如何知道哪一个先被加载才合适呢?
就如我之前所说,新的插件运行在守护进程之外, 这意味着守护进程本身需要寻找一种合适的方式去和他们进行交互。 每个插件都内建了一个HTTP服务器,这个服务器会被守护进程所探测到,并且提供一系列的远程调用接口,通过HTTP POST方法来交换JSON化的信息。每个插件需要暴露的远程调用接口取决于其想用实现的功能(授权,卷驱动,网络驱动和IPAM)。
插件发现机制
那么,“一个会被Docker守护进程所探测到的HTTP服务”是什么意思?
Docker包含一系列的方法去找到一个插件的HTTP服务。 它首先检查所有定义在/run/docker/plugins
下的Unix的socket接口。比如你的插件名字是 myplugin
,那么对应的socket文件应该定义在如下位置: /run/docker/plugins/myplug.sock
除此之外,docker也会检查目录 /etc/docker/plugins
或者 /usr/lib/docker/plugins
目录下包含的特定后缀的文件。目前有两种特定类型的文件可用:
JSON规范(specification)文件 (*.json)
这种文件只是一个普通的 *.json
文件,包含一些特定的信息:
如下是一个插件的JSON规范文件的例子:
{
"Name": "myplugin",
"Addr": "https://fntlnz.wtf/myplugin",
"TLSConfig": {
"InsecureSkipVerify": false,
"CAFile": "/usr/shared/docker/certs/example-ca.pem",
"CertFile": "/usr/shared/docker/certs/example-cert.pem",
"KeyFile": "/usr/shared/docker/certs/example-key.pem",
}
}
纯文本文件 (*.spec)
你可以使用文件后缀为 *.spec
的纯文本来提供一个插件的信息。 这个文件需要指定插件的HTTP服务器的TCP或者UNIX接口地址,例如:
tcp://127.0.0.50:8080
05unix:///path/to/myplugin.sock
激活机制
插件需要至少实现并且提供激活的功能,这是一个插件所需要的最基础的控制机制。 这个机制使得Docker可以知道某个插件支持哪些具体的协议来提供对应的功能。守护进程在需要的时候,会远程调用插件的/plugin.Activate远程调用,这个远程调用需要至少回复插件所支持的协议:
{
"Implements": ["NetworkDriver"]
}
可用的协议或者说功能如同上面所描述的:
每一个协议将另外引入其支持的一些激活访问的远程RPC调用。在下面的文章中将进一步的讨论 VolumeDriver
的协议,我们将会列举所有 VolumeDriver.*
形式的远程调用,并且通过这些远程调用来实践一个"Hello World"的局驱动插件。
错误处理
插件必须提供有意义的错误信息给Dokcer daemon这样它可以返回给客户端。错误返回信息格式如下:
{
"Err": string
}
这必须和HTTP 错误代码400和500一起使用。
卷驱动协议
卷驱动协议不仅简单又实用。第一件需要知道的事情是在握手 (/Plugin.Activate)
的过程中,插件必须把它们自己注册为卷驱动。
{
"Implements": ["VolumeDriver"]
}
任何一个卷驱动都需要提供在主机文件系统中可写的路径。
使用卷驱动插件与标准插件的经验很相似。你可以用-d标志字创建一个卷来使用你的容器驱动。
docker volume create -d=myplugin --name myvolume
或者你可以用-v标志字来创建一个容器时同时启动一个容器,也可以用'--volume-driver'的标志字来指定你容器驱动插件的名字。
docker run -v myvolume:/my/path/on/container --volume-driver=myplugin alpine sh
写一个”Hello World”卷驱动插件
让我们写一个简单的插件,可以用本地的文件系统从 /tmp/exampledriver
文件夹中产生卷。简单地说,当客户端请求一个叫做 myvolume
的卷,这个插件会将那个卷与挂载点 /tmp/exampledriver/myvolume
一一对应,并挂载在那个文件夹上。
VolumeDriver协议是由如下总共7个PRC call和一个Activation组成:
- /VolumeDriver.Create
- /VolumeDriver.Remove
- /VolumeDriver.Mount
- /VolumeDriver.Path
- /VolumeDriver.Unmount
- /VolumeDriver.Get
- /VolumeDriver.List
对于这里的每个RPC操作,我们需要完成相关可以返回完整的JSON负载的POST端点。你可以参考完整的 规范 。
幸运的是, docker/go-plugin-helpers 这个项目已经做了很多相关的工作,包含一系列用Go写的组件包,用于Docker的插件。
因为我们要完成一个卷驱动插件,我们需要创建一个结构体来完成 volumn
的 volume.Driver
接口。
volume.Driver
接口定义如下所示:
type Driver interface {
Create(Request) Response
List(Request) Response
Get(Request) Response
Remove(Request) Response
Path(Request) Response
Mount(Request) Response
Unmount(Request) Response
}
如你所见,这个接口函数是与VolumeDriverRPC请求是一一对应的。所以我们可以通过创建我们driver的结构体开始。
type ExampleDriver struct {
volumes map[string]string
m *sync.Mutex
mountPoint string
}
那其实并不难。我们创建一个具有几个属性的结构体:
- Volumnes: 我们将要用这个属性来保存“volume name” => “mountpoint”的键值对
- m: 这只是一个互斥值,用来阻止同一时间不能执行的操作
- mountPoint: 这是我们插件的基本挂载点
为了让我们的结构体实现 volume.Driver
接口,它需要实现全部的接口函数。
Create
func (d ExampleDriver) Create(r volume.Request) volume.Response {
logrus.Infof("Create volume: %s", r.Name)
d.m.Lock()
defer d.m.Unlock()
if _, ok := d.volumes[r.Name]; ok {
return volume.Response{}
}
volumePath := filepath.Join(d.mountPoint, r.Name)
_, err := os.Lstat(volumePath)
if err != nil {
logrus.Errorf("Error %s %v", volumePath, err.Error())
return volume.Response{Err: fmt.Sprintf("Error: %s: %s", volumePath, err.Error())}
}
d.volumes[r.Name] = volumePath
return volume.Response{}
}
这个函数当每次一个客户端想要创建一个卷的时候都会被调用。这里的逻辑很简单,当登录后,命令被执行完成后,我们会锁住mutex,这样的话我们就确定这时没人可以操作volumns的对应关系。当运行结束后,mutex会被自动释放。
然后会检查卷是否已经存在,如果是的话,我们会只返回一个空的结果来表示卷是可用的。如果卷还不可用,我们会创建一个带有自身挂载点的字符串,检查路径是否可写,并且把它添加到volumns地图中。成功的话,我们将返回一个表示空结果,或者如果路径是不可写入的,我们将会抛出错误。
这个插件不会自动处理文件创建(这其实很简单),用户可以手动完成。
List
func (d ExampleDriver) List(r volume.Request) volume.Response {
logrus.Info("Volumes list ", r)
volumes := []*volume.Volume{}
for name, path := range d.volumes {
volumes = append(volumes, &volume.Volume{
Name: name,
Mountpoint: path,
})
}
return volume.Response{Volumes: volumes}
}
一个卷插件必须列出注册在自己插件上的所有卷。这个函数基本做的就是——它循环遍历一遍所有的卷,然后把它们放在一个列中并且返回结果。
Get
func (d ExampleDriver) Get(r volume.Request) volume.Response {
logrus.Info("Get volume ", r)
if path, ok := d.volumes[r.Name]; ok {
return volume.Response{
Volume: &volume.Volume{
Name: r.Name,
Mountpoint: path,
},
}
}
return volume.Response{
Err: fmt.Sprintf("volume named %s not found", r.Name),
}
}
这个函数主要是返回一些关于这个卷的信息。我们在卷地图中搜索卷的名字并且在结果中返回它的名字和挂载点。
Remove
func (d ExampleDriver) Remove(r volume.Request) volume.Response {
logrus.Info("Remove volume ", r)
d.m.Lock()
defer d.m.Unlock()
if _, ok := d.volumes[r.Name]; ok {
delete(d.volumes, r.Name)
}
return volume.Response{}
}
这个函数当客户端请求Dokcer daemon来删除一个卷时会被调用。首先当我们操作卷地图时需要锁住mutex,然后我们删除那个卷。
Path
func (d ExampleDriver) Path(r volume.Request) volume.Response {
logrus.Info("Get volume path", r)
if path, ok := d.volumes[r.Name]; ok {
return volume.Response{
Mountpoint: path,
}
}
return volume.Response{}
}
Docker需要一些环境变量来通过所给的卷名来查询挂载点。这就是这个函数的功能——取到卷名并且返回那个卷的挂载点。
Mount
func (d ExampleDriver) Mount(r volume.Request) volume.Response {
logrus.Info("Mount volume ", r)
if path, ok := d.volumes[r.Name]; ok {
return volume.Response{
Mountpoint: path,n
}
}
return volume.Response{}
}
当某个容器停止,这个函数都会被调用一次。这里,我们在卷地图中搜索请求的卷名并返回挂载点,这样的话Docker就可以使用它了。
在这个例子中,这个函数的执行过程与Path函数相同。在一个真实的插件中,Mount函数可能要做更多的事情,比如配置资源或为这个资源请求远程的文件系统。
Unmount
func (d ExampleDriver) Unmount(r volume.Request) volume.Response {
logrus.Info("Unmount ", r)
return volume.Response{}
}
这个函数每当一个容器停止并且Dokcer不再使用这块卷时会被调用。这里我们不做任何事。一个生产就绪的插件可能会在这个时候注销资源。
Server
现在我们的驱动已经就绪,我们可以创建服务来给Docker daemon提供Unix socket服务。这里的空for循环是为了让main函数处于死循环中因为服务会到另一个独立的go路径。
func main() {
driver := NewExampleDriver()
handler := volume.NewHandler(driver)
if err := handler.ServeUnix("root", "driver-example"); err != nil {
log.Fatalf("Error %v", err)
}
for {
}
}
这里一个可能可以改进的地方就是可以处理不同的信号,避免异常干扰。
目前,我们还没有实现 /Plugin.Activate
PRC调用。go-plugin-helpers在我们注册卷处理器的时候会帮我们实现这个。
因为我展示给你的只是最重要的代码块并且忽略了一些部分。你可以从github上clone到完成的代码:
Clone
git clone https://github.com/fntlnz/docker-volume-plugin-example.git
然后你就可以编译你的插件并使用他了。
Build
$ cd docker-volume-plugin-example
$ go build .
Run
这时,我们需要启动插件服务,这样Docker daemon就可以发现它了。
# ./docker-volume-plugin-example
你可以检查插件是否已经创建了unix socket:
# ls -la /run/docker/plugins
会有如下的结果输出:
total 0
drwxr-xr-x. 2 root root 60 Apr 25 12:49 .
drwx------. 6 root root 120 Apr 25 02:13 ..
srw-rw----. 1 root root 0 Apr 25 12:49 driver-example.sock
比较推荐的做法是在开始Docker daemon之前启动你的插件,并且在停止Docker diamond后再停止插件。我通常会在产品中遵循这个建议,当在我本地的测试环境中,我通常是在容器里面测试插件的,所以我没有其他选择,必须要在启动Docker之后再启动插件。
使用你的插件
现在你的插件运行起来了,你可以用它来启动一个容器并且制定卷驱动。在启动容器之前,我们需要在挂载点 /tmp/exampledriver
下创建 myvolumename
。
一个真实生产就绪的插件应该做到可以自动处理挂载点的创建。
$ mkdir /tmp/exampledriver/myvolumename
你可以通过 docker volumn ls
来检查卷是否被创建了,输出结果如下:
DRIVER VOLUME NAME
local dcb04fb12e6d914d4b34b7dbfff6c72a98590033e20cb36b481c37cc97aaf162
local f3b65b1354484f217caa593dc0f93c1a7ea048721f876729f048639bcfea3375
driver-example myvolumename
现在每个将要放在容器的 /data
文件夹里的文件都会被写在主机的 /tmp/exampledriver/myvolumename
文件夹里。
可用的插件
你可以在 这里 找到很多插件。我最爱的插件有:
现在你学习了插件的API,然后你就可以自己写个插件来玩啦~棒棒哒!
但你现在还可以做点事情。举个例子,我给你展示了怎么用Golang写的官方插件助手来用Go语言写你的插件。但你可能没用过Golang——你可能使用Rust或Java,甚至Javascript。如果这样的话,你可以考虑用你的语言写一个插件助手噢。
“考虑用你最爱的语言写一个@Docker插件助手吧。”——@fntlnz
原文链接: Extend Docker Via Plugin (翻译:潘丽娜(Intel软件工程师))