最近给一个 FastAPI 项目(uv 管理依赖 + Playwright 无头浏览器 + ffmpeg)搭 Docker 开发环境,过程中把镜像、容器、卷、环境变量这些概念反复踩了一遍。这篇文章把最终的配置和背后的判断逻辑完整记录下来,希望能帮你在"每个配置到底该写在哪"这类问题上少走点弯路。
一、先想清楚:开发阶段要的是什么
开发环境和生产环境的目标完全不同:
| 诉求 | 开发阶段 | 生产阶段 |
|---|---|---|
| 改代码 | 保存即生效,不能重新 build | 无所谓,发版才更新 |
| 改依赖 | 频繁变动,重装要快 | 基本不变 |
| 镜像体积 | 不太敏感 | 越小越好 |
| 启动方式 | compose 一把梭 | 自包含镜像,docker run 即可 |
所以开发阶段的核心设计是:镜像只装不变的系统环境,代码挂载进去,依赖放命名卷,启动命令统一由 compose 管理。
二、镜像、容器、卷:三者到底是什么关系
一句话版本:
- 镜像:只读模板,build 时确定,里面装系统库、浏览器这些"不变的东西"
- 容器:镜像跑起来的实例,多一层"可写层",运行时新写的文件都在这里,删容器就没
- 卷:独立于容器生命周期的持久化目录,容器随便删,数据都在
实际磁盘位置上,它们都归 Docker 统一管理(Linux 默认在 /var/lib/docker/ 下),但绑定挂载除外——那是你自己指定的宿主机普通目录。
三、Dockerfile 和 compose 的职责划分
这是最容易混乱的地方。我最终采用的原则是:
构建期要用的留 Dockerfile,运行时配置(端口、启动命令、环境变量、挂载)全放 compose。
最终的 Dockerfile 干净得只剩四件事:
FROM ghcr.nju.edu.cn/astral-sh/uv:python3.11-bookworm-slim
# 构建期需要: 镜像源加速(装包/下载 chromium 用)
ENV UV_DEFAULT_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple \
PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright
WORKDIR /app
# 系统依赖: ffmpeg + libatomic1(playwright node 驱动依赖)
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \
sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \
apt-get update && \
apt-get install -y --no-install-recommends ffmpeg ca-certificates libatomic1 && \
rm -rf /var/lib/apt/lists/*
# 预装 chromium 运行时系统库(临时装 playwright 调 install-deps, 装完即卸)
RUN uv pip install --system playwright && \
playwright install-deps chromium && \
uv pip uninstall --system playwright && \
rm -rf /var/lib/apt/lists/*
# 预装 chromium 浏览器内核(默认路径 /root/.cache/ms-playwright)
RUN uv run --with playwright playwright install chromium
注意几个点:
- 不 COPY 任何项目文件——代码靠挂载,改代码不用重新 build
- 不写 EXPOSE 和 CMD——端口和启动命令由 compose 统一管理
- ENV 只剩构建期需要的镜像源——运行时配置全部下放 compose
compose 则接管全部运行时配置:
services:
ys_web:
build: .
container_name: ys_web
ports:
- "8765:8765"
environment:
- TZ=Asia/Shanghai
- UV_PROJECT_ENVIRONMENT=/opt/venv # Python 依赖安装位置 → venv 卷
- UV_CACHE_DIR=/uv-cache # 包下载缓存位置 → uv-cache 卷
- OUTDIR=/downloads # 视频输出目录 → downloads 绑定挂载
volumes:
- .:/app # 代码挂载, 改代码即时生效
- venv:/opt/venv # 命名卷: 依赖持久化
- uv-cache:/uv-cache # 命名卷: 下载缓存持久化
- /宿主机/下载目录:/downloads # 绑定挂载: 输出文件
command: sh -c "uv sync --no-dev && uv run uvicorn main:app --host 0.0.0.0 --port 8765 --reload"
restart: unless-stopped
volumes:
venv: # 空声明 = 全部用默认配置(local driver, 自动命名)
uv-cache:
四、路径映射详解:容器目录和宿主机到底是什么关系
路径映射(挂载)是理解 Docker 数据管理的核心,单独展开讲。
4.1 挂载不是复制,是"同一个目录"
最容易误解的一点:挂载之后,容器内那个路径就是一个"窗口",看到的就是背后的宿主机目录/卷,读写直接落在同一份数据上,双向实时生效。
所以容器里下载的视频,宿主机目录里立刻就能看到,不存在"从容器里拷出来"这个步骤。反过来,宿主机上改了代码,容器里立刻读到新内容——这正是热重载能工作的基础。
4.2 两种挂载方式:看左边长什么样
挂载语法统一是 源:容器内路径,左边的写法决定类型:
volumes:
- .:/app # 绑定挂载: 相对路径(compose 文件所在目录)
- /宿主机/downloads:/downloads # 绑定挂载: 绝对路径
- venv:/opt/venv # 命名卷: 纯名字, Docker 创建和管理
- /opt/something # 匿名卷: 只写容器路径, Docker 随机起名(不推荐, 难找难清理)
判断口诀:左边带斜杠或点(/、./、../、~/)的是宿主机路径,纯单词的是卷名。
| 绑定挂载 | 命名卷 | |
|---|---|---|
| 左边写法 | 路径(./data、/mnt/downloads) | 纯名字(venv) |
| 宿主机位置 | 你指定,想放哪放哪 | Docker 分配(/var/lib/docker/volumes/...) |
| 谁管理 | 你自己(就是普通目录) | Docker(用 docker volume 命令操作) |
| 需要在底部 volumes 声明吗 | 不需要 | 需要(空声明即可) |
| 适合场景 | 代码、输出文件等你要直接碰的 | 依赖、缓存等不用直接碰的 |
4.3 覆盖语义:挂载点会"遮蔽"原内容
挂载生效后,容器内该路径显示的是挂载源的内容,镜像里同名目录下原有的东西会被遮住(不是删除,只是看不见)。
这就是为什么 .:/app 挂载后:
- 镜像里 COPY 进去的代码会被宿主机代码覆盖(所以开发镜像干脆不 COPY)
- 镜像里
/app/.venv也会被遮蔽(所以 venv 要挪到/opt/venv)
一个例外细节:命名卷首次挂载时,如果卷是空的、且镜像里该目录有内容,Docker 会把镜像内容复制进卷做初始化(仅这一次,之后卷里有数据就不再复制)。绑定挂载没有这个行为,永远是直接覆盖。
4.4 只读挂载
挂载后面可以加 :ro 禁止容器写入:
- ./config:/app/config:ro # 容器只能读, 改不了宿主机文件
注意本项目不能给代码目录加 :ro——uv sync 启动时可能要写锁文件,--reload 也要读文件变化,但读没问题;如果哪天启动报权限错误,先检查是不是挂载成了只读。
4.5 本项目的完整映射图
| 宿主机侧 | 容器侧 | 类型 | 谁往里写 | 用途 |
|---|---|---|---|---|
项目目录(.) | /app | 绑定挂载 | 你(改代码)、uv(写锁文件) | 代码热更新 |
ys_web_venv 卷 | /opt/venv | 命名卷 | uv sync | Python 依赖 |
ys_web_uv-cache 卷 | /uv-cache | 命名卷 | uv 下载包时 | 下载缓存 |
/宿主机/下载目录 | /downloads | 绑定挂载 | 下载程序 | MP4 输出 |
| (无挂载,镜像层) | /root/.cache/ms-playwright | — | build 时预装 | chromium 浏览器 |
和环境变量配合起来看,一条完整链路是:
compose 环境变量 UV_CACHE_DIR=/uv-cache → 告诉 uv 缓存放哪
compose 挂载 uv-cache:/uv-cache → 该目录接到命名卷
Docker 创建卷 ys_web_uv-cache → 实际存到 /var/lib/docker/volumes/...
4.6 改了挂载配置怎么生效
docker compose restart 不会应用 volumes/ports 的变更——这些属于容器的创建参数,必须重建容器:
docker compose up -d # 检测到配置变化会自动重建容器, 数据都在卷/宿主机上, 无感
五、开发模式最关键的一个坑:.venv 被挂载覆盖
uv 默认把虚拟环境建在项目目录下(/app/.venv)。但开发模式用了 .:/app 挂载,宿主机目录会整个覆盖容器里的 /app,包括镜像里装好的 .venv。
更糟的是,如果你在 Windows 宿主机上跑过 uv sync,挂载进去的 .venv 是 Windows 版的,Linux 容器根本跑不了。
解法是把虚拟环境挪到挂载点之外,用 uv 的官方环境变量:
environment:
- UV_PROJECT_ENVIRONMENT=/opt/venv
volumes:
- venv:/opt/venv # 再配个命名卷持久化
uv sync 和 uv run 都会自动读这个变量,其他命令一行都不用改。
同理,浏览器内核、uv 缓存也都要么预装进镜像、要么放挂载点之外的卷里。
六、环境变量到底写在哪:一个判断原则
每一对"环境变量 + 卷挂载"都是配套的:环境变量告诉工具把东西写到哪,卷负责持久化那个目录。这类配置放 compose 最直观——对应关系一目了然,改路径只动一个文件:
environment:
- UV_CACHE_DIR=/uv-cache # ←┐ 成对出现
volumes: # │
- uv-cache:/uv-cache # ←┘
而构建期要用的(比如镜像源、浏览器下载镜像站)必须留在 Dockerfile,否则 build 时读不到。
有些变量"两头都要用",比如 PLAYWRIGHT_BROWSERS_PATH(build 时决定浏览器装哪,运行时决定去哪找)。这类其实可以直接删掉——build 和运行时的默认路径一致(/root/.cache/ms-playwright),不写反而最省心。删掉配置之前,先想清楚默认值能不能覆盖两端,能就放心删。
七、命名卷完全指南
7.1 它是从哪来的
compose 底部的空声明:
volumes:
venv: # 空不代表没用, 而是"全部用默认配置"
uv-cache:
docker compose up 时 Docker 自动创建,实际名字带项目前缀(如 ys_web_venv)。空声明等价于 driver: local + 自动命名。需要自定义时才填参数,比如 external: true 引用已有卷。
7.2 物理位置在哪
/var/lib/docker/volumes/<项目名>_<卷名>/_data
但别背路径,直接查:
docker volume inspect ys_web_venv # 看 Mountpoint
docker info | grep "Docker Root Dir" # NAS 上数据根目录常被改到存储池
7.3 会被忘掉浪费空间吗
不会,不需要记名字:
docker volume ls # 列出所有卷
docker system df -v # 看每个卷占多少空间、谁在用
docker volume prune # 清理没有任何容器在用的孤儿卷(在用的不会动)
7.4 能被别的项目复用吗
可以,声明为外部卷即可:
volumes:
uv-cache:
external: true
name: global-uv-cache
但要分卷看待:
- uv-cache:推荐共享,只是下载缓存,多个 Python 项目共用省空间、提速
- venv:千万别共享,A 项目
uv sync会把 B 项目的依赖卸掉 - 浏览器:可以共享,但要求各项目 playwright 版本接近
八、容器里的 /opt、/root 到底在哪
启动容器后,容器里的系统目录由镜像层 + 容器可写层叠加而成,物理上在 /var/lib/docker/overlay2/<哈希>/ 下——一堆哈希目录,是 Docker 的内部实现,不要手动进去改。
正确的查看方式是进容器:
docker compose exec ys_web bash
ls /opt # venv 在这
ls /root/.cache/ms-playwright # chromium 在这
df -h # 看挂载点
一条原则:永远通过 docker 命令操作,不直接碰 /var/lib/docker/ 下的文件。
九、数据持久化:容器删除后什么还在,为什么
9.1 核心机制:容器只是一层"可写薄膜"
容器 = 镜像(只读层)+ 容器可写层 + 挂载点。删除容器时,Docker 只撕掉"可写层"这一层薄膜,其他东西一概不动。所以数据安不安全,只看一件事:
它写的时候,落在可写层里,还是落在挂载点里。
落在可写层 → 随容器删除消失;落在挂载点(卷或绑定挂载)→ 数据在容器之外,删除容器与它无关。
| 东西 | 写在哪 | 删容器后 |
|---|---|---|
| 镜像(系统库、预装 chromium) | 镜像只读层 | 在(删容器根本碰不到镜像) |
| 命名卷(依赖、缓存) | 卷(容器外) | 在 |
| 绑定挂载(代码、视频) | 宿主机目录(容器外) | 在 |
| 运行时新写的临时文件(没挂载的目录) | 容器可写层 | 没了 |
| 运行中的任务进度、内存状态 | 进程内存 | 没了(重启也没,和删不删无关) |
9.2 怎么确认我的数据落在哪
不要凭感觉,直接查挂载清单:
# 查看容器所有挂载点: Source(宿主机侧) → Destination(容器侧)
docker inspect ys_web --format '{{json .Mounts}}' | python -m json.tool
# 或看 compose 实际生效的配置
docker compose config | grep -A 10 volumes
对照规则自查:程序会写入的每个目录,要么在 Mounts 列表里(安全),要么在镜像里预装(重建可恢复),否则就是会丢的。
以本项目为例:
/downloads(视频)→ 绑定挂载 ✅/opt/venv(依赖)→ 命名卷 ✅/root/.cache/ms-playwright(浏览器)→ 镜像预装,重建容器自动带出来 ✅/app(代码)→ 绑定挂载 ✅- 日志、临时分片(如果写到别处)→ 可写层,删容器就没 ⚠️
9.3 各种"删除"操作的影响矩阵
"删容器"其实有好几个层级,破坏力完全不同:
| 操作 | 容器 | 可写层 | 命名卷 | 镜像 | 绑定挂载 |
|---|---|---|---|---|---|
docker compose stop | 保留(停止状态) | 保留 | 保留 | 保留 | 保留 |
docker compose down | 删除 | 删除 | 保留 | 保留 | 保留 |
docker compose down -v | 删除 | 删除 | 删除 | 保留 | 保留 |
docker rmi <镜像> | 不影响 | 不影响 | 不影响 | 删除 | 保留 |
docker volume prune | 不影响 | 不影响 | 只删无容器使用的 | 不影响 | 保留 |
| 宿主机上手动删目录 | — | — | — | — | 真删了 |
两个要点:
- 绑定挂载的数据,任何 docker 命令都删不掉——
down -v也只删命名卷,不动绑定挂载;唯一丢的方式是你自己在宿主机上删目录。这也是为什么最重要的数据(代码、输出文件)建议用绑定挂载 stop和down的区别:stop 保留可写层,start 后原样继续;down 删可写层,up 后是全新容器(但挂载数据都在)
9.4 场景演练:这些操作后项目处于什么状态
docker compose restart
进程重启,可写层都还在。跑了一半的任务丢了(内存清空),其他无变化。
docker compose down && docker compose up -d
全新容器:代码在、视频在、依赖在(venv 卷)、缓存有(uv-cache 卷)、浏览器在(镜像)。uv sync 秒过,服务直接可用。日常随便做,无感。
docker compose down -v && docker compose up -d
卷被清空:uv sync 全量重装依赖(有镜像源加速,几分钟),浏览器不受影响(在镜像里)。代码视频依然在。
docker rmi ys_web && docker compose up -d --build
镜像重建:系统库、chromium 全部重新下载安装(最慢的一条路径),卷若保留则依赖不用重装。
9.5 需要备份时怎么办
绑定挂载:就是宿主机普通目录,直接拷走/rsync/快照,NAS 上正常备份即可,无需特殊处理。
命名卷:数据在 Docker 管理目录里,推荐用一个临时容器把卷内容打包出来:
# 备份 venv 卷到当前目录的 venv-backup.tar.gz
docker run --rm \
-v ys_web_venv:/data:ro \
-v $(pwd):/backup \
alpine tar czf /backup/venv-backup.tar.gz -C /data .
# 恢复: 解包回卷
docker run --rm \
-v ys_web_venv:/data \
-v $(pwd):/backup \
alpine sh -c "cd /data && tar xzf /backup/venv-backup.tar.gz"
不过对依赖、缓存这类"可重建"的数据,通常不值得备份——重装一遍比备份恢复还简单。真正值得备份的只有:代码、输出文件、以及不可再生的业务数据(如果有数据库,务必挂卷并定期备份)。
9.6 持久化设计的三条军规
- 重要的数据用绑定挂载:你看得见、摸得着、能直接备份,任何 docker 命令都误删不了
- 可重建的数据用命名卷:依赖、缓存放卷里图个省心和速度,丢了大不了重装
- 永远不要依赖容器可写层:程序写文件前想清楚落点——一个
down就没了;如果哪天发现"重启容器后文件还在,down 之后没了",那就是写进了可写层
十、最终的工作流
# 日常使用
docker compose up -d # 启动(首次会装依赖, 之后秒起)
docker compose logs -f # 看日志
docker compose restart # 改了 pyproject.toml 依赖后
docker compose down # 停止(数据都在)
# 偶尔维护
docker compose up -d --build # 改了 Dockerfile / 系统依赖 / playwright 大版本
docker system df -v # 磁盘体检
docker system prune # 清理无用镜像和停止的容器
docker volume prune # 清理孤儿卷
十一、心得总结
- 开发镜像做减法:不 COPY 代码、不写 CMD/EXPOSE、ENV 只留构建期必需的,运行时配置全归 compose
- 挂载会覆盖:任何落在被挂载目录里的东西(.venv、缓存、浏览器)都要么挪走、要么预装进镜像
- 环境变量和卷成对思考:一个指路径、一个管持久化,放同一个文件里才不容易改漏
- 删配置前先想默认值:很多显式配置其实和默认行为一致,删掉更清爽
- 数据安不安全只看挂载:容器可写层随删随没,重要的东西必须挂卷或绑定挂载
- Docker 内部目录别手贱:
/var/lib/docker/下的东西只用 docker 命令操作
把这些想明白之后,Docker 开发环境就从"照着抄能跑但不敢动"变成"每一行都知道为什么存在"了。