Python 项目构建 Docker 镜像

Python 是解释型语言,无需编译即可运行,但对运行环境(解释器版本、第三方依赖、系统库)依赖很强,“本机跑得好好的,换台机器就报错”是常态。把运行环境和项目代码一起打包进 Docker 镜像,就能做到”一次构建,到处运行”,极大简化部署与迁移。

项目结构标准化

典型 Python 项目 Docker 化前,根目录通常包含以下核心文件:

my_python_app/ ├── app.py # 入口脚本 ├── requirements.txt # 依赖清单 ├── Dockerfile # 镜像构建说明 ├── .dockerignore # 构建上下文忽略规则 └── templates/ # 模板/静态资源(可选) └── index.html

依赖清单可由 pip freeze > requirements.txt 生成;使用 Poetry / PDM 的项目则对应 pyproject.toml

Dockerfile 配置

Dockerfile

在项目根目录创建 Dockerfile,一个可用的基础版本如下:

# 基础镜像(这里用 DaoCloud 加速源拉取更快) FROM docker.m.daocloud.io/library/python:3.11-slim # 设置工作目录(容器内的应用根目录) WORKDIR /app # 先单独复制依赖清单(缓存优化) COPY requirements.txt . # 安装依赖(官方源较慢,使用阿里云镜像加速) RUN pip install --no-cache-dir -r requirements.txt \ -i https://mirrors.aliyun.com/pypi/simple/ # 再复制项目代码 COPY . . # 暴露端口(按应用实际监听端口填写) EXPOSE 5000 # 环境变量(可选) # ENV FLASK_APP=app.py # ENV FLASK_ENV=production # 容器启动命令 CMD ["python", "app.py"]

常用指令说明:

指令作用说明示例值
FROM指定基础镜像python:3.11-slim
WORKDIR设置工作目录/app
COPY复制文件到容器requirements.txt .
RUN构建阶段执行命令pip install -r requirements.txt
EXPOSE声明容器监听端口5000
ENV设置环境变量FLASK_ENV=production
CMD容器启动命令["python", "app.py"]

关于基础镜像-slim 体积小、依赖少,是大多数场景的首选;若依赖需要编译 C 扩展,可改用完整版 python:3.11;追求极致体积可了解 -alpine,但 Alpine 用的是 musl libc,部分含 C 扩展的包(如旧版 numpy/pandas)需额外处理,新手不建议。

为什么先复制 requirements.txt 再复制代码

利用 Docker 的分层缓存进行 Python 镜像优化

Docker 按指令逐层构建并缓存,只要某一层的输入没变,就直接复用缓存层。依赖通常很久才变一次,而代码几乎每次都改。如果写成下面这样:

COPY . . # 代码一改,这层缓存就失效 RUN pip install -r requirements.txt # 于是依赖每次都重装,很慢

把”装依赖”放在”复制代码”之前,只要 requirements.txt 没动,pip install 这层就一直命中缓存,每次改代码重新构建都是秒级完成。

.dockerignore

构建时 Docker 会把整个上下文目录打包发给守护进程。用 .dockerignore 排除无关文件,既能加快构建、缩小镜像,也能避免把本地密钥、虚拟环境误打进镜像。在根目录创建 .dockerignore

__pycache__/ *.py[cod] .Python env/ venv/ .venv/ .git/ .gitignore .DS_Store .env *.log

镜像构建与容器运行

构建镜像(-t 指定镜像名/标签,末尾的 . 表示用当前目录作为构建上下文):

docker build -t my-python-app .

运行容器:

docker run -d -p 5000:5000 --name my-python-app my-python-app

参数说明:

  • -d:后台运行(detached)。
  • -p 5000:5000:端口映射,格式为 宿主端口:容器端口
  • --name my-python-app:容器命名,便于后续 logs/stop/rm 引用。
  • 末尾的 my-python-app:要运行的镜像名。

查看日志与状态:

docker ps # 查看运行中的容器 docker logs -f my-python-app # 跟踪日志输出

使用 docker-compose

当项目需要持久化数据卷、固定多个运行参数,或后续要编排多个服务(如 app + 数据库)时,把配置写进 docker-compose.yml 比一长串 docker run 命令更易维护:

docker compose up -d --build # 构建并后台启动 docker compose ps # 查看状态 docker compose logs -f # 查看日志 docker compose down # 停止并移除容器

如果构建结果有误,需要清理环境并彻底重建:

# 停止容器并删除数据卷(-v 会一并清理 volume,注意数据丢失) docker compose down -v # 清理悬空镜像、构建缓存等 docker system prune -f # 重新构建并启动 docker compose up -d --build

CASE:

以下是一些实际案例:

Veridrop

Veridrop 是一个 AI API 中转站检测工具,支持直接网页使用,也支持自托管(CLI + Web 服务)。但它的仓库里并没有提供 Dockerfile,README 里说的 docker compose up 缺少对应文件,需要自己补齐。下面以它的 Web 服务为例,演示一个真实项目的 Docker 化。

服务器上拉取仓库:

git clone https://github.com/canarybyte/veridrop.git cd veridrop

阅读源码后,有三个关键约束决定了配置怎么写:

  1. 必须单 worker:任务队列状态存在进程内存(dict + asyncio 锁),多 worker 会拆散状态、导致任务状态查询失败。
  2. 应用必须放在 /opt/veridrop:排行榜与 sitemap 代码把数据路径 /opt/veridrop/web_data 写死了,无法用环境变量改。
  3. web_data 要挂成持久卷:检测报告 JSON/JPG、wishlist 都写在这里,挂卷后容器重启仍能保住分享链接。

.dockerignore

# VCS / 本地 clone 元数据 .git .gitignore # Python 构建/运行产物 **/__pycache__/ **/*.py[cod] *.egg-info/ .eggs/ build/ dist/ venv/ .venv/ env/ # 测试与工具缓存 .pytest_cache/ .mypy_cache/ .ruff_cache/ tests/ # 本地密钥/输出 —— 绝不打进镜像 .env .env.* !.env.example out/ tmp/ web_data/ # 运行时用不到的文档(README/LICENSE 单独 COPY) docs/ *.md !README.md # 容器内用不到的部署辅助脚本 deploy.sh bench.sh veridrop.service .github/

Dockerfile

FROM python:3.12-slim ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ PIP_NO_CACHE_DIR=1 \ VERIDROP_JOBS_DIR=/opt/veridrop/web_data/jobs \ VERIDROP_WISHLIST_PATH=/opt/veridrop/web_data/wishlist.txt # 排行榜/sitemap 把数据路径写死在 /opt/veridrop,工作目录必须对齐 WORKDIR /opt/veridrop # pyproject 在构建时会读取 README.md + LICENSE,editable 安装还需要包源码 COPY pyproject.toml README.md LICENSE ./ COPY src/ ./src/ COPY web/ ./web/ COPY data/ ./data/ RUN pip install --upgrade pip \ && pip install -e ".[web]" # 预创建持久化目录,保证挂载空卷时目录也存在 RUN mkdir -p /opt/veridrop/web_data/jobs EXPOSE 8000 VOLUME ["/opt/veridrop/web_data"] # 必须单 worker:任务队列状态在进程内存里,多 worker 会拆散状态 CMD ["uvicorn", "web.server:app", \ "--host", "0.0.0.0", "--port", "8000", \ "--workers", "1", \ "--proxy-headers", "--forwarded-allow-ips=*"]

这里几个细节值得注意:环境变量提前声明(如 PYTHONUNBUFFERED=1 让日志实时输出、不被缓冲);COPY 拆成多条而非 COPY . .,既精确又能更好利用缓存;启动用 uvicorn 模块方式运行 web.server:app,所以 web/ 目录必须在工作目录下。

docker-compose.yml

services: veridrop: build: . image: veridrop:local container_name: veridrop ports: - "9000:8000" # 宿主 9000 -> 容器 8000 volumes: - veridrop_data:/opt/veridrop/web_data restart: unless-stopped healthcheck: # 注意:healthcheck 在容器内部执行,必须用容器端口 8000,而非宿主映射的 9000 test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz').status==200 else 1)"] interval: 30s timeout: 5s retries: 3 start_period: 10s volumes: veridrop_data:

构建并启动:

docker compose up -d --build

验证(注意宿主端口映射成了 9000):

docker compose ps curl http://localhost:9000/healthz # 返回 {"ok": true, ...} 即成功 docker compose logs -f veridrop # 查看日志

浏览器访问 http://<服务器IP>:9000 即可打开 Web 界面。

端口排查提醒ports: "9000:8000" 的含义是”宿主 9000 → 容器 8000”。容器内部的进程(如 healthcheck、应用自身)始终用 8000;从宿主机或外部访问才用 9000。把这两者搞混是部署时最常见的坑之一。