第六章: 终结繁琐:使用 Docker Compose 编排多容器应用
第六章: 终结繁琐:使用 Docker Compose 编排多容器应用
Prorise第六章: 终结繁琐:使用 Docker Compose 编排多容器应用
摘要: 在本章,我们将彻底告别那些繁琐、冗长且需要手动按顺序执行的 docker
命令。我们将学习使用 Docker Compose——现代容器化应用编排的基石——来让我们用一份独立的、声明式的 docker-compose.yml
配置文件,描述 并 管理 我们整个前后端分离的应用。本章结束后,您将能通过 docker compose up
和 docker compose down
两个简单的命令,实现整个应用环境的一键启动与销毁。
本章的起点:回顾第五章的“痛苦”
在深入 Docker Compose 之前,让我们清晰地回顾一下,在上一章为了让我们的前后端应用跑起来,我们手动执行了多少步骤:Z
docker network create my-app-network
(手动创建网络)docker volume create my-api-data
(手动创建数据卷)docker run -d --name backend-api --network ... -v ... my-api:1.1
(手动启动后端,并挂载网络和数据卷)docker run -d --name frontend --network ... -p ... my-react-app:production-v1.0
(手动启动前端,并挂载网络和端口)docker stop backend-api frontend && docker rm backend-api frontend
(手动清理)
这个过程不仅繁琐,而且极易出错。任何一个参数的遗漏或错误,都可能导致应用无法正常工作。Docker Compose 的诞生,正是为了将我们从这种指令式的、过程化的操作中解放出来。
6.1. 初识 docker-compose.yml
:将命令翻译为配置
承上启下: 我们将学习如何将上一章那些冗长的 docker run
命令,逐字逐句地“翻译”成 docker-compose.yml
文件中的配置项。这将是我们从“命令式”操作转向“声明式”管理的第一次实践。
第一步:创建 docker-compose.yml
文件
在您项目的根目录下(即包含 my-react-app
和 backend-api
两个子目录的地方),创建一个名为 docker-compose.yml
的新文件。这个 YAML 文件将是我们应用所有服务的“总纲”。
第二步:将后端服务“翻译”为 Compose 配置
我们的目标是翻译这条命令:docker run -d --name backend-api --network my-app-network -v my-api-data:/app/data my-api:1.1
我们在 docker-compose.yml
中这样描述它:
1 | # docker-compose.yml (v0.1 - 初始翻译) |
引入一个更佳实践:由 Compose 直接构建镜像
直接使用 image: my-api:1.1
意味着我们必须先手动 docker build
好这个镜像。但 Compose 提供了更强大的功能:它可以直接根据 Dockerfile
为我们构建镜像。
我们将 image
配置项替换为 build
:
1 | # docker-compose.yml (v0.1 - 初始翻译) |
这比手动构建要方便得多。
第三步:将前端服务“翻译”为 Compose 配置
同样地,我们来翻译前端的启动命令:docker run -d --name frontend --network my-app-network -p 8080:80 my-react-app:production-v1.0
将其追加到 docker-compose.yml
文件中:
1 | # docker-compose.yml (v0.1 - 完整初稿) |
关于 version
字段
在旧版的 docker-compose.yml
文件中,你通常会在文件顶部看到一个 version: '3.8'
这样的字段。在新版的 Docker Compose 中,这个字段已经变为可选。Compose 会根据你使用的 YAML 关键字自动推断文件格式。为保持简洁,我们遵循现代规范,省略该字段。
第四步:第一次运行
我们已经有了一个初步的 docker-compose.yml
文件。现在,我们不再需要 docker run
了。在 docker-compose.yml
所在的根目录下,执行:
1 | docker compose up |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[+] Building 2.5s (15/15) FINISHED
=> [backend-api internal] load build definition from Dockerfile
=> => transferring dockerfile: 142B
...
=> [frontend internal] load build definition from Dockerfile
=> => transferring dockerfile: 621B
...
[+] Running 2/2
✔ Container backend-api Started
✔ Container frontend Started
Attaching to backend-api, frontend
backend-api | API server listening on port 3000
frontend | /docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
...
您会看到:
- Docker Compose 首先并行 构建 了
backend-api
和frontend
两个服务的镜像。 - 然后,它 启动 了两个容器。
- 由于我们没有加
-d
参数,所有服务的日志会混合输出到当前终端,这对于初次启动和调试非常有用。
现在,你可以访问 http://localhost:8080
,前端页面应该可以正常显示。
第五步:识别缺失的环节并清理
虽然服务启动了,但我们的 v0.1
版本还存在明显的问题:
- 我们还没有定义
my-app-network
,所以前后端容器之间依然无法通信。 - 我们还没有定义
my-api-data
数据卷,所以后端的数据依然是易失的。
在终端中按下 Ctrl+C,Compose 会优雅地停止所有服务。然后,执行以下命令来彻底清理本次启动创建的所有资源(容器、默认网络等):
1 | docker compose down |
我们已经成功地将启动命令转化为了配置文件,并体验了 docker compose
的基本工作流程。在下一节,我们将把网络和数据卷的定义也加入进来,构建一个完整的多服务应用。
6.2. 协同工作:在 Compose 中定义 networks
与 volumes
承上启下: 在上一节,我们成功地将 docker run
命令的基本操作“翻译”成了 docker-compose.yml
中的 services
配置。然而,我们的应用目前还处于“残缺”状态:前后端服务之间无法通信,后端的数据也无法持久化。现在,我们将把在第五章手动创建的 network
和 volume
也纳入 Compose 的管理体系,构建一个功能完整的应用栈。
第一步:在 Compose 文件中声明“共享资源”
docker-compose.yml
不仅能定义 services
,还能在顶层定义我们整个应用栈所需的共享资源,例如网络和数据卷。
请用以下内容 覆盖 你现有的 docker-compose.yml
文件。我们在 services
的 同级,新增了 networks
和 volumes
两个顶层关键字。
1 | # docker-compose.yml (v0.2 - 添加网络与数据卷) |
代码解读:
- 我们在文件底部使用
networks
和volumes
块,像声明变量一样,声明 了我们应用需要一个名为my-app-network
的网络和一个名为my-api-data
的数据卷。 - 在每个
service
的定义内部,我们通过networks
和volumes
关键字来 引用 这些已声明的资源,将服务“接入”网络或“挂上”数据卷。
第二步:“一键启动”完整应用
我们的 docker-compose.yml
文件现在已经描述了一个功能完整的应用栈。让我们来启动它。这次,我们将使用 -d
参数,让它在后台运行。
1 | docker compose up -d |
1
2
3
4
5
[+] Running 4/4
✔ Network my-docker-guide_my-app-network Created
✔ Volume "my-docker-guide_my-api-data" Created
✔ Container backend-api Started
✔ Container frontend Started
这就是 Compose 的魔力! 只用一个命令,Docker Compose 就为我们自动完成了所有事情:
- 它检查到
my-app-network
网络不存在,于是 自动创建 了它。 - 它检查到
my-api-data
数据卷不存在,于是也 自动创建 了它。 - 它构建了两个服务的镜像(如果需要),并以正确的配置(连接了网络、挂载了数据卷)启动了两个容器。
你可能会注意到,Compose 创建的网络和数据卷名前面,被自动加上了项目目录名作为前缀(例如 my-docker-guide_my-app-network
)。这是 Compose 用来隔离不同项目资源的方式,非常实用。
第三步:全面验证
验证网络通信: 我们可以使用
docker compose exec
命令,在某个服务容器内执行命令。1
2# 在 frontend 容器内,执行 curl 命令访问 backend-api 服务
docker compose exec frontend sh -c "curl http://backend-api:3000/api/data"您应该能看到后端 API 成功返回了 JSON 数据,证明了服务间的 DNS 解析和通信完全正常。
验证数据持久化:
- 连续执行几次上面的
exec
命令,让计数器增长。 - 现在,执行
docker compose down
来 销毁 应用环境。该命令会停止并删除容器、网络,但 默认会保留具名数据卷,以防数据丢失。1
docker compose down
- 再次执行
docker compose up -d
重建 应用环境。 - 最后,再次执行
exec
命令访问 API。1
docker compose exec frontend sh -c "curl http://backend-api:3000/api/data"
您会发现
hits
计数器在上次的基础上继续增加了!这证明了数据卷成功地在容器的“生死轮回”之间,为我们保全了数据。- 连续执行几次上面的
如果您希望在 down
的时候,连同数据卷也一起删除,可以使用 -v
标志:docker compose down -v
。请谨慎操作此命令。
我们现在已经拥有了一个健壮的、可一键部署和销毁的多服务应用定义。在下一节,我们将为这个应用引入数据库,并学习如何管理服务之间的启动依赖关系。
6.3. 管理依赖与健康:depends_on
与 healthcheck
承上启下: 我们的应用正在变得越来越真实。一个典型 Web 应用除了前后端,还必然包含一个数据库。现在,我们将为系统添加一个 PostgreSQL 数据库服务。这个新角色的加入,会立刻引入一个在多服务架构中至关重要的新问题:服务启动依赖。后端 API 必须在数据库完全准备好之后才能启动,否则就会因为连接失败而崩溃。
第一步:在 docker-compose.yml
中添加数据库服务
我们首先在 docker-compose.yml
文件中定义我们的新成员:一个 postgres
数据库服务。
请用以下内容 覆盖 你现有的 docker-compose.yml
文件。注意新增的 db
服务和 db-data
数据卷:
1 | # docker-compose.yml (v0.3 - 添加数据库) |
第二步:改造后端 API 并重建镜像(关键修正)
现在,我们让 backend-api
在启动时去尝试连接数据库。这需要我们安装新的 npm
依赖,并 明确地重建镜像。
首先,为 backend-api
项目添加 pg
依赖库。
1 | cd backend-api |
然后,用以下仅用于测试数据库连接的代码,覆盖 backend-api/index.js
。
1 | // backend-api/index.js (仅用于测试数据库连接) |
接着,执行最关键的一步:重建后端镜像。
因为我们修改了 package.json
(通过 npm install pg
),旧的镜像已经过时,它内部的 node_modules
缺少 pg
模块。我们必须基于新的代码和依赖,构建一个新的镜像。docker compose build
命令就是为此而生。
1 | docker compose build backend-api |
这个命令会告诉 Compose:“请只重新构建 backend-api
服务的镜像”。
第三步:制造“启动失败”的场景
镜像已正确更新,现在我们可以来观察服务间的启动竞争问题了。
1 | docker compose up |
仔细观察前台输出的日志。你会看到 backend-api
服务在疯狂地尝试重启,并不断打印出 Failed to connect... ECONNREFUSED
的错误。
1 | my-app-db | ... database system is ready to accept connections |
痛点所在: Docker Compose 默认以 并行 方式启动所有服务。backend-api
启动得太快了,此时 db
容器虽然可能已经创建,但其内部的 PostgreSQL 服务进程还 没有完全初始化好并准备接受连接,导致 API 连接失败并崩溃。
请按下 Ctrl+C 并执行 docker compose down
清理环境。
第四步:使用 depends_on
与 healthcheck
确保服务可用
为了解决这个问题,我们需要一个组合拳:使用 depends_on
控制启动的 顺序,并使用 healthcheck
确保我们等待的是一个 真正可用 的服务。
请将最终的、完整的 docker-compose.yml
修改如下:
1 | # docker-compose.yml (v0.4 - 健壮的依赖管理) |
关键改动:
- 我们在
db
服务下添加了healthcheck
块,使用 PostgreSQL 自带的pg_isready
工具来检查数据库是否就绪。 - 我们将
backend-api
的depends_on
修改为更精确的格式,明确要求它等待db
服务的状态变为healthy
后再启动。
最终验证
现在,再次执行 docker compose up -d
启动整个应用。然后立刻执行 docker compose ps
查看服务状态,你会看到 db
服务的状态最初是 running (health: starting)
,几秒后会变为 running (healthy)
。
此时,查看 backend-api
的日志 docker compose logs backend-api
,你会看到一条干净利落的成功连接信息:
✅ Successfully connected to the database!
6.4. 解耦配置:环境变量与 .env
文件
承上启下: 在上一节,我们成功地为应用添加了数据库服务,并解决了启动依赖问题。但在 docker-compose.yml
文件中,我们留下了一个巨大的隐患:我们将数据库的用户名和密码 硬编码 在了配置文件里。将敏感凭证直接写入版本控制系统,是软件开发中的 头号禁忌。
解决方案:使用 .env
文件进行配置解耦
Docker Compose 提供了一套优雅的机制来解决这个问题。它允许我们在 docker-compose.yml
中使用变量占位符,然后在一个名为 .env
的独立文件中定义这些变量的实际值。Compose 在启动时会自动读取 .env
文件,并将值注入到配置中。
核心优势:
- 安全: 我们可以将
.env
文件添加到.gitignore
中,确保敏感信息永远不会被提交到代码仓库。 - 灵活: 团队中的每个成员都可以在本地维护自己的
.env
文件,而无需修改共享的docker-compose.yml
。 - 环境一致: 生产服务器上可以放置一个包含生产密码的
.env
文件,实现不同环境的配置切换。
第一步:创建 .env
文件
在您项目的根目录下(与 docker-compose.yml
文件位于同一级),创建一个名为 .env
的新文件。
1 | # .env - 存储我们所有的敏感信息和环境特定配置 |
在真实项目中,请立刻将 .env
文件添加到你的 .gitignore
文件中!
第二步:改造 docker-compose.yml
以使用变量
现在,我们修改 docker-compose.yml
,用 ${VARIABLE_NAME}
的语法来引用 .env
文件中定义的变量。同时,我们也要将这些凭证传递给 backend-api
服务,以便它能连接到数据库。
请用以下内容 覆盖 你现有的 docker-compose.yml
文件:
1 | # docker-compose.yml (v0.5 - 使用环境变量) |
第三步:改造 backend-api
以读取环境变量
我们的 backend-api
服务现在需要从环境变量(process.env
)中读取数据库连接信息,而不是使用硬编码的值。
用以下内容 覆盖 backend-api/index.js
文件:
1 | // backend-api/index.js (读取环境变量) |
第四步:最终验证
所有改造都已完成。现在,我们来验证配置是否被成功加载。
首先,清理掉可能在运行的旧环境。
1 | docker compose down |
接着,使用 docker compose config
命令来预览 Compose 解析后的最终配置。 这是一个非常有用的调试工具。
1 | docker compose config |
在输出的 YAML 中,你应该能看到 db
和 backend-api
服务的 environment
部分,${...}
占位符已经被 .env
文件中的 实际值 所替换。
然后,一键启动整个应用。
1 | docker compose up -d |
最后,查看 backend-api
的日志。
1 | docker compose logs backend-api |
你应该能看到那条成功的连接信息:✅ Successfully connected to the database using credentials from .env file!
6.5. 环境隔离:使用 profiles
管理服务组合
承上启下: 我们的 docker-compose.yml
正在演变成一个完整的应用定义,包含了前端、后端和数据库。在真实的开发流程中,我们常常还需要一些 辅助服务,例如数据库管理工具、日志分析平台或测试工具。这些服务在开发和调试时非常有用,但在默认启动或生产部署时,我们并不希望运行它们。profiles
功能正是为了优雅地解决这个问题而设计的。
痛点背景:如何管理“可选”服务?
我们希望在 docker-compose.yml
中定义一个数据库管理工具(例如 Adminer),但我们不希望每次执行 docker compose up
时它都自动启动。我们只想在需要进行数据库调试时,才手动将它“激活”。
解决方案:为服务分配 profiles
(配置文件)
profiles
关键字允许我们为一个或多个服务打上“标签”。被打上标签的服务,将不会被默认启动。只有当我们在命令行中明确激活该标签(profile)时,这些服务才会被创建和启动。
第一步:在 docker-compose.yml
中添加调试服务
我们来为应用栈添加一个 adminer
服务。Adminer 是一个轻量级的、通过 Web 界面管理多种数据库的工具。
请将 adminer
服务的定义,添加到您的 docker-compose.yml
文件中。
1 | # docker-compose.yml (v0.6 - 添加 Profile) |
第二步:验证默认启动行为
现在,我们来启动我们的应用栈。
1 | docker compose up -d |
启动完成后,我们来查看正在运行的服务。
1 | docker compose ps |
1
2
3
4
NAME IMAGE COMMAND SERVICE STATUS PORTS
backend-api my-docker-guide-backend-api "docker-entrypoint.s…" backend-api running
frontend my-docker-guide-frontend "/docker-entrypoint.…" frontend running 0.0.0.0:8080->80/tcp
my-app-db postgres:16-alpine "docker-entrypoint.s…" db running (healthy) 5432/tcp
请注意,adminer
服务 没有 被启动。因为我们为它分配了一个 profile
,它已经变成了一个“可选”服务。
第三步:激活 debug
Profile
现在,我们假设需要调试数据库。我们可以使用 --profile
标志来同时启动默认服务和 debug
profile 中的所有服务。
首先,清理掉当前的环境。
1 | docker compose down |
然后,使用 --profile
标志启动。
1 | docker compose --profile debug up -d |
再次查看正在运行的服务。
1 | docker compose ps |
1
2
3
4
5
NAME IMAGE COMMAND SERVICE STATUS PORTS
adminer adminer "entrypoint.sh docke…" adminer running 0.0.0.0:8081->8080/tcp
backend-api my-docker-guide-backend-api "docker-entrypoint.s…" backend-api running
frontend my-docker-guide-frontend "/docker-entrypoint.…" frontend running 0.0.0.0:8080->80/tcp
my-app-db postgres:16-alpine "docker-entrypoint.s…" db running (healthy) 5432/tcp
这一次,adminer
服务成功启动了!
第四步:使用 Adminer 连接数据库
现在,打开你的 Windows 浏览器,访问 http://localhost:8081
。你将看到 Adminer 的登录界面。
- System: 选择
PostgreSQL
- Server: 输入
db
(这是我们在 Compose 中为数据库服务定义的名字!) - Username: 输入我们在
.env
文件中定义的prorise_user
- Password: 输入我们在
.env
文件中定义的s3cr3t_p@ssw0rd_zxcv
- Database: 输入我们在
.env
文件中定义的prorise_db
点击登录,你就可以通过图形化界面来查看和管理 my-app-db
容器中的数据库了。
在结束本节前,请清理环境:
1 | docker compose down |
6.6. 本章核心速查总结:Docker Compose 常用配置与命令
承上启下: 恭喜您!您已经成功地从执行零散的 docker
命令,迈入了使用 docker-compose.yml
进行声明式服务编排的全新阶段。我们通过一步步的迭代,将一个多服务应用从手动管理的混乱状态,转化为了一个优雅、健壮、可一键启停的应用栈。本节将把本章所有核心的 YAML 关键字和 CLI 命令浓缩起来,作为您日后工作中可随时查阅的“弹药库”。1
docker-compose.yml
核心关键字速查
分类 | 关键字 | 核心描述与用法 |
---|---|---|
服务定义 | services | 顶层关键字,所有独立的应用服务都在其下定义。 |
镜像来源 | build: <path> | (推荐) 指定包含 Dockerfile 的路径,让 Compose 负责构建镜像。 |
镜像来源 | image: <name>:<tag> | 指定一个已经存在于本地或远程仓库的镜像。 |
容器配置 | container_name: <name> | 设置一个固定的、可读的容器名称,对应 docker run --name 。 |
网络 | ports: ["<host>:<container>"] | 映射端口,对应 docker run -p 。 |
网络 | networks: ["<net_name>"] | 将服务连接到在顶层 networks 块中定义的网络。 |
数据持久化 | volumes: ["<vol/path>:<ctn_path>"] | 挂载数据卷或绑定挂载,对应 docker run -v 。 |
配置 | environment: ["KEY=VALUE"] | 设置环境变量,支持从 .env 文件进行 ${VAR} 格式的变量替换。 |
启动控制 | depends_on | 控制服务启动顺序。推荐与 healthcheck 结合使用。 |
启动控制 | healthcheck | 定义一个命令来检查容器内应用是否真正健康、可用。 |
环境管理 | profiles: ["<profile_name>"] | 将服务分配给一个非默认的配置文件组,实现服务的按需启动。 |
资源声明 | networks / volumes | 顶层关键字,用于声明整个应用栈所需的网络和数据卷资源。 |
docker compose
核心命令速查
命令 | 核心描述与常用参数 |
---|---|
docker compose up | (核心) 根据 docker-compose.yml 创建并启动所有服务。默认在前台运行。 |
-d : 在后台(detached)模式下运行。 | |
--build : 强制重新构建所有服务的镜像,即使已存在。 | |
--profile <name> : 激活指定 profile 中的服务。 | |
docker compose down | (核心) 停止并 移除 所有相关的容器、网络。 |
-v : 同时移除在 volumes 块中定义的具名数据卷。 | |
docker compose ps | 列出当前 Compose 项目所管理的所有容器的状态。 |
docker compose logs [service_name] | 查看一个或所有服务的日志。 |
-f : 持续跟踪(follow)实时日志输出。 | |
docker compose exec <service> <cmd> | 在指定服务的一个正在运行的容器中,执行一个命令。 |
docker compose build [service_name] | 构建或重新构建一个或所有服务的镜像。 |
docker compose config | 验证并显示经过变量替换和解析后的最终配置,是调试的利器。 |
高频面试题与陷阱
在 docker-compose.yml
中,depends_on
和 healthcheck
是如何协同工作的?为什么说只用 depends_on
还不够可靠?
这是一个非常好的问题,它触及了多服务应用稳定启动的核心。
单独使用 depends_on
,比如 depends_on: ['db']
,它只保证了一件事:db
服务的 容器 会在 api
服务的 容器 启动之前被启动。但它完全不关心 db
容器 内部 的 PostgreSQL 进程是否已经完成了初始化、是否已经准备好接受外部连接。
这就会产生一个“竞态条件”:API 容器可能已经启动并开始尝试连接数据库,而此时数据库服务进程还在加载配置、检查文件,根本没“开门营业”,从而导致 API 连接失败并崩溃。
说得很好。那 healthcheck
是如何解决这个问题的?
healthcheck
解决了“知其然,并知其所以然”的问题。我们在数据库服务中定义一个 healthcheck
,比如用 pg_isready
命令。Docker 会在容器启动后,定期执行这个命令。只有当 pg_isready
成功返回,Docker 才会将这个容器的状态标记为 healthy
。
然后,我们将 API 服务的 depends_on
升级为 depends_on: { db: { condition: service_healthy } }
。这样一来,Compose 的行为就变成了:“启动 db
容器,然后持续等待,直到 db
服务的 healthcheck
状态变为 healthy
,然后,且仅当此时,才启动 api
服务”。这就完美地解决了竞态条件,确保了应用栈的启动是健壮和可预测的。