使用插件扩展Docker

本文讲的是使用插件扩展Docker【编者的话】本文介绍了通过插件扩展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
  • *.spec

JSON规范(specification)文件(*.json)

这种文件只是一个普通的*.json文件,包含一些特定的信息:
  • Name:当前可发现的插件名称
  • Addr:插件的HTTP服务器实际可访问的地址信息
  • 传输安全配置(TLSConfig):这是一个可选项;当你需要指定通过SSL协议连接到HTTP服务器时才需要被设置

如下是一个插件的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

unix:///path/to/myplugin.sock


激活机制

所有这些协议最基本的共同点便是均需实现一套插件的激活机制。 这个机制使得Docker可以知道某个插件支持哪些具体的协议来提供对应的功能。守护进程在必要的时候,会远程调用插件的/plugin.Activate远程调用,这个远程调用则必须反馈插件所支持的协议:
{
"Implements": ["NetworkDriver"]
} 

可用的协议或者说功能如同上面所描述的:
  • 权限控制
  • 网络驱动
  • 卷驱动
  • IP地址管理驱动

除了激活的调用接口每个协议还会额外引入一些它自己支持的一些RPC调用。本文将进一步的讨论VolumeDriver协议,我们将会列举所有VolumeDriver.*形式的远程调用,并且将实际编写一个“Hello World”卷驱动插件。

错误处理

插件必须提供有意义的错误信息给Docker 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调用和一个激活调用组成:
  • /VolumeDriver.Create
  • /VolumeDriver.Remove
  • /VolumeDriver.Mount
  • /VolumeDriver.Path
  • /VolumeDriver.Unmount
  • /VolumeDriver.Get
  • /VolumeDriver.List

对于这里的每个RPC操作,我们需要实现可以返回完整的JSON数据体的POST端点。你可以转到这里参考完整的规范。

幸运的是,docker/go-plugin-helpers这个项目已经做了很多相关的工作,包含一系列用Go写的帮助实现Docker插件的包。

当我们打算实现一个卷驱动插件时,我们需要在volume包里创建一个结构体来完成对应的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请求是一一对应的。因此我们可以通过创建我们驱动的结构体开始。
type ExampleDriver struct {
volumes    map[string]string
m          *sync.Mutex
mountPoint string
} 


这其实并不难。我们只是创建了一个具有几个属性的结构体:
  • Volumes:我们将要用这个属性来保存“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,这样的话我们就确定这时没人可以操作volumes字典。当运行结束后,mutex会被自动释放。

然后它会检查卷是否已经存在,如果是的话,我们会只返回一个空的结果来表示卷是可用的。如果卷还不可用,我们会创建一个带有自身挂载点的字符串,检查路径是否可写,并且把它添加到volumes字典中。成功的话,我们将返回一个空结果,或者如果路径是不可写的,我们将会抛出错误。

这个插件不会自动处理目录的创建(虽说这其实很简单),用户可以手动完成。

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),
}
} 

这个函数主要是返回一些关于这个卷的信息。我们在volumes字典中搜索卷的名字并且在结果中返回它的名字和挂载点。

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{}
} 

这个函数当客户端请求Docker daemon删除一个卷时会被调用。首先当我们操作volumes字典时需要锁住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{}

} 

当某个容器停止,这个函数都会被调用一次。这里,我们在volumes字典中搜索请求的卷名并返回挂载点,这样的话Docker就可以使用它了。

在这个例子中,这个函数的执行过程与Path函数相同。在一个真实的插件中,Mount函数可能要做更多的事情,比如配置资源或为这个资源请求远程的文件系统。

Unmount

func (d ExampleDriver) Unmount(r volume.Request) volume.Response {
logrus.Info("Unmount ", r)
return volume.Response{}
} 

这个函数每当一个容器停止并且Docker不再使用这块卷时会被调用。这里我们不做任何事。一个生产就绪的插件可能会在这个时候注销资源。

Server

现在我们的驱动已经就绪,我们可以创建服务来给Docker daemon提供Unix socket服务。这里空的for循环是为了让main函数处于死循环中,因为服务会到另一个独立的goroutine。
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 daemon后再停止插件。我通常会在生产环境中遵循这个建议,当在我本地的测试环境中,我通常是在容器里面测试插件的,所以我没有其他选择,必须要在启动Docker之后再启动插件。

使用你的插件

现在你的插件运行起来了,你可以用它来启动一个容器并且指定卷驱动。在启动容器之前,我们需要在挂载点/tmp/exampledriver下创建myvolumename

一个真实生产就绪的插件应该可以做到自动处理挂载点的创建。
$ mkdir /tmp/exampledriver/myvolumename
# docker run -it -v myvolumename:/data --volume-driver=driver-example alpine sh

你可以通过docker volume ls来检查卷是否被创建了,输出结果如下:
DRIVER              VOLUME NAME
local               dcb04fb12e6d914d4b34b7dbfff6c72a98590033e20cb36b481c37cc97aaf162
local               f3b65b1354484f217caa593dc0f93c1a7ea048721f876729f048639bcfea3375
driver-example      myvolumename

现在每个将要放在容器的/data文件夹里的文件都会被写在主机的/tmp/exampledriver/myvolumename文件夹里。

可用的插件

你可以在这里找到很多插件。我最爱的插件有:
  • Flocker:这个插件可以让你的卷“跟随”着你的容器,让你拥有更稳定的容器,因为如数据库等将会保持一致。
  • Netshare plugin:我用这个插件来把NFS文件夹挂载在容器里。它也可以支持EFS和CIFS。
  • Weave Network Plugin:这个可以让你看到一些挂载在相同网络交换机上但在不同地方独立运行的容器。

现在你已经了解了插件的API是如何工作的,然后你就可以自己写个插件来玩啦~棒棒哒!

但你现在还可以做点事情。举个例子,我给你展示了怎么用Golang写的官方插件助手来用Go语言写你的插件。但你可能没用过Golang——你可能使用Rust或Java,甚至JavaScript。如果这样的话,你可以考虑用你的语言写一个插件助手噢。

考虑用你最爱的语言写一个@Docker插件助手吧。”——@fntlnz
原文链接:Extend Docker Via Plugin(翻译:潘丽娜,校对:吴佳兴)

================================================================
译者介绍
潘丽娜,Intel软件工程师。

原文发布时间为:2016-05-11
本文作者:Lynna 
本文来自云栖社区合作伙伴DockerOne,了解相关信息可以关注DockerOne。
原文标题:使用插件扩展Docker
上一篇:活动回顾 | 4月24合肥ACE企业数据库应用实践技术沙龙


下一篇:40亿条/秒!Flink流批一体在阿里双11首次落地的背后