254 lines
8.2 KiB
Bash
Executable File
254 lines
8.2 KiB
Bash
Executable File
#!/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
|
||
|
||
# 首次部署:先写切换点,再只启动代理。
|
||
# - 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} 个)"
|