本文详细介绍了如何使用docker封装一个java应用(名字叫cspj,这个java应用涉及数据持久化以及RMI调用),包括:
- docker安装
- 镜像制作
- 容器运行
- 数据文件处理
- 私有仓库
- swarm集群,包括service、node、stack、task等相关概念
- 使用docker-machine快速创建docker虚拟机
1. docker介绍
docker是一种容器技术,对操作系统、文件系统、网络等进行了封装,使其中的进程可以完整运行。
docker和虚拟机是不同的技术。虚拟机虚拟了一套硬件环境,需要在这个硬件环境之上安装完整的操作系统、jdk等相关软件,才能运行一个java应用,虚拟机和宿主机在操作系统层面就是相互隔离的。而docker则不同,以Linux下的docker为例,它使用的依旧是宿主机中的Linux内核,只不过在宿主机的用户层上虚拟了一个容器,这个容器中的Linux只是操作系统中的一小部分,使用的依旧是宿主机的Linux内核。比如宿主机是Ubuntu,docker虚拟的操作系统可以是alpine,这只是虚拟了alpine和Ubuntu不同的部分。
一个docker容器中一般只运行一个应用,这和虚拟机也是不同的。比如我们的一个应用有java应用,有数据库mysql,那么java应用运行在一个容器里,mySql运行在另一个容器里。他们之间可以通过docker虚拟的网络进行交互。
docker中的容器就是运行中的进程。它是通过镜像进行启动的。docker中的镜像就相当于一个模板,启动一个容器就相当于通过模板创建一个可执行的应用。因此,只要镜像不变,所有通过这个镜像创建的容器都是一摸一样的。又因为docker进行了操作系统、文件系统、网络等方面的封装,所以这个镜像就可以在各种不同的环境上运行,从而保证一致的执行效果。
容器运行之后,在其中会有一个可读写层,这是用来临时保存容器中应用在运行中产生的数据的。当这个容器被销毁之后,所保存的数据也就消失了。使用原有的镜像重新运行一个新的容器,就又是一个全新的应用了。
所以,如果我们需要对容器中的数据进行持久化,就需要用到volume或者bind mounts技术。比如我们的java应用中有一个内置文件数据库Derby,如果需要保留对这个文件数据库的修改,同时又不想改变镜像文件,就可以把这个文件数据库使用volume或bind mounts技术保存到宿主机的文件系统中。这样,即使容器被销毁,容器中所修改的文件数据库也会被保留下来。
还有一种方法保存容器中的临时数据,就是使用commit命令把容器可读写层中的临时数据也一起生成一个新的镜像。以后通过这个新镜像运行的容器,就都保留了这部分数据,这部分数据也就成了新镜像的一层,而且无法被修改。通过这个新镜像运行的容器,会生成一个新的可读写层,用来临时保存此次运行中生成的数据。如果一直使用commit保存数据,新的镜像就会越来越大。docker官方不推荐使用这种方法保存数据。
在详细说一下docker的镜像。docker的镜像是使用Dockerfile制作的。Dockerfile是一个脚本,docker build命令会读取这个脚本,按照其指令构造镜像。docker的镜像是一层一层的。每一个Dockerfile指令,都会生成镜像中的一层。
我们自己制作的docker镜像通常不会从最底层开始构建。比如我们要制作一个java应用的镜像,我们就要依赖于openjdk:8-alpine的官方镜像。在这个基础之上,再制作我们的java应用镜像层。而官方的openjdk:8-alpine则是基于alpine操作系统制作的镜像,在这个操作系统之上,它为我们设置好了各种环境变量,我们在这个镜像之上就可以直接制作我们自己的java应用镜像,而不必关心jdk的设置了。
aphine 是一个特别简洁的官方的Linux操作系统系统容器镜像,只有5M大小。从中也可以看出docker和虚拟机的区别,虚拟机中运行的操作系统一定是完整的操作系统,通常都会有几个G的大小。
2. 环境准备
实验电脑为Intel-Core-i7 CPU, 安装Windows10操作系统,使用VirtualBox安装了CentOS-7虚拟机。我们将在CentOS-7虚拟机上安装Docker。关于如何在安装设置虚拟机,请参看这里。
如果要执行8.2节中的实例,必须使用VMWare虚拟机安装CentOS-7系统,因为VMWare支持nested vm。还需要设置vmware虚拟机的处理器中,选择“虚拟化Intel VT-x/EPT或AMD-V/RVI(V)。
如果使用AMD处理器,则可以使用VirtualBox安装CentOS-7,因为最新的VirtualBox-6支持在AMD系统上打开netstad vm。
VirtualBox中安装的CentOS-7系统的IP地址是192.168.56.104.
3. 安装Docker
Docker分为社区版和企业版,我们使用社区版即可。
- 卸载旧docker
$ sudo yum remove docker \
docker-client \
docker-client-latest \
docker-common \
docker-latest \
docker-latest-logrotate \
docker-logrotate \
docker-engine
- 安装docker
# 安装依赖
$ sudo yum install -y yum-utils \
device-mapper-persistent-data \
lvm2
# 设置国内镜像源
$ sudo yum-config-manager \
--add-repo \
https://mirrors.ustc.edu.cn/docker-ce/linux/centos/docker-ce.repo
# 安装最新版本
$ sudo yum install docker-ce docker-ce-cli containerd.io
# 启动
$ sudo systemctl start docker
# 验证,或从docker官方下载hello-world镜像并根据镜像运行容器。这个镜像只有不到2K
$ sudo docker run hello-world
# 把用户添加到docker组中,这样执行docker命令时就不必使用sudo了
$ sudo usermod -aG docker your-user
设置镜像加速器可以加速从Docker Hub获取镜像的速度。在/etc/docker/daemon.json
文件中(如不存在请新建)添加如下内容:
{
"registry-mirrors": [
"https://dockerhub.azk8s.cn",
"https://reg-mirror.qiniu.com"
]
}
之后启动服务:
$ sudo systemctl daemon-reload
$ sudo systemctl restart docker
更详细的安装方法请参看Get Docker Engine - Community for CentOS和安装 Docker
docker其实是C/S模式的,我们在Linux终端输入的docker命令其实是客户端,后台还有一个服务端在运行。客户端和服务端可以不运行在同一个机器上。
4. 制作镜像
4.1 准备镜像文件
新建一个空目录,把java应用程序放入到这个目录中,并新建Dockerfile文件。这里我们先不考虑临数据库持久化的问题,直接把所有应用程序进行打包:
[eric@centos7min2 cspj-server]$ ll
total 4
drwxrwxr-x. 2 eric eric 206 Sep 28 21:53 bin
drwxrwxr-x. 2 eric eric 207 Sep 27 16:23 conf
drwxrwxr-x. 4 eric eric 92 Sep 29 14:03 database
-rw-rw-r--. 1 eric eric 93 Sep 29 14:19 Dockerfile
drwxr-xr-x. 3 eric eric 278 Sep 27 16:12 lib
这个java应用程序的启动脚本是bin/startServer.sh,这个脚本中启动命令最后有&
符号,需要去掉。因为容器中运行的程序都是在前台运行的,如果加上&符号,这个在前台运行的startServer.sh脚本就执行完毕,这个容器也就立即停止了。
bin/setEnv.sh中设定了一些RMI参数,为了可以进行RMI连接,设置其内容如下:
#!/bin/sh
export IP=`awk 'END{print $1}' /etc/hosts`
echo "$IP cspj-host" >> /etc/hosts
cat /etc/hosts
export JAVA_EXECUTE=java
export CSPJ_LIBPATH=../lib/*.jar
export CSPJ_LIBPATH_OPT=../lib/opt/*.jar
export CSPJ_CLASSPATH=../conf/
export JVM_OPTARGS="-Xmx1024m -Xms1024m"
export CSPJ_OPTARGS="-Dcspj.home=$PWD/../ -Dfile.encoding=UTF-8"
export CSPJ_JMXARGS="-Djava.rmi.server.hostname=cspj-host -Dcom.sun.management.jmxremote.port=9998 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false"
echo $CSPJ_JMXARGS
其中IP是容器运行时动态获取容器的IP地址,把这个地址写入到/etc/hosts中时为了后续进行RMI连接,设置-Djava.rmi.server.hostname=cspj-host也是为了后续的RMI连接
4.2 编写Dockerfile
Dockerfile中内容为:
FROM openjdk:8-alpine
COPY . /cspj-server/
WORKDIR /cspj-server/bin
CMD ["./startServer.sh"]
- FROM指令表示我们的镜像所基于的基础镜像。这里我们使用的是openjdk:8-alpine。官方镜像都可以从docker hub中搜索。比如搜索openjdk,在openjdk界面点击TAGS页面,输入一些过滤信息,就可以找到对应版本。比如我们还可以使用8-jre-alpine进行过滤,只使用jre
- COPY指令表示把当前目录下的所有文件(Dockerfile除外)复制到镜像中的/cspj-server/目录。注意一定要有最后的“/”符号,表示复制到这个目录中。由于上一条指令我们基于的基础镜像openjdk:8-alpine就已经包含了一个基础的Linux文件系统,所以执行COPY命令时,就是在镜像中的文件系统中进行操作。COPY命令会自动在这个文件系统中创建不存在的/cspj-server/文件夹
- WORKDIR指令指定镜像的工作目录。相当于在镜像的文件系统中进入这个目录,并把这个目录设置为工作目录
- CMD指令指定通过这个镜像启动容器时需要执行什么命令。我们这里把应用程序的启动脚本作为执行命令
在构建镜像时,docker中每条指令都会构建一层,所以如果有RUN命令时,一般把多个操作都写在一行里。
4.3 构建镜像
在刚才的目录中,执行 docker build -t ws3495/cspj-server:v1.0.0 .
命令,构建镜像。:v1.0.0
可以省略,此时默认是:latest
。注意不要丢掉最后的“.”,它以宿主机的一个文件夹作为"context",Dockerfile中的指令就是基于这个“context”进行构建的。比如这个docker build命令指定了当前路径(/home/eric/dockertest/forbuildimage/cspj-server)为“context”,那么在Dockerfile中,COPY . /cspj-server/ 指令中的“.”指的就是宿主机的/home/eric/dockertest/forbuildimage/cspj-server目录。关于docker build指令可以参考docker build,关于上下文可以参考这里。
通过执行刚才的命令,其执行过程为:
[eric@centos7min2 cspj-server]$ docker build -t ws3495/cspj-server:v1.0.0 .
Sending build context to Docker daemon 32.8MB
Step 1/4 : FROM openjdk:8-alpine
---> a3562aa0b991
Step 2/4 : COPY . /cspj-server/
---> 27361ab40a65
Step 3/4 : WORKDIR /cspj-server/bin
---> Running in aef7152e561a
Removing intermediate container aef7152e561a
---> b0fcdabdde69
Step 4/4 : CMD ["./startServer.sh"]
---> Running in 7a11c32dccae
Removing intermediate container 7a11c32dccae
---> 4e56f3b72f1d
Successfully built 4e56f3b72f1d
Successfully tagged ws3495/cspj-server:v1.0.0
通过这个执行过程中每个step,可以看出docker构建镜像时的操作:
- Sending build context to Docker daemon: docker客户端把“context”发送到docker服务端
- Step 1/4: 根据基础镜像openjdk:8-alpine构建第一层。如果这是第一次执行,会从docker hub上拉取这个镜像,缓存在本地。以后再次执行这个指令时,就会直接从本地获取这个镜像了。
- Step 2/4: 构建第2层,把相对“context”路径“.”下所有文件复制到镜像的/cspj-server/文件夹
- Step 3/4: 构建第3层。先启动一个容器aef7152e561a,执行了所要求的指令,随后删除了这个临时的容器,并把执行结果进行了提交,就生成了镜像的一层。
- Step 4/4: 设置容器启动命令。
- 最后打上一个tag
通过执行docker image ls
命令,可以看到刚才构建的镜像:
[eric@centos7min2 cspj-server]$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
ws3495/cspj-server v1.0.0 4e56f3b72f1d 12 minutes ago 137MB
ws3495/cspj-server v1.0.1 e5868fe7c123 4 hours ago 132MB
openjdk 8-alpine a3562aa0b991 4 months ago 105MB
registry latest f32a97de94e1 6 months ago 25.8MB
hello-world latest fce289e99eb9 9 months ago 1.84kB
prakhar1989/static-site latest f01030e1dcf3 3 years ago 134MB
这个命令输出一个类似表格的结构,第一行是表头。第二行就是我们刚构建的ws3495/cspj-server:v1.0.0,第3行是我们从docker hub上拉取的openjdk:8-alpine镜像。
5. 启动应用
5.1 启动容器
执行命令docker run -d -p 27449:27449 -p 27450:27450 ws3495/cspj-server:v1.0.0
,依据刚才制作的镜像,启动一个容器:
[eric@centos7min2 cspj-server]$ docker run -d \
> -p 27449:27449 -p 27450:27450 \
> ws3495/cspj-server:v1.0.0
47ed8277b0e0bfbb90a798a8b5499a0ee693499fd2342615388248ad72e932ab
命令执行结束后,返给我们一个字符串,这个串就是这个刚刚启动的容器的ID。由于我们使用了-d
参数,所以这个容器在后台运行。
命令中的-p
参数把容器中的端口和宿主机中的端口进行了映射。外部访问host port的连接就会被转发到这个容器的container port上。
使用docker logs id
指令可以查看容器的日志,id仅需前几位即可:
[eric@centos7min2 cspj-server]$ docker logs 47ed827
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
172.17.0.2 47ed8277b0e0
172.17.0.2 cspj-host
-Djava.rmi.server.hostname=cspj-host -Dcom.sun.management.jmxremote.port=9998 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false
Starting CSPJ Server...
2019-09-29_10:35:33.263[0000]CSPJ Server Process ID:10
2019-09-29_10:35:33.275[0000]CSPJ Server Version:CSPJ_V1.3.7.1 Build:2019-08-03 10:54:51
2019-09-29_10:35:33.276[0000]系统日志初始化成功[/cspj-server/log/syslog.trace]
2019-09-29_10:35:33.277[0000]平台主目录:/cspj-server
2019-09-29_10:35:33.277[0000]平台配置信息主目录:/cspj-server/conf
...
2019-09-29_10:35:37.587[0000]终端[RMI Registry]监听端口[27449]数据端口[27450]
2019-09-29_10:35:37.587[0000]终端守护进程启动成功
2019-09-29_10:35:37.587[0000]初始化交易主控入口……
2019-09-29_10:35:37.589[0000]交易主控入口初始化成功[DefaultTransactionInvoker]
2019-09-29_10:35:37.589[0000]启动通讯口岸……
2019-09-29_10:35:37.599[0000]启动通讯口岸:SocketPortal[NIO]
2019-09-29_10:35:37.599[0000]通讯口岸启动成功
2019-09-29_10:35:37.649[0000]CSPJ Server 启动成功
使用命令docker ps
可以查看正在运行的容器:
[eric@centos7min2 cspj-server]$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
47ed8277b0e0 ws3495/cspj-server:v1.0.0 "./startServer.sh" 53 minutes ago Up 53 minutes 0.0.0.0:27449-27450->27449-27450/tcp jolly_shockley
对于这个运行中的容器,可以使用命令docker exec -it id sh
,进入这个容器进行查看和修改:
[eric@centos7min2 cspj-server]$ docker exec -it 47ed827 sh
/cspj-server/bin # ls
SecInputKey.sh derby.log jmxremote.password serverStatus.sh startServer.sh transform.sh
codeinfo.sh ij.sh pwdgen.sh setEnv.sh stopServer.sh
/cspj-server/bin # cd ../log
/cspj-server/log # ls
error.trace root.trace syslog.trace trace
/cspj-server/bin #
此时就进入到了容器里,在里面对容器中的内容进行修改,就会写入到容器的可读写层(应该是最上层)。之后再执行docker restart
时,这个容器的修改不会消失。只有在使用docker rm
命令删除这个容器时,所有临时存储的文件就会消失。或者再使用docker run ... ws3495/cspj-server:v1.0.0
命令运行一个新容器时,这个容器中所作的修改也不会被新容器知道。
5.2 从外部RMI连接到容器的27449端口
在我们的windows 10系统上,修改C:\Windows\System32\drivers\etc\hosts
文件,添加一行192.168.56.104 cspj-host
,其中192.168.56.104是CentOS-7虚拟机的IP。就可以使用IDE(RMI连接的客户端工具)连接了:
其原理是:
现在这个集群共有3个IP地址:
- IP1: 是windows 10操作系统所在的地址,也是RMI客户端所在的地址
- IP2: 192.168.56.104,是CentOS-7系统(在win10中运行的VirtualBox虚拟机中)所在的地址
- IP3: 是docker容器运行的地址,即RMI服务端所在的地址
由于docker启动时设置了-p 27449:27449 -p 27450:27450
参数,所有发送到IP2:27449和IP2:27450的信息都会被转发到IP3:27449和IP3:27450。这两个端口是我们设置的RMI提供服务的端口。
在docker容器中启动的java应用(RMI服务端,IP3)在启动时设置了-Djava.rmi.server.hostname=cspj-host选项,当客户端使用rmi方式连接到docker容器中的进程时,容器中的进程会向客户端返回一个本机的cspj-host
参数标定服务端所在的地址(已在docker中的/etc/hosts
中设置了IP3 cspj-host
(参看5.1节中的docker logs指令的输出结果))。客户端会从本机的hosts
文件中查找cspj-host
所在的地址。
客户端需要以RMI方式连接到IP3上时,需要通过IP2进行中转,所以在RMI客户端所在的机器上,需要在hosts
文件中设置IP2 cspj-host
,使客户端去IP2:27449获取RMI服务。又由于IP2会把所有27449端口的数据包都转发到IP3:27449,所以就会最终找到真正的RMI服务。
当有更多的IP对数据包进行转发时,也是一样的,客户端需要设置hosts中cspj-host为IP2所在的地址。
注意:连接过程中可能会碰到NoSuchObject的异常,需要多试几次。或者在IP2上启动一个cspj,IDE连接上之后,再关闭IP2上的cspj,然后再试
6. 修改数据文件
6.1 使用commit生成新的镜像
通过5.2节图中的界面,我们可以修改Derby数据库文件。主要修改内容是在容器中开放18000端口,所有向这个端口发送的数据,都会收到一个返回信息,信息中标明这个容器的IP地址。修改之后,使用docker commit
命令,把这个容器存为一个新镜像ws3495/cspj-server:tmp
[eric@centos7min2 cspj-server]$ docker commit 47ed8277b0e0 ws3495/cspj-server:tmp
sha256:ae4f6b8ecf435b714c331372d93c96bbb56460469bb9dd06e4e0f93faa8659a8
[eric@centos7min2 cspj-server]$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
ws3495/cspj-server tmp ae4f6b8ecf43 9 seconds ago 141MB
ws3495/cspj-server v1.0.0 4e56f3b72f1d About an hour ago 137MB
ws3495/cspj-server v1.0.1 e5868fe7c123 5 hours ago 132MB
openjdk 8-alpine a3562aa0b991 4 months ago 105MB
registry latest f32a97de94e1 6 months ago 25.8MB
hello-world latest fce289e99eb9 9 months ago 1.84kB
prakhar1989/static-site latest f01030e1dcf3 3 years ago 134MB
可以看出,commit生成的镜像tmp比原始镜像v1.0.0镜像大了不少。
使用命令docker stop
命令停掉现在这个容器,再使用docker ps -a
命令,可以看到其状态为Exited(stop的容器可以使用docker start
命令再启动,其修改不会丢失):
[eric@centos7min2 cspj-server]$ docker stop 47ed8277b0e0
47ed8277b0e0
[eric@centos7min2 cspj-server]$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
47ed8277b0e0 ws3495/cspj-server:v1.0.0 "./startServer.sh" 58 minutes ago Exited (137) 31 seconds ago jolly_shockley
我们根据刚才commit的镜像,启动一个新容器,这次把18000端口也映射到宿主机:
[eric@centos7min2 cspj-server]$ docker run -d \
> -p 27449:27449 -p 27450:27450 -p 18000:18000 \
> ws3495/cspj-server:tmp
f211f0844f78dc38460260c34b7e9cb7f9ae8245c7d246e35e7be7cf4d3ba23c
新运行的容器和刚才那个容器的ID是不一样的。使用IDE连接到这个新容器上,可以看到刚才在那个容器中所作的修改,这里都存在。
在windows 10上,使用telnet连接到CentOS-7的18000端口,发送一段数据,可以看到返回信息:
其中的ip addr is 172.17.0.2即容器中java应用返回信息。
6.2 更好的方法
v1.0.0镜像启动了一个容器47ed8277b0e0,可不可以在这个运行的容器上再用类似-p
的参数映射出一个端口呢?根据How do I assign a port mapping to an existing Docker container?这个答案,需要修改docker守护进程的配置文件,不是一个很好的解决方案。
commit会使镜像不断增大。可以使用bind mounts技术,在docker run
命令中,使用--mount type=bind,source=
参数,把宿主机上的一个文件夹挂载到容器中。我们的java应用所有数据库修改都是修改/cspj-server/database目录,这样对数据库的修改就可以保存下来,即使容器被删除了,对数据库的修改也不会消失。不过要注意,
[eric@centos7min2 cspj-server]$ ll database/
total 20
drwxrwxr-x. 2 eric eric 97 Sep 29 14:03 log
-rw-rw-r--. 1 eric eric 608 Sep 29 14:03 README_DO_NOT_TOUCH_FILES.txt
drwxrwxr-x. 2 eric eric 8192 Sep 29 14:03 seg0
-rw-rw-r--. 1 eric eric 1003 Sep 29 14:03 service.properties
或者使用外置的数据库,不要和java应用集成在一起。例如连接到外部的oracle数据库;或者启动一个mySQL的docker container,使用docker-compose
工具把他们关联到一起。详情请参看“容器集群”一节
7. 私有仓库registry
如果无法连接Docker Hub,我们可以搭建私有仓库。使用官方镜像registry即可搭建:
# 搭建本地的registry,默认在/etc/lib/registry中
[eric@centos7min2 cspj-server]$ docker run -d -p 5000:5000 --restart=always --name registry registry
821497a1688646027389b8c3547ab3e321e8df1c1fa442987506b3b5784de52e
# 查看本地registry上存在的镜像
[eric@centos7min2 cspj-server]$ curl 127.0.0.1:5000/v2/_catalog
{"repositories":[]}
# 使用docker tag标记一个到127.0.0.1:5000的镜像
[eric@centos7min2 cspj-server]$ docker tag ws3495/cspj-server:tmp 127.0.0.1:5000/ws3495/cspj-server:tmp
[eric@centos7min2 cspj-server]$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
127.0.0.1:5000/ws3495/cspj-server tmp ae4f6b8ecf43 About an hour ago 141MB
ws3495/cspj-server tmp ae4f6b8ecf43 About an hour ago 141MB
ws3495/cspj-server v1.0.0 4e56f3b72f1d 3 hours ago 137MB
ws3495/cspj-server v1.0.1 e5868fe7c123 6 hours ago 132MB
openjdk 8-alpine a3562aa0b991 4 months ago 105MB
registry latest f32a97de94e1 6 months ago 25.8MB
hello-world latest fce289e99eb9 9 months ago 1.84kB
prakhar1989/static-site latest f01030e1dcf3 3 years ago 134MB
# 推送这个镜像
[eric@centos7min2 cspj-server]$ docker push 127.0.0.1:5000/ws3495/cspj-server:tmp
The push refers to repository [127.0.0.1:5000/ws3495/cspj-server]
e907982060bf: Pushed
4c70c37a74c9: Pushed
ceaf9e1ebef5: Pushed
9b9b7f3d56a0: Pushed
f1b5933fe4b5: Pushed
tmp: digest: sha256:588c372c92e3909fb280311d273be512dfc5641eb66e0514b9c90c9db314dcb4 size: 1369
# 查看结果
[eric@centos7min2 cspj-server]$ curl 127.0.0.1:5000/v2/_catalog
{"repositories":["ws3495/cspj-server"]}
# 重新pull
[eric@centos7min2 cspj-server]$ docker image rm 127.0.0.1:5000/ws3495/cspj-server:tmp
Untagged: 127.0.0.1:5000/ws3495/cspj-server:tmp
Untagged: 127.0.0.1:5000/ws3495/cspj-server@sha256:588c372c92e3909fb280311d273be512dfc5641eb66e0514b9c90c9db314dcb4
[eric@centos7min2 cspj-server]$ docker pull 127.0.0.1:5000/ws3495/cspj-server:tmp
tmp: Pulling from ws3495/cspj-server
Digest: sha256:588c372c92e3909fb280311d273be512dfc5641eb66e0514b9c90c9db314dcb4
Status: Downloaded newer image for 127.0.0.1:5000/ws3495/cspj-server:tmp
127.0.0.1:5000/ws3495/cspj-server:tmp
[eric@centos7min2 cspj-server]$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
ws3495/cspj-server tmp ae4f6b8ecf43 About an hour ago 141MB
127.0.0.1:5000/ws3495/cspj-server tmp ae4f6b8ecf43 About an hour ago 141MB
ws3495/cspj-server v1.0.0 4e56f3b72f1d 3 hours ago 137MB
ws3495/cspj-server v1.0.1 e5868fe7c123 6 hours ago 132MB
openjdk 8-alpine a3562aa0b991 4 months ago 105MB
registry latest f32a97de94e1 6 months ago 25.8MB
hello-world latest fce289e99eb9 9 months ago 1.84kB
prakhar1989/static-site latest f01030e1dcf3 3 years ago 134MB
更多搭建私有仓库的方法请参看私有仓库
8. 容器集群
一个container(容器)只做一件事情,一个container中只运行一个程序。如果我们的应用由好几个部分组成,应该如何把它们组织到一起呢?比如一个应用可以分为server(逻辑处理)、db(数据库操作)、monitor(监控)等几部分,一般会把这几部分分别制作成镜像,启动到不同的container中。怎么把它们组成一个完整的可以对外提供服务的应用呢?如果需要多个应用,如何进行负载均衡呢?
这里就要用到集群管理。Docker自带一个集群管理工具Swarm(蜂群),使用它可以解决我们刚才提出的问题。
使用Swarm,先要明确与之相关的一些概念:
- node: swarm集群中每一个运行了docker服务的机器(虚拟机或物理机),都被称为node。参看第3节
- container: 这个我们已经知道了,就是容器,是通过镜像(image)运行起来的。swarm之外运行的container随时可以加入到swarm中,成为一个task
- task: 简单来说,在swarm集群中,每个运行中的container就是一个task
- service: 一个应用的不同部分被称为service。service中可以有多个task正在运行
- stack: 一些相互关联的service一起对外提供服务,就构成了一个stack。例如页面service和数据库service组合在一起才能让用户使用,这就是一个stack
docker命令中的node
、service
、stack
指令,都必须在swarm集群环境下使用。
8.1 负载均衡
我们前面创建了ws3495/cspj-server:tmp镜像。它对外提供一个服务,对任意发送到18000端口的请求,返回一个容器的IP地址。IDE工具可以用RMI方式连接到这个容器,对容器的数据进行操作。
现在需要实现对这个服务的负载均衡。我们需要根据这个镜像启动多个容器,让它们共同对外提供更加稳定可靠的服务。
8.1.1 创建compose文件
在任意位置创建一个compose文件 docker-compose.yml
:
version: "3"
services:
server:
image: ws3495/cspj-server:tmp
deploy:
replicas: 2
resources:
limits:
cpus: "0.5"
memory: 1024M
restart_policy:
condition: on-failure
ports:
# :
- "27449:27449"
- "27450:27450"
- "18000:18000"
networks:
- cspjnet
networks:
cspjnet:
- services: services下的每一项都是一个service
- server: 我们把cspj程序提供的service叫做server
- image: 根据哪个镜像启动容器
- replicas: 启动2个容器。启动后,每个容器都是一个task,它们共同组成了一个service(名字叫做server)
- limits: 限制每个容器的CPU和内存使用率
- restart_policy: 失败后立即重启
- ports: 端口映射,左侧是宿主机的端口,右侧是容器的端口
- networks: 为这几个容器新建的虚拟网络(默认设置,即load-balanced overlay network)
8.1.2 初始化swarm
swarm中的节点(node)分为manager和worker。我们需要初始化一个manager,然后把其它节点作为worker加入进来。这里我们只有一个机器(CentOS-7),所以我们只创建一个manager节点。
执行命令docker swarm init --advertise-addr 192.168.56.104
创建manager节点:
[eric@centos7min2 swarm]$ docker swarm init --advertise-addr 192.168.56.104
Swarm initialized: current node (eby778btfq9hvpzn62gnnv5ux) is now a manager.
To add a worker to this swarm, run the following command:
docker swarm join --token SWMTKN-1-4famuxyeqheaiiip2rp2vyk7fuq5v4csvn4432w2czbm7ctov2-6yjw4idxlrdy4vjbknd4d1gdg 192.168.56.104:2377
To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.
这个命令会创建几个network,用于集群管理。ingress和none就是swarm在这个节点上创建的,docker_gwbridge可能也是:
[eric@centos7min2 swarm]$ docker network ls
NETWORK ID NAME DRIVER SCOPE
015532cb540e bridge bridge local
a4025c27d82d docker_gwbridge bridge local
20ce1819213b host host local
5fnz5sfhkka7 ingress overlay swarm
ec1e8f535e07 none null local
可以执行一些检查,看一看这个节点现在的状态:
# swarm中只有一个节点,并且是manager
[eric@centos7min2 swarm]$ docker node ls
ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION
eby778btfq9hvpzn62gnnv5ux * centos7min2 Ready Active Leader 19.03.2
# 还没有stack
[eric@centos7min2 swarm]$ docker stack ls
NAME SERVICES ORCHESTRATOR
# 也没有service
[eric@centos7min2 swarm]$ docker service ls
ID NAME MODE REPLICAS IMAGE PORTS
8.1.3 部署并启动应用
执行命令docker stack deploy -c docker-compose.yml cspj
,swarm就可以根据docker-compose.yml文件中的配置,自动部署并启动应用。我们给这个stack命名为cspj。
# 部署应用。会根据yml文件中的设置,创建虚拟网络cspj_cspjnet,使用ws3495/cspj-server:tmp镜像启动一个服务cspj_server
[eric@centos7min2 swarm]$ docker stack deploy -c docker-compose.yml cspj
Creating network cspj_cspjnet
Creating service cspj_server
# swarm又新建了一个cspj_cspjnet的虚拟网络
[eric@centos7min2 swarm]$ docker network ls
NETWORK ID NAME DRIVER SCOPE
015532cb540e bridge bridge local
d5a7fdih7h92 cspj_cspjnet overlay swarm
a4025c27d82d docker_gwbridge bridge local
20ce1819213b host host local
5fnz5sfhkka7 ingress overlay swarm
ec1e8f535e07 none null local
# 查看所有stack。现在只有1个,名字叫cspj,里面有1个service
[eric@centos7min2 swarm]$ docker stack ls
NAME SERVICES ORCHESTRATOR
cspj 1 Swarm
# 查看所有service。输出信息表明cspj_server这个service中有两个task(REPLICAS),并且都已经启动了
[eric@centos7min2 swarm]$ docker service ls
ID NAME MODE REPLICAS IMAGE PORTS
uq82928el14d cspj_server replicated 2/2 ws3495/cspj-server:tmp *:18000->18000/tcp, *:27449-27450->27449-27450/tcp
# 查看cspj_server这个service中的task。swarm自动为每个task进行了编号
[eric@centos7min2 swarm]$ docker service ps cspj_server
ID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR PORTS
j7kjtj81re1o cspj_server.1 ws3495/cspj-server:tmp centos7min2 Running Running 5 minutes ago
1elk64sfezfo cspj_server.2 ws3495/cspj-server:tmp centos7min2 Running Running 5 minutes ago
# 按照普通方式查看container,发现ID和service ps中显示的ID不一样。为什么?
[eric@centos7min2 swarm]$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
8d9f67c15cb6 ws3495/cspj-server:tmp "./startServer.sh" 21 minutes ago Up 21 minutes 27449-27450/tcp cspj_server.2.1elk64sfezfoql0ex1njwjlnp
dee88b746baf ws3495/cspj-server:tmp "./startServer.sh" 21 minutes ago Up 21 minutes 27449-27450/tcp cspj_server.1.j7kjtj81re1o1ge5anwnlmzka
821497a16886 registry "/entrypoint.sh /etc…" 24 hours ago Up 24 hours 0.0.0.0:5000->5000/tcp registry
在Windows 10上,向CentOS-7的18000端口发送数据,可以看到负载均衡的效果:
IDE也可以正常连接。
8.1.4 关闭应用并恢复
使用docker stack rm cspj
关闭并移除cspj这个task
# 关闭stack
[eric@centos7min2 swarm]$ docker stack rm cspj
Removing service cspj_server
Removing network cspj_cspjnet
# stack已被移除
[eric@centos7min2 swarm]$ docker stack ls
NAME SERVICES ORCHESTRATOR
# service已被移除
[eric@centos7min2 swarm]$ docker service ls
ID NAME MODE REPLICAS IMAGE PORTS
# cspj_cspjnet已被移除
[eric@centos7min2 swarm]$ docker network ls
NETWORK ID NAME DRIVER SCOPE
015532cb540e bridge bridge local
a4025c27d82d docker_gwbridge bridge local
20ce1819213b host host local
5fnz5sfhkka7 ingress overlay swarm
ec1e8f535e07 none null local
# task已被关闭并移除
[eric@centos7min2 swarm]$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
821497a16886 registry "/entrypoint.sh /etc…" 24 hours ago Up 24 hours 0.0.0.0:5000->5000/tcp registry
使用命令docker swarm leave --force
移除swarm集群(即把最后一个node从swarm集群中移除掉)。之后,docker node
, docker stack
, docker service
这些命令就不可用了:
# 关闭swarm
[eric@centos7min2 compose]$ docker swarm leave --force
Node left the swarm.
8.2 多个node多个servcie
本节使用docker-machine工具创建多个虚拟docker node,并把它们都用swarm组织成一个集群。同时,我们再在docker-compose.yml文件中添加一个service,使多个service共同工作。操作平台是CentOS-7。docker-machine可以完全独立使用,不必同时安装docker,docker-machine创建的虚拟机中就有docker服务。
由于docker-machine创建虚拟机需要先安装virtualbox,而目前版本的virtualbox(6.0)仅能在AMD的CPU上支持嵌套的虚拟机,所以在本节中我们使用vmware workstation pro 15(有30天免费试用期,或者使用vmware workstation palyer),在这个虚拟机上安装CentOS-7,然后再在CentOS-7上安装docker-machine,docker-machine再创建基于virtualbox的虚拟机。
新搭建的CentOS-7系统的IP地址是192.168.154.100.
8.2.1 安装docker-machine
docker-machine可以快速部署带有docker服务的虚拟机。
在CentOS-7上使用docker-machine需要先安装virtual-box。新建/etc/yum.repos.d/virtualbox.repo
文件,内容如下:
[virtualbox]
name=Oracle Linux / RHEL / CentOS-$releasever / $basearch - VirtualBox
baseurl=http://download.virtualbox.org/virtualbox/rpm/el/$releasever/$basearch
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://www.virtualbox.org/download/oracle_vbox.asc
然后执行如下命令安装virtualbox。安装成功之后,执行sudo systemctl status vboxdrv
,可以查看virtualbox的状态:
-
sudo yum update
,会更新所有软件,可以不执行。如果执行,需在执行后重启系统。 -
yum install -y kernel-devel kernel-headers gcc make perl
,之后完后可能需要重启系统 sudo yum install VirtualBox-6.0
[eric@vmwmin1 ~]$ sudo systemctl status vboxdrv
● vboxdrv.service - VirtualBox Linux kernel module
Loaded: loaded (/usr/lib/virtualbox/vboxdrv.sh; enabled; vendor preset: disabled)
Active: active (exited) since Wed 2019-10-02 00:23:22 CST; 23min ago
Process: 822 ExecStart=/usr/lib/virtualbox/vboxdrv.sh start (code=exited, status=0/SUCCESS)
Oct 02 00:19:51 vmwmin1 systemd[1]: Starting VirtualBox Linux kernel module...
Oct 02 00:19:54 vmwmin1 vboxdrv.sh[822]: vboxdrv.sh: Starting VirtualBox services.
Oct 02 00:19:54 vmwmin1 vboxdrv.sh[855]: Starting VirtualBox services.
Oct 02 00:19:54 vmwmin1 vboxdrv.sh[822]: vboxdrv.sh: Building VirtualBox kernel modules.
Oct 02 00:19:54 vmwmin1 vboxdrv.sh[860]: Building VirtualBox kernel modules.
Oct 02 00:23:22 vmwmin1 systemd[1]: Started VirtualBox Linux kernel module.
安装docker-machine:
$ base=https://github.com/docker/machine/releases/download/v0.16.0 &&
curl -L $base/docker-machine-$(uname -s)-$(uname -m) >/tmp/docker-machine &&
sudo mv /tmp/docker-machine /usr/local/bin/docker-machine &&
chmod +x /usr/local/bin/docker-machine
安装成功后,执行docker-machine ls
,可以看到还不存在由docker-machine创建的虚拟机。
8.2.2 创建虚拟机(两个node)
使用docker-machine create --driver virtualbox
可以直接创建带有docker服务的虚拟机,不必事先安装docker。这里我们创建两个虚拟机,分别为myvm1
和myvm2
:
# 创建myvm1,由于是第一次执行,会从github上下载一些文件
[eric@vmwmin1 ~]$ docker-machine create --driver virtualbox myvm1
Running pre-create checks...
(myvm1) Image cache directory does not exist, creating it at /home/eric/.docker/machine/cache...
(myvm1) No default Boot2Docker ISO found locally, downloading the latest release...
(myvm1) Latest release for github.com/boot2docker/boot2docker is v18.09.9
(myvm1) Downloading /home/eric/.docker/machine/cache/boot2docker.iso from https://github.com/boot2docker/boot2docker/releases/download/v18.09.9/boot2docker.iso...
(myvm1) 0%....10%....20%....30%....40%....50%....60%....70%....80%....90%....100%
Creating machine...
(myvm1) Copying /home/eric/.docker/machine/cache/boot2docker.iso to /home/eric/.docker/machine/machines/myvm1/boot2docker.iso...
(myvm1) Creating VirtualBox VM...
(myvm1) Creating SSH key...
(myvm1) Starting the VM...
(myvm1) Check network to re-create if needed...
(myvm1) Found a new host-only adapter: "vboxnet0"
(myvm1) Waiting for an IP...
Waiting for machine to be running, this may take a few minutes...
Detecting operating system of created instance...
Waiting for SSH to be available...
Detecting the provisioner...
Provisioning with boot2docker...
Copying certs to the local machine directory...
Copying certs to the remote machine...
Setting Docker configuration on the remote daemon...
Checking connection to Docker...
Docker is up and running!
To see how to connect your Docker Client to the Docker Engine running on this virtual machine, run: docker-machine env myvm1
# 创建myvm2,省略了一些输出内容
[eric@vmwmin1 ~]$ docker-machine create --driver virtualbox myvm2
...
Docker is up and running!
To see how to connect your Docker Client to the Docker Engine running on this virtual machine, run: docker-machine env myvm2
# 查看这两个虚拟机
[eric@vmwmin1 ~]$ docker-machine ls
NAME ACTIVE DRIVER STATE URL SWARM DOCKER ERRORS
myvm1 - virtualbox Running tcp://192.168.99.100:2376 v18.09.9
myvm2 - virtualbox Running tcp://192.168.99.101:2376 v18.09.9
8.2.3 配置http方式访问私有仓库
本文开始时我们使用的CentOS-7系统的地址是192.168.56.104,我们在这个CentOS-7系统上使用docker搭建个了一个私有仓库。现在我们又使用vmware新搭建了一个CentOS-7系统,其地址是192.168.154.100,在这个系统上使用docker-machine创建了两个虚拟机myvm1和myvm2。我们需要使myvm1和myvm2可以访问这个私有仓库,所以需要对myvm1和myvm2进行一些配置,使其可以以不安全的方式访问私有仓库。
通过执行命令docker-machine scp
会把文件拷贝到对应的虚拟机中。
通过执行命令docker-machine ssh
可以直接在虚拟机中执行命令。如果省略""
中的内容,就可以以ssh方式连接到虚拟机中。
在docker-machine所在的CentOS-7系统上,在任意位置新建一个文件daemon.json
,内容为:
{
"insecure-registries": [
"192.168.56.104:5000"
]
}
192.168.56.104是私有仓库所在的地址。
把这个文件拷贝到myvm1和myvm2的/etc/docker/目录下:
# 拷贝文件
$ docker-machine scp daemon.json myvm1:~
$ docker-machine scp daemon.json myvm2:~
$ docker-machine ssh myvm1 "sudo mv daemon.json /etc/docker/"
$ docker-machine ssh myvm2 "sudo mv daemon.json /etc/docker/"
# 重启
[eric@vmwmin1 ~]$ docker-machine restart myvm1 myvm2
Restarting "myvm2"...
Restarting "myvm1"...
(myvm2) Check network to re-create if needed...
(myvm2) Waiting for an IP...
Waiting for SSH to be available...
(myvm1) Check network to re-create if needed...
(myvm1) Waiting for an IP...
Waiting for SSH to be available...
Detecting the provisioner...
Detecting the provisioner...
Restarted machines may have new IP addresses. You may need to re-run the `docker-machine env` command.
# 查看重启后状态
[eric@vmwmin1 ~]$ docker-machine ls
NAME ACTIVE DRIVER STATE URL SWARM DOCKER ERRORS
myvm1 - virtualbox Running tcp://192.168.99.102:2376 v18.09.9
myvm2 - virtualbox Running tcp://192.168.99.103:2376 v18.09.9
配置好daemon.json之后,myvm1和myvm2就可以以不安全的方式(HTTP)访问私有仓库了。
8.2.4 配置swarm集群
使用swarm命令,把这两个节点加入到swarm集群中:
# 初始化myvm1,会自动设置myvm1为manager
[eric@vmwmin1 ~]$ docker-machine ssh myvm1 "docker swarm init --advertise-addr 192.168.99.102"
Swarm initialized: current node (tjpk0hxlhij9v77yh30ehnzkg) is now a manager.
To add a worker to this swarm, run the following command:
docker swarm join --token SWMTKN-1-11sad8xx5hp9tyt9oed3gdgzx9ma7lfkk2chm0l8hi3mc0we2s-0bjuixv1bpsvryjsizppfr7bz 192.168.99.102:2377
To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.
# 根据上一个输出的提示,把myvm2加入到swarm中
[eric@vmwmin1 ~]$ docker-machine ssh myvm2 "docker swarm join --token SWMTKN-1-11sad8xx5hp9tyt9oed3gdgzx9ma7lfkk2chm0l8hi3mc0we2s-0bjuixv1bpsvryjsizppfr7bz 192.168.99.102:2377"
This node joined a swarm as a worker.
# 查看swarm中的节点,*标记的myvm1是manager
[eric@vmwmin1 ~]$ docker-machine ssh myvm1 "docker node ls"
ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION
tjpk0hxlhij9v77yh30ehnzkg * myvm1 Ready Active Leader 18.09.9
w46api0hvap58ghhe3spdd9i7 myvm2 Ready Active 18.09.9
8.2.5 两个service
新建一个docker-compose2.yml
,其内容为:
version: "3"
services:
server:
image: 192.168.56.104:5000/ws3495/cspj-server:tmp
deploy:
replicas: 2
resources:
limits:
cpus: "0.5"
memory: 1024M
restart_policy:
condition: on-failure
ports:
# :
- "27449:27449"
- "27450:27450"
- "18000:18000"
networks:
- cspjnet
visualizer:
image: 192.168.56.104:5000/dockersamples/visualizer:stable
ports:
- "8080:8080"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
deploy:
placement:
constraints: [node.role == manager]
networks:
- cspjnet
networks:
cspjnet:
这个compose文件中有两个service,一个是我们的java应用server;另一个是visualizer,这是一个可以通过浏览器观察swarm节点状态的镜像。我们已经提前把它们push到私有仓库了(visualizer镜像也可以直接从Docker Hub中获取)。
把这个compose文件拷贝到myvm1上(必须是manager节点,不能是worker节点),就可以部署了:
# 把docker-compose2.yml拷贝到myvm1的~目录下
[eric@vmwmin1 ~]$ docker-machine scp docker-compose2.yml myvm1:~
docker-compose2.yml 100% 693 405.5KB/s 00:00
# 向myvm1虚拟机发送指令,进行service部署
[eric@vmwmin1 ~]$ docker-machine ssh myvm1 "docker stack deploy -c docker-compose2.yml cspj"
Creating network cspj_cspjnet
Creating service cspj_server
Creating service cspj_visualizer
# 查看新部署的stack
[eric@vmwmin1 ~]$ docker-machine ssh myvm1 "docker stack ls"
NAME SERVICES ORCHESTRATOR
cspj 2 Swarm
# 查看新部署的service,可以看到现在是部署并启动了2个service,其中cspj_server启动了2份,cspj_visualizer启动了1份
[eric@vmwmin1 ~]$ docker-machine ssh myvm1 "docker service ls"
ID NAME MODE REPLICAS IMAGE PORTS
lo86uqnj2unu cspj_server replicated 2/2 192.168.56.104:5000/ws3495/cspj-server:tmp *:18000->18000/tcp, *:27449-27450->27449-27450/tcp
89d5hype8ert cspj_visualizer replicated 1/1 192.168.56.104:5000/dockersamples/visualizer:stable *:8080->8080/tcp
# CentOS-7系统上(192.168.154.100),所有发送到8080端口的数据包都会被转发到myvm1(192.168.99.102)的8080端口
[eric@vmwmin1 ~]$ sudo firewall-cmd --list-forward-ports
port=8080:proto=tcp:toport=:toaddr=192.168.99.102
port=18000:proto=tcp:toport=:toaddr=192.168.99.102
port=27449:proto=tcp:toport=:toaddr=192.168.99.102
port=27450:proto=tcp:toport=:toaddr=192.168.99.102
使用浏览器连接http://192.168.154.100:8080/,可以看到swarm中节点和service的状态:
8.2.6 清理
通过使用命令docker-machine ssh myvm1 "docker stack rm cspj"
关闭并删除cspj这个stack,会同时停止并删除server和visualizer这两个service,会同时停止并删除cspj_server.1, cspj_server.2, cspj_visualizer这3个docker容器。
通过使用命令docker-machine stop myvm1 myvm2
和docker-machine rm myvm1 myvm2
停止并删除这两个虚拟机。
8.2.7 简化指令
执行docker-machine env myvm1
,按照其输出结果的提示,执行eval $(docker-machine env myvm1)
,可以把myvm1设置为active。此时可以在CentOS-7系统上直接执行docker命令即会向myvm1发送执行,而不必通过docker-machine ssh myvm1 "
向myvm1发送指令了。有兴趣可以自行尝试。
9. 总结
docker为应用部署提供了极大方便,镜像设置好之后,可以在任何地方快速部署,保证一样的执行效果。docker的镜像尽量把每个功能拆分出来,使多个镜像组成stack共同对外提供服务。如果需要数据持久化,可以使用volume功能把数据存储在宿主机上。volume功能也可以在多个service之间共享存储数据。
docker-machine提供了便捷搭建虚拟机,便捷管理虚拟机的能力,使得集群的管理更加方便。
docker中还有很多地方值得探索,比如如何使用config设置配置文件,如何搭建HTTPS方式的私有仓库,kubernetes和swarm的比较,docker的底层工作机制是如何实现的。后续会进一步对这些内容进行分析。
参考文档
- docker for begineres,推荐阅读
- Docker Documentation
- Docker — 从入门到实践
- Docker入门教程
- docker一些官方项目的docs
- What is Docker? How Does it Work?
- About Windows containers
- Get container's ip inside container
- How do I assign a port mapping to an existing Docker container?
- 私有仓库
- Example stack.yml for zookeeper
- Compose file version 3 reference
- Docker Machine Overview
- docker command reference
- Docker Swarm 深入浅出
- Swarm mode key concepts
- How To Install VirtualBox 6.0 / 5.2 on CentOS 7 / RHEL 7
- Virtualbox enable nested vtx/amd-v greyed out