#!/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)。 # 一次性 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 镜像源, # 默认阿里源(境内快);要稳定命中 docker cache 就别在两组源之间来回换 # (换源会从 pip 层炸开全重跑)。想用官方源:PIP_INDEX_URL= sudo -E bash ...(置空) # ZCBOT_SKIP_SANDBOX_BUILD=1 跳过第 4 步(host backend 机器 / 临时不想动 docker) set -euo pipefail # 部署目录 = 本脚本所在目录(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" # 镜像源默认阿里(境内快);Dockerfile sed 只替 host 前缀,mirror 站目录结构与官方一致。 # 置空(如 PIP_INDEX_URL= sudo -E ...)则该项不传 build-arg,回落 Dockerfile 官方源默认。 APT_MIRROR="${APT_MIRROR-https://mirrors.aliyun.com}" PIP_INDEX_URL="${PIP_INDEX_URL-https://mirrors.aliyun.com/pypi/simple/}" NPM_REGISTRY="${NPM_REGISTRY-https://registry.npmmirror.com/}" 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)" 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) 依赖 ─────────────────────────────────────────────────── log "pip install -r requirements.txt ..." asuser "$PY" -m pip install -q -r "$APP_DIR/requirements.txt" # ── 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 [ "${ZCBOT_SKIP_SANDBOX_BUILD:-0}" = "1" ]; then log "ZCBOT_SKIP_SANDBOX_BUILD=1,跳过 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}") 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 之后!)─────────────────────────── log "systemctl restart $SERVICE ..." 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"