AI 应用上线实战:从本地脚本到 Docker 容器化(密钥、健康检查、镜像瘦身)
LLM 系列又一篇。垂类 Agent 篇讲了系统架构,Harness 篇讲了开发环境------这篇讲最后一公里:上线。本地跑得好好的 AI 应用,一进容器就环境崩、密钥泄漏、启动超时,这篇把链路走通。
一、AI 应用上线为什么有自己的坑
把 AI 应用从本地脚本变成生产服务,三个坑等着你:
- 环境地狱:本地 Python 3.11 + 特定版本依赖,换台机器就崩------「在我电脑上是好的」的容器版
- 密钥泄漏:API Key 写死在代码里提交到仓库,或者被打进镜像层(安全篇的信任边界,上线时是最容易失守的一环)
- 启动超时:LLM 应用启动要预热(加载配置、建向量索引、测 API 连通),健康检查等不及就反复重启
解法是一套标准的容器化流程:多阶段 Dockerfile → 密钥走环境变量 → 健康检查留宽限 → compose 编排。
二、Dockerfile:多阶段构建
单阶段构建会把开发依赖、测试文件全塞进最终镜像。多阶段的核心思路:构建环境和运行环境分离:
dockerfile
# 阶段 1:依赖安装(利用层缓存,代码改动不重装依赖)
FROM python:3.12-slim AS deps
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
# 阶段 2:最终运行镜像
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libgomp1 && rm -rf /var/lib/apt/lists/* # 只装运行时必需
COPY --from=deps /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
WORKDIR /app
COPY src/ ./src/
RUN useradd -m -u 1000 aiuser && chown -R aiuser /app
USER aiuser # 别用 root 跑(安全篇老话)
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
三个关键细节:
requirements.txt先 COPY、代码后 COPY------依赖层缓存,改代码不重装依赖,构建从几分钟降到几秒- slim 基础镜像 :从完整
python:3.12换3.12-slim,体积直接减七成 - 非 root 用户跑:容器逃逸的最后一道防线
三、镜像瘦身:.dockerignore 是免费的
.dockerignore 写严格,构建上下文瘦下来(这些进上下文会被永久固化进镜像层):
.git
.venv
__pycache__
*.md
tests/
*.env # 关键:env 文件绝不进构建上下文
.claude/
*.env 必须在 .dockerignore 里 ------密钥文件一旦进了构建上下文,即使后面删掉,镜像层里还在。API 应用的镜像应该很小(几百 MB),如果你们的镜像上了 GB,先看 .dockerignore。
四、密钥管理:环境变量,绝不写进镜像
安全篇讲过密钥是 P0 级------容器化后规则不变:
yaml
# docker-compose.yml
services:
app:
build: .
env_file: .env # 密钥走 env 文件,不进镜像
environment:
- LLM_BASE_URL=${LLM_BASE_URL}
- LLM_API_KEY=${LLM_API_KEY} # 运行时注入,镜像里只有变量名
ports:
- "8000:8000"
python
# 应用代码:启动时校验,缺了就 fast-fail
import os
def require_env(key: str) -> str:
v = os.environ.get(key)
if not v:
raise RuntimeError(f"缺少环境变量 {key},检查 .env") # 启动即失败,别等第一个请求
return v
LLM_API_KEY = require_env("LLM_API_KEY")
启动时校验密钥,别等第一个请求才发现------配置错误在启动时暴露,比线上第一个请求 500 便宜得多(Harness 篇的 fast-fail 老话)。
五、健康检查:给启动留宽限
LLM 应用启动慢(预热连接、建索引),默认健康检查等不及就反复杀------冷启动误杀:
yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 12 # 12 × 10s = 120s 启动宽限
start_period: 60s # 启动初期不计失败次数
应用侧配一个轻量 /health:
python
@app.get("/health")
def health():
return {"ok": True} # 只查进程活着,别在 health 里调 LLM(会连锁超时)
/health 只查进程活,/ready 才查依赖通------两个探针分开,health 里调 LLM API 是常见坑:API 一慢,健康检查连锁超时,服务被杀,重启,更慢。
六、compose 编排:应用 + 向量库一把起
知识库问答篇的系统要带向量库,compose 一把编排:
yaml
services:
app:
build: .
env_file: .env
depends_on:
vectordb:
condition: service_healthy # 等向量库就绪再起应用
ports: ["8000:8000"]
vectordb:
image: pgvector/pgvector:pg17
environment:
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data # 数据落盘,容器重建不丢索引
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 10
volumes:
pgdata:
两个细节:depends_on 配 condition: service_healthy (等就绪不是等启动)、数据卷落盘(容器重建不丢向量索引------重建一次索引是分钟级,别让容器重启给清了)。
七、上线后的可观测
可观测性篇的三件套原样挂上:token 追踪 (每次调用记 prompt/completion tokens)、延迟分位数 (p95 而不是平均值)、错误分类 (限流/超时/内容拒绝分开计数)。容器化后加一条:日志打到 stdout ,交给 docker logs / 采集器------别在容器里自己写日志文件(容器一删就没了)。
八、踩坑提醒
- 密钥进了镜像层 :COPY 过的文件即使后删也留在层里------env 文件进
.dockerignore,密钥只走运行时注入 - healthcheck 里调 LLM:健康检查连锁超时 → 服务被杀重启循环------health 只查进程,ready 才查依赖
- 没留启动宽限 :LLM 应用预热慢,默认健康检查会冷启动误杀------
start_period+ 多 retries - 向量库数据没落盘:容器重建索引全丢------volume 挂载,重建是分钟级成本
- 依赖没分层缓存 :代码改一个字全量重装依赖------
requirements.txt先 COPY
总结
| 环节 | 一句话 |
|---|---|
| Dockerfile | 多阶段构建,依赖层缓存,非 root 跑 |
| 瘦身 | slim 镜像 + 严格 .dockerignore,env 文件绝不进上下文 |
| 密钥 | 环境变量运行时注入,启动时校验 fast-fail |
| 健康检查 | start_period 留宽限,health 只查进程不查依赖 |
| 编排 | depends_on 等就绪,数据卷落盘 |
| 可观测 | 日志打 stdout,token/延迟/错误三件套照挂 |
上线是系列的最后一公里:垂类 Agent 的架构、可观测性的三件套、安全篇的密钥边界、Harness 的 fast-fail,全在容器化这一步汇合。本地能跑只是及格线,容器里稳了才算上线。 觉得有用点个关注。