将 Python 项目部署到服务器时,很多团队会选择 Docker。它可以把代码、运行环境和依赖统一封装到镜像中,减少"本地可以运行,服务器却报错"的问题。
不过,能成功构建镜像并不代表 Dockerfile 已经写好。一个未经优化的 Python 镜像可能存在以下问题:
-
镜像体积过大;
-
构建速度很慢;
-
每次修改代码都重新安装依赖;
-
镜像中残留编译工具和缓存;
-
容器以 root 用户运行;
-
敏感配置被写入镜像;
-
服务异常后无法被及时发现。
本文将以 FastAPI 项目为例,介绍如何通过多阶段构建、构建缓存、非 root 用户和健康检查,制作一个更适合生产环境的 Python 镜像。
一、准备一个 FastAPI 项目
示例项目结构如下:
docker-fastapi-demo/
├── app/
│ ├── __init__.py
│ └── main.py
├── requirements.txt
├── Dockerfile
├── compose.yaml
└── .dockerignore
在 app/main.py 中创建一个简单接口:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def index():
return {
"message": "Hello Docker"
}
@app.get("/health")
def health():
return {
"status": "ok"
}
requirements.txt 内容如下:
fastapi
uvicorn[standard]
本地启动方式:
uvicorn app.main:app \
--host 0.0.0.0 \
--port 8000
访问:
http://localhost:8000
如果返回以下内容,说明项目运行正常:
{
"message": "Hello Docker"
}
二、一个最基础的 Dockerfile
最简单的 Dockerfile 可以这样写:
FROM python:3.12
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD [
"uvicorn",
"app.main:app",
"--host",
"0.0.0.0",
"--port",
"8000"
]
构建镜像:
docker build \
-t fastapi-demo:latest \
.
运行容器:
docker run \
--name fastapi-demo \
-p 8000:8000 \
fastapi-demo:latest
这份 Dockerfile 可以工作,但并不适合直接用于生产环境。
主要问题包括:
-
完整 Python 基础镜像体积较大;
-
项目中的无关文件也会被复制;
-
修改任何代码都可能导致依赖重新安装;
-
pip下载缓存可能保留在镜像中; -
容器默认以 root 用户运行;
-
编译依赖和运行依赖没有分离。
接下来逐步进行优化。
三、选择合适的基础镜像
Python 官方镜像通常提供多个变体。
常见选择包括:
python:3.12
python:3.12-slim
python:3.12-alpine
完整镜像
FROM python:3.12
优点是系统组件比较完整,安装依赖时遇到的问题较少;缺点是镜像体积相对较大。
Slim 镜像
FROM python:3.12-slim
Slim 版本保留 Python 运行所需的基础环境,同时移除了大量不必要的软件包,通常是 Python Web 项目比较稳妥的选择。
Alpine 镜像
FROM python:3.12-alpine
Alpine 体积很小,但它使用 musl libc。一些包含原生扩展的 Python 依赖可能没有对应的预编译包,需要在构建时自行编译。
因此,镜像体积最小不一定代表总体成本最低。对于普通 FastAPI 项目,python:3.12-slim 往往更容易维护。
四、利用 Docker 构建缓存
下面这种写法会先复制整个项目:
COPY . .
RUN pip install -r requirements.txt
只要任意代码文件发生变化,COPY . . 对应的缓存就会失效,后面的依赖安装步骤也必须重新执行。
更合理的方式是先复制依赖文件:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install \
--no-cache-dir \
-r requirements.txt
COPY app ./app
只要 requirements.txt 没有变化,修改业务代码后重新构建镜像时,Docker 就可以复用依赖安装层。
Dockerfile 中的指令顺序非常重要。一般可以把不经常变化的内容放在前面,把频繁变化的业务代码放在后面。
五、使用 .dockerignore 排除无关文件
Docker 构建时,会先把构建上下文发送给 Docker 引擎。
如果项目中包含虚拟环境、日志、Git 历史或测试缓存,不仅会拖慢构建速度,还可能把不应该进入镜像的内容复制进去。
可以创建 .dockerignore:
.git
.gitignore
.venv
venv
__pycache__
*.pyc
*.pyo
.pytest_cache
.mypy_cache
.coverage
htmlcov
logs
*.log
.env
.DS_Store
README.md
尤其要注意 .env。
如果 .env 中包含数据库密码、JWT 密钥或第三方接口密钥,不应该通过 COPY . . 写进镜像。即使后续在新的镜像层中删除文件,它仍然可能存在于之前的镜像层中。
六、什么是多阶段构建?
多阶段构建允许在一个 Dockerfile 中使用多个 FROM。
前一个阶段负责下载和编译依赖,后一个阶段只保留运行程序需要的内容。
基本结构如下:
FROM python:3.12-slim AS builder
# 下载和编译依赖
FROM python:3.12-slim AS runtime
# 只复制运行所需内容
这种方式可以把编译器、头文件和临时文件留在构建阶段,避免它们进入最终运行镜像。
七、使用 Wheel 构建 Python 依赖
可以先在构建阶段把依赖打包成 Wheel:
FROM python:3.12-slim AS builder
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /build
COPY requirements.txt .
RUN pip wheel \
--wheel-dir=/wheels \
-r requirements.txt
pip wheel 会把依赖准备到 /wheels 目录。
运行阶段再从本地 Wheel 安装:
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=builder /wheels /wheels
COPY requirements.txt .
RUN pip install \
--no-index \
--find-links=/wheels \
-r requirements.txt
其中:
-
--no-index:不再访问在线软件源; -
--find-links=/wheels:从指定目录查找依赖; -
构建阶段与运行阶段相互隔离;
-
最终镜像不需要保留构建过程中的下载缓存。
八、完整的多阶段 Dockerfile
综合前面的优化,可以得到以下 Dockerfile:
FROM python:3.12-slim AS builder
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /build
COPY requirements.txt .
RUN pip wheel \
--wheel-dir=/wheels \
-r requirements.txt
FROM python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
RUN groupadd \
--system appgroup \
&& useradd \
--system \
--gid appgroup \
--create-home \
appuser
COPY --from=builder /wheels /wheels
COPY requirements.txt .
RUN pip install \
--no-cache-dir \
--no-index \
--find-links=/wheels \
-r requirements.txt \
&& rm -rf /wheels
COPY --chown=appuser:appgroup \
app ./app
USER appuser
EXPOSE 8000
CMD [
"uvicorn",
"app.main:app",
"--host",
"0.0.0.0",
"--port",
"8000"
]
这份 Dockerfile 主要进行了以下优化:
-
使用
slim基础镜像; -
将依赖构建和程序运行分离;
-
先复制依赖文件,提高缓存利用率;
-
不保留
pip缓存; -
不复制无关项目文件;
-
使用非 root 用户运行服务;
-
启用 Python 日志即时输出。
九、为什么要使用非 root 用户?
Docker 容器默认通常以 root 用户运行。
如果应用或依赖存在漏洞,攻击者进入容器后就可能获得容器内的 root 权限。虽然容器与宿主机之间存在隔离,但降低容器进程权限仍然是重要的安全措施。
Dockerfile 中可以创建专用用户:
RUN groupadd \
--system appgroup \
&& useradd \
--system \
--gid appgroup \
appuser
复制代码时指定文件所有者:
COPY --chown=appuser:appgroup \
app ./app
最后切换用户:
USER appuser
切换后,应用可能无法写入原本只有 root 有权限的目录。
如果程序需要保存临时文件,可以提前创建并设置权限:
RUN mkdir -p /app/tmp \
&& chown -R appuser:appgroup /app/tmp
生产环境中更推荐把需要持久保存的数据放在数据库、对象存储或挂载卷中,而不是直接保存在容器可写层。
十、为容器增加健康检查
容器进程仍然存在,并不代表应用一定能够正常处理请求。
例如:
-
应用线程可能已经卡死;
-
数据库连接可能不可用;
-
服务端口可能没有正确监听;
-
初始化过程可能失败;
-
外部依赖可能异常。
可以在 FastAPI 中提供健康检查接口:
@app.get("/health")
def health():
return {
"status": "ok"
}
然后在 compose.yaml 中配置健康检查:
services:
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "8000:8000"
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"
]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
这里使用 Python 标准库发起请求,不需要为了健康检查额外安装 curl。
查看容器状态:
docker compose ps
健康检查成功后,容器状态会显示为 healthy。
十一、健康检查接口应该检查什么?
最简单的存活检查只需要确认应用能够响应:
@app.get("/health/live")
def liveness():
return {
"status": "alive"
}
就绪检查则可以判断应用是否具备处理业务请求的条件:
@app.get("/health/ready")
def readiness():
database_ok = check_database()
redis_ok = check_redis()
if not database_ok or not redis_ok:
raise HTTPException(
status_code=503,
detail="Service unavailable",
)
return {
"status": "ready"
}
两者的用途不同:
-
存活检查失败,说明应用进程可能需要重启;
-
就绪检查失败,说明暂时不应该把流量发送给该实例。
健康检查不应该执行复杂查询,也不应该返回数据库密码、服务器地址或详细异常堆栈。
十二、通过环境变量传递配置
不要把数据库地址和密钥直接写入 Dockerfile:
ENV DATABASE_PASSWORD=my_password
更合理的方式是在运行时传入:
services:
api:
build: .
environment:
APP_ENV: production
DATABASE_HOST: database
DATABASE_PORT: "5432"
DATABASE_NAME: app
敏感信息可以从外部环境读取:
services:
api:
environment:
JWT_SECRET: ${JWT_SECRET}
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
启动前设置变量:
export JWT_SECRET="随机密钥"
export DATABASE_PASSWORD="数据库密码"
docker compose up -d
需要注意,普通环境变量并不是专门的机密管理系统。对安全要求较高的生产环境,应该使用云平台、容器编排平台或专用密钥管理服务提供的 Secret 能力。
十三、同言翻译的容器化部署思路
对于包含接口服务、异步任务和缓存等多个组件的应用,容器化可以让不同服务使用相对独立的运行环境。
以 同言翻译 为例,可以根据业务职责把系统拆分为多个容器:
API 服务
实时通信服务
异步任务 Worker
数据库
Redis
反向代理
其中,API 服务负责账号、会话和配置管理;实时通信服务处理持续的数据连接;Worker 执行会话摘要、文件导出等耗时任务;Redis 可以保存短期状态或作为消息代理。
这些组件不一定要使用同一个镜像。可以根据实际功能准备不同的启动命令:
services:
api:
build: .
command:
[
"uvicorn",
"app.main:app",
"--host",
"0.0.0.0",
"--port",
"8000"
]
worker:
build: .
command:
[
"celery",
"-A",
"app.celery_app.celery_app",
"worker",
"--loglevel=info"
]
即使 API 和 Worker 共用基础镜像,也应该作为不同容器运行,这样可以分别设置资源限制和扩容数量。
对于 同言翻译 这类可能处理语音、文本和会话数据的应用,还应注意:
-
不把用户内容写入镜像;
-
不在构建参数中传递敏感密钥;
-
临时文件使用独立目录并及时清理;
-
容器日志避免输出完整原文和译文;
-
根据服务职责限制网络访问;
-
为不同容器配置最小必要权限;
-
对上传文件设置类型和大小限制。
容器化解决的是环境一致性和服务交付问题,并不能自动保证数据安全。权限控制、数据加密和日志脱敏仍然需要在应用层实现。

十四、控制容器资源
如果不限制资源,一个异常容器可能占用大量 CPU 或内存,影响同一台服务器上的其他服务。
在支持相关配置的运行环境中,可以为服务设置资源限制:
services:
api:
build: .
mem_limit: 512m
cpus: 1.0
资源限制需要根据实际压测结果设置。
如果配置太小,应用可能频繁被终止;如果完全不限制,异常请求或内存泄漏又可能影响整台服务器。
对于不同类型的服务,可以使用不同配置:
-
API 服务关注响应速度和并发连接;
-
Worker 关注任务耗时和内存占用;
-
音视频处理服务通常需要更多 CPU;
-
模型服务可能需要 GPU 和较大的内存。
十五、正确处理日志
容器中的应用日志建议输出到标准输出和标准错误,而不是只写入容器内部文件。
Python 可以设置:
ENV PYTHONUNBUFFERED=1
这样日志会及时输出,不会因为缓冲而延迟显示。
查看日志:
docker compose logs api
持续查看:
docker compose logs \
--follow \
api
生产环境可以使用集中式日志系统收集容器日志,并根据请求 ID、用户 ID和任务 ID进行检索。
日志中不应该记录:
-
用户密码;
-
完整 Token;
-
数据库连接密码;
-
第三方接口密钥;
-
未脱敏的个人信息;
-
不必要的完整业务内容。
十六、不要在容器启动时执行太多初始化操作
有些项目会在容器启动命令中同时执行数据库迁移和启动服务:
执行数据库迁移
↓
初始化数据
↓
启动 Web 服务
这种方式在单实例环境中可能没有问题,但多个容器同时启动时,可能出现重复执行迁移或争抢数据库锁的情况。
更稳妥的方式是把数据库迁移作为独立部署步骤:
构建镜像
↓
执行一次数据库迁移
↓
启动或更新应用实例
应用启动过程应该尽量简单、可预测,并且支持重复启动。
十七、镜像标签不要只使用 latest
构建镜像时只使用 latest:
docker build \
-t fastapi-demo:latest \
.
无法清楚判断当前服务器运行的是哪一次构建。
可以加入版本号或代码提交编号:
fastapi-demo:1.2.0
fastapi-demo:20260804
fastapi-demo:a1b2c3d
部署时使用明确版本:
services:
api:
image: fastapi-demo:1.2.0
这样出现问题时,更容易确认运行版本并回滚到之前的镜像。
镜像一旦构建完成,最好不要在不同时间用相同标签覆盖不同内容。
十八、如何进一步缩小镜像?
除了多阶段构建,还可以从以下方面减少镜像体积:
1. 使用合适的基础镜像
优先评估 slim,不要默认使用包含大量工具的完整镜像。
2. 删除不必要依赖
定期检查 requirements.txt,移除已经不再使用的包。
3. 区分开发和生产依赖
测试工具、代码格式化工具和调试工具不一定需要进入生产镜像。
可以拆分为:
requirements.txt
requirements-dev.txt
4. 避免复制测试数据和本地环境
通过 .dockerignore 排除测试输出、虚拟环境和缓存文件。
5. 合理合并清理步骤
安装系统依赖时,可以在同一层中完成缓存清理,避免缓存留在前面的镜像层。
不过,镜像优化不应该以牺牲可读性和稳定性为代价。几十 MB 的体积差异,未必值得引入复杂且难以维护的构建流程。
十九、常见误区
误区一:镜像越小越好
镜像大小只是一个指标。兼容性、安全更新、构建速度和维护成本同样重要。
误区二:容器内部保存的数据会一直存在
容器被删除或重新创建后,可写层中的数据可能丢失。重要数据应该保存在数据库、对象存储或持久化卷中。
误区三:使用 Docker 后就不需要配置管理
Docker 统一了运行环境,但不同环境仍然需要独立的数据库地址、密钥和业务配置。
误区四:容器正在运行就代表服务正常
进程可能存在,但业务接口已经无法处理请求,因此需要健康检查和外部监控。
误区五:Dockerfile 中删除密钥就不会泄露
如果密钥曾经被复制到前面的镜像层,即使后面删除,也可能从镜像历史中恢复。敏感信息不应该进入构建上下文和镜像层。
二十、总结
一个适合生产环境的 Python Docker 镜像,至少应该考虑:
-
选择合适的基础镜像;
-
利用 Docker 构建缓存;
-
配置
.dockerignore; -
使用多阶段构建;
-
移除安装缓存和构建工具;
-
使用非 root 用户;
-
在运行时注入配置;
-
增加健康检查;
-
控制资源使用;
-
规范日志输出;
-
使用明确的镜像版本。
多阶段构建的核心价值,并不只是缩小镜像体积,而是把"如何构建应用"和"如何运行应用"分离开来。
构建阶段可以安装编译工具、下载依赖和生成产物;运行阶段则只保留启动服务真正需要的内容。这样构建出的镜像通常更清晰,也更容易部署和维护。

