Files
backend_v2/deploy.sh
toom1996 2e76507d37 deploy: 本地预构建发布(push-build),对齐 1Panel 与 backend_v2 目录
- 新增 Dockerfile.prebuilt / scripts/push-build.sh:本机构建、只上传产物,服务器零编译
- deploy.sh 增加 --prebuilt(自动 --no-pull + 产物校验),并保留 --no-cache
- compose 支持 BACKEND_DOCKERFILE / WEB_DOCKERFILE 切换;预构建时用 prebuilt 变体
- init-db.sh / scripts/backup-db.sh 改为 docker exec 1Panel 的 Postgres 容器
- WEB_DIR 默认改为 ../frontend_v2;nginx 切换文件改到 .deploy/ 避免弄脏工作区
- 新增 .gitattributes 固定脚本与配置为 LF
2026-10-21 00:02:18 +08:00

295 lines
9.6 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
# 零停机部署:同步代码 → 构建不可变镜像 → 蓝绿滚动 → 常驻代理切换
#
# ./deploy.sh 拉 GIT_BRANCH(默认 main)+ 只更新后端
# ./deploy.sh all 前后端都更新
# ./deploy.sh web 只更新前端
# ./deploy.sh --rollback 切回上一个槽(不动代码,秒级)
# ./deploy.sh all --no-pull 用服务器上现有代码构建
# ./deploy.sh web --no-cache 强制不用构建缓存(前端 SSG 依赖后端数据,数据变了要强制重建)
# ./deploy.sh --prune 只清理旧镜像(保留最近 KEEP_IMAGES 个,默认 5)
# ./deploy.sh all --prebuilt 用已上传的预构建产物(不拉代码、不在容器内编译)
# GIT_BRANCH=dev ./deploy.sh all 临时指定分支(优先于 .env)
#
# 每次发布生成 RELEASE=<时间戳>-<后端提交号>,镜像以此打标签(不可变,不覆盖历史)。
# 发布记录写在 .deploy/releases.log;每个槽跑的是哪个 RELEASE 可用 docker inspect 查。
set -euo pipefail
cd "$(dirname "$0")"
TARGET="backend"
NO_PULL=0
NO_CACHE=0
PRUNE_ONLY=0
PREBUILT=0
for arg in "$@"; do
case "$arg" in
backend|web|all) TARGET="$arg" ;;
--rollback) TARGET="rollback" ;;
--no-pull) NO_PULL=1 ;;
--no-cache) NO_CACHE=1 ;;
--prune) PRUNE_ONLY=1 ;;
--prebuilt) PREBUILT=1 ;;
*) echo "用法: ./deploy.sh [backend|web|all|--rollback|--prune] [--no-pull] [--no-cache] [--prebuilt]"; exit 1 ;;
esac
done
[ -f .env ] || { echo "!! 缺少 .env:先 cp .env.example .env 并填写"; exit 1; }
# 命令行显式传入的分支优先于 .env(.env 会覆盖同名 shell 变量,所以先记下来)
CLI_GIT_BRANCH="${GIT_BRANCH-}"
set -a; . ./.env; set +a
[ -n "$CLI_GIT_BRANCH" ] && GIT_BRANCH="$CLI_GIT_BRANCH"
GIT_BRANCH="${GIT_BRANCH:-main}"
KEEP_IMAGES="${KEEP_IMAGES:-5}"
WEB_DIR="${WEB_DIR:-../frontend_v2}"
STATE_DIR=".deploy"
ACTIVE_CONF="$STATE_DIR/nginx/active.conf"
PORT="${HTTP_PORT:-80}"
mkdir -p "$STATE_DIR/nginx"
cur_slot() { cat "$STATE_DIR/$1" 2>/dev/null || echo a; }
other_slot() { [ "$1" = a ] && echo b || echo a; }
short_sha() { [ -d "$1/.git" ] && git -C "$1" rev-parse --short HEAD 2>/dev/null || echo "nogit"; }
# ---- 发布号 ----
# 时间戳 + 后端提交号:一眼看出线上跑的是哪个提交,同一秒重复执行也不会撞标签。
new_release() { echo "$(date +%Y%m%d-%H%M%S)-$(short_sha .)"; }
record_release() {
[ -n "${RELEASE:-}" ] || return 0
printf '%s %s 后端=%s 前端=%s (%s)\n' \
"$RELEASE" "$(date '+%F %T')" \
"$(short_sha .)" "$(short_sha "$WEB_DIR")" "$TARGET" \
>> "$STATE_DIR/releases.log"
}
# 生成切换文件(写到 .deploy/,不进版本库):backend / web 各自独立的活跃槽
render_active() {
local b w
b=$(cur_slot backend); w=$(cur_slot web)
cat > "$ACTIVE_CONF" <<EOF
# ⚠️ 由 deploy.sh 生成,勿手改
map \$host \$web_up { default "web_${w}:3000"; }
map \$host \$api_up { default "backend_${b}:8090"; }
map \$host \$ssg_up { default "backend_${b}:8091"; }
map \$host \$admin_up { default "backend_${b}:8092"; }
EOF
}
# ---- 代码同步 ----
sync_repo() { # $1=目录 $2=展示名
local dir=$1 label=$2 cur
if [ ! -d "$dir/.git" ]; then
echo " $label:不是 git 仓库,跳过($dir)"
return 0
fi
cur=$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?")
git -C "$dir" fetch --prune origin "$GIT_BRANCH"
if [ "$cur" != "$GIT_BRANCH" ]; then
echo " $label:当前在 $cur,切到 $GIT_BRANCH"
git -C "$dir" checkout "$GIT_BRANCH"
fi
if ! git -C "$dir" pull --ff-only origin "$GIT_BRANCH" >/dev/null 2>&1; then
echo " !! $label 无法快进到 origin/$GIT_BRANCH(服务器上有本地提交或未提交改动)"
echo " git -C $dir status 查看;确认可丢弃后:git -C $dir reset --hard origin/$GIT_BRANCH"
exit 1
fi
echo " $label:$GIT_BRANCH @ $(short_sha "$dir") $(git -C "$dir" log -1 --pretty=%s | cut -c1-40)"
}
sync_code() {
echo "==> 同步代码(分支 ${GIT_BRANCH})"
sync_repo "." "后端"
sync_repo "$WEB_DIR" "前端"
}
# ---- 容器与槽位 ----
cid() { docker compose ps -q "$1" 2>/dev/null || true; }
healthy() {
local c
c=$(cid "$1"); [ -n "$c" ] || return 1
[ "$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$c" 2>/dev/null)" = healthy ]
}
slot_image() { # 槽正在跑的镜像标签
local c
c=$(cid "$1"); [ -n "$c" ] || return 1
docker inspect -f '{{.Config.Image}}' "$c" 2>/dev/null
}
wait_healthy() { # $1=服务名 $2=超时秒
local i
for ((i = 0; i < $2; i++)); do
healthy "$1" && return 0
sleep 1
done
return 1
}
reload_proxy() {
docker compose exec -T proxy nginx -t >/dev/null
docker compose exec -T proxy nginx -s reload
local i
for ((i = 0; i < 10; i++)); do
sleep 1
if curl -fsS "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1; then
return 0
fi
done
echo "!! 代理 reload 后健康检查失败"
return 1
}
switch_to() { # $1=component $2=slot
echo " → 切换 $1 到槽 $2"
echo "$2" > "$STATE_DIR/$1"
render_active
reload_proxy
}
roll() { # $1=component(backend|web)
local comp=$1 old idle
old=$(cur_slot "$comp"); idle=$(other_slot "$old")
echo "==> $comp:活跃槽 $old,更新空闲槽 $idle(镜像 $RELEASE)"
local build_flag=""
[ "$NO_CACHE" = "1" ] && build_flag="--no-cache"
docker compose build $build_flag "${comp}_a" >/dev/null
# latest 只是给手动执行 compose 命令用的便利别名,deploy 自己从不依赖它
docker tag "fashion-${comp}:${RELEASE}" "fashion-${comp}:latest" 2>/dev/null || true
echo " 重建 ${comp}_${idle}"
docker compose up -d --no-deps --force-recreate "${comp}_${idle}"
if ! wait_healthy "${comp}_${idle}" 120; then
echo "!! ${comp}_${idle} 120 秒未 healthy,保持旧槽继续服务"
docker compose logs --tail 40 "${comp}_${idle}"
exit 1
fi
switch_to "$comp" "$idle"
echo " 完成:旧槽 $old 仍在运行($(slot_image "${comp}_${old}" 2>/dev/null || echo '未知镜像'))"
}
do_rollback() {
local comp old other img
for comp in backend web; do
old=$(cur_slot "$comp"); other=$(other_slot "$old")
if healthy "${comp}_${other}"; then
img=$(slot_image "${comp}_${other}" 2>/dev/null || echo "未知")
switch_to "$comp" "$other"
echo " 回滚 $comp:$old → $other(该槽镜像 ${img})"
else
echo " $comp:槽 $other 不可用,跳过(可能还没发布过第二次)"
fi
done
}
prune_images() {
local comp tags t
echo "==> 清理旧镜像(每个组件保留最近 ${KEEP_IMAGES} 个)"
for comp in backend web; do
tags=$(docker images "fashion-${comp}" --format '{{.Tag}}' 2>/dev/null \
| grep -vE '^(latest|<none>)$' | sort -r | tail -n +$((KEEP_IMAGES + 1)))
[ -z "$tags" ] && { echo " $comp:无需清理"; continue; }
for t in $tags; do
if docker rmi "fashion-${comp}:${t}" >/dev/null 2>&1; then
echo " $comp:删除 $t"
else
echo " $comp:$t 仍被容器使用,跳过"
fi
done
done
}
# ---- 预构建产物校验 ----
# 本地构建 + 上传的路径:镜像只负责打包,不在服务器上编译。
# 同时强制 --no-pull:代码已在本地构建完毕,服务器仓库不需要(也不应该)再更新。
use_prebuilt() {
PREBUILT=1
NO_PULL=1
export BACKEND_DOCKERFILE=Dockerfile.prebuilt
export WEB_DOCKERFILE=Dockerfile.prebuilt
echo "==> 使用预构建产物(不在服务器编译)"
case "$TARGET" in
backend|all)
[ -f server-linux-amd64 ] || {
echo "!! 缺少 server-linux-amd64:先在本机执行 scripts/push-build.sh"
exit 1
}
echo " 后端二进制:$(du -h server-linux-amd64 | cut -f1)"
;;
esac
case "$TARGET" in
web|all)
[ -f "$WEB_DIR/dist/server/entry.mjs" ] || {
echo "!! 缺少 $WEB_DIR/dist/server/entry.mjs:前端还没构建或没上传"
exit 1
}
echo " 前端产物:$WEB_DIR/dist"
;;
esac
}
# ---- 主流程 ----
if [ "$PRUNE_ONLY" = "1" ]; then
prune_images
exit 0
fi
if [ "$PREBUILT" = "1" ] && [ "$TARGET" != "rollback" ]; then
use_prebuilt
fi
if [ "$TARGET" != "rollback" ]; then
export RELEASE="${RELEASE:-$(new_release)}"
echo "==> 发布号 $RELEASE"
if [ "$NO_PULL" = "0" ]; then
sync_code
else
echo "==> 跳过拉代码(--no-pull)"
fi
else
export RELEASE="${RELEASE:-latest}"
fi
# 首次部署:先写切换点,再只启动代理。
# - render_active 必须先于 proxy 启动,否则 include 的 map 为空,nginx 会因未知变量启动失败。
# - --no-deps 避免首次起代理时顺带构建前端(此时 18091 尚未就绪,SSG 取数会失败)。
render_active
if ! healthy proxy; then
echo "==> 启动代理(首次只起代理,槽位由 roll 流程构建)"
docker compose up -d --no-deps proxy
fi
if [ "$TARGET" = "rollback" ]; then
do_rollback
exit 0
fi
# 顺序有讲究:先切后端(前端构建期要读活跃后端的 SSG),再切前端
case "$TARGET" in
backend) roll backend ;;
web) roll web ;;
all)
roll backend
roll web
;;
esac
record_release
echo "==> 代理检查"
if curl -fsS "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1; then
echo " 正常"
else
echo " !! /healthz 不通,请检查 docker compose logs proxy"
exit 1
fi
docker image prune -f >/dev/null
echo "完成:$RELEASE"
echo "旧镜像可用 ./deploy.sh --prune 清理(默认保留最近 ${KEEP_IMAGES} 个)"