现象: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;给了则继续执行(实测进入构建阶段)。
184 lines
8.9 KiB
Bash
Executable File
184 lines
8.9 KiB
Bash
Executable File
#!/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
|