Docker Compose 开发环境实践:把依赖服务从本机里搬出去

很多项目最难复现的问题,不在业务代码,而在开发环境。

A 同学本机 MySQL 是 8.0,B 同学是 5.7;有人 Redis 没设密码,有人端口被占用;新人入职第一天光装数据库、改配置、导数据就花了半天。等项目依赖更多服务,比如消息队列、对象存储、搜索引擎,本机环境会越来越乱。

我印象最深的一次,是一个 bug 只在某个同事机器上复现:他本机 MySQL 是 5.7,sql_mode 没开 ONLY_FULL_GROUP_BY,一条带 GROUP BY 的查询在他那儿能跑,到了测试环境的 8.0 直接报错。排查这个问题花了大半天,最后发现根因是环境差异,跟代码逻辑没半点关系。那之后我就下定决心,开发依赖的版本必须锁死,不能各装各的。

Docker Compose 的价值,就是把这些依赖服务从本机里搬出去,用一份配置描述清楚。版本、端口、初始化方式都写在文件里,跟着代码一起进 git,谁拉下来都一样。

不要一开始就容器化所有东西

开发环境容器化有两种思路。

第一种是只容器化依赖服务,比如数据库、Redis、MQ,应用本身仍然在本机跑。这是我更推荐的起步方式。

第二种是应用和依赖全部进容器。这对团队一致性更强,但调试、热更新、文件权限、性能都需要更多处理。我踩过几个坑:容器里跑 Node,本机改了代码却不热更新,得靠 volume 挂载源码再配 nodemon,而 node_modules 又不能直接挂(宿主机和容器里编译出来的二进制不兼容,像 bcryptsharp 这种带 native 扩展的包一挂就挂掉);Mac 上 Docker Desktop 的文件系统是经过虚拟化的,大量小文件的项目(比如全量挂载 node_modules)IO 慢得离谱,热更新延迟能到好几秒。这些问题都能解,但都要花时间,对刚起步的项目不划算。

对于大多数前后端项目,先把依赖服务放进 Compose、应用留在本机跑,收益已经很明显,而且基本没什么副作用。等团队规模上来、CI 也要用同一套环境时,再考虑把应用也搬进去不迟。

说一个我后来才想明白的原因:macOS 没有 Linux 内核,Docker Desktop 实际是在虚拟机里跑了个 Linux,宿主机目录要跨虚拟机边界共享进去,这层开销是 Linux 原生 bind mount 没有的。早期用的 osxfs/gRPC-FUSE 实现慢得离谱,后来换成 VirtioFS 才好了不少——我实测同一个项目 pnpm install,从四五分钟降到一分钟出头。如果你还在用老版本 Docker Desktop 且抱怨挂载慢,去 Settings → General 把文件共享实现切成 VirtioFS,很多时候比折腾别的都管用。

这也是为什么我对「源码挂进容器」这件事一直很克制:跨虚拟机的文件共享天然有成本,能不挂就不挂。真要挂,也只挂源码、把 node_modules 做成容器内的匿名卷隔离开,既绕过了 native 二进制不兼容,又避开了大目录的 IO 地狱。

一个基础 compose 文件

例如项目依赖 Postgres 和 Redis:

1services:
2  postgres:
3    image: postgres:16
4    ports:
5      - "5432:5432"
6    environment:
7      POSTGRES_DB: app_dev
8      POSTGRES_USER: app
9      POSTGRES_PASSWORD: app_password
10    volumes:
11      - postgres_data:/var/lib/postgresql/data
12
13  redis:
14    image: redis:7
15    ports:
16      - "6379:6379"
17    command: ["redis-server", "--appendonly", "yes"]
18    volumes:
19      - redis_data:/data
20
21volumes:
22  postgres_data:
23  redis_data:

这里有个习惯我一定会坚持:镜像标签写明确的大版本,比如 postgres:16 而不是 postgres:latestlatest 看着省事,但某天有人重新 pull 一下,Postgres 从 16 跳到 17,数据目录格式变了,容器起不来,你还莫名其妙。锁版本能避免这种「昨天还好好的今天就崩了」的玄学问题。讲究一点的项目我甚至会锁到小版本(postgres:16.4),保证团队和 CI 完全一致。

启动:

1docker compose up -d

停止:

1docker compose down

这样团队里每个人拿到的数据库和缓存版本都是一致的。本机只需要安装 Docker,不需要分别安装一堆服务。

端口映射也有个现实问题要提一句:"5432:5432" 左边是宿主机端口,如果你本机早就装过一个 Postgres 占着 5432,up 的时候就会报 bind: address already in use。我的做法是给容器映射一个不常用的端口,比如 "15432:5432",应用连接时连 15432。这样本机原生服务和容器服务互不打架,多个项目并行开发也不会抢端口。

数据卷决定数据是否保留

Compose 里的 volume 很重要:

1volumes:
2  - postgres_data:/var/lib/postgresql/data

它表示数据库数据存到命名卷里。容器删除后,数据仍然可以保留。

如果执行:

1docker compose down

容器会被删掉,但 volume 通常还在。

如果执行:

1docker compose down -v

volume 也会删除,数据库数据会清空。

这两个命令的差异必须让团队知道。否则有人只是想重启服务,却把开发数据删了。我们组就出过这事:有人为了「干净重启」习惯性敲了 down -v,结果把同事辛苦造了一上午的测试数据全清了,对方当场表情管理失败。从那以后我在 README 里专门标红了一句——平时重启用 restartdown 就够了,-v 只在你确实想推倒重来的时候用。

顺带说一下,命名卷的数据其实是存在 Docker 自己管理的目录里(Linux 上是 /var/lib/docker/volumes),不会跟着项目目录走。所以有时候你 git clean 清了一遍工作区,数据库数据还在,反而会疑惑「为什么旧数据没清掉」。想看当前都有哪些卷、占了多少空间,可以用:

1docker volume ls
2docker system df -v

我还见过另一种坑:用绑定挂载(bind mount)把数据库目录直接挂到宿主机某个文件夹,本意是方便查看。但 Postgres 对数据目录的权限和文件系统很挑剔,Mac/Windows 上绑定挂载经常出权限或 IO 问题。数据库这类有状态服务,老老实实用命名卷,别用绑定挂载。

这背后其实是 Postgres 自身的硬性约束。它启动时会检查数据目录的 owner 必须是当前进程的用户(容器里默认是 postgres 用户,UID 999),并且权限位必须是 07000750,否则直接拒绝启动,报 data directory has invalid permissions。命名卷由 Docker 在 Linux VM 内部创建,UID/权限天然对得上;而你从 Mac 宿主机绑定进去的目录,owner 是被映射过的宿主用户,UID 对不上,Postgres 一看就翻脸。更隐蔽的是 fsync 语义:Postgres 用 fsync 来保证 WAL 落盘,而某些跨虚拟机的共享文件系统对 fsync 的实现并不严格,理论上存在「以为写盘了其实没落」的风险——开发环境丢点数据无所谓,但这正是为什么生产环境也绝不会拿网络/共享文件系统当 PGDATA。

数据卷这东西还有个共性,跟后面「初始化脚本」一节要说的是同一个道理:一旦数据目录被创建过,它就「定型」了,后续你对镜像版本、初始化脚本的改动都不会自动生效,除非重建这个卷,具体表现留到那一节一起说。

健康检查比 depends_on 更可靠

很多人会写:

1services:
2  api:
3    depends_on:
4      - postgres

但这只表示启动顺序,不代表 Postgres 已经准备好接受连接。数据库容器启动了,不等于数据库服务初始化完成。

可以加健康检查:

1services:
2  postgres:
3    image: postgres:16
4    environment:
5      POSTGRES_DB: app_dev
6      POSTGRES_USER: app
7      POSTGRES_PASSWORD: app_password
8    healthcheck:
9      test: ["CMD-SHELL", "pg_isready -U app -d app_dev"]
10      interval: 5s
11      timeout: 3s
12      retries: 10
13
14  api:
15    build: .
16    depends_on:
17      postgres:
18        condition: service_healthy

如果应用也在容器里,这个配置能避免应用启动时数据库还没准备好导致连接失败。

即使应用在本机跑,健康检查也有价值,因为你可以通过 docker compose ps 直接看到服务状态。

这里有个特别容易踩的细节:Postgres 镜像在初始化阶段,会先用本地 socket 启一次数据库执行初始化脚本,这时候它对外的 TCP 端口其实还没真正就绪。早期我只用 pg_isready 不带参数,结果它在初始化那一瞬间就报 ready 了,应用连过去反而被拒。所以 pg_isready 一定要带上 -U-d,让它去探测目标库是否真能连。intervalretries 也别设太小,给数据库初始化留足时间——第一次创建数据目录加载脚本,慢的话十几秒很正常。

Redis 也可以加,但探测命令不一样,用它自带的 redis-cli

1services:
2  redis:
3    image: redis:7
4    healthcheck:
5      test: ["CMD", "redis-cli", "ping"]
6      interval: 5s
7      timeout: 3s
8      retries: 5

redis-cli ping 返回 PONG 才算健康。如果 Redis 设了密码,记得带上 -a,否则它会一直认证失败、状态卡在 unhealthy。

健康检查的状态机也值得说清楚,因为我曾经被它的「启动期反复 unhealthy」坑过一次。容器刚起来时状态是 starting,在这个阶段健康检查即使失败也不会被算进 retries,只有过了首次成功(或者超过你设的时间)才进入 healthy/unhealthy。早期我没设 start_period,给一个启动要二十多秒的服务配了 retries: 5 interval: 5s,结果它还没初始化完,五次探测就用光了,直接被判 unhealthy,依赖它的 api 容器拿到 condition: service_healthy 一看不健康就拒绝启动,整条链路卡死。正确做法是用 start_period 给一段宽限期,这段时间内的失败不计数:

1services:
2  postgres:
3    image: postgres:16
4    healthcheck:
5      test: ["CMD-SHELL", "pg_isready -U app -d app_dev"]
6      interval: 5s
7      timeout: 3s
8      retries: 5
9      start_period: 30s   # 这 30 秒内探测失败不计入 retries

还有个容易忽略的点:healthcheck.test 的命令是在容器内部执行的,用的是容器里有的二进制。pg_isreadyredis-cli 这些官方镜像自带没问题,但如果你想用 curl 探测一个基于 alpine 精简镜像的服务,很可能镜像里压根没装 curl,健康检查会一直报 executable file not found,状态永远 unhealthy。这种时候要么换成镜像里有的工具(比如 wget,或者直接 nc -z),要么干脆用语言运行时自己探,别想当然以为宿主机有的命令容器里也有。

环境变量不要写散

应用连接数据库时,通常需要环境变量:

1DATABASE_URL=postgres://app:app_password@localhost:5432/app_dev
2REDIS_URL=redis://localhost:6379

Compose 里也有一份配置。如果两边不一致,就会出现“容器正常但应用连不上”的问题。

我喜欢在 README 里明确写:

1本机应用连接地址:
2DATABASE_URL=postgres://app:app_password@localhost:5432/app_dev
3REDIS_URL=redis://localhost:6379

如果应用也在 Compose 网络里跑,连接地址要改成服务名:

1DATABASE_URL=postgres://app:app_password@postgres:5432/app_dev
2REDIS_URL=redis://redis:6379

这是新手最容易混淆的点:本机访问容器用 localhost:映射端口,容器之间访问用服务名。我带新人时这个问题一年要解释好几遍——他在容器里跑应用,配置却写着 localhost:5432,于是应用想连「自己这个容器的 5432」,当然连不上。Compose 默认会建一个网络,同一个 Compose 里的服务可以直接用服务名互相找到,根本不用关心 IP。

还有一点关于端口的区别也值得记一笔:服务之间走的是容器网络内部端口,跟你 ports 映射出来的宿主机端口没关系。也就是说,哪怕你把 Postgres 映射成 "15432:5432",另一个容器连它用的还是 postgres:5432,因为容器内部 Postgres 还是监听 5432。ports 那个映射只是给宿主机用的窗口。

环境变量本身我倾向用 .env 文件统一管理,Compose 会自动读取项目根目录的 .env,可以在 yaml 里用 ${POSTGRES_PASSWORD} 引用:

1services:
2  postgres:
3    image: postgres:16
4    environment:
5      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

.env 千万别提交进仓库,加进 .gitignore,另外放一份 .env.example 当模板让新人照着填。这样既统一了配置来源,又不至于把密码(哪怕是开发密码)泄进 git 历史。

这里还有个把我坑过的细节,跟变量插值的「层级」有关。Compose 顶层的 ${POSTGRES_PASSWORD}Compose 在解析 yaml 时 替换的,它读的是项目根目录那份 .env(外加你 shell 的环境变量)。但 service.environment 下面写的变量,是注入到容器进程里的,跟 Compose 解析时读的 .env 不是一回事。有次我在 .env 里改了密码,docker compose config 打出来 yaml 也确实是新密码,可应用还连不上——因为旧容器是用旧密码初始化数据目录的,Postgres 的密码在 initdb 那一刻就写进数据目录了,后面你改环境变量它根本不读。这又绕回前面那条铁律:改了初始化相关的环境变量,必须 down -v 重建数据卷才生效。想确认 Compose 最终解析成什么样,别靠猜,直接:

1docker compose config        # 打印插值后的完整配置,密码、端口一目了然

这条命令是我排查「配置到底生效没」时第一个敲的,比对着几份 yaml 文件脑补合并结果靠谱多了。

初始化脚本

数据库经常需要初始化表结构或种子数据。Postgres 镜像支持把脚本挂到初始化目录:

1services:
2  postgres:
3    image: postgres:16
4    volumes:
5      - postgres_data:/var/lib/postgresql/data
6      - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql

需要注意:初始化脚本和数据目录的版本信息一样,都只在数据目录第一次创建时「定型」——volume 一旦存在,很多改动就不会再自动生效,这是同一个根子上的两个坑。

一是脚本不会重跑:如果 volume 已经存在,再改 init.sql 不会自动重新执行。这条规则我吃过亏,当时改了 init.sql 想加一张表,docker compose up 重启后死活没生效,对着脚本看了半天没找出问题,最后才反应过来是 volume 早就存在了,初始化目录根本不会再被触发。

二是版本不兼容:如果 volume 是被另一个版本的 Postgres 写过数据的,比如数据目录是 15 的格式而镜像换成了 16,新容器启动时检查 PG_VERSION 文件对不上,会直接报 database files are incompatible with server 然后退出。

这两种情况的解法是一样的:想让初始化脚本重新跑一遍,或者甩掉不兼容的旧数据,都得把卷删掉重建:

1docker compose down -v
2docker compose up -d

不过遇到版本不兼容先别急着重建,想清楚是不是只想升级——真要跨大版本迁移数据,得用 pg_upgrade 或 dump/restore,不能指望容器自动帮你迁移;开发环境图省事直接重建当然也无妨,但报错的含义得心里有数。

这也是为什么开发环境要区分:

  • 初始化脚本:第一次创建数据库时用
  • 迁移脚本:数据库已经存在后持续演进
  • 种子数据:为本地调试准备基础数据

不要把所有事情都塞进 init.sql。所以我现在的习惯是:init.sql 里只放那些「数据库一辈子只需要做一次」的事,比如建扩展(CREATE EXTENSION IF NOT EXISTS "uuid-ossp";)、设时区。表结构和后续变更全交给应用层的迁移工具(Prisma migrate、Flyway、golang-migrate 之类),这些工具自带版本记录,能增量执行、能回滚,比手改 init.sql 靠谱太多。种子数据则单独写个脚本或 npm script,需要的时候手动跑,跟初始化解耦。

顺便提一句:docker-entrypoint-initdb.d 目录里的文件是按文件名字母序执行的,如果你挂多个脚本,记得用 01-02- 这样的前缀控制顺序,不然依赖关系一乱又是一堆莫名报错。

不要把生产配置直接搬到本地

开发环境和生产环境应该相似,但不应该完全一样。

开发环境可以暴露端口、使用简单密码、保留调试数据。生产环境则需要网络隔离、密钥管理、备份、资源限制、监控告警。

危险做法是把生产 compose 文件稍微改一下用于本地,里面还残留真实域名、真实 token、真实数据库地址。

更稳的是拆分:

1compose.yml
2compose.dev.yml
3compose.prod.yml

本地启动:

1docker compose -f compose.yml -f compose.dev.yml up -d

公共配置放 compose.yml,环境差异放覆盖文件里。

日志和排查

常用命令要写进项目文档。我自己整理了一套排查顺序,按这个走基本能覆盖九成本地问题:

1# 1. 服务到底起没起、健康不健康
2docker compose ps
3
4# 2. 起不来或反复重启,看日志找报错
5docker compose logs -f --tail=100 postgres
6
7# 3. 怀疑端口冲突,看宿主机谁占了
8lsof -i :5432
9
10# 4. 进容器手动连一下,确认服务本身没问题
11docker compose exec postgres psql -U app -d app_dev
12
13# 5. 单个服务状态不对,先尝试重启
14docker compose restart redis
15
16# 6. 实在乱了,重建容器(数据卷保留)
17docker compose up -d --force-recreate

比如应用连不上数据库,走的就是第 1-4 步:先看 ps 确认容器是否健康,再看日志有没有报错,怀疑端口冲突就查一下 lsof,最后进容器手动连一次,确认数据库本身没问题。排查顺序清楚,团队就不会每次都靠猜。

docker compose ps 里的 STATUS 列特别值得看一眼:如果显示 unhealthy,说明健康检查一直没过,多半是探测命令写错了或者密码不对;如果是 restarting,那就是容器启动后立刻崩溃、被自动拉起又崩,去看日志准能看到报错堆栈。

还有个新人常犯的误区:改了 compose 文件里的 environmentcommand,以为 docker compose restart 就能生效。其实 restart 只是把现有容器停了再开,配置不会重新读取。改了配置得用 up -d,Compose 会比对差异、按需重建容器。这个区别不搞清楚,调半天配置不生效能把人逼疯。

统一错误处理仍然需要

即使依赖服务由 Compose 管理,应用层也不能假设依赖永远可用。

数据库可能还没启动,Redis 可能连接失败,网络可能抖动。API 层应该把这些错误转换成统一结构:

1function handleApiError(error, response) {
2  console.error(error)
3
4  response.status(500).json({
5    error: {
6      code: 'INTERNAL_ERROR',
7      message: '服务暂时不可用,请稍后重试',
8    },
9  })
10}

开发环境稳定只是减少问题,不是消灭问题。服务依赖越多,哪一层该接住错误、接不住的时候怎么往上抛,就越要提前想清楚。

多项目并行时的隔离

只跑一个项目时这些都不是问题,但凡你同时在维护两三个项目,麻烦就来了。

Compose 默认会用所在目录名当「项目名」,容器名、网络名、卷名都带这个前缀。如果两个项目恰好目录名一样(比如都叫 app),它们的资源就可能互相覆盖,你在 A 项目 down 一下,B 项目的容器也跟着没了。我习惯显式指定项目名,避免歧义:

1docker compose -p blog-api up -d

或者直接写进 compose 文件顶部:

1name: blog-api
2services:
3  postgres:
4    # ...

端口也是同理。两个项目都想用 5432,至少得有一个改映射,否则第二个永远起不来。我现在新项目都会给依赖服务的宿主机端口加个项目相关的偏移,比如这个项目用 5432,那个用 5433,记在各自 README 里,省得每次都现猜。

Docker 跑久了还会攒下一堆停掉的容器、悬空镜像和没人用的卷,磁盘悄悄就满了。我每隔一阵会清一次:

1docker system prune          # 清停掉的容器、悬空镜像、未用网络
2docker system prune --volumes # 连未被引用的卷也清掉,慎用

--volumes 的那条要看清楚再敲,它会删掉所有没被容器引用的卷,包括你某个临时停掉项目的数据库数据。

一次「时好时坏」的连接事故复盘

讲个完整的排查过程,因为这种「偶发、不稳定复现」的问题最磨人。当时现象是:本地起的服务连 Postgres,大部分时候正常,但每隔一段时间会蹦出一两次 connection refused,过几秒又好了。代码没动过,最让人抓狂的就是这种没规律的间歇性失败。

我按前面那套顺序走。docker compose ps 看状态,STATUS 一直是 healthy,没毛病;docker compose logs postgres 翻日志,没崩溃记录,但翻到中间发现一段——LOG: received fast shutdown request 紧接着 LOG: database system is ready to accept connections。也就是说 Postgres 在那个时间点被重启过一次。谁重启的?我没敲 restart

顺着这条线查,发现是另一个同事的脚本里写了 docker compose restart postgres 做「数据重置」,跑在一个定时任务里。重启的那几秒,端口短暂不可用,恰好我的连接池在那一刻去拿连接,就吃到了 connection refused。根因找到了,但这暴露出我自己代码的问题:连接池没配重试和健康探活,一次拿连接失败就直接把错误抛给上层了。

修法分两层。Compose 这边,把会引起重启的操作收敛掉,定时重置改成只清数据不重启容器(TRUNCATE 而不是 restart)。应用这边,给连接池加上获取超时和失败重试,让它能扛住依赖服务的短暂抖动:

1// 以 node-postgres 的连接池为例
2const { Pool } = require('pg')
3
4const pool = new Pool({
5  connectionString: process.env.DATABASE_URL,
6  max: 10,
7  // 拿不到连接时最多等 5 秒,而不是无限挂着
8  connectionTimeoutMillis: 5000,
9  // 空闲连接 30 秒回收,避免攥着一堆失效连接
10  idleTimeoutMillis: 30000,
11})
12
13// 池子里的连接出错(比如服务端重启把连接断了)不要让整个进程崩
14pool.on('error', (err) => {
15  console.error('idle client error', err.message)
16})
17
18// 带退避重试的查询封装:扛短暂抖动,但别无脑重试写操作
19async function queryWithRetry(text, params, retries = 2) {
20  for (let attempt = 0; ; attempt++) {
21    try {
22      return await pool.query(text, params)
23    } catch (err) {
24      const retryable = ['ECONNREFUSED', 'ECONNRESET', '57P01'].some((c) =>
25        String(err.code).includes(c),
26      )
27      if (!retryable || attempt >= retries) throw err
28      await new Promise((r) => setTimeout(r, 200 * 2 ** attempt)) // 指数退避
29    }
30  }
31}

这里特意只对连接类错误(ECONNREFUSEDECONNRESET、Postgres 的 57P01 管理员关闭连接)做重试,业务报错(比如唯一约束冲突)绝不能重试,否则一条插入可能被执行两次。哪些错误能重试、哪些绝不能,这条线必须分清楚。

间歇性问题的排查思路可以固定下来:不要盯着稳态看,去日志里找「状态变化的时间点」——一段服务重启、一次网络抖动,往往就藏在两条看似无关的日志之间。开发环境再稳,应用层也得假设依赖会抖,这跟前面「统一错误处理仍然需要」说的是同一件事:连接池的重试和超时不是给生产环境单独准备的开销,本机连的哪怕是 Compose 里的服务,也该走一样的防御逻辑。

用 profiles 和 extends 管理「按需启动」的服务

项目依赖一多,并不是每次开发都要把全套服务拉起来。我平时只写接口,根本用不上 Elasticsearch 和 MinIO,但它们启动占内存、拖慢 up。早期我的笨办法是注释掉不用的服务,结果经常忘了取消注释,或者提交时把注释也带进去,搞得别人一头雾水。

Compose 的 profiles 就是为这个设计的。给服务打上 profile 标签,默认 up 时不带这个 profile 的服务才会起,需要时再显式带上:

1services:
2  postgres:
3    image: postgres:16
4    # 不写 profiles,属于默认启动
5
6  redis:
7    image: redis:7
8    # 默认启动
9
10  elasticsearch:
11    image: elasticsearch:8.13.0
12    profiles: ["search"]      # 只有显式启用 search 才起
13    environment:
14      discovery.type: single-node
15      ES_JAVA_OPTS: "-Xms512m -Xmx512m"
16
17  minio:
18    image: minio/minio
19    profiles: ["storage"]
20    command: server /data

平时开发就普通 docker compose up -d,只起 Postgres 和 Redis。哪天要调搜索功能:

1docker compose --profile search up -d        # 额外把 elasticsearch 拉起来
2docker compose --profile search --profile storage up -d  # 多个一起

这比注释代码干净太多,配置始终是完整、可提交的,谁要用谁自己 --profile 点开。

另一个我常用的是 extends/锚点(YAML anchor)来消除重复。当多个数据库服务共享一大堆相同配置(日志驱动、重启策略、公共环境变量)时,复制粘贴很容易改漏一处。用 YAML 锚点抽出公共片段:

1x-common: &common
2  restart: unless-stopped
3  logging:
4    driver: json-file
5    options:
6      max-size: "10m"
7      max-file: "3"
8
9services:
10  postgres:
11    <<: *common              # 合并公共配置
12    image: postgres:16
13
14  redis:
15    <<: *common
16    image: redis:7

x- 开头的顶层键是 Compose 规范里专门留给扩展字段的,Compose 会忽略它、不当成服务解析,正好拿来放锚点。这里那个 logging 配置顺便提一句:开发环境跑久了容器日志能膨胀到几个 G,把磁盘吃满,加上 max-size/max-file 做日志轮转,能省掉「磁盘莫名其妙满了」这类排查。

Docker Compose 很适合管理本地开发依赖。它能让数据库、缓存、消息队列这些服务版本一致、启动方式一致、排查方式一致。

真正落地时,不要一开始就把所有东西容器化。先容器化依赖服务,写清楚端口、环境变量、数据卷、健康检查、初始化脚本和常用命令。这样新人启动项目会轻松很多,团队也能少花时间处理“我本机不一样”的问题。