说实话,我第一次用Docker Compose之前,也觉得自己挺懂容器化。单容器跑起来没问题,docker run 那一堆参数背得滚瓜烂熟。但一旦项目超过两三个服务——前端、后端、数据库、缓存、消息队列——你就会立刻意识到,手工 docker run 根本不是人能维护的事情。
Docker Compose 解决的就是这个痛点:把多个容器的启动方式、网络关系、数据卷挂载、环境变量全部写进一个声明式配置文件里,一条 docker compose up -d 全部拉起,一条 docker compose down 全部清理。这篇文章我会从核心概念讲起,再带你完整经历一个模拟微服务项目的容器化改造过程——包含后端 API、MySQL、Redis、前端静态资源、定时任务五个服务。无论你是刚接触容器的小白,还是已经在用 Compose 但总在网络上栽跟头的人,这篇都能给你一些可以直接抄作业的东西。
1. 先把概念打牢:Compose 到底解决了什么问题?
1.1 从单容器到多容器,痛点到底从哪来
你单独跑一个 Nginx 容器很简单,docker run -p 80:80 nginx 就完事。但真实项目是多个服务互相依赖:后端要连数据库,数据库要挂数据卷,前端要反代到后端,Redis 要配密码,队列要消费消息。如果用 docker run 一个个启动,你会遇到的问题包括但不限于:
- 容器启动顺序完全靠人工,今天先起数据库明天忘起 Redis,然后挨个排查依赖失败。
- 不同容器要共享网络,你得手动 docker network create,再把每个容器加进去,命令又长又容易漏。
- 环境变量、端口映射、卷挂载分散在十几个 shell 脚本里,换台机器部署就是一场灾难。
- 想一键停掉所有服务?不存在的,你得一个个 docker stop,还得记得容器名。
Compose 的核心思路非常简单:把容器编排的意图写进代码,而不是记在脑子里或散落在脚本中。它不是一个容器运行时,而是 Docker 官方提供的容器编排工具,早年叫 fig,现在直接内置在 Docker 客户端里,连安装都可以省略。
1.2 Compose 文件的顶层逻辑其实就三块
一份标准的 docker-compose.yml 看起来字段很多,但顶层结构永远只有三大块:
version: "3.8" services: # 你要启动的所有容器,每个容器称为一个“服务” networks: # 自定义网络,让服务通过服务名互访 volumes: # 具名数据卷,实现持久化存储很多初学者一上来就盯着 services 里的几十个配置项,反而把整体结构搞混。我习惯把一个 Compose 文件当作一张「部署蓝图」:services决定跑什么,networks决定怎么连,volumes决定数据放哪。三者独立定义,再用缩进和引用建立关系,这种松耦合设计让单个服务可以很轻易地挪到别的编排环境里。
要注意一个细节:新版 Docker Compose 已经不太强调 version 字段,老版本的version: "3.8"写了也不会报错,但如果你的 Docker 版本够新,省略它更清爽。这只是历史遗留习惯,不影响功能。
2. 服务、网络、数据卷:三个核心要素逐一拆解
2.1 services 服务定义里最常用的字段长什么样
services 是 Compose 文件的核心,我最常用到的字段是这几个:
| 字段 | 作用 | 备注 |
|---|---|---|
| image | 指定镜像名或镜像仓库地址 | 本地没有时自动拉取 |
| build | 指定 Dockerfile 所在目录 | 和 image 二选一或同时用 |
| container_name | 自定义容器名 | 不写的话 Compose 自动生成 |
| ports | 宿主机端口和容器端口映射 | 格式"8080:80" |
| environment | 环境变量 | 敏感信息应改用 env_file |
| volumes | 卷挂载 | 支持具名卷和 bind mount |
| depends_on | 声明服务依赖 | 有局限,后面细说 |
| restart | 重启策略 | no/always/on-failure |
| command | 覆盖容器默认启动命令 | 常用于微调启动参数 |
一个典型的后端服务定义长这样:
services: api: build: ./backend container_name: order-api ports: - "8080:8080" environment: SPRING_PROFILES_ACTIVE: dev volumes: - ./backend/logs:/app/logs depends_on: - mysql - redis restart: always这里有几个值得展开的点。
ports的写法我建议始终使用"宿主机端口:容器端口"的字符串格式,别写不带引号的8080:8080。在部分 YAML 解析器中,这种写法会被误读为其他类型,虽然新版 Compose 已经修正,但加上引号是最稳妥的习惯。
environment里如果想覆盖默认值,我常用.env文件配合${VAR}插值语法,而不是直接把所有明文写进 compose 文件。比如${MYSQL_PASSWORD:-root123456}表示环境变量里有就用,没有就用root123456做兜底,这种写法在本地和 CI 里都能无缝切换。
2.2 networks 网络模型:服务名就是你的 DNS
Compose 默认会为整个项目创建一个网络,所有服务都加入其中,于是服务之间可以用服务名直接互相访问。这一点很多人会忽略,因为太自然了——你根本不需要去查某个容器的 IP 是多少,只要在代码里写mysql:3306就能连上数据库,写redis:6379就能连上缓存。
如果你希望服务之间隔离,可以自己定义多个网络。比如后端服务需要同时访问数据库和 Redis,前端只需要访问后端,就可以这样设计:
services: frontend: build: ./frontend networks: - frontend-net api: build: ./backend networks: - frontend-net - backend-net mysql: image: mysql:8.0 networks: - backend-net networks: frontend-net: backend-net:这样前端的请求只能打到api服务上,碰不到数据库。很多人觉得容器化以后安全就不用管了,实际上网络隔离就是一道很好的防线。
还有一点:服务名解析依赖 Docker 内置的 DNS,和容器是否暴露宿主机端口无关。也就是说,api服务即使不配置ports,frontend也能通过api:8080访问它。我经常在测试环境故意不映射端口,只保留服务间通信,以减少宿主机端口冲突。
2.3 volumes 数据持久化:容器删了数据不能跟着没
容器是临时性的,docker compose down之后再up,默认情况下容器内生成的文件会全部消失——包括 MySQL 的数据、Redis 的 RDB 文件、应用上传的图片。要让数据保留下来,就得上卷。
Compose 支持三种挂载方式:
- 具名卷(named volume):
mysql-data:/var/lib/mysql,由 Docker 管理,存储在宿主机的/var/lib/docker/volumes下,适合数据库这种「我不关心文件放哪,但你别给我删」的场景。 - 绑定挂载(bind mount):
./config:/app/config,把宿主机目录直接映射进容器,适合配置文件、日志、开发时的热更新代码。 - 匿名卷:直接写
/container/path,不指定宿主机路径,通常只在 Dockerfile 里使用,Compose 里用得少。
实际项目中我推荐的组合是:MySQL 和 Redis 用具名卷,日志和配置用绑定挂载。字段长这样:
services: mysql: image: mysql:8.0 volumes: - mysql-data:/var/lib/mysql - ./mysql/init:/docker-entrypoint-initdb.d volumes: mysql-data:注意./mysql/init这种初始化脚本目录:MySQL 官方镜像会在首次启动时自动执行/docker-entrypoint-initdb.d下的所有.sql和.sh文件。这个特性非常适合初始化表结构。缺点是只有第一次启动时执行,后续即使更新脚本文件也不会重跑,我在项目里踩过这个坑,后来都是靠删卷重新 up 来重置。
3. 真实微服务项目:模拟订单系统的容器化全过程
3.1 项目拓扑与镜像准备思路
纸上谈兵没意思,我们直接进入一个模拟项目:一个简化版的订单系统,包含五个服务:
frontend:Nginx 托管的静态前端页面,反向代理后端 APIapi:Java Spring Boot 写的主后端,提供订单接口worker:Python 写的定时任务服务,消费订单状态mysql:MySQL 8.0,存储订单数据redis:Redis 7,做缓存和队列
整体依赖关系是:api 依赖 mysql、redis;worker 依赖 redis;frontend 依赖 api;api 调用 worker 的逻辑是通过 Redis 队列完成。
这个过程最容易犯的错是:想着把现有服务直接扔进容器,结果各种路径、配置文件写死、依赖缺失。我的建议是,先把每个服务单独容器化跑通,再写 Compose 文件整合。如果一上来就五六个服务一起 up,出问题你根本不知道从哪查。
3.2 五服务 Compose 文件实战
我先给出完整的 docker-compose.yml,再逐段说明为什么这么写:
version: "3.8" services: mysql: image: mysql:8.0 container_name: order-mysql restart: always environment: MYSQL_ROOT_PASSWORD: root_pass MYSQL_DATABASE: order_db MYSQL_USER: order_user MYSQL_PASSWORD: order_pass volumes: - mysql-data:/var/lib/mysql - ./mysql/init:/docker-entrypoint-initdb.d ports: - "3306:3306" healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 5s timeout: 3s retries: 10 redis: image: redis:7-alpine container_name: order-redis restart: always command: ["redis-server", "--requirepass", "redis_pass"] volumes: - redis-data:/data ports: - "6379:6379" healthcheck: test: ["CMD", "redis-cli", "-a", "redis_pass", "ping"] interval: 5s timeout: 3s retries: 10 api: build: ./backend container_name: order-api restart: always depends_on: mysql: condition: service_healthy redis: condition: service_healthy environment: DB_HOST: mysql DB_PORT: 3306 DB_NAME: order_db DB_USER: order_user DB_PASSWORD: order_pass REDIS_HOST: redis REDIS_PORT: 6379 REDIS_PASSWORD: redis_pass ports: - "8080:8080" volumes: - ./backend/logs:/app/logs worker: build: ./worker container_name: order-worker restart: always depends_on: redis: condition: service_healthy environment: REDIS_HOST: redis REDIS_PORT: 6379 REDIS_PASSWORD: redis_pass volumes: - ./worker/logs:/app/logs frontend: build: ./frontend container_name: order-frontend restart: always depends_on: - api ports: - "80:80" volumes: - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro volumes: mysql-data: redis-data:注意一个关键点:depends_on只保证启动顺序,不保证依赖服务已经可用。新版本 Compose 支持了condition: service_healthy这种完整形态——只有依赖服务健康检查通过,才开始启动当前服务。这比原来的死等模式可靠得多,建议直接用。
3.3 各服务的 Dockerfile 与启动命令
后端./backend/Dockerfile示例:
FROM maven:3.8-eclipse-temurin-17 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests FROM eclipse-temurin:17-jre WORKDIR /app COPY --from=builder /build/target/order-api.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]这里用多阶段构建,先用 Maven 镜像打包,再拷贝到精简 JRE 镜像里。这样最终镜像体积能瘦掉一大半,而且不会把源代码和构建依赖带进生产环境。
前端./frontend/Dockerfile更简单,就是打包静态文件后用 Nginx 托管:
FROM node:20-alpine AS builder WORKDIR /build COPY package*.json . RUN npm ci COPY . . RUN npm run build FROM nginx:alpine COPY --from=builder /build/dist /usr/share/nginx/html EXPOSE 80worker 是一个 Python 脚本,反复从 Redis 队列里取消息:
FROM python:3.12-alpine WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple COPY worker.py . CMD ["python", "worker.py"]requirements.txt里只需要redis和python-dotenv两个依赖就够了。跑定时任务时要注意:容器内的 Python 进程必须以前台方式运行,不能直接写死 sleep 循环后退出,否则容器会因为主进程结束而自动停止。
3.4 启动、验证、日志常用命令速查
启动前先检查语法:
docker compose config这个命令会校验 compose 文件语法,并把最终的配置打印出来,环境变量插值也会被解析成实际值。我每次改配置后都会先跑一遍,能省下大量排错时间。
启动服务:
docker compose up -d-d是后台运行。想看实时日志就换成docker compose up,或者启动后用:
docker compose ps docker compose logs -f api验证服务间连通性最直接的方式是进入容器测试网络解析:
docker exec -it order-api ping mysql docker exec -it order-api curl http://frontend如果 ping 不通某个服务名,优先检查是不是两个服务不在同一个网络里。这是我在实际项目里排查过最多的问题之一。
4. 生产化改造:环境差异、健康检查与安全细节
4.1 多环境配置:override 文件与环境变量插值
真实项目至少要分本地开发、测试、生产三套环境,不可能每个环境都维护一份完整 compose 文件。最佳实践是:docker-compose.yml写通用配置,docker-compose.override.yml写本机开发覆盖,生产环境用-f指定另一份覆盖文件。
默认情况下,执行docker compose up会自动加载同目录下的docker-compose.override.yml,这个文件不需要显式指定。而生产环境我习惯单独建一份docker-compose.prod.yml,部署时手动指定:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d开发环境的 override 文件常用这个套路:
services: api: build: ./backend volumes: - ./backend/src:/app/src environment: SPRING_PROFILES_ACTIVE: dev把宿主机源码挂载进容器,配合后端的热部署插件,就能实现改代码实时生效,不用每次 rebuild 镜像。这个体验非常贴近本地开发,但要注意文件挂载性能在 macOS 上可能不太理想,实测大规模依赖扫描时会明显变慢。
环境变量插值则靠项目根目录的.env文件,Compose 会自动加载。比如:
MYSQL_ROOT_PASSWORD=root_pass MYSQL_DATABASE=order_db在 compose 文件里写${MYSQL_ROOT_PASSWORD}就能引用。注意,.env文件不要提交到 Git 仓库,养成在.gitignore里加.env的好习惯。
4.2 健康检查:别让 depends_on 成为摆设
前面已经提到condition: service_healthy,这里展开说说健康检查本身。每个服务都可以加healthcheck节点,Docker 会按固定间隔执行检查命令,连续失败达到重试次数就标记服务为 unhealthy。
MySQL 的健康检查命令是mysqladmin ping,Redis 是redis-cli ping,后端 Java 服务没有内置健康检查命令的话,可以用 curl 打它的 actuator 端点:
healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1"] interval: 10s timeout: 5s retries: 3 start_period: 30sstart_period很关键。像 Spring Boot 启动可能要二三十秒,如果不设置这个字段,健康检查可能因为首次检查时应用还没起完,就直接被标记为 unhealthy。我调试过不少这种“看着明明起来了但服务就是显示不健康”的疑难杂症,原因就是没给足启动缓冲时间。
4.3 资源限制与安全加固:配置项容易漏但很重要
容器如果没有资源限制,就可能出现一个服务吃光宿主机内存的情况。Compose 支持deploy.resources.limits字段,即使不用 Swarm 模式,在本地 Docker 环境也会生效:
deploy: resources: limits: cpus: "1.0" memory: 512M另一个容易忽略的点是容器运行用户。默认情况 Docker 容器以 root 运行,虽然方便但风险高。官方镜像大多支持user指令,或者干脆装好普通用户。MySQL 和 Redis 官方镜像默认就不以 root 跑主进程,但自己写的应用容器一定要留意这一点:
services: api: user: "1000:1000"前提是宿主机的用户 ID 与容器内用户能对上,或者镜像里预先创建了同 ID 的用户,否则可能出现数据卷目录没权限写入的尴尬情况。这个我在挂载应用日志目录时踩过坑,后来统一在 Dockerfile 里用USER appuser声明,Compose 侧反而省心。
还要避免把数据库端口暴露到公网,尤其是3306、6379。如果只是服务间通信,完全可以去掉ports映射。万不得已需要远程维护,也尽量限制来源 IP 或用 SSH 隧道。
5. 常见报错与排查心得实录
5.1 端口占用与容器 IP 变动
报错信息基本长这样:
Error response from daemon: driver failed programming external connectivity on endpoint order-api: Bind for 0.0.0.0:8080 failed: port is already allocated这个不用想太多,就是宿主机端口被占了。排查命令:
lsof -i :8080找到占用进程后处理掉,或者直接修改 Compose 文件里的端口映射。这种事在本地开发时见得多,因为前一个docker compose up没正确 down,旧的容器还占着端口。可以先跑docker compose down,再确认docker ps里确实没有残留容器。
容器 IP 变动的问题比较隐形。你手动进入容器查到的 IP,和另一个容器访问到的 IP 可能不一样,因为 Docker DNS 是按服务名动态解析的。所以一定要养成在应用配置里写服务名而不是 IP 的习惯。
5.2 数据卷权限与热更新失效
挂载目录后经常遇到 Permission denied,尤其是宿主机目录的 UID 和容器内用户的 UID 不一致时。解决思路有三个:
- 改宿主机目录权限,
chmod -R 777这种最暴力但别在正式环境用。 - 在 Dockerfile 里创建和宿主机 UID 一致的用户。
- 在 Compose 里给服务指定
user: "UID:GID"。
我一般推荐第二种,改 Dockerfile 最干净,镜像换个机器也不会有问题。
热更新失效是另一类坑:明明把./backend/src挂载进了容器,但改了代码不生效。排查顺序是——先确认路径对不对,再用docker exec进容器看文件内容有没有同步到,最后确认后端框架的 devtools 是否在容器里启用。很多时候是依赖没装全,或启动参数没带 dev 配置,而不是 Compose 的问题。
5.3 健康检查一直失败的排查思路
服务明明能访问,但健康检查一直不过,现象是docker compose ps里 STATUS 列显示unhealthy。我总结了一套排查顺序:
- 手动执行健康检查命令,确认它在容器内真的能跑通。很多情况是命令不存在,或者依赖的客户端没装。
- 检查端口是容器内端口还是宿主机端口,健康检查命令测试的是容器内地址。
- 看
start_period是否太短,应用启动慢的先把起始等待期拉长到 30s 以上。 - 看应用是否有防火墙或安全组策略,拦截了回环地址的访问,容器内 localhost 访问有时和预想不同。
有一次我排查了半天,最后发现是健康检查命令里用了curl localhost,但应用其实监听在 IPv6 的::1上,curl 默认走 IPv4 自然失败。改成curl http://127.0.0.1:8080/actuator/health后立刻正常。
5.4 问题排查速查表
| 现象 | 常见原因 | 排查命令/手段 |
|---|---|---|
| 服务起不来 | 端口被占、镜像不存在、启动命令错误 | docker compose ps、docker logs |
| 服务间连不上 | 不在同一网络、服务名拼错 | docker exec 进入容器 ping 服务名 |
| 数据丢失 | 没挂卷或卷被误删 | docker volume ls、检查 compose 中 volumes |
| 权限不足 | UID 不一致 | docker exec 查看容器内用户、ls -l 宿主目录 |
| 健康检查失败 | 命令不对或 start_period 过短 | 手动在容器内执行检查命令 |
| 配置不生效 | 环境变量拼写错误或插值未解析 | docker compose config 查看解析结果 |
6. 从 Compose 到更复杂的编排:下一步往哪走
6.1 单机 Compose 的边界,心里要有数
Compose 擅长的是单机编排,一台宿主机上把服务安排得明明白白。但如果你的服务需要横向扩展到多台机器,或者需要一个调度器来自动处理某节点宕机后容器迁移,Compose 就力不从心了。这时候要么上云托管服务,要么引入容器编排平台。但从 Compose 过渡过去并不难,因为 Compose 文件里的 services、networks、volumes 概念和编排平台中的工作负载、网络策略、持久卷声明几乎一一对应。
我见过很多团队用 Compose 把一个系统的可复现性做得很好,即使规模不大,也建议先把 Compose 的配置管理、资源限制、健康检查这套基本功打扎实。这些经验在未来换到任何集群环境都不浪费。
6.2 日志采集与配置管理:还没被解决的两个问题
单机 Compose 默认情况下,日志是写到 Docker 的 JSON 文件里,docker compose logs能查历史日志,但服务一多、流量一大,日志文件就会不断膨胀,轮转策略也需要手动配置。我建议在 Compose 里给每个服务加日志限制:
logging: driver: json-file options: max-size: "20m" max-file: "5"配置管理则是另一个被低估的问题。Compose 文件的 env_file 和 .env 已经解决了一部分,但密钥管理、动态配置下发这些需求,单靠 Compose 做得很初级。实操上我一般把敏感配置先交给专门的秘密管理方案,Compose 里的环境变量只保留非敏感部分,或者启动时拉取并注入成临时文件。
6.3 给新手的上手路径建议
我的体验是,学 Compose 最快的方式不是读文档,而是把一个现有项目一步步容器化。一开始别追求所有服务都用 Compose 管理,先把最核心的后端和数据库容器化了,跑通之后再加入 Redis、前端、队列、定时任务,每加一个服务解决一个新的实际需求。
过程中你会自然碰到网络、数据卷、健康检查、资源限制这些绕不开的坎,每解决一个,你对容器编排的理解就深一层。
7. 最后再分享两个实操心得
第一个心得是关于「清理」。我几乎每周都会遇到因为上次实验没清理干净导致环境异常的情况。所以现在养成了习惯:每次动手前先docker compose down加docker volume prune确认清理一致,然后再up。虽然多花几秒,但能避免很多莫名其妙的问题。
第二个心得是镜像标签。不要在 compose 文件里写latest,也不要在代码里依赖latest。镜像一旦更新,同样的image: mysql:8.0拉到的内容可能已经变了,复现环境就会不稳定。我现在都会锁定具体的版本标签,哪怕是本地测试也会用mysql:8.0.36这种明确的版本号。这个习惯在排查问题时帮过大忙——当你发现“昨天还能跑,今天就不行了”,很可能就是镜像版本悄悄变了。
带过不少人从零入门容器化,几乎每个人都倒在了同一个地方:只盯着命令和配置,没有先想明白「服务怎么连、数据怎么存、依赖怎么等」这三个问题。先把这三件事想清楚,再回头写 compose 文件,你会发现自己不需要背任何语法,一切都很自然。