
Docker 进阶一 Compose我把单容器玩具变成多服务架构那点事如果你已经能熟练写出 Dockerfiledocker run跑自己想跑的容器也显得轻车熟路——然后某天你突然意识到自己手里那个“能用”的项目实际上是个由前端、后端、数据库、缓存四个镜像拼起来的杂牌军。每次重新拉起环境都要四五个docker run命令按顺序敲端口还总担心写串改一个环境变量得翻历史记录跨机器部署时别人根本无从下手。这就是该把 Docker Compose 请出来的时候了。Compose 是 Docker 官方的容器编排工具核心是用一个docker-compose.yml文件把多个容器的启动流程、网络关系、依赖顺序、数据卷挂载、环境变量全部声明式地写在代码里。一条docker compose up -d命令就能把整套服务拉起来换机器部署时拷贝一个 compose 文件外加项目目录就能复制整个运行环境。这篇教程面向的是已经会用docker run跑容器、但对多容器协作还没成体系的开发者我会从一个真实的前后端三段式项目出发讲透 Compose 的配置思路、参数背后的逻辑、能直接照抄的实操步骤以及我踩过的那些坑。1. Compose 解决的核心痛点和设计思路1.1 从 docker run 到 Compose到底解决了什么我需要先把问题讲透。假设你现在要跑一个常见的 Web 应用一个 Nginx 前端、一个 Node.js 后端、一个 MongoDB 数据库。用docker run逐个拉起的话至少要有三步docker run -d --name web --network mynet -p 8080:80 nginx docker run -d --name api --network mynet -e DB_HOSTmongo node-app docker run -d --name mongo --network mynet -v mongo-data:/data/db mongo这里面的顺序是有讲究的你得先手动建网络docker network create mynet然后容器之间靠容器名互相访问环境变量比如DB_HOSTmongo得自己拼还得保证容器创建顺序和启动时机。更麻烦的是一旦要改端口、换镜像版本、增加环境变量每次都是在一串超长的命令里改改完还得记住哪些容器需要重建、哪些不需要。Compose 把所有这些声明式地放进配置文件里这是它和一组脚本方案的本质区别。写脚本也是一个思路但脚本是命令式的它描述了“怎么做”的每一步而 Compose 文件描述的是“最终要什么”的声明式状态。docker compose up会自动对比当前容器实际状态和配置文件的期望状态只重建配置发生变化的容器这点和 Kubernetes 的理念一脉相承只是 Compose 的粒度控制在单机范围内。1.2 版本选择docker-compose 还是 docker compose我见过不少人在这个问题上吃亏。早期 Compose 是独立的 Python 工具命令是docker-compose带横杠安装方式通常是下载二进制或通过 pip 安装。从 Docker 引擎引入 compose v2 以后官方推荐直接使用docker compose带空格这个子命令它已经内置在 Docker Desktop 和大部分新版 Docker Engine 中无需额外安装。如果你现在仍然习惯打docker-compose我建议尽早切换。不只因为 v1 已经停止维护更因为 v2 在处理配置合并、环境变量插值、性能表现和调试信息方面都更现代化。切换几乎没有成本除了把命令里的横杠改成空格更关键的是docker-compose up --build这种常用写法同样适用于docker compose。在本文里我统一使用docker compose这种新写法。不过有一点需要留意如果你的系统里同时存在 v1 和 v2命令解析可能走不同的实现给出的报错信息也会不一样排查问题时首先要确认自己用的到底是哪个版本。2. 核心配置项逐个拆解2.1 services、networks、volumes 三驾马车一个标准的 Compose 文件从上到下分三大块services定义容器networks定义容器间的内部通信网络volumes定义数据持久化存储。这三者之间的依赖关系我建议这样理解services 是主角networks 和 volumes 是支撑它的基础设施。version: 3.8 # 官方其实已经不建议强制指定版本但我保留它兼容旧习惯 services: api: build: ./api networks: [app-net] volumes: - app-data:/storage networks: app-net: volumes: app-data:networks段里如果只用小写别名像app-net:下面什么都不写Compose 会创建一个默认的 bridge 网络。同理volumes:段只声明名称Compose 会创建一个命名卷named volume。这里最反直觉的地方在于服务里挂载的卷app-data是「命名卷」还是「匿名卷」完全取决于你是否在顶层volumes段里列出它。开发者刚上手时以为 volumes 是给容器用的其实顶层段更像是一个「注册表」声明过的卷变成可复用的命名卷不声明的话 Docker 也会帮你创建一个匿名卷只是销毁后难以找回数据。关于version字段现在官方文档已经明确表示不再检查这个字段保留它的意义更多是与老版本兼容。我建议在新项目里干脆省略version避免有人误以为可以通过改版本号来启用新语法特性。语法特性由 Docker Compose 二进制版本决定不是由文件头决定。2.2 depends_on 是个大坑健康检查才是正确解法这也是 Compose 进阶路上第一个真正的挡路石。很多人的直觉是你依赖数据库那我就用depends_on让数据库先启动。确实depends_on能控制容器的启动顺序但它只能保证依赖的容器“被创建并开始启动”不能保证里面的服务已经“就绪”。比如 MongoDB 容器已经启动但 mongod 进程还在初始化端口监听时你的后端程序就已经尝试连接了结果自然是connection refused然后崩溃退出。早版本的depends_on只有这种裸顺序控制后来 Compose 规范加入了service_healthy条件只有当被依赖的容器通过健康检查后依赖它的容器才会被启动。这是目前最可靠的方式。来看一个完整的配置示例services: mongo: image: mongo:7 healthcheck: test: [CMD, mongosh, --quiet, --eval, db.adminCommand(ping).ok] interval: 10s timeout: 5s retries: 5 start_period: 30s api: build: ./api depends_on: mongo: condition: service_healthy这里面的start_period参数容易被忽略。它指定了容器启动后多少秒内的失败不计数相当于给了慢启动应用一个宽限期。数据库容器第一次拉镜像、加载数据卷可能需要几秒甚至几十秒如果把start_period设成 30 秒健康检查不会因为初期失败而立刻按 retries 判定为 unhealthy能避免不少误判。我还想提醒一点depends_on控制的是启动顺序不是网络连通性。即使两个容器都在同一个自定义网络里API 容器启动后也不是自动就能访问数据库的域名。如果你在应用代码里没有实现重试逻辑单向依赖的场景下健康检查几乎是必需品。2.3 环境变量优先级Compose 文件里别写死任何机密Compose 支持多种方式给容器传递环境变量最常见的三种来源是compose 文件里的environment段、.env文件、宿主机的 shell 环境变量。三者的优先级从高到低是shell 环境变量 .env 文件 compose 文件里的environment。这里的 shell 环境变量指的不是容器内的而是执行docker compose命令的那个终端的环境变量。我建议把敏感信息全部丢到.env文件里并且把.env加进.gitignore。比如services: api: environment: - DB_URImongodb://${MONGO_USER}:${MONGO_PASSWORD}mongo:27017/app然后.env文件里MONGO_USERroot MONGO_PASSWORDChangeMeBeforeDeploy这样 compose 文件本身是干净的可以放心提交到仓库真正要保密的信息放在不上传的.env里换环境时只需改.env不用动 compose 文件。这种做法的好处在多人协作时尤其明显——新人拉下仓库只需要复制一份.env.example为.env填上自己的本地密码即可启动整套环境不需要去排查某个变量到底写在哪一层。2.4 网络模式自定义 bridge 和 host mode 的取舍Compose 默认会给整个项目创建一个 bridge 网络所有服务都能通过服务名互相解析。大多数场景下这是最佳选择因为容器间通信走内部 IP不需要暴露到宿主机。需要对外提供服务时再用ports把端口映射出来。services: web: image: nginx:alpine ports: - 8080:80这里的字符串8080:80表示把宿主机 8080 端口映射到容器的 80 端口。注意引号不能省YAML 解析8080:80如果不加引号有可能被某些解析器当作端口格式嵌套处理属于稀奇古怪但确实能遇到的坑。network_mode: host就完全不一样了。它让容器直接共享宿主机的网络栈端口不会做 NAT容器里监听什么端口宿主机就监听什么端口。这种模式性能最好、延迟最低适用于对网络性能极度敏感的服务比如一些中间件缺点是容器之间不能通过网络别名互访端口冲突时你也只能手动换端口。在 Compose 项目里我一般不会全局使用 host 模式因为多服务协作时还是需要 bridge 网络的服务发现能力。3. 实操搭建一个带健康检查的三段式 Web 应用3.1 选用什么场景为什么选这个组合我用一个完整的案例来演示 Compose 的实际落地前端用 Nginx 托管静态页面并反向代理/api路径到后端服务后端用 Node.js 写一个极简的 REST API数据存到 MongoDB。这个组合覆盖了 Compose 最常见的三类场景需要构建镜像的本地代码服务、直接拉官方镜像的三方中间件、依赖顺序和数据卷持久化。我建议你不要跳过这个案例直接看结论——自己动手跑一遍之后你对上面那些配置项的关系会清楚得多。项目目录结构如下compose-demo/ ├── docker-compose.yml ├── .env ├── front/ │ ├── Dockerfile │ └── index.html ├── api/ │ ├── Dockerfile │ ├── package.json │ └── server.js └── db/ └── init.js # 可选初始化脚本这个结构是刻意设计的front 和 api 各自有独立的 Dockerfile适合放进各自的子目录未来如果拆分成独立仓库不需要动 Compose 文件db 目录则放一些初始化脚本通过数据卷挂载进 MongoDB 容器实现首次启动时的自动初始化。3.2 编写 Dockerfile认清哪些东西该放在镜像构建期在写 compose 文件之前得先把两个服务的镜像准备好。前端的 Dockerfile 很典型——一个两阶段构建# front/Dockerfile FROM nginx:alpine AS runtime COPY index.html /usr/share/nginx/html/index.html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80这个镜像背后没有 Node.js 构建过程因为示例的 index.html 是纯静态文件。如果你的前端是 React/Vue 工程两阶段构建会长这样FROM node:20-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:alpine COPY --frombuild /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf为什么非要做这个多阶段构建核心原因是镜像体积和运行时依赖的纯净度。Node 基础镜像普遍 1GB 以上Nginx alpine 镜像不过几十 MB。构建期的 node_modules 和构建缓存都是编译工具链的一部分不应该带进运行镜像。Nginx 只需要静态文件其他一概不要。实际生产经验告诉我镜像精简除了省磁盘还能缩小攻击面——容器里少一个编译器就少一个被利用的入口。后端的 Dockerfile 如下# api/Dockerfile FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --onlyproduction COPY server.js . EXPOSE 3000 CMD [node, server.js]注意这里我故意用npm ci --onlyproduction而不是npm install。npm ci依赖 package-lock.json 文件能保证每次装出来的依赖版本完全一致这在多环境部署时非常重要。--onlyproduction表示生产依赖开发依赖不会进入镜像。如果你的 API 有常用的 TypeScript 或测试框架它们只在开发容器里出现不会浪费运行镜像的空间。3.3 编写 docker-compose.yml从零到一个能跑的系统现在可以写核心文件docker-compose.yml了。我会按实际编写顺序来描述因为 Compose 文件里服务和网络之间的依赖关系决定了你必须先想清楚拓扑。第一步先定义服务骨架services: front: build: ./front ports: - 8080:80 depends_on: api: condition: service_healthy api: build: ./api environment: - DB_URImongodb://mongo:27017/appdb depends_on: mongo: condition: service_healthy mongo: image: mongo:7 volumes: - mongo-data:/data/db - ./db/init.js:/docker-entrypoint-initdb.d/init.js:ro healthcheck: test: [CMD, mongosh, --quiet, --eval, db.adminCommand(ping).ok]这里有几个值得展开的点。./db/init.js挂载到/docker-entrypoint-initdb.d/是 MongoDB 官方镜像提供的一种初始化机制首次启动空数据卷时容器会在启动 mongo 服务之前依次执行该脚本目录下的.js和.sh文件。这个挂载方式我用了:ro只读后缀避免容器内误改宿主机文件。要注意的是如果数据卷已经存在且非空这个初始化脚本不会再次执行所以一旦想重跑初始化就得先把数据卷删掉用docker compose down -v——这是个很容易踩的坑我先预告一下。第二步把网络和数据卷定义加到文件底部volumes: mongo-data: networks: app-net: driver: bridge等等如果服务内部都默认在同一个 bridge 网络上还需要显式写app-net吗不一定。Compose 自动会创建一个默认网络所有服务都挂在上面。显式声明网络的场景通常是需要控制子网 IP 段、需要和外部容器跨项目共享网络、或者想刻意隔离某些服务。示例里其实不需要显式声明我就让默认网络兜底顶层只写 volumes避免配置文件过度设计。最后补上资源限制和日志轮转这是生产环境绝对不能缺的services: front: build: ./front ports: - 8080:80 restart: unless-stopped deploy: resources: limits: cpus: 0.5 memory: 256M logging: driver: json-file options: max-size: 10m max-file: 3restart: unless-stopped的意思是除非你手动docker compose stop否则不管容器因为什么原因退出Docker 守护进程都会尝试把它拉起来系统重启时也会自动启动。这个策略很适用于本教程的服务型容器。logging段里的max-size和max-file可能是很多人的盲区。默认 json-file 日志驱动不会切割大小一个疯狂打日志的容器能写满宿主机磁盘。我习惯在每个服务上都加这个限制10MB 一个文件、保留 3 个足够日常排查看日志也不会让磁盘爆掉。资源限制段用deploy.resources.limits明确写死上限是因为不限制的话后端某些有内存泄漏的旧版本进程可能在 OOM 前先把整个宿主机的内存吃满导致其他服务一起被拖垮。cpus: 0.5表示最多使用半个核memory: 256M则是硬限制超过之后容器会被杀掉。3.4 服务代码细节这个示例不是玩具后端server.js我写成一个最简可用的 API但包含了一个关键实践——连接 MongoDB 之前的重试逻辑const express require(express); const mongoose require(mongoose); const app express(); const PORT process.env.PORT || 3000; const DB_URI process.env.DB_URI || mongodb://mongo:27017/appdb; async function connectDB() { for (let i 0; i 10; i) { try { await mongoose.connect(DB_URI); console.log(DB connected); return; } catch (err) { console.log(DB connection attempt ${i 1} failed, retrying...); await new Promise((r) setTimeout(r, 3000)); } } process.exit(1); } connectDB(); app.get(/health, (req, res) res.json({ status: ok })); app.get(/items, async (req, res) { const items await mongoose.connection.db.collection(items).find().toArray(); res.json(items); }); app.listen(PORT, () console.log(API listening on ${PORT}));即便我们已经在 Compose 里用了健康检查和条件依赖重试逻辑依然需要。原因很简单健康检查只能保证 mongo 容器“曾经健康”不能保证 API 进程启动到实际发起连接的瞬间数据库刚好可用。网络抖动、容器重启、瞬时的连接池打满都可能让一次连接失败。面向失败编程是容器编排环境的基本素养。前端的 Nginx 配置也不复杂但有一个大家都会碰到的核心点反向代理后端服务时不能用 localhost而要用 Compose 网络里的服务名。# front/nginx.conf server { listen 80; location / { root /usr/share/nginx/html; index index.html; } location /api/ { proxy_pass http://api:3000/; proxy_set_header Host $host; } }http://api:3000/是容器内的服务名解析。只有在同一个 Compose 网络里DNS 才会把api解析到 API 容器的内部 IP。这个细节也解释了为什么我一再强调网络配置的规范性如果两个服务在不同的网络里服务名解析直接失败报错是域名解析错误非常容易让新手误以为是代码问题。3.5 从启动到验证完整命令序列和参数解释代码和配置都准备好后现在进行本地完整验证。cd compose-demo # 首次启动前检查配置是否正确 docker compose config # 构建镜像并后台启动 docker compose up -d --build # 查看所有容器状态 docker compose ps # 实时查看所有服务的日志输出 docker compose logs -fdocker compose config这个命令值得养成习惯。它会把 compose 文件、.env文件、默认值合并后的最终结果打印出来所有${VAR}插值都会展开成实际值。你在写完配置后跑一下这个命令能第一时间发现环境变量写错、YAML 缩进错误、端口绑定冲突等问题不需要启动容器后才报错。docker compose up -d --build执行后会发生这些事读取 compose 文件检查是否有命名卷需要创建创建网络然后按依赖顺序构建镜像、创建并启动容器。启动后验证服务连通性我习惯用 curlcurl http://localhost:8080/ # 应该返回 Nginx 的首页内容 curl http://localhost:8080/api/health # 应该返回 {status:ok} docker compose exec api curl http://mongo:27017 2/dev/null || true # 验证 API 容器内能否访问到 mongo 服务名如果你访问/api/health长时间没有响应优先看 API 容器日志有没有报数据库连接失败再看 MongoDB 健康检查有没有通过。4. 常见问题与排查技巧实录4.1 容器启动顺序引起的“假死”问题现象API 容器启动后立刻退出日志里一堆ECONNREFUSED。原因分析depends_on没有使用condition: service_healthy裸的 depends_on 只能保证 mongo 容器处于运行状态不保证 mongod 进程真的正在监听端口。这个在上一节说过但是实际排查时还有另一个隐蔽原因MongoDB 镜像里没有预装mongosh老版本镜像用的是mongo客户端健康检查命令本身就会失败导致 API 永远等不到 healthy 状态。解决先检查健康检查命令在容器里能不能执行。docker compose exec mongo mongosh --quiet --eval db.adminCommand(ping).ok如果是老版本镜像需要换成mongo客户端命令。然后调整start_period到合理的 30s、60s给数据库充足的冷启动时间。4.2 服务名解析失败却用 IP 能通现象容器内curl http://api:3000失败但用docker inspect拿到的容器 IP 去 curl 却能通。原因要么两个容器不在同一个自定义网络里要么服务名拼写和 compose 里的 key 不一致。Compose 默认网络是项目名加下划线加default如果你手动创建了网络并挂载到部分服务上服务之间的连通性就受网络拓扑限制。解决用docker compose exec front sh进入前端容器执行ping api看返回的 IP 是不是 API 容器的 IP。不通的话检查 compose 文件里 front 和 api 是否都挂了同一个网络。另一个比较隐蔽的坑如果你在 compose 文件里定义了多个自定义网络服务只声明了其中一个网络它就只能和同网络的兄弟通信。4.3 数据卷“变了”又“没变”的困惑现象改了db/init.js重启容器数据库初始化脚本没执行数据还是旧的。原因MongoDB 镜像的/docker-entrypoint-initdb.d/机制只在数据卷为空时生效。一旦mongo-data卷里有了数据即使删除容器卷里的数据仍然存在脚本不会重新执行。解决想要干净的初始数据必须删卷重建。docker compose down -v docker compose up -d但down -v是危险操作它会删除所有命名卷包括你可能要保留的正式库备份。实际操作中我建议给数据库卷起一个明确的语义化名字比如compose_demo_mongo_data并且确认没有生产数据挂在上面再执行。或者更稳妥的做法在.env里设一个版本号参数来给卷后缀命名重建时修改后缀产生新卷同时保留旧卷作为备份参考。4.4 宿主机端口被占用Compose 启动失败现象启动报port is already allocated。原因另一个容器或宿主机本机服务占用了你映射出来的宿主机端口。解决优先排查占用仲裁者。Linux 下可以用sudo lsof -i :8080查看是哪个进程占了端口然后决定是改端口还是停掉占用进程。Compose 文件里端口映射比docker run -p好改的地方在于改完直接docker compose up -d只会重建端口发生变化的容器不影响其他服务的运行状态。但有一个细节改端口后旧容器可能会被 recreate短暂会中断服务生产环境要注意切换时机。4.5 日志输出乱码、中文全是问号现象容器内 Node.js 打印中文日志宿主机看到的是???或乱码。原因容器基础镜像多数是精简版没有安装中文字体或 locale 不完整。解决在 Dockerfile 里设置环境变量或者在 compose 文件里给服务加上environment: - NODE_ENVproduction - LANGC.UTF-8以及 Dockerfile 里安装必要的 locale 数据。以 Node 镜像为例ENV LANGC.UTF-8通常够用但如果你输出的是来源自外部文件的中文还需要保证文件读取编码是 UTF-8。真正的容器生产最佳实践是日志全部输出英文或纯结构化 JSON避免非 ASCII 字符带来的各种麻烦。5. 我的一些经验沉淀第一次在正式项目里引入 Compose 时我以为它只是把docker run命令翻译成 YAML 而已。真正用了一个月后我意识到Compose 最有价值的不是省去打命令的时间而是逼着你用「声明式」的眼光重新审视整套系统——你必须在文件里显式写出服务之间的关系、数据落在哪里、依赖如何度量和验证。这些思考在单容器阶段完全是多余的却是走向集群编排前最关键的一步思想转变。最后分享一个小技巧在项目的Makefile里封装常用的 compose 命令能极大减少每天的重复劳动。比如up: docker compose up -d --build down: docker compose down logs: docker compose logs -f clean: docker compose down -v以后每天开发只需要make up不用再对着docker compose up -d --build这一长串命令反复敲。工具的意义不是让你变懒而是让你把精力省下来专心解决真正有挑战的问题。把 Compose 用熟再往 Kubernetes 方向走会发现很多概念都是相通的——服务发现、健康检查、资源限制、声明式编排你在单机 Compose 里积累的直觉直接映射到集群环境的抽象里。这也是我为什么一直强调进阶的关键不在多炫技的命令而在把基础组件之间的协作关系理清楚。