#!/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" </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|)$' | 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} 个)"