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
阅读源码后,有三个关键约束决定了配置怎么写:
- 必须单 worker:任务队列状态存在进程内存(dict + asyncio 锁),多 worker 会拆散状态、导致任务状态查询失败。
- 应用必须放在
/opt/veridrop:排行榜与 sitemap 代码把数据路径/opt/veridrop/web_data写死了,无法用环境变量改。 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。把这两者搞混是部署时最常见的坑之一。