前言
把 Python 项目做成 Docker 镜像,本质上是三件事:选一个带 Python 的基础镜像、把依赖装进去、把代码和启动命令放进去。流程不长,但几乎每个人第一次写Dockerfile都会踩同一组坑——改一行代码就触发依赖重装、镜像里混进了.env和.git、容器跑起来日志看不到、以 root 身份跑服务。
需要先纠正一个说法:「导出为镜像」不是把本地环境打包带走。本地装好的虚拟环境(venv)里写死了绝对路径,直接复制进镜像在另一个系统上通常不可用。正确的做法是让镜像自己执行一次安装,把requirements.txt或pyproject.toml里的依赖重新装一遍,这样镜像才是自洽、可复现的。
还要提醒一句基础镜像的选择:不要沿用python:2.7这类标签。Python 2.7 已于 2020 年 1 月 1 日停止维护,不再有安全更新,而镜像里跑什么版本完全由FROM决定,不会因为宿主机升级而改变。文中示例统一使用 Python 3.12 的 slim 镜像。
本文按「写一个能用的Dockerfile」的顺序展开:先给最小可用版本,再逐条讲层缓存、依赖声明、.dockerignore、非 root 用户、多阶段构建和启动命令的选择,最后列出最容易踩的坑。
一、最小可用的 Dockerfile
在项目根目录新建一个名为Dockerfile的文本文件(没有后缀):
# 基础镜像:标签显式指定版本,不要用 latest
FROM python:3.12-slim
# 设置容器内的工作目录
WORKDIR /app
# 先只复制依赖清单,这样依赖没变时这一层可以复用缓存
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 再复制项目代码
COPY . .
# 以 exec 形式给出启动命令
CMD ["python", "main.py"]构建与运行:
docker build -t myapp:1.0 .
docker run --rm myapp:1.0docker build末尾那个点表示「构建上下文是当前目录」,不能省。-t myapp:1.0给镜像打名字和标签。
二、层缓存:为什么复制顺序不能乱
Docker 构建是分层的,每一条指令产生一层,只要某一层的输入没变,就能直接复用缓存。因此顺序至关重要:
| 复制顺序 | 改一行业务代码后 | 改一个依赖后 |
|---|
先COPY . .再pip install | 整个依赖重装 | 整个依赖重装 |
先COPY requirements.txt再装依赖,最后COPY . . | 只有最后一层重建 | 依赖层重建 |
# 推荐顺序:变化慢的在前,变化快的在后
FROM python:3.12-slim
WORKDIR /app
# 1) 系统依赖(很少变)
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
# 2) Python 依赖清单(偶尔变)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 3) 源码(经常变)
COPY . .
CMD ["python", "main.py"]--no-cache-dir让 pip 不在镜像里留下下载缓存;rm -rf /var/lib/apt/lists/*是同一个目的——把安装过程中产生的临时文件在同一层里删掉,否则它们依然会占镜像体积。
三、.dockerignore:控制构建上下文
COPY . .会把构建上下文里的所有文件都送进去,包括.git、.venv、__pycache__、.env和本地数据库文件。这既拖慢构建,也可能把密钥带进镜像。
在项目根目录创建.dockerignore(与Dockerfile同级):
.git
.gitignore
.venv
venv
__pycache__
*.pyc
*.pyo
.pytest_cache
.mypy_cache
.env
.env.*
*.sqlite3
*.log
Dockerfile
docker-compose.yml注意.dockerignore只影响「什么不会被送进构建上下文」,不影响容器里已有的文件。它也不能替代权限管理——真正的密钥应该用环境变量或挂载的 secret 传入,而不是写死在镜像里。
四、启动命令:CMD与ENTRYPOINT
两者都用来指定容器启动时执行什么,区别在于可覆盖性:
| 指令 | 能否被docker run的参数覆盖 | 典型用途 |
|---|
CMD | 能,docker run image 其他命令会整体替换 | 默认启动方式 |
ENTRYPOINT | 不能整体替换,追加的参数作为它的参数传入 | 把镜像做成「一个可执行程序」 |
两种写法还有 shell 形式与 exec 形式的区别,这一点在 Python 项目里后果很实在:
# exec 形式:PID 1 就是 python,能正确接收 SIGTERM
CMD ["python", "main.py"]
# shell 形式:实际是 /bin/sh -c "python main.py",
# python 是 sh 的子进程,信号不会自动转发
CMD python main.py推荐一律写成 exec 形式(JSON 数组)。容器里 Python 进程作为 PID 1 还有一个额外好处:KeyboardInterrupt与优雅退出能正常工作。
如果项目是 Web 服务,通常写成:
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]EXPOSE只是文档性质的声明,真正把端口映射到宿主机靠docker run -p 8000:8000。
五、非 root 用户与多阶段构建
默认情况下容器里是 root,这不是好习惯。加一个普通用户只需要三行:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# 创建普通用户并切换
RUN useradd --create-home --uid 10001 appuser \
&& chown -R appuser:appuser /app
USER appuser
CMD ["python", "main.py"]注意USER之后的所有指令都以该用户身份执行,所以chown必须在USER之前做完。
多阶段构建用来把「编译期需要、运行期不需要」的东西留在中间镜像里。纯 Python 项目如果只装 wheel,收益有限;但如果依赖里有需要编译的包,或者你希望运行镜像里连 pip 的构建工具链都没有,就值得用:
# ---------- 第一阶段:安装依赖 ----------
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
# 装到一个独立前缀下,方便整目录复制
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ---------- 第二阶段:运行镜像 ----------
FROM python:3.12-slim
WORKDIR /app
# 只把装好的依赖复制过来,构建工具留在上一层
COPY --from=builder /install /usr/local
COPY . .
RUN useradd --create-home --uid 10001 appuser \
&& chown -R appuser:appuser /app
USER appuser
CMD ["python", "main.py"]COPY --from=builder从上一次的构建阶段取文件,被引用阶段的内容不会进入最终镜像。
六、几个实用的环境变量
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1| 变量 | 作用 |
|---|
PYTHONDONTWRITEBYTECODE=1 | 不生成.pyc,避免容器里出现多余文件 |
PYTHONUNBUFFERED=1 | 标准输出不缓冲,docker logs能实时看到日志 |
PIP_NO_CACHE_DIR=1 | 等价于每次都给 pip 加--no-cache-dir |
PYTHONUNBUFFERED是排查「程序明明在跑但日志一片空白」的第一手段:不加它,Python 在非交互式环境下会把 stdout 缓冲起来。
完整示例
一个可以直接照抄的项目结构:
myproject/
├── Dockerfile
├── .dockerignore
├── requirements.txt
├── main.py
└── app/
└── __init__.pyFROM python:3.12-slim AS builder
ENV PIP_NO_CACHE_DIR=1
WORKDIR /build
COPY requirements.txt .
RUN pip install --prefix=/install -r requirements.txt
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
RUN useradd --create-home --uid 10001 appuser \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["python", "main.py"]# 构建
docker build -t myapp:1.0 .
# 前台运行,验证输出
docker run --rm -p 8000:8000 myapp:1.0
# 后台运行并查看日志
docker run -d --name myapp -p 8000:8000 myapp:1.0
docker logs -f myapp
# 进入容器排查
docker run --rm -it myapp:1.0 /bin/sh常见坑点
1. 先COPY . .再装依赖,改一行代码就全量重装
❌ 错误写法:
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "main.py"]✅ 正确写法:依赖清单先复制、单独安装,源码最后复制:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "main.py"]2. 没有.dockerignore,密钥和.git一起进镜像
❌ 错误做法:什么都不忽略,.env里的数据库密码被COPY . .带进镜像层——即使后续删掉,它依然留在历史层里。
✅ 正确做法:加.dockerignore排除.env、.git、.venv,密钥改用运行时环境变量或 secret 注入。
3. 用了FROM python:latest
❌ 错误写法:
FROM python:latest今天拉到的可能是 3.13,明天重建就变成别的版本,环境不可复现。
✅ 正确写法:显式指定次版本:
FROM python:3.12-slim4. 把本地的 venv 直接复制进镜像
❌ 错误写法:
COPY venv /app/venv
ENV PATH="/app/venv/bin:$PATH"venv 的脚本里写死了创建时的绝对路径,换个位置或换个系统就会失效,而且里面可能还混着平台相关的二进制包,最终镜像还会大出一大截。
✅ 正确写法:在镜像内重新安装依赖(pip install -r requirements.txt),或参考上一节的多阶段构建把依赖装到独立前缀再复制。
5. 用 shell 形式的CMD,信号收不到
❌ 错误写法:
CMD python main.py实际执行的是/bin/sh -c "python main.py",Python 是 shell 的子进程,docker stop的SIGTERM不会转发给它。
✅ 正确写法:
CMD ["python", "main.py"]6. 日志看不到,以为程序没起来
❌ 错误现象:docker logs什么都没有,但服务其实正常响应。
✅ 正确做法:设置ENV PYTHONUNBUFFERED=1,让 stdout 不缓冲;同时避免在代码里自己重定向或缓冲日志输出。
7. Web 服务监听127.0.0.1导致宿主机访问不到
❌ 错误写法:uvicorn app:app --host 127.0.0.1。容器内的回环地址只对容器自己可见。
✅ 正确写法:监听所有网卡(--host 0.0.0.0),再用docker run -p 8000:8000做端口映射。
8. 用COPY --from时忘了被引用阶段本身也要能独立构建
❌ 错误写法:在 builder 阶段只写了COPY requirements.txt .却从其他路径取文件,或者引用了不存在的阶段名,构建时报failed to solve、invalid from flag value。
✅ 正确写法:确认AS builder的别名与COPY --from=builder完全一致,且被复制的路径在上一个阶段里确实存在。
总结
| 环节 | 关键做法 |
|---|
| 基础镜像 | FROM python:3.12-slim,不要用latest |
| 复制顺序 | 依赖清单先、源码后,让缓存命中 |
| 安装依赖 | pip install --no-cache-dir -r requirements.txt,不复制 venv |
| 忽略文件 | 用.dockerignore排除.git、.venv、.env |
| 启动命令 | exec 形式CMD ["python", "main.py"] |
| 端口 | EXPOSE只是声明,映射靠-p,服务要监听0.0.0.0 |
| 权限 | 创建普通用户并USER切换 |
| 镜像瘦身 | 多阶段构建 + 同一层内清理临时文件 |
| 日志 | PYTHONUNBUFFERED=1 |
把 Python 项目打成镜像的核心思路是「让镜像自己装依赖」,而不是搬运本地环境。按「基础镜像 → 系统依赖 → Python 依赖 → 源码 → 用户 → 启动命令」这个顺序写Dockerfile,缓存、体积、安全这三件事基本就都照顾到了。