205 lines
10 KiB
Bash
Executable File
205 lines
10 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# zcbot 蓝绿无感部署(RUN.md §无感更新 B 档)。
|
|
#
|
|
# 拓扑:zcbot@blue(127.0.0.1:8765)/ zcbot@green(127.0.0.1:8766)双 systemd 实例,
|
|
# nginx upstream(/etc/nginx/zcbot_upstream.conf,单行 server 指令)在中间切流量。
|
|
#
|
|
# 干什么(顺序写死):
|
|
# 1) update.sh --prepare-only 拉码 / pip / db migration / docker build(复用单实例脚本)
|
|
# 2) systemctl restart zcbot@<空闲色> 空闲实例起新代码
|
|
# 3) curl 空闲实例 /healthz 验活 + 核对 instance 字段
|
|
# 4) 改写 nginx upstream → reload 新 REST/SSE 全走新实例;旧 SSE 留旧实例不断
|
|
# 5) 等旧实例在跑 run 自然清空 轮询 DB run_owner=旧色 的活跃行,归零才停
|
|
# (上限 DRAIN_WAIT_SECONDS 默 600;期间旧 SSE
|
|
# 继续直播不断流 —— 这步是"无感"的关键)
|
|
# 6) systemctl stop zcbot@<旧live色> 此时通常已无在跑 run,秒停;超上限的残留 run
|
|
# 走 SIGTERM drain → 协作式 cancel(标 idle 可重发)
|
|
# 7) DB 收尾 旧色 SIGKILL 兜底残留的 running 行标 error
|
|
# 8) enable 新 / disable 旧 重启机器只拉起当前 live 色
|
|
#
|
|
# 效果:部署全程无 503 窗口(对比单实例 update.sh 的几十秒 drain 窗口),在跑对话的
|
|
# SSE 直播也不断(对比直接 stop:uvicorn timeout_graceful_shutdown=5 会 5s 掐长连接)。
|
|
#
|
|
# 怎么跑:sudo bash /opt/zcbot/deploy/update_bluegreen.sh
|
|
# --skip-build 透传给 update.sh(跳过 sandbox 镜像重建)
|
|
# --switch blue|green 只切 nginx 流量到指定色(不拉码不重启;半途恢复 / 手动调流用)
|
|
#
|
|
# 一次性 bootstrap(装模板 unit / nginx 配置 / 停旧单实例服务)见 RUN.md §蓝绿部署。
|
|
# 回滚注意:代码目录两实例共享,旧实例停了再起跑的也是新代码 —— 回滚 = git revert 后
|
|
# 重跑本脚本,不是把流量切回旧色。
|
|
|
|
set -euo pipefail
|
|
|
|
ORIG_ARGS=("$@")
|
|
SKIP_BUILD_ARG=()
|
|
SWITCH_TO=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--skip-build) SKIP_BUILD_ARG=(--skip-build); shift ;;
|
|
--switch) SWITCH_TO="${2:-}"; shift 2 ;;
|
|
-h|--help) echo "用法:sudo $0 [--skip-build] [--switch blue|green]"; exit 0 ;;
|
|
*) echo "[deploy] ERROR: 未知参数:$1" >&2; exit 1 ;;
|
|
esac
|
|
done
|
|
|
|
SCRIPT_DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
|
|
APP_DIR="${APP_DIR:-$(dirname "$SCRIPT_DIR")}"
|
|
APP_USER="${APP_USER:-$(stat -c %U "$APP_DIR")}"
|
|
PY="$APP_DIR/.venv/bin/python"
|
|
UPSTREAM_FILE="${UPSTREAM_FILE:-/etc/nginx/zcbot_upstream.conf}"
|
|
# 切流量后等旧实例在跑 run 自然收尾的上限(超了就 stop,残留 run 走协作式 cancel)
|
|
DRAIN_WAIT_SECONDS="${DRAIN_WAIT_SECONDS:-600}"
|
|
|
|
declare -A PORT=([blue]=8765 [green]=8766)
|
|
|
|
log() { echo "[deploy] $*"; }
|
|
fail() { echo "[deploy] ERROR: $*" >&2; exit 1; }
|
|
asuser() { sudo -u "$APP_USER" "$@"; }
|
|
|
|
# ── 0) 前置检查 ────────────────────────────────────────────────
|
|
[ "$(id -u)" -eq 0 ] || fail "需 root(systemctl / 写 nginx 配置),请用:sudo $0"
|
|
[ -d "$APP_DIR/.git" ] || fail "$APP_DIR 不是 git 仓库"
|
|
[ -x "$PY" ] || fail "找不到 venv python:$PY"
|
|
[ -f "$APP_DIR/.env" ] || fail "缺 $APP_DIR/.env"
|
|
command -v nginx >/dev/null || fail "nginx 未安装(蓝绿依赖 nginx 反代,见 RUN.md §蓝绿部署 bootstrap)"
|
|
[ -f "$UPSTREAM_FILE" ] || fail "缺 $UPSTREAM_FILE(nginx upstream 未初始化,见 RUN.md §蓝绿部署 bootstrap)"
|
|
systemctl cat zcbot@blue >/dev/null 2>&1 || fail "模板 unit 未安装(sudo cp deploy/zcbot@.service /etc/systemd/system/ && daemon-reload)"
|
|
if systemctl is-active --quiet zcbot 2>/dev/null; then
|
|
fail "旧单实例服务 zcbot.service 还在跑(占 8765 与 zcbot@blue 冲突)—— 先走 RUN.md §蓝绿部署 bootstrap 停掉它"
|
|
fi
|
|
DB_URL="$(grep -E '^[[:space:]]*ZCBOT_DB_URL=' "$APP_DIR/.env" | tail -n1 | cut -d= -f2-)"
|
|
[ -n "$DB_URL" ] || fail ".env 里没读到 ZCBOT_DB_URL"
|
|
|
|
# ── 当前 live 色:从 nginx upstream 文件反推(单一事实源,不另立状态文件)──
|
|
detect_live() {
|
|
if grep -q ":${PORT[blue]}" "$UPSTREAM_FILE"; then echo blue
|
|
elif grep -q ":${PORT[green]}" "$UPSTREAM_FILE"; then echo green
|
|
else fail "$UPSTREAM_FILE 内容不认识(应含 127.0.0.1:${PORT[blue]} 或 :${PORT[green]}):$(cat "$UPSTREAM_FILE")"
|
|
fi
|
|
}
|
|
|
|
wait_health() { # $1=color;实例 /healthz 返 ok 且 instance 字段对得上
|
|
local color="$1" url="http://127.0.0.1:${PORT[$color]}/healthz" body=""
|
|
for _ in $(seq 1 30); do
|
|
body="$(curl -fsS --max-time 2 "$url" 2>/dev/null || true)"
|
|
if echo "$body" | grep -q '"ok"'; then
|
|
echo "$body" | grep -q "\"instance\":\"$color\"" \
|
|
|| fail "$url 返回的 instance 不是 $color(实例 env 没生效?):$body"
|
|
log "zcbot@$color 健康:$body"
|
|
return 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
echo "[deploy] ----- journalctl -u zcbot@$color -n 50 -----" >&2
|
|
journalctl -u "zcbot@$color" -n 50 --no-pager >&2 || true
|
|
fail "$url 30s 内没返回 ok(看上面日志)"
|
|
}
|
|
|
|
switch_nginx() { # $1=color;改 upstream + 校验 + reload,校验失败回滚原文件
|
|
local color="$1" backup
|
|
backup="$(cat "$UPSTREAM_FILE")"
|
|
echo "server 127.0.0.1:${PORT[$color]};" > "$UPSTREAM_FILE"
|
|
if ! nginx -t 2>&1; then
|
|
echo "$backup" > "$UPSTREAM_FILE"
|
|
fail "nginx -t 校验失败,upstream 已回滚"
|
|
fi
|
|
systemctl reload nginx 2>/dev/null || nginx -s reload
|
|
log "nginx upstream → zcbot@$color(127.0.0.1:${PORT[$color]})"
|
|
}
|
|
|
|
# ── --switch:只调流量,不部署 ─────────────────────────────────
|
|
if [ -n "$SWITCH_TO" ]; then
|
|
[[ "$SWITCH_TO" == "blue" || "$SWITCH_TO" == "green" ]] || fail "--switch 只接受 blue|green"
|
|
systemctl is-active --quiet "zcbot@$SWITCH_TO" || fail "zcbot@$SWITCH_TO 没在跑,先 systemctl start"
|
|
wait_health "$SWITCH_TO"
|
|
switch_nginx "$SWITCH_TO"
|
|
log "OK:流量已切到 zcbot@$SWITCH_TO(另一实例未动,自行决定 stop/disable)"
|
|
exit 0
|
|
fi
|
|
|
|
LIVE="$(detect_live)"
|
|
if [ "$LIVE" = "blue" ]; then IDLE="green"; else IDLE="blue"; fi
|
|
log "当前 live=$LIVE,本次部署到 idle=$IDLE"
|
|
|
|
# ── 1) 准备:拉码 / 依赖 / migration / sandbox 镜像(复用 update.sh)──
|
|
# migration 先于切换跑 —— 项目纪律要求 schema 改动平滑兼容存量代码,旧实例(旧代码)
|
|
# 在新 schema 上继续 drain 是安全的。
|
|
OLD_HEAD="$(asuser git -C "$APP_DIR" rev-parse HEAD)"
|
|
bash "$APP_DIR/deploy/update.sh" --prepare-only "${SKIP_BUILD_ARG[@]}"
|
|
NEW_HEAD="$(asuser git -C "$APP_DIR" rev-parse HEAD)"
|
|
|
|
# 本脚本自身被 pull 更新 → 用新版本重跑(同 update.sh 的自更新逻辑;里面的
|
|
# prepare 已幂等,重跑秒过)。ZCBOT_UPDATE_BG_REEXEC 防死循环。
|
|
if [ "$OLD_HEAD" != "$NEW_HEAD" ] && [ "${ZCBOT_UPDATE_BG_REEXEC:-0}" != "1" ] \
|
|
&& ! asuser git -C "$APP_DIR" diff --quiet "$OLD_HEAD" "$NEW_HEAD" -- deploy/update_bluegreen.sh; then
|
|
log "update_bluegreen.sh 自身有更新 —— 用新版本重跑..."
|
|
exec env ZCBOT_UPDATE_BG_REEXEC=1 bash "$APP_DIR/deploy/update_bluegreen.sh" "${ORIG_ARGS[@]}"
|
|
fi
|
|
|
|
# ── 2) 空闲实例起新代码 ────────────────────────────────────────
|
|
log "systemctl restart zcbot@$IDLE ..."
|
|
systemctl restart "zcbot@$IDLE"
|
|
|
|
# ── 3) 验活(healthz + instance 字段)──────────────────────────
|
|
wait_health "$IDLE"
|
|
|
|
# ── 4) nginx 切流量:新请求全走 $IDLE,旧 SSE 留 $LIVE 上不断 ──
|
|
switch_nginx "$IDLE"
|
|
|
|
# ── 5) 等旧实例在跑的 run 自然清空(SSE 直播不断的关键)──────────
|
|
# 新流量已全走新实例;旧实例上的 run 继续跑、SSE 继续推(nginx reload 不影响已建立
|
|
# 连接)。直接 stop 的话 uvicorn timeout_graceful_shutdown=5 会 5s 掐掉长连接 ——
|
|
# run 能跑完但用户看到断流,所以先等 DB 里 run_owner=旧色 的活跃行归零再停。
|
|
# 超 DRAIN_WAIT_SECONDS 的转 stop 的 SIGTERM drain(协作式 cancel,标 idle 可重发)。
|
|
count_live_runs() {
|
|
asuser env ZCBOT_DB_URL="$DB_URL" "$PY" - "$LIVE" <<'PYEOF'
|
|
import os, sys
|
|
from sqlalchemy import create_engine, text
|
|
eng = create_engine(os.environ["ZCBOT_DB_URL"])
|
|
with eng.connect() as c:
|
|
print(c.execute(text(
|
|
"SELECT count(*) FROM tasks "
|
|
"WHERE run_status IN ('running','cancelling') AND run_owner=:o"
|
|
), {"o": sys.argv[1]}).scalar())
|
|
PYEOF
|
|
}
|
|
log "等 zcbot@$LIVE 在跑的 run 自然收尾(上限 ${DRAIN_WAIT_SECONDS}s)..."
|
|
waited=0
|
|
while [ "$waited" -lt "$DRAIN_WAIT_SECONDS" ]; do
|
|
n="$(count_live_runs)"
|
|
if [ "$n" = "0" ]; then
|
|
log "zcbot@$LIVE 已无在跑 run"
|
|
break
|
|
fi
|
|
log " $LIVE 还有 $n 个 run 在跑,${waited}s/${DRAIN_WAIT_SECONDS}s ..."
|
|
sleep 10
|
|
waited=$((waited + 10))
|
|
done
|
|
|
|
# ── 6) 停旧实例(通常已空,秒停;残留的走 SIGTERM drain)────────
|
|
log "systemctl stop zcbot@$LIVE ..."
|
|
systemctl stop "zcbot@$LIVE"
|
|
|
|
# ── 7) DB 收尾:旧色被 SIGKILL 兜底砍掉的 run(没来得及收尾的)标 error ──
|
|
# 正常路径这里是 0 行;非 0 说明有 run 超上限被硬停,对应 task 重新可发消息。
|
|
asuser env ZCBOT_DB_URL="$DB_URL" "$PY" - "$LIVE" <<'PYEOF'
|
|
import os, sys
|
|
from sqlalchemy import create_engine, text
|
|
color = sys.argv[1]
|
|
eng = create_engine(os.environ["ZCBOT_DB_URL"])
|
|
with eng.begin() as c:
|
|
r = c.execute(text(
|
|
"UPDATE tasks SET run_status='error', "
|
|
"run_error='instance stopped before run finished' "
|
|
"WHERE run_status IN ('running','cancelling') AND run_owner=:o"
|
|
), {"o": color})
|
|
print(f"[deploy] reaped {r.rowcount} leftover run(s) owned by {color}")
|
|
PYEOF
|
|
|
|
# ── 8) 开机自启只留新 live 色 ──────────────────────────────────
|
|
systemctl enable "zcbot@$IDLE" >/dev/null 2>&1 || true
|
|
systemctl disable "zcbot@$LIVE" >/dev/null 2>&1 || true
|
|
|
|
log "OK:蓝绿切换完成($NEW_HEAD),live=$IDLE,旧实例 $LIVE 已停"
|
|
log "看实时日志:journalctl -u zcbot@$IDLE -f"
|