#!/usr/bin/env bash # ============================================================ # llmsproxy 原子化滚动部署脚本 # # 策略: # 1. /tmp (tmpfs) 构建,/usr/local/bin (ext4) 部署 # 2. 构建后先 copy 到目标文件系统(.llmsproxy.tmp), # 再 rename(2) 做原子替换(同一文件系统内 mv 是原子的) # 3. runtime.json 不替换(保留运行时密钥、审计状态) # 4. systemctl restart → 服务中断仅在 Go 进程重启瞬间 (<500ms) # 5. 健康检查确认新进程正常 # # 配置文件(2026-09-05 起): # 本脚本不再假设 config.yaml 与部署无关。实测教训:给源加 headers 时 # 漏删原有的 `headers: {}` → YAML 重复键 → 进程启动即退出 → 健康检查 # 失败 → 旧的 rollback 只恢复二进制,配置错误仍在,于是旧二进制也起不来, # 服务陷入 systemd 重启循环(当时累计 446 次)。 # 因此: # - 支持 --config 投放一份待部署配置(原子 rename 到 TARGET_CONFIG) # - 重启前先用新二进制 `-check` 预检配置,不合法就地失败,绝不重启服务 # - rollback 同时恢复二进制与配置文件,回滚后再次 -check 才重启 # # 注意事项: # - 本服务依赖自身(推理依赖),restart 前后会短暂中断, # 但重试机制(systemd RestartSec=5 + 客户端 retry)可自愈 # - 适配器文件替换后需重启才能生效(VM 启动时批量加载) # ============================================================ set -euo pipefail # ---------- 配置 ---------- readonly TARGET_BIN="/usr/local/bin/llmsproxy" readonly TARGET_CONFIG="/etc/llmsproxy/config.yaml" readonly TARGET_ADAPTERS="/etc/llmsproxy/adapters" readonly REPO_DIR="/home/program/llmsproxy" readonly HEALTH_URL="http://127.0.0.1:8081/v1/models" readonly BIN_NAME="llmsproxy" # 构建目录在 /tmp(tmpfs 更快) readonly TMP_DIR="/tmp/llmsproxy-deploy" # 同文件系统暂存文件(用于原子 rename) readonly STAGING_BIN="${TARGET_BIN}.staging" readonly STAGING_CONFIG="${TARGET_CONFIG}.staging" # 回滚备份:健康检查失败时从这里恢复旧二进制 / 旧配置 readonly BACKUP_BIN="${TARGET_BIN}.bak" readonly BACKUP_CONFIG="${TARGET_CONFIG}.rollback.bak" # 待部署配置(--config 指定;为空 = 不改动线上配置) NEW_CONFIG_SRC="" usage() { cat <<'EOF' 用法: ./deploy.sh [--config ] --config 投放这份配置到 /etc/llmsproxy/config.yaml(原子替换)。 省略时不改动线上配置,只部署二进制与适配器。 无论是否指定,重启前都会用新二进制 -check 预检线上配置。 流程: 构建 → 适配器同步 → 备份(二进制+配置) → 原子替换 → 配置预检 → 重启 → 健康检查 → 失败则回滚(二进制+配置)并重启 EOF } parse_args() { while (( $# > 0 )); do case "$1" in --config) [[ $# -ge 2 ]] || { echo "--config 需要一个文件参数" >&2; exit 2; } NEW_CONFIG_SRC="$2" shift 2 ;; -h|--help) usage; exit 0 ;; *) echo "未知参数: $1" >&2; usage; exit 2 ;; esac done } # ---------- 日志 ---------- log() { printf '\033[1;34m[%s]\033[0m %s\n' "$(date +%H:%M:%S)" "$*"; } warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; } err() { printf '\033[1;31m[ERR]\033[0m %s\n' "$*" >&2; } fail() { err "$@"; exit 1; } # ---------- 清理 ---------- cleanup() { log "清理临时文件" rm -rf "$TMP_DIR" "$STAGING_BIN" "$STAGING_CONFIG" } trap cleanup EXIT # ---------- 前置检查 ---------- precheck() { log "前置检查" [[ -f "$REPO_DIR/go.mod" ]] || fail "仓库目录 $REPO_DIR 不存在" command -v go &>/dev/null || fail "未找到 go" command -v systemctl &>/dev/null || fail "未找到 systemctl" [[ $(id -u) -eq 0 ]] || fail "需要 root 权限" if [[ -n "$NEW_CONFIG_SRC" ]]; then [[ -f "$NEW_CONFIG_SRC" ]] || fail "待部署配置不存在: $NEW_CONFIG_SRC" # 同一文件比对:--config 指向线上文件本身时没有"投放"动作可做, # 但仍然会走重启前的 -check 预检。 if [[ "$(readlink -f "$NEW_CONFIG_SRC")" == "$(readlink -f "$TARGET_CONFIG" 2>/dev/null)" ]]; then warn "--config 指向线上配置本身,跳过投放(仍会预检)" NEW_CONFIG_SRC="" else log "待部署配置: $NEW_CONFIG_SRC → $TARGET_CONFIG" fi fi OLD_SIZE=$(stat -c%s "$TARGET_BIN" 2>/dev/null || echo 0) OLD_SHA256=$(sha256sum "$TARGET_BIN" 2>/dev/null | awk '{print $1}') log "旧二进制: $TARGET_BIN (${OLD_SIZE} bytes, sha256=${OLD_SHA256:0:16})" log "go: $(go version)" } # ---------- 备份旧二进制 + 旧配置(用于回滚)---------- # 配置也必须备份:启动失败常常是配置问题,只回滚二进制会让旧二进制 # 继续吃坏配置,服务照样起不来。 backup_old_state() { log "备份旧二进制到 $BACKUP_BIN" if [[ -f "$TARGET_BIN" ]]; then cp -f "$TARGET_BIN" "$BACKUP_BIN" log "✓ 旧二进制已备份 (sha256=$(sha256sum "$BACKUP_BIN" | awk '{print $1}' | cut -c1-16))" else warn "旧二进制不存在,跳过备份(无回滚点)" fi if [[ -f "$TARGET_CONFIG" ]]; then cp -f "$TARGET_CONFIG" "$BACKUP_CONFIG" log "✓ 旧配置已备份到 $BACKUP_CONFIG (sha256=$(sha256sum "$BACKUP_CONFIG" | awk '{print $1}' | cut -c1-16))" else warn "线上配置不存在: $TARGET_CONFIG(首次部署?无配置回滚点)" fi } # ---------- 构建 ---------- build() { log "构建新二进制" rm -rf "$TMP_DIR" mkdir -p "$TMP_DIR/bin" cd "$REPO_DIR" CGO_ENABLED=1 go build -tags luajit \ -trimpath \ -ldflags="-s -w" \ -o "$TMP_DIR/bin/$BIN_NAME" \ ./cmd/llmsproxy NEW_BIN="$TMP_DIR/bin/$BIN_NAME" NEW_SIZE=$(stat -c%s "$NEW_BIN") if [[ "$NEW_SIZE" -eq "$OLD_SIZE" ]]; then NEW_SHA256=$(sha256sum "$NEW_BIN" | awk '{print $1}') if [[ "$NEW_SHA256" == "$OLD_SHA256" ]]; then # 二进制无变化。只有在同时也没有待部署配置时才能直接退出; # 否则会造成“--config 指定了新配置却被静默丢弃”。 if [[ -z "$NEW_CONFIG_SRC" ]]; then warn "新二进制与旧版本完全一致(sha256 相同)且无待部署配置,跳过部署" exit 0 fi log "二进制无变化(sha256 相同),但有待部署配置,继续" fi fi log "新二进制: $NEW_BIN (${NEW_SIZE} bytes)" # 验证:确认包含关键修复 local checks=( "mergeUsage" "prompt_tokens" "completion_tokens" "message_start" ) for c in "${checks[@]}"; do if strings "$NEW_BIN" | grep -qF "$c"; then log " ✓ 新二进制包含: $c" else warn " ⚠ 新二进制不包含: $c(非预期但非致命)" fi done } # ---------- 原子替换二进制 ---------- # 原理: # /tmp 是 tmpfs,/usr/local/bin 是 ext4——不同文件系统 rename(2) 不原子。 # 因此先 copy 到目标文件系统上的 .staging 文件,再 rename(2) 到目标路径。 # rename(2) 在同一文件系统内是元数据级操作,对正在读写的进程安全: # - 旧 inode 的 fd 继续有效(进程持有旧文件,不会读到截断内容) # - 新进程打开 path 看到新 inode # - 旧 inode 在最后一个 fd 关闭后释放 atomic_replace_binary() { log "原子替换二进制" local new_bin_tmp="$TMP_DIR/bin/$BIN_NAME" [[ -f "$new_bin_tmp" ]] || fail "新二进制文件不存在: $new_bin_tmp" # Step 1: copy 到目标文件系统(非原子,但目标文件唯一) log " copy → $STAGING_BIN (同文件系统暂存)" cp -f "$new_bin_tmp" "$STAGING_BIN" chmod 0755 "$STAGING_BIN" # Step 2: 原子 rename(同一文件系统,元数据级操作) log " rename → $TARGET_BIN (原子)" mv -f "$STAGING_BIN" "$TARGET_BIN" # 验证 NEW_SHA256=$(sha256sum "$TARGET_BIN" | awk '{print $1}') log "✓ 二进制已原子替换 (sha256=${NEW_SHA256:0:16})" } # ---------- 原子替换配置 ---------- # 与二进制同一套 copy→rename 手法:先落到 /etc 上的 .staging,再 rename(2)。 # 只有 --config 指定了来源时才动线上配置。 atomic_replace_config() { [[ -n "$NEW_CONFIG_SRC" ]] || return 0 log "原子替换配置" log " copy → $STAGING_CONFIG (同文件系统暂存)" cp -f "$NEW_CONFIG_SRC" "$STAGING_CONFIG" chmod 0644 "$STAGING_CONFIG" log " rename → $TARGET_CONFIG (原子)" mv -f "$STAGING_CONFIG" "$TARGET_CONFIG" log "✓ 配置已原子替换 (sha256=$(sha256sum "$TARGET_CONFIG" | awk '{print $1}' | cut -c1-16))" } # ---------- 配置预检 ---------- # 用刚部署的新二进制解析线上配置。`-check` 只做 parse + ApplyDefaults, # 不启动 Lua VM、不碰 runtime.json、不绑端口,因此可以安全地在服务仍在 # 运行时执行。不合法就在重启前失败——这正是上次事故缺的那一步。 verify_config() { log "配置预检(新二进制 -check)" [[ -f "$TARGET_CONFIG" ]] || fail "线上配置不存在: $TARGET_CONFIG" local out if out=$("$TARGET_BIN" -check -config "$TARGET_CONFIG" 2>&1); then log "✓ 配置合法" return 0 fi err "配置预检失败,未重启服务(线上进程仍在跑旧配置):" printf '%s\n' "$out" >&2 return 1 } # ---------- 同步适配器 ---------- sync_adapters() { log "同步适配器" # 只覆盖内置适配器(与 internal/lua/adapters 同名的文件), # 保留目录里其它运行时上传的 .lua —— WebUI 上传的自定义适配器 # 必须跨部署存活,清空会静默移除线上源依赖的适配器。 SRC_ADAPTERS="$REPO_DIR/internal/lua/adapters" [[ -d "$SRC_ADAPTERS" ]] || fail "内嵌适配器目录不存在: $SRC_ADAPTERS" # 备份旧的适配器文件(含任何运行时覆盖版本,如 opencode.lua) BACKUP_DIR="/etc/llmsproxy/adapters.bak.$(date +%Y%m%d%H%M%S)" if [[ -d "$TARGET_ADAPTERS" ]]; then mkdir -p "$BACKUP_DIR" cp -rf "$TARGET_ADAPTERS/"*.lua "$BACKUP_DIR/" 2>/dev/null || true log " 旧适配器已备份到 $BACKUP_DIR" fi cp -f "$SRC_ADAPTERS/"*.lua "$TARGET_ADAPTERS/" chmod 0644 "$TARGET_ADAPTERS/"*.lua # 校验:每个适配器必须包含 usage 透传修复(opencode/openai/deepseek 等应有 'uses') local required_files=(openai deepseek anthropic gemini ollama opencode github groq kimicode mistral sensenova agentrouter) for f in "${required_files[@]}"; do [[ -f "$TARGET_ADAPTERS/$f.lua" ]] || warn " 缺少适配器: $f.lua" done local usage_fixed=0 for f in openai deepseek github groq kimicode mistral opencode; do grep -qE '\buses\b' "$TARGET_ADAPTERS/$f.lua" 2>/dev/null && ((usage_fixed++)) || true done log " usage 透传修复适配器: ${usage_fixed}/7 (openai/deepseek/github/groq/kimicode/mistral/opencode)" log "✓ 适配器已同步到 $TARGET_ADAPTERS/" } # ---------- 重启服务 ---------- restart_service() { log "重启服务(中断 <500ms)" systemctl restart llmsproxy.service || fail "systemd restart 失败" log "✓ llmsproxy.service restarted" } # ---------- 健康检查 ---------- healthcheck() { log "健康检查" local attempts=20 local wait_sec=1 local attempt=1 while (( attempt <= attempts )); do # 用 curl 检查(200=有 key 未鉴权但服务正常,401=需要认证,都说明服务在运行) local code code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "$HEALTH_URL" 2>/dev/null || echo "000") if [[ "$code" == "200" || "$code" == "401" ]]; then log "✓ 服务健康 (status=$code, ${attempt}/${attempts})" return 0 fi log " 等待中 ($attempt/$attempts, status=$code)" sleep "$wait_sec" (( attempt++ )) done warn "健康检查未通过,请检查服务状态: systemctl status llmsproxy.service" return 1 } # ---------- 回滚 ---------- # 健康检查失败时恢复旧二进制**和旧配置**并重启,保证推理服务可用。 # 这是最后一道保险:即使新版本启动失败(编译错误、配置不兼容、 # panic on start 等),也能让旧版本继续服务,避免推理能力丢失。 # # 为什么必须一并回滚配置:启动失败最常见的原因就是配置本身不合法 # (实测:YAML 重复键)。只恢复二进制的话,旧二进制照样解析不了坏配置, # 服务继续在 systemd 重启循环里空转。 rollback() { err "健康检查失败,启动回滚流程" local restored_any=0 if [[ -f "$BACKUP_BIN" ]]; then log "恢复旧二进制" cp -f "$BACKUP_BIN" "$TARGET_BIN" chmod 0755 "$TARGET_BIN" restored_any=1 else err "无二进制回滚备份 ($BACKUP_BIN 不存在)" fi # 只在本次真的换过配置时才回滚配置,避免把用户在部署期间 # 通过 WebUI 做的合法改动一起抹掉。 if [[ -n "$NEW_CONFIG_SRC" && -f "$BACKUP_CONFIG" ]]; then log "恢复旧配置" cp -f "$BACKUP_CONFIG" "$TARGET_CONFIG" chmod 0644 "$TARGET_CONFIG" restored_any=1 fi if (( restored_any == 0 )); then err "无任何回滚点,无法自动恢复" err "请手动检查: systemctl status llmsproxy.service && journalctl -u llmsproxy -n 50" return 1 fi # 回滚后先验证恢复出来的组合真的能解析,再重启——否则只是把 # 重启循环换个二进制继续跑。 if ! "$TARGET_BIN" -check -config "$TARGET_CONFIG" >/dev/null 2>&1; then err "回滚后的配置仍不合法,拒绝重启(避免 systemd 重启风暴)" err " 手动修复 $TARGET_CONFIG 后执行: $TARGET_BIN -check -config $TARGET_CONFIG" err " 备份可用: 二进制=$BACKUP_BIN 配置=$BACKUP_CONFIG" return 1 fi log "重启服务(旧版本)" systemctl restart llmsproxy.service || { err "重启失败,服务可能完全宕机" err "请手动检查: systemctl status llmsproxy.service" return 1 } # 回滚后再健康检查(只查 1 次,失败就认了) sleep 2 local code code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$HEALTH_URL" 2>/dev/null || echo "000") if [[ "$code" == "200" || "$code" == "401" ]]; then log "✓ 回滚成功,旧版本已恢复服务 (status=$code)" log " 新版本部署失败,请检查构建/配置后重试" return 0 else err "回滚后服务仍未恢复 (status=$code)" err "服务可能已完全宕机,请手动介入" return 1 fi } # ---------- 打印部署摘要 ---------- print_summary() { log "═════════════════════════════════════════════" log "部署完成" log " 二进制: $TARGET_BIN" log " 配置: $TARGET_CONFIG$([[ -n "$NEW_CONFIG_SRC" ]] && echo " (本次已替换)" || echo " (未改动)")" log " 适配器: $TARGET_ADAPTERS" log "═════════════════════════════════════════════" } # ---------- 主流程 ---------- # 顺序要点:配置投放与预检都排在 restart_service **之前**, # 于是坏配置在服务仍然健康时就被拦下,根本不会触发重启。 main() { parse_args "$@" log "═══ llmsproxy 原子部署开始 ═══" precheck build sync_adapters backup_old_state atomic_replace_binary atomic_replace_config if ! verify_config; then # 配置不合法:服务还没重启,只需把二进制/配置退回原状。 err "配置预检失败,回退本次改动(服务未受影响)" [[ -f "$BACKUP_BIN" ]] && cp -f "$BACKUP_BIN" "$TARGET_BIN" && chmod 0755 "$TARGET_BIN" if [[ -n "$NEW_CONFIG_SRC" && -f "$BACKUP_CONFIG" ]]; then cp -f "$BACKUP_CONFIG" "$TARGET_CONFIG" && chmod 0644 "$TARGET_CONFIG" fi exit 1 fi restart_service if ! healthcheck; then rollback || true exit 1 fi print_summary } main "$@"