ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

将python项目导出为docker镜像实现过程

将python项目导出为docker镜像实现过程

前言


把 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.0

docker 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__.py

FROM 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-slim

4. 把本地的 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,缓存、体积、安全这三件事基本就都照顾到了。




返回列表