考虑这样一个场景,一个正在运行的 web 应用,它用到了 vue 、java8 、java15 、tomcat 、nginx 、php 、mysql 和 redis 。如果要将这个应用迁移到一台新的服务器上运行,那么就需要在这台机器上重新安装所需的软件以及环境变量,这是很痛苦的。
使用 docker 可以轻松构建一个项目并运行,然而在真实的使用场景中,我们的项目并非是单一的,而是多个项目相互依赖组成一个 web 应用。
那么,有没有什么办法来解决这个问题呢?当然有,使用docker-compose,又叫 docker 编排。
前置条件:Docker和 docker-compose,一键安装见这里。
先来了解几个基础概念。
如果只是想学习如何编排整个 web 应用,可直接从编排容器章节开始阅读。
Image 镜像
操作系统分为内核和用户空间,对于 Linux 而言,内核启动后,会挂载root
文件系统,为其提供用户空间支持。而Docker镜像就相当于一个root
文件系统。比如官方的镜像ubuntu:20.04
就包含了完整的一套 ubuntu 最小系统的rooot
文件系统。
Docker 的镜像是一个比较特殊的文件系统,它除了提供容器运行时所需的东西外,还包含了一些为运行时准备的一些配置参数(挂载卷、环境变量、用户等)。镜像不会包含任何的动态数据,因此在构建之后,它的内容不会被改变。
如果你还是一头雾水的话,可以将它比作“备份”,在需要的时候把它拿出来即可原封不动的获得备份时刻的内容。
可能有些开发者会有疑问,即使是最小的 root 文件系统,它的体积也是庞大的。Docker 在设计时,就充分利用 Union FS 的技术,将其设计为分层存储的架构。这种架构使得镜像的复用、定制变的更为容易,可以用之前构建好的镜像作为基础层,然后进一步添加新的层,以定制自己所需的内容,构建新的镜像。
⚠️注意:镜像构建时,会一层层构建,前一层是后一层的基础。每一层构建完就不会再发生改变,后一层上的任何改变只发生在自己这一层。因此,在编写镜像配置文件的时候,每一层尽量只包含该层需要添加的东西,任何额外的东西应该在该层构建结束前清理掉(比如你在某一层需要删除前一层的文件,它只是将文件标记为了已删除,最终容器运行的时候,虽然不会看到这个文件,但是实际上该文件会一直跟随镜像)。
容器
镜像与容器之间的关系,类似于 Java 中的类与实例。镜像是类,容器是实例化出来的实例。容器可以被创建、启动、停止、删除、暂停等。
容器的实质是进程,但与直接在宿主执行的进程不同,容器进程运行于属于自己的独立的命名空间。因此容器可以拥有自己的 root 文件系统、网络配置、进程空间。容器内的进程是运行在一个隔离的环境里,使用起来,就好像是在一个独立于宿主的系统下操作一样。
前面我们讲过镜像使用的是分层存储,容器也是如此。每一个容器运行时,以镜像为基础层,在其上创建一个当前容器的存储层,这个为容器运行时读写而准备的存储层称之为容器存储层。
⚠️注意:容器存储层的生命周期和容器一样,容器销毁时,存储层也随之销毁。因此,任何保存于存储层的信息都会随着容器的销毁而丢失。
因此,当我们的容器需要进行数据写入时,需要 使用
volume
数据卷 或者 挂载宿主机的目录 来实现数据的持久化。
仓库
当我们构建好镜像之后,很容易在当前的宿主机上运行,但是,如果需要在其他服务器上使用这个镜像,我们就需要一个仓库来存储和分发它们。Docker Registry 就是这样的服务。
一个 Docker Registry 中可以包含多个 仓库(Repository
);每个仓库可以包含多个 标签(Tag
);每个标签对应一个镜像。
一个仓库包含同一个软件不同版本的镜像,标签就对应着软件的各个版本。我们通过<仓库名>:<标签>
的格式来指定具体是那个版本的镜像。
我们以 ubuntu 镜像为例,ubuntu 是仓库的名字,其内包含有不同的版本标签,如:18.04
,20.04
。我们在编写配置文件的时候,就可以通过ubuntu:18.04
、ubuntu:20.04
来指定具体所需版本的镜像。如果忽略了标签,将视为ubuntu:latest
。
公开服务
Docker Registry 公开服务是开放给用户使用,允许用户管理镜像的 Registry 服务。一般这类公开服务允许用户免费上传、下载公开的镜像。
最常使用的公开服务是官方的 Docker Hub,这个也是默认的 Registry 。拥有大量的官方镜像。除此之外,还有:
- Red Hat 的 Quay.io
- Google 的 Google Container Registry
私有部署
当用户制作好一个镜像后,不方便在公开服务上发布时,就需要在内部自己搭建 Docker Registry 。官方提供了 Docker Registry 镜像,可以直接使用做为私有 Registry 服务。
开源的 Docker Registry 镜像只提供了 Docker Registry API 的服务端实现,足以支持 docker
命令,不影响使用。但不包含图形界面,以及镜像维护、用户管理、访问控制等高级功能。
除了官方的 Docker Registry 外,还有第三方软件实现了 Docker Registry API ,提供了用户界面以及一些高级功能。比如:Harbor
创建镜像
Docker 镜像的构建是通过读取Dockerfile
文件来完成的,它本质是一个文本文件,其内包含了一条条的指令,每一条指令构建一层,因此每一条指令的内容,就是描述该层应当如何构建。
编写配置文件
此处以开源项目 chat-system 的后端服务为例,在项目的根目录创建名为chat-system-server-DockerFile
的文件,在文件内添加下述代码:
- 通过 FROM 指令来指定基础镜像
- 通过 COPY 指令来复制文件到镜像内
- 通过 EXPOSE 指令来声明服务运行时的端口号
FROM tomcat:9.0.41-jdk8-openjdk COPY ./chat-system-server.war /usr/local/tomcat/webapps/ COPY ./tomcat/conf/server.xml /usr/local/tomcat/conf/server.xml EXPOSE 8080
上面的指令:我们用了 9.0.41 版本的 tomcat 且 jdk 版本为 1.8 的镜像作为基础环境,随后我们拷贝了项目的 war 包到 tomact 的 webapps 目录下,拷贝了 tomcat 的配置文件到了镜像内。声明了8080
为本服务的访问端口号。
常用的指令
在 DockerFile 中,除了前面我们所讲的,还有很多内置的指令可以用,此处我们挑几个常用的来做下讲解。
- ADD 用于从 URL 获取文件并将其放到目标路径下
- RUN 用于执行命令行命令,支持两种格式:
- shell 格式,
RUN <命令>
相当于直接在命令行中输入命令。 - exec 格式,
RUN ["可执行文件","参数 1","参数 2"]
- shell 格式,
- CMD 用于指定容器在启动时所需要运行的程序以及参数,与 RUN 命令支持的格式相同
- ENV 用于设置环境变量
- WORKDIR 用于指定工作目录,镜像在构建过程中,每一行都代表一层,如果你在上一层做了操作,在当前层需要基于上层完成其他事情,就需要指定它的工作目录
如果想了解完整的的指令列表,可参阅:Dockerfile 指令详解。
⚠️注意:在编写配置文件的时候,如果需要执行多个类似于RUN的指令时,请用
&&
来拼接。因为前面我们讲过 docker 是分层存储机制,每一个指令都会建立一层,如果运行了多个 RUN 指令,它就会创建多层镜像,这是完全没有意义的,而且很多运行时不需要的东西,都被装进了镜像里,比如编译环境、更新的软件包等等。结果就是产生非常臃肿、非常多层的镜像,不仅仅增加了构建部署的时间,也很容易出错。Dockerfile 支持 Shell 类的行尾添加\
的命令换行方式,以及行首#
进行注释的格式。RUN set -x; buildDeps='gcc libc6-dev make wget' \ && apt-get update \ && apt-get install -y $buildDeps \ && wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz"
构建镜像
打开终端,进入 Dockerfile 文件所在在的目录,执行 docker build -t chat-system-server:1.0.0 -f chat-system-server-DockerFile .
命令,即可开始构建流程。
- -t 用于指定容器名
- -f 用于指定配置文件
- . 表示当前目录,在终端执行
build
命令时,需要指定构建镜像的上下文路径。
⚠️注意:如果你没有指定配置文件,它默认会使用目录下名为Dockerfile的文件。如果你的配置文件中包含了 COPY 操作,请务必指定上下文路径。
启动容器
启动容器有两种方式,一种是基于镜像新建一个容器并启动,另一种是启动一个处于终止状态的容器。
新建并启动
使用docker run 镜像名
即可创建一个容器并启动它,我们以上个章节创建的镜像为例。
docker run chat-system-server:1.0.0
命令执行后,你将在控制台看到相关的输出。
容器启动之后,通过镜像中声明的 8080 端口访问,你会发现访问不了。这是因为容器启动后没有做端口映射,我们需要在启动命令中添加-p
参数来指定端口。
- -p 后面跟主机访问地址,
:
后跟的是容器的访问端口。 - 容器的访问端口通常会使用 Dockerfile 中通过
EXPOSE
指令所定义的端口号。
docker run -p 127.0.0.1:8080:8080 chat-system-server:1.0.0
启动已终止容器
使用docker container start 容器名
即可启动一个处于终止状态的容器。因为我们是新建的容器,没有给它命名,docker 会默认一个容器名,我们可以通过docker ps -a
命令来拿到容器名,或者通过 Webstorm 的 Docker 插件来获取。
docker container start crazy_wu
常用的参数与命令
容器在启动时,有丰富的参数可以配置,此处我列举几个最常用的参数。
在后台运行容器
我们新建并启动一个容器时,默认会在当前终端中运行,终端窗口关闭后,容器也会跟着终止。通过-d
参数即可让它在后台运行。
docker run -d -p 127.0.0.1:8080:8080 chat-system-server:1.0.0
为容器命名
我们在新建容器时,可以通过--name
参数来给它命名。
docker run --name local_chat_system_server -d -p 127.0.0.1:8080:8080 chat-system-server:1.0.0
终止与删除容器
我们可以通过stop
和rm
指令来终止和删除容器。
# 终止容器 docker container stop 容器名 # 删除容器 docker container rm 容器名
进入容器
如果容器处于后台运行时,我们需要进入容器,做一些操作。就需要使用docker exec -it 容器名 bash
来实现。
docker exec -it local_chat_system_server bash
执行命令后,就能看到熟悉的终端了。
数据挂载
前面我们说过,容器内存储的数据会随着容器的终止而丢失,需要挂载数据卷来实现数据的持久化存储。通常有两种做法:
- 数据卷
- 目录映射
通过数据卷来做存储需要先使用docker volume create 卷名
命令来创建,新建并启动一个容器的时候通过--mount
指令或者-v 数据卷:容器目录地址
来挂载。
# 创建一个数据卷 docker volume create chat-system-data # 启动容器并挂载数据卷 docker run -d \ --name local_chat_system_server \ --mount source=chat-system-data,target=/usr/local/data \ chat-system-server:1.0.0
容器启动后,会将日志写到我挂载的数据卷中。当我想在主机上查看写入的日志数据时,发现怎么找也找不到。查了很多资料,最后发现他的数据卷存储很复杂,并没有集中存储,而是分布式的。
这种方式显然不是我想要的,而目录映射的方式正好满足我的需求。我们只需要将数据卷改为主机的路径即可,此处我们以-v 参数为例。
docker run -d \ --name local_chat_system_server \ -v /Users/likai/Documents/chat-system-server-data:/usr/local/data \ chat-system-server:1.0.0
⚠️注意:目录映射的形式会把指定的主机路径与容器内的目标路径做关联,本地主机做的操作会响应到容器内,反之亦然。
编排容器
现在,回到文章开头所说的那个场景,一开始打算使用ubuntu:20.04
作为基础镜像,写DockerFile
安装需要的依赖包,设置环境变量,打包成镜像来使用。
但是,如果全部打包到一个镜像里 ,后期维护与扩展将成为恶梦,后面你要添加新项目,想访问一些之前项目里提供的服务,你就得做很大的改动。一般这种场景我们都会使用Docker Compose来实现的。
简而言之,Docker Compose 的作用就是将多个独立的容器组合起来,让容器之间可以轻易的互相访问,最终实现我们的需求。
编写配置文件
容器的编排是通过编写docker-compose.yml
配置文件来实现的,一般我们会将这个文件创建在项目的根目录。它的配置文件中包含有很多配置项,此处我们只列举本文需要用到的。
- version ,用于指定 Docker Compose 文件的格式版本,以确保正确解析和处理文件中的配置,截止目前(2023-11-15)比较主流的版本为 3
- networks ,用于自定义网络
- services ,用于定义各种服务(mysql 、redis 、nginx 等)将它们组合到一起,形成一个 web 应用
定义网络
在物理机上部署服务时,多个服务之间相互访问,需要物理机必须处于同一个网关下(防火墙也要正确的配置),这样就能实现多个容器之间的互联互通。
在 docker-compose 中流程也是一样的,因此我们就需要先定义一个网络,设置好网关。如下所示,我们定义了一个名为kaisir-docker-network的自定义网络。
external: true
表示该网络为外部网络,其他 Docker 容器可以连接到这个网络name
指定了网络名称driver: bridge
指定网络的连接模式为桥接,他还有很多其他模式可供选择( host 、overlay 等)具体请自行查阅相关资料ipam
用于 IP 地址的配置- driver: default ,用于指定 IP 地址管理的驱动程序,此处选择默认即可
- config ,用于配置 IP 地址池,在这里我们定义了一个子网为
192.168.30.0/24
,其中包含了192.168.30.1
到192.168.30.254
的所有 IP 地址。gateway
指定了网关地址为192.168.30.1
networks: kaisir-docker-network: external: true name: kaisir-docker-network driver: bridge ipam: driver: default config: - subnet: 192.168.30.0/24 gateway: 192.168.30.1
通过上述配置,我们就可以在服务中连接这个网络了。有些开发者可能对
192.168.30.0/24
比较疑惑,这里就稍作下解释 。这是一个 CIDR ( Classless Inter-Domain Routing )表示法,用于表示 IP 地址范围。CIDR 表示法包括两部分:IP 地址的网络部分和主机部分,通过斜杠后面的数字表示网络的长度。在
192.168.30.0/24
中:
192.168.30.0
是网络的基础 IP 地址。/24
表示网络的前缀长度,即网络中有多少个连续的 IP 地址。在这里,它表示有 24 位用于网络,剩余的 32 – 24 = 8 位用于主机。具体地,
192.168.30.0/24
表示从192.168.30.0
到192.168.30.255
的 IP 地址范围,其中包括了192.168.30.1
到192.168.30.254
的所有 IP 地址。192.168.30.0
是网络地址,192.168.30.255
是广播地址。
定义服务
我可以在services
指令下定义我们需要的服务,为他们连接网络、挂载数据卷、设置时区、定义访问端口等,我们以 mysql 为例来做讲解,如下所示:
- mysql 为服务名称
- image 为镜像名称
- container_name 为容器名称
- volumes 为我们需要挂载的数据卷
- ports 为端口映射
- networks 为服务需要接入的网络,定义服务需要分配的 ip 地址
- environment 设置容器的环境变量,此处我们设置了 mysql 的 root 用户密码以及时区
services: mysql: image: mysql:5.7.42 container_name: local_mysql volumes: - /Users/likai/Documents/mysql_data:/var/lib/mysql - /Users/likai/Documents/mysql_conf/my.cnf:/etc/my.cnf ports: - 3306:3306 networks: kaisir-docker-network: ipv4_address: 192.168.30.11 environment: - MYSQL_ROOT_PASSWORD=xxxx - TZ=Asia/Shanghai
通过这几行配置,我们就拥有了一个 mysql 服务。并且其他服务可以通过192.168.30.11:3306
访问到这个服务。
我们再来定义 Java 服务和 nginx 服务,以我的chat-system-server
为例,在打包 Java 服务时,将数据库的地址指向我们刚才的 mysql 服务即可。
chat-system-server: image: tomcat:9.0.41-jdk8-openjdk container_name: chat_system_server ports: - 8080:8080 volumes: - /Users/likai/Documents/docker_tomcat/webapps:/usr/local/tomcat/webapps - /Users/likai/Documents/chat-system-server-data:/usr/local/data - /Users/likai/Documents/docker_tomcat/conf/:/usr/local/tomcat/conf environment: - TZ=Asia/Shanghai networks: kaisir-docker-network: ipv4_address: 192.168.30.12 nginx-server: image: nginx:1.18.0 container_name: local_nginx ports: - 80:80 - 443:443 volumes: - /Users/likai/Documents/nginx_config:/etc/nginx - /Users/likai/Documents/nginx_data:/usr/share/nginx/data environment: - TZ=Asia/Shanghai networks: - kaisir-docker-network depends_on: - redis - mysql - chat-system-server - kodbox - halo
同样的,其他的服务也通过这种格式进行定义即可。上面的配置中,我还定义了 redis 、kodbox 、halo 这三个服务。
- kodbox 是我之前搭建的网盘服务,依赖 php 环境
- halo 是我的个人网站,依赖 Java15 环境
因为有多个服务都需要用到数据库,我需要确保数据库先启动,因此上面的配置中,我还用depends_on
指令定义了服务的启动顺序。
⚠️注意:上面的配置文件中,本地路径是写死在配置文件中的,实际在使用的时候一般会通过变量的形式注入一个路径前缀进来,如下所示:
- 如果
MY_VOLUME_PATH
环境变量存在并且非空,则使用它的值- 否则,使用默认值
/default/path
volumes: - ${MY_VOLUME_PATH:-/default/path}/docker_tomcat/webapps:/usr/local/tomcat/webapps
启动服务
最后,我们在终端通过docker-compose up
命令即可启动我们定义好的所有服务。因为我们映射了本地的 8080 端口为 chat_system_server 的服务,我们在 postman 内通过127.0.0.1:8080
即可访问到这个容器所提供的服务了。
docker-compose up
在 nginx 的配置文件中我也暴露了 80 端口出来,指向了 halo 服务,在浏览器中直接访问127.0.0.1
就能看到服务所提供的内容了。
⚠️注意:如果你的配置文件中定义了路径变量则需要在启动时传入这个变量,如下所示:
MY_VOLUME_PATH=/path/to/your/volume docker-compose up
踩坑记录
如果在配置 mysql 服务时,启动服务后会报错find: '/var/lib/mysql/mysql.sock': No such file or directory
,导致启动失败,尝试删除容器、删除镜像,清空主机映射的目录文件,都解决不了。
这是权限原因导致的,在 docker-compose.yml
文件中,给 mysql 服务添加下述配置即可解决。问题解决后,再把这些去掉即可。
command: - /bin/bash - -c - | chmod +rw /var/lib/mysql mysqld
参考: