
先坦白一个场景估计用过 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其实是够用的。要启一个 Redisdocker 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_PASSWORD123456 -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_USERwebapp \ -e POSTGRES_PASSWORDsecret123 \ -v dbdata:/var/lib/postgresql/data \ postgres:15 # 3. 启动后端 API docker run -d \ --name webapp-api \ --network webapp_net \ -e DB_HOSTwebapp-db \ -e DB_PORT5432 \ -e DB_USERwebapp \ -e DB_PASSWORDsecret123 \ -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 FLAGtrue在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( hostdb, useros.getenv(POSTGRES_USER), passwordos.getenv(POSTGRES_PASSWORD), databaseos.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_USERwebapp POSTGRES_PASSWORDchange_me_in_prod POSTGRES_DBwebapp_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_VERSIONdev docker compose up -d部署生产环境时RELEASE_VERSION1.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 提交前的检查脚本里。这样任何人在改动配置后如果格式有问题提交就会直接被拦截。这类小机制看着简单长期用下来省掉的排障时间真的不少。