上一篇文章中,我们学会了 Docker 的原理与机制,侧重于 Docker 开发者的角度。这个文章中,我们从 Docker 使用者的角度来讨论如何让 Docker 便利软件开发与维护工作。

基本概念与逻辑

这一节总结 Docker 中常见的术语,以及它们是如何关联起来,便利开发与运维工作的。

先看一个最基础的 Web 应用架构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
my-app/
├── docker-compose.yml
├── .env
├── nginx/
│ └── default.conf
├── backend/
│ ├── Dockerfile
│ ├── requirements.txt
│ └── app.py
└── frontend/
├── Dockerfile
├── package.json
├── package-lock.json
└── ...

这个 Web 应用涉及前端、后端、数据库以及 nginx 来进行反向代理。

注意到,在后端和前端的文件夹下,存有 Dockerfile 文件,这个文件指导我们如何构建一个 Docker 镜像。

在根目录下,有一个 docker-compose.yml 文件,它相当于配置的大脑,根据 Dockerfile 以及其他配置来启动服务(容器)。

镜像和容器的关系

镜像和容器是 Docker 中的两个核心概念,可以用 OOP 里的类与对象来理解。镜像就是类,是一个模板,其层叠式的结构包含了容器运行所必需的全部数据(代码、依赖、配置文件等)。容器是一个对象,一个实例化应用,是真正运行的服务。

docker-compose.yml 文件所在的目录下,用 docker compose up 来构建并启动容器。

  1. 首先构建项目名、网络;
  2. 之后利用 yml 文件,对每个 service,如果有 build,则执行 docker build <path> 来构建 image;如果有 image,从 Docker Hub 拉取镜像。这一步只是构建了镜像(模板),并没有启动容器;
  3. 如果有需要,创建 volume 来实现数据的持久化;
  4. 对于每个 service,compose 会翻译成一条等价的 docker run
  5. 最后,处理依赖关系,按照顺序执行每个 docker run

看上去有些混乱,没关系,接下来将逐步讲解细节。学习细节后再回看,一切都清晰了。

三个细节内容

我觉得 Docker 有 3 个组分要重点理解:Dockerfile 的语法,docker-compose 的配置,以及 docker 相关的 CLI 命令。

Dockerfile 让我们可以构建个性化容器,docker-compose 很方便的启动多个容器来协同工作,docker 相关的 CLI 命令让我们对镜像与容器进行管理。

Dockerfile 的语法

如果机械地背诵 Dockerfile 中的每条原语(关键字),枯燥且无趣,以几个例子来逐渐掌握其精髓,最后总结一些常见问题与辨析。

1
2
3
4
5
6
7
8
9
10
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["python", "app.py"]

这个例子构建了一个 flask 后端,可以理解为是文章开头那个目录树中,backend/ 下的 Dockerfile 内容。我们逐行来看。

第一行的 FROM,表示以某个镜像为基础来进行我们镜像的构建,格式有如下三种:

  • FROM <image>
  • FROM <image>:<tag>
  • FROM <image>@<digest>

这里,我们选择了从 Docker Hub 中 python 这个官方镜像中拉取标签为 3.11-slim 的镜像。这个镜像等价于依赖 Debian slim 的 Python 3.11 环境,因此我们有了一个已经装好 Python 的 Linux 系统文件树

在这个镜像的肩膀上,我们进行自己镜像的构建工作。首先,选择 /app 作为这个文件系统的工作目录。这个目录是容器中的,和宿主机的系统毫无关系。

这个工作目录会作为其后续 RUNCMDENTRYPOINTCOPYADD 指令的工作目录。换言之,现在你 cd 到了 /app,后续的指令都在这个文件夹下运行。

随后执行 COPY 指令,把第一个参数复制到第二个参数的位置。其中,第一个参数是 build 命令运行时所在的文件夹,也就是前文目录树的 backend/。后面的 . 是一个相对路径,相对于谁呢?WORKDIR 指定的那个目录。

这个 COPY 指令和 scpsftp 这种涉及到远程交互的命令有异曲同工之妙,两个文件分别是不同机器(宿主机、容器)上的位置。

下面执行 RUN 命令,安装必要的依赖。在哪里安装的?还是 WORKDIR

之后,再次复制,把后端的代码文件复制到容器中。为什么要复制两次?一次不好吗?在本节结束,我们会讨论这个事情。

最后,CMD 执行命令,在我们运行 docker run backend 构建完容器后,在容器内运行 python app.py,启动 Python 服务。


再看一个前端的例子,展示分阶段构建的优点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# frontend/Dockerfile

# ---------- Stage 1: build ----------
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build

# ---------- Stage 2: runtime ----------
FROM nginx:alpine

# 拷贝前端构建产物
COPY --from=build /app/dist /usr/share/nginx/html

# 拷贝 nginx 配置
COPY nginx/default.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

这里有两个阶段。第一个阶段,以 node 镜像为基座开始构建我们的镜像。切换工作目录为 /app,复制前端代码到容器内,安装依赖,并构建项目,在 dist 中得到结果。

之后,再以 nginx 为基础镜像来构建,把前一个阶段构建的文件复制到 nginx 的对外目录下。这里需要展开讨论两件事:

  1. COPY 命令的新语法,通过指定 --from 参数从前序镜像中获取我们想要的文件,剩下的丢掉。这要求我们在 FROM 语句中给某个阶段起名字,方便引用;
  2. 这样做的好处是,最终得到的镜像,只包含 nginx 和网页文件,不包含构建时的庞杂依赖与中间文件,节约了镜像体积。

问题讨论

Dockerfile 命令和镜像层次结构的关系

Docker 中,镜像是一个层次的结构。问题来了,刚刚我们讲,Dockerfile 是构建镜像的方法,那,Dockerfile 里的每个指令和层次之间的对应关系是什么?一言以蔽之,使得文件系统的内容发生改变的命令,会让新的层产生。

首先理解什么是「层」,其并不是一个能独立存在的「中间镜像」,而是文件系统的差异快照。当执行一个命令前后,文件系统的内容不同,新的层就会出现。

可以和 Git 的 commit 类比:每次运行完一个 Dockerfile 的命令,会自动执行 git add --all ; git commit。如果前后有内容变化,新的层(commit)产生了,否则会提示 nothing to commit,没有变化。只是举例,Docker 不依赖 Git。

在后端的构建例子中,FROM 会引入新的一层,以及这个镜像的父镜像的一系列层;WORKDIR 如果创建了新的文件夹,也会有一层;COPY 不必多说;RUN 安装了依赖,也会新引入一层。

CMD 会不会引入新的一层?启用后端,意味着数据库可能被改写、日志会被写入。并不会,因为 Docker 中有两个时间段。构建镜像时,前面所述的命令会生成镜像;实例化镜像,即启动容器时,CMDENTRYPOINT 等指令才会运行。这两个过程是严格隔离的。

启动容器后,改写的是可写层;构建镜像时,创建的是只读层,不是一个概念。

为什么构建后端要复制两次

明白了层的概念后,为什么后端要在构建镜像时复制两次?第一条 COPY 显得有点多余,一下子全部复制,不好吗?

还是刚刚所说的,执行一条命令后,触发 addcommit,如果只执行一次复制,试想我们修改了代码中短短一行,会发生什么?这层的缓存失效,自这层开始的后续层都需要重新构建——依赖要重新安装,如果是大型项目,可能还有后续环节。

如果复制两次呢?修改代码,底层依赖不变,第二次复制所对应层的缓存失效,只需要执行一个简单的文件复制,时间开销大大降低。

这告诉我们,在构建镜像的时候,应该按照文件变化的频度来分层:自底层到上层,变化的频度逐渐增加,不要让文件系统扁平化。这和 Git 中倡导我们每做一个功能的变更便 commit 的道理是一致的。

Docker 怎样判断缓存命中

那么,问题来了,你有没有想过,Docker 是如何判断缓存是否命中的?这似乎有个悖论:不运行命令,就得不到输出,没有输出,怎么判断这次输出和缓存是否一致呢?

这是通过输出来判断缓存的思维方式,而 Docker 是采用输入+命令来判断缓存是否命中的。简而言之,有这样一个公式

$$
Layer = f(上一层状态, 指令, 指令的输入)
$$

Docker 对函数输入做哈希,而不是和函数输出比较。大致包含下面的内容:

1
2
3
4
5
6
cache_key =
hash(
parent_layer_digest
+ instruction_text
+ instruction_inputs
)

如果父层的 digest(也是个哈希)没变,执行的指令没变,指令参数也没变,那自然可以认为输出不变。


这有一个潜在的问题,函数的输入是不是全部的输入?换言之,如果指令依赖时间信息、网络信息,Docker 很可能无法捕获到这一点。

如执行 apt-get update,包的版本更新很快,不同时间会有很大的差异。Docker 的缓存判断不了这件事。

比如曾经 build 过一次,之后经过了很长时间,应用迭代,install 指令多了一个依赖。这时候 update 层的缓存仍然命中,而 install 则可能安装不了最新的包。写在一行可以避免这个问题。

此外,一般来说,合并为一行后,还会补充清除缓存文件的指令,这样可以减少镜像的大小。如:

1
2
3
4
5
RUN apt-get update && apt-get install -y \
package1 \
package2 \
package3 && \
rm -rf /var/lib/apt/lists/*

CMD、ENTRYPOINT 和 docker run 的关系

CMD 的格式是:

1
2
3
CMD ["<executable>","<param1>","<param2>"] (exec form, this is the preferred form)
CMD ["<param1>","<param2>"] (as default parameters to ENTRYPOINT)
CMD <command> <param1> <param2> (shell form)

CMD 相当于一个缺省参数,告知 Docker 在启动容器时默认执行的指令。

docker run backend 相当于在容器内执行 python app.py。如果输入 docker run backend bash,那就在容器中执行 bash

CMD 的第二个格式如果想要合法(即只传入参数,不指定可执行文件),则必须用 ENTRYPOINT 指定文件,语法如下:

1
2
ENTRYPOINT ["<executable>", "<param1>", "<param2>"] (exec form, preferred)
ENTRYPOINT <command> <param1> <param2> (shell form)

ENTRYPOINT 如果指定了参数,会覆盖 CMD 中的参数配置。

啰嗦太多,看一张表格吧。

Dockerfile 写法 docker run … 最终执行什么
CMD ["python","app.py"] docker run img python app.py
CMD ["python","app.py"] docker run img bash bash ✅(CMD 被替换)
ENTRYPOINT ["python","app.py"] docker run img python app.py
ENTRYPOINT ["python","app.py"] docker run img bash python app.py bash
ENTRYPOINT ["python","app.py"] + CMD ["--dev"] docker run img python app.py --dev
同上 docker run img --prod python app.py --prod
有 ENTRYPOINT docker run --entrypoint bash img bash ✅(ENTRYPOINT 被替换)

如果指定了多个 CMD 或者 ENTRYPOINT,只有最后一个有效。

SHELL 和 RUN 等指令的关系

执行程序的指令(RUN 等)有两种方式:shell form 和 exec form。前者相当于在 shell 中敲命令,后者相当于直接使用 exec 系统调用来运行程序。

差别在哪?shell form 中,我们的程序 PID != 1,shell 的 PID 是 1。意味着和容器交互时(docker exec),传入的信号先发送给 shell,我们的应用不一定能收到,可能影响容器的优雅退出。

exec form 的好处就是,我们的程序 PID 为 1,代价是我们用不了 shell 展开,如 $HOME 是字面值,并不会被展开为当前用户的家目录。

SHELL 的作用是指定 shell form 所使用的 shell。不会影响 exec form。

仅此而已。而 shell form 也并不被推荐,所以了解一下就好了。

docker-compose 的配置

一言以蔽之,docker-compose.yml 的作用是:让容器的启动可重复、更简单。前半句的意思是,每次手动运行 docker run,难以记录并重复;后半句的意思是,不必琢磨指令的复杂执行顺序,docker compose 来解析。

看个例子:

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
# docker-compose.yml
version: "3.9"

services:
nginx:
image: my-frontend-nginx
ports:
- "80:80"
depends_on:
- backend

backend:
build: ./backend
env_file:
- .env
expose:
- "8000"

db:
image: mysql:8.0
env_file:
- .env
volumes:
- mysql-data:/var/lib/mysql

volumes:
mysql-data:

Notes:

  • version 用来表示 yaml 按哪套语法规则来解析,在 compose v1 时代,是有用的。现在这个字段不强制使用;
  • services 表示要启用的服务,这里有 3 个,每个服务对应一个容器;
  • 每个服务中,如果是 build,则根据 build 字段指定的位置中的 Dockerfile 进行镜像搭建;如果是 image,则从 Docker Hub 拉取镜像:
  • volumes 实现目录的挂载,有两种格式<宿主机路径>:<容器内路径><卷名称>:<容器内路径>
    • 前者使得容器访问内部路径时,其实访问的是宿主机的内容,相当于符号链接;
    • 后者将数据由 Docker 管理,不关心宿主机的具体目录,适合数据库和持久化状态(一般存在宿主机的/var/lib/docker/volumes 下)。使用这个方法,必须在外层的 volumes 中声明卷名。先在外层声明,再在服务中使用;
  • ports 表示暴露的端口,格式是<宿主机端口>:<容器端口>。如果没有宿主机端口,则只能通过 Docker 网络访问,不会在宿主机上占用端口;
  • depends_on 可以指定依赖关系;
  • 配置文件中的相对路径 .,指的是相对于 docker-compose.yml 所在目录。

问题讨论

这里暴露的端口与 Dockerfile 中暴露的端口有何联系

在前端的 Dockerfile 中,我们指定了 EXPOSE 80,这里又写了 expose 80:80,是否重复了?如果不对应会怎样?

Dockerfile 中的 80,充其量相当于注释,用处不大。但是比注释强的地方是,Docker 认它,可以通过一些配置指令获取这个镜像约定的监听端口。

docker-compose.yml 中的 80:80 是真正实例化镜像,用 docker run 启动容器,真正实现了端口的映射——容器内的 80 端口(第二个 80)与宿主机的 80 端口(第一个 80)对应。

问题来了,如果我写 80:60,怎样?没什么用。现在宿主机的 80 端口和容器的 60 端口绑定,然而容器内没有应用监听 60 端口——服务失效。

要是 81:80 呢?可以,但不方便。现在容器内 nginx 监听 80 端口,对应宿主机的 81 端口。直接域名访问,不通,因为 http 服务默认在宿主机的 80 端口。除非请求的时候用户在导航栏输入 www.example.com:81,显式指定,这样才能被 nginx 监听到。

凭什么容器中 nginx 监听的端口是 80?我想改成 60 行不行。这个 80 是有来由的,把我们这个应用的 nginx 配置文件补上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# nginx/default.conf
server {
listen 80;
server_name www.example.com;

# 前端页面
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri /index.html;
}

# 后端 API
location /api/ {
proxy_pass http://backend:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}

看到其中的 listen 80 了吗?在这里指定的。要是你改成 listen 60,那 80:60 就 OK 了。

什么是 Docker 网络

关于 Docker 网络,指的是 Docker 可以为项目创建网络,让这个项目中每个服务对应的容器可以通信。比如 nginx 中通过 backend:8000 来访问后端容器;或者后端通过 db 来连接数据库:

1
2
3
4
5
6
# Backend
DB_HOST=db
DB_PORT=3306
DB_NAME=myapp
DB_USER=myuser
DB_PASSWORD=mypass
1
2
3
4
5
6
7
8
9
10
11
import os
import pymysql

conn = pymysql.connect(
host=os.environ["DB_HOST"],
user=os.environ["DB_USER"],
password=os.environ["DB_PASSWORD"],
database=os.environ["DB_NAME"],
)

print("Connected to MySQL!")

docker-compose 自带 DNS,将服务名映射到(私有) IP 地址。每个服务的名字相当于 DNS 的主机名。Voilà,非常方便。

有关 Docker 网络实现的细节,不再赘述。大体涉及到虚拟交换机、DNS 等内容。强调的地方是,只有在同一个网络内的容器才能通信。如果不配置容器的网络,其在默认的网络中,虽然容器间确实可以通信,但不可靠,不推荐。

docker 的 CLI 命令

最后,再花一点篇幅讲讲 docker 的 CLI 命令。不列举,讲一个全流程的场景。

阶段一:启动

启动整个项目:docker compose up

后台启动,-d = detached,容器在后台跑,终端立刻返回 docker compose up -d

只启动某一个服务:docker compose up -d backend

阶段二:查看运行时状态

docker compose ps 查看 compose 管理的容器。

docker ps 查看所有 running 的容器,加上 -a 能看到已停止的容器。

查看容器日志,可以通过 compose 方式 docker compose logs,只看某个服务的 docker compose logs backend,加上 -f 则是实时跟随,就像 tail -f 一样。

docker 原生的命令,也可以查看日志:docker logs project-backend

通过 docker exec -it project-backend sh 或者 docker compose exec backend sh 来进入容器(前提是容器由 compose 创建)。

通过 docker inspect project-backend 来查看容器的配置信息。

阶段三:停止

docker compose stop 停止整个 compose 项目,此时容器、卷和网络都在。

docker compose down 会停止并删除容器、删除网络。但不会删除命名卷和镜像。

阶段四:清理

docker rm project-backend 删除单个容器,加上 -f 表示强制删除。

docker images 查看镜像,或者 docker image ls

通过 docker rmi backend:latest 删除镜像,如果镜像被容器占用,先删除占用的容器。

清理停止的容器:docker container prune

清理悬置镜像:docker image prune

一把梭:docker system prune

阶段五:关于 volume

查看 volumes:docker volume ls

删除 volume:docker volume rm mysql-data

问题讨论

为什么需要 compose,run 不好吗

docker run 只适合一次性地运行临时容器,从技术上讲是可以支撑长期服务的,然而维护起来很麻烦:每一步都要重新拼命令、容易出错、无法 diff、无法自动化。这就是 compose 出现的目的:替代 docker run。

compose 如何确定是哪个项目

执行 docker compose stop,停止的是哪个项目?并没有指定项目名称呀。

默认规则是,当前目录下 docker-compose.yml 启动的项目。如果当前目录下没有这个文件,会报错。

如果当前目录下启动了多个项目,可以通过参数的方式来显式指定关闭某个项目。