第23章:Python容器化与配置——同一镜像走遍开发测试生产
1. 项目背景
业务场景
食光集市订单服务的部署历史,是一部"环境不一致"的血泪史:
第一幕:开发小周在 macOS 上用pip install uvicorn==0.30.0一切正常。测试环境是 CentOS 7,Python 3.9 自带的pip版本太老,解析依赖链花了 6 分钟,最后报ERROR: No matching distribution found for uvicorn==0.30.0——CentOS 的 glibc 版本太老不支持。
第二幕:小周把database.yml数据库密码写进了配置文件然后提交到了 Git。运维在部署前手动替换密码,有次替换错了集成测试环境的密码,导致测试任务把生产库的订单表清空了——一条DELETE FROM orders WHERE status='cancelled'在 prod 库跑了 12 秒才被 DBA 发现。紧急恢复数据耗时 3 小时。
第三幕:UI 团队新来的设计师需要在本地跑起后端服务做联调。他按照 README 的步骤:装 Python 3.13 → 装 Postgres → 装 Redis → 配环境变量 → pip install → 数据库迁移。折腾了整整一上午,最后放弃了——“能不能给我一个 docker compose up 就全搞定的?”
CTO 说:“这是’在我机器上能跑’综合症的晚期症状。治疗方案是一个原则:同一镜像,走遍开发、测试、预发、生产四个环境。”
痛点
没有容器化和配置管理,团队会持续遭遇:
- 环境漂移:开发环境的
numpy==1.26,CI 镜像里是numpy==2.0——API 全变了,CI 红一片但本地绿一片。 - 配置散落:数据库密码、API 密钥散落在代码、环境变量、配置文件、启动脚本中——审计时不知道哪些机器上有哪些密钥。
- 依赖地狱:操作系统级依赖(
libpq-dev、gcc、openssl)和 Python 级依赖(psycopg2、cryptography)互相捆绑——换一台机器就要重新适配。 - 部署靠人脑:运维小李的"部署手册"是 38 条 Slack 历史消息的截图——新人入职根本部署不起来。
2. 项目设计
场景:测试库被清空的事故复盘会上,运维小李红着眼睛说"我明明替换的是 test 环境的密码"。大师把白板转过来,开始画图。
小胖(看着白板上的 Docker 架构图):“大师,Docker 我懂——就是把应用和依赖打包成一个镜像嘛。但我的困惑是——Dockerfile 怎么写才算’生产级’?我见过有人写FROM python:3.13,有人用python:3.13-slim,还有python:3.13-alpine——这三种有啥区别?”
小白:“我记得 alpine 镜像特别小,只有 8MB。但有人说它的 musl libc 兼容性不好——某些 Python C 扩展(如numpy、psycopg2)在 alpine 上需要编译,特别慢,而且运行时偶尔 segfault。这是真的吗?另外——Dockerfile 里的RUN、CMD、ENTRYPOINT的区别我永远记不住。”
大师:“两个问题分别涉及镜像选型和 Dockerfile 最佳实践——这是容器化的第一课。”
"基础镜像选择:
python:3.13(~900MB):完整 Debian,包含编译工具链。适合开发调试,不适合生产。python:3.13-slim(~150MB):精简版 Debian,只有运行时依赖。生产首选——体积适中,兼容性好。python:3.13-alpine(~50MB):基于 musl libc。体积最小,但 C 扩展需要从源码编译(apk add build-base耗时)。次选——适合纯 Python 项目。
选型结论:用-slim作为默认,体积和兼容性的最优平衡点。有特殊体积要求再考虑 alpine。"
"Dockerfile 指令区别:
RUN:在构建时执行命令(如pip install),结果固化在镜像层中。CMD:在容器启动时执行的默认命令,可被docker run ... command覆盖。ENTRYPOINT:容器的固定入口点,不会被docker run的参数覆盖(除非--entrypoint强改)。
组合推荐:ENTRYPOINT ["uvicorn"]+CMD ["src.main:app", "--host", "0.0.0.0", "--port", "8000"]——这样用户可以用docker run ... --port 9000只覆盖端口。"
技术映射:基础镜像选型 = 超市买半成品菜——slim 是净菜(洗好切好,直接下锅),alpine 是原材料(新鲜但需要自己处理)。RUN = 做菜工序(固化在菜谱里),CMD = 上菜方式(可以换成外卖盒)。
小胖:“那多阶段构建是干什么的?我看有人把编译步骤和运行步骤分开——镜像从 800MB 减到 150MB,这是怎么做到的?”
大师:“多阶段构建(multi-stage build)解决了’编译期依赖污染运行期镜像’的问题。Python 项目的典型场景——你需要gcc、libpq-dev来编译psycopg2,但运行时不需要这些编译工具。那就分两个阶段:”
# 阶段 1:构建阶段(大而全) FROM python:3.13-slim AS builder RUN apt-get update && apt-get install -y gcc libpq-dev COPY requirements.txt . RUN pip install --user -r requirements.txt # 阶段 2:运行阶段(只复制构建产物) FROM python:3.13-slim COPY --from=builder /root/.local /root/.local COPY src/ /app/src/ ENV PATH=/root/.local/bin:$PATH CMD ["uvicorn", "src.main:app"]技术映射:多阶段构建 = 搬家策略——打包时用大货车把所有东西装上(builder 镜像),到新家只卸下生活必需品(runtime 镜像),货车不留下占地方。
小胖:“那配置管理呢?之前数据库密码泄露的事——怎么才能既让应用读到配置,又不把密码写进代码或镜像?”
小白:“12-Factor App 原则里有一条——‘配置与代码分离’。我觉得 Docker 的环境变量 +.env文件是一种方案,但环境变量也有一个问题——如果容器里有人能执行env命令,所有密码全暴露了。有没有更安全的方案?”
大师:"配置管理的三个层次——从基础到进阶:
Level 1:环境变量(基础):os.environ.get("DB_PASSWORD")——最简单的方案,但敏感值会出现在docker inspect和 crash dump 中。
Level 2:Docker Secrets(进阶):Swarm/K8s 模式下,secret 挂载为文件/run/secrets/db_password,应用读取文件内容——不在环境变量中暴露,不写入镜像层。
Level 3:Vault/AWS Secrets Manager(企业级):动态获取、自动轮换、审计日志。适合合规要求高的场景。
对于食光集市当前规模——Level 1 + pydantic-settings 管理(第 14 章已学)是最佳平衡点:开发用.env文件,CI 用 GitHub Secrets 注入环境变量,生产用 K8s ConfigMap/Secret。"
3. 项目实战:食光集市订单服务容器化
环境准备
| 依赖 | 版本 | 说明 |
|---|---|---|
| Python | 3.13.14 | 基准版本 |
| Docker | 26+ | 容器运行时 |
| Docker Compose | v2 | 多容器编排 |
mkdirfoodmarket-ch23&&cdfoodmarket-ch23 python-mvenv .venv .venv\Scripts\activate分步实现
步骤1:编写生产级 Dockerfile
Dockerfile:
# ── 构建阶段:安装依赖 ── FROM python:3.13-slim AS builder WORKDIR /build COPY requirements.txt . RUN pip install --user --no-cache-dir -r requirements.txt # ── 运行阶段:最小化镜像 ── FROM python:3.13-slim # 创建非 root 用户 RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app # 从构建阶段复制已编译的依赖 COPY --from=builder /root/.local /home/appuser/.local # 复制应用代码 COPY src/ /app/src/ COPY pyproject.toml /app/ # 环境变量 ENV PATH=/home/appuser/.local/bin:$PATH ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 # 切换到非 root 用户 USER appuser # 健康检查 HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" EXPOSE 8000 CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]requirements.txt:
fastapi==0.115.0 uvicorn==0.34.0src/main.py:
"""FastAPI 最小服务——用于 Docker 演示"""importosfromfastapiimportFastAPI app=FastAPI(title="食光集市订单服务")@app.get("/health")asyncdefhealth():return{"status":"healthy","env":os.getenv("APP_ENV","unknown"),"host":os.getenv("HOSTNAME","unknown"),}步骤2:编写 Docker Compose(API + Postgres + Redis)
compose.yml:
services:api:build:.ports:-"8000:8000"environment:-APP_ENV=development-DB_HOST=postgres-DB_PORT=5432-DB_NAME=foodmarket-DB_USER=fmuser-DB_PASSWORD=fmpass-REDIS_URL=redis://redis:6379/0depends_on:postgres:condition:service_healthyredis:condition:service_healthyhealthcheck:test:["CMD","python","-c","import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]interval:15stimeout:5sretries:3postgres:image:postgres:16-alpineenvironment:-POSTGRES_DB=foodmarket-POSTGRES_USER=fmuser-POSTGRES_PASSWORD=fmpassvolumes:-pgdata:/var/lib/postgresql/datahealthcheck:test:["CMD-SHELL","pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]interval:10stimeout:5sretries:5redis:image:redis:7-alpinevolumes:-redisdata:/datahealthcheck:test:["CMD","redis-cli","ping"]interval:5stimeout:3sretries:5volumes:pgdata:redisdata:步骤3:实现 pydantic-settings 配置管理
src/config.py:
"""配置管理——pydantic-settings 从环境变量 / .env 读取"""frompydantic_settingsimportBaseSettings,SettingsConfigDictclassSettings(BaseSettings):model_config=SettingsConfigDict(env_file=".env",env_file_encoding="utf-8",case_sensitive=False,)# 应用app_env:str="development"app_port:int=8000# 数据库db_host:str="localhost"db_port:int=5432db_name:str="foodmarket"db_user:str="fmuser"db_password:str=""@propertydefdatabase_url(self)->str:returnf"postgresql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}"# Redisredis_url:str="redis://localhost:6379/0"settings=Settings().env.example(提交到 Git):
APP_ENV=development DB_HOST=localhost DB_PORT=5432 DB_NAME=foodmarket DB_USER=fmuser DB_PASSWORD=change_me REDIS_URL=redis://localhost:6379/0步骤4:编写测试
tests/test_config.py:
importosimportpytestfromsrc.configimportSettingsdeftest_defaults():"""默认值测试"""settings=Settings()assertsettings.app_env=="development"assertsettings.db_port==5432deftest_from_environment(monkeypatch):"""环境变量覆盖测试"""monkeypatch.setenv("APP_ENV","production")monkeypatch.setenv("DB_HOST","prod-db.internal")settings=Settings()assertsettings.app_env=="production"assertsettings.db_host=="prod-db.internal"deftest_database_url_property():settings=Settings(db_user="admin",db_password="secret",db_host="10.0.0.1",db_port=5432,db_name="foodmarket",)expected="postgresql://admin:secret@10.0.0.1:5432/foodmarket"assertsettings.database_url==expected运行:
pipinstallpydantic-settingsdockercompose up-d# 启动全套服务curlhttp://localhost:8000/health# 验证python-mpytest tests/-v# 运行测试dockercompose down# 清理完整代码清单
foodmarket-ch23/ ├── Dockerfile ├── compose.yml ├── .env.example ├── requirements.txt ├── pyproject.toml ├── src/ │ ├── main.py │ └── config.py ├── tests/ │ └── test_config.py4. 项目总结
优点 & 缺点
| 维度 | Docker Compose | 纯 venv 本地跑 | K8s | 无容器手动部署 |
|---|---|---|---|---|
| 环境一致性 | ★★★★★ | ★★★ | ★★★★★ | ★ |
| 启动速度 | ★★★★ | ★★★★★ | ★★★ | ★★ |
| 学习曲线 | ★★★★ | ★★★★★ | ★★ | ★★★★ |
| 资源开销 | ★★★ | ★★★★★ | ★★ | ★★★★★ |
| 适合团队规模 | 小-中 | 个人 | 中-大 | 不推荐 |
适用场景
- 开发环境一键启动:
docker compose up——新人 5 分钟跑起全套服务。 - CI/CD 中跑集成测试:在 GitHub Actions 中用
docker compose起 Postgres + Redis + API 跑全链路测试。 - 微服务编排:每个服务独立容器,Compose 管理依赖关系和网络。
- 跨平台交付:同一个镜像在 macOS、Windows、Linux 上行为一致。
- 生产部署的蓝本:Compose 文件可直接转换为 K8s YAML 或 Helm Chart。
不适用场景
- 极简单文件脚本:一个
.py文件跑定时任务,不需要容器化。 - 需要 GUI 的应用:Docker 不擅长图形界面应用。
注意事项
- 镜像层的顺序很重要:把不常变的命令放前面(如
apt-get install),常变的放后面(如COPY src/)——最大化利用 Docker 缓存。 .dockerignore是必须的:至少排除.venv/、__pycache__/、.git/、*.pyc,否则上下文大小爆炸。- 不要在 Dockerfile 里写
latest标签:FROM python:3.13而非FROM python:latest——避免某天拉到一个不兼容的新版本。 secrets不写入镜像:COPY .env /app/.env会把密码固化在镜像层中,用docker history可以查看。- 生产环境不要用
--reload:uvicorn 的 reload 模式会 fork 子进程,在容器内管理复杂且不安全。
常见踩坑经验
故障案例1:容器内 Python 找不到已安装的包
现象:docker build成功,docker run后ModuleNotFoundError: No module named 'uvicorn'。
根因:Dockerfile 中pip install用的是 root 用户,安装到/root/.local;运行时切换了USER appuser,PATH 中没有那个路径。
修复:ENV PATH=/home/appuser/.local/bin:$PATH并确保pip install --user安装到 appuser 的 home 目录,或用多阶段构建从 builder 的/root/.local复制。
故障案例2:Windows 换行符导致容器内脚本exec format error
现象:一个entrypoint.sh脚本在 Windows 上编辑后挂载到容器,报exec format error。
根因:Windows 的 CRLF 换行符在 Linux 容器内#!/bin/sh不识别。
修复:.gitattributes中设置*.sh text eol=lf,或在 Linux CI 中跑dos2unix。
故障案例3:docker compose up后 API 启动失败因为数据库还没就绪
现象:API 容器先于 Postgres 容器完全启动,数据库连接失败。
根因:depends_on只等容器启动(进程存在),不等服务就绪(端口可接受连接)。
修复:在compose.yml加healthcheck,depends_on加condition: service_healthy(Compose v2.1+);或在应用入口脚本中加入 wait-for-it 逻辑。
思考题
CMD的两种写法——CMD ["uvicorn", "src.main:app"](exec 形式)和CMD uvicorn src.main:app(shell 形式)——在信号处理上有什么区别?哪种更适合生产环境?Docker Compose 的环境变量优先级:
environment字段、.env文件、shell环境变量——三者同时存在时,哪个值最终生效?
答案见中级篇综合实战章附录。
延伸阅读与资源
Python 3实战精进:从脚本到高并发订单引擎
MongoDB 实战进阶与内核修炼
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析
