先坦白一个场景,估计用过 Docker 的人都经历过:项目要用到数据库、后端接口、前端静态资源三个容器,部署的时候得手动敲三遍docker run,每一条都是几十个参数,--network、-e、-p、-v,写到最后自己都不知道哪个参数对应哪个容器。更头疼的是,容器启动顺序还得靠脑子记,先启数据库,等几秒再启后端,最后才敢启前端,一旦顺序搞反,应用日志里一片报错。这篇文章就是来聊这个问题的——我如何用 Docker Compose 把这种“手动编排”变成“一招搞定”,以及从docker run迁移到docker-compose.yml时最值得注意的那些细节。
文章主要面向两类人:一是刚学会docker run,正被多容器部署折磨的初学者;二是已经在用 Compose,但对配置文件的坑、健康检查、变量替换这些进阶点还不够熟练的人。看完之后,你至少能明白一件事:Docker Compose 不是又一个新工具,它就是把原来要反复敲的docker run整理成了一份声明式的清单,剩下的交给工具去执行。
1. 为什么我放弃了手动 docker run 多容器部署
1.1 手动 docker run 的痛点到底在哪
单容器场景下,docker run其实是够用的。要启一个 Redis,docker run -d --name redis -p 6379:6379 redis:7,一条命令搞定,简单直接。问题出在多容器协作的时候。我第一次部署一个前后端分离项目,三条命令分别启动 Nginx、后端服务和 PostgreSQL,那时候还没用 Compose,部署一次踩一次坑。
首先是命令长度。一个后端服务要指定网络、映射端口、挂载日志目录、注入十几个环境变量,docker run参数能拆成四五行写。这不是演示,是真实情况,光是维护这一串参数就已经很累了,更别提每台服务器都要重新敲一遍。
其次是网络。容器之间要用 service name 互相访问,必须手动建自定义网络:docker network create myapp_net。建完之后,每个容器启动时都要加--network myapp_net参数。漏了一次,后端连数据库就报could not translate host name "db" to address。这类错误排查起来特别费劲,因为问题不在代码,而在部署参数。
再者是启停顺序。虽然 Docker 容器启动本身很快,但数据库服务就绪需要时间。手动敲命令的时候,人肉等待是最常见的做法:“敲完数据库命令,数到十再敲后端”。这个方法在当时管用,但换成 Compose 之后才发现,应该有更理性的做法。
最后是配置无法版本化。一条docker run命令打在终端里,跑完就没了,没人知道这台机器上到底跑了什么参数。而docker-compose.yml是一个文件,可以放进 Git 仓库,队友拉下来就能复现同一套环境。
1.2 docker run 仍然有用的场景
把docker run说得一无是处也不公平。它最擅长的是临时任务和快速验证,这类场景下 Compose 反而是多余的。
比如调试一个镜像内部结构,我经常用docker run --rm -it nginx:alpine sh进容器看文件系统。一条命令,用完即焚,连清理都省了。再比如跑一次性数据库迁移脚本,docker run --rm -v $(pwd):/app -w /app node:20 npm run migrate,跑完容器自动删除,不占用任何资源。
还有临时起一个测试用的 MySQL,不需要持久化,直接docker run -d --name test-mysql -e MYSQL_ROOT_PASSWORD=123456 -p 3306:3306 mysql:8,用完docker rm -f test-mysql就完事。这种场景下 Compose 反而重了,没必要写一个 YAML 文件来托管一次性容器。
所以我的判断标准很简单:如果一个应用要长期运行、依赖其他服务、需要持久化存储,就值得写成 Compose;如果只是临时任务的过场容器,docker run --rm更合适。
2. Docker Compose 的核心概念与配置语法
2.1 声明式配置:把“步骤”变成“状态”
docker run是命令式的,它告诉 Docker“今天要创建一个容器,参数是什么”。docker-compose.yml是声明式的,它描述“系统最终应该是什么样”,至于怎么创建、先创建谁、怎么连通,都交给 Docker Compose 自己处理。
这个区别非常重要。命令式的部署流程是“文档”加“脚本”,每一步都需要人去执行;声明式的部署是“配置”加“工具”,只要描述清楚目标状态,工具就能保证最终结果符合预期。
打个通俗的比方,手动煮方便面是命令式:烧水、放面、加调料包、计时看到面条软了关火。用自动电饭煲做焖饭是声明式:把米、水、菜、调料放进去,按下“煮饭”模式,机器自动处理火候和时间。Compose 就是这个“煮饭模式”。
2.2 docker-compose.yml 的三个核心区域
一个标准的 Compose 文件包含三个顶层区域:services、networks、volumes,三者的职责非常明确。
services用来定义服务,也就是“要跑哪些容器”。每个服务对应一个容器图像、启动参数、环境变量、端口、挂载卷等信息。这部分和docker run的参数一一对应,几乎能无缝平移。
networks定义服务间的网络拓扑。默认情况下,Compose 会自动创建一个项目网格,所有在同一个 Compose 文件里的服务都能用服务名互相访问。如果要模拟隔离环境,或者让不同 Compose 项目共享网络,才需要手动定义networks。
volumes定义持久化存储。顶层声明的卷是具名卷,由 Docker 管理存储位置,适合数据库这类需要长期保存数据的服务。也可以用./local_path:/container_path做绑定挂载,适合日志、配置文件这类需要从宿主机直接访问的场景。
services: app: image: myapp:1.0 ports: - "8080:80" volumes: - app-data:/var/lib/app volumes: app-data:这段配置里,app-data是具名卷,数据存放在 Docker 管理的目录中,你不需要关心它在宿主机哪里,Docker 会处理好备份和恢复。
2.3 image 还是 build,怎么选
Compose 服务的定义里有两种“来源”方式:image指定现成镜像,build指定本地 Dockerfile 构建。两者还可以组合出现。
services: app: build: ./app image: myapp:1.0这种组合的含义是:先用./app目录下的 Dockerfile 构建出镜像,然后给它打上myapp:1.0标签。本地没有镜像就构建,有镜像就直接用,配合docker compose up -d --build可以在源码更新后强制重建。
选image还是build,取决于项目形态。如果是部署开箱即用的中间件,比如 PostgreSQL、Redis、Portainer,直接用image就够了;如果是自己开发的应用,需要从源码构建镜像,那就用build,并配合.env里的镜像标签来控制版本。
3. 从 docker run 到 docker-compose.yml:一次真实迁移过程
3.1 原始的三条 docker run 命令
为了演示迁移过程,我拿一个典型场景举例:一个 Web 应用,包含 PostgreSQL 数据库、后端 API 服务和前端 Nginx 静态站点。手动部署时,大致需要以下操作:
# 1. 创建自定义网络 docker network create webapp_net # 2. 启动 PostgreSQL docker run -d \ --name webapp-db \ --network webapp_net \ -e POSTGRES_USER=webapp \ -e POSTGRES_PASSWORD=secret123 \ -v dbdata:/var/lib/postgresql/data \ postgres:15 # 3. 启动后端 API docker run -d \ --name webapp-api \ --network webapp_net \ -e DB_HOST=webapp-db \ -e DB_PORT=5432 \ -e DB_USER=webapp \ -e DB_PASSWORD=secret123 \ -p 8080:8080 \ webapp-api:1.0 # 4. 启动前端 Nginx docker run -d \ --name webapp-web \ --network webapp_net \ -v /opt/webapp/dist:/usr/share/nginx/html:ro \ -p 80:80 \ nginx:alpine这里还省略了更复杂的环节,比如后端容器要等数据库就绪以后再启动,前端 Nginx 的配置文件也要挂载进去。三个容器三条命令,已经能看出问题了:网络参数重复了三遍,环境变量分散,依赖关系完全靠人脑记住。
3.2 把 docker run 命令翻译成 Compose 文件
现在把它们变成 Compose 文件。最直观的做法就是逐条对应:每个docker run变成一个 service,--name对应container_name,-e对应environment,-p对应ports,-v对应volumes。
services: db: image: postgres:15 container_name: webapp-db environment: POSTGRES_USER: webapp POSTGRES_PASSWORD: secret123 volumes: - dbdata:/var/lib/postgresql/data networks: - webapp_net api: image: webapp-api:1.0 container_name: webapp-api environment: DB_HOST: db DB_PORT: 5432 DB_USER: webapp DB_PASSWORD: secret123 ports: - "8080:8080" networks: - webapp_net web: image: nginx:alpine container_name: webapp-web volumes: - /opt/webapp/dist:/usr/share/nginx/html:ro ports: - "80:80" networks: - webapp_net volumes: dbdata: networks: webapp_net:就这么简单,三个容器被整合进一个文件里。docker compose up -d一条命令,Compose 自动创建网络和卷、按配置启动所有服务。
有个细节值得注意:后端服务的DB_HOST从webapp-db变成了db。这就是 Compose 的便利之处,同一个 Compose 文件里的服务,默认使用服务名作为 DNS 名称,服务名比容器名更稳定,也更容易读。
3.3 环境变量与端口映射的几个细节
写环境变量时最容易犯的错是 YAML 的类型解析问题。比如-e FLAG=true在docker run里传的是字符串"true",但在 YAML 里写成FLAG: true会被解析成布尔值,有些应用会因此行为异常。所以环境变量值建议统一加引号,或者明确用字符串形式。
environment: FLAG: "true" LAUNCH_TIMEOUT: "30"端口映射同理,"8080:8080"要加引号,否则 YAML 1.1 的解析器可能把8080:8080当成带冒号的时间对象来解析。虽然新版 Compose 规范已经修正了这个问题,但为了兼容性和可读性,惯例上还是加引号更稳妥。
另一个容易被忽略的点是ports和expose的区别。ports会把端口映射到宿主机,外部可以直接访问;expose只是告诉“同一个网络里的其他服务可以访问这个端口”,宿主机完全看不到。在 Compose 服务之间互通时,expose就够了,不一定每个端口都要暴露到宿主机,减少不必要的端口暴露也有利于安全。
4. 多容器编排完整实战:Nginx + 后端 + PostgreSQL 一套带走
4.1 项目目录与前置准备
这一节我们从零开始,写一个可以被真实部署的三容器项目。项目结构是这样的:
webapp/ ├── backend/ │ ├── Dockerfile │ └── main.py ├── frontend/ │ └── index.html ├── nginx/ │ └── default.conf ├── .env └── docker-compose.yml后端我用一个最简单的 Python HTTP 服务做演示,它连接 PostgreSQL,读取数据库中的一条记录并返回 JSON。前端就是一个静态页面。Nginx 负责给前端页面和后端 API 做反向代理。
backend/Dockerfile内容如下:
FROM python:3.11-slim WORKDIR /app RUN pip install psycopg2-binary COPY main.py . CMD ["python", "main.py"]后端代码中连接数据库时,主机名写成db,因为在 Compose 网络里,服务名就是主机名。
import os import psycopg2 import time # 等待数据库就绪,最多重试 10 次 for _ in range(10): try: conn = psycopg2.connect( host="db", user=os.getenv("POSTGRES_USER"), password=os.getenv("POSTGRES_PASSWORD"), database=os.getenv("POSTGRES_DB"), ) break except Exception: time.sleep(2) cur = conn.cursor() cur.execute("SELECT 'ok'") row = cur.fetchone() from http.server import HTTPServer, BaseHTTPRequestHandler class Handler(BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) self.end_headers() self.wfile.write(str(row).encode()) HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()这个示例代码写得非常简单,核心是演示服务间的网络依赖,不是工程最佳实践。真实项目请用 FastAPI 或者 Flask,再用 uvicorn 跑服务。
4.2 编写 docker-compose.yml
接下来是重点,docker-compose.yml文件:
services: db: image: postgres:15 environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} volumes: - dbdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 5s timeout: 3s retries: 10 restart: unless-stopped backend: build: ./backend environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} ports: - "8080:8080" depends_on: db: condition: service_healthy restart: unless-stopped web: image: nginx:alpine ports: - "80:80" volumes: - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - ./frontend:/usr/share/nginx/html:ro depends_on: - backend restart: unless-stopped volumes: dbdata:这一段配置里有三个设计要点。
第一个是${POSTGRES_USER}这类的变量替换。值不直接写在 YAML 里,而是放到.env文件中,这样不同环境部署时只要改.env,不用改 Compose 文件,也避免了把数据库密码直接提交到 Git 仓库里。
第二个是healthcheck加depends_on.condition。这是从“人肉等数据库就绪”进化到“程序等数据库就绪”的关键。pg_isready命令会持续探测 PostgreSQL 是否可连接,Compose 只有在db服务的健康检查变成healthy之后,才会启动backend服务。
第三个是restart: unless-stopped。它保证容器崩溃或重启后能自动恢复,这是生产环境部署中最基础的自愈能力。
.env文件内容如下:
POSTGRES_USER=webapp POSTGRES_PASSWORD=change_me_in_prod POSTGRES_DB=webapp_db4.3 数据卷持久化与健康检查的用意
volumes里的dbdata是一个具名卷,用来存放 PostgreSQL 的数据文件。为什么不能直接放在容器里?因为容器一旦被删掉,里面的数据就没了。docker compose down不会删除具名卷,下次启动时数据还在。除非显式执行docker compose down -v,才会连卷一起清掉。
这一点在测试环境踩过坑。有一次为了彻底重置环境,执行了docker compose down -v,整个数据库数据都没了。当时不觉得有什么,后来才明白-v是“连数据一起删除”的意思,这操作在生产环境绝对不能随便用。
健康检查的另一个好处是,它不只影响启动顺序,还会影响日常排障。执行docker compose ps时,你能直接看到每个容器是running、healthy还是unhealthy状态。unhealthy的状态往往能提前暴露数据库权限、连接配置等问题,比等服务真正崩了再查日志高效得多。
4.4 启动、验证与基础操作
写好后,在这个目录下执行:
docker compose config这条命令会校验 YAML 语法,并把变量替换后的最终配置打印出来。做任何变更前,先跑一遍config是最稳妥的做法,能提前发现引号、缩进、变量没定义等问题。
确认无误后启动:
docker compose up -d --build-d表示后台运行,--build表示构建镜像再启动。之后用docker compose ps看一下状态。正常运行的情况下,应该能看到三个服务都在运行,且db处于 healthy 状态。
验证服务间的连通性,可以进入后端容器测试一下:
docker compose exec backend python -c "import socket; print(socket.gethostbyname('db'))"能打印出数据库容器的 IP 地址,说明 Compose 网络已经正常打通。
5. 日常用得最多的 Compose 命令与运维技巧
5.1 高频命令速查表
Compose 的命令体系和docker run不一样,它不是“每用一个容器敲一条命令”,而是“针对整个项目执行操作”。下面这些命令是我日常使用频率最高的:
| 命令 | 作用 | 我的使用习惯 |
|---|---|---|
docker compose up -d | 后台启动所有服务 | 变更配置后最常用 |
docker compose ps | 查看服务状态 | 每次操作前先看一眼 |
docker compose logs -f | 跟踪日志输出 | 排查问题时配合服务名使用 |
docker compose down | 停止并删除容器 | 不删卷,保留数据 |
docker compose down -v | 停止并删除容器和卷 | 仅限测试环境 |
docker compose exec app sh | 进入运行中的容器 | 临时调试、查看文件 |
docker compose run --rm app npm run migrate | 在容器中执行一次性命令 | 跑迁移、脚本 |
docker compose config | 校验并显示最终配置 | 修改 YAML 后必做 |
docker compose restart app | 重启某个服务 | 改了代码不想重建镜像时 |
特别注意docker compose down和docker compose stop的区别。前者会删除容器(默认不删卷),后者只是停止容器。用stop停止后,docker compose up -d会从现有容器继续启动;用down之后,则相当于重新部署一次。
5.2 日志、进入容器、执行一次性命令
日志排查是使用 Compose 时的高频操作。docker compose logs支持指定服务名查看单个服务的日志,加了-f可以持续跟踪:
docker compose logs -f backend后端容器崩溃时,日志里往往直接给出原因,比去宿主机翻/var/log快得多。有些镜像本身不输出日志到 stdout,而是写到文件,这时候需要改镜像配置或观察容器内的输出路径。
进入容器做调试也是常态。docker compose exec在不影响容器状态的前提下进入工作区:
docker compose exec backend /bin/bash注意不要用docker compose run来替代exec。run会创建一个全新的临时容器,而exec是进入已经运行的容器内执行命令。跑数据库迁移这类任务时,确实需要用run --rm新建一个容器,此时要保证新容器能连接到 Compose 项目网络,默认会自动加入。
in 最新版 Compose 中,docker compose run --rm backend npm run migrate这种写法很常用,--rm保证任务结束后临时容器自动清理,不会在docker ps -a里堆积垃圾容器。
5.3 用 .env 按环境切换配置
一个 Compose 文件适配多个环境,核心就是env_file和.env变量替换两套机制。
.env文件作用于 Compose 文件本身,用来替换 YAML 中的${VAR},比如镜像标签、端口、密码等。env_file则是容器内部的环境变量文件,容器启动后应用可以直接读取。
services: app: build: ./app env_file: - ./env/app.env environment: RELEASE_VERSION: ${RELEASE_VERSION}部署测试环境时:
RELEASE_VERSION=dev docker compose up -d部署生产环境时:
RELEASE_VERSION=1.4.2 docker compose up -d同一个 Compose 文件,只通过变量切换,镜像版本、环境变量、资源限制都随之变化,不需要维护两份几乎一样的 YAML 文件。
Compose 变量替换还有默认值语法,这个很实用:
services: web: image: nginx:${NGINX_VERSION:-1.25}${NGINX_VERSION:-1.25}表示:如果当前环境没有定义NGINX_VERSION,就用1.25作为默认值。这样即使.env文件里少写一个变量,服务也照常能启动。
6. 常见故障排查与避坑笔记
6.1 depends_on 为什么会“失效”
很多人第一次用depends_on时都会被坑到。默认的depends_on只保证容器启动顺序,不保证服务就绪。也就是说,db容器启动了,但 PostgreSQL 进程可能还没准备好接受连接,此时backend已经开始连接数据库,就会报错。
新版 Compose 支持用condition: service_healthy解决这个问题,这也是 4.2 节配置里做法的依据。但前提是你给依赖的服务配置了healthcheck。没有健康检查的话,Compose 永远不知道它是否就绪。
如果你的镜像不想加健康检查,又存在启动顺序问题,备用方案是在应用代码里做重试逻辑。后端启动时循环尝试连接数据库若干次,每次间隔几秒。这个方案不依赖 Compose,也适用于docker run场景,我两个方案都留着,在实际应用中看情况选。
6.2 服务间网络不通的经典原因
服务之间访问不通,最常见的两个原因:一是服务不在同一个网络,二是服务名解析失败。
同一个 Compose 文件里的服务默认共享一个项目网络,但如果你手动定义了多个networks,某个服务只加入了其中一个,另一个服务在别的网络里,就会互相访问不到。另一个情况是跨 Compose 项目访问,默认也不能通过服务名互通,必须用external: true将已有的外部网络引入。
networks: shared_net: external: true服务名解析失败的排查方法是在容器内执行 DNS 解析:
docker compose exec backend getent hosts db如果查不到 IP,检查 YAML 里服务名是否拼错、网络配置是否正确。查到了但连不上,继续用nc -zv db 5432检查端口连通性。
6.3 端口冲突与卷权限
端口冲突是docker compose up失败的高频原因。报错信息通常类似Bind for 0.0.0.0:8080 failed: port is already allocated。
排查方式很简单:
docker ps | grep 8080如果端口被宿主机其他进程占用,先用lsof -i :8080确认,再决定是换 Compose 端口映射还是停掉冲突的进程。
卷权限问题更隐蔽。数据库容器首次启动时,如果挂载的目录没有写权限,经常直接退出。看日志会看到类似Permission denied的报错。解决方案通常是在 Compose 中使用具名卷,让 Docker 自动初始化正确的权限;如果必须绑定宿主机目录,可能需要指定user或者在宿主机上调整目录属主。
services: db: image: postgres:15 volumes: - dbdata:/var/lib/postgresql/data具名卷是规避卷权限问题最省事的方案,这也是我在示例配置中优先选择它的原因。
6.4 配置文件层面的几个坑
YAML 缩进问题是最常见的报错来源。services下面的服务定义必须缩进两个空格,字段名和值之间必须有空格。这个问题在docker compose config阶段就能暴露,建议改了文件后第一时间跑一下。
还有一个坑是关于container_name的。设置固定的container_name会方便宿主机上对容器做操作,但这也意味着同一个 Compose 项目只能在这个宿主机部署一次。如果你想用同一套 Compose 文件部署多个实例,比如多租户场景,就不应该设置container_name,让 Compose 自动生成带项目名前缀的容器名来避免冲突。
还有一个容易被新手忽略的细节:docker compose up -d之后,默认不会重新构建镜像。如果你改了本地 Dockerfile,需要显式加上--build参数,否则启动的还是旧镜像。这也是为什么我在 4.4 节启动命令里直接写了up -d --build——省得每次忘记。
另外,depends_on与restart: unless-stopped配合使用时要注意:如果db服务重启了,backend不会自动跟着重启。从编排的角度看这其实是合理的——编排工具负责启动时的依赖顺序,容器运行时的崩溃恢复由各自的restart策略负责,两者职责分离。理解了这一点,你就不会在db升级重启后发现后端断了而抱怨 Compose 不干活了。
实际操作里还有一个经常用到的小技巧:修改了 Compose 文件的某个服务配置,只想重建这个服务,可以这样操作:
docker compose up -d --no-deps backend--no-deps告诉 Compose 不要连带启动依赖服务,只更新指定的backend,这在调试单个服务时特别省时间。我在本地迭代后端代码时几乎每次都靠它,效率比docker compose up -d --build全量重建高出一截。
做了那么多项目的容器化改造,我个人的体会是:Docker Compose 真正解决的不是“跑不跑得起来”的问题,而是“能不能稳定复现、能不能版本化、能不能团队协作”的问题。一份docker-compose.yml放进 Git 仓库,新同事拉下来敲一行docker compose up -d就能获得和线上一致的环境,这种体验带来的价值远超过省下的那几分钟敲命令时间。
最后再分享一个我用了很久的工作流:所有需要容器化的项目,我都会在根目录维护一个docker-compose.yml,并把docker compose config --quiet写进 Git 提交前的检查脚本里。这样任何人在改动配置后,如果格式有问题,提交就会直接被拦截。这类小机制看着简单,长期用下来省掉的排障时间真的不少。