05. 容器化与部署:Docker、CI/CD、Cloud Run
读完这篇你能:
- 写一个生产级 Dockerfile,把 LLM 应用打包成镜像
- 用 docker-compose 在本地一键起 App + DB + Redis + MinIO
- 把镜像部署到 Cloud Run,绑定域名,配 HTTPS
- 配 GitHub Actions,push 即自动上线
对应架构层:部署层 + 接入层(基础)
前置知识:01-04 篇(应用代码)
预计用时:4 小时(含动手)
0. 为什么算法工程师要学这个
你的 LLM 应用在本机能跑,但老板说"明天上线"——你怎么把它送到一个真实的服务器上,让全球用户能访问?
你可能会想:"我把代码拷到服务器,装 Python,装依赖,跑 uvicorn 不就行?"
理论上对,实际上 99% 会出问题:
- 服务器是 Ubuntu,本机是 macOS,有些库行为不一样
- 服务器 Python 版本是 3.10,你本机是 3.11,语法不兼容
- 你装的某个包版本和服务器不一样,行为变了
- 你忘了把
.env文件拷过去 - 服务器没装 libpq-dev,psycopg2 装不上
- 你本机有 Postgres,服务器没有,要重新配
- ……
这就是 "在我机器上能跑" 问题——环境不一致。
Docker 解决这个。它把"代码 + 依赖 + 操作系统"打包成一个镜像(Image),在任何装 Docker 的机器上跑出来的行为一模一样。
算法工程师类比:Docker 像训练时的 requirements.txt + Docker 镜像 + 数据 checksum——保证别人复现你的结果。但 Docker 更彻底,连 OS 层都打包。
学完这一篇,你就具备了把代码送到生产的能力,这是算法工程师 → 工程师的分水岭。
1. 核心概念(最少必要理论)
1.1 容器化演进
物理机时代:一个服务器跑一个应用,资源浪费
↓
虚拟机时代(VMware、KVM):一台物理机切多个 VM,每个 VM 有完整 OS
- 隔离好,但重(每个 VM 几 GB)
- 启动慢(分钟级)
↓
容器时代(Docker):一台物理机跑多个容器,共享 OS 内核
- 轻量(一个容器几十 MB)
- 启动快(秒级)
- 可复现(镜像 = 二进制 artifact)
容器 vs 虚拟机(必考):
| 维度 | 虚拟机 | 容器 |
|---|---|---|
| 隔离级别 | 硬件级(每个 VM 一个完整 OS) | OS 级(共享内核,进程隔离) |
| 启动时间 | 分钟级 | 秒级 |
| 资源占用 | GB 级 | MB 级 |
| 隔离强度 | 强 | 弱(共享内核) |
| 用途 | 强隔离、异构 OS | 应用打包、微服务 |
1.2 Docker 核心概念
| 概念 | 类比 | 说明 |
|---|---|---|
| Dockerfile | 菜谱 | 文本文件,描述"怎么构建镜像" |
| Image(镜像) | 装好的菜 | 只读模板,包含代码 + 环境 |
| Container(容器) | 端上桌的菜 | 镜像运行起来的实例,可启停 |
| Registry(仓库) | 菜市场 | 存镜像的地方(Docker Hub、GHCR、ECR) |
| Volume(卷) | 储藏室 | 持久化数据(容器删了还在) |
| Network(网络) | 餐桌 | 容器之间怎么通信 |
核心命令(必背):
docker build -t myapp:latest . # 根据当前目录的 Dockerfile 构建镜像
docker images # 查看本地镜像
docker run -p 8000:8000 myapp:latest # 启动容器,端口映射
docker ps # 查看正在跑的容器
docker logs <container_id> # 看日志
docker exec -it <id> bash # 进入容器
docker stop <id> # 停止
docker rm <id> # 删除容器
docker rmi <image> # 删除镜像
1.3 Dockerfile 写作基础
最小例子:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
逐行解读:
FROM:基础镜像。python:3.11-slim是精简版,体积小WORKDIR:容器内的工作目录COPY requirements.txt .:先拷依赖文件RUN pip install:装依赖。为什么不一起 COPY . / ? 看下面"分层缓存"COPY . .:拷代码EXPOSE 8000:声明端口(文档性质,实际端口映射在docker run -p)CMD:启动命令
1.4 分层缓存(Layer Cache)
Dockerfile 每条指令产生一层(layer)。Docker 会缓存每一层。
FROM python:3.11-slim → layer 1 (基础镜像)
WORKDIR /app → layer 2
COPY requirements.txt . → layer 3
RUN pip install -r req.txt → layer 4 ← 缓存这里
COPY . . → layer 5 (代码改了,从这里重新构建)
CMD ... → layer 6
关键:COPY . . 会让任何代码改动都触发 layer 5 重建。如果你先 COPY . . 再 pip install,每次代码改一行都要重装依赖(几分钟)。
正确顺序:先 COPY requirements.txt + pip install,再 COPY . .。这样改代码只用重建最后两层(几秒)。
1.5 docker-compose:多容器编排
一个 LLM 应用要起 4 个服务:App + Postgres + Redis + MinIO。用 docker run 写 4 行命令,管理痛苦。
docker-compose 用 YAML 描述多容器拓扑,一行命令 up 起整个栈:
services:
app:
build: .
ports: ["8000:8000"]
depends_on: [postgres, redis]
environment:
DATABASE_URL: postgresql://myuser:mypassword@postgres:5432/myapp
postgres:
image: postgres:16
# ...
redis:
image: redis:7
# ...
docker compose up # 前台跑
docker compose up -d # 后台跑
docker compose down # 停掉并删除
docker compose logs -f app # 看 app 的日志
1.6 部署平台选型
| 平台 | 特点 | 适合 |
|---|---|---|
| Cloud Run(GCP) | Serverless 容器,按请求计费,缩到 0 | LLM 应用、流量波动大 |
| Fly.io | 边缘部署,多区域 | 全球用户、低延迟 |
| Vercel | Next.js 原生,无服务器函数 | 前端 + 轻量 API |
| AWS ECS / Fargate | AWS 的容器服务 | 已用 AWS 的团队 |
| Kubernetes(自管) | 完全控制,复杂 | 大规模、百台机器+ |
| Render / Railway | Heroku 替代品,简单 | 小项目、Demo |
本系列用 Cloud Run,因为:
- 配置最简(一个 Dockerfile 就能上线)
- 按请求计费(LLM 应用流量不稳定时省钱)
- 自动 HTTPS、自动扩缩容
- 有 $300 免费额度
1.7 CI/CD 是什么
CI(Continuous Integration):push 代码 → 自动跑测试 → 通过才能合并。
CD(Continuous Deployment/Delivery):合并到 main → 自动部署到生产。
主流工具:
- GitHub Actions(最流行,免费额度足)
- GitLab CI(GitLab 自带)
- Jenkins(老牌,自建)
- CircleCI(商业产品)
LLM 应用的 CI/CD 流程:
push 到 main 分支
↓
GitHub Actions 触发
↓
跑测试(pytest)
↓
构建 Docker 镜像
↓
推到镜像仓库(GHCR / Artifact Registry)
↓
部署到 Cloud Run
↓
健康检查
↓
通知 Slack / 飞书
1.8 部署策略
| 策略 | 怎么做 | 优缺点 |
|---|---|---|
| 滚动更新(Rolling) | 一批一批替换旧版本 | 默认,无停机,但新旧短暂共存 |
| 蓝绿部署(Blue-Green) | 蓝环境跑着,绿环境部署好,流量一切 | 切换快,但要 2 倍资源 |
| 金丝雀(Canary) | 先放 5% 流量到新版本,观察,逐步加 | 风险最低,Cloud Run 原生支持 |
| 重建(Recreate) | 先停旧的,再启新的 | 简单但有停机 |
2. 实战:把前 4 篇的应用部署上线
2.1 写生产级 Dockerfile
项目根目录新建 Dockerfile:
# Stage 1: 构建阶段(用大镜像装依赖)
FROM python:3.11-slim AS builder
WORKDIR /app
# 系统依赖(编译 psycopg2 等需要)
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# Stage 2: 运行阶段(用更小的镜像)
FROM python:3.11-slim AS runtime
# 只装运行时需要的系统库(不是编译工具)
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 curl \
&& rm -rf /var/lib/apt/lists/*
# 创建非 root 用户
RUN useradd -m -u 1000 appuser
WORKDIR /app
# 从 builder 拷 pip 包
COPY --from=builder /root/.local /home/appuser/.local
COPY . .
# 设置环境变量
ENV PATH=/home/appuser/.local/bin:$PATH \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PORT=8000
USER appuser
EXPOSE 8000
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# 生产用 gunicorn + uvicorn worker,多进程
CMD exec gunicorn -k uvicorn.workers.UvicornWorker \
-w 4 -b 0.0.0.0:${PORT} \
--timeout 120 --graceful-timeout 30 \
main:app
生产级关键点:
- 多阶段构建:builder 镜像有编译工具(大),runtime 镜像只拷产物(小)。最终镜像从 1GB 缩到 200MB。
- 非 root 用户:容器被攻破后,攻击者不是 root,影响有限
PYTHONUNBUFFERED=1:Python 日志立即输出,不缓冲(否则看不到实时日志)- gunicorn 多 worker:1 个容器里跑 4 个 Python 进程,充分利用多核
- HEALTHCHECK:Docker 自动探测容器健康,挂了自动重启
requirements.txt:
fastapi==0.115.0
uvicorn[standard]==0.32.0
gunicorn==23.0.0
sqlmodel==0.0.22
alembic==1.13.3
asyncpg==0.30.0
psycopg2-binary==2.9.10
redis==5.2.0
boto3==1.35.0
openai==1.50.0
pydantic==2.9.0
pydantic-settings==2.5.2
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
slowapi==0.1.9
structlog==24.4.0
python-dotenv==1.0.1
.dockerignore(避免把垃圾拷进镜像):
venv/
__pycache__/
*.pyc
.env
.git/
.github/
node_modules/
*.md
tests/
2.2 本地构建和测试
# 构建镜像
docker build -t my-llm-app:latest .
# 看镜像大小
docker images my-llm-app
# 跑起来(把 .env 当环境变量传进去)
docker run --rm -p 8000:8000 --env-file .env my-llm-app:latest
# 测试
curl http://localhost:8000/health
# {"status":"ok"}
2.3 docker-compose 完整版
services:
app:
build: .
ports: ["8000:8000"]
env_file: .env
depends_on:
postgres: {condition: service_healthy}
redis: {condition: service_started}
restart: unless-stopped
postgres:
image: postgres:16
environment:
POSTGRES_USER: myuser
POSTGRES_PASSWORD: mypassword
POSTGRES_DB: myapp
ports: ["5432:5432"]
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myuser"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7
ports: ["6379:6379"]
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
minio:
image: minio/minio
ports: ["9000:9000", "9001:9001"]
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
volumes: [miniodata:/data]
volumes:
pgdata:
miniodata:
docker compose up -d --build
docker compose ps
docker compose logs -f app
2.4 部署到 Cloud Run
前置:Google Cloud 账号 + 安装 gcloud CLI。
# 登录
gcloud auth login
# 设置项目
gcloud config set project YOUR_PROJECT_ID
# 启用 APIs
gcloud services enable run.googleapis.com artifactregistry.googleapis.com sql-component.googleapis.com
# 创建 Artifact Registry(存镜像)
gcloud artifacts repositories create my-app-repo \
--repository-format=docker --location=asia-east1
# 配置 Docker 认证
gcloud auth configure-docker asia-east1-docker.pkg.dev
构建并推送镜像:
docker build -t asia-east1-docker.pkg.dev/YOUR_PROJECT_ID/my-app-repo/myapp:latest .
docker push asia-east1-docker.pkg.dev/YOUR_PROJECT_ID/my-app-repo/myapp:latest
部署到 Cloud Run:
gcloud run deploy my-llm-app \
--image asia-east1-docker.pkg.dev/YOUR_PROJECT_ID/my-app-repo/myapp:latest \
--region asia-east1 \
--platform managed \
--port 8000 \
--memory 1Gi \
--cpu 1 \
--min-instances 0 \
--max-instances 10 \
--timeout 300 \
--concurrency 80 \
--allow-unauthenticated \
--set-env-vars "OPENAI_API_KEY=sk-...,ENV=prod" \
--set-secrets "DATABASE_URL=cloud-sql-conn:latest"
关键参数:
--min-instances 0:没流量时缩到 0(省钱,但有冷启动)--max-instances 10:防突发流量打爆--timeout 300:LLM 调用慢,5 分钟超时--concurrency 80:每个实例最多并发 80 个请求--set-secrets:密码、API key 用 Secret Manager,不要放 env-vars
部署完会返回一个 URL:https://my-llm-app-xxxx-de.a.run.app。
测试:
curl https://my-llm-app-xxxx-de.a.run.app/health
2.5 绑定域名 + HTTPS
在 Cloud Run 控制台 → "域名映射" → 添加域名 → 它会告诉你 CNAME 记录怎么配 → 去你的 DNS 服务商(Cloudflare、阿里云)加记录 → 等几分钟 → HTTPS 自动启用。
2.6 配 GitHub Actions 自动部署
项目根目录新建 .github/workflows/deploy.yml:
name: Deploy to Cloud Run
on:
push:
branches: [main]
workflow_dispatch: # 支持手动触发
env:
PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
REGION: asia-east1
REPO: my-app-repo
SERVICE: my-llm-app
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install -r requirements.txt
- run: pip install pytest
- run: pytest # 测试失败则不部署
deploy:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # Workload Identity Federation
steps:
- uses: actions/checkout@v4
- name: Auth to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123/locations/global/workloadIdentityPools/my-pool/providers/my-provider
service_account: deployer@YOUR_PROJECT_ID.iam.gserviceaccount.com
- name: Configure Docker
run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev
- name: Build and Push
run: |
docker build -t ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPO }}/${{ env.SERVICE }}:${{ github.sha }} .
docker push ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPO }}/${{ env.SERVICE }}:${{ github.sha }}
- name: Deploy
run: |
gcloud run deploy ${{ env.SERVICE }} \
--image ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPO }}/${{ env.SERVICE }}:${{ github.sha }} \
--region ${{ env.REGION }} \
--platform managed
在 GitHub 仓库 Settings → Secrets 里加:
GCP_PROJECT_ID- Workload Identity Federation 配置(更安全,不要用 service account key 文件)
效果:git push origin main → GitHub Actions 自动跑测试 → 通过则构建镜像 → 推送 → 部署到 Cloud Run。整个过程 3-5 分钟。
3. 进阶:多环境管理
3.1 Dev / Staging / Prod 分离
绝对不要直接部署到生产。标准三环境:
| 环境 | 用途 | 数据 |
|---|---|---|
| Dev | 本地开发 | Docker compose |
| Staging | 上线前验证 | 小规模真实数据 |
| Prod | 真实用户 | 完整数据,严格保护 |
用 Git 分支管理:
main → 自动部署到 prod
↑
PR 合并到 release → 自动部署到 staging
↑
feature/xxx → 本地开发
Cloud Run 多服务:
my-llm-app-devmy-llm-app-stagingmy-llm-app-prod
每个有独立的数据库、Redis、S3 桶。
3.2 Secrets 管理
绝不能把生产密钥放 Git。三层方案:
- 本地开发:
.env(在.gitignore) - CI:GitHub Secrets → 注入为环境变量
- 生产:Cloud Secret Manager / AWS Secrets Manager / Vault
from google.cloud import secretmanager
def get_secret(name: str) -> str:
client = secretmanager.SecretManagerServiceClient()
response = client.access_secret_version(
request={"name": f"projects/YOUR_PROJECT/secrets/{name}/versions/latest"}
)
return response.payload.data.decode()
Cloud Run 部署时:
gcloud run deploy ... \
--set-secrets "OPENAI_API_KEY=openai-key:latest,DATABASE_URL=db-url:latest"
3.3 数据库用托管服务
不要在生产环境用 docker-compose 跑 Postgres。原因:
- 没有自动备份
- 没有高可用
- 没有读副本
- 升级要手动
- 安全更新要自己管
用 Cloud SQL(GCP)、RDS(AWS)、AlloyDB——这些都是托管的 Postgres,点几下就有备份、HA、监控。
gcloud sql instances create my-app-db \
--database-version POSTGRES_16 \
--cpu 1 --memory 4Gi \
--region asia-east1 \
--backup-start-time 02:00
gcloud sql databases create myapp --instance my-app-db
连接 Cloud Run 到 Cloud SQL:
gcloud run deploy ... \
--add-cloudsql-instances my-project:asia-east1:my-app-db
数据库 URL 变成 Unix socket:postgresql://user:pass@/cloudsql/my-project:asia-east1:my-app-db/myapp
3.4 日志聚合
Cloud Run 自动把容器 stdout/stderr 收到 Cloud Logging。
代码里用结构化日志(JSON),Cloud Logging 能按字段查询:
import structlog
logger = structlog.get_logger()
logger.info("user_login", user_id=123, ip="1.2.3.4")
在 Cloud Logging 里搜:jsonPayload.message="user_login" AND jsonPayload.user_id=123。
详见 06. 可观测性。
3.5 零停机部署
Cloud Run 默认就是滚动更新:
- 新版本启动
- 健康检查通过后,流量切到新版本
- 旧版本优雅关闭(等待正在处理的请求完成)
你的应用要配合:收到 SIGTERM 信号时,不再接新请求,等已有请求处理完:
import signal
@app.on_event("shutdown")
async def shutdown():
logger.info("graceful_shutdown_started")
# 等正在跑的 LLM 调用完成
# 释放数据库连接池
# 关闭 Redis 客户端
4. 常见踩坑
坑 1:镜像太大,构建太慢
# ❌ 用 python:3.11(完整版,1GB+)
FROM python:3.11
# ✅ 用 slim 或 alpine
FROM python:3.11-slim # 150MB
Alpine 更小但可能装包有问题(用 musl 而不是 glibc),-sllim 是折中。
坑 2:用 latest tag 部署
# ❌ latest 永远是"最新的",但具体是哪个版本不知道
docker build -t myapp:latest .
docker push myapp:latest
# 出问题了想回退到昨天的版本?已经覆盖了。
正确:用 Git SHA 或语义化版本:
docker build -t myapp:${GIT_SHA} .
docker build -t myapp:v1.2.3 .
坑 3:数据库密码放 .env 推到 Git
结果:GitHub 有机器人扫公开仓库,5 分钟内扫到 → 数据库被拖。
解决:
.env加入.gitignore- 用 Secret Manager
- 启用 Git 的 push protection(GitHub 设置里)
坑 4:容器内用 root 跑
# ❌ root 跑,被攻破后攻击者拿 root 权限
CMD ["uvicorn", "..."]
# ✅ 非 root
RUN useradd -m appuser
USER appuser
CMD ["uvicorn", "..."]
坑 5:没配 healthcheck,容器卡死但 Docker 不知道
# ✅ 加 HEALTHCHECK
HEALTHCHECK --interval=30s CMD curl -f http://localhost:8000/health || exit 1
Docker 会每 30 秒调一次,失败 3 次标记为 unhealthy,编排系统会重启。
坑 6:Cloud Run 冷启动慢
Cloud Run --min-instances 0 省钱,但冷启动要 5-10 秒。
缓解:
--min-instances 1(贵但无冷启动)- 减小镜像体积(启动更快)
- 用 Lazy 初始化(不要在启动时把所有东西都加载)
坑 7:CORS / HTTPS 证书问题
部署到 Cloud Run 后:
- 默认有 HTTPS(域名
.run.app) - 自定义域名也会自动签证书
- CORS 要明确允许前端域名,不要用
*
5. 检查清单
- 我能写一个多阶段构建的 Dockerfile
- 我知道为什么要
COPY requirements.txt在COPY . .之前 - 我会用 docker-compose 起多服务栈
- 我构建的镜像里不是 root 用户
- 我能解释 gunicorn -w 4 是干嘛的
- 我把应用部署到了 Cloud Run,公网能访问
- 我绑定了自定义域名,HTTPS 自动启用
- 我的密钥在 Secret Manager,不在代码里
- 我配了 GitHub Actions,push 即自动部署
- 我有 dev/staging/prod 三个环境
- 我用了 Cloud SQL / RDS 而不是 docker 里的 Postgres
- 我的应用收到 SIGTERM 能优雅关闭
6. 下一步
部署搞定!你的应用已经在公网跑了。但跑起来 ≠ 跑得好——你怎么知道:
- 用户访问慢不慢?
- 错误率多高?
- LLM 成本多少?
- 哪个接口是热点?
下一站可观测层:
- 06. 可观测性:日志、指标、追踪、告警
加完可观测性,你才能真正"看到"你的系统。
进阶学习资源
- Docker 官方教程:https://docs.docker.com/get-started/
- Cloud Run 文档:https://cloud.google.com/run/docs
- GitHub Actions 文档:https://docs.github.com/en/actions
- 《Docker Deep Dive》:Nigel Poulton
- 十二要素应用(The Twelve-Factor App):https://12factor.net/
- Distroless 镜像(更安全):https://github.com/GoogleContainerTools/distroless