Docker 多阶段构建实战:让 Python 镜像更小、更快、更安全

将 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 可以工作,但并不适合直接用于生产环境。

主要问题包括:

  1. 完整 Python 基础镜像体积较大;

  2. 项目中的无关文件也会被复制;

  3. 修改任何代码都可能导致依赖重新安装;

  4. pip 下载缓存可能保留在镜像中;

  5. 容器默认以 root 用户运行;

  6. 编译依赖和运行依赖没有分离。

接下来逐步进行优化。

三、选择合适的基础镜像

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 用户;

  • 在运行时注入配置;

  • 增加健康检查;

  • 控制资源使用;

  • 规范日志输出;

  • 使用明确的镜像版本。

多阶段构建的核心价值,并不只是缩小镜像体积,而是把"如何构建应用"和"如何运行应用"分离开来。

构建阶段可以安装编译工具、下载依赖和生成产物;运行阶段则只保留启动服务真正需要的内容。这样构建出的镜像通常更清晰,也更容易部署和维护。

相关推荐
风曦Kisaki1 小时前
Kubernetes(K8s)笔记Day07: StatefulSet 有状态控制器详解与使用案例
linux·docker·云原生·容器·kubernetes
WangChenGe1 小时前
Windows 10/11 上 Pyenv-win 完整安装教程
python
想会飞的蒲公英1 小时前
PyTorch中SGD 与 Momentum 从零理解:给最朴素的优化器加上“惯性“
人工智能·pytorch·python·深度学习·机器学习
张文君1 小时前
docker registry 删除镜像
运维·docker·容器
拿铁铁1 小时前
平板坡口机换碳刷时间参考手册
python·电脑
夜雨风云1 小时前
系统或软件的安全(Security)
安全·security·软件设计·软件架构设计·服务质量模型
略略略咯咯2 小时前
stream流浅拷贝
开发语言·python
vx-程序开发2 小时前
【java项目分享】springboot农产品销售平台14952
java·javascript·spring boot·python·eclipse·django·php
cuiyaonan20002 小时前
some weird issues regarding docker
运维·docker·容器