从实战搞懂 Docker:一个 Python 项目的开发环境容器化笔记

8 阅读14分钟

最近给一个 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 syncPython 依赖
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 持久化设计的三条军规

  1. 重要的数据用绑定挂载:你看得见、摸得着、能直接备份,任何 docker 命令都误删不了
  2. 可重建的数据用命名卷:依赖、缓存放卷里图个省心和速度,丢了大不了重装
  3. 永远不要依赖容器可写层:程序写文件前想清楚落点——一个 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           # 清理孤儿卷

十一、心得总结

  1. 开发镜像做减法:不 COPY 代码、不写 CMD/EXPOSE、ENV 只留构建期必需的,运行时配置全归 compose
  2. 挂载会覆盖:任何落在被挂载目录里的东西(.venv、缓存、浏览器)都要么挪走、要么预装进镜像
  3. 环境变量和卷成对思考:一个指路径、一个管持久化,放同一个文件里才不容易改漏
  4. 删配置前先想默认值:很多显式配置其实和默认行为一致,删掉更清爽
  5. 数据安不安全只看挂载:容器可写层随删随没,重要的东西必须挂卷或绑定挂载
  6. Docker 内部目录别手贱:/var/lib/docker/ 下的东西只用 docker 命令操作

把这些想明白之后,Docker 开发环境就从"照着抄能跑但不敢动"变成"每一行都知道为什么存在"了。