知识点思维导图
21 个知识节点
参考资料
Python(32) - 打包与部署
读完后,你应能完成以下任务:
- 绘制“Python(32) - 打包与部署 / 先建立直觉:Docker 镜像 ≈ 把「node + node_modules + 你的代码」连同操作系统一起打成一个能到处跑的盒子”的关键对象与数据流,解释“前端的 dist/ 是「产物」,Docker 镜像是「产物 + 环境」。 -> Python 比前端更需要 Docker。”,并用源码位置、日志或 Trace 标注证据。
- 为“Python(32) - 打包与部署 / Dockerfile:构建镜像的配方(核心,必须会读会写)”设计正常与异常输入,验证“Dockerfile 是一行行的「指令」,从上到下执行,每一行大致生成镜像的一层(layer)。”,输出首个偏差位置与回归测试结果。
- 实现“Python(32) - 打包与部署 / .dockerignore:别把垃圾拷进镜像(≈ .gitignore / .npmignore)”的最小代码或配置,检验“核心坑(前端直觉会害你):前端可能习惯「node_modules 拷不拷无所谓反正会被覆盖」,但在 Python 里绝不能把本地 .venv/ 拷进容器。”,输出命令、结果与 Diff,并说明不适用边界。
前端部署你早就熟了:
npm run build出一堆静态文件丢 CDN,或者node server.js起个进程。但 Python 后端部署的真正难题不是「怎么起进程」,而是「我本机跑得好好的,到了服务器为啥跑不起来」——解释器版本不对、系统少装了个libpq、第三方包版本飘了。这篇把解决这件事的两件核心武器讲透:Docker(把整台机器连环境一起打包)和环境变量(让同一份代码在开发/生产跑出不同配置,且不把密钥写死进代码)。
一、先建立直觉:Docker 镜像 ≈ 把「node + node_modules + 你的代码」连同操作系统一起打成一个能到处跑的盒子
类比:前端部署 node 服务时,你最怕的是「我本地 Node 18,服务器 Node 14,跑挂了」。你解决它的方式通常是 .nvmrc 锁版本、package-lock.json 锁依赖。Docker 把这件事做到极致——它不只锁 Node 版本,而是把整个运行环境(操作系统层 + 解释器 + 系统库 + 你装的包 + 你的代码)打成一个不可变的镜像(image),这个镜像在你笔记本、同事电脑、生产服务器上跑出来的行为逐字节一致。
镜像(image) ≈ 一份打好包的「快照」,类似 npm publish 出去的那个 tarball,但里面连 OS 和运行时都有
容器(container) ≈ 用镜像跑起来的一个实例进程,类似 node server.js 起来的那个进程
Dockerfile ≈ 构建镜像的「配方」,角色类似 package.json 的 scripts.build + 一份装机说明
边界(这里和前端心智不一样):
- 前端的
dist/是「产物」,Docker 镜像是「产物 + 环境」。npm run build出来的静态文件还得依赖目标机器上有对的 Node;Docker 镜像自带运行时,目标机器只要装了 Docker 就行,不关心它本身是什么系统。 - Python 比前端更需要 Docker。前端最终产物常常是纯静态文件(浏览器自带运行时),而 Python 后端永远需要一个对的解释器 + 一堆可能依赖系统底层库的包(比如
psycopg2要libpq、Pillow要图像库)。在不同机器手动凑齐这些极易翻车,所以 Python 后端部署几乎默认上 Docker。
二、Dockerfile:构建镜像的配方(核心,必须会读会写)
Dockerfile 是一行行的「指令」,从上到下执行,每一行大致生成镜像的一层(layer)。下面是一个 FastAPI 项目最典型的 Dockerfile,逐行注释:
# 1. 选基础镜像:相当于「先装一台预装了 Python 3.12 的精简 Linux」
# -slim 是瘦身版(只留运行 Python 必需的系统库),镜像更小;别用默认的胖版
FROM python:3.12-slim
# 2. 设定容器内的工作目录,之后的 COPY / RUN 都以它为当前目录
# 类比:相当于 cd /app,不存在会自动创建
WORKDIR /app
# 3. 关键优化:先单独 COPY 依赖清单,再装包,最后才 COPY 业务代码
# WHY:Docker 按层缓存,只要 requirements.txt 没变,pip install 这一层就直接命中缓存,
# 不必每次改业务代码都重装全部依赖(和前端「先 COPY package.json 再 npm ci」同一招)
COPY requirements.txt .
# 4. 装依赖。--no-cache-dir 让 pip 不在镜像里留下载缓存,进一步缩小镜像体积
RUN pip install --no-cache-dir -r requirements.txt
# 5. 现在才把业务代码拷进去(这一层变动频繁,放最后才不破坏上面的依赖缓存)
COPY . .
# 6. 声明容器对外暴露的端口(文档性质,真正映射靠 docker run -p)
EXPOSE 8000
# 7. 容器启动时执行的命令:用 uvicorn 起 FastAPI
# --host 0.0.0.0 是必须的——容器里只听 127.0.0.1 的话,宿主机根本连不进来(常见坑,见第六节)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
并排对照一份前端 node 服务的 Dockerfile,你会发现结构几乎一模一样:
# ——— 前端/node 服务版,结构同构,方便你对照记忆 ———
FROM node:20-slim
WORKDIR /app
COPY package*.json ./ # 先拷依赖清单(对应上面 COPY requirements.txt)
RUN npm ci # 装依赖(对应 pip install)
COPY . . # 再拷业务代码
EXPOSE 3000
CMD ["node", "server.js"] # 启动命令(对应 uvicorn ...)
| 概念 | node / 前端 | Python |
|---|---|---|
| 基础运行时镜像 | FROM node:20-slim |
FROM python:3.12-slim |
| 依赖清单 | package.json / package-lock.json |
requirements.txt(或 pyproject.toml) |
| 装依赖命令 | npm ci |
pip install -r requirements.txt |
| 启动命令 | node server.js |
uvicorn app.main:app ... |
| 忽略文件 | .dockerignore / .npmignore |
.dockerignore |
app.main:app的含义:app/main.py这个文件里那个名为app的 FastAPI 实例(app = FastAPI())。冒号左边是模块路径,右边是变量名——这套「模块:变量」寻址在 FastAPI 篇(第 14 篇)已出现过。
三、.dockerignore:别把垃圾拷进镜像(≈ .gitignore / .npmignore)
COPY . . 会把当前目录全部拷进镜像。你绝对不想把本地虚拟环境 .venv/、__pycache__/、.git/ 这些拷进去——既撑大镜像,又可能把本地环境的脏东西带进容器。
# .dockerignore —— 语法和 .gitignore 完全一样
.venv/ # 本地虚拟环境,容器内会用 pip 重新装,绝不能拷(对应 node_modules)
__pycache__/ # Python 字节码缓存,垃圾
*.pyc
.git/ # 版本历史,没必要进镜像
.env # 本地密钥文件!拷进镜像 = 密钥泄露,必须排除
*.md
tests/ # 测试代码一般不进生产镜像
核心坑(前端直觉会害你):前端可能习惯「node_modules 拷不拷无所谓反正会被覆盖」,但在 Python 里绝不能把本地 .venv/ 拷进容器。原因后面第六节细讲——一句话:虚拟环境里记的是你本机的绝对路径,拷进 Linux 容器直接失效。正确做法永远是容器内用 pip install 重装一份。
四、构建与运行:几条命令对照 npm
# 1. 构建镜像,-t 给镜像起名+打标签(≈ npm publish 前定个包名@版本)
# 最后那个 . 是「构建上下文」——告诉 Docker 拿当前目录的文件来构建
docker build -t my-api:1.0 .
# 2. 跑容器
# -p 8000:8000 把宿主机 8000 端口映射到容器内 8000(左宿主:右容器)
# -d 后台跑(detached),不占终端
# --name 给容器实例起名,方便后续 stop/logs
docker run -d -p 8000:8000 --name my-api my-api:1.0
# 3. 看日志(容器里 print / 日志都打到这),等价于看 pm2 logs
docker logs -f my-api
# 4. 停掉并删除容器实例(镜像还在,随时能再 run)
docker stop my-api && docker rm my-api
-p 8000:8000 这个映射是前端最容易忽略的点:容器有自己独立的网络命名空间,容器内的 8000 默认外面访问不到,必须显式用 -p 宿主端口:容器端口 打通,类似你给一台内网机器做端口转发。
五、环境变量:让同一份代码在开发/生产跑出不同配置,且不把密钥写死
这块和前端心智几乎可以无痛平移。前端你早就在用 .env + process.env(Vite 的 import.meta.env、CRA 的 process.env.REACT_APP_*)来区分开发/生产。Python 是同一套思路。
5.1 读环境变量:os.environ ≈ process.env
并排看 node:
// node 版,逻辑一一对应
const dbUrl = process.env.DATABASE_URL // 取不到是 undefined(node 不会像 Python 那样抛错)
const port = process.env.PORT ?? '8000' // 同样默认是字符串
const debug = process.env.DEBUG ?? 'false'
核心坑(必记):环境变量永远是字符串,这点和 node 一样,但 Python 新手更容易栽。os.getenv("DEBUG") 拿到的是字符串 "false",而非 Python 内置布尔常量 False——而非空字符串 "false" 在 if 里是「真」!
5.2 本地用 .env 文件:python-dotenv ≈ 前端的 dotenv
生产环境的变量由部署平台/容器注入(见 5.3),但本地开发总不能每次手敲 export。前端用 .env 文件,Python 用 python-dotenv 库做同样的事:
pip install python-dotenv
# .env 文件(语法和前端的 .env 一样:KEY=VALUE,一行一个)
# ⚠️ 这个文件必须进 .gitignore 和 .dockerignore,里面是密钥!
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
OPENAI_API_KEY=sk-xxxxxxxx
DEBUG=true
5.3 进阶:用 Pydantic Settings 集中管理配置(推荐,FastAPI 生态标配)
散在各处的 os.getenv 容易漏校验、漏类型转换。FastAPI 生态推荐用 pydantic-settings,把所有配置收进一个带类型校验的类——类比前端把零散的 process.env.X 收进一个 config.ts 并用 zod 校验。
# 注意带上 [dotenv] 这个 extra:读取 .env 文件的能力依赖 python-dotenv,
# pydantic-settings 默认不装它,少了它下面的 env_file=".env" 会被静默忽略(.env 读不进来)
pip install "pydantic-settings[dotenv]"
对照前端用 zod 校验配置的写法,你会发现是同一个套路:
// 前端版:用 zod 把 process.env 校验成强类型 config
import { z } from "zod"
const Settings = z.object({
databaseUrl: z.string(), // 必填
port: z.coerce.number().default(8000), // 字符串自动转 number(对应上面 int 的自动转换)
debug: z.string().default("false").transform((v) => v === "true"), // 显式比对 "true":注意 zod 的 z.coerce.boolean() 会把字符串 "false" 也当成 true(同 5.1 那个坑),所以不能用它
})
const settings = Settings.parse({ // 校验失败直接抛,启动即暴露
databaseUrl: process.env.DATABASE_URL,
port: process.env.PORT,
debug: process.env.DEBUG,
})
5.4 把环境变量喂给容器:三种方式
镜像里绝不写死密钥(写死 = 谁拿到镜像谁拿到密钥)。运行时再注入:
# 方式一:-e 单个传(适合临时/少量变量)
docker run -d -p 8000:8000 -e DATABASE_URL="postgresql://..." -e DEBUG=false my-api:1.0
# 方式二:--env-file 整个文件传(适合多变量,注意这个文件别进 git)
docker run -d -p 8000:8000 --env-file .env.production my-api:1.0
# 方式三:docker-compose.yml(多容器编排时最常用,配置写成声明式文件)
services:
api: # 服务名,相当于一个容器的逻辑名字
build: . # 用当前目录的 Dockerfile 构建(也可换成 image: 直接用现成镜像)
ports:
- "8000:8000" # 端口映射,等价于 docker run -p 8000:8000
env_file:
- .env.production # 从文件注入环境变量
environment:
- DEBUG=false # 也可在这里直接写单个变量(会覆盖 env_file 同名项)
docker-compose 的角色 ≈ 前端的 docker-compose/pm2 ecosystem 配置:把「跑哪些服务、各自端口/环境/依赖关系」写成一份可提交的声明式文件,一句 docker compose up -d 全拉起来,不用记一长串 docker run 参数。
六、前端新手最容易踩的 5 个坑
| 坑 | 现象 | 正确做法 |
|---|---|---|
.venv/ 被拷进镜像 |
容器内报「找不到 python / 包路径错乱」 | .venv 写进 .dockerignore,容器内 pip install 重装 |
uvicorn 没加 --host 0.0.0.0 |
容器跑着,宿主机 curl 却连不上 |
启动命令必须 --host 0.0.0.0,监听所有网卡 |
忘了 -p 端口映射 |
容器正常,外面就是访问不到 | docker run -p 宿主:容器,否则容器网络与外界隔离 |
| 把环境变量当布尔用 | DEBUG=false 却仍进了 debug 分支 |
显式 .lower() == "true",或用 pydantic-settings 自动转 bool |
| 密钥写死进代码/镜像 | 镜像泄露即密钥泄露 | .env 进 .gitignore+.dockerignore,运行时再注入 |
重点展开第一个坑(最反前端直觉):.venv 本质是一堆指向你本机绝对路径的软链和脚本(比如硬编码了 /Users/imber/.../python)。拷进一台 Linux 容器后,这些路径根本不存在,环境直接废掉。这和 node_modules 不同——node_modules 里多是平台无关的 JS 源码,跨机器拷常常还能用(原生模块除外),所以前端容易误以为「环境目录拷过去就能跑」。Python 必须在目标环境里用 pip install 现装。
七、总结
- 先建立直觉:Docker 镜像 ≈ 把「node + node_modules + 你的代码」连同操作系统一起打成一个能到处跑的盒子:前端的 dist/ 是「产物」,Docker 镜像是「产物 + 环境」。 -> Python 比前端更需要 Docker。
- Dockerfile:构建镜像的配方(核心,必须会读会写):Dockerfile 是一行行的「指令」,从上到下执行,每一行大致生成镜像的一层(layer)。
- .dockerignore:别把垃圾拷进镜像(≈ .gitignore / .npmignore):核心坑(前端直觉会害你):前端可能习惯「node_modules 拷不拷无所谓反正会被覆盖」,但在 Python 里绝不能把本地 .venv/ 拷进容器。
- 构建与运行:几条命令对照 npm:-p 8000:8000 这个映射是前端最容易忽略的点:容器有自己独立的网络命名空间,容器内的 8000 默认外面访问不到,必须显式用 -p 宿主端口:容器端口 打通,类似你给一台内网机器做端口转发。
- 环境变量:让同一份代码在开发/生产跑出不同配置,且不把密钥写死:Python 是同一套思路。
- 前端新手最容易踩的 5 个坑:| uvicorn 没加 --host 0.0.0.0 | 容器跑着,宿主机 curl 却连不上 | 启动命令必须 --host 0.0.0.0,监听所有网卡 |
学完自测
选择所有正确答案;提交后逐项核对判断依据。