Files
HomeDash/deploy/zd.sh
HomeDash Bot ff4af5fa31 fix(deploy): 必填校验插在 HOST 赋值之前,set -u 下直接崩
现象:NAVI_HOST=... bash deploy/zd-host.sh v54 直接报
line 21: HOST: unbound variable 并退出(生产未受影响,纯脚本缺陷)。

原因:上一轮加 NAVI_HOST 必填校验时,把校验插到了 STAMP 之后、HOST 赋值之前,
而脚本是 set -u,提前引用未赋值变量即中止。

修复:校验移到 HOST 赋值之后;zd-host.sh 与 zd.sh 都改。
自测:不给 NAVI_HOST → 友好提示 + 退出码 2;给了则继续执行(实测进入构建阶段)。
2026-09-12 10:38:17 +08:00

184 lines
8.9 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
# HomeDash 零停机部署
#
# 为什么需要它:直接 docker stop/rename/run 会有十几秒完全不可用,而且新容器
# nginx 先就绪、SearXNG 还在启动,这期间 /search 返回 502。本脚本先在对侧端口
# 把新容器验证到底(含 /search,顺带预热 SearXNG),再改门户 nginx 上游并
# reload(瞬时切换),最后才停旧容器并保留为回滚点。
#
# 用法:
# deploy/zd.sh <version> 例: deploy/zd.sh v29
#
# 环境变量(默认值即本项目生产环境,可覆盖):
# NAVI_HOST 目标机 ssh 地址(必填)
# NAVI_SSH_PASS 目标机密码(无 ssh key 时必填)
# NAVI_UPSTREAM_HOST 门户 nginx 里 dashy 域名对应的主机名,默认 dashy.example.com
# NAVI_PORTAL_CONF 门户 nginx 配置文件绝对路径
# NAVI_DATA_DIR 数据卷宿主机路径
# NAVI_BASE_IMAGE 构建基础镜像,默认 jianf-navi:latest
#
# 失败会自动回滚:切上游前失败只清掉新容器(老容器继续服务);切上游后失败
# 会把配置还原并 reload。每次改动门户配置前都会先备份。
set -euo pipefail
VER="${1:?用法: deploy/zd.sh <version>}"
TAG="homedash:ui-${VER}"
STAMP="$(date +%Y%m%d%H%M%S)"
HOST="${NAVI_HOST:-}" # 必填:NAVI_HOST=user@host
# 必填校验要在赋值之后(set -u 下提前引用会直接 unbound variable)
[ -n "$HOST" ] || { echo "!! 请指定目标机:NAVI_HOST=user@host bash $0 <版本>" >&2; exit 2; }
SSH_PASS="${NAVI_SSH_PASS:-}"
UPSTREAM_HOST="${NAVI_UPSTREAM_HOST:-dashy.example.com}"
PORTAL_CONF="${NAVI_PORTAL_CONF:-/vol1/docker/portal/nginx/nginx.conf}"
DATA_DIR="${NAVI_DATA_DIR:-/vol1/docker/navi-data}"
BASE_IMAGE="${NAVI_BASE_IMAGE:-jianf-navi:latest}"
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CTX=/tmp/homedash-ctx
TAR="/tmp/homedash-${VER}.tar.gz"
REMOTE_TAR="/tmp/homedash-${VER}-${STAMP}.tar.gz"
SSH_OPTS=(-o StrictHostKeyChecking=no)
if [ -n "$SSH_PASS" ]; then
SSH=(sshpass -p "$SSH_PASS" ssh "${SSH_OPTS[@]}" "$HOST")
SCP=(sshpass -p "$SSH_PASS" scp -q "${SSH_OPTS[@]}")
else
SSH=(ssh "${SSH_OPTS[@]}" "$HOST")
SCP=(scp -q "${SSH_OPTS[@]}")
fi
echo "== 1/3 本地构建 ${TAG}(源码 ${SRC})=="
cd "$SRC"
node --check web/navi.js
(cd admin-server && gofmt -l . && go test ./... >/dev/null)
(cd history-server && gofmt -l . && go test ./... >/dev/null)
rm -rf "$CTX"; mkdir -p "$CTX/runtime" "$CTX/web/extension" "$CTX/admin" "$CTX/portal"
(cd history-server && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o "$CTX/runtime/history-server" .)
(cd admin-server && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o "$CTX/runtime/admin-server" .)
cp nginx.conf supervisord.conf searxng-settings.yml conf.example.yml "$CTX/"
cp admin/index.html "$CTX/admin/index.html"
cp -a portal/. "$CTX/portal/"
cp web/extension/install.html "$CTX/web/extension/install.html"
cp web/navi.js web/navi.css web/patch-index.py "$CTX/web/"
cat > "$CTX/Dockerfile" <<EOF
FROM ${BASE_IMAGE}
COPY runtime/history-server /usr/local/bin/history-server
COPY runtime/admin-server /usr/local/bin/admin-server
COPY nginx.conf /etc/nginx/nginx.conf
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY searxng-settings.yml /etc/searxng/settings.yml
COPY conf.example.yml /app/defaults/conf.yml
COPY admin/index.html /app/admin/index.html
COPY portal /app/portal
COPY web/extension/install.html /app/dashy/extension/install.html
COPY web/navi.js /app/dashy/navi.js
COPY web/navi.css /app/dashy/navi.css
COPY web/patch-index.py /tmp/patch-index.py
RUN python3 /tmp/patch-index.py /app/dashy/index.html
# 基础镜像里的 HEALTHCHECK 还指向旧的 8080;这里必须覆盖,
# 否则容器会一直显示 unhealthy(功能正常但健康信号是假的)。
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
CMD curl -fs http://localhost:18080/healthz || exit 1
EOF
DOCKER_BUILDKIT=0 docker build -q -t "$TAG" "$CTX"
docker save "$TAG" | gzip -1 > "$TAR"
echo " 镜像与包就绪: $(ls -sh "$TAR" | awk '{print $1}')"
echo "== 2/3 上传 =="
"${SCP[@]}" "$TAR" "$HOST:$REMOTE_TAR"
echo "== 3/3 远端零停机切换 =="
"${SSH[@]}" "TAG='$TAG' STAMP='$STAMP' REMOTE_TAR='$REMOTE_TAR' VER='$VER' \
PORTAL_CONF='$PORTAL_CONF' UPSTREAM_HOST='$UPSTREAM_HOST' DATA_DIR='$DATA_DIR' bash -s" <<'REMOTE'
set -euo pipefail
CUR_PORT=$(grep -oP "${UPSTREAM_HOST//./\\.}\\s+http://127\\.0\\.0\\.1:\\K[0-9]+" "$PORTAL_CONF" | head -1)
[ -n "$CUR_PORT" ] || { echo "!! 读不到门户上游端口" >&2; exit 1; }
if [ "$CUR_PORT" = "3003" ]; then NEW_PORT=3004; else NEW_PORT=3003; fi
echo " 上游端口 ${CUR_PORT} → 新端口 ${NEW_PORT}"
CONF_BAK="${PORTAL_CONF}.bak-${STAMP}"
cp -a "$PORTAL_CONF" "$CONF_BAK"
gzip -dc "$REMOTE_TAR" | docker load >/dev/null
# 记录切换前的历史条数,切换后用于对比(同步接口只累加不删除,
# 所以"变少"就是异常;写死条数则会把用户自己删记录误判成失败)
OLD_HIST=$(curl -s "http://127.0.0.1:${CUR_PORT}/admin/api/history" 2>/dev/null | grep -o '"url"' | wc -l)
OLD_HIST=${OLD_HIST:-0}
docker inspect navi --format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep -E '^(NAVI_ADMIN_TOKEN|NAVI_SYNC_TOKEN|SEARXNG_BASE_URL|DEBIAN_FRONTEND)=' > "/tmp/navi-${STAMP}.env"
flipped=0
on_err() {
rc=$?
echo "!! 失败(rc=$rc),回滚" >&2
if [ "$flipped" = "1" ]; then
cp -a "$CONF_BAK" "$PORTAL_CONF"
docker exec portal-nginx nginx -t && docker exec portal-nginx nginx -s reload || true
fi
docker rm -f navi-next >/dev/null 2>&1 || true
exit $rc
}
trap on_err ERR
docker rm -f navi-next >/dev/null 2>&1 || true
docker run -d --name navi-next --restart unless-stopped --network bridge \
-p "${NEW_PORT}:18080" --env-file "/tmp/navi-${STAMP}.env" \
-v "${DATA_DIR}:/app/data" "homedash:ui-${VER}" >/dev/null
w200() { for _ in $(seq 1 90); do [ "$(curl -s -o /dev/null -w '%{http_code}' "$1" 2>/dev/null || true)" = "200" ] && { echo " ready $1"; return 0; }; sleep 2; done; echo " TIMEOUT $1" >&2; return 1; }
w200 "http://127.0.0.1:${NEW_PORT}/healthz"
w200 "http://127.0.0.1:${NEW_PORT}/conf.yml"
w200 "http://127.0.0.1:${NEW_PORT}/admin"
w200 "http://127.0.0.1:${NEW_PORT}/navi.js"
# /search 会真正跑一次搜索:既确认 SearXNG 可用,也顺带把它预热,
# 这样切过去之后用户不会撞上"nginx 就绪但 SearXNG 还在启动"的 502。
w200 "http://127.0.0.1:${NEW_PORT}/search?q=linux"
HIST=$(curl -s "http://127.0.0.1:${NEW_PORT}/admin/api/history" | grep -o '"url"' | wc -l)
[ "$HIST" -ge "$OLD_HIST" ] || { echo "!! 历史条数从 $OLD_HIST 掉到 $HIST(疑似丢数据)" >&2; false; }
echo " 新容器全绿(历史 ${OLD_HIST} → ${HIST} 条),开始切换上游"
sed "s#\\(${UPSTREAM_HOST//./\\.}\\s*http://127\\.0\\.0\\.1:\\)${CUR_PORT};#\\1${NEW_PORT};#" "$PORTAL_CONF" > /tmp/portal.conf.new
# 关键:必须同 inode 覆写(cat > 而不是 sed -i / mv)。
# 该文件是以单文件 bind mount 挂进 portal-nginx 的,且挂载是只读的;
# 用 sed -i / mv 会换新 inode,容器仍指向旧(孤儿)inode,reload 读到的还是旧端口
# → 上游被切走而新端口无人应答 → 全站 502(本项目真实踩过)。
cat /tmp/portal.conf.new > "$PORTAL_CONF"
grep -q "${UPSTREAM_HOST} *http://127.0.0.1:${NEW_PORT};" "$PORTAL_CONF" || { echo "!! 宿主机配置未改写" >&2; false; }
# 容器看不到新值时(历史上被 sed -i 换过 inode)只能重启它重新绑定,
# 这会断开所有域名约 1 秒,所以先走 reload 路径。
if ! docker exec portal-nginx grep -q "127.0.0.1:${NEW_PORT};" /etc/nginx/nginx.conf; then
echo " 容器未看到新配置(inode 错位)→ 重启 portal-nginx"
docker restart portal-nginx >/dev/null
sleep 4
fi
docker exec portal-nginx grep -q "127.0.0.1:${NEW_PORT};" /etc/nginx/nginx.conf \
|| { echo "!! 容器内配置仍是旧端口" >&2; false; }
docker exec portal-nginx nginx -t
docker exec portal-nginx nginx -s reload
sleep 2
flipped=1
# 切后哨兵:经门户访问不得是 502/503/504(未登录正常是 302)。
# 这是"上游切走但新端口无人应答"唯一能看到的现象。
CODE=$(curl -s -o /dev/null -w '%{http_code}' -H "Host: ${UPSTREAM_HOST}" http://127.0.0.1/conf.yml)
case "$CODE" in
502|503|504) echo "!! 门户访问返回 $CODE(上游不可达)" >&2; false ;;
esac
echo " 上游已切到 ${NEW_PORT}(门户探测 $CODE)"
w200 "http://127.0.0.1:${NEW_PORT}/admin"
docker stop navi >/dev/null
docker rename navi "navi-prev-${STAMP}"
docker rename navi-next navi
trap - ERR
for n in $(docker ps -a --filter name=navi-prev --format '{{.Names}}'); do
case "$n" in *"$STAMP"*) ;; *) docker rm -f "$n" >/dev/null;; esac
done
rm -f "$REMOTE_TAR"
echo " 配置备份=$CONF_BAK 回滚点=navi-prev-${STAMP}"
echo "DEPLOY_OK tag=homedash:ui-${VER} port=${NEW_PORT} health=$(docker inspect navi --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}')"
REMOTE