Docker Compose 多容器编排实战:从 docker run 到声明式环境部署
2026/9/19 16:29:34 网站建设 项目流程

1. 从三个 docker run 到一个命令:先解决“手动敲命令”的原始痛苦

先说一个我见过无数次的场景:一个刚接触容器化部署的同事,要在本地把一套应用跑起来。这套应用不算复杂,一个 Nginx 做前端静态资源服务,一个 Node.js 写后端 API,一个 MySQL 存数据。他打开终端,深吸一口气,开始敲第一条命令:

docker run -d --name mysql \ -e MYSQL_ROOT_PASSWORD=root123 \ -e MYSQL_DATABASE=app_db \ -p 3306:3306 \ -v mysql_data:/var/lib/mysql \ mysql:8.0

然后第二条、第三条,中间还要处理网络问题——三个容器要互相通信,得先手动建一个自定义网络,再把三个容器都挂进去。运气好,十分钟能搞定;运气不好,敲完第三条命令发现第二条的容器名字被占用了,或者 MySQL 数据卷忘了挂载,删掉重建,又要等容器重新初始化。

这种模式的问题不在于“能不能跑起来”,而在于这套操作完全没有被记录下来。你今天敲了三遍 docker run,明天同事问你怎么搭环境,你得重新回忆;换一台电脑,又得从头来过。更别提如果某天要把 MySQL 换掉、加一个 Redis 缓存、多挂一个日志收集容器,所有的命令都要重新组织和排布。

这就是 Docker Compose 存在的理由。它把“若干个容器的定义、依赖关系、网络连接、数据卷挂载”统一写进一个声明式的配置文件里,用一条docker compose up -d替代掉之前所有的手工操作。这里的Docker Compose不是替代 docker run,而是把 docker run 的用法抽象、固化、版本化。它本质上做的是多容器编排:把多个互相协作的独立容器,按照你定义的拓扑关系一次性创建并管理起来。

在我刚开始接触 Compose 的时候,有个误区一直存在:很多人觉得 Compose 只是“把命令写进 YAML”,不值得专门学。但实际用下来你会发现,Compose 解决的问题远不止“少敲几次命令”——它让环境构建从“操作过程”变成了“配置描述”,这是一个质的变化。本文就用一个实际可复现的前后端分离项目作为主线,讲清楚 Compose 的核心用法、关键原理、以及那些只有踩过坑才知道的细节。

2. compose.yaml 不是魔法:逐行拆解配置与背后的 docker run 等价操作

2.1 从一个最小可用的双容器环境说起

我们先用一个最简单但完整的例子:Nginx 反向代理 + 一个后端 API 容器。这是绝大多数 Web 应用的雏形。在项目根目录下创建一个compose.yaml(旧版叫docker-compose.yml,新版推荐使用前者,Compose 插件两者都认):

services: api: image: node:18-alpine container_name: demo-api working_dir: /app volumes: - ./api:/app command: sh -c "npm install && node server.js" ports: - "3000:3000" networks: - demo-net web: image: nginx:1.25-alpine container_name: demo-web ports: - "8080:80" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - api networks: - demo-net networks: demo-net: driver: bridge

这三个部分分别对应你手动执行的三类操作。services下面每个顶级 key 是一个服务,等价于一次docker runnetworks声明了一个 Bridge 网络,等价于docker network create demo-net;而每个服务内部的关键字段,几乎都能一一映射回 docker run 的参数。

这时候做个对比,上面这段配置如果全部用 docker run 还原,大概是这么一坨命令:

docker network create demo-net docker run -d --name demo-api \ --network demo-net \ -p 3000:3000 \ -v $(pwd)/api:/app \ -w /app \ node:18-alpine \ sh -c "npm install && node server.js" docker run -d --name demo-web \ --network demo-net \ -p 8080:80 \ -v $(pwd)/nginx.conf:/etc/nginx/conf.d/default.conf:ro \ --depends-on demo-api \ nginx:1.25-alpine

看到区别了吗?Compose 的价值在这里已经体现得很清楚:配置即文档。任何人拿到的是一份结构化的 YAML,而不是三条需要口头解释的命令。而且这份配置是可以入库、可以 review、可以 diff 的。

2.2 理解每个核心字段的“为什么”

我在带团队的时候,最怕看到的就是新人照抄配置,不知道每个字段背后的意图。下面把高频字段逐个拆开讲清楚。

image + container_nameimage就是告诉 Compose 要去拉哪个镜像,等价于 docker run 的镜像名参数。container_name是可选的,如果你不指定,Compose 会自动生成一个项目名_服务名_序号风格的名称。这里有个值得注意的点:container_name 一旦指定,就要保证全局唯一。如果同一台机器上另一个 Compose 项目也用了相同的 container_name,容器创建时直接报冲突。这一点在工作中很常见,尤其是多人共用一台开发机的时候。我的建议是:部署到生产环境的 Compose 文件里,除非有特殊需求,否则不要写死 container_name,让 Compose 自己命名,能有效避免命名冲突。

ports"3000:3000"这种写法,映射关系是宿主机端口:容器端口。需要注意的是,Compose 里端口映射如果要同时支持 IPv6 或者指定特定地址,写法会更复杂,但绝大多数场景下这种简写就够了。还有一个极其隐蔽的坑:如果写成ports: - "3000",那相当于把容器的 3000 端口随机映射到宿主机的一个高位端口,expose 出去的是没用的。很多人从 docker run 转过来时容易踩这个,docker run 里-p 3000也有同样的随机映射含义,但这在 Compose 文件里更隐蔽,因为 ops 同学看配置很难发现。

volumes./api:/app是 bind mount,把宿主机当前目录下的api文件夹挂到容器的/app。如果你在 Docker Desktop(Mac/Windows)上跑,这里的路径背后还有一层虚拟化文件系统的转换,性能上会比 Linux 原生环境差一些,但开发场景完全够用。命名卷(如mysql_data:/var/lib/mysql)则在另一个维度工作:它由 Docker 管理,数据不依赖宿主机某个具体路径的目录结构,更适合存放数据库这类长期数据。生产环境我强烈建议数据库用命名卷,不要用 bind mount 直接挂到你自己的某个目录,因为命名卷有 Docker 的备份/迁移生态支持,而且不会因为宿主机目录权限变化导致 MySQL 起不来。

environment / env_fileenvironment 字段等价于-e MYSQL_ROOT_PASSWORD=xxx。它有两种写法:列表形式和映射形式。列表形式适合从宿主机环境变量动态传入,映射形式写起来最直观。实际项目中,我强烈推荐把环境变量拆到.env文件里,然后在 compose.yaml 中通过${变量名}引用,或者用env_file直接让容器内读取。这样做的好处是——配置不入库、密钥不入库。比如数据库密码这类敏感信息,不该硬编码在 compose.yaml 里提交到 Git。

depends_on这是编排的核心概念。字面意思很好懂:web 服务依赖于 api 服务。但很多人忽略了它的真实行为边界:depends_on 只控制启动顺序,不保证依赖服务内部已经就绪。什么意思?默认情况下,depends_on 等的是“api 这个容器启动了”,而不是“api 这个容器里的 Node.js 进程已经监听 3000 端口了”。如果 api 容器启动需要 5 秒初始化,web 容器可能第 2 秒就启动完开始尝试连接 api,结果连接失败。后面我们会专门讲怎么用健康检查解决这个“假启动”的问题。

networksCompose 默认会为每个项目创建一个桥接网络,所有服务默认连入这个网络。如果你在配置里手动声明并绑定demo-net,相当于把这个默认行为显式化。显式声明网络还有个好处是:你可以让一部分服务只被内部访问,不映射任何宿主机端口,只有入口服务暴露端口。这是最常见的“内网隔离”做法。

2.3 build 和 image 同时出现时:本地镜像优先还是远程拉取优先

进阶一点的用法:一个服务可以同时写buildimage。比如:

services: api: build: ./api image: demo-api:latest

这种情况,build告诉 Compose“这个服务的镜像是通过构建本地 Dockerfile 得到的”,image则指定构建完成后给镜像打什么标签。运行时,Compose 会优先使用本地已有的demo-api:latest,如果没有,就触发一次构建。这个设计非常适合本地开发:你改了代码,docker compose up -d --build会重新构建镜像;如果哪天本地镜像被清了,Compose 会尝试按 image 字段去远端拉——如果远端私有仓库里有这个 tag,也能直接拉下来用,不需要重复构建。

我见过不少人在生产环境误用了这个机制:本地构建了同名镜像,推到仓库时没注意 tag,导致 Compose 拉到了旧版本,排错排了半天。这里我给一个实操建议:生产部署时,请在配置文件里同时指定固定的 image tag(如 demo-api:1.2.3),而不是用 latest。这样每次构建、推送、部署的镜像都是确定性的,不会出现“本地和线上不是同一个镜像”的诡异问题。

3. 生命周期管理实操:从启动日志到清理残留的一次性讲清

3.1 compose up 的三个常用变体:前台、后台与强制重建

Compose 的入口命令就一个核心词:docker compose up。但它有很多变体,对应不同的使用场景。

首次启动,直接在项目目录下执行:

docker compose up -d

-d就是 detached 模式,等价于 docker run 的-d,容器在后台运行,终端不会挂住。如果你不加-d,容器会在前台跑,所有服务的日志会滚动打印在终端上——这个模式特别适合验证配置,因为你能第一时间看到容器启动的完整输出。it’s just like 调试期间我几乎总是先跑一遍前台模式,确认没问题了再 Ctrl+C 停掉,然后改用-d正式后台运行。

当你改了 compose.yaml 里的配置(比如改了端口映射、加了环境变量),光执行docker compose up -d有时候不够。Compose 会检测配置变化并重建对应容器,但如果你改了 Dockerfile 里的内容,或者镜像 tag 没变,就需要用:

docker compose up -d --build

--build会强制重新构建镜像,而不是直接用缓存。注意,即便没有新增依赖,--build也会重新执行构建流程,所以这个命令不要盲目每次都用,只在“镜像本身需要重建”的时候用。

还有一种情况:手动删掉了某个容器,但服务定义还在,执行docker compose up -d会把缺失的容器重新创建出来。这就是 Compose 的“声明式自愈”能力——它时刻盯着“当前实际运行的容器集合”与“配置里描述的期望状态”之间的差异,有差异就尝试纠正。

3.2 查看日志的正确姿势:别再用 docker attach 了

热词里有“docker run 打印日志”,我猜很多人的习惯是docker logs -f <容器名>。这个思路没错,Compose 也提供了等价的命令:

docker compose logs -f web

这里有个独门技巧:Compose 可以同时查看多个服务的日志,而且会自动给每行日志加上服务名前缀,颜色也区分开。比如docker compose logs -f api web会把 api 和 web 的日志交错打出来,这对排查“A 服务日志里出现了报错,到底是它自己炸了还是 B 服务调它超时”这种跨服务问题极其有用。

docker logsdocker attach是完全不同的东西。docker attach是把当前终端接到容器的标准输入输出上,搞不好直接终端卡死,想退出还得按 Ctrl+P+Q。日常排查问题,用 logs 就好,别去折腾 attach。

3.3 进入容器调试:exec 的两个关键参数

容器跑起来后要进去看文件、跑命令,用:

docker compose exec api sh

注意这里也带服务名,Compose 会自动定位到对应容器。两个关键参数值得说:-T禁用伪终端分配,适合在 CI 脚本里跑命令时使用,避免输出里出现奇怪的控制字符;--user指定进入容器后的用户身份,比如你想用 root 身份进容器调一些权限问题:

docker compose exec --user root api sh

如果容器里连sh都没有(比如某些极简的 distroless 镜像),那就要么用docker compose cp把文件拷出来看,要么检查镜像构建阶段是否遗漏了调试工具。

3.4 优雅停机与彻底清理:stop、down、down -v 的区别

这是新手最容易混淆的一组命令。

  • docker compose stop:停止所有容器,但保留容器、网络、数据卷。
  • docker compose down:停止并删除所有容器和网络,但保留数据卷。
  • docker compose down -v:在 down 基础上,连数据卷一起删除。

我用一张表把关键区别列清楚:

命令容器网络数据卷(命名卷)镜像
docker compose stop保留保留保留保留
docker compose down删除删除保留保留
docker compose down -v删除删除删除保留
docker compose down --rmi all删除删除保留删除

这张表值得收藏。尤其是down -v,它在本地开发时很爽(环境一键归零),但在生产环境误操作就是灾难——数据库数据全没了。我的习惯是:生产服务器上永远不手动执行 down -v,而是把 down 命令写进变更脚本,且明确注释掉 -v。真要清理数据,先备份,再手动删卷。

这里还要提一个热词“docker run --rm”。--rm表示容器退出后自动删除容器文件系统,这个参数在 docker run 里很常见,尤其适合跑一次性任务。Compose 里没有专门的全局--rm等价物,但你可以通过启动方式变通:用docker compose run代替up,它创建的容器默认就是一次性行为,退出后自动清理。比如:

docker compose run --rm api npm test

这个命令会在 api 服务定义的基础上启动一个临时容器,跑npm test,跑完自动删除。非常适合做 CI 里的测试步骤,而不会污染正在运行的 api 容器。

4. 多副本与服务发现:up --scale 和 depends_on 的隐藏面

4.1 一个容器不够用了,怎么快速水平扩展

Compose 的初衷是“编排多容器”,但有一个高级用法很多人不知道:docker compose up --scale可以对同一个服务启动多个副本。

docker compose up -d --scale api=3

执行后,api 服务下会同时运行 3 个容器。这在本地模拟集群环境或者测试负载均衡时很好用。但这里有几个隐藏前提:

第一,--scale只能扩展没有固定container_name的服务。如果配置里写死了container_name: demo-api,Compose 会直接报错,因为容器名全局唯一,没法创建同名副本。这又一次印证了我前面说的:写死 container_name 要慎之又慎。

第二,扩展出来的多个容器之间要能协同工作。如果你的 api 服务依赖本地文件系统的某个目录(bind mount),多个容器同时读写同一个目录就可能产生数据竞争。如果 api 服务是无状态的(只做计算,不存状态),那扩展就非常安全。

第三,也是最关键的,多个副本之间如何被其他服务发现?Compose 默认创建的网络自带 DNS 解析功能。假设你扩展出了 api_1、api_2、api_3,在同一个 Compose 网络里,其他服务只要通过服务名api去访问,Docker 内置的 DNS 会随机解析到其中一个副本的 IP。也就是说,Compose 天然给你做了一层最简版的负载均衡。Nginx 配置里直接把 upstream 指向http://api:3000即可,不需要再手动配置上游地址列表。

这种机制在真实项目里很实用。比如你要验证 Nginx 在多个后端副本之间做轮询的效果,起一个 scale=3 的 api 服务,再配一个 Nginx 反向代理,几分钟就能搭出一个可观测的负载均衡环境,不依赖任何 K8s。

4.2 depends_on 配合健康检查:彻底告别“依赖服务还没就绪”

前面提到过 depends_on 只保证“容器启动了”而不是“进程就绪了”。要解决“api 还没就绪,web 就开始连”的问题,需要配合 healthcheck。

在服务定义里加上:

services: api: image: node:18-alpine healthcheck: test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/health"] interval: 5s timeout: 3s retries: 5 start_period: 10s

然后在 web 服务里:

web: image: nginx:1.25-alpine depends_on: api: condition: service_healthy

这样 Compose 在启动 web 之前,会一直等到 api 的健康检查通过(即返回 0 且 HTTP 200),真正做到了“按依赖的可用状态来编排启动顺序”,而不是傻等几秒或者碰运气。

这里有个坑:基础镜像里不一定有 wget/curl。上面用了wget,但很多 Node.js 的 alpine 精简镜像并没有默认装 wget,你得在 Dockerfile 里提前装好,或者改用 Node 自带的健康检查方式(比如写一行 node 脚本用 http 模块请求)。我把常见基础镜像的可用探测工具列一下:

基础镜像自带探测工具推荐 healthcheck 探测方式
node:18-alpine无 wget/curlnode -e "fetch('http://localhost:3000/health')" 或提前安装 wget
nginx:1.25-alpine无 curl提前安装 curl,或用 wget
mysql:8.0自带的 mysqladminmysqladmin ping -h localhost
redis:7-alpine自带的 redis-cliredis-cli ping
postgres:16自带的 pg_isreadypg_isready -U postgres

加粗提醒:healthcheck 里的start_period非常有用。它表示容器启动后“预留”多少秒,这段时间内即使健康检查失败也不算失败,避免镜像刚启动还没初始化完就被判死。比如数据库首次初始化可能需要 20~30 秒,你在没有 start_period 的情况下直接设置 5 秒检查一次、重试 5 次,大概率前几次全部失败,容器被标记为 unhealthy,依赖它的服务就会一直等待。加上 start_period: 30s,问题立即解决。

4.3 compose.yaml 里配置多个 Compose 文件:开发与生产环境的差异化

真实项目里,开发环境和生产环境的配置几乎不可能完全一致。比如开发环境要把数据库端口映射到宿主机方便 Navicat 连接,生产环境则不应该暴露数据库端口。这种差异怎么处理?

Compose 支持通过多个 YAML 文件合并配置,基础配置写在compose.yaml,差异化配置写在compose.override.yaml(开发默认加载)或compose.prod.yaml(生产用-f指定加载)。

我的习惯是这样组织:

  • compose.yaml:放所有环境共通的基础定义(镜像、网络、卷、服务依赖关系)。
  • compose.override.yaml:放开发环境专属的配置(端口映射、本地目录挂载、调试用的额外服务)。
  • compose.prod.yaml:放生产环境专属的配置(固定镜像 tag、资源限制、重启策略、密钥挂载)。

默认执行docker compose up -d时,Compose 会自动加载compose.yamlcompose.override.yaml。部署生产时用:

docker compose -f compose.yaml -f compose.prod.yaml up -d

后面的文件会覆盖前面文件的同名配置项。这个“覆盖”是深合并,列表会追加,映射会覆盖。但这个机制有个审查点:override 文件里的端口映射是新增的映射列表,不是替换。所以如果你在 compose.yaml 里已经映射了"3000:3000",override 里又写了"3001:3000",最终结果是两个映射都存在,容器同时在 3000 和 3001 端口监听。要完全替换,需要在 override 里显式把原来的映射也列出来,然后把要覆盖的改掉。这个细节容易出事故,我实际就遇到过 prod 文件覆盖端口时,忘了把 dev 的端口从映射列表里剔掉,导致生产环境容器把自己暴露到了意外的宿主机端口。

5. 数据卷、持久化与容器重启策略:别让容器一挂数据就没了

5.1 三种数据持久化方式的应用场景选择

容器的文件系统是临时的,容器删除后数据也跟着没。要让数据持久化,Compose 里主要有三种手段:

  • bind mount(绑定挂载):把宿主机目录挂到容器路径。适合开发场景、配置文件下发。缺点是宿主机目录权限和 SELinux 会影响容器内读写,跨平台时路径写法要留意。
  • named volume(命名卷):由 Docker 管理,数据放在 Docker 数据目录下。适合数据库、消息队列等有状态服务。
  • tmpfs:数据存内存,容器停止后清空。适合保存缓存类临时数据,但生产环境较少用。

写法上,bind mount 和命名卷的差异很小:

services: mysql: image: mysql:8.0 volumes: - mysql_data:/var/lib/mysql # 命名卷 - ./init-sql:/docker-entrypoint-initdb.d:ro # bind mount volumes: mysql_data:

第一次启动 MySQL 容器,如果/var/lib/mysql是空的,MySQL 镜像会初始化一个新的数据目录;如果卷已有数据,就直接使用。这就是为什么很多 MySQL 容器起不来的原因之一——卷权限不对。宿主机上mysql_data这个卷的属主和容器内 mysql 用户不一致,导致 MySQL 无法写入。解决方式有两种:在 Compose 文件里指定卷的 driver_opts(用 local 驱动和 uid/gid 参数),或者干脆让 MySQL 镜像自动初始化并给卷设置正确的属主。

5.2 重启策略:容器崩了以后系统怎么处理

生产环境容器不可能永远不挂。重启策略决定了容器退出后 Docker 守候进程怎么做。

services: api: image: demo-api:1.2.3 restart: unless-stopped

restart 的取值有四种:

  • no:容器退出不自动重启,默认值。
  • always:无论容器怎样退出,总是重启。这个策略有个副作用,手动 stop 容器后,重启 Docker 服务时容器仍然会被拉起来,可能不符合预期。
  • on-failure[:max-retries]:仅在非正常退出(退出码不为 0)时重启,可限制重试次数。适合脚本类任务。
  • unless-stopped:容器非正常退出时重启,但在 Docker 守护进程重启时,如果容器之前被手动 stop 过,则不会自动拉起。这是我最推荐生产环境使用的策略。

这里有个容易忽略的点:restart 策略和 depends_on 的交互。restart 只作用于单个容器;如果 api 服务崩溃后重启了,但依赖它的 web 服务还在跑,web 期间的缓存的连接可能已失效,需要应用层有重连机制。不要把服务恢复的希望全寄托在 restart 上,应用自身的健壮性才是根本。

5.3 利用.env文件管理环境差异:Compose 的变量替换机制

.env文件是 Compose 项目目录下一个特殊的文件。它不需要任何额外配置,Compose 会自动读取它,并把里面的变量作为整个配置文件的可用变量。比如:

# .env MYSQL_ROOT_PASSWORD=ChangeMe123 API_PORT=3000

然后在 compose.yaml 里引用:

services: api: image: demo-api:1.2.3 ports: - "${API_PORT}:3000" mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}

这样做的好处是:同一个 compose.yaml 可以在不同环境套用不同的 .env 文件。比如本地开发用.env.development,生产用.env.production,启动时通过--env-file指定:

docker compose --env-file .env.production up -d

有一件事要特别强调:.env文件千万别提交到 Git 仓库。它包含的是密码、端口、密钥等敏感配置,仓库里应该只有.env.example,里面写占位符,让同事拷贝后自己填。我在团队里甚至加过一条 Git hook,强制检测有没有把.env被误提交。

5.4 常见错误集合:端口冲突、镜像不存在、缩进错误

本地开发最常碰到的三类报错,我单独列出来。

错误一:端口绑定失败

Error response from daemon: driver failed programming external connectivity on endpoint xxx Bind for 0.0.0.0:8080 failed: port is already allocated

原因:宿主机 8080 端口被别的进程占用了。排查思路:先用lsof -i :8080netstat -tunlp | grep 8080看谁占了,再改 compose.yaml 里的端口映射。注意,改了端口映射后容器要重建,docker compose up -d会自动重建,但如果你只用docker compose restart,配置是不会重载的,必须 up 才会触发配置变更检测。

错误二:镜像拉不下来

failed to solve: rpc error: code = Unknown desc = failed to pull and unpack image

原因可能是网络策略限制,也可能是镜像 tag 不存在。如果你是在内网环境,需要提前配置镜像加速器,或者把镜像先推送到内网仓库再改 image 字段。按我个人的经验,这通常是私有仓库地址写错了、tag 拼错了,或者在 Docker Desktop 里没登录私有仓库。注意看报错信息里面有没有manifest unknown,有的话基本都是 tag 拼错。

错误三:YAML 缩进解析失败

yaml: line 5: mapping values are not allowed in this context

这是新手最常犯的错。Compose 对缩进非常敏感,两个空格是一个缩进层级,不能乱用 Tab,也不能符号和值之间少了空格。我现在写任何 compose.yaml 文件,都会尽量先做一次docker compose config,这个命令会解析配置文件并打印出最终合并后的完整配置,能提前发现大部分语法问题。

6. 从 docker run --rm 到 Compose run:一次性任务与调试容器的最佳姿势

6.1 为什么临时任务不要用 docker run 新起容器

在容器化改造的过程中,很多人习惯临时起一个容器跑命令,用完就删:

docker run --rm -it node:18-alpine npm install

这个思路没问题,但在 Compose 项目里,这样的临时容器和现有服务不在同一个网络,也没有挂载你项目目录的数据卷,跑出来的成果并不被主服务所见。比如你想在项目依赖环境里执行数据库迁移脚本,直接 docker run 新起一个容器,很可能连不上 mysql 服务。

更合理的做法是用docker compose run

docker compose run --rm api npm run migrate

docker compose run会在 api 服务定义的基础上启动一个新容器。它的特点是:

  • 继承服务定义里的网络、卷、环境变量。
  • 默认执行服务定义的 command,也可以像例子一样在末尾覆盖 command。
  • 退出后容器自动删除(结合--rm),不留垃圾。

这比临时 docker run 优雅得多。日常开发做数据库迁移、执行测试脚本、进入 REPL 调试,都是这个命令的应用场景。

6.2 临时端口映射与服务覆盖:不污染 compose.yaml 的运行时参数

有时候做临时调试,想让临时容器暴露一个端口方便浏览器直接访问,但不想把映射写进 compose.yaml(因为那样会改动配置)。这时可以在 run 命令后用-p临时指定:

docker compose run --rm -p 5173:5173 api npm run dev

这个参数只对本次启动的临时容器生效,不改变任何配置文件。同理,-e也可以临时加环境变量:

docker compose run --rm -e DEBUG=true api node server.js

但要注意,run 启动的临时容器会启动它依赖的服务吗?默认不会。docker compose run只启动你指定的那个服务,除非它配置了 depends_on,Compose 会顺带把依赖服务也拉起来,这是新版 Compose 的默认行为,很容易忽略。如果你不想让依赖服务被连带启动,可以加--no-deps参数。

6.3 用 compose exec 调试正在运行的容器:单容器调试的完整流程

当应用已经在后台用up -d跑起来了,调试过程一般按这个顺序走:

# 查看服务运行状态和健康状态 docker compose ps # 查看某个服务的实时日志(排查启动失败和运行时报错) docker compose logs -f api # 进入容器内看进程和网络 docker compose exec api sh # 如果容器反复重启,先看退出码 docker compose ps -a docker inspect <容器名> --format='{{.State.ExitCode}}'

举一个实际定位问题的例子:某次 api 服务一直处于 Restarting 状态,我一看退出码是 1,进容器又进不去(因为容器还没启动完就退了)。排查思路是:先看日志,docker compose logs记录了容器进程的输出,发现是 Node.js 端口被占用。再docker compose exec进去确认确实有别的进程绑定在 3000 端口,最后通过修改端口映射解决。整个过程完全没用到 docker run 新起容器,都是基于现有 Compose 环境操作,非常顺滑。

7. 用 Compose 搭建一套带后端、数据库、缓存的完整项目:从零到可用的全流程

7.1 需求拆解与配置落地

前面把各种原子能力都讲了一遍,这里走一遍完整流程。假设我要在本地搭一套“用户注册 API + Redis 缓存 + MySQL 存储”的服务,最终目录结构是这样的:

project-root/ ├── compose.yaml ├── .env ├── api/ │ ├── Dockerfile │ ├── package.json │ └── server.js └── web/ └── nginx.conf

compose.yaml 全貌:

services: api: build: ./api image: demo-api:local restart: unless-stopped depends_on: mysql: condition: service_healthy redis: condition: service_healthy environment: DB_HOST: mysql DB_PORT: 3306 DB_USER: app_user DB_PASSWORD: ${DB_PASSWORD} REDIS_HOST: redis REDIS_PORT: 6379 ports: - "${API_PORT}:3000" networks: - backend mysql: image: mysql:8.0 restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} MYSQL_DATABASE: app_db MYSQL_USER: app_user MYSQL_PASSWORD: ${DB_PASSWORD} volumes: - mysql_data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${DB_ROOT_PASSWORD}"] interval: 5s timeout: 3s retries: 10 start_period: 30s networks: - backend redis: image: redis:7-alpine restart: unless-stopped command: ["redis-server", "--appendonly", "yes"] volumes: - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 10 networks: - backend networks: backend: volumes: mysql_data: redis_data:

7.2 为什么这里用了 healthcheck + depends_on 的完整形态

这套配置里最值得学习的地方是,我没用简单地 depends_on 列表写法,而是用了依赖服务健康检查的条件写法。原因很简单:api 连接 MySQL 和 Redis 的时机决定了它能不能正常启动,而 MySQL 和 Redis 容器启动到可服务状态都需要时间。MySQL 首次初始化可能耗时 30 秒以上,如果 api 在 MySQL 还没就绪时就尝试连接,启动会直接失败。通过 healthcheck + condition: service_healthy,api 会在依赖服务健康后才启动,从根本上解决了竞争条件。

还有一个细节:MySQL 的 healthcheck 里用了mysqladmin ping,这是个很常见的探活方式,但ping成功并不代表 MySQL 已经可以正常接受用户请求,ping只是检测 MySQL 进程是否活着。在生产环境,更严格的健康检查应该是执行一个实际查询,但这个作为本地开发已经足够,因为依赖项启动后短时间内就会完全就绪。

7.3 本地一键启动与一键重置的完整工作流

配置文件就位后,日常操作流程是:

# 首次启动(前台模式,先看日志有没有报错) docker compose up # 确认没问题后,Ctrl+C 停止,再后台启动 docker compose up -d # 查看服务状态 docker compose ps # 查看所有服务日志(带服务名前缀,区分颜色) docker compose logs -f # 停止服务但保留数据 docker compose stop # 再次启动 docker compose start # 彻底停掉并删除容器和网络,但保留数据卷 docker compose down

如果代码改了需要重启 api:

docker compose up -d --build api

这比删容器重建高效多了,Compose 会检测到 api 镜像发生了变化,自动重建 api 容器并保持其他容器不动。这一点是 docker run 手工操作很难做到的——你手动停哪个容器、删哪个容器、重建哪个容器,全靠记忆,Compose 则通过配置差异来自动决策。

7.4 发布到另一台机器:compose.yaml + .env 的便携部署

项目要换到另一台机器部署,整个流程不用再回忆 docker run 命令。把整个项目目录(包括 compose.yaml 和 .env)拷过去,执行:

docker compose up -d --build

新机器会自动拉取镜像、构建本地镜像、创建网络、创建卷、启动容器。这里给一个安全提醒:交换项目目录时,.env 一定不要通过聊天工具明文传输。我的做法是用环境变量注入的方式:部署机器上提前写一个.env,compose.yaml 里的变量按需读取,避免密钥落在 Git 记录或者聊天记录里。

8. 绕开这些坑:命名冲突、数据卷残留与健康检查误报的排查实录

8.1 改名后认不出卷:卷名复用导致的“数据丢失”错觉

有次我重构项目,把服务名从db改成了database,然后发现原来在db服务下的 MySQL 数据好像“丢”了。排查后才发现:Compose 给卷自动生成的命名规则是项目名_卷声明名,服务名改了不会影响卷名(只要卷声明名一样),但如果你把卷声明名也改了,比如从mysql_data改成mysql_data_v2,新卷是空的,MySQL 就重新初始化了,看起来就像是“数据丢失”。

实际排查过程很有代表性:

# 查看当前项目的卷列表 docker volume ls | grep project-name # 找到旧卷(名字可能带数字后缀,如 project-name_mysql_data_old) # 如果要恢复,先停掉当前服务,再把旧卷挂回去

这种坑的本质在于:卷是独立于容器的实体,不要以为改了服务配置,数据就会跟着“迁移”。数据卷的声明名是持久化身份的标识,改名之前要想清楚。生产环境更稳妥的做法是:卷名永远带明确的业务含义,并且不随服务名变化。

8.2 同一个 Docker 环境跑两个 Compose 项目的端口冲突

团队协作时,同一台开发机可能同时跑多个 Compose 项目。如果两个项目都映射了 8080 端口,后启动的那个必然报端口冲突。这种问题靠手动改端口很烦,我常用的解决思路是:每个项目在.env里定义一套端口前缀,例如:

# 项目A API_PORT=3000 # 项目B API_PORT=3001

compose.yaml 里统一用${API_PORT}引用。这样两个人各跑各的项目,端口冲突的概率大大降低。如果实在避免不了冲突,至少docker compose ps能快速看出是哪个项目占了端口。

8.3 healthcheck 一直失败,但容器实际是正常工作的

还有一次踩坑经历:Redis 容器明明可以正常读写,但健康检查状态一直显示 unhealthy。查了半天发现,redis-cli ping 在容器内执行没问题,但我在 healthcheck 里写了["CMD", "redis-cli", "ping"],而基础镜像redis:7-alpine的 redis-cli 输出结果是PONG加换行,按理说退出码是 0,应该算健康。问题出在另一处:我把 healthcheck 的 retries 设成了 10,interval 设成了 1s,而 Redis 启动时因为开启了 AOF 持久化,重启过程可能耗时较长,前 10 次检查全部失败,容器被标记为 unhealthy。

这个案例说明:healthcheck 的参数不是越大越好,要根据服务实际的启动耗时来设置。Redis 启动要 3~5 秒,interval 设成 5s、retries 设成 5,配合 start_period 足够了。我后来把参数调成interval: 10s, retries: 3, start_period: 10s,状态就稳定了。调优 healthcheck 的经验是:先让服务稳定启动,看日志里健康检查什么时候开始成功,然后倒推参数,而不是拍脑袋设一组数字就完事。

8.4 数据卷权限问题的完整排查链路

在 Linux 服务器上跑 MySQL 容器,数据卷权限问题出现的频率非常高。典型报错是:

[ERROR] [MY-011087] [Server] Different lower_case_table_names settings for server ('0') and data dictionary ('1').

或者更直接的:

mysqld: Can't create/write to file '/var/lib/mysql/ibtmp1' (Errcode: 13 - Permission denied)

排查链路分享如下:

  1. 确认卷目录的属主和权限:ls -l /var/lib/docker/volumes/项目名_mysql_data/_data,发现属主是 root,而 MySQL 容器内以 mysql 用户运行。
  2. 确认容器内 mysql 用户的 UID/GID:查看官方镜像 Dockerfile,MySQL 8.0 镜像中 mysql 用户的 UID 是 999。宿主机卷目录的属主应该是 999:999,但默认创建成 root。
  3. 修复方案:手动chown -R 999:999卷目录,然后重启容器。但注意,这属于临时修复,因为docker compose down后又重建卷,权限问题可能再次出现。
  4. 根治方案:在 compose.yaml 里给卷指定 driver_opts,使用 local 驱动并设置 uid/gid:
volumes: mysql_data: driver: local driver_opts: type: none device: /data/mysql o: bind,uid=999,gid=999

这个方案可以把卷直接挂到宿主机一个确定的目录,并强制设置属主。但注意,uidgid这些挂载选项在不同 Docker 版本、不同平台上的支持程度不一样,用之前要在目标环境做验证。

9. 写在最后的一点个人体会:Compose 让我重新理解了“环境即代码”

讲了这么多,其实最想分享的体会是:Docker Compose 的价值核心不是“少敲几行命令”,而是把环境的定义从人脑转移到了文件里。以前搭一套开发环境,靠的是 README 里写一堆 docker run 命令,新人照着敲,敲错了还不一定能发现。现在靠一份 compose.yaml,环境是确定性的——同一个配置文件,在任何人机器上跑出来的是同一套拓扑、同一组网络、同一组卷。这才是它最大的魅力。

我个人的建议是,无论项目大小,只要涉及两个以上容器的协作,就直接用 Compose 管理,而不是 docker run 一把梭。哪怕只是本地起一个 MySQL 给后端联调用,也值得写一份 compose.yaml 放在项目里。这不是过度设计,而是给三个月后的自己留一条活路——到时候你不需要回忆当初是怎么启动数据库的,只需要docker compose up -d就行。

最后再分享一个小技巧:新机器上第一次启动 Compose 项目时,我会先跑docker compose config检查配置,再跑docker compose up -d,如果一切顺利,最后用docker compose ps确认状态。这套三步操作我已经形成肌肉记忆了,强烈推荐你也养成习惯。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询