ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Docker Compose实战:从docker run到微服务容器编排

Docker Compose实战:从docker run到微服务容器编排 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_HOSTmysql \ -e REDIS_HOSTredis \ 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.ymlservices、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 来构建镜像。如果你用的是现成镜像则写imageservices: 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 up3.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 到 scaleCompose 的运维命令常用就那么几条我用下来体感最顺的是启动整套环境前台模式方便直接看日志docker compose up如果是后台运行加-ddocker 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-service3用脚本模拟用户服务高并发看日志里三个容器的请求分配情况这条命令用得频繁了以后你就能直观感受到负载均衡的效果。对在宿主机上已经跑着的容器做更新更通用的写法是把整个环境重建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_PASSWORDrootpass MYSQL_DATABASEmyapp MYSQL_USERapp MYSQL_PASSWORDapppass这个.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 的逻辑数据更稳妥是用mysqldumpdocker 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成.envdocker compose up -d跑几分钟一套完整环境就出来了。这种体验的差距做过微服务的人都懂。它也让“这个 bug 我本地复现不了”这类话越来越少。因为大家跑的是同一套 Compose 定义的环境镜像相同、配置相同、依赖相同。万一还是有差异先用docker compose config看看实际展开的配置能快速定位是不是本地的 .env 变量不一样。我现在带项目基本要求就是能容器化的服务一律写 Compose 编排入口命令统一环境变量用模板文件管理所有人在同一套环境编排下工作。这套约定虽然牺牲了一点灵活性但换来的是极其稳定的联调体验和非常低的沟通成本。编排微服务小队这件事本身就是在给整个团队理清“谁依赖谁、谁暴露什么端口、谁在哪个网络里”的关系。从这个角度说写好一份 Compose 文件收益远不止于容器启动本身。
返回列表