mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 08:57:57 +00:00
fix(deploy): roll back config together with the binary, and preflight it before restart
The 446-restart-loop incident: adding a headers block to a source without
removing the source's existing `headers: {}` produced a duplicate YAML key.
The process exited on startup, healthcheck failed, and rollback restored only
the binary — so the old binary kept parsing the same broken config and the
service span in systemd's restart loop. Config was treated as out of scope
for deployment; it is not.
Three changes close the loop:
1. cmd/llmsproxy: new `-check` flag validates a config (parse +
ApplyDefaults) and exits, without starting the Lua VM, touching
runtime.json, or binding a port — safe to run against a live service.
Unlike normal startup it does NOT create a default config, so a missing
file is an error.
2. deploy.sh `--config <file>`: stage a config for deployment, atomically
renamed into place with the same copy->rename(2) technique as the binary.
Omitted means the live config is left alone.
3. Ordering: config replacement and preflight both run BEFORE
restart_service, so an invalid config is caught while the service is still
healthy and never triggers a restart. rollback() now restores binary AND
config (only when this run replaced it, so concurrent WebUI edits survive),
then re-runs -check before restarting — refusing to restart into a config
that still fails, instead of trading one restart storm for another.
Also: the sha256-unchanged early exit now only fires when there is no pending
config, otherwise `--config` would be silently dropped.
Verified on the live deployment:
- reproduced the exact duplicate-key config: preflight caught it, PID
unchanged (zero interruption), binary and config both rolled back, gateway
still answering 200
- valid config: replaced, service restarted, new value live
- no --config: binary-only deploy unaffected
- go test -tags luajit ./... passes
This commit is contained in:
@ -23,8 +23,24 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfgPath := flag.String("config", "config.yaml", "path to gateway config file")
|
cfgPath := flag.String("config", "config.yaml", "path to gateway config file")
|
||||||
|
checkOnly := flag.Bool("check", false, "validate the config file and exit (0 = valid, 1 = invalid); nothing is started and no file is written")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
// -check is the deploy-time preflight: parse and validate the config
|
||||||
|
// without starting the Lua VM, touching runtime.json, or binding a port.
|
||||||
|
// It deliberately does NOT call EnsureDefault, so a missing file is an
|
||||||
|
// error here instead of being silently created.
|
||||||
|
if *checkOnly {
|
||||||
|
if _, err := os.Stat(*cfgPath); err != nil {
|
||||||
|
log.Fatalf("[llmsproxy] check: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := config.Load(*cfgPath); err != nil {
|
||||||
|
log.Fatalf("[llmsproxy] check: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("[llmsproxy] check: %s is valid", *cfgPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
created, err := config.EnsureDefault(*cfgPath)
|
created, err := config.EnsureDefault(*cfgPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("[llmsproxy] config: %v", err)
|
log.Fatalf("[llmsproxy] config: %v", err)
|
||||||
|
|||||||
171
deploy.sh
171
deploy.sh
@ -10,6 +10,16 @@
|
|||||||
# 4. systemctl restart → 服务中断仅在 Go 进程重启瞬间 (<500ms)
|
# 4. systemctl restart → 服务中断仅在 Go 进程重启瞬间 (<500ms)
|
||||||
# 5. 健康检查确认新进程正常
|
# 5. 健康检查确认新进程正常
|
||||||
#
|
#
|
||||||
|
# 配置文件(2026-09-05 起):
|
||||||
|
# 本脚本不再假设 config.yaml 与部署无关。实测教训:给源加 headers 时
|
||||||
|
# 漏删原有的 `headers: {}` → YAML 重复键 → 进程启动即退出 → 健康检查
|
||||||
|
# 失败 → 旧的 rollback 只恢复二进制,配置错误仍在,于是旧二进制也起不来,
|
||||||
|
# 服务陷入 systemd 重启循环(当时累计 446 次)。
|
||||||
|
# 因此:
|
||||||
|
# - 支持 --config <file> 投放一份待部署配置(原子 rename 到 TARGET_CONFIG)
|
||||||
|
# - 重启前先用新二进制 `-check` 预检配置,不合法就地失败,绝不重启服务
|
||||||
|
# - rollback 同时恢复二进制与配置文件,回滚后再次 -check 才重启
|
||||||
|
#
|
||||||
# 注意事项:
|
# 注意事项:
|
||||||
# - 本服务依赖自身(推理依赖),restart 前后会短暂中断,
|
# - 本服务依赖自身(推理依赖),restart 前后会短暂中断,
|
||||||
# 但重试机制(systemd RestartSec=5 + 客户端 retry)可自愈
|
# 但重试机制(systemd RestartSec=5 + 客户端 retry)可自愈
|
||||||
@ -19,6 +29,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
# ---------- 配置 ----------
|
# ---------- 配置 ----------
|
||||||
readonly TARGET_BIN="/usr/local/bin/llmsproxy"
|
readonly TARGET_BIN="/usr/local/bin/llmsproxy"
|
||||||
|
readonly TARGET_CONFIG="/etc/llmsproxy/config.yaml"
|
||||||
readonly TARGET_ADAPTERS="/etc/llmsproxy/adapters"
|
readonly TARGET_ADAPTERS="/etc/llmsproxy/adapters"
|
||||||
readonly REPO_DIR="/home/program/llmsproxy"
|
readonly REPO_DIR="/home/program/llmsproxy"
|
||||||
readonly HEALTH_URL="http://127.0.0.1:8081/v1/models"
|
readonly HEALTH_URL="http://127.0.0.1:8081/v1/models"
|
||||||
@ -28,8 +39,40 @@ readonly BIN_NAME="llmsproxy"
|
|||||||
readonly TMP_DIR="/tmp/llmsproxy-deploy"
|
readonly TMP_DIR="/tmp/llmsproxy-deploy"
|
||||||
# 同文件系统暂存文件(用于原子 rename)
|
# 同文件系统暂存文件(用于原子 rename)
|
||||||
readonly STAGING_BIN="${TARGET_BIN}.staging"
|
readonly STAGING_BIN="${TARGET_BIN}.staging"
|
||||||
# 回滚备份:健康检查失败时从这里恢复旧二进制
|
readonly STAGING_CONFIG="${TARGET_CONFIG}.staging"
|
||||||
|
# 回滚备份:健康检查失败时从这里恢复旧二进制 / 旧配置
|
||||||
readonly BACKUP_BIN="${TARGET_BIN}.bak"
|
readonly BACKUP_BIN="${TARGET_BIN}.bak"
|
||||||
|
readonly BACKUP_CONFIG="${TARGET_CONFIG}.rollback.bak"
|
||||||
|
|
||||||
|
# 待部署配置(--config <file> 指定;为空 = 不改动线上配置)
|
||||||
|
NEW_CONFIG_SRC=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
用法: ./deploy.sh [--config <file>]
|
||||||
|
|
||||||
|
--config <file> 投放这份配置到 /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)" "$*"; }
|
log() { printf '\033[1;34m[%s]\033[0m %s\n' "$(date +%H:%M:%S)" "$*"; }
|
||||||
@ -40,7 +83,7 @@ fail() { err "$@"; exit 1; }
|
|||||||
# ---------- 清理 ----------
|
# ---------- 清理 ----------
|
||||||
cleanup() {
|
cleanup() {
|
||||||
log "清理临时文件"
|
log "清理临时文件"
|
||||||
rm -rf "$TMP_DIR" "$STAGING_BIN"
|
rm -rf "$TMP_DIR" "$STAGING_BIN" "$STAGING_CONFIG"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
@ -52,6 +95,18 @@ precheck() {
|
|||||||
command -v systemctl &>/dev/null || fail "未找到 systemctl"
|
command -v systemctl &>/dev/null || fail "未找到 systemctl"
|
||||||
[[ $(id -u) -eq 0 ]] || fail "需要 root 权限"
|
[[ $(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_SIZE=$(stat -c%s "$TARGET_BIN" 2>/dev/null || echo 0)
|
||||||
OLD_SHA256=$(sha256sum "$TARGET_BIN" 2>/dev/null | awk '{print $1}')
|
OLD_SHA256=$(sha256sum "$TARGET_BIN" 2>/dev/null | awk '{print $1}')
|
||||||
|
|
||||||
@ -59,8 +114,10 @@ precheck() {
|
|||||||
log "go: $(go version)"
|
log "go: $(go version)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------- 备份旧二进制(用于回滚)----------
|
# ---------- 备份旧二进制 + 旧配置(用于回滚)----------
|
||||||
backup_old_binary() {
|
# 配置也必须备份:启动失败常常是配置问题,只回滚二进制会让旧二进制
|
||||||
|
# 继续吃坏配置,服务照样起不来。
|
||||||
|
backup_old_state() {
|
||||||
log "备份旧二进制到 $BACKUP_BIN"
|
log "备份旧二进制到 $BACKUP_BIN"
|
||||||
if [[ -f "$TARGET_BIN" ]]; then
|
if [[ -f "$TARGET_BIN" ]]; then
|
||||||
cp -f "$TARGET_BIN" "$BACKUP_BIN"
|
cp -f "$TARGET_BIN" "$BACKUP_BIN"
|
||||||
@ -68,6 +125,13 @@ backup_old_binary() {
|
|||||||
else
|
else
|
||||||
warn "旧二进制不存在,跳过备份(无回滚点)"
|
warn "旧二进制不存在,跳过备份(无回滚点)"
|
||||||
fi
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------- 构建 ----------
|
# ---------- 构建 ----------
|
||||||
@ -89,9 +153,14 @@ build() {
|
|||||||
if [[ "$NEW_SIZE" -eq "$OLD_SIZE" ]]; then
|
if [[ "$NEW_SIZE" -eq "$OLD_SIZE" ]]; then
|
||||||
NEW_SHA256=$(sha256sum "$NEW_BIN" | awk '{print $1}')
|
NEW_SHA256=$(sha256sum "$NEW_BIN" | awk '{print $1}')
|
||||||
if [[ "$NEW_SHA256" == "$OLD_SHA256" ]]; then
|
if [[ "$NEW_SHA256" == "$OLD_SHA256" ]]; then
|
||||||
warn "新二进制与旧版本完全一致(sha256 相同),跳过部署"
|
# 二进制无变化。只有在同时也没有待部署配置时才能直接退出;
|
||||||
|
# 否则会造成“--config 指定了新配置却被静默丢弃”。
|
||||||
|
if [[ -z "$NEW_CONFIG_SRC" ]]; then
|
||||||
|
warn "新二进制与旧版本完全一致(sha256 相同)且无待部署配置,跳过部署"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
log "二进制无变化(sha256 相同),但有待部署配置,继续"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "新二进制: $NEW_BIN (${NEW_SIZE} bytes)"
|
log "新二进制: $NEW_BIN (${NEW_SIZE} bytes)"
|
||||||
@ -140,6 +209,41 @@ atomic_replace_binary() {
|
|||||||
log "✓ 二进制已原子替换 (sha256=${NEW_SHA256:0:16})"
|
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() {
|
sync_adapters() {
|
||||||
log "同步适配器"
|
log "同步适配器"
|
||||||
@ -206,20 +310,49 @@ healthcheck() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ---------- 回滚 ----------
|
# ---------- 回滚 ----------
|
||||||
# 健康检查失败时恢复旧二进制并重启,保证推理服务可用。
|
# 健康检查失败时恢复旧二进制**和旧配置**并重启,保证推理服务可用。
|
||||||
# 这是最后一道保险:即使新版本启动失败(编译错误、配置不兼容、
|
# 这是最后一道保险:即使新版本启动失败(编译错误、配置不兼容、
|
||||||
# panic on start 等),也能让旧版本继续服务,避免推理能力丢失。
|
# panic on start 等),也能让旧版本继续服务,避免推理能力丢失。
|
||||||
|
#
|
||||||
|
# 为什么必须一并回滚配置:启动失败最常见的原因就是配置本身不合法
|
||||||
|
# (实测:YAML 重复键)。只恢复二进制的话,旧二进制照样解析不了坏配置,
|
||||||
|
# 服务继续在 systemd 重启循环里空转。
|
||||||
rollback() {
|
rollback() {
|
||||||
err "健康检查失败,启动回滚流程"
|
err "健康检查失败,启动回滚流程"
|
||||||
if [[ ! -f "$BACKUP_BIN" ]]; then
|
|
||||||
err "无回滚备份 ($BACKUP_BIN 不存在),无法自动恢复"
|
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"
|
err "请手动检查: systemctl status llmsproxy.service && journalctl -u llmsproxy -n 50"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "恢复旧二进制"
|
# 回滚后先验证恢复出来的组合真的能解析,再重启——否则只是把
|
||||||
cp -f "$BACKUP_BIN" "$TARGET_BIN"
|
# 重启循环换个二进制继续跑。
|
||||||
chmod 0755 "$TARGET_BIN"
|
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 "重启服务(旧版本)"
|
log "重启服务(旧版本)"
|
||||||
systemctl restart llmsproxy.service || {
|
systemctl restart llmsproxy.service || {
|
||||||
@ -248,18 +381,32 @@ print_summary() {
|
|||||||
log "═════════════════════════════════════════════"
|
log "═════════════════════════════════════════════"
|
||||||
log "部署完成"
|
log "部署完成"
|
||||||
log " 二进制: $TARGET_BIN"
|
log " 二进制: $TARGET_BIN"
|
||||||
|
log " 配置: $TARGET_CONFIG$([[ -n "$NEW_CONFIG_SRC" ]] && echo " (本次已替换)" || echo " (未改动)")"
|
||||||
log " 适配器: $TARGET_ADAPTERS"
|
log " 适配器: $TARGET_ADAPTERS"
|
||||||
log "═════════════════════════════════════════════"
|
log "═════════════════════════════════════════════"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------- 主流程 ----------
|
# ---------- 主流程 ----------
|
||||||
|
# 顺序要点:配置投放与预检都排在 restart_service **之前**,
|
||||||
|
# 于是坏配置在服务仍然健康时就被拦下,根本不会触发重启。
|
||||||
main() {
|
main() {
|
||||||
|
parse_args "$@"
|
||||||
log "═══ llmsproxy 原子部署开始 ═══"
|
log "═══ llmsproxy 原子部署开始 ═══"
|
||||||
precheck
|
precheck
|
||||||
build
|
build
|
||||||
sync_adapters
|
sync_adapters
|
||||||
backup_old_binary
|
backup_old_state
|
||||||
atomic_replace_binary
|
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
|
restart_service
|
||||||
if ! healthcheck; then
|
if ! healthcheck; then
|
||||||
rollback || true
|
rollback || true
|
||||||
|
|||||||
Reference in New Issue
Block a user