Docker 入门之实际应用
序
在上一篇文章中,我们学会了 Docker 的原理与机制,侧重于 Docker 开发者的角度。这个文章中,我们从 Docker 使用者的角度来讨论如何让 Docker 便利软件开发与维护工作。
基本概念与逻辑
这一节总结 Docker 中常见的术语,以及它们是如何关联起来,便利开发与运维工作的。
先看一个最基础的 Web 应用架构:
1 | |
这个 Web 应用涉及前端、后端、数据库以及 nginx 来进行反向代理。
注意到,在后端和前端的文件夹下,存有 Dockerfile 文件,这个文件指导我们如何构建一个 Docker 镜像。
在根目录下,有一个 docker-compose.yml 文件,它相当于配置的大脑,根据 Dockerfile 以及其他配置来启动服务(容器)。
镜像和容器的关系:
镜像和容器是 Docker 中的两个核心概念,可以用 OOP 里的类与对象来理解。镜像就是类,是一个模板,其层叠式的结构包含了容器运行所必需的全部数据(代码、依赖、配置文件等)。容器是一个对象,一个实例化应用,是真正运行的服务。
在 docker-compose.yml 文件所在的目录下,用 docker compose up 来构建并启动容器。
- 首先构建项目名、网络;
- 之后利用 yml 文件,对每个 service,如果有 build,则执行
docker build <path>来构建 image;如果有 image,从 Docker Hub 拉取镜像。这一步只是构建了镜像(模板),并没有启动容器; - 如果有需要,创建 volume 来实现数据的持久化;
- 对于每个 service,compose 会翻译成一条等价的
docker run; - 最后,处理依赖关系,按照顺序执行每个
docker run。
看上去有些混乱,没关系,接下来将逐步讲解细节。学习细节后再回看,一切都清晰了。
三个细节内容
我觉得 Docker 有 3 个组分要重点理解:Dockerfile 的语法,docker-compose 的配置,以及 docker 相关的 CLI 命令。
Dockerfile 让我们可以构建个性化容器,docker-compose 很方便的启动多个容器来协同工作,docker 相关的 CLI 命令让我们对镜像与容器进行管理。
Dockerfile 的语法
如果机械地背诵 Dockerfile 中的每条原语(关键字),枯燥且无趣,以几个例子来逐渐掌握其精髓,最后总结一些常见问题与辨析。
1 | |
这个例子构建了一个 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 作为这个文件系统的工作目录。这个目录是容器中的,和宿主机的系统毫无关系。
这个工作目录会作为其后续 RUN、CMD、ENTRYPOINT、COPY 和 ADD 指令的工作目录。换言之,现在你 cd 到了 /app,后续的指令都在这个文件夹下运行。
随后执行 COPY 指令,把第一个参数复制到第二个参数的位置。其中,第一个参数是 build 命令运行时所在的文件夹,也就是前文目录树的 backend/。后面的 . 是一个相对路径,相对于谁呢?WORKDIR 指定的那个目录。
这个 COPY 指令和 scp、sftp 这种涉及到远程交互的命令有异曲同工之妙,两个文件分别是不同机器(宿主机、容器)上的位置。
下面执行 RUN 命令,安装必要的依赖。在哪里安装的?还是 WORKDIR。
之后,再次复制,把后端的代码文件复制到容器中。为什么要复制两次?一次不好吗?在本节结束,我们会讨论这个事情。
最后,CMD 执行命令,在我们运行 docker run backend 构建完容器后,在容器内运行 python app.py,启动 Python 服务。
再看一个前端的例子,展示分阶段构建的优点。
1 | |
这里有两个阶段。第一个阶段,以 node 镜像为基座开始构建我们的镜像。切换工作目录为 /app,复制前端代码到容器内,安装依赖,并构建项目,在 dist 中得到结果。
之后,再以 nginx 为基础镜像来构建,把前一个阶段构建的文件复制到 nginx 的对外目录下。这里需要展开讨论两件事:
COPY命令的新语法,通过指定--from参数从前序镜像中获取我们想要的文件,剩下的丢掉。这要求我们在FROM语句中给某个阶段起名字,方便引用;- 这样做的好处是,最终得到的镜像,只包含 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 中有两个时间段。构建镜像时,前面所述的命令会生成镜像;实例化镜像,即启动容器时,CMD、ENTRYPOINT 等指令才会运行。这两个过程是严格隔离的。
启动容器后,改写的是可写层;构建镜像时,创建的是只读层,不是一个概念。
为什么构建后端要复制两次
明白了层的概念后,为什么后端要在构建镜像时复制两次?第一条 COPY 显得有点多余,一下子全部复制,不好吗?
还是刚刚所说的,执行一条命令后,触发 add 和 commit,如果只执行一次复制,试想我们修改了代码中短短一行,会发生什么?这层的缓存失效,自这层开始的后续层都需要重新构建——依赖要重新安装,如果是大型项目,可能还有后续环节。
如果复制两次呢?修改代码,底层依赖不变,第二次复制所对应层的缓存失效,只需要执行一个简单的文件复制,时间开销大大降低。
这告诉我们,在构建镜像的时候,应该按照文件变化的频度来分层:自底层到上层,变化的频度逐渐增加,不要让文件系统扁平化。这和 Git 中倡导我们每做一个功能的变更便 commit 的道理是一致的。
Docker 怎样判断缓存命中
那么,问题来了,你有没有想过,Docker 是如何判断缓存是否命中的?这似乎有个悖论:不运行命令,就得不到输出,没有输出,怎么判断这次输出和缓存是否一致呢?
这是通过输出来判断缓存的思维方式,而 Docker 是采用输入+命令来判断缓存是否命中的。简而言之,有这样一个公式
$$
Layer = f(上一层状态, 指令, 指令的输入)
$$
Docker 对函数输入做哈希,而不是和函数输出比较。大致包含下面的内容:
1 | |
如果父层的 digest(也是个哈希)没变,执行的指令没变,指令参数也没变,那自然可以认为输出不变。
这有一个潜在的问题,函数的输入是不是全部的输入?换言之,如果指令依赖时间信息、网络信息,Docker 很可能无法捕获到这一点。
如执行 apt-get update,包的版本更新很快,不同时间会有很大的差异。Docker 的缓存判断不了这件事。
比如曾经 build 过一次,之后经过了很长时间,应用迭代,install 指令多了一个依赖。这时候 update 层的缓存仍然命中,而 install 则可能安装不了最新的包。写在一行可以避免这个问题。
此外,一般来说,合并为一行后,还会补充清除缓存文件的指令,这样可以减少镜像的大小。如:
1 | |
CMD、ENTRYPOINT 和 docker run 的关系
CMD 的格式是:
1 | |
CMD 相当于一个缺省参数,告知 Docker 在启动容器时默认执行的指令。
如 docker run backend 相当于在容器内执行 python app.py。如果输入 docker run backend bash,那就在容器中执行 bash。
CMD 的第二个格式如果想要合法(即只传入参数,不指定可执行文件),则必须用 ENTRYPOINT 指定文件,语法如下:
1 | |
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 | |
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 | |
看到其中的 listen 80 了吗?在这里指定的。要是你改成 listen 60,那 80:60 就 OK 了。
什么是 Docker 网络
关于 Docker 网络,指的是 Docker 可以为项目创建网络,让这个项目中每个服务对应的容器可以通信。比如 nginx 中通过 backend:8000 来访问后端容器;或者后端通过 db 来连接数据库:
1 | |
1 | |
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 启动的项目。如果当前目录下没有这个文件,会报错。
如果当前目录下启动了多个项目,可以通过参数的方式来显式指定关闭某个项目。
