🏠 总目录📚 本教程 14 · 容器化与部署
📑 本页目录(点开跳转)

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.txtpip installCOPY . . 只有最后一层失效,依赖层原样命中缓存

排序法则: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 · 推理服务化 如果你部署的不是应用而是模型本身,容器里的东西完全不同(引擎、显存、批处理),去那边

✅ 检查点

  1. 「在我机器上能跑」的三种具体死法是什么?为什么大小写敏感那条特别阴?
  2. requirements.txt 和容器各自钉住了什么?容器最被低估的收益是什么?
  3. 层缓存的排序法则是什么?COPY . . 写在 pip install 之前会怎样?
  4. 多阶段构建把什么留在了上一阶段?为什么不该拿「镜像小了多少」当指标?
  5. 没有 .dockerignore 会把哪两样东西打进镜像?后果为什么是复合的?
  6. compose 里 depends_on 不写 condition: service_healthy 会产生什么样的 bug?
  7. 三种部署形态该怎么选?哪四条是「该离开 PaaS」的信号?哪两条不是?
  8. 反向代理的三个坑分别是什么?为什么在本地全都发现不了?超时太短最坏的形态是什么样?
  9. /healthz/readyz 的失败处理有什么相反之处?把数据库检查写进 /healthz 会引发什么?
  10. 优雅关闭的四步顺序是什么?宽限期该怎么定?正在流式的请求有哪三种处置?
👀 答案
  1. 语言版本不一样;② 系统库不一样libpq/libgomp 你机器上早有,干净服务器上没有);③ 看不见的环境(时区、locale、工作目录、文件系统大小写敏感)。第三条阴在 Windows/macOS 默认不敏感、Linux 敏感 —— import Utils 能本地跑三个月,上线第一次才炸。
  2. requirements.txt 只钉住「装了哪些 Python 包」,容器钉住除了内核以外的一整台机器。最被低估的收益是 ⭐ 「回得去」:回滚 = 换回上一个 tag;没有它时回滚等于「revert 再构建一次,祈祷这次和上次一样」。
  3. 按「多久变一次」从上到下排,越少变的越靠上:系统包 → 语言依赖 → 静态资源 → 业务代码。写反了,改一行代码就让 COPY 那层失效,后面的 pip install 跟着重跑,依赖全部重装。(顺带:Dockerfile 只认独占一行的注释。)
  4. 编译器、pip 缓存、测试依赖、devDependencies 这些只在构建时需要的东西。体积不能当指标是因为它取决于你装了什么(装了 torch 就小不下来);⭐ 该盯「改一行代码到重新部署完要多久」
  5. .env 和整个 .git含历史里那把你以为删掉的旧 key)。复合在于它们会跟着镜像被推到镜像仓库里
  6. depends_on 默认只等「容器启动了」,而 Postgres 还要几秒才能接受连接 —— 得到一个只在冷启动出现、重跑一次就好的连接失败,这种 bug 最难被当回事。
  7. 默认 PaaS;服务变多、要精细控资源时上云容器服务;VPS 只在成本极敏感或必须自控时选(⚠️ 代价是兼职做运维)。四条信号:溢价超过请人管编排的成本 / 需要 GPU / 合规要求区域 / 内部服务多到平台网络模型套不进来。不是信号:「显得不专业」「反正以后总要上 K8s」。
  8. 响应被缓冲超时太短上传 413。本地发现不了是因为本地根本没有代理这一层,而且本地问的都是短问题、传的都是小文件。最坏形态:代理 60 秒切断而后端还在继续生成 —— 用户看到半截、token 照花,应用日志却写着「成功,耗时 95 秒」。
  9. /healthz 失败 = 重启我;/readyz 失败 = 别派活但别重启我。 把数据库检查写进 /healthz,数据库抖 5 秒会让编排器杀掉全部实例,小故障放大成全站故障。另外绝不在健康检查里调模型 API —— 每 10 秒一次就是一天 8640 次
  10. ① 先摘流量 → ② 停止接新连接 → ③ 等已有请求做完 → ④ 超宽限期强杀。「先摘流量」在最前,否则永远等不完;⚠️ 框架做不到,因为进程收到 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

打卡记录保存在你的浏览器里,首页能看到总进度