zcbot/deploy/update.sh

180 lines
11 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# zcbot 日常部署脚本(Ubuntu / systemd)。详见 RUN.md §部署 SOP。
#
# 干什么(顺序写死,别动):
# 1) git pull --ff-only 拉新代码(工作区脏 / 非 ff 直接中止,不乱 merge)
# 2) pip install -r 幂等;requirements 没变 docker/pip 都秒过
# 3) db upgrade head ★ alembic migration,已在 head 则 no-op
# 4) docker build sandbox 重建 run_python 沙箱镜像(layer cache:改代码秒过,
# requirements 变了才重下 chromium/字体/pip ~5-10min)
# 5) systemctl restart ★ 必须在 4 之后 —— 重启时 shutdown_all 清旧容器,
# 下次 ensure() 才用新镜像重建 per-user 容器
# 6) curl /healthz 验活
#
# 怎么跑:在部署机 `sudo deploy/update.sh`(需 root 跑 systemctl;文件操作降到 $APP_USER)。
# --skip-build 跳过第 4 步 sandbox 镜像重建(host backend 机器 / 临时不想动 docker)
# 一次性 bootstrap(useradd / 写 systemd unit / enable)不在这里 —— 见 RUN.md §部署 一次性。
#
# 可调 env(不传则用默认 / Dockerfile 默认):
# APP_DIR(默认=脚本上级目录,即仓库根) APP_USER(默认=部署目录属主) SERVICE(zcbot) PORT(8765)
# APT_MIRROR / PIP_INDEX_URL / PIP_TRUSTED_HOST / NPM_REGISTRY 镜像源,
# 默认:pip+apt 清华(tuna),npm 腾讯(清华无 npm 源;npmmirror 访问不稳,腾讯 npm 历来 OK)。
# pip 选清华:腾讯 PyPI 曾给出损坏的 litellm wheel(index hash 对、文件字节不对 → pip "DO NOT
# MATCH THE HASHES");清华境内稳 + 新包同步及时(阿里 PyPI 曾滞后到没有 litellm>=1.83)。
# 注:坏 wheel 只是腾讯 PyPI 的事,腾讯 npm 不受影响。
# 要稳定命中 docker cache 就别在多组源之间来回换(换源会从 pip 层炸开全重跑)。
# 想用官方源:PIP_INDEX_URL= sudo -E bash ...(置空)
set -euo pipefail
# ── 参数 ──────────────────────────────────────────────────────
# 默认 build;--skip-build 显式跳过(不为旧用法留 env 别名,开发期不留兼容)。
# 原始参数先存一份 —— pull 后若脚本自身有更新要 exec 新版本重跑,得把参数原样传回。
ORIG_ARGS=("$@")
SKIP_BUILD=0
while [ $# -gt 0 ]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
-h|--help) echo "用法:sudo $0 [--skip-build]"; exit 0 ;;
*) echo "[deploy] ERROR: 未知参数:$1(只支持 --skip-build)" >&2; exit 1 ;;
esac
done
# 部署目录 = 本脚本所在目录(deploy/)的上一级,不写死路径;仍可 env 覆盖。
# readlink -f 解析软链 + 相对路径,兼容 `sudo ./update.sh` / `sudo bash /abs/deploy/update.sh`
# / 经软链调用(Ubuntu coreutils 有 readlink -f)。
SCRIPT_DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
APP_DIR="${APP_DIR:-$(dirname "$SCRIPT_DIR")}"
# 服务用户默认 = 部署目录属主(bootstrap 时 chown zcbot:zcbot /opt/zcbot,属主即服务用户),
# 不写死;可 env 覆盖。推成 root 见下方前置检查 warn。
APP_USER="${APP_USER:-$(stat -c %U "$APP_DIR")}"
SERVICE="${SERVICE:-zcbot}"
PORT="${PORT:-8765}"
IMAGE="zcbot-sandbox:latest"
PY="$APP_DIR/.venv/bin/python"
HEALTH_URL="http://127.0.0.1:${PORT}/healthz"
# 镜像源默认:pip+apt 清华(tuna,境内稳 + 同步及时;Dockerfile sed 只替 host 前缀,目录结构同官方),
# npm 用腾讯(清华不提供 npm registry;npmmirror 访问不稳被弃,腾讯 npm 历来 OK)。
# pip 选清华:腾讯 PyPI 曾返回损坏的 litellm wheel(hash 不匹配),阿里 PyPI 又一度滞后(litellm 只到 1.82.6)。
# 备选 npm 源:华为 https://repo.huaweicloud.com/repository/npm/ / USTC https://npmreg.mirrors.ustc.edu.cn/
# 置空(如 PIP_INDEX_URL= sudo -E ...)则该项不传 build-arg,回落 Dockerfile 官方源默认。
APT_MIRROR="${APT_MIRROR-https://mirrors.tuna.tsinghua.edu.cn}"
PIP_INDEX_URL="${PIP_INDEX_URL-https://pypi.tuna.tsinghua.edu.cn/simple/}"
NPM_REGISTRY="${NPM_REGISTRY-https://mirrors.cloud.tencent.com/npm/}"
log() { echo "[deploy] $*"; }
fail() { echo "[deploy] ERROR: $*" >&2; exit 1; }
# 文件操作以 $APP_USER 身份跑(git/pip/db/docker 都碰 /opt/zcbot,保持 owner 一致)
asuser() { sudo -u "$APP_USER" "$@"; }
# ── 0) 前置检查 ────────────────────────────────────────────────
[ "$(id -u)" -eq 0 ] || fail "需 root 跑 systemctl,请用:sudo $0"
[ -d "$APP_DIR/.git" ] || fail "$APP_DIR 不是 git 仓库(部署目录不对?)"
[ -x "$PY" ] || fail "找不到 venv python:$PY"
[ -f "$APP_DIR/.env" ] || fail "$APP_DIR/.env(ZCBOT_DB_URL 等)"
# 服务用户落到 root 八成是部署目录属主不对(systemd 服务 / 沙箱不该跑 root,
# HOST_UID 还要跟 bind mount 属主对齐);提醒但不强制中止
[ "$APP_USER" = "root" ] && log "WARN: APP_USER 推导为 root(部署目录属主是 root?)── 确认服务确实以 root 跑;否则 APP_USER=zcbot sudo -E bash $0"
id "$APP_USER" >/dev/null 2>&1 || fail "服务用户不存在:$APP_USER(检查部署目录属主 / 显式设 APP_USER)"
# 工作区脏(仅看已跟踪文件;.venv/.env/workspace 都 gitignore,不算)→ 中止,
# 避免 ff-only 失败或把本地手改卷进部署
if [ -n "$(asuser git -C "$APP_DIR" status --porcelain -uno)" ]; then
asuser git -C "$APP_DIR" status --short -uno
fail "工作区有未提交改动,先处理干净再部署"
fi
# ── 1) 拉代码 ─────────────────────────────────────────────────
OLD_HEAD="$(asuser git -C "$APP_DIR" rev-parse HEAD)"
log "git pull --ff-only ..."
asuser git -C "$APP_DIR" pull --ff-only
NEW_HEAD="$(asuser git -C "$APP_DIR" rev-parse HEAD)"
# 脚本改自己的坑:正在跑的是 pull 前的旧脚本(变量默认值早在顶部求值,bash 又按字节
# 偏移边读边跑),pull 进来的新 update.sh 这一轮不生效。若这次 pull 动了本脚本,exec
# 新版本从头重跑(原始参数原样传回),ZCBOT_UPDATE_REEXEC 标记防死循环。pull 幂等,
# 重跑里它变 no-op,只多一次 git 往返,代价可忽略。
if [ "$OLD_HEAD" != "$NEW_HEAD" ] && [ "${ZCBOT_UPDATE_REEXEC:-0}" != "1" ] \
&& ! asuser git -C "$APP_DIR" diff --quiet "$OLD_HEAD" "$NEW_HEAD" -- deploy/update.sh; then
log "update.sh 自身有更新 —— 用新版本重跑(避免旧脚本跑出过期行为)..."
exec env ZCBOT_UPDATE_REEXEC=1 bash "$APP_DIR/deploy/update.sh" "${ORIG_ARGS[@]}"
fi
if [ "$OLD_HEAD" = "$NEW_HEAD" ]; then
log "代码无更新($NEW_HEAD),仍继续 pip/migration/build 兜底幂等"
else
log "新提交 $OLD_HEAD -> $NEW_HEAD:"
asuser git -C "$APP_DIR" --no-pager log --oneline "$OLD_HEAD..$NEW_HEAD" || true
fi
# ── 2) 依赖 ───────────────────────────────────────────────────
# host venv 的 pip 也走同一镜像源 —— 经 sudo -u 后 PIP_INDEX_URL env 会被洗掉,
# 不显式 --index-url 的话 host 用自己的 pip.conf(可能仍指阿里,撞 litellm>=1.83 缺版本)。
# 不带 -q:依赖装包是耗时步骤,留输出让部署时能看到进度(下载哪个包 / 卡在哪)。
pip_args=(-r "$APP_DIR/requirements.txt" --timeout 60)
[ -n "${PIP_INDEX_URL:-}" ] && pip_args+=(--index-url "${PIP_INDEX_URL}")
[ -n "${PIP_TRUSTED_HOST:-}" ] && pip_args+=(--trusted-host "${PIP_TRUSTED_HOST}")
log "pip install -r requirements.txt(源:${PIP_INDEX_URL:-官方默认})..."
asuser "$PY" -m pip install "${pip_args[@]}"
# ── 3) DB migration ──────────────────────────────────────────
# env.py 直接读 os.environ['ZCBOT_DB_URL'](不读 .env),这里从 .env 抠出来显式喂进去。
# 不整体 source .env —— 避免把 .env 当 shell 执行(值里若有 $(...) 会被求值)。
DB_URL="$(grep -E '^[[:space:]]*ZCBOT_DB_URL=' "$APP_DIR/.env" | tail -n1 | cut -d= -f2-)"
[ -n "$DB_URL" ] || fail ".env 里没读到 ZCBOT_DB_URL"
log "db upgrade head ..."
asuser env ZCBOT_DB_URL="$DB_URL" "$PY" "$APP_DIR/main.py" db upgrade head
# ── 4) 重建 sandbox 镜像 ──────────────────────────────────────
if [ "$SKIP_BUILD" = "1" ]; then
log "--skip-build,跳过 sandbox 镜像重建"
else
# build-arg 稳定才命中 cache:HOST_UID/GID 跟 host $APP_USER 对齐(bind mount owner),
# 镜像源仅在对应 env 非空时才传(传空会把 Dockerfile 默认覆盖成空串,pip 直接挂)。
build_args=(
--build-arg "HOST_UID=$(id -u "$APP_USER")"
--build-arg "HOST_GID=$(id -g "$APP_USER")"
)
[ -n "${APT_MIRROR:-}" ] && build_args+=(--build-arg "APT_MIRROR=${APT_MIRROR}")
[ -n "${PIP_INDEX_URL:-}" ] && build_args+=(--build-arg "PIP_INDEX_URL=${PIP_INDEX_URL}")
[ -n "${PIP_TRUSTED_HOST:-}" ] && build_args+=(--build-arg "PIP_TRUSTED_HOST=${PIP_TRUSTED_HOST}")
[ -n "${NPM_REGISTRY:-}" ] && build_args+=(--build-arg "NPM_REGISTRY=${NPM_REGISTRY}")
# 进度走 docker 默认(TTY 下是分层折叠刷新的 UI,直观);不强制 plain。
log "docker build $IMAGE(改代码秒过 / requirements 变了 ~5-10min)..."
asuser docker build \
-f "$APP_DIR/deploy/sandbox/Dockerfile" \
"${build_args[@]}" \
-t "$IMAGE" \
"$APP_DIR"
fi
# ── 5) 重启 service(在 build 之后!)───────────────────────────
# 优雅 drain:若有在跑的 run,restart 会先等它们收尾(≤ unit TimeoutStopSec,
# 见 RUN.md / config/agent.yaml shutdown 段)再换新版 —— 这步"看着卡住"几十秒是正常的。
log "systemctl restart $SERVICE(有在跑的 run 会先 drain可能等几十秒)..."
systemctl restart "$SERVICE"
# ── 6) 验活 ───────────────────────────────────────────────────
log "等 /healthz 起来 ..."
ok=0
for i in $(seq 1 15); do
if curl -fsS --max-time 2 "$HEALTH_URL" 2>/dev/null | grep -q '"ok"'; then
ok=1
break
fi
sleep 1
done
systemctl --no-pager --lines=0 status "$SERVICE" || true
if [ "$ok" -ne 1 ]; then
echo "[deploy] ----- journalctl -u $SERVICE -n 50 -----" >&2
journalctl -u "$SERVICE" -n 50 --no-pager >&2 || true
fail "$HEALTH_URL 15s 内没返回 ok,service 可能没起干净(看上面日志)"
fi
log "OK:healthz 通过,部署完成($NEW_HEAD)"
log "看实时日志:journalctl -u $SERVICE -f"