Skip to main content

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 容器,按请求计费,缩到 0LLM 应用、流量波动大
Fly.io边缘部署,多区域全球用户、低延迟
VercelNext.js 原生,无服务器函数前端 + 轻量 API
AWS ECS / FargateAWS 的容器服务已用 AWS 的团队
Kubernetes(自管)完全控制,复杂大规模、百台机器+
Render / RailwayHeroku 替代品,简单小项目、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-dev
  • my-llm-app-staging
  • my-llm-app-prod

每个有独立的数据库、Redis、S3 桶。

3.2 Secrets 管理

绝不能把生产密钥放 Git。三层方案:

  1. 本地开发:.env(在 .gitignore)
  2. CI:GitHub Secrets → 注入为环境变量
  3. 生产: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 默认就是滚动更新:

  1. 新版本启动
  2. 健康检查通过后,流量切到新版本
  3. 旧版本优雅关闭(等待正在处理的请求完成)

你的应用要配合:收到 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.txtCOPY . . 之前
  • 我会用 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 成本多少?
  • 哪个接口是热点?

下一站可观测层:

加完可观测性,你才能真正"看到"你的系统。

进阶学习资源