前置回顾
第 05 篇讲过:容器之间默认可以通过 docker0 网桥互通,但 docker run 只能管一个容器.
第 01 篇讲过:veth pair + bridge 组网是需要手动配置的.
Compose 的设计目标就是把这些手动操作自动化 --你只需要声明"有哪些服务,它们怎么关联",Compose 替你创建网络,启动容器,配置 DNS.
为什么需要 Compose
一个典型的 Web 应用通常不止一个容器:
一个博客系统需要: ├─ web 容器(处理 HTTP 请求) ├─ api 容器(业务逻辑) ├─ db 容器(PostgreSQL) └─ redis 容器(缓存) 如果用纯 docker run: # 1. 创建网络 docker network create blog-net # 2. 启动 db(记住各种参数) docker run -d --name db --network blog-net \ -e POSTGRES_PASSWORD=xxx \ -v blog-pgdata:/var/lib/postgresql/data \ postgres:16 # 3. 启动 redis docker run -d --name redis --network blog-net \ -v blog-redis:/data \ redis:7-alpine # 4. 启动 api docker run -d --name api --network blog-net \ -e DB_HOST=db -e REDIS_HOST=redis \ myapp-api:latest # 5. 启动 web docker run -d --name web --network blog-net \ -p 80:80 \ myapp-web:latest # 停止时还要按顺序一个个关 # 每次启动都要记住所有参数 # 换一台机器要重敲所有命令
用 Compose,上面所有操作变成一个文件 + 一条命令:
services: db: image: postgres:16 environment: POSTGRES_PASSWORD: xxx volumes: - pgdata:/var/lib/postgresql/data redis: image: redis:7-alpine volumes: - redisdata:/data api: build: ./api environment: DB_HOST: db REDIS_HOST: redis web: build: ./web ports: - "80:80" volumes: pgdata: redisdata:
docker run 是逐个拧螺丝--每启动一个容器你都要亲手指定网络,端口,环境变量,卷.
Compose 是流水线--你把设计图纸(compose.yml)画好,一条命令整个生产线跑起来.图纸可以放进 git 版本控制,任何人拉下来一键启动.
核心概念与 YAML 结构
最小的 compose.yml 长什么样
services: web: image: nginx ports: - "8080:80" api: build: . environment: - PORT=3000 networks: backend: volumes: db-data:
docker compose vs docker-compose(V2 vs V1)
你可能会在教程里同时看到两种写法--它们不是两个等效命令,而是同一产品的两个大版本 :
对比项
V1 docker-compose(横杠)
V2 docker compose(空格)
本质
独立 Python 脚本,需 pip install 单独安装
Docker CLI 内置插件(Go 重写),装 Docker Desktop 就自带
命令格式
docker-compose up -d
docker compose up -d
维护状态
2023 年 7 月停止维护
活跃维护,当前标准
version 字段
需要 version: "3.8"
不再需要,可以删掉
depends_on condition
部分支持
完整支持 service_healthy
--profile
不支持
支持
本教程全部使用 V2(空格). 如果你的 docker-compose(横杠)报 command not found,说明机器上只有 V2,把横杠换成空格就行.目前最新版 Docker Desktop 会把两个命令都指向同一个插件,两者都能用,但习惯上用空格版.
docker compose up 背后做了什么
回顾第 05 篇的 docker run 全流程(dockerd → containerd → runc → kernel).Compose 不是取代这个链--它是这个链的"批量调用管理器":
docker compose up -d 背后:
解析 compose.yml → 整理出 services / networks / volumes
创建 network(等于 docker network create xxx)
创建 volume(等于 docker volume create xxx)
按依赖顺序启动容器(depends_on 决定先后)
每个容器走完整的 docker run 流程(dockerd → containerd → runc → kernel)
Compose 不是"容器运行时",它是"容器运行的编排器".
它调用 Docker API 来管理多个容器,但它自己不创建任何 Namespace/Cgroup.
depends_on 的陷阱:启动顺序 ≠ 服务就绪
Compose 用 depends_on 控制容器启动的先后顺序:
services: api: depends_on: - db - redis
但这里有一个很容易踩的坑:depends_on 只保证容器进程先启动,不保证容器内的服务已经 ready .来看一个典型故障场景:
docker compose up -d 时间线: T+0s: db 容器启动 → postgres 进程开始运行 T+1s: api 容器启动(depends_on 已满足--db 进程在跑) T+1s: api 连 db:5432 → 连接成功!执行 SQL:CREATE TABLE ... T+2s: ❌ SQL 失败! 原因: postgres 进程虽然起来了,但数据库内部还在: • 从 WAL(预写日志)恢复数据 • 回放上次未完成的 checkpoint • 初始化共享内存缓冲区 → 这个过程可能持续 5-30 秒 → 在此期间数据库不接受查询连接 → api 以为 db 已经 ready,实际上还没有
解决方案一:healthcheck + depends_on condition(Compose v3.9+)
healthcheck 是 Docker 容器级别的健康检查机制.它定期在容器内执行一条命令,根据命令的返回值判断容器是否"真的就绪":
services: db: image: postgres:16 environment: POSTGRES_PASSWORD: secret healthcheck: test: ["CMD-SHELL" , "pg_isready -U postgres" ] interval: 5s timeout: 3s retries: 5 start_period: 10s api: depends_on: db: condition: service_healthy
healthcheck 的状态变化过程:
容器启动 → starting(start_period 内不检查) → 第一次检查 pg_isready 返回 1(还没好)→ 状态仍是 starting → 第二次检查 pg_isready 返回 1 → 仍是 starting → 第三次检查 pg_isready 返回 0 → healthy!← depends_on 条件满足,api 启动
docker compose ps docker inspect <container> --format '{{json .State.Health}}' | python3 -m json.tool
常见服务的 healthcheck 写法
服务
healthcheck 命令
说明
PostgreSQL
pg_isready -U postgres
PostgreSQL 自带的就绪检测工具
MySQL
mysqladmin ping -h localhost
MySQL 自带的管理工具
Redis
redis-cli ping
返回 PONG 说明就绪
HTTP 服务
curl -f http://localhost:8080/health
应用暴露 /health 端点,返回 200 即健康
通用 TCP
nc -z localhost 8080
仅检查端口是否监听,不保证业务逻辑正常
解决方案二:应用层 retry(更健壮)
即使有 healthcheck,数据库也可能在运行中途挂了.所以 healthcheck + retry 是双保险,不是二选一 :
func connectDB (dsn string ) *sql.DB { for i := 0 ; i < 10 ; i++ { db, err := sql.Open("postgres" , dsn) if err == nil && db.Ping() == nil { return db } time.Sleep(2 * time.Second) } panic ("database not reachable after 10 retries" ) }
服务间网络与 DNS
服务名就是域名--这是 Compose 最有价值的功能
回顾第 05 篇:Docker 用 docker0 网桥 + iptables 实现容器间通信.但容器之间怎么知道彼此的 IP?手动查 IP 再写到配置文件里?容器重启 IP 就变了.
Compose 的解决方案:内置 DNS 服务器 .同一个 compose 文件里的所有容器自动加入一个默认网络,且 服务名自动成为 DNS 域名 :
services: api: build: ./api environment: DB_HOST: db DB_PORT: "5432" REDIS_HOST: redis db: image: postgres:16 redis: image: redis:7
自定义网络:隔离不同业务组
services: api: networks: - backend - frontend db: networks: - backend web: networks: - frontend networks: frontend: backend:
Volume 与数据持久化
回顾第 04/05 篇:容器的可写层(upperdir)在容器删除后就被销毁.数据库的数据不能存在可写层--容器一删数据就没了.Volume 是独立于容器生命周期的存储 ,数据存在宿主机上,挂载到容器内的指定路径.
命名卷的完整生命周期
先看一个最简单的命名卷配置,然后跟踪它从创建到销毁的全过程:
services: db: image: postgres:16 volumes: - pgdata:/var/lib/postgresql/data
这个配置背后实际发生了什么?完整的生命周期:
1. docker compose up -d Compose 看到你引用了一个叫 pgdata 的卷 → 检查宿主机上是否已存在此卷 → 没有 → 自动 docker volume create → 在宿主机上创建目录:/var/lib/docker/volumes/项目名_pgdata/_data/ → 注意:实际卷名会加上项目名前缀,避免不同项目冲突 2. db 容器启动时,Docker 做了一次 bind mount: mount --bind /var/lib/docker/volumes/项目名_pgdata/_data \ /var/lib/postgresql/data → 容器内 /var/lib/postgresql/data 指向宿主机的 _data 目录 → postgres 写入的所有数据,实际落到了宿主机上 → 这些数据不在容器的可写层(upperdir)里! 3. docker compose down 容器删除 → 可写层销毁 ️但 /var/lib/docker/volumes/项目名_pgdata/_data 完好 → 数据没丢! 4. docker compose up -d(重新启动) 卷 pgdata 已存在 → 直接复用,不再创建 → postgres 看到上次的数据全在 → 这就是"持久化"的本质 5. docker compose down -v 容器删除 + 卷也删除 → /var/lib/docker/volumes/项目名_pgdata/ 整个目录被销毁 → 数据永久丢失
用一张图理解关键:卷的数据在宿主机文件系统上,不在容器的 OverlayFS 里 .
宿主机 容器内(merged 视图) /var/lib/docker/volumes/ / myapp_pgdata/ ├─ var/ _data/ ← 数据真实物理位置 │ └─ lib/ ├─ PG_VERSION │ └─ postgresql/ ├─ base/ │ └─ data/ ← 指向宿主机 _data └─ pg_wal/ │ 不是 upperdir! │ 容器删了 → 这部分没了 但宿主机 _data 还在 → 数据完好
docker volume inspect myapp_pgdata
顶层 volumes: 为什么"可选"
教程前面的例子里有顶层的 volumes: 声明:
volumes: pgdata: redisdata:
"可选"的意思不是"写了和没写不一样"--而是 Compose 会自动补.
当你在 service 里写了 - pgdata:/var/lib/...,Compose 发现顶层没声明这个卷,它不报错,自动帮你创建,效果等于隐式补了顶层的 volumes: pgdata:.
所以日常使用中写不写顶层声明效果完全相同 .
顶层声明的真正用途有两个场景:
volumes: shared-data: external: true volumes: pgdata: driver: local driver_opts: type: none o: bind device: /mnt/fast-ssd/pgdata
如果你不需要这两个场景,顶层声明写了纯粹是为了让 compose.yml 读起来更完整 --一眼看清这个项目依赖哪些持久化资源.效果上写不写都一样.
macOS 上怎么配最方便
macOS 的特殊性:Docker Desktop 跑在一个隐藏的 Linux 虚拟机里. 你本机没有 /var/lib/docker/--它只存在于 VM 内部.命名卷默认路径在 VM 里,从 macOS 终端和 Finder 都看不到.
┌────────────────────────────────────────────────┐ │ Linux VM (Docker Desktop) │ ├────────────────────────────────────────────────┤ │ • dockerd + 所有容器 │ │ • /var/lib/docker/volumes/ (你看不到这个路径) │ └────────────────────────────────────────────────┘
如果你希望数据直接出现在 macOS 的 ~/docker_data/ 下,有 两种方式 :
services: db: image: postgres:16 volumes: - ~/docker_data/pgdata:/var/lib/postgresql/data redis: image: redis:7 volumes: - ~/docker_data/redisdata:/data
services: db: image: postgres:16 volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata: driver: local driver_opts: type: none o: bind device: ${DATA_DIR:-~/docker_data}/pgdata DATA_DIR=~/docker_data
macOS 上学习,用方式一就够. 数据就在 ~/docker_data/ 下,Finder 能看到,删了重建也方便.等部署到 Linux 服务器上再把路径去掉,让 Docker 自动管理.
macOS bind mount 的路径限制
bind mount 的宿主机路径必须是 Docker Desktop 已共享的目录(Settings → Resources → File Sharing).
默认共享:/Users,/tmp,/Volumes,/private.
所以 ~/docker_data/ 能用(在 /Users 下),但 /opt/data 会报错.
另外 macOS 上 bind mount 比 Linux 原生慢--开发热更新够用,数据库生产不推荐.
bind mount - 开发环境用
services: api: build: ./api volumes: - ./api/src:/app/src - /app/node_modules
命名卷 vs bind mount 速查
命名卷
bind mount
写法
pgdata:/var/lib/...
~/data/pgdata:/var/lib/...
数据存在哪
Docker 管理(/var/lib/docker/volumes/)
你指定的宿主机路径
docker volume ls 可见
✅
❌
可迁移到其他机器
✅(docker volume 导出)
❌(路径硬编码)
权限管理
Docker 自动处理
宿主机什么权限就什么权限
典型场景
生产数据库
开发热更新
环境分离:开发 vs 生产
多 compose 文件叠加
这是 Compose 最优雅的设计之一.不同环境的差异用"覆盖文件"表达,基础配置放在主干文件:
services: api: build: . environment: NODE_ENV: production db: image: postgres:16 volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata:
services: api: build: target: dev environment: NODE_ENV: development volumes: - ./src:/app/src ports: - "3000:3000" db: ports: - "5432:5432" adminer: image: adminer ports: - "8080:8080
services: api: restart: always deploy: resources: limits: cpus: "2" memory: 512M logging: driver: json-file options: max-size: "10m" max-file: "3" db: restart: always deploy: resources: limits: memory: 1G
docker compose -f compose.yml -f compose.dev.yml up -d docker compose -f compose.yml -f compose.dev.yml up -d --build docker compose -f compose.yml -f compose.prod.yml up -d docker compose -f compose.yml -f compose.dev.yml down
profiles:按需启停服务
核心规则:三条就够
profile 名称完全自定义 --debug,tools,monitoring,heavy,叫什么都可以,只要 YAML 里写的和命令行传的一致就行
没写 profiles: 的服务永远启动 --它们属于隐式的"默认组",docker compose up 就跑
写了 profiles: 的服务只有显式开启才启动 --不传对应的 --profile 就不会启动
基本用法
services: api: build: . db: image: postgres:16 adminer: image: adminer ports: ["8080:8080" ] profiles: - debug prometheus: image: prom/prometheus profiles: - monitoring
docker compose up -d docker compose --profile debug up -d docker compose --profile monitoring up -d docker compose --profile debug --profile monitoring up -d docker compose --profile "*" up -d
一个服务可以属于多个 profile
services: adminer: image: adminer profiles: - debug - tools
docker compose --profile debug up -d docker compose --profile tools up -d
用环境变量预设 profile
有些 profile 你希望每次都生效(比如开发环境的调试工具),可以在 .env 里预设,省去每次敲 --profile:
COMPOSE_PROFILES=debug,tools docker compose up -d
常见用法:按重量级拆分
services: api: build: . db: image: postgres:16 redis: image: redis:7 adminer: image: adminer profiles: [debug ] mailhog: image: mailhog/mailhog profiles: [debug ] prometheus: image: prom/prometheus profiles: [monitoring ] grafana: image: grafana/grafana profiles: [monitoring ] elasticsearch: image: elasticsearch:8 profiles: [heavy ] kafka: image: confluentinc/cp-kafka profiles: [heavy ]
然后三个场景三条命令:
docker compose up -d docker compose --profile debug up -d docker compose --profile monitoring up -d docker compose --profile "*" up -d
profile 本质上是给服务打标签,然后按标签过滤.
没打标签的服务属于"默认组",永远启动.
打了标签的服务是"按需组",只有你显式选了对应标签才启动.
这和 docker compose -f compose.yml -f compose.dev.yml 的多文件叠加并不冲突--二者可以组合使用:文件叠加负责"哪些服务的配置不同",profile 负责"哪些服务要不要启动".
生产环境注意事项
Compose 能用于生产吗?可以,但要知道它的边界:
场景
Compose 够用?
说明
单机部署
✅
Compose 的核心场景--一台机器上跑多容器应用
多机 / 集群
❌
Compose 不会跨节点调度容器--这是 K8s 的领域
零停机更新
有限
docker compose up -d --no-deps api 可以逐个更新,但没有滚动更新策略
健康检查
✅
healthcheck + depends_on: condition: service_healthy
日志聚合
⚠️
每个容器的日志仍独立,需要额外工具(如 Loki + Promtail)
Secrets 管理
✅
Compose 支持 secrets: 顶层声明(但文件模式,不如 K8s Secrets 完善)
小结
本篇要点回顾
要点
一句话概括
Compose 的定位
批量管理 docker run 的编排器--用 YAML 描述多容器应用,一键启动/停止
服务名 = DNS 域名
同一 compose 的容器通过服务名互相发现,重启后 IP 变了自动更新
自定义网络
不同 service 可以接入不同 network,实现流量隔离(db 不暴露给前端网络)
命名卷
数据存于宿主机 /var/lib/docker/volumes/(macOS 在 Linux VM 内),独立于容器生命周期,down 不删,down -v 才删
顶层 volumes 声明
日常使用中可选(Compose 自动补),需要引用外部已有卷或指定物理存储路径时才必须写
macOS 卷配置
用 ~/docker_data/pgdata:/var/lib/... 最简单;命名卷 + driver_opts: device: 可保留命名卷心智模型;纯命名卷在 VM 内,日常不可见
bind mount
宿主机目录直通容器--开发环境神器(改了代码立刻生效),生产禁用
环境分离
多文件叠加(-f compose.yml -f compose.dev.yml)让基础配置和差异配置解耦
profiles
给服务打自定义标签,没标签的永远启动,有标签的只有 --profile 显式开启才启动;一个服务可属于多个 profile;可在 .env 用 COMPOSE_PROFILES 预设
生产边界
Compose 适合单机,不适合多机编排;没有滚动更新,自动扩缩容--那是 K8s 的领域
动手练习
把之前写的那个简单 HTTP 服务(06 篇的练习)改造成一个 Compose 项目:api 服务 + redis 服务(缓存),验证服务间能通过服务名互相访问
在 compose 中加入 healthcheck,用 docker compose ps 观察健康状态的变化
创建 compose.dev.yml,用 bind mount 挂载源码目录,改代码后验证热更新;再创建 compose.prod.yml,加上 restart 策略和资源限制
把 06 篇的多阶段构建 Dockerfile 作为 compose 中某个 service 的 build 源,验证 docker compose up --build 会重新构建镜像
用 docker compose exec sh 进入运行中的容器,测试 ping 另一个服务名,验证 DNS 解析
思考题:如果 api 服务启动时需要数据库表已创建好,depends_on 能解决吗?如果不能,有哪些方案?(提示:回顾之前讨论过的"启动顺序 ≠ 服务就绪")