#!/usr/bin/env bash # 生产环境部署:选择「应用 + 区域 + 镜像版本 tag」部署,支持回滚。 # # 镜像由 ./prod-build.sh 预先构建推送;本脚本只做「远端拉取 + 重启」。 # 所有应用共用同一镜像 starpivot-app 与本目录的模板,「远端运行目录」和「容器名」按应用区分(见下方 APPS): # ym → 目录 /home/work/ym-a11,容器 ym-a11;voitrans → /home/work/voitrans-a11,容器 voitrans-a11 … # 这样同一台机可以并存多个应用,不会因为容器名相同互相顶掉。 # 各区域一套服务器(见下方 region_profile)。 # 配置归属:.env 与 confs/*.yaml 由你在服务器维护(脚本只下发 *.example 模板,绝不覆盖); # docker-compose.yml 则由本脚本统一下发覆盖——它不含任何环境相关内容(全部走 ${VAR},值来自 .env), # 手工维护只会与仓库模板漂移:ym 首次部署时服务器上那份旧 compose 就把 container_name 写死成 starpivot-app, # 导致改了模板也部不出 ym-a11。覆盖前会在远端留一份 docker-compose.yml.bak。 # # 用法(在 deploy/voitrans/ 下): # ./prod-deploy.sh # 不带参数 → 交互式引导:逐步选「应用 / 区域 / 版本」,部署前确认 # ./prod-deploy.sh <应用> <区域> # 直接部署,如 ./prod-deploy.sh voitrans shanghai 0.1.1 # ./prod-deploy.sh <应用> <区域> rollback # 回滚到上一次部署版本(与当前版本来回切换) # ./prod-deploy.sh <区域> # 省略应用 → 默认首个应用(兼容旧写法) # ./prod-deploy.sh -h # 查看用法 # 应用: 见 APPS(默认 voitrans) 区域: shanghai | japan | singapore | along # 镜像仓库跟区域走:shanghai/japan/singapore → 云雁仓库,along → 阿龙仓库;需与 prod-build.sh 所选公司一致。 set -euo pipefail SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SVC_DIR/../.." && pwd)" # 各公司镜像仓库地址/账号统一放这里,换仓库只改 deploy/registry-profiles.sh source "$REPO_ROOT/deploy/registry-profiles.sh" blue() { printf "\033[34m%s\033[0m\n" "$*"; } green() { printf "\033[32m%s\033[0m\n" "$*"; } red() { printf "\033[31m%s\033[0m\n" "$*"; } yellow(){ printf "\033[33m%s\033[0m\n" "$*"; } # ── 私有仓库凭据(写死;公开仓库前清空 REGISTRY_PASS)── # 镜像仓库按「区域所属公司」自动选(见 region_profile 里的 REGION_COMPANY:三个云雁区域走 yunyan, # 阿龙区域走 along 自己的 registry.ymaikj.com),也可用环境变量强制指定:COMPANY=lingpu ./prod-deploy.sh … # 所选公司必须与 prod-build.sh 构建时所选公司一致,否则拉不到镜像。 COMPANY_OVERRIDE="${COMPANY:-}" # 环境变量显式指定的公司;留空 = 跟区域走 # 共用镜像:所有应用都用这一个。名字走 prod_image_name(),与 prod-build.sh 同源,防漂移。 # 真正的 REGISTRY / IMAGE_NAME 要等选完区域才定(见 region_profile 之后),这里只取个展示用的名字。 IMAGE_HINT="$(prod_image_name "$DEFAULT_COMPANY" app)" || exit 1 DOCKER_NETWORK="1panel-network" # compose 外部网络名(与服务器一致) # ╔══════════════════════════════════════════════════════════════════════╗ # ║ 应用列表 —— 共用镜像 starpivot-app 与本目录模板,「远端部署目录」按应用区分。 ║ # ║ 格式: "key|显示名|远端部署目录" ║ # ║ 容器名由 key 推导:-a11(见下方 CONTAINER_NAME),与目录 basename 一致。 ║ # ║ ⚠️ 目录只在此表配置,各区域一律照此表走——不要再在 region_profile 里覆盖, ║ # ║ 否则会出现「配了 ym-a11 却部到别处」(2026-08-18 部署 ym 时踩过)。 ║ # ║ 新增应用:在此加一行(远端目录在目标服务器上自行备好 .env/confs/compose)。 ║ # ╚══════════════════════════════════════════════════════════════════════╝ APPS=( "voitrans|Voitrans|/home/work/voitrans-a11" "lumi|Lumi|/home/work/lumi-a11" "echomeet|EchoMeet|/home/work/echomeet-a11" "deapvoice|DeapVoice|/home/work/deapvoice-a11" "deapsound|DeapSound|/home/work/deapsound-a11" "allhelp|AllHelp|/home/work/allhelp-a11" "deepglass|DeepGlass|/home/work/deepglass-a11" "ym|Ym|/home/work/ym-a11" ) app_keys() { local e k; for e in "${APPS[@]}"; do IFS='|' read -r k _ <<<"$e"; printf '%s\n' "$k"; done; } app_field() { # $1=key $2=字段序号(2=显示名 3=远端目录) local e k n r for e in "${APPS[@]}"; do IFS='|' read -r k n r <<<"$e" [ "$k" = "$1" ] || continue case "$2" in 2) printf '%s' "$n" ;; 3) printf '%s' "$r" ;; esac return 0 done return 1 } is_app() { app_field "$1" 2 >/dev/null 2>&1; } DEFAULT_APP="${APPS[0]%%|*}" # 省略应用时的默认(首个);用参数展开取首行 key,避开 head 截断管道触发 SIGPIPE # ╔══════════════════════════════════════════════════════════════════════╗ # ║ 各区域部署目标 —— 每区域一整套 HOST/PORT/USER/KEY(IP 与 SSH 私钥各不相同)。║ # ║ 写死在此(本文件纳入 git)。公开仓库前清空 HOST/KEY。 ║ # ║ 新增区域:在 region_profile 加一段 case,并把区域名加进 REGIONS 数组。 ║ # ╚══════════════════════════════════════════════════════════════════════╝ REGIONS=(shanghai japan singapore along) # 可选环境/区域(交互菜单按此顺序展示) region_profile() { # 远端部署目录/容器名由「应用」决定(见 APPS),此处只管各区域服务器连接信息 REGION_COMPANY="$DEFAULT_COMPANY" # 该区域镜像仓库所属公司(默认云雁;可被环境变量 COMPANY 覆盖) case "$REGION" in shanghai) DEPLOY_HOST="139.224.212.237" # ← 上海生产服务器 IP(必填) DEPLOY_PORT=22 DEPLOY_USER=root DEPLOY_KEY=/Users/liwei/Documents/key/aliyun_shanghai.pem # ← 上海服务器 SSH 私钥(各区域不同) ;; japan) DEPLOY_HOST="8.216.34.101" # ← 日本生产服务器 IP(必填) DEPLOY_PORT=22 DEPLOY_USER=root DEPLOY_KEY=/Users/liwei/Documents/key/aliyun_japan.pem # ← 日本服务器 SSH 私钥(各区域不同) ;; singapore) DEPLOY_HOST="47.84.198.24" # ← 新加坡生产服务器 IP(必填) DEPLOY_PORT=22 DEPLOY_USER=root DEPLOY_KEY=/Users/liwei/Documents/key/alibaba_singapore.pem # ← 新加坡服务器 SSH 私钥(各区域不同) ;; along) DEPLOY_HOST="47.116.104.181" # ← 阿龙生产服务器 IP DEPLOY_PORT=22 DEPLOY_USER=root DEPLOY_KEY="/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem" # 与阿龙测试机同一把钥匙(2026-08-18 由 ~/.ssh/along.pem 迁到此处;文件名 releae 是原文件笔误) REGION_COMPANY="along" # 镜像从 registry.ymaikj.com 拉(实体在阿龙测试机 8.133.166.29,账号 admin) # 目录不在这里覆盖:一律走 APPS 表(ym → /home/work/ym-a11)。 # 早期这里曾写死 DEPLOY_DIR_OVERRIDE=/home/work/starpivot/app,导致部 ym 时落到该目录、 # 且与已有容器 starpivot-app 撞名,2026-08-18 移除。阿龙机上那份旧部署保持原样、不再由本脚本管理。 ;; *) red "✗ 未知区域: ${REGION}(应为 ${REGIONS[*]// /|})"; exit 1 ;; esac } # 取某区域的目标主机(供交互菜单展示),不污染当前环境变量 region_host_of() { ( REGION="$1" region_profile >/dev/null 2>&1; printf '%s' "${DEPLOY_HOST:-}" ); } # ── 用法 ── usage() { blue "生产部署(共用镜像 ${IMAGE_HINT},远端目录与容器名按应用区分)" cat >&2 < 预先构建推送的版本号,如 0.1.1 rollback : 回滚到上一次部署版本(与当前版本来回切换) 示例: ./prod-deploy.sh ym along 0.1.4 # 部署 ym 0.1.4 到阿龙正式机 ./prod-deploy.sh 0.1.4 # 只给版本 → 交互里选应用、区域 ./prod-deploy.sh ym along # 给应用+区域 → 交互里选版本(会列出仓库里已有的 tag) ./prod-deploy.sh ym along rollback # ym 阿龙回滚到上一版本 ./prod-deploy.sh shanghai 0.1.1 # 省略应用 → 默认首个应用(${DEFAULT_APP:-?}) 提示: 不带参数(或参数不全)运行将进入交互式引导,逐步选择应用 / 环境 / 版本。 EOF } # ── 参数解析:按「内容」识别,顺序随意 ── # 以前按位置解析(<应用> <区域> ,或旧式 <区域> ),只给一个参数时它会被当成区域: # ./prod-deploy.sh 0.1.4 → REGION=0.1.4,② 选区域整步被跳过,最后才报「未知区域: 0.1.4」。 # 现在每个参数各自归位,认不出的当场报错,绝不猜。 case "${1:-}" in -h|--help|help) usage; exit 0 ;; esac is_region() { local r; for r in "${REGIONS[@]}"; do [ "$r" = "$1" ] && return 0; done; return 1; } is_tag_like() { case "$1" in ''|*[!A-Za-z0-9._-]*) return 1 ;; *) return 0 ;; esac; } APP_KEY=""; REGION=""; TAG_ARG=""; MODE=""; INTERACTIVE=0 for a in "$@"; do if is_app "$a"; then [ -z "$APP_KEY" ] || { red "✗ 应用指定了两次: $APP_KEY / $a"; exit 1; } APP_KEY="$a" elif is_region "$a"; then [ -z "$REGION" ] || { red "✗ 区域指定了两次: $REGION / $a"; exit 1; } REGION="$a" elif [ "$a" = "rollback" ]; then MODE="rollback" elif is_tag_like "$a"; then [ -z "$TAG_ARG" ] || { red "✗ 版本指定了两次: $TAG_ARG / $a"; exit 1; } TAG_ARG="$a" else red "✗ 无法识别的参数: '$a'" red " 应用应为 $(app_keys | paste -sd '/' -);区域应为 ${REGIONS[*]// /|};版本只能含字母数字与 . _ -" exit 1 fi done if [ -n "$MODE" ] && [ -n "$TAG_ARG" ]; then red "✗ rollback 与版本号 '$TAG_ARG' 不能同时给"; exit 1; fi # 进入交互引导(打印一次用法抬头);同时标记为交互模式,末尾会二次确认 enter_interactive() { [ "$INTERACTIVE" = "1" ] && return 0; INTERACTIVE=1; usage; echo >&2; } need_tty() { [ -t 0 ] || { red "✗ $1(当前无 TTY,无法交互,请在命令行显式给出)"; exit 1; }; } # ① 选择应用 if [ -z "$APP_KEY" ]; then if [ -n "$REGION" ] && { [ -n "$TAG_ARG" ] || [ -n "$MODE" ]; }; then APP_KEY="$DEFAULT_APP" # 旧式「<区域> 」:默认首个应用,不打断 yellow "⚠ 未指定应用,按旧式用法使用默认应用: ${APP_KEY}(其它应用请写成 ./prod-deploy.sh <应用> ${REGION} ${TAG_ARG:-rollback})" if [ -t 0 ]; then INTERACTIVE=1; fi # 有终端时末尾仍二次确认,避免默认应用部错 else need_tty "未指定应用" enter_interactive blue "① 选择应用:" i=1 while IFS= read -r k; do printf " %d) %-12s → 远端目录 %s 容器 %s\n" "$i" "$k" "$(app_field "$k" 3)" "${k}-a11" >&2 i=$((i+1)) done < <(app_keys) napp="$(app_keys | wc -l | tr -d ' ')" printf " 请输入序号 [1-%s]: " "$napp" >&2 read -r sel || { red "✗ 已取消"; exit 1; } case "$sel" in ''|*[!0-9]*) red "✗ 无效序号: $sel"; exit 1 ;; esac { [ "$sel" -ge 1 ] && [ "$sel" -le "$napp" ]; } || { red "✗ 序号超出范围"; exit 1; } APP_KEY="$(app_keys | sed -n "${sel}p")" green " ✓ 应用: $APP_KEY" fi fi is_app "$APP_KEY" || { red "✗ 未知应用: ${APP_KEY}(应为 $(app_keys | paste -sd '/' -))"; exit 1; } # 解析应用 → 远端部署目录 + 容器名(镜像与模板共用同一套) APP_DISPLAY="$(app_field "$APP_KEY" 2)" DEPLOY_DIR="$(app_field "$APP_KEY" 3)" CONTAINER_NAME="${APP_KEY}-a11" # 容器名按应用区分(ym → ym-a11),注入 compose 的 ${CONTAINER_NAME} # ② 选择环境 / 区域 if [ -z "$REGION" ]; then need_tty "未指定环境/区域" enter_interactive blue "② 选择部署环境 / 区域:" i=1 for r in "${REGIONS[@]}"; do h="$(region_host_of "$r")" printf " %d) %-10s → %s\n" "$i" "$r" "${h:-(未配置 IP,需先在脚本 region_profile 填)}" >&2 i=$((i+1)) done printf " 请输入序号 [1-%d]: " "${#REGIONS[@]}" >&2 read -r sel || { red "✗ 已取消"; exit 1; } case "$sel" in ''|*[!0-9]*) red "✗ 无效序号: $sel"; exit 1 ;; esac { [ "$sel" -ge 1 ] && [ "$sel" -le "${#REGIONS[@]}" ]; } || { red "✗ 序号超出范围"; exit 1; } REGION="${REGIONS[$((sel-1))]}" green " ✓ 环境: $REGION" fi # 区域定了才知道连哪台机、拉哪个仓库(③ 列版本时就要用,故提到这里) region_profile : "${DEPLOY_HOST:?请在 prod-deploy.sh 的 region_profile($REGION) 段填该区域 IP}" : "${DEPLOY_KEY:?请在 prod-deploy.sh 的 region_profile($REGION) 段填该区域 SSH 私钥}" COMPANY="${COMPANY_OVERRIDE:-$REGION_COMPANY}" # 默认跟区域走,环境变量 COMPANY 可强制覆盖 registry_profile "$COMPANY" || exit 1 IMAGE_NAME="$(prod_image_name "$COMPANY" app)" || exit 1 _ssh() { ssh -p "$DEPLOY_PORT" -i "${DEPLOY_KEY/#\~/$HOME}" -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 "$DEPLOY_USER@$DEPLOY_HOST" "$@"; } _scp() { scp -P "$DEPLOY_PORT" -i "${DEPLOY_KEY/#\~/$HOME}" -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 "$@"; } _dest() { echo "$DEPLOY_USER@$DEPLOY_HOST:$1"; } # 仓库里已推送的 tag(Docker Registry v2 API);取不到就返回非 0,交互里退回手工输入 _sort_desc() { if printf '1\n' | sort -V >/dev/null 2>&1; then sort -Vr; else sort -r; fi; } list_remote_tags() { command -v curl >/dev/null 2>&1 || return 1 local json all nums rest json="$(curl -fsS --max-time 10 -u "$REGISTRY_USER:$REGISTRY_PASS" "https://$REGISTRY/v2/$IMAGE_NAME/tags/list" 2>/dev/null)" || return 1 # 清空白只能用 sed —— tr -d '[:space:]' 会把换行一并删掉,整串 tag 会挤成一行 all="$(printf '%s' "$json" | sed -n 's/.*"tags":[[]\([^]]*\)[]].*/\1/p' | tr ',' '\n' | tr -d '"' | sed 's/[[:space:]]//g; /^$/d')" [ -n "$all" ] || return 1 nums="$(printf '%s\n' "$all" | grep -E '^[0-9]' | _sort_desc || true)" # 版本号:新的排前面 rest="$(printf '%s\n' "$all" | grep -vE '^[0-9]' | sort || true)" # latest / dev-latest 之类排后面 printf '%s\n%s\n' "$nums" "$rest" | sed '/^$/d' | head -15 } # 远端当前/上一次部署版本(读不到不影响部署,仅作展示) remote_tags_state() { _ssh "printf 'CUR=%s\n' \"\$(cat '$DEPLOY_DIR/.deploy_tag' 2>/dev/null | tr -d '[:space:]')\"; printf 'PREV=%s\n' \"\$(cat '$DEPLOY_DIR/.deploy_tag.prev' 2>/dev/null | tr -d '[:space:]')\"" 2>/dev/null || true; } # ③ 选择版本 / 动作 if [ -z "$MODE" ] && [ -z "$TAG_ARG" ]; then need_tty "未指定版本 tag" enter_interactive echo >&2 blue "③ 选择版本(镜像 ${REGISTRY}/${IMAGE_NAME},目标 ${DEPLOY_HOST}:${DEPLOY_DIR}):" st="$(remote_tags_state)" CUR_REMOTE="$(printf '%s\n' "$st" | sed -n 's/^CUR=//p' | head -1)" PREV_REMOTE="$(printf '%s\n' "$st" | sed -n 's/^PREV=//p' | head -1)" if [ -n "$CUR_REMOTE" ]; then printf " 远端当前运行: %s%s\n" "$CUR_REMOTE" "${PREV_REMOTE:+ 上一版: $PREV_REMOTE}" >&2 else printf " 远端尚无部署记录(.deploy_tag 为空,应是该应用首次部署)\n" >&2 fi TAGS=() while IFS= read -r t; do [ -n "$t" ] && TAGS+=("$t"); done < <(list_remote_tags || true) if [ "${#TAGS[@]}" -gt 0 ]; then i=1 for t in "${TAGS[@]}"; do mark="" [ "$t" = "$CUR_REMOTE" ] && mark=" ← 当前运行" [ "$t" = "$PREV_REMOTE" ] && mark=" ← 上一版" printf " %d) %s%s\n" "$i" "$t" "$mark" >&2 i=$((i+1)) done else yellow " (没能从仓库读到 tag 列表,请直接输入版本号)" fi printf " r) rollback%s\n" "${PREV_REMOTE:+ → 回到 $PREV_REMOTE}" >&2 printf " 请输入序号 / 版本号 / rollback: " >&2 read -r sel || { red "✗ 已取消"; exit 1; } sel="$(printf '%s' "$sel" | tr -d '[:space:]')" case "$sel" in '') red "✗ 未输入"; exit 1 ;; r|rollback) MODE="rollback" ;; *[!0-9]*) TAG_ARG="$sel" ;; # 含非数字 → 当成版本号(如 0.1.4) *) # 纯数字 → 序号;超出范围时才当版本号(如 20260818) if [ "${#TAGS[@]}" -gt 0 ] && [ "$sel" -ge 1 ] && [ "$sel" -le "${#TAGS[@]}" ]; then TAG_ARG="${TAGS[$((sel-1))]}" else TAG_ARG="$sel" fi ;; esac green " ✓ 版本: ${TAG_ARG:-rollback}" fi if [ "$MODE" != "rollback" ]; then MODE="deploy"; NEW_TAG="$TAG_ARG"; else NEW_TAG=""; fi # deploy 模式校验 tag:去空白 + 非空 + 合法字符(避免空/空格 tag 让远端 docker 报 invalid reference format) if [ "$MODE" = "deploy" ]; then NEW_TAG="$(printf '%s' "$NEW_TAG" | tr -d '[:space:]')" [ -n "$NEW_TAG" ] || { red "✗ 未指定版本 tag —— deploy 必须带版本号(如 0.1.1,需先 ./prod-build.sh 0.1.1 构建推送);回滚请用 rollback"; exit 1; } case "$NEW_TAG" in *[!A-Za-z0-9._-]*) red "✗ 非法版本 tag: '$NEW_TAG'(只能含字母数字与 . _ -)"; exit 1 ;; esac fi # 交互模式下,部署前二次确认(带全参数的非交互/CI 调用不打断) if [ "$INTERACTIVE" = "1" ]; then echo >&2 blue "即将部署 → 应用: $APP_DISPLAY($APP_KEY) 镜像: $IMAGE_NAME 容器: $CONTAINER_NAME 环境: $REGION 目标: $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_DIR 动作: $MODE${NEW_TAG:+ $NEW_TAG}" printf "确认执行?[y/N]: " >&2 read -r yn || true case "$yn" in [yY]|[yY][eE][sS]) ;; *) red "已取消"; exit 0 ;; esac fi # 同步「模板」(只传 *.example,绝不覆盖你在服务器维护的真实 docker-compose.yml / .env / confs/*.yaml) blue ">>> [$APP_KEY/$REGION] 同步 compose 与模板 → $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_DIR" _ssh "mkdir -p '$DEPLOY_DIR/confs' '$DEPLOY_DIR/log'" # ── docker-compose.yml:按「应用」现生成再下发 ── # 仓库模板里的 service 名是占位的 app,这里改写成本应用名(ym → ym-a11), # 让 docker compose ps / 1Panel 面板里一眼看得出是哪个应用;container_name 仍由 ${CONTAINER_NAME} 注入。 # 下发策略:与远端现有文件逐字节比对,一致就跳过(等于「已存在且没变化就不处理」), # 不一致才备份成 .bak 再覆盖 —— 纯粹「存在就不碰」会让服务器上的旧 compose 永远不更新, # ym 首次部署容器名一直是 starpivot-app 就是这么来的。 COMPOSE_RENDERED="$(mktemp -t starpivot-compose)" trap 'rm -f "$COMPOSE_RENDERED"' EXIT sed "s|^ app:| ${CONTAINER_NAME}:|" "$SVC_DIR/docker-compose.yml" > "$COMPOSE_RENDERED" grep -q "^ ${CONTAINER_NAME}:" "$COMPOSE_RENDERED" || { red "✗ 生成 compose 失败:模板 $SVC_DIR/docker-compose.yml 里没找到 service 定义行「 app:」"; exit 1; } LOCAL_SUM="$(md5 -q "$COMPOSE_RENDERED" 2>/dev/null || md5sum "$COMPOSE_RENDERED" | cut -d' ' -f1)" REMOTE_SUM="$(_ssh "md5sum '$DEPLOY_DIR/docker-compose.yml' 2>/dev/null | cut -d' ' -f1" || true)" COMPOSE_CHANGED=0 if [ "$LOCAL_SUM" = "$REMOTE_SUM" ]; then echo " docker-compose.yml 已是最新(service=${CONTAINER_NAME}),跳过" else COMPOSE_CHANGED=1 _ssh "[ -f '$DEPLOY_DIR/docker-compose.yml' ] && cp -a '$DEPLOY_DIR/docker-compose.yml' '$DEPLOY_DIR/docker-compose.yml.bak' || true" _scp "$COMPOSE_RENDERED" "$(_dest "$DEPLOY_DIR/docker-compose.yml")" echo " docker-compose.yml 已更新(service=${CONTAINER_NAME},旧文件备份为 docker-compose.yml.bak)" fi # 其余只发模板,绝不覆盖你维护的真实 .env / confs/*.yaml _scp "$SVC_DIR"/confs/*.yaml.example "$(_dest "$DEPLOY_DIR/confs/")" _scp "$SVC_DIR/env/env.example" "$(_dest "$DEPLOY_DIR/env.example")" # ip2region 离线 IP 库:静态数据非凭据,home 的 sys.ipinfo 启动必需(缺了直接 panic)。 # 约 46MB,只在远端缺失时传一次(dev-deploy 是每次都传;prod 这边以前根本没传,新应用首次部署必挂)。 if ! _ssh "test -s '$DEPLOY_DIR/confs/ip2region_v4.xdb' && test -s '$DEPLOY_DIR/confs/ip2region_v6.xdb'"; then blue ">>> [$APP_KEY/$REGION] 首次同步 ip2region 离线 IP 库(约 46MB,仅在远端缺失时传)" _scp "$REPO_ROOT"/apps/services/sys/ipinfo/ip2region_v*.xdb "$(_dest "$DEPLOY_DIR/confs/")" fi # 启动前置检查:home.yaml 里引用的词库/IP 库文件必须真实存在,否则 home 在 sys 初始化阶段就 panic, # entrypoint 随即杀掉整个容器 → 无限重启。这种失败的表象具有欺骗性:日志里最扎眼的是 # 「Table 'xxx.userdevice' doesn't exist」(那些表本该由 home 里的 user 模块建), # 真凶却是上面几十行处的 panic。2026-08-18 部署 ym 时排查了半天,故在此提前拦一道。 MISSING="$(_ssh bash -s -- "$DEPLOY_DIR" <<'PREFLIGHT' DIR="$1"; cd "$DIR" 2>/dev/null || exit 0 [ -f confs/home.yaml ] || exit 0 miss="" for f in $(grep -v '^[[:space:]]*#' confs/home.yaml | grep -oE '\./wordfilter/[^ "]+\.txt' | sort -u); do [ -f "$f" ] || miss="$miss $f" done for f in $(grep -v '^[[:space:]]*#' confs/home.yaml | grep -oE '\./confs/[^ "]+\.xdb' | sort -u); do [ -f "$f" ] || miss="$miss $f" done printf '%s' "$miss" PREFLIGHT )" if [ -n "$(printf '%s' "$MISSING" | tr -d '[:space:]')" ]; then red "✗ 启动前置检查未通过 —— $DEPLOY_DIR 下缺少 confs/home.yaml 引用的文件:" red " $MISSING" red " 这些文件缺一个,home 就会在 sys 初始化时 panic,容器会无限重启(日志里却更像是「表不存在」)。" red " 处理方式:" red " · 词库 txt:不需要敏感词过滤就把 confs/home.yaml 里 WorldFile 那几行注释掉(模板已默认注释);" red " 要用就把 txt 放到 $DEPLOY_DIR/wordfilter/ 下(它们不在 git 里)。" red " · ip2region *.xdb:本脚本会自动下发,出现此提示说明上一步 scp 失败,检查网络/磁盘后重试。" exit 1 fi # 远端拉取并重启(不上传真实配置):版本历史存独立的 .deploy_tag;部署成功后会把当前 TAG 写回 .env 的 TAG 行(仅这一行)。 blue ">>> [$APP_KEY/$REGION] 远端部署($MODE${NEW_TAG:+ $NEW_TAG}) → $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_DIR 容器: $CONTAINER_NAME" _ssh bash -s -- "$DEPLOY_DIR" "$MODE" "$NEW_TAG" "$REGISTRY/$IMAGE_NAME" "$REGISTRY" "$REGISTRY_USER" "$REGISTRY_PASS" "$DOCKER_NETWORK" "$IMAGE_NAME" "$CONTAINER_NAME" <<'REMOTE' set -e DIR="$1"; MODE="$2"; NEW_TAG="$3"; IMG_PREFIX="$4"; REG="$5"; REG_USER="$6"; REG_PASS="$7"; NET="$8"; IMG_NAME="${9}"; CNAME="${10}" [ -f "$DIR/.env" ] || { echo "✗ 缺少 $DIR/.env(请照 env.example 在服务器填好真实 .env)"; exit 1; } [ -f "$DIR/confs/gateway.yaml" ] || { echo "✗ 缺少 $DIR/confs/*.yaml(请照 confs/*.yaml.example 在服务器填好)"; exit 1; } [ -f "$DIR/docker-compose.yml" ] || { echo "✗ 缺少 $DIR/docker-compose.yml(本应由部署脚本刚刚下发,检查上一步同步是否失败)"; exit 1; } STATE="$DIR/.deploy_tag" # 当前部署 tag(独立文件,记录版本历史) PREVF="$DIR/.deploy_tag.prev" # 上一次部署 tag CUR_TAG="$([ -f "$STATE" ] && cat "$STATE" || true)" CUR_PREV="$([ -f "$PREVF" ] && cat "$PREVF" || true)" if [ "$MODE" = "rollback" ]; then [ -n "$CUR_PREV" ] || { echo "没有上一次版本记录(.deploy_tag.prev),无法回滚"; exit 1; } FINAL_TAG="$CUR_PREV"; FINAL_PREV="$CUR_TAG" # 与当前版本来回切换 else FINAL_TAG="$NEW_TAG" if [ -n "$CUR_TAG" ] && [ "$CUR_TAG" != "$NEW_TAG" ]; then FINAL_PREV="$CUR_TAG"; else FINAL_PREV="$CUR_PREV"; fi fi echo "$REG_PASS" | docker login "$REG" -u "$REG_USER" --password-stdin >/dev/null docker network inspect "$NET" >/dev/null 2>&1 || docker network create "$NET" docker pull "$IMG_PREFIX:$FINAL_TAG" # service 名改过(app → <应用>-a11)之后,旧 compose 建的同名容器在新 compose 眼里既不是自己的服务、 # 又占着 container_name,直接 up 会报 "container name is already in use"。它反正要被 --force-recreate # 重建,这里先按 compose 标签认一下:不是本 service 的同名容器就删掉。 OLD_SVC="$(docker inspect -f '{{index .Config.Labels "com.docker.compose.service"}}' "$CNAME" 2>/dev/null || true)" if [ -n "$OLD_SVC" ] && [ "$OLD_SVC" != "$CNAME" ]; then echo "→ 同名容器 $CNAME 属于旧 service '$OLD_SVC',先移除再按新 compose 重建" docker rm -f "$CNAME" >/dev/null 2>&1 || true fi # REGISTRY/IMAGE_NAME/TAG 通过 inline 环境变量传给 compose(优先级高于 --env-file), # 确保 compose 用的镜像和上面 docker pull 的完全是同一个——两者曾经漂移过(pull voitrans-a11、compose 找 starpivot-app) # CONTAINER_NAME 决定容器叫什么(<应用>-a11),同一台机上多个应用靠它区分; # COMPOSE_PROJECT_NAME 同样按应用取,避免两个应用目录同名时 compose 项目撞车。 # --remove-orphans:旧 compose 里 service 名各不相同(starpivot-app / <应用>-a11 …),统一成 app 之后 # 那些旧容器会变成本项目的孤儿容器,不清掉会继续用旧名字跑着、甚至和新容器名撞车 cd "$DIR" && REGISTRY="$REG" IMAGE_NAME="$IMG_NAME" TAG="$FINAL_TAG" CONTAINER_NAME="$CNAME" COMPOSE_PROJECT_NAME="$CNAME" docker compose --env-file .env up -d --force-recreate --remove-orphans docker image prune -f # 记录当前运行版本:① 写回 .env 的 TAG 行(只改这一行,便于 cat .env 查看)② 维护 .deploy_tag 历史 if grep -q '^TAG=' "$DIR/.env"; then sed -i "s/^TAG=.*/TAG=$FINAL_TAG/" "$DIR/.env" else printf 'TAG=%s\n' "$FINAL_TAG" >> "$DIR/.env" fi # 容器名同样写回 .env,这样在服务器上手工 docker compose 时用的是同一个名字,不会退回默认 starpivot-app if grep -q '^CONTAINER_NAME=' "$DIR/.env"; then sed -i "s/^CONTAINER_NAME=.*/CONTAINER_NAME=$CNAME/" "$DIR/.env" else printf 'CONTAINER_NAME=%s\n' "$CNAME" >> "$DIR/.env" fi echo "$FINAL_TAG" > "$STATE" [ -n "$FINAL_PREV" ] && echo "$FINAL_PREV" > "$PREVF" || true echo "已部署版本 TAG=$FINAL_TAG 容器=$CNAME (上一次=${FINAL_PREV:-无});已写回 .env 的 TAG / CONTAINER_NAME 行" # 兜底校验:容器名必须是按应用推导的那个。ym 首次部署就栽在这——服务器上那份手工 compose 写死了 # container_name: starpivot-app,脚本注入的 CONTAINER_NAME 根本没被引用,跑起来还是叫 starpivot-app。 if ! docker ps --format '{{.Names}}' | grep -qx "$CNAME"; then echo "⚠ 容器名不是预期的 $CNAME —— 说明 $DIR/docker-compose.yml 没引用 CONTAINER_NAME 变量" echo ' 正常应为: container_name: ${CONTAINER_NAME:-starpivot-app}' docker compose ps 2>/dev/null || true fi REMOTE green "✓ [$APP_KEY/$REGION] $MODE 完成"