容器配置
容器网络
容器网络实质上也是由 Docker 为应用程序所创造的虚拟环境的一部分,它能让应用从宿主机操作系统的网络环境中独立出来,形成容器自有的网络设备、IP 协议栈、端口套接字、IP 路由表、防火墙等等与网络相关的模块。
在 Docker 网络中,有三个比较核心的概念,也就是:沙盒 ( Sandbox )、网络 ( Network )、端点 ( Endpoint )。
- 沙盒提供了容器的虚拟网络栈,也就是之前所提到的端口套接字、IP 路由表、防火墙等的内容。其实现隔离了容器网络与宿主机网络,形成了完全独立的容器网络环境。
- 网络可以理解为 Docker 内部的虚拟子网,网络内的参与者相互可见并能够进行通讯。Docker 的这种虚拟网络也是于宿主机网络存在隔离关系的,其目的主要是形成容器间的安全通讯环境。
- 端点是位于容器或网络隔离墙之上的洞,其主要目的是形成一个可以控制的突破封闭的网络环境的出入口。当容器的端点与网络的端点形成配对后,就如同在这两者之间搭建了桥梁,便能够进行数据传输了。
这三者形成了 Docker 网络的核心模型,也就是容器网络模型 ( Container Network Model )。
浅析 Docker 的网络实现
容器网络模型为容器引擎提供了一套标准的网络对接范式,而在 Docker 中,实现这套范式的是 Docker 所封装的 libnetwork 模块。
而对于网络的具体实现,在 Docker 的发展过程中也逐渐抽象,形成了统一的抽象定义。进而通过这些抽象定义,便可以对 Docker 网络的实现方式进行不同的变化。
目前 Docker 官方为我们提供了五种 Docker 网络驱动,分别是:Bridge Driver、Host Driver、Overlay Driver、MacLan Driver、None Driver。
其中,Bridge 网络是 Docker 容器的默认网络驱动,简而言之其就是通过网桥来实现网络通讯 ( 网桥网络的实现可以基于硬件,也可以基于软件 )。而 Overlay 网络是借助 Docker 集群模块 Docker Swarm 来搭建的跨 Docker Daemon 网络,我们可以通过它搭建跨物理主机的虚拟网络,进而让不同物理机中运行的容器感知不到多个物理机的存在。Bridge Driver 和 Overlay Driver 在开发中使用频率较高。
容器互联
由于 Docker 提倡容器与应用共生的轻量级容器理念,所以容器中通常只包含一种应用程序,但我们知道,如今纷繁的系统服务,没有几个是可以通过单一的应用程序支撑的。拿最简单的 Web 应用为例,也至少需要业务应用、数据库应用、缓存应用等组成。也就是说,在 Docker 里我们需要通过多个容器来组成这样的系统。
而这些互联网时代的应用,其间的通讯方式主要以网络为主,所以打通容器间的网络,是使它们能够互相通讯的关键所在。
要让一个容器连接到另外一个容器,我们可以在容器通过 docker create
或 docker run
创建时通过 --link
选项进行配置。
例如,这里我们创建一个 MySQL 容器,将运行我们 Web 应用的容器连接到这个 MySQL 容器上,打通两个容器间的网络,实现它们之间的网络互通。
$ sudo docker run -d --name mysql -e MYSQL_RANDOM_ROOT_PASSWORD=yes mysql
$ sudo docker run -d --name webapp --link mysql webapp:latest
容器间的网络已经打通,那么我们要如何在 Web 应用中连接到 MySQL 数据库呢?Docker 为容器间连接提供了一种非常友好的方式,我们只需要将容器的网络命名填入到连接地址中,就可以访问需要连接的容器了。
假设我们在 Web 应用中使用的是 JDBC 进行数据库连接的,我们可以这么填写连接。
String url = "jdbc:mysql://mysql:3306/webapp";
在这里,连接地址中的 mysql 就好似我们常见的域名解析,Docker 会将其指向 MySQL 容器的 IP 地址。
看到这里,读者们有没有发现 Docker 在容器互通中为我们带来的一项便利,也就是我们不再需要真实的知道另外一个容器的 IP 地址就能进行连接。再具体来对比,在以往的开发中,我们每切换一个环境 ( 例如将程序从开发环境提交到测试环境 ),都需要重新配置程序中的各项连接地址等参数,而在 Docker 里,我们并不需要关心这个,只需要程序中配置被连接容器的别名,映射 IP 的工作就交给 Docker 完成了。
暴露端口
需要注意的是,虽然容器间的网络打通了,但并不意味着我们可以任意访问被连接容器中的任何服务。Docker 为容器网络增加了一套安全机制,只有容器自身允许的端口,才能被其他容器所访问。
这个容器自我标记端口可被访问的过程,我们通常称为暴露端口。我们在 docker ps
的结果中可以看到容器暴露给其他容器访问的端口。
$ sudo docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
95507bc88082 mysql:5.7 "docker-entrypoint.s…" 17 seconds ago Up 16 seconds 3306/tcp, 33060/tcp mysql
这里我们看到,MySQL 这个容器暴露的端口是 3306 和 33060。所以我们连接到 MySQL 容器后,只能对这两个端口进行访问。
端口的暴露可以通过 Docker 镜像进行定义,也可以在容器创建时进行定义。在容器创建时进行定义的方法是借助 --expose
这个选项。
$ sudo docker run -d --name mysql -e MYSQL_RANDOM_ROOT_PASSWORD=yes --expose 13306 --expose 23306 mysql:5.7
这里我们为 MySQL 暴露了 13306 和 23306 这两个端口,暴露后我们可以在 docker ps
中看到这两个端口已经成功的打开。
$ sudo docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
3c4e645f21d7 mysql:5.7 "docker-entrypoint.s…" 4 seconds ago Up 3 seconds 3306/tcp, 13306/tcp, 23306/tcp, 33060/tcp mysql
容器暴露了端口只是类似我们打开了容器的防火墙,具体能不能通过这个端口访问容器中的服务,还需要容器中的应用监听并处理来自这个端口的请求。
通过别名连接
纯粹的通过容器名来打开容器间的网络通道缺乏一定的灵活性,在 Docker 里还支持连接时使用别名来使我们摆脱容器名的限制。
$ sudo docker run -d --name webapp --link mysql:database webapp:latest
在这里,我们使用 --link <name>:<alias>
的形式,连接到 MySQL 容器,并设置它的别名为 database。当我们要在 Web 应用中使用 MySQL 连接时,我们就可以使用 database 来代替连接地址了。
String url = "jdbc:mysql://database:3306/webapp";
管理网络
容器能够互相连接的前提是两者同处于一个网络中 ( 这里的网络是指容器网络模型中的网络 ),网络这个概念我们可以理解为 Docker 所虚拟的子网,而容器网络沙盒可以看做是虚拟的主机,只有当多个主机在同一子网里时,才能互相看到并进行网络数据交换。
当我们启动 Docker 服务时,它会为我们创建一个默认的 bridge 网络,而我们创建的容器在不专门指定网络的情况下都会连接到这个网络上。所以我们刚才之所以能够把 webapp 容器连接到 mysql 容器上,其原因是两者都处于 bridge 这个网络上。
我们通过 docker inspect
命令查看容器,可以在 Network 部分看到容器网络相关的信息。
$ sudo docker inspect mysql
[
{
## ......
"NetworkSettings": {
## ......
"Networks": {
"bridge": {
"IPAMConfig": null,
"Links": null,
"Aliases": null,
"NetworkID": "bc14eb1da66b67c7d155d6c78cb5389d4ffa6c719c8be3280628b7b54617441b",
"EndpointID": "1e201db6858341d326be4510971b2f81f0f85ebd09b9b168e1df61bab18a6f22",
"Gateway": "172.17.0.1",
"IPAddress": "172.17.0.2",
"IPPrefixLen": 16,
"IPv6Gateway": "",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"MacAddress": "02:42:ac:11:00:02",
"DriverOpts": null
}
}
## ......
}
## ......
}
]
这里我们能够看到 mysql 容器在 bridge 网络中所分配的 IP 地址,其自身的端点、Mac 地址,bridge 网络的网关地址等信息。
Docker 默认创建的这个 bridge 网络是非常重要的,理由自然是在没有明确指定容器网络时,容器都会连接到这个网络中。
创建网络
在 Docker 里,我们也能够创建网络,形成自己定义虚拟子网的目的。
docker CLI 里与网络相关的命令都以 docker network
开头,其中创建网络的命令是 docker network create
。
$ sudo docker network create -d bridge individual
通过 -d
选项我们可以为新的网络指定驱动的类型,其值可以是刚才我们所提及的 bridge、host、overlay、maclan、none,也可以是其他网络驱动插件所定义的类型。这里我们使用的是 Bridge Driver ( 当我们不指定网络驱动时,Docker 也会默认采用 Bridge Driver 作为网络驱动 )。
通过 docker network ls
或是 docker network list
可以查看 Docker 中已经存在的网络。
$ sudo docker network ls
NETWORK ID NAME DRIVER SCOPE
bc14eb1da66b bridge bridge local
35c3ef1cc27d individual bridge local
之后在我们创建容器时,可以通过 --network
来指定容器所加入的网络,一旦这个参数被指定,容器便不会默认加入到 bridge 这个网络中了 ( 但是仍然可以通过 --network bridge
让其加入 )。
$ sudo docker run -d --name mysql -e MYSQL_RANDOM_ROOT_PASSWORD=yes --network individual mysql:5.7
我们通过 docker inspect
观察一下此时的容器网络。
$ sudo docker inspect mysql
[
{
## ......
"NetworkSettings": {
## ......
"Networks": {
"individual": {
"IPAMConfig": null,
"Links": null,
"Aliases": [
"2ad678e6d110"
],
"NetworkID": "35c3ef1cc27d24e15a2b22bdd606dc28e58f0593ead6a57da34a8ed989b1b15d",
"EndpointID": "41a2345b913a45c3c5aae258776fcd1be03b812403e249f96b161e50d66595ab",
"Gateway": "172.18.0.1",
"IPAddress": "172.18.0.2",
"IPPrefixLen": 16,
"IPv6Gateway": "",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"MacAddress": "02:42:ac:12:00:02",
"DriverOpts": null
}
}
## ......
}
## ......
}
]
可以看到,容器所加入网络已经变成了 individual 这个网络了。
这时候我们通过 --link
让处于另外一个网络的容器连接到这个容器上,看看会发生什么样的效果。
$ sudo docker run -d --name webapp --link mysql --network bridge webapp:latest
docker: Error response from daemon: Cannot link to /mysql, as it does not belong to the default network.
ERRO[0000] error waiting for container: context canceled
可以看到容器并不能正常的启动,而 Docker 提醒我们两个容器处于不同的网络,之间是不能相互连接引用的。
我们来改变一下,让运行 Web 应用的容器加入到 individual 这个网络,就可以成功建立容器间的网络连接了。
$ sudo docker run -d --name webapp --link mysql --network individual webapp:latest
端口映射
刚才我们提及的都是容器直接通过 Docker 网络进行的互相访问,在实际使用中,还有一个非常常见的需求,就是我们需要在容器外通过网络访问容器中的应用。最简单的一个例子,我们提供了 Web 服务,那么我们就需要提供一种方式访问运行在容器中的 Web 应用。
在 Docker 中,提供了一个端口映射的功能实现这样的需求。
通过 Docker 端口映射功能,我们可以把容器的端口映射到宿主操作系统的端口上,当我们从外部访问宿主操作系统的端口时,数据请求就会自动发送给与之关联的容器端口。
要映射端口,我们可以在创建容器时使用 -p
或者是 --publish
选项。
$ sudo docker run -d --name nginx -p 80:80 -p 443:443 nginx:1.12
使用端口映射选项的格式是 -p <ip>:<host-port>:<container-port>
,其中 ip 是宿主操作系统的监听 ip,可以用来控制监听的网卡,默认为 0.0.0.0,也就是监听所有网卡。host-port 和 container-port 分别表示映射到宿主操作系统的端口和容器的端口,这两者是可以不一样的,我们可以将容器的 80 端口映射到宿主操作系统的 8080 端口,传入 -p 8080:80
即可。
我们可以在容器列表里看到端口映射的配置。
$ sudo docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
bc79fc5d42a6 nginx:1.12 "nginx -g 'daemon of…" 4 seconds ago Up 2 seconds 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp nginx
打印的结果里用 ->
标记了端口的映射关系。
管理和存储数据
Docker 里,容器运行的文件系统处于沙盒环境中,与外界其实是隔离的,那么我们又要如何在 Docker 中合理的通过文件与外界进行数据交换呢?
数据管理实现方式
Docker 容器中的文件系统于我们这些开发使用者来说,虽然有很多优势,但也有很多弊端,其中显著的两点就是:
- 沙盒文件系统是跟随容器生命周期所创建和移除的,数据无法直接被持久化存储。
- 由于容器隔离,我们很难从容器外部获得或操作容器内部文件中的数据。
当然,Docker 很好的解决了这些问题,这主要还是归功于 Docker 容器文件系统是基于 UnionFS。由于 UnionFS 支持挂载不同类型的文件系统到统一的目录结构中,所以我们只需要将宿主操作系统中,文件系统里的文件或目录挂载到容器中,便能够让容器内外共享这个文件。
由于通过这种方式可以互通容器内外的文件,那么文件数据持久化和操作容器内文件的问题就自然而然的解决了。
同时,UnionFS 带来的读写性能损失是可以忽略不计的,所以这种实现可以说是相当优秀的。
挂载方式
基于底层存储实现,Docker 提供了三种适用于不同场景的文件系统挂载方式:Bind Mount、Volume 和 Tmpfs Mount。
-
Bind Mount 能够直接将宿主操作系统中的目录和文件挂载到容器内的文件系统中,通过指定容器外的路径和容器内的路径,就可以形成挂载映射关系,在容器内外对文件的读写,都是相互可见的。
-
Volume 也是从宿主操作系统中挂载目录到容器内,只不过这个挂载的目录由 Docker 进行管理,我们只需要指定容器内的目录,不需要关心具体挂载到了宿主操作系统中的哪里。
-
Tmpfs Mount 支持挂载系统内存中的一部分到容器的文件系统里,不过由于内存和容器的特征,它的存储并不是持久的,其中的内容会随着容器的停止而消失。
挂载文件到容器
要将宿主操作系统中的目录挂载到容器之后,我们可以在容器创建的时候通过传递 -v
或 --volume
选项来指定内外挂载的对应目录或文件。
$ sudo docker run -d --name nginx -v /webapp/html:/usr/share/nginx/html nginx:1.12
使用 -v
或 --volume
来挂载宿主操作系统目录的形式是 -v <host-path>:<container-path>
或 --volume <host-path>:<container-path>
,其中 host-path 和 container-path 分别代表宿主操作系统中的目录和容器中的目录。这里需要注意的是,为了避免混淆,Docker 这里强制定义目录时必须使用绝对路径,不能使用相对路径。
我们能够指定目录进行挂载,也能够指定具体的文件来挂载,具体选择何种形式来挂载,大家可以根据具体的情况来选择。
当挂载了目录的容器启动后,我们可以看到我们在宿主操作系统中的文件已经出现在容器中了。
$ sudo docker exec nginx ls /usr/share/nginx/html
index.html
在 docker inspect
的结果里,我们可以看到有关容器数据挂载相关的信息。
$ sudo docker inspect nginx
[
{
## ......
"Mounts": [
{
"Type": "bind",
"Source": "/webapp/html",
"Destination": "/usr/share/nginx/html",
"Mode": "",
"RW": true,
"Propagation": "rprivate"
}
],
## ......
}
]
在关于挂载的信息中我们可以看到一个 RW 字段,这表示挂载目录或文件的读写性 ( Read and Write )。实际操作中,Docker 还支持以只读的方式挂载,通过只读方式挂载的目录和文件,只能被容器中的程序读取,但不接受容器中程序修改它们的请求。在挂载选项 -v
后再接上 :ro
就可以只读挂载了。
$ sudo docker run -d --name nginx -v /webapp/html:/usr/share/nginx/html:ro nginx:1.12
由于宿主操作系统文件挂载在权限允许的情况下能够挂载任何目录或文件,这给系统的安全性造成了一定的隐患,所以我们在使用 Bind Mount 的时候,一定要特别注意挂载的外部目录选择。当然,在保证安全性的前提下,有几种常见场景非常适合使用这种挂载方式。
-
当我们需要从宿主操作系统共享配置的时候。对于一些配置项,我们可以直接从容器外部挂载到容器中,这利于保证容器中的配置为我们所确认的值,也方便我们对配置进行监控。例如,遇到容器中时区不正确的时候,我们可以直接将操作系统的时区配置,也就是 /etc/timezone 这个文件挂载并覆盖容器中的时区配置。
-
当我们需要借助 Docker 进行开发的时候。虽然在 Docker 中,推崇直接将代码和配置打包进镜像,以便快速部署和快速重建。但这在开发过程中显然非常不方便,因为每次构建镜像需要耗费一定的时间,这些时间积少成多,就是对开发工作效率的严重浪费了。如果我们直接把代码挂载进入容器,那么我们每次对代码的修改都可以直接在容器外部进行。
挂载临时文件目录
Tmpfs Mount 是一种特殊的挂载方式,它主要利用内存来存储数据。由于内存不是持久性存储设备,所以其带给 Tmpfs Mount 的特征就是临时性挂载。
与挂载宿主操作系统目录或文件不同,挂载临时文件目录要通过 --tmpfs
这个选项来完成。由于内存的具体位置不需要我们来指定,这个选项里我们只需要传递挂载到容器内的目录即可。
$ sudo docker run -d --name webapp --tmpfs /webapp/cache webapp:latest
容器已挂载的临时文件目录我们也可以通过 docker inspect
命令查看。
$ sudo docker inspect webapp
[
{
## ......
"Tmpfs": {
"/webapp/cache": ""
},
## ......
}
]
挂载临时文件首先要注意它不是持久存储这一特性,在此基础上,它有几种常见的适应场景。
-
应用中使用到,但不需要进行持久保存的敏感数据,可以借助内存的非持久性和程序隔离性进行一定的安全保障。
-
读写速度要求较高,数据变化量大,但不需要持久保存的数据,可以借助内存的高读写速度减少操作的时间。
使用数据卷
除了与其他虚拟机工具近似的宿主操作系统目录挂载的功能外,Docker 还创造了数据卷 ( Volume ) 这个概念。数据卷的本质其实依然是宿主操作系统上的一个目录,只不过这个目录存放在 Docker 内部,接受 Docker 的管理。
在使用数据卷进行挂载时,我们不需要知道数据具体存储在了宿主操作系统的何处,只需要给定容器中的哪个目录会被挂载即可。
我们依然可以使用 -v
或 --volume
选项来定义数据卷的挂载。
$ sudo docker run -d --name webapp -v /webapp/storage webapp:latest
数据卷挂载到容器后,我们可以通过 docker inspect
看到容器中数据卷挂载的信息。
$ sudo docker inspect webapp
[
{
## ......
"Mounts": [
{
"Type": "volume",
"Name": "2bbd2719b81fbe030e6f446243386d763ef25879ec82bb60c9be7ef7f3a25336",
"Source": "/var/lib/docker/volumes/2bbd2719b81fbe030e6f446243386d763ef25879ec82bb60c9be7ef7f3a25336/_data",
"Destination": "/webapp/storage",
"Driver": "local",
"Mode": "",
"RW": true,
"Propagation": ""
}
],
## ......
}
]
这里我们所得到的信息与绑定挂载有所区别,除了 Type 中的类型不一样之外,在数据卷挂载中,我们还要关注一下 Name 和 Source 这两个信息。
其中 Source 是 Docker 为我们分配用于挂载的宿主机目录,其位于 Docker 的资源区域 ( 这里是默认的 /var/lib/docker ) 内。当然,我们并不需要关心这个目录,一切对它的管理都已经在 Docker 内实现了。
为了方便识别数据卷,我们可以像命名容器一样为数据卷命名,这里的 Name 就是数据卷的命名。在我们未给出数据卷命名的时候,Docker 会采用数据卷的 ID 命名数据卷。我们也可以通过 -v <name>:<container-path>
这种形式来命名数据卷。
$ sudo docker run -d --name webapp -v appdata:/webapp/storage webapp:latest
由于 -v
选项既承载了 Bind Mount 的定义,又参与了 Volume 的定义,所以其传参方式需要特别留意。前面提到了,-v
在定义绑定挂载时必须使用绝对路径,其目的主要是为了避免与数据卷挂载中命名这种形式的冲突。
虽然与绑定挂载的原理差别不大,但数据卷在许多实际场景下你会发现它很有用。
-
当希望将数据在多个容器间共享时,利用数据卷可以在保证数据持久性和完整性的前提下,完成更多自动化操作。
-
当我们希望对容器中挂载的内容进行管理时,可以直接利用数据卷自身的管理方法实现。
-
当使用远程服务器或云服务作为存储介质的时候,数据卷能够隐藏更多的细节,让整个过程变得更加简单。
共用数据卷
数据卷的另一大作用是实现容器间的目录共享,也就是通过挂载相同的数据卷,让容器之间能够同时看到并操作数据卷中的内容。这个功能虽然也可以通过绑定挂载来实现,但通过数据卷来操作会更加的舒适、简单。
由于数据卷的命名在 Docker 中是唯一的,所以我们很容易通过数据卷的名称确定数据卷,这就让我们很方便的让多个容器挂载同一个数据卷了。
$ sudo docker run -d --name webapp -v html:/webapp/html webapp:latest
$ sudo docker run -d --name nginx -v html:/usr/share/nginx/html:ro nginx:1.12
我们使用 -v
选项挂载数据卷时,如果数据卷不存在,Docker 会为我们自动创建和分配宿主操作系统的目录,而如果同名数据卷已经存在,则会直接引用。
如果有朋友觉得这样对数据卷的操作方式还不够直接和准确,我们还可以通过 docker volume
下的几个命令专门操作数据卷。
通过 docker volume create
我们可以不依赖于容器独立创建数据卷。
$ sudo docker volume create appdata
通过 docker volume ls
可以列出当前已创建的数据卷。
$ sudo docker volume ls
DRIVER VOLUME NAME
local html
local appdata
删除数据卷
虽然数据卷的目的是用来持久化存储数据的,但有时候我们也难免有删除它们以释放空间的需求。直接去 Docker 的目录下删除显然不是好的选择,我们应该通过 Docker 对数据卷的管理命令来删除它们。
我们可以直接通过 docker volume rm
来删除指定的数据卷。
$ sudo docker volume rm appdata
在删除数据卷之前,我们必须保证数据卷没有被任何容器所使用 ( 也就是之前引用过这个数据卷的容器都已经删除 ),否则 Docker 不会允许我们删除这个数据卷。
对于我们没有直接命名的数据卷,因为要反复核对数据卷 ID,这样的方式并不算特别友好。这种没有命名的数据卷,通常我们可以看成它们与对应的容器产生了绑定,因为其他容器很难使用到它们。而这种绑定关系的产生,也让我们可以在容器删除时将它们一并删除。
在 docker rm
删除容器的命令中,我们可以通过增加 -v
选项来删除容器关联的数据卷。
$ sudo docker rm -v webapp
如果我们没有随容器删除这些数据卷,Docker 在创建新的容器时也不会启用它们,即使它们与新创建容器所定义的数据卷有完全一致的特征。也就是说,此时它们已经变成了孤魂野鬼,纯粹的占用着硬盘空间而又不受管理。
此时我们可以通过 docker volume rm
来删除它们,但前提时你能在一堆乱码般的数据卷 ID 中找出哪个是没有被容器引用的数据卷。
为此,Docker 向我们提供了 docker volume prune
这个命令,它可以删除那些没有被容器引用的数据卷。
$ sudo docker volume prune -f
Deleted Volumes:
af6459286b5ce42bb5f205d0d323ac11ce8b8d9df4c65909ddc2feea7c3d1d53
0783665df434533f6b53afe3d9decfa791929570913c7aff10f302c17ed1a389
65b822e27d0be93d149304afb1515f8111344da9ea18adc3b3a34bddd2b243c7
## ......
数据卷容器
在数据卷的基础上,我们有一种相对新颖的用法,也就是数据卷容器。所谓数据卷容器,就是一个没有具体指定的应用,甚至不需要运行的容器,我们使用它的目的,是为了定义一个或多个数据卷并持有它们的引用。
创建数据卷容器的方式很简单,由于不需要容器本身运行,因而我们找个简单的系统镜像都可以完成创建。
$ sudo docker create --name appdata -v /webapp/storage ubuntu
在使用数据卷容器时,我们不建议再定义数据卷的名称,因为我们可以通过对数据卷容器的引用来完成数据卷的引用。而不设置数据卷的名称,也避免了在同一 Docker 中数据卷重名的尴尬。
之前我们提到,Docker 的 Network 是容器间的网络桥梁,如果做类比,数据卷容器就可以算是容器间的文件系统桥梁。我们可以像加入网络一样引用数据卷容器,只需要在创建新容器时使用专门的 --volumes-from
选项即可。
$ sudo docker run -d --name webapp --volumes-from appdata webapp:latest
引用数据卷容器时,不需要再定义数据卷挂载到容器中的位置,Docker 会以数据卷容器中的挂载定义将数据卷挂载到引用的容器中。
虽然看上去数据卷容器与数据卷的使用方法变化不大,但最关键的就在于其真正隐藏了数据卷的配置和定义,我们只需要通过数据卷容器的名称来使用它。这些细节的隐藏,意味着我们能够更轻松的实现容器的迁移。
备份和迁移数据卷
由于数据卷本身就是宿主操作系统中的一个目录,我们只需要在 Docker 资源目录里找到它就可以很轻松的打包、迁移、恢复了。虽然这么做相对其他虚拟化方案来说已经很简单了,但在 Docker 里还不是最优雅的解决方式。
利用数据卷容器,我们还能够更方便的对数据卷中的数据进行迁移。
数据备份、迁移、恢复的过程可以理解为对数据进行打包,移动到其他位置,在需要的地方解压的过程。在数据打包之前,我们先建立一个用来存放打包文件的目录,这里我们使用 /backup
作为例子。
要备份数据,我们先建立一个临时的容器,将用于备份的目录和要备份的数据卷都挂载到这个容器上。
$ sudo docker run --rm --volumes-from appdata -v /backup:/backup ubuntu tar cvf /backup/backup.tar /webapp/storage
在这条命令中,除了挂载的配置外,我们再注意几个选项。通过 --rm
选项,我们可以让容器在停止后自动删除,而不需要我们再使用容器删除命令来删除它,这对于我们使用一些临时容器很有帮助。在容器所基于的镜像之后,我们还看到了一串命令,也就是 tar cvf /backup/backup.tar /webapp/storage
,其实如果我们在镜像定义之后接上命令,可以直接替换掉镜像所定义的主程序启动命令,而去执行这一条命令。在很多场合下,我们还能通过这个方法干很多不同的事情。
在备份后,我们就可以在 /backup 下找到数据卷的备份文件,也就是 backup.tar 了。
如果要恢复数据卷中的数据,我们也可以借助临时容器完成。
$ docker run --rm --volumes-from appdata -v /backup:/backup ubuntu tar xvf /backup/backup.tar -C /webapp/storage --strip
恢复的过程与备份的过程类似,只不过把打包的命令转换为解包的命令而已。
另一个挂载选项
上面我们讲到了使用 -v
选项来挂载存在容易混淆的问题,其主要原因是挂载的方式和配置随着 Docker 的不断发展日渐丰富,而 -v
选项的传参方式限制了它能使用的场景。
其实在 Docker 里为我们提供了一个相对支持丰富的挂载方式,也就是通过 --mount
这个选项配置挂载。
$ sudo docker run -d --name webapp webapp:latest --mount 'type=volume,src=appdata,dst=/webapp/storage,volume-driver=local,volume-opt=type=nfs,volume-opt=device=<nfs-server>:<nfs-path>' webapp:latest
在 --mount
中,我们可以通过逗号分隔这种 CSV 格式来定义多个参数。其中,通过 type 我们可以定义挂载类型,其值可以是:bind,volume 或 tmpfs。另外,--mount
选项能够帮助我们实现集群挂载的定义,例如在这个例子中,我们挂载的来源是一个 NFS 目录。
由于在实际开发中,-v
基本上足够满足我们的需求,所以我们不常使用相对复杂的 --mount
选项来定义挂载,这里我们只是将它简单介绍,供大家参考。
保存和共享镜像
让 Docker 引以为傲的是它能够实现相比于其他虚拟化软件更快的环境迁移和部署,在这件事情上,轻量级的容器和镜像结构的设计无疑发挥了巨大的作用。通过将容器打包成镜像,再利用体积远小于其他虚拟化软件的 Docker 镜像,我们可以更快的将它们复制到其他的机器上。在这一节中,我们就专门来谈谈如何进行这样的迁移。
提交容器更改
之前我们已经介绍过了,Docker 镜像的本质是多个基于 UnionFS 的镜像层依次挂载的结果,而容器的文件系统则是在以只读方式挂载镜像后增加的一个可读可写的沙盒环境。
基于这样的结构,Docker 中为我们提供了将容器中的这个可读可写的沙盒环境持久化为一个镜像层的方法。更浅显的说,就是我们能够很轻松的在 Docker 里将容器内的修改记录下来,保存为一个新的镜像。
将容器修改的内容保存为镜像的命令是 docker commit
,由于镜像的结构很像代码仓库里的修改记录,而记录容器修改的过程又像是在提交代码,所以这里我们更形象的称之为提交容器的更改。
$ sudo docker commit webapp
sha256:0bc42f7ff218029c6c4199ab5c75ab83aeaaed3b5c731f715a3e807dda61d19e
Docker 执行将容器内沙盒文件系统记录成镜像层的时候,会先暂停容器的运行,以保证容器内的文件系统处于一个相对稳定的状态,确保数据的一致性。
在使用 docker commit
提交镜像更新后,我们可以得到 Docker 创建的新镜像的 ID,之后我们也能够从本地镜像列表中找到它。
$ sudo docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
<none> <none> 0bc42f7ff218 3 seconds ago 372MB
## ......
像通过 Git 等代码仓库软件提交代码一样,我们还能在提交容器更改的时候给出一个提交信息,方便以后查询。
$ sudo docker commit -m "Configured" webapp
为镜像命名
在上面的例子里,我们发现提交容器更新后产生的镜像并没 REPOSITORY 和 TAG 的内容,也就是说,这个新的镜像还没有名字。
之前我们谈到过,使用没有名字的镜像并不是很好的选择,因为我们无法直观的看到我们正在使用什么。好在 Docker 为我们提供了一个为镜像取名的命令,也就是 docker tag
命令。
$ sudo docker tag 0bc42f7ff218 webapp:1.0
使用 docker tag
能够为未命名的镜像指定镜像名,也能够对已有的镜像创建一个新的命名。
$ sudo docker tag webapp:1.0 webapp:latest
当我们对未命名的镜像进行命名后,Docker 就不会在镜像列表里继续显示这个镜像,取而代之的是我们新的命名。而如果我们对以后镜像使用 docker tag
,旧的镜像依然会存在于镜像列表中。
$ sudo docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
webapp 1.0 0bc42f7ff218 29 minutes ago 372MB
webapp latest 0bc42f7ff218 29 minutes ago 372MB
## ......
由于镜像是对镜像层的引用记录,所以我们对镜像进行命名后,虽然能够在镜像列表里同时看到新老两个镜像,实质是它们其实引用着相同的镜像层,这个我们能够从镜像 ID 中看得出来 ( 因为镜像 ID 就是最上层镜像层的 ID )。正是这个原因,我们虽然创建了新的镜像,但对物理存储的占用空间却不是镜像大小直接翻倍,并且创建也在霎那之间。
除了使用 docker tag
在容器提交为新的镜像后为镜像命名这种方式外,我们还可以直接在 docker commit
命令里指定新的镜像名,这种方式在使用容器提交时会更加方便。
$ sudo docker commit -m "Upgrade" webapp webapp:2.0
镜像的迁移
在我们将更新导出为镜像后,就可以开始迁移镜像的工作了。
由于 Docker 是以集中的方式管理镜像的,所以在迁移之前,我们要先从 Docker 中取出镜像。docker save
命令可以将镜像输出,提供了一种让我们保存镜像到 Docker 外部的方式。
$ sudo docker save webapp:1.0 > webapp-1.0.tar
在默认定义下,docker save
命令会将镜像内容放入输出流中,这就需要我们使用管道进行接收 ( 也就是命令中的 > 符号 ),这属于 Linux 等系统控制台中的用法,这里我们不做详细讲解。
管道这种用法有时候依然不太友好,docker save
命令还为我们提供了 -o
选项,用来指定输出文件,使用这个选项可以让命令更具有统一性。
$ sudo docker save -o ./webapp-1.0.tar webapp:1.0
在镜像导出之后,我们就可以找到已经存储镜像内容的 webapp-1.0.tar 这个文件了。有兴趣的朋友,可以使用解压软件查看其中的内容,你会看到里面其实就是镜像所基于的几个镜像层的记录文件。
导入镜像
我们可以通过很多种方式将导出的镜像文件复制到另一台机器上,在这么操作之后,我们就要将镜像导入到这台新机器中运行的 Docker 中。
导入镜像的方式也很简单,使用与 docker save
相对的 docker load
命令即可。
$ sudo docker load < webapp-1.0.tar
相对的,docker load
命令是从输入流中读取镜像的数据,所以我们这里也要使用管道来传输内容。当然,我们也能够使用 -i
选项指定输入文件。
$ sudo docker load -i webapp-1.0.tar
镜像导入后,我们就可以通过 docker images
看到它了,导入的镜像会延用原有的镜像名称。
批量迁移
通过 docker save
和 docker load
命令我们还能够批量迁移镜像,只要我们在 docker save
中传入多个镜像名作为参数,它就能够将这些镜像都打成一个包,便于我们一次性迁移多个镜像。
$ sudo docker save -o ./images.tar webapp:1.0 nginx:1.12 mysql:5.7
装有多个镜像的包可以直接被 docker load
识别和读取,我们将这个包导入后,所有其中装载的镜像都会被导入到 Docker 之中。
导出和导入容器
也许 Docker 的开发者认为,提交镜像修改,再导出镜像进行迁移的方法还不够效率,所以还为我们提供了一个导出容器的方法。
使用 docker export
命令我们可以直接导出容器,我们可以把它简单的理解为 docker commit
与 docker save
的结合体。
$ sudo docker export -o ./webapp.tar webapp
相对的,使用 docker export
导出的容器包,我们可以使用 docker import
导入。这里需要注意的是,使用 docker import
并非直接将容器导入,而是将容器运行时的内容以镜像的形式导入。所以导入的结果其实是一个镜像,而不是容器。在 docker import
的参数里,我们可以给这个镜像命名。
$ sudo docker import ./webapp.tar webapp:1.0
在开发的过程中,使用 docker save
和 docker load
,或者是使用 docker export
和 docker import
都可以达到迁移容器或者镜像的目的。