MySQL 版本不对、Redis 端口被占、Node 版本和 package-lock 打架,中间还重装了一次 Python。
这种事我经历过太多次了。后来我们团队的做法是:不给文档,给一份compose.yaml。新人git clone完,敲一条命令,环境就有了。
这篇就把这套东西从零搭一遍。用的是 Docker Compose v2 的最新写法,包含几个大多数人没用过但确实省事的功能。
环境准备
只需要装 Docker Desktop(或者 Docker Engine + Compose v2 插件),别的什么都不用。
docker--version# 24.x 以上dockercompose version# v2.x,注意是 "docker compose",中间是空格有个小细节:docker-compose(带横杠)是 v1,早就停止维护了。如果你敲出来是这个,说明装的是老的 Python 版,建议换掉。
第一步:写 compose.yaml
先说一个很多人不知道的变化——version这个顶层字段已经废弃了。新的标准文件名是compose.yaml(其次是compose.yml),docker-compose.yml属于旧命名。
# compose.yamlservices:api:build:context:.target:develop# 多阶段构建的 dev 阶段ports:-"8000:8000"environment:DATABASE_URL:postgresql+asyncpg://app:secret@db:5432/appdbREDIS_URL:redis://cache:6379/0depends_on:db:condition:service_healthy# 等健康检查通过再启动,不是等容器起来cache:condition:service_starteddevelop:watch:-action:syncpath:./apptarget:/app/app-action:rebuildpath:./pyproject.tomldb:image:postgres:17-alpineenvironment:POSTGRES_USER:appPOSTGRES_PASSWORD:secretPOSTGRES_DB:appdbvolumes:-db_data:/var/lib/postgresql/dataports:-"5432:5432"# 暴露出来,本地 IDE 能直连healthcheck:test:["CMD-SHELL","pg_isready -U app -d appdb"]interval:5stimeout:3sretries:5start_period:10scache:image:redis:8-alpinevolumes:-cache_data:/datavolumes:db_data:cache_data:depends_on里的condition: service_healthy是重点。默认的depends_on只保证容器启动顺序,不保证里面的服务真的能用了。Postgres 容器起来后要几秒才接受连接,如果 api 抢跑,就会报连接拒绝——很多人第一次用 Compose 都被这个坑过。
第二步:写多阶段 Dockerfile
开发和生产用同一个 Dockerfile,靠target区分:
FROM python:3.13-slim AS base ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 WORKDIR /app COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv # ---- 开发阶段 ---- FROM base AS develop COPY pyproject.toml uv.lock ./ RUN uv sync --frozen COPY . . CMD ["uv", "run", "uvicorn", "app.main:app", "--reload", "--host", "0.0.0.0", "--port", "8000"] # ---- 生产阶段 ---- FROM base AS prod COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-dev COPY . . USER 1001 CMD ["uv", "run", "gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "-w", "4", "-b", "0.0.0.0:8000"]开发阶段带--reload和不带--no-dev,生产阶段反过来。这两个阶段分开写,比在 compose 里用command覆盖更清楚。
第三步:用 watch 告别手动 build
Compose v2.22 起有了develop.watch,v2.23 加了sync+restart,v2.32 又加了restart和sync+exec。它有三种动作:
| action | 行为 | 什么时候用 |
|---|---|---|
sync | 只同步文件,不重启 | 应用自己有热重载(uvicorn --reload、nodemon) |
sync+restart | 同步后重启容器 | 改配置需要重启才生效,比如 nginx.conf |
rebuild | 重新构建镜像并替换容器 | 依赖清单变了,pyproject.toml/package.json |
dockercomposewatch# 或者直接dockercompose up--watch它会监控你配的路径,改代码就同步进去,uvicorn 自己热重载;改了依赖清单就自动 rebuild。日常用下来,基本告别了手动build那三分钟。
有个坑要注意:watch 的 sync 需要镜像里有stat、mkdir、rmdir三个命令,而且容器里的用户必须对目标路径有写权限。如果用非 root 用户,Dockerfile 里COPY记得加--chown,否则同步会静默失败:
COPY --chown=app:app . /app第四步:profiles 区分环境
单机开发时你可能还想开个 pgAdmin、看看 Prometheus 面板,但这些不该是所有人默认启动的。用 profiles:
pgadmin:image:dpage/pgadmin4:latestprofiles:["debug"]ports:-"5050:80"prometheus:image:prom/prometheus:latestprofiles:["monitoring"]ports:-"9090:9090"dockercompose up-d# 只有基础服务dockercompose--profiledebug up-d# 加 pgAdmindockercompose--profiledebug--profilemonitoring up-d没写profiles的服务永远启动,写了 profile 的只在激活时才起。比维护三四份 compose 文件清爽得多。
第五步:别再明文写密码
见过太多人的 compose 文件里直接写MYSQL_ROOT_PASSWORD: 123456,然后连文件一起提交到 Git。
Compose 支持 secrets:
services:db:image:postgres:17-alpineenvironment:POSTGRES_PASSWORD_FILE:/run/secrets/db_password# 注意是 _FILE 后缀secrets:-db_passwordsecrets:db_password:file:./secrets/db_password.txt注意 Postgres 用的是POSTGRES_PASSWORD_FILE这种带_FILE后缀的变量,不是直接把值传进去。这是镜像本身的约定,不是 Compose 的语法,换别的镜像要查一下它支持不支持。
第六步:日志和信号也别漏
本地开发环境跑久了,最常见的诡异问题不是服务挂了,是磁盘满了。Docker 默认的json-file日志驱动不带轮转,一个疯狂打日志的服务,几天就能写掉几十 GB。
给服务加上轮转配置:
services:api:logging:driver:json-fileoptions:max-size:"10m"max-file:"3"另外两个值得加的小配置:
api:stop_grace_period:30s# docker compose down 时给它 30 秒优雅退出init:true# 用 tini 做 1 号进程,正确回收僵尸子进程init: true在跑 shell 脚本、或者应用会 fork 子进程时很有用。容器里的 1 号进程如果不会wait()回收子进程,跑久了会攒一堆僵尸进程占着 PID。加这一行就够了,Compose 会自动注入一个轻量的 init。
常用命令
dockercompose up-d# 起dockercomposeps# 看状态dockercompose logs-fapi# 跟日志dockercomposeexecapibash# 进容器dockercompose up-d--build# 重建dockercompose config# 校验并打印合并后的配置dockercompose down-v# 停掉并删数据卷最后那条down -v慎用,-v会连数据卷一起删掉,数据库就清空了。想重置环境时很好用,但手滑一次就得重来。
docker compose config这条值得养成习惯:它会把你写的文件、.env、以及include进来的所有内容合并展开,打印最终生效的配置。遇到"我明明改了配置怎么没生效",先跑这条看一眼。
几个我实际踩过的坑
1. 容器里的 localhost 不是你的电脑。在容器里连localhost:5432一定失败,因为那是容器自己。要连宿主机的服务,用host.docker.internal(Mac/Windows);Linux 上这个域名默认不存在,得加extra_hosts: - "host.docker.internal:host-gateway"。
2. bind mount 在 Mac/Windows 上很慢。挂一个几万文件的node_modules进去,文件 IO 能慢到怀疑人生。解决方案就是用develop.watch的 sync 代替 bind mount,或者把依赖目录用匿名卷屏蔽掉:
volumes:-./app:/app-/app/node_modules# 匿名卷,让容器内自己维护3. 改了配置忘了重载。Compose 里改compose.yaml后,部分字段(比如 ports、environment)需要docker compose up -d重新创建容器才生效,光restart不行。改完记得up -d,或者up -d --force-recreate。
最后
这套东西的价值不在于"用了 Docker",在于环境变成了代码。新人来了不用问任何人,docker compose up -d就完事;换台电脑不用重新配一遍;线上出问题排查时,本地能和线上跑同一套依赖版本。
我们团队现在的标准是:README 里超过三行的环境配置说明,一律改成一份 compose 文件。环境配置是文档最容易过期的地方,写成代码就不会了。