第 47 章
单机生产部署:Docker 容器化
第47章 单机生产部署:Docker 容器化
导语
"在我机器上能跑"是开发者的老梗,Docker 解决了这个问题。将 Hermes Agent 容器化不仅实现了环境一致性,还带来了清晰的资源隔离、优雅的密钥管理和可重复的部署流程。本章提供完整的 Dockerfile、docker-compose.yml 配置,以及生产环境必须考虑的健康检查、数据持久化、资源限制等关键配置。
47.1 完整 Dockerfile
多阶段构建 Dockerfile(Hermes Agent + Ollama)
# Dockerfile
# 多阶段构建:最小化最终镜像大小
# ─── 阶段一:构建依赖 ────────────────────────────────────────────────────────
FROM python:3.11-slim AS builder
# 设置构建参数
ARG DEBIAN_FRONTEND=noninteractive
ARG PIP_NO_CACHE_DIR=1
ARG PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
# 安装构建工具
RUN apt-get update && apt-get install -y \
build-essential \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
# 安装 Python 依赖(利用 Docker 层缓存)
COPY requirements.txt .
RUN pip install --prefix=/install --no-deps -r requirements.txt
# ─── 阶段二:最终运行镜像 ────────────────────────────────────────────────────
FROM python:3.11-slim AS runtime
LABEL maintainer="[email protected]"
LABEL description="Hermes Agent MCP Server"
LABEL version="1.0.0"
# 运行时依赖
RUN apt-get update && apt-get install -y \
curl \
ca-certificates \
tini \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# 创建非 root 用户(安全最佳实践)
RUN groupadd -r hermes && useradd -r -g hermes -m -d /home/hermes hermes
WORKDIR /app
# 从构建阶段复制已安装的依赖
COPY --from=builder /install /usr/local
# 复制应用代码
COPY --chown=hermes:hermes . /app/
# 创建必要目录
RUN mkdir -p /app/logs /app/data /app/cache \
&& chown -R hermes:hermes /app/logs /app/data /app/cache
# 切换到非 root 用户
USER hermes
# 暴露端口
EXPOSE 8765
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8765/health || exit 1
# 使用 tini 作为 init 进程(正确处理信号和僵尸进程)
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["python", "-m", "hermes_agent.server"]
requirements.txt
# Core dependencies
mcp==1.0.0
httpx==0.27.0
asyncio
pydantic==2.5.0
python-dotenv==1.0.0
# Monitoring
prometheus-client==0.20.0
# Utilities
tenacity==8.2.3 # 重试机制
structlog==24.1.0 # 结构化日志
Dockerfile.ollama(Ollama + 模型预热)
# Dockerfile.ollama
# 包含预下载模型的 Ollama 镜像(可选,加速启动)
FROM ollama/ollama:latest
# 预下载模型(构建时)
# 注意:这会使镜像非常大(30-50 GB),建议使用 volume 方案替代
# RUN ollama pull nous-hermes3:70b
# 自定义启动脚本
COPY scripts/ollama_entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 11434
ENTRYPOINT ["/entrypoint.sh"]
# scripts/ollama_entrypoint.sh
#!/bin/bash
set -e
# 启动 Ollama 服务
ollama serve &
OLLAMA_PID=$!
# 等待 Ollama 就绪
echo "等待 Ollama 启动..."
until curl -sf http://localhost:11434/api/version > /dev/null 2>&1; do
sleep 1
done
echo "Ollama 已就绪"
# 拉取模型(如果不存在)
MODEL="${OLLAMA_MODEL:-nous-hermes3:70b}"
if ! ollama list | grep -q "$MODEL"; then
echo "拉取模型: $MODEL"
ollama pull "$MODEL"
else
echo "模型已存在: $MODEL"
fi
# 等待 Ollama 进程
wait $OLLAMA_PID
47.2 完整 docker-compose.yml
# docker-compose.yml
# 生产级 Hermes Agent + Ollama 一体化部署
version: "3.9"
services:
# ─── Ollama 推理引擎 ────────────────────────────────────────────────────────
ollama:
image: ollama/ollama:latest
container_name: hermes-ollama
restart: unless-stopped
environment:
- OLLAMA_HOST=0.0.0.0:11434
- OLLAMA_NUM_PARALLEL=4 # 并行请求数
- OLLAMA_MAX_LOADED_MODELS=1 # 最大加载模型数
- OLLAMA_KEEP_ALIVE=10m # 空闲卸载时间
- OLLAMA_MODELS=/models # 模型存储路径
volumes:
- ollama_models:/models # 持久化模型存储
- /tmp/ollama:/tmp # 临时文件
ports:
- "127.0.0.1:11434:11434" # 只绑定本地(不对外暴露)
# GPU 支持
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
limits:
memory: 90G # 内存限制(A100 80G + 系统内存)
# 健康检查
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:11434/api/version"]
interval: 30s
timeout: 10s
retries: 5
start_period: 120s # 首次启动等待时间(模型加载)
networks:
- hermes-internal
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
# ─── 模型初始化(一次性任务)──────────────────────────────────────────────
ollama-init:
image: ollama/ollama:latest
container_name: hermes-ollama-init
restart: "no" # 只运行一次
depends_on:
ollama:
condition: service_healthy
environment:
- OLLAMA_HOST=http://ollama:11434
command: >
sh -c "
ollama pull ${OLLAMA_MODEL:-nous-hermes3:70b} &&
echo '模型初始化完成'
"
volumes:
- ollama_models:/models
networks:
- hermes-internal
# ─── Hermes Agent MCP Server ────────────────────────────────────────────────
hermes-agent:
build:
context: .
dockerfile: Dockerfile
args:
- BUILD_ENV=production
container_name: hermes-agent
restart: unless-stopped
depends_on:
ollama:
condition: service_healthy
ollama-init:
condition: service_completed_successfully
environment:
# 从 .env 文件或 Docker secrets 读取
- HERMES_BASE_URL=http://ollama:11434
- HERMES_MODEL=${HERMES_MODEL:-nous-hermes3:70b}
- MCP_PORT=8765
- MAX_TOKENS=4096
- TEMPERATURE=0.1
- CONTEXT_WINDOW=65536
- REQUEST_TIMEOUT=120
# 监控
- PROMETHEUS_PORT=9090
- LOG_LEVEL=INFO
- LOG_FORMAT=json # 生产环境使用结构化日志
# 功能开关
- ENABLE_RATE_LIMITING=true
- MAX_REQUESTS_PER_MINUTE=60
env_file:
- .env.production # 敏感配置从文件读取(不提交到 Git)
secrets:
- api_key
- db_password
volumes:
- hermes_logs:/app/logs
- hermes_data:/app/data
- hermes_cache:/app/cache
ports:
- "127.0.0.1:8765:8765" # MCP Server 端口(本地)
- "127.0.0.1:9090:9090" # Prometheus 指标
# 资源限制
deploy:
resources:
limits:
cpus: "4.0"
memory: 4G
reservations:
cpus: "1.0"
memory: 1G
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8765/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
- hermes-internal
- hermes-external
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "10"
# ─── Nginx 反向代理 ─────────────────────────────────────────────────────────
nginx:
image: nginx:alpine
container_name: hermes-nginx
restart: unless-stopped
depends_on:
- hermes-agent
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro # SSL 证书
- nginx_logs:/var/log/nginx
ports:
- "80:80"
- "443:443"
networks:
- hermes-external
healthcheck:
test: ["CMD", "nginx", "-t"]
interval: 60s
timeout: 5s
retries: 3
# ─── Prometheus 监控 ─────────────────────────────────────────────────────────
prometheus:
image: prom/prometheus:latest
container_name: hermes-prometheus
restart: unless-stopped
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
ports:
- "127.0.0.1:9091:9090"
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=30d'
networks:
- hermes-internal
# ─── 网络配置 ─────────────────────────────────────────────────────────────────
networks:
hermes-internal:
driver: bridge
internal: true # 内部网络,不可访问外网
hermes-external:
driver: bridge
# ─── 数据卷配置 ───────────────────────────────────────────────────────────────
volumes:
ollama_models:
driver: local
driver_opts:
type: none
o: bind
device: /data/ollama/models # 绑定到宿主机大容量存储
hermes_logs:
driver: local
hermes_data:
driver: local
hermes_cache:
driver: local
nginx_logs:
driver: local
prometheus_data:
driver: local
# ─── Docker Secrets 配置 ──────────────────────────────────────────────────────
secrets:
api_key:
file: ./secrets/api_key.txt # 密钥文件(不提交 Git)
db_password:
file: ./secrets/db_password.txt
47.3 环境变量管理(Secrets)
分层配置策略
配置优先级(高 → 低):
1. Docker Secrets(/run/secrets/) ← 最安全(内存挂载)
2. 环境变量(-e KEY=VALUE) ← 方便,但 docker inspect 可见
3. .env 文件 ← 开发环境用,生产不推荐
4. 默认值(代码中硬编码) ← 仅作兜底
Docker Secrets 使用
# 创建 secrets 目录(不提交到 Git)
mkdir -p secrets
echo -n "your-super-secret-api-key" > secrets/api_key.txt
echo -n "db-password-here" > secrets/db_password.txt
# 设置权限
chmod 600 secrets/*.txt
# .gitignore
echo "secrets/" >> .gitignore
echo ".env.production" >> .gitignore
# 在代码中读取 Docker Secrets
import os
from pathlib import Path
def read_secret(secret_name: str, env_fallback: str = None) -> str:
"""
优先从 Docker Secret 文件读取,
回退到环境变量,最后使用默认值。
"""
# Docker Secret 路径(容器内固定路径)
secret_file = Path(f"/run/secrets/{secret_name}")
if secret_file.exists():
return secret_file.read_text().strip()
# 回退到环境变量
if env_fallback:
value = os.getenv(env_fallback)
if value:
return value
# 回退到同名环境变量
value = os.getenv(secret_name.upper())
if value:
return value
raise ValueError(f"Secret '{secret_name}' not found in Docker Secrets or environment")
# 使用示例
API_KEY = read_secret("api_key", env_fallback="API_KEY")
DB_PASSWORD = read_secret("db_password", env_fallback="DB_PASSWORD")
.env.production 文件模板
# .env.production(不提交 Git!)
# 复制自 .env.example 并填写实际值
# Hermes 配置
HERMES_MODEL=nous-hermes3:70b
CONTEXT_WINDOW=65536
MAX_TOKENS=4096
TEMPERATURE=0.1
REQUEST_TIMEOUT=120
# 功能开关
ENABLE_RATE_LIMITING=true
MAX_REQUESTS_PER_MINUTE=60
ENABLE_CORS=false
CORS_ORIGINS=https://your-domain.com
# 监控
LOG_LEVEL=WARNING # 生产环境减少日志量
PROMETHEUS_ENABLED=true
SENTRY_DSN= # 可选:错误追踪
# .env.example(提交 Git,无实际值)
HERMES_MODEL=nous-hermes3:70b
CONTEXT_WINDOW=65536
MAX_TOKENS=4096
TEMPERATURE=0.1
REQUEST_TIMEOUT=120
ENABLE_RATE_LIMITING=true
MAX_REQUESTS_PER_MINUTE=60
LOG_LEVEL=INFO
PROMETHEUS_ENABLED=true
SENTRY_DSN=
47.4 数据持久化(Volume 配置)
Volume 策略
# 不同数据类型的 volume 策略
volumes:
# 1. 模型文件(最重要,必须持久化,体积巨大)
ollama_models:
driver: local
driver_opts:
type: none
o: bind
device: /data/ollama/models # 绑定宿主机 SSD/NVMe 存储
# 2. 应用日志(持久化,便于问题排查)
hermes_logs:
driver: local
# 使用 logrotate 防止磁盘撑满
# 3. KV 存储 / 对话历史
hermes_data:
driver: local
driver_opts:
type: none
o: bind
device: /data/hermes/data
# 4. 模型推理缓存(可丢失,重启自动重建)
hermes_cache:
driver: tmpfs # 使用内存(重启清空,但更快)
driver_opts:
device: tmpfs
o: size=2g # 限制 2GB 内存缓存
备份策略
#!/bin/bash
# backup_hermes.sh — 定期备份重要数据
BACKUP_DIR="/backup/hermes/$(date +%Y%m%d)"
mkdir -p "$BACKUP_DIR"
# 备份对话历史和配置
docker run --rm \
-v hermes_data:/data \
-v "$BACKUP_DIR":/backup \
alpine tar czf /backup/hermes_data.tar.gz -C /data .
# 备份日志(仅最近 7 天)
docker run --rm \
-v hermes_logs:/logs \
-v "$BACKUP_DIR":/backup \
alpine find /logs -mtime -7 -exec tar czf /backup/recent_logs.tar.gz {} +
# 删除 30 天前的备份
find /backup/hermes/ -maxdepth 1 -type d -mtime +30 -exec rm -rf {} +
echo "备份完成: $BACKUP_DIR"
ls -lh "$BACKUP_DIR"
47.5 健康检查配置
分层健康检查
# health_check.py — 健康检查端点实现
from fastapi import FastAPI
import httpx
import asyncio
import time
app = FastAPI()
@app.get("/health")
async def health_check():
"""基础健康检查 — 只检查进程是否存活"""
return {"status": "ok", "timestamp": time.time()}
@app.get("/health/ready")
async def readiness_check():
"""就绪检查 — 检查依赖服务是否可用"""
checks = {}
is_ready = True
# 检查 Ollama
try:
async with httpx.AsyncClient(timeout=5) as client:
response = await client.get("http://ollama:11434/api/version")
checks["ollama"] = {
"status": "ok" if response.status_code == 200 else "error",
"latency_ms": response.elapsed.total_seconds() * 1000
}
except Exception as e:
checks["ollama"] = {"status": "error", "error": str(e)}
is_ready = False
# 检查磁盘空间
import shutil
disk = shutil.disk_usage("/app/data")
free_gb = disk.free / 1024 ** 3
checks["disk"] = {
"status": "ok" if free_gb > 1 else "warning",
"free_gb": round(free_gb, 2)
}
if free_gb < 0.5:
is_ready = False
# 检查内存
import psutil
mem = psutil.virtual_memory()
checks["memory"] = {
"status": "ok" if mem.percent < 90 else "warning",
"used_percent": mem.percent
}
return {
"status": "ready" if is_ready else "not_ready",
"checks": checks,
"timestamp": time.time()
}
@app.get("/health/live")
async def liveness_check():
"""存活检查 — 更深度的检查(推理能力)"""
try:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
"http://ollama:11434/api/generate",
json={
"model": "nous-hermes3:70b",
"prompt": "Hi",
"stream": False,
"options": {"num_predict": 5}
}
)
if response.status_code == 200:
return {"status": "ok", "inference": "working"}
except Exception as e:
return {"status": "error", "inference": str(e)}, 503
return {"status": "error"}, 503
Nginx 配置(健康检查路由)
# nginx/nginx.conf
upstream hermes_backend {
server hermes-agent:8765;
keepalive 32;
}
server {
listen 80;
server_name _;
# 健康检查(不记录日志)
location /health {
access_log off;
proxy_pass http://hermes_backend;
}
# MCP 端点
location / {
proxy_pass http://hermes_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_read_timeout 300s;
}
}
47.6 容器资源限制
资源限制最佳实践
# docker-compose.yml 资源配置示例
services:
hermes-agent:
deploy:
resources:
limits:
cpus: "4.0" # 最多使用 4 核
memory: 4G # 内存上限 4GB(OOM Kill 阈值)
reservations:
cpus: "0.5" # 保证至少 0.5 核
memory: 512M # 保证至少 512MB
# ulimit 配置
ulimits:
nofile:
soft: 65536
hard: 65536
nproc:
soft: 4096
hard: 4096
ollama:
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1 # 使用 1 块 GPU(指定数量)
# 或 count: all 使用所有 GPU
capabilities: [gpu]
limits:
memory: 90G # 大内存限制(GPU 推理需要系统内存配合)
监控资源使用
# 实时监控所有容器资源
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}"
# 监控特定容器
docker stats hermes-agent hermes-ollama
# GPU 使用率
nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu \
--format=csv --loop=5
47.7 生产部署操作手册
首次部署
# 1. 准备目录结构
mkdir -p /data/ollama/models /data/hermes/{data,logs} secrets
# 2. 填写 secrets
echo -n "your-api-key" > secrets/api_key.txt
cp .env.example .env.production
vim .env.production
# 3. 构建镜像
docker compose build --no-cache
# 4. 启动(后台)
docker compose up -d
# 5. 查看初始化日志
docker compose logs -f ollama-init
# 6. 验证服务
curl http://localhost:8765/health
curl http://localhost:8765/health/ready
# 7. 测试 MCP
python test_mcp.py
零停机更新
# 更新 Hermes Agent(不重启 Ollama)
docker compose build hermes-agent
docker compose up -d --no-deps hermes-agent
# 滚动更新(多实例场景)
# 结合 Nginx upstream,实现零停机
回滚
# 快速回滚到上一版本
docker compose stop hermes-agent
docker compose rm -f hermes-agent
docker tag hermes-agent:previous hermes-agent:latest
docker compose up -d hermes-agent
本章小结
Docker 容器化 Hermes Agent 的关键要素:
- 多阶段构建:分离 builder 和 runtime,最小化镜像体积
- 非 root 运行:创建专用用户,减少安全风险
- Secrets 管理:Docker Secrets > 环境变量 > .env 文件(按安全级别排序)
- 数据持久化:模型文件用 bind mount,应用数据用 named volume,缓存用 tmpfs
- 健康检查三层:
/health(存活)+/health/ready(就绪)+/health/live(推理可用) - 资源限制:必须设置 memory limits,防止 OOM 影响整个宿主机
思考题
-
在 Docker Compose 配置中,
ollama-init服务设置了restart: "no"。但如果模型下载到一半失败了,下次重启 ollama-init 不会重新运行。应该如何修改配置,让初始化服务在失败后可以手动重新触发? -
当 Hermes Agent 容器的内存使用超过
memory: 4G限制时,Docker 会发送 SIGKILL 信号(OOM Kill)而不是优雅关闭。如何修改代码,使得在接收到 SIGTERM 时能够优雅关闭(完成正在处理的请求再退出)? -
本章使用
tini作为容器的 init 进程。为什么 Python 进程(尤其是有子进程的场景)需要一个 init 进程?如果不使用 tini,可能会出现什么问题?