feat(deploy): 加一键部署脚本 deploy/update.sh(pull/pip/migration/build/restart/healthz)

固化日常部署:git pull --ff-only → pip install → db upgrade head →
docker build sandbox → systemctl restart → curl /healthz 验活。

两处钉死:① migration 不能漏,env.py 直读 os.environ 不读 .env,脚本从
.env 抠 ZCBOT_DB_URL 显式喂进去;② build 必先于 restart —— 容器复用 +
tools/ 烤进镜像,restart 时 shutdown_all 清旧容器,下次 ensure() 才用
新镜像重建。sandbox 每次 build 无所谓(重活在 COPY tools/ 之上,cache
让改代码部署秒过)。镜像源默认阿里,可置空回落官方。前置守卫 + healthz
失败 dump journalctl 非零退出。bootstrap 不进脚本,留 RUN.md。

RUN.md §部署 SOP 重写为指向脚本 + 手动 fallback;PROGRESS 加一条。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-01 14:50:46 +08:00
parent fb5e68d9e7
commit f66d55cc3b
3 changed files with 165 additions and 12 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-01(documents / Materials Project secret-bearing 能力改 host-side tools,key 不进 sandbox) 最后更新:2026-06-01(加一键部署脚本 deploy/update.sh:pull/pip/migration/build/restart/healthz,build 必先于 restart)
--- ---
@ -23,6 +23,8 @@
### 2026-06-01 ### 2026-06-01
- **修 MP host 工具的全量下载(IP 被封根因)**:`mp_search_summary` 之前不给 `summary.search` 传分页参数 → mp-api 默认 `chunk_size=1000``list(docs)` 自动翻完所有页,`limit` 只在客户端切片,等于每次搜索都整库级下载 → MP 判 abusive traffic 封 host IP/ASN(403 "blocked")。改为 `search(num_chunks=1, chunk_size=limit, ...)`,服务端单页限量。`mp_get_entries` 的 `limit` 同样是"只裁剪保存、不减网络流量"的假参数,但 `get_entries_in_chemsys` 天然全量(相图用途),改不了,只在 description 里点明"拉整个 chemsys、元素越多越重、别反复调"。测试加断言锁定 `num_chunks/chunk_size` 已传。**注:宿主 IP `49.232.14.174` 当前仍被 MP 临时封(无公开时限、不确认自动解除),需发邮件 support@materialsproject.org 人工解封后才能联网复测。**
- **加一键部署脚本 `deploy/update.sh`(Ubuntu / systemd)**:把日常部署固化成一把梭 —— `git pull --ff-only``pip install -r``db upgrade head``docker build` sandbox 镜像 → `systemctl restart zcbot``curl /healthz` 验活。**两处必须钉死的顺序 / 步骤**:① migration 不能漏(`db/migrations/env.py` 直读 `os.environ['ZCBOT_DB_URL']` 不读 .env,脚本从 .env 抠出来 `env ZCBOT_DB_URL=...` 喂进去);② **build 必须在 restart 之前** —— sandbox 容器 per-user 长驻复用、`tools/` 是 build 进镜像(非 mount),restart 时 `pool.shutdown_all` 清旧容器、下次 `ensure()` 才用新 `zcbot-sandbox:latest` 重建,顺序反了新 tools/ 要等下次重启才生效。**sandbox 每次都 build 无所谓**:重活(pip ~1G / chromium / 字体 / mermaid)都在 Dockerfile `COPY tools/` 之上,layer cache 让改代码部署秒过、只有 `requirements.txt` 变了才整体重建(~5-10min)。镜像源默认阿里(`${VAR-default}` 不带冒号,显式置空可回落官方源)。前置守卫:非 root / 非 git 仓库 / 工作区脏(已跟踪文件)/ 缺 .env 中止;healthz 15s 不 ok → dump journalctl 非零退出。`ZCBOT_SKIP_SANDBOX_BUILD=1` 跳过 build(host backend 机)。一次性 bootstrap(useradd / 写 unit / enable)不进脚本,留 RUN.md。git 可执行位已置(100755)。**纯运维脚本,DESIGN 不动**;`RUN.md` §部署 SOP 重写为指向脚本 + 手动逐条 fallback。
- **sandbox 镜像加中文字体,修 matplotlib / mermaid 出图中文方块**:用户报绘图(mermaid + matplotlib)出的 PNG 里中文全是豆腐块 □。**根因 = `deploy/sandbox/Dockerfile``python:3.12-slim` 起一个 CJK 字体都没装**:matplotlib `skills/plot_pub/style.py::_find_chinese_font()` 扫候选无果退回 Arial/DejaVu;mermaid 经 mmdc→chromium 渲染,chromium 经 fontconfig 也找不到中文字形;`skills/ppt/scripts/render_icon.py` 引用的 `wqy-microhei.ttc` / `NotoSansCJK-Regular.ttc` 路径根本不存在。三处同一病根。**改法**:① Dockerfile chromium 块后加一层 `apt-get install fonts-noto-cjk fonts-wqy-microhei fontconfig && fc-cache -f`(Noto 出版级 +~330MB,wqy 兜底 +~5MB 且匹配 style.py 现有候选 / render_icon 引用路径);② `style.py` 候选清单首位加 `"Noto Sans CJK SC"` 让 matplotlib 优先用 Noto。fontconfig 刷缓存供 chromium 选字;matplotlib 走自家 font_manager 扫 `/usr/share/fonts` 运行时首用自动建缓存,无需额外处理。**否决**仅装 wqy(体积小但黑体不如 Noto 精致,出版图略糙)/ 仅装 Noto(render_icon 第一候选 wqy.ttc 落空走 fallback OK 但不够干净)。**纯镜像 + 配置改,DESIGN 不动**(无架构 / 取舍 / schema 变化);`RUN.md` 故障表加一行(中文方块 → 重 build 镜像 + 清旧容器 + `fc-list :lang=zh` 验证)。**生效**:改了 Dockerfile 必须 `docker build` 重建 + `docker rm -f` 清旧容器 + restart web,旧容器仍跑老镜像不会自动更新。 - **sandbox 镜像加中文字体,修 matplotlib / mermaid 出图中文方块**:用户报绘图(mermaid + matplotlib)出的 PNG 里中文全是豆腐块 □。**根因 = `deploy/sandbox/Dockerfile``python:3.12-slim` 起一个 CJK 字体都没装**:matplotlib `skills/plot_pub/style.py::_find_chinese_font()` 扫候选无果退回 Arial/DejaVu;mermaid 经 mmdc→chromium 渲染,chromium 经 fontconfig 也找不到中文字形;`skills/ppt/scripts/render_icon.py` 引用的 `wqy-microhei.ttc` / `NotoSansCJK-Regular.ttc` 路径根本不存在。三处同一病根。**改法**:① Dockerfile chromium 块后加一层 `apt-get install fonts-noto-cjk fonts-wqy-microhei fontconfig && fc-cache -f`(Noto 出版级 +~330MB,wqy 兜底 +~5MB 且匹配 style.py 现有候选 / render_icon 引用路径);② `style.py` 候选清单首位加 `"Noto Sans CJK SC"` 让 matplotlib 优先用 Noto。fontconfig 刷缓存供 chromium 选字;matplotlib 走自家 font_manager 扫 `/usr/share/fonts` 运行时首用自动建缓存,无需额外处理。**否决**仅装 wqy(体积小但黑体不如 Noto 精致,出版图略糙)/ 仅装 Noto(render_icon 第一候选 wqy.ttc 落空走 fallback OK 但不够干净)。**纯镜像 + 配置改,DESIGN 不动**(无架构 / 取舍 / schema 变化);`RUN.md` 故障表加一行(中文方块 → 重 build 镜像 + 清旧容器 + `fc-list :lang=zh` 验证)。**生效**:改了 Dockerfile 必须 `docker build` 重建 + `docker rm -f` 清旧容器 + restart web,旧容器仍跑老镜像不会自动更新。
- **documents / Materials Project secret-bearing 能力改 host-side tools,key 不进 sandbox**:新增 `tools/documents.py` 三工具(`document_list_kb` / `document_search` / `document_download`)和 `tools/materials_project.py` 三工具(`mp_search_summary` / `mp_get_structure` / `mp_get_entries`),`core/agent_builder.py` 仅在宿主 env `DOCUMENT_SEARCH_API_KEY` / `MP_API_KEY` 存在时注册。`document_download` / `mp_get_structure` / `mp_get_entries` 绑定当前 task_dir 写文件,模型不能传 working_dir;`document_search` 默认截断 `md_content`,避免整篇论文进上下文。同步更新 `DESIGN.md` secret-bearing domain tools 规则、`RUN.md` env / 故障兜底、`SKILL_LIST.md`、`skills/documents/SKILL.md`、`skills/pymatgen/SKILL.md`;旧 `run_python` helper 不再是带 key API 主路径。测试 `tests/test_secret_host_tools.py` 覆盖 documents search 截断、download 固定 task_dir、MP tool 不泄露 host key。 - **documents / Materials Project secret-bearing 能力改 host-side tools,key 不进 sandbox**:新增 `tools/documents.py` 三工具(`document_list_kb` / `document_search` / `document_download`)和 `tools/materials_project.py` 三工具(`mp_search_summary` / `mp_get_structure` / `mp_get_entries`),`core/agent_builder.py` 仅在宿主 env `DOCUMENT_SEARCH_API_KEY` / `MP_API_KEY` 存在时注册。`document_download` / `mp_get_structure` / `mp_get_entries` 绑定当前 task_dir 写文件,模型不能传 working_dir;`document_search` 默认截断 `md_content`,避免整篇论文进上下文。同步更新 `DESIGN.md` secret-bearing domain tools 规则、`RUN.md` env / 故障兜底、`SKILL_LIST.md`、`skills/documents/SKILL.md`、`skills/pymatgen/SKILL.md`;旧 `run_python` helper 不再是带 key API 主路径。测试 `tests/test_secret_host_tools.py` 覆盖 documents search 截断、download 固定 task_dir、MP tool 不泄露 host key。
- **删 `skills/pymatgen/materials.py::mp_rester()` + `scripts/smoke_scientific_skills.py` 改走 host tool**:`mp_rester` 是 sandbox 内读 `MP_API_KEY` 的旧入口,host tool 化后多余且违背"key 不进 sandbox",直接删(连带清 `import os` / `contextlib.contextmanager`,只留 `CEMENT_PHASES` / `lookup_phase`);smoke A6 / step D 改用 `MaterialsProjectSearchSummaryTool`。**实测闭环**:初次 step D 真连 `api.materialsproject.org`**403**(工具行为正确,403 干净透传成 `[Error]` 不崩),定位为 `.env` legacy 旧版 key 在新版 `mp-api` 失效;换 next-gen materialsproject.org dashboard 长 key 后**复测通过**(查 Ca3SiO5 返 3 条 `mp-xxxx` + `energy_above_hull`,~5s),MP host 工具端到端联网通路确认可用。documents 工具未联网实测(无现成可验证调用),逻辑同 web_search 形态。 - **删 `skills/pymatgen/materials.py::mp_rester()` + `scripts/smoke_scientific_skills.py` 改走 host tool**:`mp_rester` 是 sandbox 内读 `MP_API_KEY` 的旧入口,host tool 化后多余且违背"key 不进 sandbox",直接删(连带清 `import os` / `contextlib.contextmanager`,只留 `CEMENT_PHASES` / `lookup_phase`);smoke A6 / step D 改用 `MaterialsProjectSearchSummaryTool`。**实测闭环**:初次 step D 真连 `api.materialsproject.org`**403**(工具行为正确,403 干净透传成 `[Error]` 不崩),定位为 `.env` legacy 旧版 key 在新版 `mp-api` 失效;换 next-gen materialsproject.org dashboard 长 key 后**复测通过**(查 Ca3SiO5 返 3 条 `mp-xxxx` + `energy_above_hull`,~5s),MP host 工具端到端联网通路确认可用。documents 工具未联网实测(无现成可验证调用),逻辑同 web_search 形态。

43
RUN.md
View File

@ -2,7 +2,7 @@
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md` > 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`
最后更新:2026-06-01(documents / Materials Project 改 host-side tools 持 key,sandbox 不读 secret) 最后更新:2026-06-01(加一键部署脚本 deploy/update.sh;§部署 SOP 重写为指向脚本 + 手动 fallback)
--- ---
@ -249,19 +249,40 @@ ExecStart=/opt/zcbot/.venv/bin/python main.py web --host 0.0.0.0 --port 8765 --r
**zcbot 端额外要做的事**:消息 broker 当前在 task 进程内(`web/broker.py`),蓝绿期间同 task 不同进程会丢事件。nginx 侧用 `hash $arg_task_id consistent` 保同 task 落同实例可以缓解,但 task 创建分布是另一回事。要做这条得先把 broker 改成 Redis pub/sub。10 人内**不推**,留到真有需要再上。 **zcbot 端额外要做的事**:消息 broker 当前在 task 进程内(`web/broker.py`),蓝绿期间同 task 不同进程会丢事件。nginx 侧用 `hash $arg_task_id consistent` 保同 task 落同实例可以缓解,但 task 创建分布是另一回事。要做这条得先把 broker 改成 Redis pub/sub。10 人内**不推**,留到真有需要再上。
### 部署 SOP(目前推荐:方案 A `--reload`) ### 部署 SOP
**一把梭(推荐):`deploy/update.sh`**
```bash
# 在服务器上,root 跑(脚本内部文件操作降到 zcbot 用户)
sudo bash /opt/zcbot/deploy/update.sh
# 或(已带可执行位):sudo /opt/zcbot/deploy/update.sh
```
脚本顺序写死:`git pull --ff-only` → `pip install -r``db upgrade head`**`docker build` sandbox 镜像** → **`systemctl restart zcbot`** → `curl /healthz` 验活。要点:
- **build 必须在 restart 之前**:sandbox 容器 per-user 长驻 + 复用,`tools/` 是 build 进镜像的(非 mount)。restart 时 `shutdown_all` 清旧容器,下次 `ensure()` 才用新 `zcbot-sandbox:latest` 重建 —— 顺序反了新 tools/ 要等下次重启才生效。
- **sandbox build 每次都跑没关系**:layer cache 让重活(pip ~1G / chromium / 字体 / mermaid,都在 `COPY tools/` 之上)在改代码部署时秒过;只有 `requirements.txt` 变了才整体重建(~5-10min,正好也是该重建的时候)。host backend 机器不想动 docker:`ZCBOT_SKIP_SANDBOX_BUILD=1 sudo -E bash deploy/update.sh`。
- **镜像源默认阿里**(`APT_MIRROR=mirrors.aliyun.com` / `PIP_INDEX_URL=mirrors.aliyun.com/pypi/simple/` / `NPM_REGISTRY=registry.npmmirror.com`,境内快)。要命中 docker cache 就别两组源来回换(换源从 pip 层炸开全重跑)。想用官方源:`PIP_INDEX_URL= sudo -E bash deploy/update.sh`(置空即回落 Dockerfile 官方默认)。
- **migration 取 DB URL**:`db/migrations/env.py` 直接读 `os.environ['ZCBOT_DB_URL']`(不读 .env),脚本从 `.env` 抠出来显式 `env ZCBOT_DB_URL=... ` 喂进 `main.py db upgrade`
- **前置守卫**:非 root / 不是 git 仓库 / 工作区脏(已跟踪文件有未提交改动)/ 缺 .env → 直接中止。`/healthz` 15s 内不返回 `ok` → dump `journalctl -n 50` 并以非零退出。
> 一次性 bootstrap(useradd / 写 unit / `enable --now`)见上方"一次性"段,**不在 update.sh 里**。改 `.env` / unit 文件本身后,update.sh 的 restart 一样会让新 `.env` 生效;但改 unit 的 `[Unit]/[Service]` 字段需先手动 `sudo systemctl daemon-reload`
**手动逐条(脚本跑挂了排查用)**
```bash ```bash
# 在服务器上
cd /opt/zcbot cd /opt/zcbot
sudo -u zcbot git pull --ff-only # 拉新代码 sudo -u zcbot git pull --ff-only
sudo -u zcbot .venv/bin/python -m pip install -r requirements.txt # 依赖有变才需要 sudo -u zcbot .venv/bin/python -m pip install -r requirements.txt
sudo -u zcbot .venv/bin/python main.py db upgrade head # migration 有新版才需要 sudo -u zcbot env ZCBOT_DB_URL="$(grep ^ZCBOT_DB_URL= .env | cut -d= -f2-)" \
# 这一步通常不用做:--reload 监听到 .py 文件变动会自动重起 .venv/bin/python main.py db upgrade head
# 但 .env / unit 改了 → 手动: sudo -u zcbot docker build -f deploy/sandbox/Dockerfile \
# sudo systemctl restart zcbot --build-arg HOST_UID=$(id -u zcbot) --build-arg HOST_GID=$(id -g zcbot) \
sudo systemctl status zcbot | head -t zcbot-sandbox:latest .
sudo journalctl -u zcbot -n 50 # 看新进程起没起干净 sudo systemctl restart zcbot
curl -fsS http://127.0.0.1:8765/healthz # {"status":"ok"}
sudo journalctl -u zcbot -n 50
``` ```
--- ---

130
deploy/update.sh Executable file
View File

@ -0,0 +1,130 @@
#!/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(/opt/zcbot) APP_USER(zcbot) 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
APP_DIR="${APP_DIR:-/opt/zcbot}"
APP_USER="${APP_USER:-zcbot}"
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 等)"
# 工作区脏(仅看已跟踪文件;.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"