Docker部署AI服务
AI 服务的环境依赖比普通 Web 服务复杂得多:Python 版本、CUDA 版本、langchain 及其数百个传递依赖,任何一项不一致都可能导致"本地能跑、服务器报错"的问题。Docker 通过把应用和它依赖的所有环境打包成一个镜像,从根本上消灭了这类环境差异问题。
Docker 部署 AI 服务:从本地到生产环境
AI 服务的环境依赖比普通 Web 服务复杂得多:Python 版本、CUDA 版本、langchain 及其数百个传递依赖,任何一项不一致都可能导致"本地能跑、服务器报错"的问题。Docker 通过把应用和它依赖的所有环境打包成一个镜像,从根本上消灭了这类环境差异问题。
1.1 为什么 AI 服务特别需要 Docker
代码→Dockerfile→镜像→多容器编排的 Docker AI 服务完整部署流程
普通 Web 服务依赖就那几个包,手动装也不太难。AI 服务不一样:
依赖链深且复杂。 一个 LangChain 应用拉下来的依赖可能有几百个包,版本之间的兼容性矩阵非常复杂。numpy、pydantic、tiktoken 每一个版本升一下,可能就有包出问题。
CUDA 版本绑定。 用本地模型(如 vLLM、Ollama)的话,CUDA 驱动版本和 PyTorch 版本必须匹配。这个在不同服务器上手动维护是噩梦。
部署一致性。 开发、测试、生产三套环境行为完全一致,不会出现"测试环境能跑,生产出问题"。
快速回滚。 上线出问题,把容器切回上一个镜像版本,几秒钟搞定。
1.2 Dockerfile:写给 FastAPI + LangChain 服务
下面是一个实际可用的 Dockerfile:
# ===== 第一阶段:构建依赖 =====
FROM python:3.11-slim AS builder
# 设置工作目录
WORKDIR /build
# 安装构建工具(某些 Python 包需要编译)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
&& rm -rf /var/lib/apt/lists/*
# 先复制依赖文件,利用 Docker 层缓存
# 只要 requirements.txt 没变,这一层不会重新构建
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
# ===== 第二阶段:运行时镜像 =====
FROM python:3.11-slim AS runtime
WORKDIR /app
# 从构建阶段复制已安装的包,不带 gcc 等构建工具
COPY --from=builder /root/.local /root/.local
# 创建非 root 用户(安全)
RUN groupadd -r appuser && useradd -r -g appuser appuser
# 复制应用代码
COPY --chown=appuser:appuser . .
# 切换到非 root 用户
USER appuser
# 确保用户安装的包在 PATH 里
ENV PATH=/root/.local/bin:$PATH
# 应用监听端口
EXPOSE 8000
# 健康检查:每 30 秒检查一次,3 次失败则标记为不健康
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD python -c "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"
# 生产启动命令
CMD ["python", "-m", "uvicorn", "main:app", \
"--host", "0.0.0.0", \
"--port", "8000", \
"--workers", "2", \
"--timeout-keep-alive", "120"]
几个关键决策说明:
用 python:3.11-slim 而不是 python:3.11。 slim 版本去掉了很多不需要的工具,镜像体积从 900MB 降到 120MB 左右。AI 服务的镜像加上各种包已经够大了,基础镜像能省则省。
多阶段构建。 第一阶段装 gcc、g++ 等编译工具——某些 Python 包(比如 numpy 的某些版本)需要 C 编译器。第二阶段只复制编译好的包,不带编译工具,最终镜像干净很多,也减少了攻击面。
先复制 requirements.txt,再复制代码。 Docker 镜像是分层的,每一层都有缓存。如果把代码和依赖混在一起复制,任何一行代码改动都会触发重新安装所有依赖。把依赖安装放在单独一层,代码改了只重新复制代码,不重新装包,构建速度快很多。
非 root 用户运行。 默认情况下容器里是 root,如果应用有漏洞被利用,攻击者在容器里拥有 root 权限,危害更大。创建专用用户,切换后运行,是基本的安全实践。
健康检查。 Kubernetes 和 Docker Swarm 依赖健康检查来决定容器是否可用。start-period=60s 是因为 AI 服务启动时要加载模型、初始化向量数据库,启动本来就慢,不能一启动就被检查判死。
1.3 docker-compose.yml:把整套服务跑起来
AI 服务不会孤立存在。典型的组合:AI 服务本身、Redis(存对话历史)、Qdrant(向量数据库)。用 docker-compose 统一管理。
# docker-compose.yml
version: "3.9"
services:
# ——— AI 主服务 ———
ai-service:
build:
context: .
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
# 从 .env 文件读取,不硬编码在这里
- OPENAI_API_KEY=${OPENAI_API_KEY}
- REDIS_URL=redis://redis:6379/0
- QDRANT_URL=http://qdrant:6333
- LOG_LEVEL=${LOG_LEVEL:-INFO}
depends_on:
redis:
condition: service_healthy
qdrant:
condition: service_healthy
# 资源限制,防止 OOM 拖垮宿主机
deploy:
resources:
limits:
memory: 2g
cpus: "2.0"
reservations:
memory: 512m
restart: unless-stopped
# 日志配置:输出到 stdout,不写文件
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
# ——— Redis:存对话历史 ———
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
restart: unless-stopped
# ——— Qdrant:向量数据库 ———
qdrant:
image: qdrant/qdrant:latest
ports:
- "6333:6333"
volumes:
- qdrant_data:/qdrant/storage
environment:
- QDRANT__SERVICE__HTTP_PORT=6333
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:6333/health"]
interval: 10s
timeout: 5s
retries: 3
restart: unless-stopped
volumes:
redis_data:
qdrant_data:
.env 文件放项目根目录,不要提交到 git:
# .env
OPENAI_API_KEY=sk-your-real-key-here
LOG_LEVEL=INFO
.gitignore 里加上:
.env
.env.local
.env.production
启动整套服务:
docker compose up -d
查看日志:
docker compose logs -f ai-service
1.4 生产环境注意事项
API Key 不进镜像。 用 ENV OPENAI_API_KEY=sk-... 写进 Dockerfile 是严重错误——镜像一旦推送到仓库,Key 就泄露了,哪怕之后删掉那层,用 docker history 还是能看到历史层。正确做法是运行时通过环境变量注入,上面 docker-compose 里的写法就是标准方式。
日志输出到 stdout,不写文件。 容器的设计哲学是无状态的,写文件有几个问题:容器重启后文件消失、多个容器实例的日志分散在各自的文件系统里、日志采集系统(ELK、Loki)默认从 stdout 采集。Python 应用只需要:
import logging
import sys
logging.basicConfig(
stream=sys.stdout, # 输出到 stdout
level=os.getenv("LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
设置内存限制。 AI 服务加载模型、处理长文本,内存用量可能暴涨。不设限制的话,一个请求 OOM(Out of Memory,内存溢出,进程占用内存超过系统上限时被强制终止)会把整台宿主机拖累。上面 docker-compose 里的 limits.memory: 2g 就是安全阀,超了直接 OOM kill 容器,而不是让宿主机出问题。
需要 GPU 时的配置。 如果用本地模型(vLLM、Ollama),需要 GPU 支持:
# docker-compose.yml 中加入 GPU 支持
ai-service:
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
或者直接用命令行:
docker run --gpus all your-ai-service:latest
前提是宿主机装了 NVIDIA Container Toolkit。
1.5 常见问题排查
容器启动了但服务不通: 先看日志 docker compose logs ai-service,80% 的问题在日志里写得很清楚。常见原因是环境变量没传进去,代码里 os.getenv("OPENAI_API_KEY") 返回了 None。
容器内连不上 Redis 或 Qdrant: docker-compose 里服务之间通信用服务名,不用 localhost。Redis 的连接地址应该是 redis://redis:6379,不是 redis://localhost:6379。这是从本地开发切换到容器部署时最常见的错误。
文件权限问题: 切换了非 root 用户之后,某些目录可能没有写权限。比如 Chroma 的持久化目录、模型缓存目录。解决方法:在 Dockerfile 里用 chown 把目录权限给 appuser,或者把这些目录挂载为 volume。
镜像太大构建太慢: AI 服务镜像动辄 3-4GB,每次推送很慢。两个优化方向:一是把 pip install 的缓存清掉(--no-cache-dir,上面 Dockerfile 已经加了);二是用 .dockerignore 排除不需要的文件:
.git
.env
__pycache__
*.pyc
*.pyo
.pytest_cache
chroma_db/
*.log
1.6 开发阶段就用 Docker
Docker 不只是部署工具,更是开发工具。在本地开发时就用 docker-compose 跑整套服务,和生产环境保持一致,"本地能跑,线上不行"的问题基本就消失了。
建立一个好习惯:requirements.txt 固定版本(pip freeze > requirements.txt),.env.example 提交到仓库(不包含真实 Key,只列出需要哪些变量),新同事克隆代码后执行 docker compose up 就能跑起来。
环境问题是低价值的消耗,解决起来很烦,又和业务无关。Docker 能把这部分成本压到接近零。