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
docker help # 查看所有可用的命令
docker help cp # 查看单个命令 cp 的使用帮助

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
3
4
5
MAILER_CID = $(docker run -d dockerinaction/ch2_mailer)
WEB_CID = $(docker create nginx)
AGENT_CID = $(docker create --link $WEB_CID:insideweb \
--link $MAILER_CID:insidemailer \
dockerinaction/ch2_agent

从软件部署的角度来看,开发应用程序时,应该尽量减少对环境的信赖,这样可以提高程序的扩展性,并降低维护的复杂度;

2.5 构建与环境无关的系统

2.5.1 只读文件系统

创建只读容器的好处是可以防止出现有意或无意的破坏;同时,由于数据与程序分离,意味着当程序出现损坏时,可以快速再启动一个新的,而无需担心里面的数据恢复问题;

通过在 docker run 中使用 –read-only 选项,即可创建只读容器;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
SQL_CID = $(docker create -e MYSQL_ROOT_PASSWORD=ch2demo mysql:5)

docker start $SQL_CID

MAILER_CID = $(docker run -d dockerinaction/ch2_mailer)

docker start $MAILER_CID

WP_CID = $(docker create --link $SQL_CID:mysql -p 80 \
-v /run/lock/apache2/ -v /run/apaches/ \
--read-only wordpress:4)

docker start $WP_CID

AGENT_CID = $(docker create --link $WP_CID:insideweb \
--link $MAILER_CID:insidemailer \
dockerinaction/ch2_agent

docker start $AGENT_CID
2.5.1 环境变量注入

创建容器时,使用 –env 选项,可以为容器设置环境变量的值;而容器中的应用程序在运行时,可以依赖这些注入的环境变量;

脚本示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
DB_CID = $(docker create -e MYSQL_ROOT_PASSWORD=ch2demo mysql:5)

docker start $DB_CID

MAILER_CID = $(docker run -d dockerinaction/ch2_mailer)

docker start $MAILER_CID

if [! -n "$CLIENT_CID"]; then
echo "client id not set"
exit 1
fi

WP_CID = $(docker create \
--link $DB_CID:mysql \
--name wp_$CLIENT_ID \
-p 80 \
-v /run/lock/apache2/ -v /run/apaches/ \
-e WORDPRESS_DB_NAME=$CLIENT_ID \
--read-only wordpress:4)

docker start $WP_CID

AGENT_CID = $(docker create \
--name agent_$CLIENT_ID \
--link $WP_CID:insideweb \
--link $MAILER_CID:insidemailer \
dockerinaction/ch2_agent

docker start $AGENT_CID

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 run -v host-dir:/container-dir:ro <image-name>

除绑定本机目录为容器中的存储卷外,也要以绑定单个文件到容器中,这样可以只替换容器中的单个文件,而不是覆盖整个目录;

绑定本机目录的缺点

  • 这种该种方式制造了容器与本机环境的耦合,降低了可移植性;如果要移植的目录系统没有相应的本机目录,将会导致出错;
  • 当创建多个容器时,这种绑定方式可能会带来冲突;

由 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
node /app/app.js

之后,每次需要执行代码时,只需将主机的代码目录挂载到容器中的 /app 项下即可;

基本原理:将不同的文件注入容器中的相同位置,来改变容器的行为变现;

场景B

需要对容器中的数据进行分析,但是源镜像中不带有相应的工具应用程序;此时可以通过创建新容器,并挂载一个含有相应工具的目录到容器中,然后通过 docker exec 就可以在容器中调用这些工具了;

a6dc608d5b22a00d56b64c2b016b3be7.png

场景C

配置文件放在某个镜像中,不同环境(开发/生产/测试)各放一个文件夹,创建不同的卷容器,复制不同文件夹下的配置文件;实际的应用程序关联相应的卷容器即可;

c57ed5ba1fb522c9ecc054a657373f9b.png

第5章 网络访问

5.1 网络相关的背景知识

本地回环地址:总共只有一个 IP 地址,从该地址发出的信息,不加处理的立即发送回给该地址,而不会访问其他地址;本地回环主要用来和本地上的其他进程进行通信;

广播地址:它是一个网络地址,用来和网络中的其他主机进行通讯;

NAT:net address translation,网络地址转换;当 IP 数据包通过路由器时,对 IP地址进行重写;当有多台主机共用一个公网 IP时,需要使用这个技术;因此它可以用来缓解 IPv4 地址紧张的问题;缺点是会造成一定程度的性能损失;

Docker 会创建一个网桥,容器可通过该网桥,连接到主机上面的网络;

5.2 Docker 的网络

Docker 创建的网桥很像一个家庭路由器,它接收来自外部的数据,然后再分发给内部相应的容器;

Docker 总共有四种网络类型,每个容器都属于其中的一种;它们之间的区别在于隔离程度不同;

536b4d222f801bb57a05a71d07770520.png

5.3 Closed 容器

在 Closed 类型的容器中,进程只能和容器中的其他进程通信,不能和容器外的进程通讯,也不能被容器外的其他进程访问;因此它的隔离程度最高;

选项:–net none

1
docker run --rm --net none alpine:latest ip addr

5.4 Bridged 容器

所有连接到 docker 网桥的接口,都是 docker 内部虚拟网络的一部分;因此这些接口之间可以相互访问,同时,也可以通过网桥和外部进行通讯;

选项:–net bridge(默认选项,因此可不写)

1
docker run --rm --net bridge alpine:latest ip addr
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
docker run --expose 8000 -P <image_name>
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
2
docker run -d --name DATA --expose 3306 dockerinaction/mysql \
service mysql start
1
2
docker run -d --name APP --link DATA:db dockerinaction/webapp \
startapp.sh -db tcp://db:3306

为新创建容器添加链接会有如下的效果:

  • 创建关于被链接的目标容器的环境变量;
  • 添加链接的别名和目标容器的 IP 地址到 DNS 文件中;
  • 添加防火墙规则以实现二者的通信(如需)
5.7.2 链接别名

别名不可避免会带来冲突或者失败的可能,因为各个容器的创建者有可能未就别名形成共同的约定;

解决的办法之一:在容器启动时,内置一段自动运行的脚本,来检测相关的依赖条件是否都已经成立

1
2
!/bin/sh
if [ -z ${DATABASE_PORT+x} ] then echo "Link alias 'database' was not set!" exit else exec "$@" fi

原理:当使用 –link 链接选项时,docker 会在新建的容器中,创建相应的以链接的目标容器别名为前缀的一系列环境变量,例如 –link mydb:database 的别名为 database,则容器中的环境变量的前缀为 DATABASE;

5.7.3 环境变量的改动

链接所创建的一系列环境变量如下:

  • 别名_PORT_端口号_协议名_PORT
  • 别名_PORT_端口号_协议名_ADDR
  • 别名_PORT_端口号_协议名_PROTO
  • 别名_PORT_端口号_协议名
  • 别名_PORT
  • /本容器名/被链接的容器名

3c6b306c7da5a14defe56b4c4991145e.png

5.7.4 链接的本质和缺点

缺点:链接的原理是通过创建目标容器时,进行查询并保存源容器的信息来实现的;因此,当源容器重启后,目标容器保存的仍然是源容器的旧信息,这时将导致调用源容器的服务失败;

解决方法:使用 DNS 动态解析;

第6章 隔离–限制危险

6.1 资源分配

6.1.1 内存限制

–memory <数字><单位> 选项实现内存使用限制;

1
docker run -d --name mylab --memory 256m --cpu-shares 1024 <镜像名>

需要考虑两个问题

  • 程序的正常运行最少需要分配多少内存?
  • 系统有多少可分配的内存?
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
docker run --rm --user nobody busybox:latest
1
2
# 用户名+用户组
docker run --rm --user nobody:default busybox:latest
1
2
# 用户ID+用户组ID
docker run --rm --user 10000:20000 busybox:latest
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
docker commit -a "@ccw" -m "Added something" my_container_name new_image_name

在创建一个容器时,如果使用了 entrypoint 选项,它的参数将在容器创建后被执行;如果没有指定 entrypoint 选项,则原本的默认命令会被执行;当有了 entrypoint 后,如果基于当前容器新建镜像,则新镜像会默认继承这个 entrypoint 选项,成为基于新镜像创建的容器的默认命令;

其实不仅是 entrypoint 会被记录下来,旧容器中的以下内容也会被记录进行新镜像,包括:(如果没有指定,则继承原镜像)

  • 所有的环境变量
  • 工作目录
  • 暴露的端口集合
  • 所有的卷定义
  • 命令和参数
  • 容器入口点

7.2 深入 docker 镜像和层

联合文件系统的层非常类似 git 中的每次 commit,它只是对新的修改进行标记,而并不是真的去删除旧的文件;而在读取的时候,通过从最上层开始读取,就可以保证读取的是最新的文件;

这种方法的缺点是随着改动次数的增加,镜像将不可避免的变得越来越大,即使改动是删除里面的文件也是如此;因为文件并没有被真正的删除,而只是增加了一层,然后标记了删除而已;

为了解决镜像变得臃肿的问题,更好的解决办法是使用分支,这跟 git 的版本管理是一样的;但是,两个分支有可能在后续的某个地方存在很多相同的操作,这样不可避免会有很多重复的工作;为了解决这个问题,办法是使用 dockerfile 来记录对镜像的改动,从而是使得镜像的构建工作变得自动化,同时也减少出错的可能性,办法是使用导出和导入扁平文件系统;

7.3 导出和导入扁平文件系统

导出方法
  • 基于镜像创建容器,记得带上 For Export 命令;
  • 使用 docker export 进行导出;
1
2
3
docker run --name export-test myimage:latest ./echo For Export
docker export --output contents.tar export-test
docker rm export-test
导入方法
  • 将文件打包成一个压缩文件
  • 使用 docker import 将其导入并生成一个新镜像
1
2
3
tar -cf static_hello.tar hello.o
docker import - myimage < static_hello.tar
# import 后面的中横杠表示从标准输入读取导入,此处也可以替换为 URL 来表示从远程导入

小结:通过导入的方法新创建的镜像的优点是它会非常小,因为它会将所有无用的文件都剔除掉,使得文件层被打扁到只剩下一层;缺点也在于此,它将使得层的复用变得无效

7.4 版本控制的最佳实践

使用注释+版本号结合的方式进行版本说明,这样可以减少很多不必要的误会;

6a601711abefd4fbc8b888b5e2a0c8ed.png

第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
2
ENTRYPOINT ["app/mailer.sh"] # 指定了一个入口点的待运行程序
CMD ["/var/log/mailer.log"] # 为入口点的程序提供了一个参数,指明日志文件路径

ADD 作用类似 COPY,不同点在于:

  • 如果指定一个 URL,它会拉取远程文件;
  • 如果指定一个压缩包,它会自动提取其中的源文件;

8.3 注入下游镜像在构建时发生的操作

ONBUILD 是一个非常特别的指令,它的内容不会在构建当前镜像时被执行,而只会在被引用构建其他镜像时,才会被执行;

1
2
3
4
5
FROM busybox:latest
WORKDIR /app
RUN touch /app/base-evidence
# 以下的 onbuild 指令被构建子镜像时处理
ONBUILD RUN ls -al /app
1
2
3
FROM dockerinaction/ch8_onbuild
RUN touch downstream-evidence
RUN ls -al .

(为什么要这么做呢?)

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
2
3
RUN for i in $(find / -type f (-perm +6000 -o -perm +2000)); 
do chmod ug-s $i;
done

第9章 公有软件和私有软件分发

9.1 选择一个分发方法

没有标准唯一的分发方法,只能根据项目的不同需求,选择最合适的一种方法;每种方法都有相应的优缺点;

f6c80f19cb278cc546575f2cdbd39213.png

有很多的选择维度,需要根据实际情况进行权衡;

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
2
3
4
5
6
7
8
9
10
11
12
# Filename: docker-compose.yml
wordpress:
image: wordpress:4.2.2
links:
- "db:mysql"
ports:
- "8080:80"

db:
image: mariadb
environment:
MYSQL_ROOT_PASSWORD: example
1
2
3
4
5
6
# 启动当前目录下由 docker-compose.yml 文件定义的容器
docker-compose up
# 查看所有容器的日志
docker-compose logs
# 删除当前目录下由 docker-compose.yml 文件创建的容器
docker-compose rm -v

若当前目录下的 docker-compose.yml 文件定义的容器已经创建,再次运行 docker-compose up,它会删除原来的镜像,并重新创建;

如果只需重新构建其中一个或多个容器,则可以使用 docker-compose build 命令;

感觉 docker-compose 下的命令跟原来的 docker 貌似大致差不多

docker-compose rm -vf 不能强制停止容器,需要先 stop;

CentOS 安装 Docker-compose
1
2
3
4
# 版本号 1.25.4 需要根据实际情况更新
sudo curl -L "https://github.com/docker/compose/releases/download/1.25.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose


11.2 环境内的迭代

11.2.1 构建、启动、重启服务
1
2
3
4
5
6
7
8
9
10
11
# 构建镜像(但不拉取)
docker-compose build
# 拉取镜像(但不构建)
docker-compose pull
# 启动
docker-compose up -d [某个镜像或全部]
# 不重启所有依赖的容器
docker-compose up --no-dep -d <某个镜像>
# 当某个容器中的应用程序代码有修改时,只需重新构建该镜像,并再次 docker-compose up 即可;
docker-compose build <镜像名>
docker-compose up -d
11.2.2 服务伸缩和删除
1
2
3
# 创建多个应用程序的容器,以提高并发能力
docker-compose scale <镜像名>=<扩展数量>
# 扩展数量为 1,则会删除多余的容器(如有)

如果容器内的端口被映射到主机的 0 号端口上,则表示自动选择一个主机上可用的端口进行映射;相对手工指定一个固定的主机端口,这种做法的好处是,当批量创建多个容器以提高并发时,不会出现冲突;

1
2
# 删除整个服务
docker-compose down
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
2
3
4
5
docker-machine create --driver virtualbox host1
docker-machine start host1
docker-machine stop host1
docker-machine kill host1
docker-machine rm host1

只有每台主机都安装了 docker 引擎后,后续使用 swarm 进行集群的管理才具备可行性;
当有多台机器时,每次对其中一台机器进行操作,都需要有一个切换的动作,即将当前默认连接的机器,切换成要进行操作的机器;当有很多机器时,这种操作方式的工作量很大,貌似非常不人性;

12.2 Swarm 简介

swarm 管理的对象是集群,集群由若干数量的主机组成,里面的机器有两种类型,一种是管理节点 manager,一种是工作节点 node;

集群对外提供服务,当这个服务被外部访问时,如果请求到达的节点,实际并没有运行提供该服务的容器,则该请求会被路由到提供该服务的节点上;

swarm 创建集群的步骤
1. 创建集群的标识符
1
2
3
4
5
6
7
# 创建一个新的本地 docker engine
docker-machine --driver virtualbox local
# 切换到新建的 docker engine
eval "$(docker-machine env local)"
# 使用 swarm 镜像创建容器,调用 create 命令创建标识符(token)
docker run --rm swarm create
# 此处假设得到的 token 值为 "abcdef1234567890"
2. 创建集群节点
1
2
3
4
5
6
# 创建管理节点 machine0
docker-machine create --driver virtualbox --swarm --swarm-discovery --swarm-master token://abcdef1234567890 machine0-manager
# 创建工作节点 machine1
docker-machine create --driver virtualbox --swarm --swarm-discovery token://abcdef1234567890 machine1
# 创建工作节点 machine2
docker-machine create --driver virtualbox --swarm --swarm-discovery token://abcdef1234567890 machine2

当节点运行在 swarm 集群模式下时,运行有些 docker 命令将显示不一样的效果,命令将作用于整个集群,而不是单个节点

1
2
3
4
5
# 显示集群信息
docker info
# 拉取镜像到每一个节点上
docker pull <镜像名>
# docker rm <容器名> 将会自动在某个节点上删除容器;

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 随机调度算法

随机算法的策略是啥也不管,无为而治,所以暂时还不知道哪种业务场景特别使用这个算法;


Docker 实战
https://ccw1078.github.io/2020/01/02/Docker 实战/
作者
ccw
发布于
2020年1月2日
许可协议