1. 从 docker run 到 docker compose:为什么我们需要一个“指挥官”
1.1 手动启动微服务小队的混乱现场
我印象很深,前几年接手一个老项目时,服务从单体刚拆成三个微服务模块,加上配套的 Redis、MySQL,每次本地环境启动都是一场灾难。终端窗口开了一排,每个窗口盯着一条docker run命令,参数又长又容易记错,端口映射谁的在前谁的在后、哪个容器该连哪个网络,全靠脑子硬记。
典型的docker run长这样:
docker run -d --name user-service \ -p 8081:8081 \ --network my-net \ -e DB_HOST=mysql \ -e REDIS_HOST=redis \ myapp/user-service:latest三个服务加两个中间件,就是五条这样的命令。每次还要按顺序执行,因为 user-service 得等 MySQL 先启动。一旦某个容器崩溃,排查过程就更难受了——你根本不记得刚才哪条命令配了什么环境变量。这种模式在小团队里撑过一两周还行,一旦微服务数量超过五个,基本就是灾难。
Docker Compose 解决的就是这个痛点。它把一组容器的定义、网络、依赖关系、环境变量全部写进一个docker-compose.yml文件里,然后一行docker compose up -d,整个“小队”按你定义的规则启动起来。不用再一个个敲命令,团队里任何一个人拿到这份文件,都能在五分钟内跑起来相同的环境。
1.2 Compose 到底编排了什么
Compose 的核心价值,在于它把“多个容器的协同关系”提升为“一个项目”的维度来管理。这个项目就是你的微服务小队。它主要编排了四样东西:
- 服务定义:每个微服务用什么镜像、暴露哪些端口、挂什么卷、配什么环境变量,全部声明式地写在文件里。
- 网络通信:同一个 Compose 项目默认创建一个独立网络,容器之间通过服务名互相访问,不用记 IP 地址。
- 依赖顺序:通过
depends_on声明启动顺序,配合健康检查可以做到“前一个服务真正可用后,下一个才开始启动”。 - 生命周期管理:启动、停止、查看日志、扩容,都以项目为单位操作。
打个不严谨但好懂的比方:docker run像是你要搬家,一件一件把家具搬上楼,还得自己记住先搬哪个后搬哪个;Compose 则是你雇了一支搬家队,告诉他们“这个柜子放主卧、这个书桌放书房”,他们在楼下整套打包,按顺序搬运,到地方自动归位。
1.3 什么样的项目需要 Compose
不是所有场景都需要 Compose。我见过有人就为了跑一个 MySQL 也写一份 compose 文件,其实docker run一条命令就够用了。但下面这些信号一出现,就说明值得上了:
- 你的项目要同时启动两个以上有相互依赖的容器
- 你需要频繁销毁重建环境(比如测试环境、CI 环境)
- 团队协作时,环境配置经常出现“我机器上跑得好好的啊”这类对话
- 你需要模拟生产环境的服务拓扑
尤其做微服务的同学,本地开发时如果还靠手动 docker run,那联调效率会低很多。Compose 这种“一条命令启动整套环境”的能力,本身就应该是开发流程里的标配。
2. 拆解 docker-compose.yml:services、networks、volumes 三件套
2.1 services 服务块:每个容器的一个“工位”
写 Compose 文件,最核心的就是services块。它下面的每一个键,对应一个服务(也就对应一个或多个容器实例)。初学者最容易忽略的是:每个服务名在 Compose 网络内就是一个可解析的 DNS 名称。比如你定义了一个叫user-service的服务,那么其他服务里访问它,直接用主机名user-service就行,不用查 IP。
一个最小可用的服务定义长这样:
services: user-service: build: ./user-service ports: - "8081:8081" environment: DB_HOST: mysql DB_PORT: 3306 depends_on: - mysql这里的build告诉 Compose:这个服务要用当前目录下./user-service里的 Dockerfile 来构建镜像。如果你用的是现成镜像,则写image:
services: redis: image: redis:7-alpine两个可以同时写,Compose 会先用build构建出镜像,再以这个镜像启动容器——这在需要给基础镜像打补丁的场景下很实用。
端口映射要理解"宿主机端口:容器内端口"的格式。"8081:8081"表示宿主机 8081 端口转发到容器内 8081 端口。如果是微服务之间内部通信,不需要暴露到宿主机,那就别映射端口,因为同一个 Compose 网络内的容器直接访问服务端口就行,暴露出去反而增加了安全风险面。
2.2 networks 网络块:容器之间怎么互相“找到”对方
Compose 默认会为整个项目创建一个 overlay 网络(本地默认是 bridge 模式),所有服务都在这个网络里。也就是说,你什么都不写,项目里所有服务就已经能互相访问了。那为什么还要显式定义networks块?
因为现实中往往需要做网络隔离。比如你有一个日志采集服务,它需要访问所有业务服务的网络,但业务服务之间反而不需要都打通。这时候就可以定义两个网络,把服务按角色挂进去:
networks: backend: driver: bridge observability: driver: bridge services: user-service: networks: - backend - observability order-service: networks: - backend log-collector: networks: - observability这样user-service和order-service都在backend网络里,可以通过服务名互相访问;log-collector在observability网络里,只能访问同样挂了observability网络的服务。这个网络隔离在测试环境里尤其有用,可以模拟生产环境的多区结构,或者用来做灰度链路验证。
还要提一个常见的坑:如果你把两个不同 Compose 项目(不同目录下的项目)放在一起跑,它们的默认网络是不通的。有些人在项目 A 的容器里访问项目 B 的 MySQL,凑了大半天,最后发现是跨网络问题。解决方式要么把两个项目放进同一个网络(用external引用已存在的网络),要么直接在编排时就合并成一个 Compose 项目。
2.3 volumes 卷挂载:数据不随容器消亡的关键
微服务化之后,很多人下意识把一切有状态的东西都往容器外丢,其实不用这么极端。像 Redis 缓存、Elasticsearch 索引这些,虽然数据要持久化,但用 named volume 就能很好解决,而且迁移起来更简单。
services: mysql: image: mysql:8.0 volumes: - mysql-data:/var/lib/mysql volumes: mysql-data:这里声明了一个叫mysql-data的命名卷。容器被删了、compose down 了,只要卷还在,数据就还在。运行docker volume ls能看到它。
用命名卷而不是 bind mount(就是你直接写./data:/var/lib/mysql那种方式),好处是数据路径完全由 Docker 管理,你不需要关心它在宿主机上的具体位置。缺点是不太方便直接去翻文件。如果你要经常查看 MySQL 的数据文件,bind mount 会更直观,但生产环境数据量大时,bind mount 性能损耗和权限问题会更多一些。
有个细节值得注意:同一个卷可以被多个服务挂载,但要小心竞争写入。比如两个服务同时挂载同一个卷去写同一个文件,大概率会出现文件锁冲突或者数据错乱。真要共享文件,建议一个服务只写,其他服务只读,或者干脆通过消息队列传输数据。
2.4 depends_on 与 healthcheck:启动顺序到底听谁的
微服务最让人头疼的往往是启动顺序。depends_on是最直观的写法:
services: user-service: depends_on: - mysql - redis但这有个坑:默认情况下depends_on只保证“mysql 容器先启动了”,不能保证“MySQL 已经准备好接受连接”。容器启动是一个过程,进程起来了但端口可能还没监听,这时候 user-service 连上去,一样是 Connection refused。传统的做法是在应用里加重试逻辑,但更好的方案是让 Compose 自己掌握“准备好了”的信号。
Compose 支持带条件的依赖:
services: mysql: image: mysql:8.0 healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 5 user-service: depends_on: mysql: condition: service_healthy这样 user-service 会等 MySQL 的健康检查通过后才启动。这个在本地开发和 CI 里用起来非常顺手,省掉很多“明明依赖起来了却连不上”的玄学问题。实际上我在后面的实用章节还会专门展开说这个部分,因为健康检查的细节直接决定了你编排的稳定性。
3. 一个完整的微服务编排实例:从 Dockerfile 到 compose up
3.1 场景设定:一个用户服务加一个Redis
光讲概念很难落地,这里我拿一个实际场景走一遍:一个注册登录的用户服务,依赖 Redis 做 session 缓存,再有一个 MySQL 存用户数据。技术栈是 Spring Boot,但思路对所有语言都一样。
项目目录结构大致如下:
myapp/ ├── user-service/ │ ├── Dockerfile │ ├── target/user-service.jar │ └── src/... ├── order-service/ │ ├── Dockerfile │ └── target/order-service.jar └── docker-compose.yml每个服务的 Dockerfile 都想办法做成一个可独立构建的镜像。比如 Spring Boot 无状态微服务的 Dockerfile 写得极其简单:
FROM eclipse-temurin:17-jre WORKDIR /app COPY target/user-service.jar app.jar EXPOSE 8081 ENTRYPOINT ["java", "-jar", "app.jar"]注意这里没写EXPOSE以外的端口信息,端口映射完全交给 Compose 层来处理。这样镜像与部署环境解耦,同一个镜像在测试环境和生产环境用不同的 Compose 文件去映射端口,而不是改镜像本身。
3.2 完整 docker-compose.yml 推荐写法
我的开发环境 Compose 文件一般长这样:
version: "3.8" services: mysql: image: mysql:8.0 container_name: myapp-mysql environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: myapp MYSQL_USER: app MYSQL_PASSWORD: apppass ports: - "3306:3306" volumes: - mysql-data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 10 redis: image: redis:7-alpine container_name: myapp-redis ports: - "6379:6379" volumes: - redis-data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5 user-service: build: ./user-service container_name: myapp-user-service ports: - "8081:8081" environment: SPRING_PROFILES_ACTIVE: dev DB_HOST: mysql DB_PORT: 3306 DB_NAME: myapp DB_USER: app DB_PASSWORD: apppass REDIS_HOST: redis REDIS_PORT: 6379 depends_on: mysql: condition: service_healthy redis: condition: service_healthy order-service: build: ./order-service container_name: myapp-order-service ports: - "8082:8082" environment: SPRING_PROFILES_ACTIVE: dev USER_SERVICE_URL: http://user-service:8081 DB_HOST: mysql DB_PORT: 3306 DB_NAME: myapp DB_USER: app DB_PASSWORD: apppass REDIS_HOST: redis REDIS_PORT: 6379 depends_on: mysql: condition: service_healthy redis: condition: service_healthy user-service: condition: service_started volumes: mysql-data: redis-data:几个细节说明一下:
数据库连接、Redis地址都用服务名而不是IP。这是 Compose 网络的核心能力,服务重启导致 IP 变化也没关系,因为在同一个网络里,服务名始终解析到新 IP。
Spring Boot 的配置值全部通过环境变量注入。Compose 文件只负责塞环境变量,应用不写死任何环境相关的值。这样做的好处是环境切换只改 Compose 文件,代码里滚动都不用动。
order-service 调 user-service 的地址是http://user-service:8081。如果你在代码里写localhost:8081,在容器里访问的是自己,肯定连不上。这个很多人第一次写都会踩坑。
version 字段现在其实可以省略了,Docker Compose v2 不再强制要求。但写上它能提醒别人这个文件是在什么语义下写的,同时也兼容一些老的 CI 工具链。
3.3 常用运维命令:从 up 到 scale
Compose 的运维命令,常用就那么几条,我用下来体感最顺的是:
启动整套环境(前台模式,方便直接看日志):
docker compose up如果是后台运行,加-d:
docker compose up -d注意:修改了 Compose 文件后,执行docker compose up -d会自动热更新有变化的服务,但不会销毁那些没变化、还在跑的容器。这是 Compose v2 一个很好的特性。
查看所有服务状态:
docker compose ps跟踪某个服务的日志:
docker compose logs -f user-service停止整套环境但保留数据卷:
docker compose down连数据卷一起删掉,环境彻底归零:
docker compose down -v这条命令要谨慎使用,-v会把所有命名卷都删了,数据库备份没做就别轻易执行。
扩容某个无状态服务:
docker compose up -d --scale user-service=3用脚本模拟用户服务高并发,看日志里三个容器的请求分配情况,这条命令用得频繁了以后,你就能直观感受到负载均衡的效果。
对在宿主机上已经跑着的容器做更新,更通用的写法是把整个环境重建:
docker compose up -d --force-recreate强制重建的方式适合排查“配置改来改去但容器不按新配置跑”的疑难杂症。
4. 实测中踩过的坑与处理技巧
4.1 depends_on 不等同于“服务已就绪”
这是写 Compose 文件最常见的一个坑。刚才的示例里,我用了condition: service_healthy来解决。但如果你管理的老项目没有健康检查,或者镜像本身没有提供健康检查工具,事情就会变得很被动。
我遇到过 MySQL 容器明明已经起来了,但 Spring Boot 服务还是报“连接池初始化失败”的情况。为什么?因为 MySQL 容器启动≠MySQL 服务可连接,初始化还需要几秒。解决办法不外乎三种:
- 在依赖条件里加上 healthcheck,让 Compose 替你等(推荐)
- 在应用代码里增加启动重试机制(治本但不一定好改代码)
- 在 Compose 里给应用服务加
restart: on-failure(粗暴的“不行就重启”)
实际项目中,我建议把应用代码里的重试机制做了,再把 Compose 里的 healthcheck 也配上,双重保险。有人觉得“代码里加重试就是逃避问题”,其实在分布式环境里,这是基本功——不是所有依赖都在你掌控之下。
4.2 网络模式选不对,服务之间互连失败
还有一种很隐蔽的情况:两个容器在同一个 Compose 项目里,服务名可以互相解析。但如果两个服务用了不同的网络模式,比如一个用了network_mode: host,另一个在默认 bridge 网络里,那host那个服务就只能通过本机端口访问另一个服务,反向直接用服务名可能不行。
network_mode: host会把容器网络直接挂到宿主机网络栈上,端口映射全部失效,相当于共享宿主机 IP。如果非要用这种模式,就得特别小心地规划访问路径。我的建议是:在 Compose 项目内部,统一用默认 bridge 网络和服务名互相访问;只有要在宿主机上回调容器服务时才考虑 host 模式。简单说,网络模式混用是很多联调问题的根源,尽量保持一致。
还有另一个跨项目通信的典型问题:项目 A 里的服务要连项目 B 里的数据库,但两个 Compose 项目网络隔离。你需要先创建一个共享网络,然后在两个项目里都用 external 引用:
docker network create myapp-shared然后在项目 B 的 compose 文件里:
networks: default: external: name: myapp-shared项目 A 同理。这样两个项目的服务就能互通了。跨项目通信在生产环境更常见,开发和测试环境如果只是临时联调,也可以直接把相关服务合并进同一个 Compose 项目,省心得多。
4.3 环境变量、密码与敏感信息的处理
开发环境密码写死在 compose 文件里无所谓,但一旦这个文件要上传到共享仓库、CI 平台,或者交给客户演示,密码就不能裸奔了。我最基本的习惯是:所有密码走环境变量,compose 文件只留占位符。
Compose 支持从宿主机环境变量展开,也支持从.env文件读取变量值,用法是:
environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}然后在项目目录下放一个.env文件:
MYSQL_ROOT_PASSWORD=rootpass MYSQL_DATABASE=myapp MYSQL_USER=app MYSQL_PASSWORD=apppass这个.env文件要加进.gitignore,绝对不能提交到仓库。team 新成员入职给他一份.env.example模板,他自己复制成.env再填值。这套流程看着简单,但真的避免了无数次“你的密码是啥来着”的尴尬。
还有一个容易被忽略的:container_name 如果指定了,全机唯一。如果你同时在跑两套不同的 Compose 项目,都指定了同一个 container_name,第二个项目启动时会直接起不来,报“name already in use”。我一般只在固定的数据库、Redis 等中间件上指定 container_name,业务服务让它自己自动生成,减少冲突。
4.4 数据卷迁移与备份的实操心得
微服务架构下,容器随便删,但数据库数据不能随便丢。卷备份其实是一个很常用的运维操作。
备份 MySQL 卷的通用方法,是再用一个临时容器挂载同一卷,然后把卷内容打包拷出来:
docker run --rm \ -v myapp_mysql-data:/var/lib/mysql \ -v $(pwd)/backup:/backup \ ubuntu \ tar czvf /backup/mysql-data.tar.gz /var/lib/mysql恢复数据同样简单,把那包数据解压回卷路径就行。这套方法适合整体冷备份,不用关心 MySQL 内部的操作系统差异。
如果只备份 MySQL 的逻辑数据,更稳妥是用mysqldump:
docker exec myapp-mysql mysqldump -u app -papppass myapp > backup.sql所以我的建议是:定期逻辑备份为主,卷快照备分为辅。只做卷备份,万一 MySQL 版本升级导致存储格式不兼容,恢复的时候会很尴尬;只做逻辑备份,则恢复速度慢。两个结合是最稳妥的。
5. 进阶思考:从 Compose 到编排器的路还有多远
5.1 Compose 的边界:单机编排器的天然上限
Compose 非常好用,但它本质上是单机编排器——所有容器都运行在一台宿主机上。当你的微服务小队扩张到下面这些场景,Compose 就开始力不从心:
- 服务数量超过二三十个,单机资源(CPU、内存)不够用了
- 需要跨多台宿主机部署,每个节点跑一部分服务
- 需要服务级别的自动伸缩,根据负载动态加减实例数
- 需要滚动更新、灰度发布、故障自动迁移
这些问题不是 Compose 设计的出发点,硬要用它解决也不是不行,但维护成本会越来越高。到了这个阶段,就该考虑 Kubernetes 或者 Docker Swarm 这类真正的容器编排平台了。
5.2 换到 K8s 后,什么变了
K8s 的很多核心概念跟 Compose 是对应得上的:Compose 里的 service 对应 K8s 里的 Deployment + Service;网络隔离对应 NetworkPolicy;卷挂载对应 PersistentVolumeClaim。正因为 Compose 文件写多了以后,你对容器周边配置的思维模式已经有了,切到 K8s 时不会太别扭。
但最大的变化在于:配置方式从声明式的 docker-compose.yml 变成了声明式的 YAML 清单,但抽象层级完全不同。Compose 只管“容器怎么跑”,K8s 还管“容器跑在哪里、挂了怎么复活、多副本怎么调度、持久化怎么接”。跨机器通信、服务发现、负载均衡这些在 Compose 里需要靠外部配置的东西,K8s 变成了内建能力。
所以我的建议是:小项目、本地开发、单机部署,Compose 完全够用,别为了上 K8s 而上 K8s。如果确实需要用 K8s,先从一个小的服务开始,别一上来就把整套微服务都搬进去。K8s 的排查链路比 Compose 复杂一个数量级,该准备的监控日志体系先准备好再去动迁移,会轻松很多。
5.3 Compose 思想在团队协作中的价值
最后聊点编排之外的。我觉得 Compose 对团队协作最大的贡献,是它把“环境一致性”变成了文件化的产物。以前新同事入职,光配环境就要两天;现在拉代码,复制.env.example成.env,docker compose up -d,跑几分钟,一套完整环境就出来了。这种体验的差距,做过微服务的人都懂。
它也让“这个 bug 我本地复现不了”这类话越来越少。因为大家跑的是同一套 Compose 定义的环境,镜像相同、配置相同、依赖相同。万一还是有差异,先用docker compose config看看实际展开的配置,能快速定位是不是本地的 .env 变量不一样。
我现在带项目,基本要求就是:能容器化的服务一律写 Compose 编排,入口命令统一,环境变量用模板文件管理,所有人在同一套环境编排下工作。这套约定虽然牺牲了一点灵活性,但换来的是极其稳定的联调体验和非常低的沟通成本。
编排微服务小队这件事,本身就是在给整个团队理清“谁依赖谁、谁暴露什么端口、谁在哪个网络里”的关系。从这个角度说,写好一份 Compose 文件,收益远不止于容器启动本身。