为大语言模型推理服务器建立自动化运维,如何实现可重复部署与失败回滚
面向大语言模型推理服务器运维人员,介绍如何固化镜像、模型、配置与启动参数,通过健康检查、业务探测、蓝绿切换、定时巡检和审计记录实现可重复部署,并提供失败回滚、故障排查及修复后验证方法。

假设一个典型现场:自动发布任务显示“成功”,容器也处于运行状态,但大语言模型推理服务器开始返回 502,或者接口能够连接却迟迟无法完成推理。值班人员准备回退时才发现,旧容器使用的是 latest 镜像,启动参数没有留档,当前配置又被脚本原地覆盖,所谓“回滚”只能依靠人工回忆。
这类问题应按“入口状态、当前发布版本、配置渲染结果、容器与模型日志、GPU或系统资源、业务级推理验证”的顺序检查。要让部署可重复、失败后可回滚,关键不是增加一条自动重启命令,而是把镜像、模型、配置和启动方式固化为不可变发布单元,在切换流量前完成健康检查,并保留上一份已知正常版本及完整审计记录。
先区分进程存活和推理可用
推理服务的启动过程通常包含多个阶段:
- 容器进程启动。
- 推理引擎读取配置。
- 模型文件加载或映射到内存、显存。
- 分词器、并行策略和缓存初始化。
- HTTP接口开始监听。
- 服务完成预热并能够返回有效推理结果。
因此,docker ps 显示 Up,只能证明容器主进程还没有退出。即使 /health 返回 200,也不一定代表模型已经加载完成。自动化发布至少应设置三道门:
- 配置门:Compose、环境变量和代理配置能够通过语法检查。
- 就绪门:模型加载完成,推理接口已进入可服务状态。
- 业务门:发送一条低成本、固定格式的探测请求,响应结构符合预期。
业务门需要按照实际推理框架的API定义实现,不能把某个框架的健康路径直接套用到所有服务上。部署前应先确认健康接口表示的是“进程存活”还是“模型就绪”。
把一次发布固化为不可变单元
可重复部署并不等于重复执行同一段Shell命令。更可靠的定义是:在前置环境一致的服务器上,使用相同发布清单,能够获得相同的镜像、模型版本、运行参数和配置内容。
建议每个发布单元至少记录以下信息:
| 项目 | 推荐记录方式 | 回滚时的作用 |
|---|---|---|
| 推理镜像 | 使用镜像摘要,如 @sha256:... |
避免标签指向发生变化 |
| 模型版本 | 模型目录版本、制品摘要或仓库修订号 | 防止代码回退但模型未回退 |
| 运行配置 | Git提交号和配置文件SHA-256 | 判断配置是否被人工修改 |
| 启动方式 | 固化的Compose文件或systemd单元 | 避免参数依赖命令历史 |
| 监听端口 | 写入发布清单 | 支持蓝绿切换 |
| 健康路径 | 写入发布清单并随版本管理 | 防止探测接口不匹配 |
| 发布时间与操作者 | 结构化审计日志 | 还原故障时间线 |
目录可以按发布版本组织:
/opt/llm-inference/
├── releases/
│ ├── 20260910-001/
│ │ ├── compose.yaml
│ │ └── manifest.env
│ └── 20260910-002/
│ ├── compose.yaml
│ └── manifest.env
└── state/
├── current
└── previous
current 和 previous 文件只保存发布编号,不保存完整配置。发布目录不应在上线后原地修改;需要调整参数时,应生成新的发布编号。
以下示例适用于安装了 Docker Engine、Docker Compose v2 和 systemd 的 Ubuntu 22.04/24.04。其他发行版的服务名、Nginx路径和包管理方式可能不同,应先核对环境:
docker version
docker compose version
systemctl --version
nginx -v
nvidia-smi
如果服务器使用CPU推理、其他GPU运行时或非NVIDIA设备,应删除或调整设备预留配置,不要直接照搬。
一个基础Compose文件可以这样组织:
services:
inference:
image: "${INFERENCE_IMAGE}"
restart: unless-stopped
env_file:
- /etc/llm-inference/runtime.env
- /etc/llm-inference/secret.env
ports:
- "127.0.0.1:${HOST_PORT}:${CONTAINER_PORT}"
volumes:
- "${MODEL_ROOT}:/models:ro"
stop_grace_period: 120s
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities:
- gpu
对应的发布清单使用确定值,而不是 latest:
INFERENCE_IMAGE=registry.example.invalid/inference@sha256:<已核验的镜像摘要>
MODEL_ROOT=/srv/models/<已核验的模型版本>
HOST_PORT=18081
CONTAINER_PORT=8000
HEALTH_PATH=/health
CONFIG_SHA256=<运行配置的SHA-256>
MODEL_REVISION=<模型修订号>
示例中的仓库、摘要、模型目录、端口和健康路径都必须替换成实际值。manifest.env 会被部署脚本读取,只能由受信任的发布流程生成,并应限制普通用户的写权限。
失败回滚应发生在流量切换边界
比较稳妥的部署方式是蓝绿发布:旧版本继续占用原端口,新版本使用另一个本地端口启动。新版本通过检查后,Nginx再把流量切过去。
Nginx上游文件可以保持简单:
upstream llm_backend {
server 127.0.0.1:18080;
keepalive 32;
}
其他站点配置通过 proxy_pass http://llm_backend; 引用该上游。自动化脚本只修改受控的上游文件,不直接重写整个Nginx配置。
一次部署应依次执行:
- 获取发布锁,防止两个任务同时运行。
- 检查发布编号、文件权限和清单内容。
- 执行
docker compose config -q。 - 拉取指定摘要的镜像。
- 检查模型目录是否存在并且可读。
- 检查候选端口是否被占用。
- 启动候选版本。
- 等待就绪检查和业务探测通过。
- 备份当前Nginx上游文件。
- 写入新上游,执行
nginx -t。 - 重载Nginx并从代理入口再次验证。
- 成功后更新
current和previous。 - 记录镜像摘要、配置摘要、操作者和结果。
下面是切换逻辑的核心示例。执行前应确认路径、服务名和健康接口;脚本会覆盖指定的Nginx上游文件,因此必须先保留已知正常配置。
#!/usr/bin/env bash
set -Eeuo pipefail
if [[ "${EUID}" -ne 0 ]]; then
echo "请以root运行,以便管理Docker、Nginx和状态文件" >&2
exit 1
fi
RELEASE_ID="${1:-}"
[[ "${RELEASE_ID}" =~ ^[A-Za-z0-9._-]+$ ]] || {
echo "发布编号格式不合法" >&2
exit 1
}
BASE=/opt/llm-inference
RELEASE_DIR="${BASE}/releases/${RELEASE_ID}"
MANIFEST="${RELEASE_DIR}/manifest.env"
COMPOSE="${RELEASE_DIR}/compose.yaml"
STATE_DIR="${BASE}/state"
UPSTREAM=/etc/nginx/conf.d/llm-upstream.conf
exec 9>/run/lock/llm-deploy.lock
flock -n 9 || {
echo "已有部署任务正在运行" >&2
exit 1
}
[[ -f "${MANIFEST}" && -f "${COMPOSE}" ]] || {
echo "发布文件不完整" >&2
exit 1
}
# manifest.env必须来自受控目录,不能接受用户临时上传的任意内容。
set -a
source "${MANIFEST}"
set +a
[[ "${HOST_PORT}" =~ ^[0-9]+$ ]] || {
echo "HOST_PORT不是有效数字" >&2
exit 1
}
docker compose --env-file "${MANIFEST}" -f "${COMPOSE}" config -q
docker compose --env-file "${MANIFEST}" -f "${COMPOSE}" pull
if ss -lnt | awk '{print $4}' | grep -Eq ":${HOST_PORT}$"; then
echo "候选端口 ${HOST_PORT} 已被占用" >&2
exit 1
fi
PROJECT="llm-${RELEASE_ID}"
docker compose -p "${PROJECT}" \
--env-file "${MANIFEST}" \
-f "${COMPOSE}" up -d
READY=0
for _ in $(seq 1 60); do
if curl --fail --silent --show-error \
--max-time 3 \
"http://127.0.0.1:${HOST_PORT}${HEALTH_PATH}" >/dev/null; then
READY=1
break
fi
sleep 5
done
if [[ "${READY}" -ne 1 ]]; then
logger -t llm-deploy "release=${RELEASE_ID} status=readiness_failed"
echo "候选版本未通过就绪检查,现有流量未切换" >&2
exit 1
fi
BACKUP="$(mktemp)"
cp -a "${UPSTREAM}" "${BACKUP}"
NEW_UPSTREAM="$(mktemp)"
cat >"${NEW_UPSTREAM}" <<EOF
upstream llm_backend {
server 127.0.0.1:${HOST_PORT};
keepalive 32;
}
EOF
install -o root -g root -m 0644 "${NEW_UPSTREAM}" "${UPSTREAM}"
if ! nginx -t; then
cp -a "${BACKUP}" "${UPSTREAM}"
nginx -t
logger -t llm-deploy "release=${RELEASE_ID} status=nginx_validation_failed"
exit 1
fi
systemctl reload nginx
# 此地址应替换为经过Nginx代理的本地健康入口。
if ! curl --fail --silent --show-error \
--max-time 5 "http://127.0.0.1/health" >/dev/null; then
cp -a "${BACKUP}" "${UPSTREAM}"
nginx -t
systemctl reload nginx
logger -t llm-deploy "release=${RELEASE_ID} status=post_switch_failed rollback=completed"
exit 1
fi
mkdir -p "${STATE_DIR}"
OLD_RELEASE="$(cat "${STATE_DIR}/current" 2>/dev/null || true)"
printf '%s\n' "${OLD_RELEASE}" >"${STATE_DIR}/previous.tmp"
printf '%s\n' "${RELEASE_ID}" >"${STATE_DIR}/current.tmp"
mv "${STATE_DIR}/previous.tmp" "${STATE_DIR}/previous"
mv "${STATE_DIR}/current.tmp" "${STATE_DIR}/current"
logger -t llm-deploy \
"release=${RELEASE_ID} previous=${OLD_RELEASE:-none} status=success"
rm -f "${BACKUP}" "${NEW_UPSTREAM}"
候选版本检查失败时,脚本不切换流量,也不默认删除容器,便于保留日志。确认不再需要现场后,才可以对明确的候选项目执行:
docker compose -p "llm-<候选发布编号>" \
--env-file "/opt/llm-inference/releases/<候选发布编号>/manifest.env" \
-f "/opt/llm-inference/releases/<候选发布编号>/compose.yaml" down
down 会停止并删除该Compose项目中的容器和网络。执行前必须再次核对项目名,且不要附加 -v,以免删除关联卷。
单卡资源不足时不能机械使用蓝绿发布
如果旧模型已经占满显存,新旧实例通常无法并行加载。此时蓝绿发布可能在候选阶段直接出现显存不足,自动化流程应改成“可回退的停止—启动”模式:
- 提前拉取镜像并完成配置检查。
- 停止接收新请求,等待在途请求结束。
- 停止旧实例,但保留旧发布目录和镜像。
- 启动新实例并完成就绪、推理探测。
- 新实例失败时立即停止候选版本,按旧清单重新启动旧实例。
- 旧版本恢复健康后再开放入口。
这种方式能够自动恢复,但不能承诺无中断。是否采用蓝绿发布,取决于服务器能否同时容纳两份模型及其缓存,而不是取决于脚本是否支持两个端口。
回滚也不能只做“镜像回退”。如果新版本修改了共享可写数据、缓存格式或外部数据库,旧程序可能无法读取新格式。模型目录应尽量只读挂载;需要变更共享状态时,应单独设计向后兼容和数据备份方案。
配置管理不要在变更后直接重启服务
Ansible等配置管理工具适合保证目录、权限和配置内容一致,但不应在模板发生变化后立刻触发推理服务重启。更安全的做法是:配置变更生成新发布单元,经过校验后再交给部署流程切换。
以下Ansible任务示例只负责落盘和检查,不自动重启:
- name: 创建推理服务配置目录
ansible.builtin.file:
path: /etc/llm-inference
state: directory
owner: root
group: llmops
mode: "0750"
- name: 渲染运行配置
ansible.builtin.template:
src: runtime.env.j2
dest: /etc/llm-inference/runtime.env
owner: root
group: llmops
mode: "0640"
register: runtime_config_result
- name: 检查发布配置能否被Compose解析
ansible.builtin.command:
argv:
- docker
- compose
- --env-file
- /opt/llm-inference/releases/{{ release_id }}/manifest.env
- -f
- /opt/llm-inference/releases/{{ release_id }}/compose.yaml
- config
- -q
changed_when: false
密钥、访问令牌和对象存储凭据不要写入普通Git仓库,也不要输出到Ansible日志。处理密钥的任务应使用受控密钥系统,并根据需要设置 no_log: true。
定时任务负责发现问题,不负责盲目重启
定时巡检建议使用systemd timer,而不是在多个用户的crontab中分散维护。巡检默认只检查并告警,不应无限重启容器。只有同时满足“刚完成发布、上一版本已知正常、失败达到规定阈值”时,才适合触发自动回滚。
服务单元示例:
[Unit]
Description=LLM inference health watchdog
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/llm-watchdog --check-only
定时器示例:
[Unit]
Description=Run LLM inference health watchdog periodically
[Timer]
OnBootSec=3min
OnUnitActiveSec=5min
RandomizedDelaySec=30s
Persistent=true
[Install]
WantedBy=timers.target
核对脚本路径和权限后,再加载定时器:
systemctl daemon-reload
systemctl enable --now llm-watchdog.timer
systemctl list-timers llm-watchdog.timer
journalctl -u llm-watchdog.service --since today
巡检至少应区分连接失败、HTTP错误、模型未就绪、响应结构异常和响应超时。连续失败次数应写入独立状态文件,成功后清零,避免单次抖动触发回滚。
出现故障时按优先级缩小范围
排查时先做只读检查,确认故障位置后再重启或回滚。
1. 检查代理入口和本地后端
curl -v --max-time 5 http://127.0.0.1/health
curl -v --max-time 5 http://127.0.0.1:<当前后端端口>/<实际健康路径>
journalctl -u nginx --since "-15 min" --no-pager
- 代理入口失败、后端正常:重点检查Nginx上游、监听地址和重载结果。
- 两者都失败:继续检查容器和模型加载。
- 两者都正常但业务请求失败:进入业务级探测和请求日志检查。
2. 核对当前版本是否与发布记录一致
cat /opt/llm-inference/state/current
cat /opt/llm-inference/state/previous
docker compose -p "llm-<当前发布编号>" \
--env-file "/opt/llm-inference/releases/<当前发布编号>/manifest.env" \
-f "/opt/llm-inference/releases/<当前发布编号>/compose.yaml" ps
如果状态文件指向新版本,而Nginx仍指向旧端口,说明切换或状态更新中断。应先确定实际承载流量的版本,不要直接覆盖状态文件。
3. 检查配置和镜像摘要
docker compose \
--env-file "/opt/llm-inference/releases/<发布编号>/manifest.env" \
-f "/opt/llm-inference/releases/<发布编号>/compose.yaml" config
sha256sum /etc/llm-inference/runtime.env
docker image inspect "<带摘要的完整镜像引用>"
配置摘要不一致通常意味着存在人工修改或发布内容未同步。此时应重新生成发布单元,不建议继续修改线上文件“凑出”正确状态。
4. 检查容器退出原因和推理日志
docker compose -p "llm-<发布编号>" \
--env-file "/opt/llm-inference/releases/<发布编号>/manifest.env" \
-f "/opt/llm-inference/releases/<发布编号>/compose.yaml" logs \
--tail 300 --timestamps
docker inspect "llm-<发布编号>-inference-1" \
--format '{{json .State}}'
退出码为137可能与进程被强制终止有关,但不能只凭退出码认定为内存不足,还应查看 .State.OOMKilled、内核日志和发布过程中的停止操作。
5. 检查主机和GPU状态
nvidia-smi
journalctl -k --since "-30 min" --no-pager | grep -iE 'oom|NVRM|Xid'
journalctl -u docker --since "-30 min" --no-pager
df -h
df -i
- 主机OOM记录:检查内存、并发数、模型加载方式和容器限制。
- GPU错误或显存不足:确认是否同时运行了新旧模型,以及模型并行参数是否发生变化。
- 磁盘或inode耗尽:检查镜像、日志和模型缓存,但不要在未确认用途时批量删除文件。
上线后的验证与回滚边界
完成修复或回滚后,不应只看一次健康检查。至少需要核对:
current指向实际承载流量的版本,previous仍可用于回退。- Nginx上游端口与当前发布清单一致,
nginx -t通过。 - 容器没有持续重启,模型日志中没有重复加载或显存分配失败。
- 代理入口、后端就绪接口和业务级推理探测均通过。
- 审计日志包含发布编号、镜像摘要、模型版本、配置摘要、操作者、失败阶段和回滚结果。
- 上一版本在观察期结束前没有被提前删除。
- 定时巡检不会因单次超时触发重启风暴。
- 日志中没有记录访问令牌、完整用户提示词或其他敏感内容。
最容易忽略的是“旧版本还能启动”这一条件。镜像存在并不代表模型文件、配置文件和依赖状态仍然可用。应定期对上一发布单元执行只读完整性检查,并在维护窗口验证回滚路径;否则自动化系统保存的可能只是一个无法真正恢复的版本编号。