📑 本页目录(点开跳转)
14 · 容器化与部署
⏱ 58 分钟 | 📦 容器不是赶时髦,它是「在我机器上能跑」这句话的解药
🎯 一句话
把「一整台机器」和代码一起提交进版本库,然后让每个环境都跑同一份产物、只换环境变量。
上一章的结论是「三个环境只有配置不同,镜像和代码必须完全一样」。这一章就是把那个「镜像」变成一个真实存在的东西 —— 一个能打 tag、能回滚、能在任何地方原样跑起来的文件。
🧩 一、「在我机器上能跑」到底是什么病
这句话被当成段子太久了,以至于大家忘了它有非常具体的三种死法:
| 死法 | 长什么样 |
|---|---|
| 语言版本不一样 | 你的 3.11,服务器的 3.9。用了新语法就是当场 SyntaxError;没用新语法则更阴 —— 某个库的行为悄悄不同 |
| ⚠️ 系统库不一样 | 某个 pip 包依赖 libpq / libgomp。你机器上早有(因为你装过别的东西),干净的服务器上没有 → ImportError: libxxx.so.1: cannot open shared object file |
| 💀 看不见的环境 | 环境变量、时区、locale、工作目录,以及文件系统大不大小写敏感 —— Windows / macOS 默认不敏感,Linux 敏感,于是 import Utils 本地跑了三个月,上线第一次 ModuleNotFoundError |
⭐ 一句话说清容器解决什么:
requirements.txt只钉住了「装了哪些 Python 包」, 容器钉住的是除了内核以外的一整台机器 —— 系统、系统库、语言运行时、文件布局、启动命令,全在里面。
⭐ 最被低估的那个收益不是「跑得起来」,是「回得去」。 镜像有 tag,部署就是「用哪个 tag」,回滚就是「换回上一个 tag」。没有这个东西的时候,回滚等于「把代码 revert 回去再重新构建一次,祈祷这次构建出来的和上次一样」。
⚠️ 容器不解决的事,别指望它:① 它不管你连的那个云数据库是不是同一个;② GPU 驱动 / CUDA 是宿主机的事,容器里那套要和宿主机对得上;③ 它不是安全隔离边界 —— 和虚拟机不是一个量级,别拿它来跑不信任的代码。
📦 二、Dockerfile:形状比语法重要
先说形状:一个 Dockerfile 就是「从一个干净的系统开始,一步步把你的东西装进去」的脚本,每一步产生一层,层会被缓存。 两个决定性设计全都来自这一句:层缓存和多阶段。
# syntax=docker/dockerfile:1
# ── 第一阶段:装依赖。编译器、pip 缓存都留在这一阶段,最后不带走
FROM python:3.11-slim AS builder
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# ⭐⭐ 先只拷依赖清单再安装 —— 层缓存的全部秘密就在这一行的【位置】
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ── 第二阶段:只留「跑起来需要」的东西
FROM python:3.11-slim
ENV PATH="/opt/venv/bin:$PATH"
# ⭐ 不缓冲 stdout,否则日志会攒着不出来,下一章的可观测性直接瞎掉
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# ⭐ 只搬走装好的包
COPY --from=builder /opt/venv /opt/venv
# ⭐⭐ 代码放最后:改一行代码,上面所有层全部命中缓存
COPY . .
RUN useradd -m app && chown -R app /app
# ⚠️ 默认是 root。真被打穿时,是 root 和不是 root 代价完全不同
USER app
EXPOSE 8000
CMD ["sh", "-c", "uvicorn app:app --host 0.0.0.0 --port ${PORT:-8000}"]
⚠️ 未实跑 —— 这一章的 Dockerfile / compose / Nginx 配置我没有 Docker 环境可以跑。 内容是按各自的语法规则写的、可以直接用,但你第一次构建时请自己看输出。
⚠️ Dockerfile 的注释只能独占一行。 COPY . . # 拷代码 里那个 # 不是注释,会被当成 COPY 的第三个参数。上面每个 ⭐ 都单独一行,就是这个原因。
⭐⭐ 层缓存:顺序写反,改一个字要重装全部依赖
规则只有一句:一层的缓存失效,它下面所有层的缓存全部作废。
| 你的写法 | 改一行业务代码之后会发生什么 |
|---|---|
COPY . . 写在 pip install 之前 |
⚠️ COPY 那层的内容变了 → 失效 → 后面的 pip install 跟着重跑,依赖全部重装 |
⭐ COPY requirements.txt → pip install → COPY . . |
只有最后一层失效,依赖层原样命中缓存 |
⭐ 排序法则:Dockerfile 从上到下,按「多久变一次」排 —— 越少变的放越上面。 系统包 → 语言依赖 → 静态资源 → 业务代码。业务代码永远在最后一行。
⚠️ 顺带一个必然踩的:依赖清单要钉版本(fastapi==0.115.0 而不是 fastapi)。不钉的话,同一份 Dockerfile 今天和下个月构建出来的是两个不同的镜像 —— 而「不可变镜像」的前提就是它真的不可变。
多阶段构建:把「构建时要的」和「运行时要的」分开
编译器、pip 缓存、测试依赖、node_modules 里的 devDependencies —— 只在构建时需要,运行时一点用没有,却会一直躺在镜像里。多阶段就是:第一个 FROM 里随便装,只把产物 COPY --from 到第二个 FROM。
⚠️ 别问「能小多少」 —— 完全取决于你装了什么:装了 torch 的镜像怎么分阶段也小不下来,纯 Web 应用则能砍掉一个量级。⭐ 真正该盯的不是镜像多大,是「改一行代码到重新部署完要多久」 —— 体积只是副产品,层缓存排对顺序才是主因。
⚠️ 别忘了 .dockerignore
.git
.env
.venv/
__pycache__/
*.pyc
node_modules/
💀 没有它的后果是复合的:COPY . . 会把 .env(本地那份密钥)和整个 .git(包括 git 历史里那把你以为已经删掉的旧 key)一起打进镜像。上一章那句「删掉代码里的 key 不等于删掉它」在这里第二次生效 —— 而且这次它会跟着镜像被推到镜像仓库里去。
🧱 三、docker-compose:本地开发的一整套
一个 AI 应用本地要起的东西通常是三个:应用、Postgres(第 6、7 章)、Redis(第 12 章的共享令牌桶)。compose 的价值是一条命令把三个都拉起来,并且它们之间用服务名互相找。
# docker-compose.yml —— 🗓️ 镜像版本号会变,按你实际用的钉
services:
app:
build: .
ports: ["8000:8000"]
environment:
# ⭐ 主机名就是服务名:容器之间不走 localhost
DATABASE_URL: postgresql://app:dev@db:5432/app
REDIS_URL: redis://cache:6379/0
LLM_API_KEY: ${LLM_API_KEY} # 从你 shell 的环境变量透进来,别写进这个文件
depends_on:
db: {condition: service_healthy} # ⭐ 等「能连了」,不是等「容器起来了」
cache: {condition: service_started}
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: dev
POSTGRES_DB: app
volumes: ["pgdata:/var/lib/postgresql/data"] # ⚠️ 没有它,容器一删数据就没了
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 10
cache:
image: redis:7
volumes:
pgdata:
⚠️ depends_on 默认只保证「容器启动了」,而 Postgres 从进程启动到能接受连接还有好几秒。不写 condition: service_healthy,你会得到一个只在冷启动时出现、重跑一次就好了的连接失败 —— 这种 bug 最难被当回事。
⭐ compose 是开发工具,不是生产编排。 它让新同事
git clone之后一条命令就能起全套(对照第 13 章:.env.example告诉他要填哪些变量,compose 告诉他要起哪些服务)。生产上数据库该用托管的,别自己在 compose 里跑一个没人备份的 Postgres。
⭐ 一个容器跑一个进程。 别在容器里起 supervisor 再拉四个 worker —— 信号、日志、健康检查、扩缩容全都以容器为单位,塞多个进程进去等于让这四样一起失真。要多副本就多起几个容器。
🛑 读到这里可以停 —— 前半章讲完了(约 21 分钟)。 后半章还有:部署形态三选:⭐ 先用 PaaS · 反向代理:三个坑,坑坑都在「本地正常」 · 健康检查:两个端点,问的是两个问题 · 优雅关闭:正在流式的那个请求怎么办 · 换个栈怎么对应 回来的时候不用重读,直接从下一节接着看就行。
🚀 四、部署形态三选:⭐ 先用 PaaS
| 形态 | 你要管什么 | 什么时候选 |
|---|---|---|
| ⭐ PaaS(连着仓库自动构建部署那类) | 只管代码和环境变量。构建、TLS 证书、域名、滚动更新、日志收集平台包了 | 默认选它。 一个人到小团队、一两个服务 |
| 云容器服务 / 编排平台 | 镜像仓库、编排配置、网络、扩缩容策略 | 服务变多、要精细控资源、公司已有这套和会用的人 |
| 自己的 VPS + Nginx | 系统更新、证书续期、日志轮转、备份、防火墙、监控…… | 成本极敏感,或必须完全自控 |
⚠️ 第三条的真实代价:你不是省了平台费,你是兼职做了运维 —— 证书忘续、磁盘被日志写满、内核安全更新没打,这些事在半夜发生的概率和你的用户数成正比。
⭐ 该离开 PaaS 的信号(出现任意一条再动):
① PaaS 的溢价已经超过「请人管一套编排」的成本
② 需要 GPU 或特殊硬件,平台不给
③ 合规要求数据待在指定区域 / 你自己的网络里
④ 内部服务多到平台的网络模型套不进来
⚠️ 这些不是信号:「PaaS 显得不专业」「反正以后总要上 K8s」。和上一章密钥三档一样 —— 早上一档,换来的是一个需要有人值班的新组件,它自己也会挂。
⭐ 三种形态唯一必须一致的东西是第 1 章那五条:常驻进程、单请求超时 ≥ 5 分钟、不缓冲响应体、能设环境变量、自带 HTTPS。Serverless 默认过不了前三条,AI 应用别去踩。
🔀 五、反向代理:三个坑,坑坑都在「本地正常」
不管哪种形态,你的应用前面几乎一定站着一层代理(平台的入口网关,或你自己的 Nginx)。它有自己的一套默认值,而那套默认值是给传统 Web 应用调的。
location /api/ {
proxy_pass http://app:8000;
proxy_http_version 1.1;
# ⭐⭐ 流式的命门:默认会攒够一批再转发
proxy_buffering off;
proxy_cache off;
# ⚠️ 默认 60 秒。一次长回答超过它,连接被【代理】切断,
# 后端日志里那次请求却是成功的 —— 两边都说自己没错
proxy_read_timeout 600s;
proxy_send_timeout 600s;
# 要上传 PDF 之类的(第 7 章入库),默认 1m 会顶到 413
client_max_body_size 50m;
# 下一章要用:把请求 id 一路带下去
proxy_set_header X-Request-ID $request_id;
}
| 坑 | 症状 | ⭐ 为什么本地发现不了 |
|---|---|---|
| 响应被缓冲 | 本地逐字蹦,线上憋十几秒一次性出现 | 本地根本没有代理这一层 |
| 超时太短 | 长回答固定在某个秒数被切断,短回答一切正常 | 本地开发问的都是短问题 |
| 上传大小限制 | 小文件能传,大文件 413 | 你测试用的是那个 3 页的 PDF |
⭐ 第一个坑第 1 章埋过伏笔:那里的 X-Accel-Buffering: no 是应用侧向代理喊的话,这里的 proxy_buffering off 是代理侧自己的开关。⚠️ 你只控制得了应用侧那句 —— 用托管入口时去文档确认它认不认这个头。
💀 超时那条最坏的形态:代理在 60 秒切断连接,但后端进程还在继续生成。用户看到半截回答,你的 token 照花,而应用日志里这次请求写着「成功,耗时 95 秒」。三方证据互相矛盾,查起来极慢 —— 所以代理超时必须大于你最长的一次生成,而不是反过来让生成去迁就它。
🩺 六、健康检查:两个端点,问的是两个问题
⭐⭐ /healthz(活着吗)和 /readyz(现在能接活吗)必须分开,因为它们的失败处理完全相反:前者失败 = 重启我,后者失败 = 先别给我派活,但别重启我。
# ⚠️ 未实跑:要真的起 uvicorn、并由平台按「先摘流量、再发 SIGTERM」的顺序动作才看得出效果
from fastapi import FastAPI
from fastapi.responses import JSONResponse
app = FastAPI()
STATE = {"ready": True}
@app.get("/healthz") # 「进程还活着吗」⚠️ 不查任何依赖
def healthz():
return {"ok": True}
@app.get("/readyz") # 「现在能接活吗」
def readyz():
if not STATE["ready"]:
return JSONResponse({"ready": False}, status_code=503)
# 真实版在这里 ping 一下数据库;⚠️ 但绝不要在这里调模型 API
return {"ready": True}
@app.post("/internal/drain") # ⭐ 关机前先被平台/preStop 钩子打一下,让流量先撤走
def drain():
STATE["ready"] = False
return {"draining": True}
💀 把数据库检查写进 /healthz 会自我放大:数据库抖了 5 秒,编排器判定所有实例都不健康、全部杀掉重启;数据库恢复了,你的服务却因为集体冷启动多挂了几分钟 —— 一次小故障被健康检查放大成一次全站故障。
⚠️ 绝不要在健康检查里调模型 API:每 10 秒探一次就是一天 8640 次(86400 ÷ 10),每个实例都这么干,而且供应商偶发抖动会直接把你判死。⚠️ /internal/drain 也必须挡在外网之外(第 8 章的鉴权,或只监听内网),否则任何人都能把你的实例一台台摘下线。
🚦 七、优雅关闭:正在流式的那个请求怎么办
发版、扩缩容、平台迁移,本质上都是同一件事:给你的进程发一个 SIGTERM,等一会儿,然后强杀。 正确顺序是四步:
① 先摘流量(/readyz 变 503,负载均衡器停止派新请求)
② 停止接受新连接
③ 等已经在跑的请求做完 ← 长回答就在这一步
④ 超过宽限期,强杀
⭐ 顺序里最关键的是 ①在②之前。反过来做,等待期间还有新请求进来,那就永远等不完。⚠️ 而框架自己做不到这一点 —— 进程收到 SIGTERM 时,负载均衡器还不知道。所以摘流量这一步要么由平台负责(PaaS 通常先从路由表摘掉再发信号),要么用编排器的 preStop 钩子先打一下上面那个 /internal/drain。你要做的是让 /readyz 反映真实状态,并给这个动作留几秒。
⚠️ 宽限期必须 ≥ 你最长的一次请求。 常见默认值在 30 秒量级(🗓️ 查你那层的配置),而一次长回答跑两分钟很正常 —— 不改的话,每次发版都会切断一批正在流式的用户,而且发版越频繁越明显。
正在流式的请求,三种处置:
| 做法 | 怎么做 | 代价 |
|---|---|---|
| ⭐ 等它做完 | 把宽限期调到大于最长生成时间,配合先摘流量 | 发版慢几分钟。默认选它 |
| 在流里通知 | 关机时往 SSE 里发一帧 {"type":"shutdown"},前端提示或自动重发 |
前端要写这段逻辑(第 10 章的分帧就是为这类事留的) |
| 转异步 | 长活本来就该走第 11 章的队列 | 连接断了任务还在,但产品形态变了 |
⚠️ 别指望「断了就重来」是免费的:那次生成已经花掉的 token 不会退,用户还看到了半截回答。这也是为什么第 11 章说「可能超过 30 秒的活别放在请求里」—— 请求活不过一次发版。
🔁 八、换个栈怎么对应
| 概念 | Python / FastAPI | Node(Express / Hono) | Go |
|---|---|---|---|
| 先拷的「依赖清单」 | requirements.txt |
package.json + lock 文件 |
go.mod + go.sum |
| 装依赖 | pip install -r |
npm ci |
go mod download |
| 多阶段的收益 | 中(去掉编译器和缓存) | 中(去掉 devDependencies) | ⭐ 最大:产物是单个二进制,运行阶段可以用几乎空的基础镜像 |
| 日志不缓冲 | PYTHONUNBUFFERED=1 |
默认就不缓冲 | 默认就不缓冲 |
| 优雅关闭 | uvicorn 处理 SIGTERM,lifespan 里收尾 |
server.close() + 自己监听 SIGTERM |
http.Server.Shutdown(ctx) |
| 健康检查 / 代理配置 | 三边完全一样 | 同 | 同 |
⭐ 这张表想说的:层缓存的排序法则、多阶段、
.dockerignore、两个健康检查端点、关机四步 —— 一条都不属于 Python。 会过期的是基础镜像的版本号和包管理器的名字。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 13 · 配置与密钥 | 「三个环境只有配置不同」那条结论在这一章才有了实体。⚠️ 而且 .env 会不会被 COPY . . 打进镜像,是那一章的密钥纪律在这一章的兑现处 |
| 01 · 第一天:两小时上线 | 挑平台那五条判据(常驻进程 / 超时 ≥ 5 分钟 / 不缓冲 / 能设环境变量 / 自带 HTTPS)就是本章三种形态的共同底线 |
| 模型上线之后 03 · 灰度、影子与回滚 | ⭐ 本章只让你能回滚(换回上一个 tag),什么时候该回滚、怎么灰度一部分流量、影子流量怎么跑在那边 |
| AI基础设施 20 · 推理服务化 | 如果你部署的不是应用而是模型本身,容器里的东西完全不同(引擎、显存、批处理),去那边 |
✅ 检查点
- 「在我机器上能跑」的三种具体死法是什么?为什么大小写敏感那条特别阴?
requirements.txt和容器各自钉住了什么?容器最被低估的收益是什么?- 层缓存的排序法则是什么?
COPY . .写在pip install之前会怎样? - 多阶段构建把什么留在了上一阶段?为什么不该拿「镜像小了多少」当指标?
- 没有
.dockerignore会把哪两样东西打进镜像?后果为什么是复合的? - compose 里
depends_on不写condition: service_healthy会产生什么样的 bug? - 三种部署形态该怎么选?哪四条是「该离开 PaaS」的信号?哪两条不是?
- 反向代理的三个坑分别是什么?为什么在本地全都发现不了?超时太短最坏的形态是什么样?
/healthz和/readyz的失败处理有什么相反之处?把数据库检查写进/healthz会引发什么?- 优雅关闭的四步顺序是什么?宽限期该怎么定?正在流式的请求有哪三种处置?
👀 答案
- ① 语言版本不一样;② 系统库不一样(
libpq/libgomp你机器上早有,干净服务器上没有);③ 看不见的环境(时区、locale、工作目录、文件系统大小写敏感)。第三条阴在 Windows/macOS 默认不敏感、Linux 敏感 ——import Utils能本地跑三个月,上线第一次才炸。 requirements.txt只钉住「装了哪些 Python 包」,容器钉住除了内核以外的一整台机器。最被低估的收益是 ⭐ 「回得去」:回滚 = 换回上一个 tag;没有它时回滚等于「revert 再构建一次,祈祷这次和上次一样」。- 按「多久变一次」从上到下排,越少变的越靠上:系统包 → 语言依赖 → 静态资源 → 业务代码。写反了,改一行代码就让
COPY那层失效,后面的pip install跟着重跑,依赖全部重装。(顺带:Dockerfile 只认独占一行的注释。) - 编译器、pip 缓存、测试依赖、devDependencies 这些只在构建时需要的东西。体积不能当指标是因为它取决于你装了什么(装了 torch 就小不下来);⭐ 该盯「改一行代码到重新部署完要多久」。
.env和整个.git(含历史里那把你以为删掉的旧 key)。复合在于它们会跟着镜像被推到镜像仓库里。depends_on默认只等「容器启动了」,而 Postgres 还要几秒才能接受连接 —— 得到一个只在冷启动出现、重跑一次就好的连接失败,这种 bug 最难被当回事。- 默认 PaaS;服务变多、要精细控资源时上云容器服务;VPS 只在成本极敏感或必须自控时选(⚠️ 代价是兼职做运维)。四条信号:溢价超过请人管编排的成本 / 需要 GPU / 合规要求区域 / 内部服务多到平台网络模型套不进来。不是信号:「显得不专业」「反正以后总要上 K8s」。
- 响应被缓冲、超时太短、上传 413。本地发现不了是因为本地根本没有代理这一层,而且本地问的都是短问题、传的都是小文件。最坏形态:代理 60 秒切断而后端还在继续生成 —— 用户看到半截、token 照花,应用日志却写着「成功,耗时 95 秒」。
/healthz失败 = 重启我;/readyz失败 = 别派活但别重启我。 把数据库检查写进/healthz,数据库抖 5 秒会让编排器杀掉全部实例,小故障放大成全站故障。另外绝不在健康检查里调模型 API —— 每 10 秒一次就是一天 8640 次。- ① 先摘流量 → ② 停止接新连接 → ③ 等已有请求做完 → ④ 超宽限期强杀。「先摘流量」在最前,否则永远等不完;⚠️ 框架做不到,因为进程收到 SIGTERM 时负载均衡器还不知道。宽限期 ≥ 最长的一次请求(默认常在 30 秒量级)。三种处置:⭐ 等它做完(默认)、在流里发一帧
shutdown、转成第 11 章的异步任务;⚠️ 已花的 token 不退。
🛑 可以停在这里
⚡ 走神救援
📦 容器是「在我机器上能跑」的解药,那句话有三种死法:语言版本不一样、系统库不一样(
libxxx.so.1: cannot open shared object file)、看不见的环境(时区、locale,以及 Windows/macOS 大小写不敏感而 Linux 敏感 ——import Utils本地跑三个月才炸)。⭐requirements.txt只钉住装了哪些包,容器钉住除了内核以外的一整台机器。 最被低估的收益不是「跑得起来」而是 ⭐「回得去」:回滚 = 换回上一个 tag。⚠️ 容器不解决:云服务不一致、GPU/CUDA(宿主机的事)、它不是安全隔离边界。 Dockerfile 两个决定性设计:⭐⭐ 层缓存 —— 一层失效,下面所有层全部作废,所以按「多久变一次」从上到下排,业务代码永远在最后一行;COPY . .写在pip install之前,改一行代码就要重装全部依赖。多阶段 —— 编译器、pip 缓存、devDependencies 留在第一阶段不带走;⚠️ 别拿镜像体积当指标(装 torch 就小不下来),⭐ 该盯「改一行到部署完要多久」。三个必踩细节:依赖要钉版本、⚠️ Dockerfile 注释只能独占一行、💀.dockerignore必须有(否则.env和整个.git、含历史里那把旧 key 会跟着镜像进仓库)。compose 把 app + Postgres + Redis 串起来、用服务名互相找;⚠️depends_on要写condition: service_healthy,否则会遇到那种只在冷启动出现、重跑一次就好的连接失败。⭐ compose 是开发工具不是生产编排;一个容器跑一个进程。 🚀 部署三选:⭐ 默认 PaaS;VPS 的代价不是省钱而是你兼职做了运维。四条离开信号:溢价超过请人管编排、需要 GPU、合规要求区域、内部服务太多;⚠️「显得不专业」「以后总要上 K8s」不是信号。🔀 反向代理三个坑全都本地正常:响应被缓冲(proxy_buffering off,本地没有代理这层)、超时太短(默认 60 秒;💀 最坏形态是代理切断而后端还在生成 —— 用户看半截、token 照花、应用日志却写「成功,耗时 95 秒」)、上传 413。⭐ 代理超时必须大于你最长的一次生成。 🩺 健康检查两个端点,失败处理相反:/healthz失败 = 重启我(⚠️ 所以不查任何依赖),/readyz失败 = 别派活但别重启。💀 把数据库检查写进/healthz,数据库抖 5 秒会让编排器杀光所有实例,小故障放大成全站故障;⚠️ 也绝不在健康检查里调模型 API(每 10 秒一次 = 一天 8640 次)。优雅关闭四步:先摘流量 → 停止接新连接 → 等已有请求做完 → 超宽限期强杀;「先摘流量」必须在最前否则永远等不完,而框架自己做不到(进程收到 SIGTERM 时负载均衡器还不知道)。⚠️ 宽限期必须 ≥ 最长请求(默认常在 30 秒量级,不改就是每次发版切断一批流式用户)。三种处置:⭐ 等它做完(默认)、在流里发一帧shutdown、转成第 11 章的异步任务 —— 而且已花的 token 不会退。
下一节 👉 15-可观测性.md