Docker 实战
第1部分 保持一台整洁的机器
第1章 欢迎来到 Docker 世界
1.1 什么是 Docker
当启动 Docker 的时候,它实际上是启动了一个父进程;当创建了某个容器时,它实际上创建了一个子进程;并且父进程会为这些子进程分配指定的内存空间和资源;每个子进程只能访问属于自己的内存空间和资源;
Linux 内核通过命名空间和cgroups技术来实现资源隔离,而 Docker 正是利用内核的这项技术,进行二次封装,方便用户使用;
镜像本质上是一个容器中所有文件的一份快照;它可以做为模板,用来创建新容器;
1.2 Docker 解决了什么问题
软件的构建往往需要很多依赖,以及一些环境变量的设置;不同的软件可能依赖不同的版本的模板;当软件越来越大或者越来越多时,事情变得非常复杂。Docker的目的在于,将不同软件的所有的这些问题进行隔离,当一个软件在容器中配置好自己的依赖和环境,就将它打包成一个镜像快照;后续需要使用该软件的人,只需复制这份快照即可;实现了一次配置,永久使用,这样可以大大简化软件的移植和部署问题;
Docker可以原生的运行在 Linux 操作系统上面,因为它使用的即是Linux的命名空间技术;但对于 Win 和 Mac,它实际上需要先创建一个虚拟机,然后所有容器共用这个虚拟机;尽管如此,相对于以前的vmware硬件虚拟化技术,这种方法在开销和性能上,都取得了更大的进步;
1.3 Docker 的好处
它让事情变得更加简单,让用户仅关心应用程序的核心部分,即如何使用它,而不需要去关心每个应用程序如何安装的问题;
1.4 Docker 不能做什么
Docker 可以实现资源的有效隔离,但它仅靠自己并不能提高安全性,尤其是当容器的应用程序以很高的权限运行的时候;在使用容器的基础上,配合使用合理的权限管理设置,才能有效的提高安全性;
在容器中随意运行高权限的不受信任来源的程序,是一个危险的行为;
第2章 在容器中运行软件
2.1 通过帮助了解如何使用 docker 命令
1 |
|
2.2 控制容器
与容器进行交互
使用 dokcer attach 可以将当前 shell 的输入和输出映射到容器中,这样就可以在当前 shell 中看到容器内的输出;同时,也可以将输入发送到容器中,实现需要的操作;
使用 ctrl+p 和 ctrl+q 两个输入,可以实现 deattach,即 attach 的反向操作,让shell和容器分离;同时不会停止容器的运行;
对容器进行连接实现访问
通过 run 创建容器时,通过增加 –link 选项,可以让新容器和已经创建的容器进行关联,从而实现对已创建容器的访问;
查看容器的日志
通过 docker logs <容器名称>,可以查看某个容器内的日志;
2.3 PID 命名空间
docker 利用 linux 的 PID 命名空间机制来实现虚拟化;每个 PID 命名空间内的 PID 都是相互独立的,它们可以重复编号,而不会互相干扰;每新建一个容器,Docker 默认都会为它分配一个新的 PID 命名空间;
但是,通过 –pid host 选项,可以实现不新建命名空间,而使用跟当前进程相同的命名空间;
2.4 消除元数据冲突
在创建容器的过程中,有时会产生命名冲突,为解决这个问题,可通过容器 ID 来进行管理;但 ID 是一种随机的字符串,无法提前预知;因此,需要通过变量或文件来获取该 ID 值,然后在随后使用,以实现自动化管理;
使用 docker create 可以创建一个未启动的容器,并获得其 ID;
使用变量管理容器的示例
1 |
|
从软件部署的角度来看,开发应用程序时,应该尽量减少对环境的信赖,这样可以提高程序的扩展性,并降低维护的复杂度;
2.5 构建与环境无关的系统
2.5.1 只读文件系统
创建只读容器的好处是可以防止出现有意或无意的破坏;同时,由于数据与程序分离,意味着当程序出现损坏时,可以快速再启动一个新的,而无需担心里面的数据恢复问题;
通过在 docker run 中使用 –read-only 选项,即可创建只读容器;
1 |
|
2.5.1 环境变量注入
创建容器时,使用 –env 选项,可以为容器设置环境变量的值;而容器中的应用程序在运行时,可以依赖这些注入的环境变量;
脚本示例:
1 |
|
2.6 建立持久化的容器
容器内的程序如果出现一些意外错误,会导致容器进入退出的状态,对于需要持续提供服务的程序来说,这些情况是不允许的,因此需要有一个能够自动启动意外暂停容器的机制;
2.6.1 自动重启容器
创建容器时,使用 –restart 选项,可以配置容器的自动重启策略;
这种策略的缺点是当容器退出并等待重启的期间,无法与容器进行交互;
2.6.2 使用 init 或 supervisor 维持容器的运行状态
操作系统启动的第一个进程(init 进程,也即PID编号为1的进程),是所有其他进程的父进程;因此,可以利用这个机制,当某个进程意外退出时,可以使用这个 init 进程对其进行重新启动;
同时,还需要引入一个 supervisor 进程,避免因某个进程终止,导致容器退出,即实现容器的持久生命周期;
2.6.3 启动脚本
相对于使用 init 和 supervisor,使用启动脚本可以带来更大的灵活性,例如在启动程序前,对相关的环境变量进行检查;
一般可以将启动脚本设置在容器的入口点中(所谓的入口点,用于在容器时,自动运行的命令或程序);
2.7 容器清理
docker stop 可用来停止容器的运行;只有当容器处于停止运行状态时,才可以使用 docker rm 进行删除;
docker rm -f 选项可以用来强制删除;它的原理相当于先发送一个 docker kill 的命令,然后再执行 rm 的命令;
在创建容器的时候,通过增加 –rm 选项,可以使得容器在退出后,自动删除,这样可以减少手工进行删除的工作;
docker rm -vf $(docker ps -a -q) 可以实现一条命令停止并删除当前所有正在运行的容器;
第3章 软件安装的简化
3.1 选择所需的软件
镜像本质上是一个文件集合,同时还有一些元信息;元信息中包含镜像的命令历史、暴露的接口、和其他镜像的关联、卷的定义等信息;
镜像有一个唯一的 ID,但这个 ID 非常不方便记忆,因此,一般使用“域名+用户名+镜像名”的方式来标识一个镜像;
同时再使用标签来标识镜像的各种不同版本;
事实上,域名+用户名+镜像名的方式,即组成了镜像的仓库地址;通过该地址,可以访问和下载镜像文件;
3.2 查找和安装软件
3.2.1 命令行使用 docker hub
一般通过索引库来查找所需要的镜像,Docker hub 是官方的索引库;
镜像文件可以使用命令行发布到 Docker hub 上,但这种方式并不安全,因为镜像的构建过程是不透明的;使用者无法保证镜像中所有文件的安全性;
另外一种方法是使用 dockerfile +官方构建引擎进行构建;这种方式的好处是保证了安全性,但是构建速度稍微慢一点;
通过 docker search <镜像名> 可以很方便的查询 docker hub 镜像仓库中的各种镜像;其中 Automated 标记为 OK 的镜像表示以公开脚本构建的,因此安全性更加有保障;
除了从 docker hub 官方仓库下载镜像外,也有其他的第三方仓库可以下载镜像,唯一的区别是域名不一样,以及可能需要登录验证;
3.2.2 镜像文件
除了从网上镜像仓库下载镜像外,还可以通过导入事先导出的镜像文件,来获得镜像;
导出示例:docker save -o myfile.tar <镜像名>
导入示例:docker load -i myfile.tar
3.2.3 从 Dockerfile 安装
使用 dockerfile,运行 docker build -t <镜像名> <dockerfile所在目录>,也是一种构建镜像的方法;这种方法的缺点是需要较多的时间;因为可能要下载很多依赖文件;
3.3 安装文件和隔离
3.3.1 容器实现隔离的机制
Linux 通过 MNT Namespace(系统命名空间) 的机制,为不同的进程提供不同的视图;每个进程隶属于某个命名空间,并可以看到该命名空间中的其他进程,但看不到其他命名空间下的进程,这样就为进程实现了隔离;
Union File System(联合文件系统):它的工作机制有点像 Git,每次的修改像是创建一个新的分支,而不是去改动旧的文件;最终的结果则是所有历史分支的叠加结果;这样即使原始文件是只读的,也可以实现虚拟的可写;
chroot 命令可以设置某个文件夹为某个进程的根文件夹,这样就可以为不同的进程虚拟出很多各自不同的根文件夹,从而可以辅助实现虚拟化和文件隔离;
3.3.2 分层文件机制的优点
- 避免重复存储:通过层的复用,避免存储冗余重复的相同文件;
- 简化软件构建过程:通过层的复用,开发者只需关心应用程序本身;
3.3.3 UnionFS 机制的不足
存储在设备中的文件需要有一个文件系统进行管理,这个文件系统有很多种实现方式,以面向不同的存储设备,以及取得不同的性能指标;为了兼容这些不同的文件系统,UnionFS 有一套转换的规则;但是,这个规则还不够完美,在某些特定情况下,会出现错误;
第4章 持久化存储和卷间数据共享
4.1 存储卷的简介
存储卷的本质其实是将某个本地文件夹挂载到容器中,这样容器中对该文件夹所做的写入操作,可以得到持久化的保存;
在 Linux 类的系统中,挂载点是指当前文件系统中的一个目录,这个目录用于连接另外一个文件系统;所谓的文件系统,可将它看做一个目录树,该目录树组织并存放着文件;
一般来说,镜像适合管理一些相对静态的数据,而存储卷适合管理一些相对动态的数据;通过这种分离,可以增灵活性;
docker run –volume 选项
- 如果只写了一个路径 path_a,则表示由 docker 生成某个本地临时目录,并挂载到容器中的 path_a;具体本地目录的路径,可以通过 docker inspect -f Volumes <容器名> 查看到;
- 如果写了两个路径 path_a : path_b,则表示将主机上的指定路径 path_a 挂载到容器中的路径 path_b;
docker run –volume-from 选项
- 用来授权一个容器 B 访问另外一个容器 A 的 volume,以实现数据共享;
- 此时,不管原容器 A 是否运行,该授权都会有效;即 B 都可以向 volume 中写入数据;因此,A 容器启动后即可退出,不需保持运行消耗资源;它唯一的目的是作为授权的中介;为了与真正的程序进行区分,一般创建容器时,会使得 echo 命令打出它的用途;
4.2 存储卷的类型
存储卷有两种类型
- 用户指定的本机目录:适用于将容器中发生的数据写入实时共享给主机进程;这种方法会替代容器中同名路径下的内容;如果本机目录不存在,Docker 会自动创建该目录(最好使用手工创建并设置权限,这样更安全一些;Win10 下自动创建失败);
- Docker 自动指定的本机目录:
通过增加 ro 参数可以设置存储卷为只读
1 |
|
除绑定本机目录为容器中的存储卷外,也要以绑定单个文件到容器中,这样可以只替换容器中的单个文件,而不是覆盖整个目录;
绑定本机目录的缺点
- 这种该种方式制造了容器与本机环境的耦合,降低了可移植性;如果要移植的目录系统没有相应的本机目录,将会导致出错;
- 当创建多个容器时,这种绑定方式可能会带来冲突;
由 Docker 创建存储目录的优点:
- 解耦,提高可移植性,避免冲突;
- 不会暴露存储位置,更加安全一些;
4.3 共享存储卷
–volume-from 可以让新创建的容器,继承其他容器的挂载设置,实现数据共享;但这种继承也有一些局限性,包括
- 只能挂载相同位置,不能更改位置;
- 多个源容器若挂载相同的点,将出现冲突,只有一个会生效;
- 无法自定义卷的读写权限,只能继承源容器的权限设置;
4.4 管理卷的生命周期
管理卷的生命周期独立于任何容器;
管理卷需要由容器创建,在使用 rm 命令删除容器的时候,通过使用 -v 删除关联的管理卷,此时 Docker 实际是先递减管理卷的引用计数,如果等于零,即没有其他容器引用该管理卷,则会触发该管理卷的删除;如果仍有引用,则只是减少计数,不会删除;
删除容器时,如果忘了使用 -v 选项,则有可能产生孤立卷;这个时候只能手工删除了,操作比较麻烦;因此,使用 -v 选项删除容器是一个好的习惯;
4.5 存储卷的高级容器模式
4.5.1 卷容器模式
方式:创建一个新容器A,挂载一个管理卷;后续其他容器继承该容器A的卷配置;从而实现卷的共享;
4.5.2 打包数据模式
方式:将数据放入镜像,基于该镜像创建容器A,然后将数据复制到挂载的管理卷中,之后其他容器继承该容器A的卷配置,从而获得镜像中的数据;
4.5.3 多态容器模式
场景A
在镜像的 dockerfile 中定义好容器启动后的默认行为,例如默认执行以下命令:
1 |
|
之后,每次需要执行代码时,只需将主机的代码目录挂载到容器中的 /app 项下即可;
基本原理:将不同的文件注入容器中的相同位置,来改变容器的行为变现;
场景B
需要对容器中的数据进行分析,但是源镜像中不带有相应的工具应用程序;此时可以通过创建新容器,并挂载一个含有相应工具的目录到容器中,然后通过 docker exec 就可以在容器中调用这些工具了;
场景C
配置文件放在某个镜像中,不同环境(开发/生产/测试)各放一个文件夹,创建不同的卷容器,复制不同文件夹下的配置文件;实际的应用程序关联相应的卷容器即可;
第5章 网络访问
5.1 网络相关的背景知识
本地回环地址:总共只有一个 IP 地址,从该地址发出的信息,不加处理的立即发送回给该地址,而不会访问其他地址;本地回环主要用来和本地上的其他进程进行通信;
广播地址:它是一个网络地址,用来和网络中的其他主机进行通讯;
NAT:net address translation,网络地址转换;当 IP 数据包通过路由器时,对 IP地址进行重写;当有多台主机共用一个公网 IP时,需要使用这个技术;因此它可以用来缓解 IPv4 地址紧张的问题;缺点是会造成一定程度的性能损失;
Docker 会创建一个网桥,容器可通过该网桥,连接到主机上面的网络;
5.2 Docker 的网络
Docker 创建的网桥很像一个家庭路由器,它接收来自外部的数据,然后再分发给内部相应的容器;
Docker 总共有四种网络类型,每个容器都属于其中的一种;它们之间的区别在于隔离程度不同;
5.3 Closed 容器
在 Closed 类型的容器中,进程只能和容器中的其他进程通信,不能和容器外的进程通讯,也不能被容器外的其他进程访问;因此它的隔离程度最高;
选项:–net none
1 |
|
5.4 Bridged 容器
所有连接到 docker 网桥的接口,都是 docker 内部虚拟网络的一部分;因此这些接口之间可以相互访问,同时,也可以通过网桥和外部进行通讯;
选项:–net bridge(默认选项,因此可不写)
1 |
|
5.4.1 自定义域名解析
背景知识:操作系统中正常有一个 hosts 文件,里面记录着域名和 IP 的映射记录;它的作用是用来将域名转换成 IP;在访问一个域名时,操作系统会先在这个文件中查找映射关系;找不到的情况下,才会提高到 DNS 服务器进行查找;
nslookup 命令可用来查询域名和IP地址的映射关系
docker 提供了多个选项用来将主机名自定义的映射到某个指定的 IP 地址,这样可以实现与具体 IP 地址的解耦;这些选项包括:
- –hostname
- –dns
- –add-host
- –dns-search
其基本原理都是将修改添加到容器中的 /ect/hosts 文件中;因此,通过查看这个文件,就可以知道当前容器做了哪些配置;理论上也可以通过修改这个文件来达到相同的效果;
5.4.2 开放对容器的访问
–publish 选项可实现主机端口到容器端口的映射;它有四种格式,分别实现不同粒度级别的控制;
- -p 3333,将容器端口 3333 绑定到主机的一个动态端口上;
- -p 3333:3333,将容器端口 3333 绑定到主机的 3333 端口上;
- -p 192.168.0.32:3333:3333,将容器端口3333绑定到 IP 地址为 192.168.0.32 的主机的 3333 端口上;
- -p 192.168.0.32::3333,将容器端口 3333 绑定到IP地址为 192.168.0.32 的主机的动态端口上;
–expose 选项可增加容器所要暴露的端口
1 |
|
5.4.3 跨容器通信
默认情况下,所有的本地容器都连接到 Docker 网桥,因此,所有本地容器都是可以相互通信的;通过 –icc=false 选项,可以关闭默认开启的跨容器通信;此时想要正常的工作,必须显式的声明依赖;这种方式可以提高容器的安全性;
5.4.4 修改网桥的配置
- –bip,设置网桥的 IP 地址,以及它的子网的 IP 范围
- –mtu,设置最大传输单元,用来限制数据包的最大值;
- –bridge,使用自定义的网桥配置;
5.5 Join 容器
–net container:<容器名> 选项,可用来将 B 容器与 A 容器进行连接;连接后,两个容器各自的资源独立,但共享一个网络组件,这意味着它们的端口全部共用(需要注意避免冲突问题);这种场景非常特殊,因为即使它们不连接,原本也可以通过 docker 网桥相互通信;
Join 容器的使用场景
- 分别处于两个容器内的程序,想通过本地回环接口来相互通信;
- A 容器中的程序改变网络栈,而 B 容器的程序依赖于被改变后的网络栈;
- A 容器中的程序,想要监控 B 容器中的程序的网络流量;
5.6 Open 容器
–net host 选项用来创建 open 容器;
open 容器完全没有隔离机制,它直接绑定主机的网络接口,因此,它也可以绑定到 1024 以下编号的端口,也能够访问到所有的主机端口;
5.7 跨容器依赖
5.7.1 链接
使用链接,可以让 B 容器访问某个服务时,被链接到 A 容器,实现二者的相互通信;
1 |
|
1 |
|
为新创建容器添加链接会有如下的效果:
- 创建关于被链接的目标容器的环境变量;
- 添加链接的别名和目标容器的 IP 地址到 DNS 文件中;
- 添加防火墙规则以实现二者的通信(如需)
5.7.2 链接别名
别名不可避免会带来冲突或者失败的可能,因为各个容器的创建者有可能未就别名形成共同的约定;
解决的办法之一:在容器启动时,内置一段自动运行的脚本,来检测相关的依赖条件是否都已经成立
1 |
|
原理:当使用 –link 链接选项时,docker 会在新建的容器中,创建相应的以链接的目标容器别名为前缀的一系列环境变量,例如 –link mydb:database 的别名为 database,则容器中的环境变量的前缀为 DATABASE;
5.7.3 环境变量的改动
链接所创建的一系列环境变量如下:
- 别名_PORT_端口号_协议名_PORT
- 别名_PORT_端口号_协议名_ADDR
- 别名_PORT_端口号_协议名_PROTO
- 别名_PORT_端口号_协议名
- 别名_PORT
- /本容器名/被链接的容器名
5.7.4 链接的本质和缺点
缺点:链接的原理是通过创建目标容器时,进行查询并保存源容器的信息来实现的;因此,当源容器重启后,目标容器保存的仍然是源容器的旧信息,这时将导致调用源容器的服务失败;
解决方法:使用 DNS 动态解析;
第6章 隔离–限制危险
6.1 资源分配
6.1.1 内存限制
–memory <数字><单位> 选项实现内存使用限制;
1 |
|
需要考虑两个问题
- 程序的正常运行最少需要分配多少内存?
- 系统有多少可分配的内存?
6.1.2 CPU
CPU 是进程轮流使用的,因此当它太忙时,程序并不会失败,而是会等很久,即性能缓慢;
限制 CPU 使用的两种方式
- 配置分配 CPU 周期的权重;权重分配仅在 CPU 资源紧张时,才会触发执行;
- 限制容器可用的 CPU 核数;
6.1.3 设备的访问权
使用 –device 选项,可将主机上的设备映射到容器中,这样容器内的程序就可以访问主机上面的设备了;
6.2 共享内存
使用 –ipc container:<待共享的容器名> ,可实现两个容器共享一个 IPC 命名空间,从而实现共享内存
–ipc host 选项可用来实现和主机共享内存,这会带来一定的危险,一般情况下应该避免使用;除非需要通信的进程只能运行在主机上;
6.3 用户角色
一般来说,容器中的用户使用 root 角色,由于 root 角色拥有最高级别的权限,因此该角色可以对容器中的文件进行一切修改;虽然这种修改只限于容器内,但是如果容器链接着存储卷,则修改会涉及到存储卷;最安全的办法是避免使用 root 用户,但在某些情况下又做不到,例如构建镜像必须使用 root 权限;
6.3.1 Linux 用户命名空间
用户命名空间是 Linux 推出的一个新功能,它可以将容器中某个 ID 的用户映射到主机上的另外一个 ID 用户;
特别注意:容器的用户和主机上的用户共享一个 ID 命名空间,这意味着,容器中 root 用户如果有机会更改或访问主机上的文件,它也是以 root 身份来运行的,这非常危险;
6.3.2 run-as 用户
–user 选项可以用来设置 run-as 用户
1 |
|
1 |
|
1 |
|
6.3.3 用户和卷
除非想让主机上面的文件被容器访问,否则一般不将文件以卷的形式挂载到容器中;因为容器和主机共享一个用户 ID 空间,因此存在一定的安全隐患;
通过设置文件所属的用户和用户组,并配合使用 –user 选项来启动容器,就可以解决不同容器对指定文件的写入和读取权限问题;
6.4 功能授权
–cap-drop 选项可以用来减少容器中进程的系统调用权限,以增加安全性;–cap-add 则相反,可以用来添加系统调用权限;
6.5 运行特权容器
当需要在容器中运行系统管理任务时,需要授予容器访问主机的系统权限,此时该容器会变成一个特权容器;
–priviledged 选项可用来开启特权容器;
6.6 使用加强工具
6.6.1 指定额外的安全选项
Linux 使用 LSM 框架作为操作系统与安全软件之间的接口层;Docker 也支持在创建容器时,加载自定义的 LSM 配置来提高安全性;
–security-opt 选项可用来设置一些安全规则;
更高级别的 Linux 安全机制一般通过引入 SELinux 或 AppArmor 模块来实现,它们可以弥补 Linux 内核本身默认配置下安全性的一些不足;Docker 支持设置选项来启用这些模块的功能;
6.6.2 微调 LXC
LXC 是一个库,用来方便的使用 Linux 命名空间,它即是当时开发 Docker 的缘起;后来出现了可移植性更高的 libcontainer 库;它们两个都是容器运行的底层引擎;Docker 支持通过 –exec-driver 对底层引擎进行自定义的更换和配置;
例如如果更换底层引擎为 LXC,则 run 或 create 容器时,就可以通过 –lxc-conf 来自定义一些配置;自定义越多,可能意味着可移植性越低,需要做好平衡;
6.7 因地制宜的容器构建
6.7.1 应用
- 确定运行应用的用户只拥有有限的权限;
- 限制浏览器的系统调用权限;
- 限制应用的 CPU 和内存资源;
- 指定应用能够访问的设备白名单;
6.7.2 高层的系统服务
系统服务不是操作系统的一部分,而是运行某些应用所需的底层支持;很少有系统服务需要全部的系统调用权限,因此,对它们进行限制有利于提高安全性;
6.7.3 底层的系统服务
底层的系统更接近于操作系统的一部分,但幸运的是,它们很少做为容器来运行,因此可以避免其可能带来的风险;
第2部分 镜像发布:如何打包软件
第7章 在镜像中打包软件
7.1 从容器中构建镜像
大致流程:
- 创建一个容器;
- 在容器中添加文件;
- 使用 commit 命令以该容器为模板创建新的镜像;
可以使用 docker diff container-name 来查看某个容器内发生的文件改动
- A 表示新添加的文件
- C 表示修改的文件
- D 表示删除的文件
docker commit 最好添加 -a 和 -m 选项,分别用来标示作者和备注信息;
1 |
|
在创建一个容器时,如果使用了 entrypoint 选项,它的参数将在容器创建后被执行;如果没有指定 entrypoint 选项,则原本的默认命令会被执行;当有了 entrypoint 后,如果基于当前容器新建镜像,则新镜像会默认继承这个 entrypoint 选项,成为基于新镜像创建的容器的默认命令;
其实不仅是 entrypoint 会被记录下来,旧容器中的以下内容也会被记录进行新镜像,包括:(如果没有指定,则继承原镜像)
- 所有的环境变量
- 工作目录
- 暴露的端口集合
- 所有的卷定义
- 命令和参数
- 容器入口点
7.2 深入 docker 镜像和层
联合文件系统的层非常类似 git 中的每次 commit,它只是对新的修改进行标记,而并不是真的去删除旧的文件;而在读取的时候,通过从最上层开始读取,就可以保证读取的是最新的文件;
这种方法的缺点是随着改动次数的增加,镜像将不可避免的变得越来越大,即使改动是删除里面的文件也是如此;因为文件并没有被真正的删除,而只是增加了一层,然后标记了删除而已;
为了解决镜像变得臃肿的问题,更好的解决办法是使用分支,这跟 git 的版本管理是一样的;但是,两个分支有可能在后续的某个地方存在很多相同的操作,这样不可避免会有很多重复的工作;为了解决这个问题,办法是使用 dockerfile 来记录对镜像的改动,从而是使得镜像的构建工作变得自动化,同时也减少出错的可能性,办法是使用导出和导入扁平文件系统;
7.3 导出和导入扁平文件系统
导出方法
- 基于镜像创建容器,记得带上 For Export 命令;
- 使用 docker export 进行导出;
1 |
|
导入方法
- 将文件打包成一个压缩文件
- 使用 docker import 将其导入并生成一个新镜像
1 |
|
小结:通过导入的方法新创建的镜像的优点是它会非常小,因为它会将所有无用的文件都剔除掉,使得文件层被打扁到只剩下一层;缺点也在于此,它将使得层的复用变得无效
7.4 版本控制的最佳实践
使用注释+版本号结合的方式进行版本说明,这样可以减少很多不必要的误会;
第8章 自动化构建和高级镜像设置
刚刚才发现之前对 dockerfile 存在误解,以为对 dockerfile 进行修改之后的重新构建都是从零开始;但其实不是的,它会复用之前缓存的所有层;
使用 –no-cache 选项可以禁止缓存使用,但这通常不是明智的选择,除非可以确保构建过程中不会发生任何问题;
8.1 使用 Dockerfile 打包 Git
Dockerfile 总共有14个指令,目前用到的包括:FROM, RUN, MAINTAINER, ENTRYPOINT 等;
8.2 Dockerfile 入门
构建指令的在线文档:https://docs.docker.com/reference/builder/
8.2.1 元数据指令
ENV 可以用来设置镜像的环境变量,并且在 dockerfile 中,这些环境变量能够被引用;
LABEL 可用来设置镜像元数据的键值对;
WORKDIR 可用来设置工作目录;若该指定的工作目录不存在,则会被自动创建;
EXPOSE 可用来设置对外暴露的端口;
ENTRYPOINT 可用来设置在容器启动时,需要被运行的可执行程序;它有两种格式:
- shell 格式:类似普通的 shell 命令,以空格分隔参数;
- exec 格式:使用字符串数组来放置命令参数;
ENTRYPOINT 如果指向不存在的文件,会导致容器启动失败,但是它可以起到复用的作用,避免让后续基于该镜像创建的子镜像之间重复构建相同层;
如果使用 shell 格式,会导致 CMD 指令的其他参数,或者 docker run 命令的参数被忽略,因此 shell 格式的灵活性有所限制;
USER 指令可用来设置默认的用户组和用户名;
8.2.2 文件系统指令
COPY 可用来复制主机上的文件到镜像中,它的副作用是会将文件的所有者更改为 root,因此,在复制完以后,如有需要,可使用 RUN 指令更改文件的所有者;
COPY 指令同样也支持 shell 和 exec 两种网格,一般建议使用 exec 风格,因为 shell 风格不支持参数字符串中带有空格;
VOLUME 用来设置存储卷,作用同 docker run 指令中的 –volume 参数;不过此处的 volume 只能创建管理存储卷,而不能创建挂载存储卷;
CMD 作用有点类似 ENTRYPOINT,不同点在于:
- 若已经设置 ENTRYPOINT 时,CMD 将为 ENTRYPOINGT 提供参数;
- 若没有设置 ENTRYPOINT 时,CMD 默认调用命令 /bin/bash,将为其提供参数;
1 |
|
ADD 作用类似 COPY,不同点在于:
- 如果指定一个 URL,它会拉取远程文件;
- 如果指定一个压缩包,它会自动提取其中的源文件;
8.3 注入下游镜像在构建时发生的操作
ONBUILD 是一个非常特别的指令,它的内容不会在构建当前镜像时被执行,而只会在被引用构建其他镜像时,才会被执行;
1 |
|
1 |
|
(为什么要这么做呢?)
8.4 使用启动脚本和多进程容器
8.4.1 验证环境是否满足条件
在容器启动时,使用启动脚本检查一下相关环境条件是否已经满足,若不满足,尽早及时报错;一般需要验证的内容包括:
- 需要使用的链接或别名
- 环境变量
- 网络访问可用性
- 网络端口可用性
- 根文件系统的挂载参数
- 存储卷
- 当前用户名或组
一般来说,启动脚本可以使用任意语言来写;但如果是为了让镜像最小化,则一般使用 shell 语言来写,因为 shell 是自带的;
8.4.2 初始化进程
Unit 系统在启动时一般会启动一个 init 初始化进程,由它来启动所有的系统服务;因此,也可以依托于 init 进程,来进行容器的初始化设置;
有很多现成的 init 工具,每种都有相应的优缺点和适用场景,需要根据实际情况进行选择;一般需要考虑的因素包括:
- 可能产生的额外依赖;
- 文件大小;
- init 进程如何传递信号量到子进程;
- 需要的用户权限;
- 是否支持监控和重启;
- 如何清理僵尸进程;
常用工具包括:runit, supervisord, busybox init, daemon 等;
8.5 加固应用镜像
加固镜像的常用方法:
- 最小化镜像的大小:包含的组件越少,漏洞可能越小;
- 强制基于某个特定镜像来构建;
- 强制容器使用合适的默认用户;
- 去除提权为 root 用户的可能;
8.5.1 内容可寻址镜像标识符
构建指令 FROM 的参数如果是镜像名+标签,这种方式有一个缺点,即如果原始镜像被改动过了,单纯从镜像名和标签上面有可能是看不出来的(当标签没有更新的时候);解决这个问题的办法是将镜像名更改为镜像 ID,这样就可以确保基础镜像的唯一性;
8.5.2 用户权限
镜像作者唯一能够做的是给镜像创建用户和用户组,以提高安全性;尽管如此,容器创建者是有绝对权限覆盖镜像作者的配置的;
有些应用在启动时,是需要 root 权限的,例如 postgres;如果容器的 USER 已经设置为非管理员,则可以使用 su 或 sudo 来启动应用进程;
如果构建的镜像是用来运行某个特定的应用程序,则一般应尽量削减容器中应用的权限;
8.5.3 SUID 和 SGID 权限
如果一个文件设置了 SUID 和 SGID 权限,则它可以让原来没有访问权限的进程,有权限访问该文件;解决的办法有两个:
- 删除该文件;
- 取消该文件的 SUID 和 SGID 权限设置;
1 |
|
第9章 公有软件和私有软件分发
9.1 选择一个分发方法
没有标准唯一的分发方法,只能根据项目的不同需求,选择最合适的一种方法;每种方法都有相应的优缺点;
有很多的选择维度,需要根据实际情况进行权衡;
9.2 通过托管 registry 发布
9..2.1 使用公有仓库的发布步骤
- docker login 登录仓库
- docker push 推送镜像
9.2.2 使用自动构建进行发布
其基本原理是使用 git 作为镜像构建指令的版本管理工具,然后使用 webhook 调用 docker hub 的接口,之后 docker 下载最新的构建指令版本,进行自动化构建,完成后推送到 docker hub 上面;
9.2.3 私有托管仓库
使用私有仓库的方式跟公有仓库完全一样,唯一的区别是下载镜像前需要登录;私有仓库虽然对公众不可见,但对提供 registry 服务的公司是可见的,因此可能不适合用于高度机密的场景;
9.3 通过私有 registry 发布
创建私有 registry 也很简单,只需从 docker hub 上下载镜像、启动容器、暴露接口,这样就可以进行访问了;
9.4 镜像的手动分发
所谓的手动分发,其实就是将镜像当作一个文件来处理;通过使用 docker 的导入导出功能,进行文件的分发;
9.5 分发镜像代码
这种方法就与 docker registry 分发机制无关了,只需使用常见的版本控制工具(如 git),就可以完成分发工作了;使用者下载最新版本的 dockerfile,然后在本地自动构建镜像;
第10章 运行自定义 Registry
第3部分 多容器和多主机环境
第11章 Docker Compose 部署
11.1 Docker Compose
docker-compose 使用 yaml 格式的文件来声明多个容器的创建过程及相互的依赖关系;
yml 文件可以配合 dockerfile 一起使用;前者用来定义依赖关系,后者用来定义镜像的构建细节;
1 |
|
1 |
|
若当前目录下的 docker-compose.yml 文件定义的容器已经创建,再次运行 docker-compose up,它会删除原来的镜像,并重新创建;
如果只需重新构建其中一个或多个容器,则可以使用 docker-compose build 命令;
感觉 docker-compose 下的命令跟原来的 docker 貌似大致差不多
docker-compose rm -vf 不能强制停止容器,需要先 stop;
CentOS 安装 Docker-compose
1 |
|
11.2 环境内的迭代
11.2.1 构建、启动、重启服务
1 |
|
11.2.2 服务伸缩和删除
1 |
|
如果容器内的端口被映射到主机的 0 号端口上,则表示自动选择一个主机上可用的端口进行映射;相对手工指定一个固定的主机端口,这种做法的好处是,当批量创建多个容器以提高并发时,不会出现冲突;
1 |
|
11.2.3 迭代和持久化
发现一个问题,即 docker-compose 项下的命令,都是要读取当前目录中的 yaml 配置文件后,才进行操作的;因此,如果在配置文件下变更某个镜像名称,有可能导致基于该镜像创建的容器,变成一个孤立的容器,不再受到 docker-compose 管理;
此时只能使用 docker 命令进行手工管理了;或者除非重新将它添加到 yaml 文件中才行;
11.2.4 网络和连接
当单独重启整个 compose 的单个服务时,由于重启后 IP 地址发生变化,因此可能导致其它依赖该容器的服务变成不可用;有两种解决办法:
- 重启整个 compose 中的所有容器;
- 使用动态解析;
11.3 深入 Compose YAML 文件
11.3.1 启动前的构建、环境变量、元数据、网络设置
build 用来指定 Dockerfile 文件所在的目录;
dockerfile 可用来指定 Dockerfile 文件的具体名称;
environment 用来设置环境变量;
labels 用来设置容器的元数据信息;
expose 声明容器暴露的接口,该值可为列表;
ports 声明与主机的端口映射;该值可为列表;
link 用来声明对其他容器的依赖;该值可为列表;
depends_on 用来指定依赖的其他镜像;
11.3.2 源镜像引用、数据卷
image 用来指定要引用的源镜像,最好使用 ID 引用,而非名称引用;
volumes 用来设置存储卷;
volumes_from 用来设置存储卷引用;
restart 用来设置重启选项;
command 用来指定要运行的命令;
11.3.3 YAML 配置复用
compose.yaml 支持多个配置文件的叠加,这样可以实现根据不同的环境(如测试、生产等),使用不同的构建方案,并实现基础配置的复用;
比如可以做一个基础的配置文件(如 docker-compose.yml),然后就不同的配置选项,各自做一份配置文件,如 docker-compose.test.yml 和 docker-compose.prod.yml;这样 docker-compose.yml 可以实现复用;
在运行 docker-compose up 进行启动时,通过 -f 选项,指定要使用的相应配置文件即可;
第12章 Docker Machine 和 Swarm 集群
前面的11章的所有实现,都是在单台机器上面;但如果需要将服务部署在不同机器上面时,就需要引入新的工具了;目前主流的多主机容器编排工具有:Swarm, Mesos, Kubernates 等,三者都有各自的优缺点和擅长的使用场景;
12.1 Docker Machine 简介
docker machine 用来在多个主机上安装和管理 docker engine;
1 |
|
只有每台主机都安装了 docker 引擎后,后续使用 swarm 进行集群的管理才具备可行性;
当有多台机器时,每次对其中一台机器进行操作,都需要有一个切换的动作,即将当前默认连接的机器,切换成要进行操作的机器;当有很多机器时,这种操作方式的工作量很大,貌似非常不人性;
12.2 Swarm 简介
swarm 管理的对象是集群,集群由若干数量的主机组成,里面的机器有两种类型,一种是管理节点 manager,一种是工作节点 node;
集群对外提供服务,当这个服务被外部访问时,如果请求到达的节点,实际并没有运行提供该服务的容器,则该请求会被路由到提供该服务的节点上;
swarm 创建集群的步骤
1. 创建集群的标识符
1 |
|
2. 创建集群节点
1 |
|
当节点运行在 swarm 集群模式下时,运行有些 docker 命令将显示不一样的效果,命令将作用于整个集群,而不是单个节点
1 |
|
12.3 Swarm 调度
Swarm 提供了三种不同的调度算法,适用于不同的业务场景;
通过 –swarm-strategy 选项用来指定算法
12.3.1 Spread 算法
该算法按照每个节点当前运行的容器数量进行排名,数量少的优先安排;数量相等的则随机选一个;
但这种算法有一个缺点,即每个容器所缺的资源是不一样的,单纯的按容器数量来排名,并不一定代表资源被合理利用;
12.3.2 用过滤器调整 spread 的调度
过滤器的原理是通过设置一些约束条件,来减少随机分配的范围,避免随机分配可能带来的资源配置不均匀;
可以用来做为过滤条件的信息有
- 手动的标签
- 环境变量
- 容器元数据;
12.3.3 BinPack 算法
Spread 算法是基于已有的节点进行容器分配的,其目标是平均荷载最小化,这意味着当节点过多时,会存在很多资源浪费;
BinPack 算法的目标是确保资源利用最大化,即仅在已有节点不能创建更多容器时,才会将容器安排在新节点上面;
每个容器需要多少资源,正常情况下是不知道的,因此,为了能够让 BinPack 发挥作用,好的作法是给每种容器标注资源使用限制;
BinPack 的策略可以让其实现集群自动伸缩,但是它付出的代价是特征一定的可靠性;
12.3.4 随机调度算法
随机算法的策略是啥也不管,无为而治,所以暂时还不知道哪种业务场景特别使用这个算法;