第 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 的关键要素:

  1. 多阶段构建:分离 builder 和 runtime,最小化镜像体积
  2. 非 root 运行:创建专用用户,减少安全风险
  3. Secrets 管理:Docker Secrets > 环境变量 > .env 文件(按安全级别排序)
  4. 数据持久化:模型文件用 bind mount,应用数据用 named volume,缓存用 tmpfs
  5. 健康检查三层/health(存活)+ /health/ready(就绪)+ /health/live(推理可用)
  6. 资源限制:必须设置 memory limits,防止 OOM 影响整个宿主机

思考题

  1. 在 Docker Compose 配置中,ollama-init 服务设置了 restart: "no"。但如果模型下载到一半失败了,下次重启 ollama-init 不会重新运行。应该如何修改配置,让初始化服务在失败后可以手动重新触发?

  2. 当 Hermes Agent 容器的内存使用超过 memory: 4G 限制时,Docker 会发送 SIGKILL 信号(OOM Kill)而不是优雅关闭。如何修改代码,使得在接收到 SIGTERM 时能够优雅关闭(完成正在处理的请求再退出)?

  3. 本章使用 tini 作为容器的 init 进程。为什么 Python 进程(尤其是有子进程的场景)需要一个 init 进程?如果不使用 tini,可能会出现什么问题?

本章评分
4.8  / 5  (3 评分)

💬 留言讨论