做容器化部署这些年,我一直有个感受:Dockerfile 只是解决了"单个容器怎么构建"的问题,真正让开发环境、测试环境、线上环境保持一致的,其实是 Docker Compose。每次看到有人把十几个docker run串成一个 shell 脚本,或者靠手打命令启动一套微服务,我就觉得有必要认真聊聊 Compose。它不仅是把-p、-v、--network这些参数挪到 YAML 里那么简单,而是把"部署一套系统"这件事,从记忆各种参数变成了写一份可以被 review、被版本管理的配置文件。
这篇文章围绕 Docker Compose 的核心概念和真实微服务项目展开,从为什么需要它、核心概念怎么理解,到如何设计一份可以落地的编排文件,再到启动顺序、健康检查、日志轮转、资源限制这些上线绕不开的细节,最后附上我实际排错时踩过的一些坑。适合刚接触容器编排的开发者,也适合已经用 Compose 但想理清内部逻辑的运维和全栈工程师。我会尽量把每一个配置项背后的理由讲清楚,而不是甩一份模板让你照抄。
1. 我为什么把项目全部改成 Compose 编排
1.1 当 docker run 开始管不过来时
大概在第三个微服务出现的时候,我彻底放弃了手动敲命令部署的想法。想想这个场景:你需要先启动数据库容器,等它初始化完成,再启动后端服务,最后启动前端容器,中间还要把网络打通、把日志目录挂载出来、给每个容器设置环境变量。第一次敲这些命令可能要十分钟,第二次加一个服务又得重新理一遍依赖关系,第三次换一台机器部署,命令行历史还不一定同步。
用docker run的时候,这些信息全部散落在 shell 历史里,既没有结构,也没有注释。而 Compose 的核心价值,就是把"部署动作"固化成一份声明式的配置文件。服务清单、镜像来源、端口映射、依赖关系、存储卷、环境变量,全部写在一个compose.yaml里,然后一条docker compose up -d全部搞定。这套思路很接近基础设施即代码,只是它更轻、更贴近开发者的日常。
1.2 Compose 的三层抽象:服务、网络、卷
要理解 Compose,先抓住三个核心对象:服务(service)、网络(network)、卷(volume)。
服务是 Compose 最基本的单元,它描述了一个容器应该是什么样:用哪个镜像、暴露哪些端口、挂载哪些目录、依赖哪些其他服务。网络解决的是"服务之间怎么找到对方",Compose 默认会为整个项目创建一个独立的 bridge 网络,同一个网络里的服务可以直接用服务名互相访问,这比记 IP 地址要可靠得多。卷解决的是"数据怎么持久化",容器本身是无状态的,重启就丢数据,卷的作用就是把容器内部的目录映射到宿主机上,让数据活到容器生命周期之外。
这三个概念对应到实际场景就非常直白:服务负责"跑什么",网络负责"怎么连通",卷负责"怎么保存"。你在 YAML 里声明这三个对象,Compose 引擎负责把它们落实成真实的 Docker 对象。如果去掉 Compose,你需要自己手动创建网络、手动写-v参数、手动处理启动顺序,而这些工作本该是自动化的一部分。
1.3 Compose 适合谁、不适合谁
得把边界画清楚。Compose 适合单机场景下的多容器编排,不管是本地开发、CI 环境,还是单台服务器上的轻量生产部署,它都能把复杂度压得很低。我这个项目一开始就是单机部署,三个业务服务加两个中间件,Compose 完全够用,并且配置清晰。
但到了多台机器、需要弹性伸缩、需要自愈和滚动更新的场景,Compose 就不够用了,那是容器编排平台的领域。用 Compose 硬扛集群管理,会把配置搞得非常别扭。我的经验是:单机、服务数量在十个以内、对高可用要求不极端的场景,Compose 是性价比最高的方案;一旦要跨主机调度,就老老实实上集群方案。这个判断越早做越好,能省掉后面很多改造成本。
2. 动手之前需要补齐的核心概念
2.1 compose.yaml 的结构骨架
不管项目多复杂,compose.yaml的核心结构就三层:顶层声明services、networks、volumes,第二层定义单个服务,第三层定义服务的具体配置项。新版 Docker 已经弱化了version字段,我不建议再写,因为 Compose 规范本身是向后兼容的,写不写都不影响解析,写了反而可能产生误导。
services: web: build: . ports: - 8080:80 redis: image: redis:7-alpine上面这份最简单的配置,已经包含了 Compose 最基本的两类服务来源:一类是用build从本地 Dockerfile 构建的,另一类是用image直接拉取现成镜像的。实际项目中两者经常混用,业务服务用build,中间件用image。ports字段把容器端口映射到宿主机端口,- "8080:80"的意思是宿主机的 8080 端口转发到容器内的 80 端口。
我在写 Compose 文件时有一个习惯:先搭骨架,再填细节。先定义好有哪些服务、它们的镜像或构建路径、彼此的依赖关系,然后再逐个服务补充环境变量、健康检查、资源限制。这样做不容易遗漏,也方便别人 review,因为骨架一眼就能看出系统里有哪几部分。
2.2 网络模型:Compose 默认网络里服务名就是主机名
很多刚开始用 Compose 的人会困惑一个问题:服务 A 怎么访问服务 B?在默认配置下,Compose 会为项目创建一个默认网络,所有没有显式指定网络的服务都会加入这个网络。在这个网络里,每个服务名就是可解析的主机名。
举个例子,如果配置里有一个user-service,另一个服务想连接它的数据库,直接写user-service作为主机名就行,完全不需要关心 user-service 容器的 IP 是多少。容器重启会导致 IP 变化,但服务名是稳定的,这比在代码里硬编码 IP 要优雅得多。
这里要特别提醒一个新手常犯的错误:容器内不要用localhost去访问另一个服务。每个容器有自己独立的网络栈,localhost指向的是本容器自己,不是宿主机,更不是其他容器。正确做法是使用服务名,或者使用宿主机在 docker 网桥上的 IP。我见过不止一次项目里因为这个问题导致服务间调用失败,排查了半天最后发现是访问地址写错了。
2.3 数据卷:别让容器一停数据就没了
容器的文件系统是临时的,容器删除后数据跟着消失,所以需要数据卷把关键目录持久化到宿主机。Compose 里声明卷分为两步:顶层声明volumes,服务内通过volumes挂载引用。
volumes: mysql-data: services: mysql: image: mysql:8.0 volumes: - mysql-data:/var/lib/mysql这里mysql-data是具名卷,数据由 Docker 管理,存放在 Docker 的数据目录下。还有一种方式是绑定挂载,直接把宿主机的某个路径映射进去:
services: nginx: image: nginx:alpine volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro两种方式各有适用场景。数据文件这种不需要用户直接查看的,用具名卷更安全,跨环境迁移也方便;配置文件这种需要随时编辑、和代码仓库保持同步的,用绑定挂载更合适。加了:ro表示只读挂载,防止容器内意外修改宿主机文件。
2.4 环境变量和 .env 的加载顺序
Compose 常用.env文件配合使用。项目根目录的.env文件里写MYSQL_ROOT_PASSWORD=xxx这类变量,Compose 解析 YAML 时会把它们替换进去,实现同一个 Compose 文件在不同环境使用不同配置的效果。
用法是在 compose.yaml 里引用:
services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}加载优先级要注意:当前 shell 的环境变量优先级最高,其次是命令行指定的--env-file,再次是项目根目录的.env,最后才轮到 Compose 文件里的默认值。这在实际操作中会造成一个隐蔽的问题——明明.env里改了密码,为什么容器里还是旧值?很可能是因为 shell 里已经导出了同名变量。遇到这种情况,先unset对应变量,或者用docker compose config验证实际生效的配置。
3. 一个接近真实项目的微服务编排:从零写到底
3.1 项目架构与端口规划
为了把这套知识落到实践,我搭过一个模拟的微服务项目,包含五个服务:API 网关、用户服务、订单服务、MySQL、Redis。业务逻辑是前端请求先到网关,网关根据路径把请求分发到对应的业务服务,业务服务之间通过服务名互相调用,数据存 MySQL,热点数据缓存到 Redis。
配置前的第一步是规划端口。我的原则是:对外暴露的入口越少越好,只有网关需要映射到宿主机端口;服务之间的内部调用全部走 Compose 网络,不映射端口;数据库和缓存如果需要调试,再额外映射到127.0.0.1,避免暴露到公网。
| 服务 | 容器端口 | 宿主机映射 | 说明 |
|---|---|---|---|
| api-gateway | 8080 | 80 | 唯一对外入口 |
| user-service | 8081 | 不映射 | 内部调用 |
| order-service | 8082 | 不映射 | 内部调用 |
| mysql | 3306 | 127.0.0.1:3306 | 仅本机调试时访问 |
| redis | 6379 | 127.0.0.1:6379 | 仅本机调试时访问 |
3.2 业务服务镜像的构建策略
业务服务不能直接拉现成镜像,需要基于 Dockerfile 构建。这一层有个关键决策:是每个服务维护一个独立 Dockerfile,还是统一用一套多阶段构建模板。我倾向每个服务独立维护 Dockerfile,因为它们的依赖、启动命令、健康检查接口都不一样,硬塞进同一个模板会把所有人的工作都搞复杂。
多阶段构建也是值得养成的习惯。以 Java 服务为例,第一阶段用包含编译工具的基础镜像完成打包,第二阶段用精简运行时镜像拷贝产物,这样最终镜像里没有编译器和源代码,体积能小一半以上。对 Python 项目同样适用,先装依赖生成轮子文件,再在运行时镜像里安装,避免把工具链带进生产环境。
另外,写 Dockerfile 时要把健康检查用到的工具考虑进去。比如镜像里需要curl或wget,有些精简镜像默认没有,结果健康检查指令一直执行失败,但服务本身是正常的,这种问题排查起来非常浪费时间。
3.3 compose.yaml 的完整配置与逐段说明
下面这份配置是我在模拟项目中实际使用的完整版本,去掉了与具体业务相关的敏感环境变量,保留了通用结构:
name: micro-demo networks: app-net: driver: bridge volumes: mysql-data: services: mysql: image: mysql:8.0 command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: demo_db volumes: - mysql-data:/var/lib/mysql ports: - "127.0.0.1:3306:3306" networks: - app-net healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-p${MYSQL_ROOT_PASSWORD}"] interval: 5s timeout: 3s retries: 10 start_period: 30s restart: unless-stopped redis: image: redis:7-alpine command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"] volumes: - redis-data:/data ports: - "127.0.0.1:6379:6379" networks: - app-net healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 5s timeout: 3s retries: 10 start_period: 10s restart: unless-stopped user-service: build: context: ./services/user-service environment: SPRING_PROFILES_ACTIVE: prod DB_HOST: mysql DB_PORT: 3306 DB_NAME: demo_db DB_USERNAME: root DB_PASSWORD: ${MYSQL_ROOT_PASSWORD} REDIS_HOST: redis REDIS_PORT: 6379 REDIS_PASSWORD: ${REDIS_PASSWORD} depends_on: mysql: condition: service_healthy redis: condition: service_started networks: - app-net restart: unless-stopped order-service: build: context: ./services/order-service environment: SPRING_PROFILES_ACTIVE: prod DB_HOST: mysql DB_PORT: 3306 DB_NAME: demo_db DB_USERNAME: root DB_PASSWORD: ${MYSQL_ROOT_PASSWORD} USER_SERVICE_URL: http://user-service:8081 depends_on: mysql: condition: service_healthy networks: - app-net restart: unless-stopped api-gateway: build: context: ./services/api-gateway environment: USER_SERVICE_URL: http://user-service:8081 ORDER_SERVICE_URL: http://order-service:8082 ports: - "80:8080" depends_on: user-service: condition: service_started order-service: condition: service_started networks: - app-net restart: unless-stopped注意几个细节。name: micro-demo是项目名称,如果不指定,Compose 会用当前目录名作为项目名,这会影响网络名称和卷名称的前缀,显式指定可以让环境更可控。mysql的command设置了 UTF-8 字符集,避免中文乱码。depends_on的condition字段是关键,后面专门展开说。
user-service里的环境变量DB_HOST: mysql就是利用了 Compose 默认网络的服务名解析——代码里连接数据库的主机名直接填配置里的服务名,层与层之间的解耦就很干净。
3.4 从启动到验证的完整操作流程
配置写好后,第一步永远是校验:
docker compose config这条命令会读取 compose.yaml 和 .env,渲染出最终生效的完整配置。如果 YAML 语法有问题、变量未定义、端口格式错误,都会在这里报出来。我习惯把它当作部署前的编译检查,过了这一步才继续往下走。
构建并启动:
docker compose up -d --build--build会强制重新构建镜像,避免 Docker 因为缓存而没有使用最新的 Dockerfile。首次启动会逐个拉取基础镜像、构建业务镜像,耗时取决于网络和构建复杂度,耐心等待即可。启动后查看状态:
docker compose ps正常情况下所有服务都应该显示running或healthy状态。然后验证服务间调用是否正常,我会先看日志:
docker compose logs -f api-gateway如果有健康检查异常,日志里一般会直接打印失败原因,比盲猜快得多。
临时调试时,进入容器:
docker compose exec user-service sh在容器内可以用getent hosts mysql验证服务名解析是否正常,也可以用curl直接请求依赖服务,确认网络层没问题后再把排查方向转移到业务代码层。
扩容和缩容是微服务部署里的高频操作。Compose 下同样可以执行:
docker compose up -d --scale user-service=3这会启动三个 user-service 实例。不过要注意,如果服务持有本地状态或者需要负载均衡,Compose 自带的简单缩放只能做多实例,不能做自动负载均衡,网关侧需要有对应的集群配置才能把请求分散到多个实例。
4. depends_on 的真相:启动顺序和依赖健康是两回事
4.1 只写 depends_on 会发生什么
很多刚接触 Compose 的人会想当然地认为,写了depends_on就表示"依赖的服务就绪了我才启动"。实际上它只保证依赖服务的容器先启动,不保证依赖服务已经可服务。compose 只是按依赖关系决定启动任务的先后,不会去检查数据库是否初始化完成、监听端口是否打开。
我最早在这上面吃过亏。项目里web服务依赖mysql,启动后 web 一直报"连接数据库被拒绝"。我用docker compose ps一看,mysql 的状态明明已经是running,为什么连不上?后来才明白,那是容器起来了,MySQL 还在初始化,root 密码都还没设置完,端口自然监听不了。只靠depends_on解决不了这个问题。
4.2 healthcheck 如何真正解决就绪判断
healthcheck才是解决"就绪判断"的正道。它让容器在启动后周期性执行一条探测命令,Docker 根据结果记录健康状态。MySQL 的常用探测命令是mysqladmin ping,Redis 是redis-cli ping,业务服务可以探测自己的健康检查接口。
配合depends_on的condition字段,才能真正实现"等依赖服务健康后再启动":
depends_on: mysql: condition: service_healthycondition有三个取值:service_started只要求容器启动,service_healthy要求健康检查通过,service_completed_successfully要求一次性任务成功退出。对数据库、消息队列这类有初始化过程的中间件,强烈建议用service_healthy。
健康检查本身的参数也值得认真调。interval是探测间隔,timeout是单次探测超时,retries是连续失败多少次标记为不健康,start_period是启动宽限期,宽限期内探测失败不会计入 retries。数据库在大数据量加载时启动可能很慢,start_period给足 30 到 60 秒是合理的,太短会误报,太长会延迟发现真实故障。
4.3 没有 healthcheck 能力的服务怎么兜底
不是所有镜像都有现成的健康检查工具。遇到这种情况,可以用一个简单的 TCP 探测兜底。即使镜像里没有curl、mysqladmin,只要容器里有 bash,就能用/dev/tcp文件描述符实现一个轻量探测脚本:
healthcheck: test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/127.0.0.1/8080'"] interval: 10s timeout: 5s retries: 5 start_period: 20s这个技巧的好处是不依赖额外工具,够用就好。比它更通用的思路是在业务代码里实现一个真正的 HTTP 健康检查接口,返回依赖服务的连通状态。生产环境里我一直推荐后者,因为mysqladmin ping只代表数据库进程活着,不代表业务依赖的表结构迁移已经完成,只有业务层自己才知道什么状态算"真的就绪"。
5. 微服务落地时的配置细节
5.1 端口映射的三个原则
第一个原则是不要把所有服务都暴露到宿主机。中间件、内部服务不映射端口,只有对外入口需要映射。第二个原则是外部端口尽量不直接使用 3306、6379 这样的默认端口,避免被扫描和恶意连接尝试,映射成不常用端口或只绑定到127.0.0.1。第三个原则是端口不要写裸数字,用"80:8080"这种带引号的字符串,防止 YAML 解析时把它当成八进制数字,虽然现代解析器大多没问题,但这个习惯能避免很多诡异的兼容性问题。
数据库调试时的做法是:
ports: - "127.0.0.1:3306:3306"这样只有本机可以访问,服务器公网 IP 访问不到这个端口,安全风险会小很多。需要对外提供服务时,再彻底去掉这个映射,让外部请求彻底走应用层网关。
5.2 日志轮转与磁盘保护
容器默认的日志驱动是json-file,如果不限制大小,一个高频服务的日志文件可以涨到几个 GB,磁盘被打满,容器直接挂掉。这个问题在长跑的服务上尤其严重。配置日志轮转在 Compose 里非常便宜:
logging: driver: "json-file" options: max-size: "50m" max-file: "5"max-size是单个日志文件的最大体积,max-file是保留多少个历史文件。50M x 5 意味着最多占 250M 磁盘空间,足够排查问题,又不会撑爆磁盘。生产环境我还会额外把日志采集到外部存储,但那是日志系统的范畴,不是 Compose 本身要解决的。
5.3 资源限制与自动重启
容器默认共享宿主机的 CPU 和内存,单个疯狂的服务会拖垮整机。给关键服务设置资源上限,是防止"一颗老鼠屎坏了一锅汤"的底线策略。在 Compose 文件里通过deploy.resources声明:
deploy: resources: limits: cpus: "1.0" memory: 512M reservations: cpus: "0.25" memory: 128Mlimits是硬上限,超过会触发 OOM 或 CPU 限流;reservations是预留值,保证服务启动时有足够的资源。这两个字段在非集群模式下同样生效,是 Docker 引擎内置的能力。
重启策略我统一用restart: unless-stopped,意思是容器非正常退出时自动重启,但如果是你手动docker compose stop的,则不重启。这是个稳妥的折中方案,既避免系统挂掉后服务永不恢复,也尊重管理员手动停服务的行为。不要用restart: always,因为它在你主动停止后重启策略依然生效,有时候会带来不必要的困扰。
6. 实操排错实录:我遇到的四个典型故障
6.1 YAML 缩进和类型错误:启动前就该被拦下的问题
有一次容器启动报错,指向environment下面的配置项,提示类型不匹配。查了半天发现是某个环境变量写成了:
environment: APP_PORT: 8080看起来没错,但这里的问题是把数字写成了裸量。有些解析器会把8080解析为整数,而应用读取环境变量时希望的是字符串。解决方案很简单,用引号包起来:
environment: APP_PORT: "8080"这个坑极其隐蔽,因为本地开发时应用可能自己做了类型容错,不会立刻暴露,直到换了严格的应用框架才炸出来。我的底线是:环境变量、端口映射里的值全部加引号,不要依赖 YAML 的类型推断。
6.2 端口冲突:Bind for 0.0.0.0:3306 failed
报错信息port is already allocated基本就是端口冲突。常见原因是宿主机上已经有进程占用了该端口,或者别的项目也在用同样的映射。排查方式很直接:
sudo lsof -i :3306看到占用进程后,两个选择:杀掉占用进程,或者改 Compose 里的映射端口。我建议优先改端口。有时候冲突的不是宿主机进程,而是另一个 Docker 容器,先执行docker ps看看哪个容器在用这个端口。
6.3 容器一直重启:关键在日志
容器进入Restarting状态而不稳定运行,最常见的原因不是网络问题,是应用启动崩溃。这时不要反复重启碰运气,直接看日志:
docker compose logs --tail 200 user-service绝大多数启动失败的原因在日志末尾就能看到:数据库连接失败、配置文件缺失、依赖服务还没就绪。我见过最典型的场景,是应用代码里数据库连接池初始化重试次数太少,健康检查还没通过就放弃连接了,这时候与其改应用代码,不如先把依赖服务的start_period放大,让数据库先准备充分。
6.4 Compose 覆盖文件与配置管理:保持干净的项目目录
项目越来越复杂之后,dev、test、prod 环境的差异不可避免。我采用的方式是保留一个基础compose.yaml,再配合环境覆盖文件:
docker compose -f compose.yaml -f compose.prod.yaml up -dcompose.prod.yaml里只写与生产环境有差异的覆盖项:资源限制更大、副本数不同、日志策略更严格。这样环境差异集中在一处,不会污染基础配置。另外,我会把.env文件加入版本忽略,生产环境的密码不应该提交到仓库。团队里其他人拿到项目后,只需要复制.env.example为.env并填入实际值,就能启动整套环境。
这套流程走下来,我最大的体会是:Compose 的难点从来不在语法,而在思维方式。你需要把一个运行中的系统拆解成服务、网络、卷三个层次,想清楚服务间怎么通信、数据怎么持久化、故障怎么恢复。把这些问题在 YAML 里想明白了,部署一个微服务项目就真的变成了一条命令的事。如果你是刚开始接触容器编排,不要急着直接套用别人的复杂模板,先把单个服务的 Compose 写利索,再逐步扩展到多服务,遇到问题优先用docker compose config和logs定位,环境始终是诚实且可复现的。