This commit is contained in:
toom1996
2026-10-19 00:33:25 +08:00
parent 3860e731db
commit 5db90af73a
17 changed files with 853 additions and 5244 deletions

251
deploy.sh Normal file
View File

@ -0,0 +1,251 @@
#!/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 --prune 只清理旧镜像(保留最近 KEEP_IMAGES 个,默认 5)
# 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
PRUNE_ONLY=0
for arg in "$@"; do
case "$arg" in
backend|web|all) TARGET="$arg" ;;
--rollback) TARGET="rollback" ;;
--no-pull) NO_PULL=1 ;;
--prune) PRUNE_ONLY=1 ;;
*) echo "用法: ./deploy.sh [backend|web|all|--rollback|--prune] [--no-pull]"; 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:-../test}"
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)"
docker compose build "${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
}
# ---- 主流程 ----
if [ "$PRUNE_ONLY" = "1" ]; then
prune_images
exit 0
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
# 首次部署先让代理起来(前端构建期要用 18091 取 SSG 数据)
if ! healthy proxy; then
echo "==> 启动代理(首次会一并拉起 db 与 a 槽)"
docker compose up -d proxy
fi
render_active
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} 个)"