feat: 添加 Linux 一键部署和更新脚本
Some checks are pending
CI — Docker Build & Push / Build & Push Image (push) Waiting to run

scripts/deploy.sh — 一键部署:
  - 自动检测发行版(Ubuntu/Debian/CentOS/RHEL/Rocky/AlmaLinux/
    Fedora/Alpine/Arch/Manjaro/openSUSE)并安装 Docker + Compose
  - 兼容 Docker Compose v1(docker-compose)和 v2(docker compose)
  - 支持两种部署模式:新建 MySQL / 现有 MySQL
  - 支持镜像来源:Gitea 仓库拉取 / 本地构建
  - 交互式配置:端口、密钥、管理员账号、MySQL 密码等
  - 自动生成加密随机 SECRET_KEY
  - 可选启用 Nginx 反向代理(--profile nginx)
  - 启动后执行健康检查,访问 /auth/login 验证

scripts/update.sh — 一键更新:
  - 读取 .deploy-state 恢复上次部署配置,无需重新输入参数
  - 更新前自动备份当前镜像标签(rollback-<时间戳>)
  - 拉取新镜像时对比摘要,无变化时提示可跳过
  - 健康检查失败时自动回滚至备份标签并重启服务
  - 自动清理旧备份镜像(仅保留最近 3 个)
  - 支持 --yes 免交互、--tag 指定目标标签

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 00:58:05 +09:00
parent b45d21100a
commit ac8c49edc7
3 changed files with 861 additions and 2 deletions

3
.gitattributes vendored
View File

@@ -1,5 +1,4 @@
# 强制 Shell 脚本使用 LF在 Windows 上构建 Linux 镜像时必须)
docker/entrypoint.sh text eol=lf
# 强制 Shell 脚本使用 LF在 Windows 上构建/运行时必须)
*.sh text eol=lf
# 普通文件使用系统默认换行

497
scripts/deploy.sh Normal file
View File

@@ -0,0 +1,497 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════════
# 个人资料库 — 一键部署脚本
# 支持发行版Ubuntu / Debian / CentOS / RHEL / Rocky / AlmaLinux /
# Fedora / Alpine / Arch / Manjaro
#
# 用法:
# curl -fsSL https://your-gitea/raw/.../scripts/deploy.sh | bash
# 或bash scripts/deploy.sh
# ═══════════════════════════════════════════════════════════════════
set -euo pipefail
IFS=$'\n\t'
# ── 脚本自身位置 & 项目根目录 ─────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
STATE_FILE="$PROJECT_DIR/.deploy-state"
LOG_FILE="/tmp/resource-library-deploy-$(date +%Y%m%d%H%M%S).log"
# ── 颜色 & 输出 ──────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
log() { echo -e "$(date '+%H:%M:%S') $*" | tee -a "$LOG_FILE"; }
info() { echo -e "${CYAN}[INFO]${NC} $*" | tee -a "$LOG_FILE"; }
ok() { echo -e "${GREEN}[ OK ]${NC} $*" | tee -a "$LOG_FILE"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*" | tee -a "$LOG_FILE"; }
err() { echo -e "${RED}[ERR ]${NC} $*" | tee -a "$LOG_FILE" >&2; }
die() { err "$*"; exit 1; }
section() { echo -e "\n${BOLD}${BLUE}── $* ─────────────────────────────────────${NC}" | tee -a "$LOG_FILE"; }
ask() { echo -e "${YELLOW}[?]${NC} $*"; }
banner() {
cat << 'EOF'
╔═══════════════════════════════════════════╗
║ 个人资料库 一键部署脚本 ║
║ Personal Resource Library Deployer ║
╚═══════════════════════════════════════════╝
EOF
}
# ── 工具函数 ──────────────────────────────────────────────────────
command_exists() { command -v "$1" &>/dev/null; }
# 生成安全随机字符串
gen_secret() {
python3 -c "import secrets; print(secrets.token_hex(32))" 2>/dev/null ||
openssl rand -hex 32 2>/dev/null ||
tr -dc 'A-Za-z0-9@#$%' < /dev/urandom | head -c 64
}
# 读取用户输入,支持默认值和可选的不回显(密码)
prompt() {
local var_name="$1" prompt_text="$2" default="${3:-}" secret="${4:-no}"
local value=""
if [ -n "$default" ]; then
ask "$prompt_text [默认: ${secret:+***}${default:-}]: "
else
ask "$prompt_text: "
fi
if [ "$secret" = "yes" ]; then
read -r -s value
echo
else
read -r value
fi
value="${value:-$default}"
eval "$var_name='$value'"
}
# ── 环境检测 ──────────────────────────────────────────────────────
check_bash_version() {
local ver="${BASH_VERSINFO[0]:-0}"
[ "$ver" -ge 4 ] || die "需要 Bash 4.0+,当前版本: $BASH_VERSION"
}
check_root() {
if [ "$(id -u)" -ne 0 ]; then
if command_exists sudo; then
warn "建议以 root 或 sudo 运行;继续尝试…"
SUDO="sudo"
else
die "需要 root 权限(或安装 sudo"
fi
else
SUDO=""
fi
}
check_internet() {
info "检测网络连通性…"
if ! curl -fsSL --max-time 8 https://get.docker.com > /dev/null 2>&1 && \
! wget -q --timeout=8 -O /dev/null https://get.docker.com 2>/dev/null; then
warn "无法访问 https://get.docker.comDocker 自动安装可能失败"
warn "请手动安装 Docker 后重新运行此脚本"
else
ok "网络正常"
fi
}
# ── 发行版检测 ────────────────────────────────────────────────────
detect_os() {
OS_ID="unknown"; OS_PRETTY="Unknown"; PKG_MGR="unknown"
if [ -f /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
OS_ID="${ID:-unknown}"
OS_PRETTY="${PRETTY_NAME:-$OS_ID}"
OS_ID_LIKE="${ID_LIKE:-}"
fi
# 根据 ID 或 ID_LIKE 映射包管理器
case "$OS_ID" in
ubuntu|debian|linuxmint|pop|raspbian|kali)
PKG_MGR="apt" ;;
centos|rhel|ol|scientific)
PKG_MGR="yum_or_dnf" ;;
rocky|almalinux|eurolinux|springdale)
PKG_MGR="dnf" ;;
fedora)
PKG_MGR="dnf" ;;
alpine)
PKG_MGR="apk" ;;
arch|manjaro|endeavouros|garuda|artix)
PKG_MGR="pacman" ;;
opensuse*|sles)
PKG_MGR="zypper" ;;
*)
if echo "${OS_ID_LIKE:-}" | grep -qiE "debian|ubuntu"; then
PKG_MGR="apt"
elif echo "${OS_ID_LIKE:-}" | grep -qiE "rhel|centos|fedora"; then
PKG_MGR="yum_or_dnf"
elif echo "${OS_ID_LIKE:-}" | grep -qiE "suse"; then
PKG_MGR="zypper"
fi
;;
esac
info "操作系统: $OS_PRETTY (ID=$OS_ID, PKG=$PKG_MGR)"
}
# ── Docker 安装 ───────────────────────────────────────────────────
# 使用 Docker 官方安装脚本支持大多数发行版Alpine/Arch 走包管理器
install_docker() {
section "安装 Docker"
if command_exists docker; then
local ver
ver=$(docker --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1)
ok "Docker 已安装: $ver"
return 0
fi
info "开始安装 Docker…"
case "$PKG_MGR" in
apk)
$SUDO apk add --no-cache docker docker-cli-compose
$SUDO rc-update add docker boot 2>/dev/null || true
$SUDO service docker start 2>/dev/null || true
;;
pacman)
$SUDO pacman -Sy --noconfirm docker docker-compose
$SUDO systemctl enable --now docker
;;
zypper)
$SUDO zypper install -y docker docker-compose
$SUDO systemctl enable --now docker
;;
*)
# 官方安装脚本Ubuntu/Debian/CentOS/RHEL/Rocky/Fedora 等)
local tmp
tmp=$(mktemp)
curl -fsSL https://get.docker.com -o "$tmp" || \
wget -qO "$tmp" https://get.docker.com || \
die "下载 Docker 安装脚本失败"
$SUDO sh "$tmp"
rm -f "$tmp"
;;
esac
# 启动并设置开机自启systemd 系统)
if command_exists systemctl && [ "$PKG_MGR" != "apk" ]; then
$SUDO systemctl enable docker 2>/dev/null || true
$SUDO systemctl start docker 2>/dev/null || true
fi
# 将当前用户加入 docker 组(后续免 sudo
if [ -n "${SUDO_USER:-}" ] && getent group docker &>/dev/null; then
$SUDO usermod -aG docker "$SUDO_USER"
warn "已将 $SUDO_USER 加入 docker 组,需重新登录后生效"
fi
ok "Docker 安装完成: $(docker --version 2>/dev/null || echo '(请重新登录后验证)')"
}
# ── Docker Compose 检测 ───────────────────────────────────────────
detect_compose() {
if docker compose version &>/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
ok "Docker Compose v2 (plugin): $(docker compose version --short 2>/dev/null)"
elif command_exists docker-compose; then
COMPOSE_CMD="docker-compose"
ok "Docker Compose v1 (standalone): $(docker-compose --version 2>/dev/null)"
else
info "未找到 Docker Compose尝试安装 Compose 插件…"
install_compose_plugin
COMPOSE_CMD="docker compose"
fi
}
install_compose_plugin() {
local COMPOSE_VER="v2.27.1"
local ARCH; ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH="x86_64" ;;
aarch64|arm64) ARCH="aarch64" ;;
armv7l) ARCH="armv7" ;;
*) die "不支持的 CPU 架构: $ARCH" ;;
esac
local URL="https://github.com/docker/compose/releases/download/${COMPOSE_VER}/docker-compose-linux-${ARCH}"
local DEST="/usr/local/lib/docker/cli-plugins/docker-compose"
$SUDO mkdir -p "$(dirname "$DEST")"
$SUDO curl -fsSL "$URL" -o "$DEST" || $SUDO wget -qO "$DEST" "$URL" || \
die "下载 Docker Compose 插件失败"
$SUDO chmod +x "$DEST"
ok "Docker Compose 插件安装完成"
}
# ── 交互配置 ──────────────────────────────────────────────────────
collect_config() {
section "部署配置"
echo
echo " 请选择部署模式:"
echo " 1) 新建 MySQL全栈适合新服务器"
echo " 2) 使用现有 MySQL适合已有数据库"
echo
ask "请输入选项 [1/2](默认: 1: "
read -r DEPLOY_MODE_INPUT
DEPLOY_MODE_INPUT="${DEPLOY_MODE_INPUT:-1}"
case "$DEPLOY_MODE_INPUT" in
1) DEPLOY_MODE="new-mysql" ; COMPOSE_FILE="docker-compose.yml" ; ENV_FILE=".env.docker" ;;
2) DEPLOY_MODE="external-db" ; COMPOSE_FILE="docker-compose.external-db.yml" ; ENV_FILE=".env.external-db" ;;
*) die "无效选项: $DEPLOY_MODE_INPUT" ;;
esac
ok "部署模式: $DEPLOY_MODE"
echo
echo " 请选择镜像来源:"
echo " 1) 从 Gitea 镜像仓库拉取(需已完成 CI 构建)"
echo " 2) 本地构建(无需 CI首次部署推荐"
echo
ask "请输入选项 [1/2](默认: 2: "
read -r IMG_SRC_INPUT
IMG_SRC_INPUT="${IMG_SRC_INPUT:-2}"
case "$IMG_SRC_INPUT" in
1) IMAGE_SOURCE="registry" ;;
2) IMAGE_SOURCE="build" ;;
*) die "无效选项: $IMG_SRC_INPUT" ;;
esac
if [ "$IMAGE_SOURCE" = "registry" ]; then
prompt REGISTRY_HOST "Gitea 镜像仓库地址" "git.hty1024.com"
prompt REGISTRY_IMAGE "镜像名(小写)" "${REGISTRY_HOST}/hty1024/ai-app-database"
prompt REGISTRY_USER "登录用户名" ""
prompt REGISTRY_PASS "登录密码 / Token" "" yes
info "登录镜像仓库…"
echo "$REGISTRY_PASS" | docker login "$REGISTRY_HOST" \
-u "$REGISTRY_USER" --password-stdin || die "登录失败,请检查凭据"
ok "镜像仓库登录成功"
else
REGISTRY_IMAGE=""
fi
echo
# ── 公共配置 ──────────────────────────────────────────────────
local auto_secret; auto_secret=$(gen_secret)
prompt APP_PORT "应用端口" "5000"
prompt SECRET_KEY "应用密钥(留空自动生成)" "$auto_secret" yes
SECRET_KEY="${SECRET_KEY:-$auto_secret}"
prompt ADMIN_USERNAME "管理员用户名" "admin"
prompt ADMIN_PASSWORD "管理员密码" "Admin@123456" yes
prompt ADMIN_EMAIL "管理员邮箱" "admin@example.com"
prompt MAX_UPLOAD_MB "最大上传大小(MB)" "500"
prompt GUNICORN_WORKERS "Gunicorn worker 数量" "4"
# ── 模式专属配置 ──────────────────────────────────────────────
if [ "$DEPLOY_MODE" = "new-mysql" ]; then
echo
info "MySQL 配置(新建数据库)"
local auto_root_pw; auto_root_pw=$(gen_secret | cut -c1-24)
local auto_user_pw; auto_user_pw=$(gen_secret | cut -c1-24)
prompt MYSQL_ROOT_PASSWORD "MySQL root 密码(留空自动生成)" "$auto_root_pw" yes
MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-$auto_root_pw}"
prompt MYSQL_PASSWORD "MySQL 应用用户密码(留空自动生成)" "$auto_user_pw" yes
MYSQL_PASSWORD="${MYSQL_PASSWORD:-$auto_user_pw}"
prompt MYSQL_EXPOSE_PORT "MySQL 对外端口(留空=不暴露)" "127.0.0.1:3306"
else
echo
info "外部数据库配置"
echo " 格式: mysql+pymysql://用户名:密码@主机:端口/数据库名"
prompt DATABASE_URL "数据库连接串" ""
[ -n "$DATABASE_URL" ] || die "外部 MySQL 模式必须提供 DATABASE_URL"
prompt DB_WAIT_SECONDS "等待数据库就绪超时(秒)" "30"
fi
# ── 是否启用 Nginx ──────────────────────────────────────────
echo
ask "是否启用 Nginx 反向代理?[y/N](默认: N: "
read -r NGINX_INPUT
USE_NGINX="${NGINX_INPUT:-N}"
if echo "$USE_NGINX" | grep -qiE "^y(es)?$"; then
USE_NGINX="yes"
prompt NGINX_HTTP_PORT "Nginx HTTP 端口" "80"
prompt NGINX_HTTPS_PORT "Nginx HTTPS 端口" "443"
else
USE_NGINX="no"
fi
}
# ── 写入 env 文件 ─────────────────────────────────────────────────
write_env_file() {
section "写入配置文件"
local env_path="$PROJECT_DIR/$ENV_FILE"
# 备份旧文件
if [ -f "$env_path" ]; then
cp "$env_path" "${env_path}.bak.$(date +%Y%m%d%H%M%S)"
warn "已备份旧配置: ${env_path}.bak.*"
fi
{
echo "# 个人资料库部署配置 — 生成于 $(date '+%Y-%m-%d %H:%M:%S')"
echo "# 请勿提交此文件到版本控制系统!"
echo ""
echo "SECRET_KEY=${SECRET_KEY}"
echo "FLASK_ENV=production"
echo "APP_PORT=${APP_PORT}"
echo "MAX_UPLOAD_SIZE_MB=${MAX_UPLOAD_MB}"
echo "GUNICORN_WORKERS=${GUNICORN_WORKERS}"
echo "GUNICORN_TIMEOUT=120"
echo "LOG_LEVEL=info"
echo ""
echo "ADMIN_USERNAME=${ADMIN_USERNAME}"
echo "ADMIN_PASSWORD=${ADMIN_PASSWORD}"
echo "ADMIN_EMAIL=${ADMIN_EMAIL}"
echo ""
if [ "$DEPLOY_MODE" = "new-mysql" ]; then
echo "MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}"
echo "MYSQL_DATABASE=resource_library"
echo "MYSQL_USER=resource_library"
echo "MYSQL_PASSWORD=${MYSQL_PASSWORD}"
echo "MYSQL_EXPOSE_PORT=${MYSQL_EXPOSE_PORT:-127.0.0.1:3306}"
else
echo "DATABASE_URL=${DATABASE_URL}"
echo "DB_WAIT_SECONDS=${DB_WAIT_SECONDS:-30}"
fi
if [ "${USE_NGINX}" = "yes" ]; then
echo ""
echo "NGINX_HTTP_PORT=${NGINX_HTTP_PORT:-80}"
echo "NGINX_HTTPS_PORT=${NGINX_HTTPS_PORT:-443}"
fi
} > "$env_path"
chmod 600 "$env_path"
ok "配置文件已写入: $env_path"
}
# ── 写入部署状态文件 ──────────────────────────────────────────────
write_state_file() {
{
echo "DEPLOY_MODE=${DEPLOY_MODE}"
echo "COMPOSE_FILE=${COMPOSE_FILE}"
echo "ENV_FILE=${ENV_FILE}"
echo "IMAGE_SOURCE=${IMAGE_SOURCE}"
echo "REGISTRY_IMAGE=${REGISTRY_IMAGE:-}"
echo "APP_PORT=${APP_PORT}"
echo "USE_NGINX=${USE_NGINX}"
echo "DEPLOYED_AT=$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
} > "$STATE_FILE"
ok "状态文件已写入: $STATE_FILE"
}
# ── 构建或拉取镜像 ────────────────────────────────────────────────
prepare_image() {
section "准备镜像"
cd "$PROJECT_DIR"
if [ "$IMAGE_SOURCE" = "registry" ]; then
info "从镜像仓库拉取: ${REGISTRY_IMAGE}:latest …"
docker pull "${REGISTRY_IMAGE}:latest" || die "镜像拉取失败"
ok "镜像拉取完成"
else
info "本地构建镜像(首次构建可能需要几分钟)…"
docker build -t resource-library:latest . 2>&1 | tee -a "$LOG_FILE"
ok "本地构建完成"
fi
}
# ── 启动服务 ──────────────────────────────────────────────────────
start_services() {
section "启动服务"
cd "$PROJECT_DIR"
local compose_args=(--env-file "$ENV_FILE" -f "$COMPOSE_FILE")
if [ "$USE_NGINX" = "yes" ]; then
compose_args+=(--profile nginx)
fi
info "执行: $COMPOSE_CMD ${compose_args[*]} up -d"
$COMPOSE_CMD "${compose_args[@]}" up -d 2>&1 | tee -a "$LOG_FILE"
ok "容器已启动"
}
# ── 健康检查 ──────────────────────────────────────────────────────
health_check() {
section "健康检查"
local max_wait=120 elapsed=0 interval=5
local url="http://localhost:${APP_PORT}/auth/login"
info "等待服务就绪(最长 ${max_wait}s"
while [ "$elapsed" -lt "$max_wait" ]; do
if curl -fsSL --max-time 5 "$url" > /dev/null 2>&1; then
ok "服务已就绪!耗时: ${elapsed}s"
return 0
fi
printf '.'
sleep "$interval"
elapsed=$((elapsed + interval))
done
echo
err "健康检查超时(${max_wait}s"
warn "查看日志: $COMPOSE_CMD -f $COMPOSE_FILE logs --tail=50"
return 1
}
# ── 部署完成提示 ──────────────────────────────────────────────────
print_success() {
local local_ip
local_ip=$(hostname -I 2>/dev/null | awk '{print $1}') || local_ip="<服务器IP>"
echo
echo -e "${GREEN}${BOLD}═══════════════════════════════════════════════${NC}"
echo -e "${GREEN}${BOLD} 🎉 部署成功! ${NC}"
echo -e "${GREEN}${BOLD}═══════════════════════════════════════════════${NC}"
echo
echo -e " 访问地址: ${CYAN}http://${local_ip}:${APP_PORT}${NC}"
if [ "$USE_NGINX" = "yes" ]; then
echo -e " Nginx: ${CYAN}http://${local_ip}:${NGINX_HTTP_PORT:-80}${NC}"
fi
echo
echo -e " 管理员账号: ${YELLOW}${ADMIN_USERNAME}${NC}"
echo -e " 管理员密码: ${YELLOW}(部署时设置)${NC}"
echo
echo -e " 查看日志: ${BLUE}$COMPOSE_CMD -f $COMPOSE_FILE logs -f${NC}"
echo -e " 更新应用: ${BLUE}bash scripts/update.sh${NC}"
echo -e " 停止服务: ${BLUE}$COMPOSE_CMD -f $COMPOSE_FILE down${NC}"
echo
echo -e " 部署日志: $LOG_FILE"
echo -e "${GREEN}${BOLD}═══════════════════════════════════════════════${NC}"
echo
}
# ── 主流程 ────────────────────────────────────────────────────────
main() {
banner
log "部署日志: $LOG_FILE"
check_bash_version
check_root
check_internet
detect_os
install_docker
detect_compose
collect_config
write_env_file
write_state_file
prepare_image
start_services
health_check
print_success
}
main "$@"

363
scripts/update.sh Normal file
View File

@@ -0,0 +1,363 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════════
# 个人资料库 — 一键更新脚本
# 支持镜像来源Gitea 镜像仓库(拉取)/ 本地源码(重新构建)
# 失败时自动回滚至上一版本
#
# 用法bash scripts/update.sh [--yes] [--tag <镜像标签>]
# --yes 跳过确认提示
# --tag <tag> 指定目标镜像标签(默认: latest
# ═══════════════════════════════════════════════════════════════════
set -euo pipefail
IFS=$'\n\t'
# ── 脚本自身位置 & 项目根目录 ─────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
STATE_FILE="$PROJECT_DIR/.deploy-state"
LOG_FILE="/tmp/resource-library-update-$(date +%Y%m%d%H%M%S).log"
# ── 颜色 & 输出 ──────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
log() { echo -e "$(date '+%H:%M:%S') $*" | tee -a "$LOG_FILE"; }
info() { echo -e "${CYAN}[INFO]${NC} $*" | tee -a "$LOG_FILE"; }
ok() { echo -e "${GREEN}[ OK ]${NC} $*" | tee -a "$LOG_FILE"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*" | tee -a "$LOG_FILE"; }
err() { echo -e "${RED}[ERR ]${NC} $*" | tee -a "$LOG_FILE" >&2; }
die() { err "$*"; exit 1; }
section() { echo -e "\n${BOLD}${BLUE}── $* ─────────────────────────────────────${NC}" | tee -a "$LOG_FILE"; }
banner() {
cat << 'EOF'
╔═══════════════════════════════════════════╗
║ 个人资料库 一键更新脚本 ║
║ Personal Resource Library Updater ║
╚═══════════════════════════════════════════╝
EOF
}
# ── 参数解析 ──────────────────────────────────────────────────────
AUTO_YES="no"
TARGET_TAG="latest"
while [ $# -gt 0 ]; do
case "$1" in
-y|--yes) AUTO_YES="yes" ;;
--tag) shift; TARGET_TAG="${1:-latest}" ;;
-h|--help)
echo "用法: $0 [--yes] [--tag <镜像标签>]"
echo " --yes 跳过确认"
echo " --tag <tag> 指定镜像标签(默认: latest"
exit 0 ;;
*) warn "未知参数: $1" ;;
esac
shift
done
# ── 工具函数 ──────────────────────────────────────────────────────
command_exists() { command -v "$1" &>/dev/null; }
confirm() {
[ "$AUTO_YES" = "yes" ] && return 0
echo -e "${YELLOW}[?]${NC} $* [y/N]: "
read -r ans
echo "$ans" | grep -qiE "^y(es)?$"
}
# ── 读取部署状态 ──────────────────────────────────────────────────
load_state() {
section "读取部署状态"
[ -f "$STATE_FILE" ] || die "未找到 .deploy-state 文件,请先运行 deploy.sh"
# shellcheck disable=SC1090
. "$STATE_FILE"
# 必须存在的字段
: "${DEPLOY_MODE:?STATE 缺少 DEPLOY_MODE}"
: "${COMPOSE_FILE:?STATE 缺少 COMPOSE_FILE}"
: "${ENV_FILE:?STATE 缺少 ENV_FILE}"
: "${IMAGE_SOURCE:?STATE 缺少 IMAGE_SOURCE}"
local env_path="$PROJECT_DIR/$ENV_FILE"
[ -f "$env_path" ] || die "env 文件不存在: $env_path"
ok "部署模式: $DEPLOY_MODE"
ok "镜像来源: $IMAGE_SOURCE"
ok "Compose: $COMPOSE_FILE"
ok "上次部署: ${DEPLOYED_AT:-未知}"
}
# ── 检测 Docker Compose 命令 ──────────────────────────────────────
detect_compose() {
if docker compose version &>/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
elif command_exists docker-compose; then
COMPOSE_CMD="docker-compose"
else
die "未找到 docker compose / docker-compose请先运行 deploy.sh"
fi
ok "Compose: $COMPOSE_CMD"
}
# ── 获取当前运行镜像的摘要 ────────────────────────────────────────
get_current_digest() {
local container_name="${1:-resource_library_app}"
docker inspect "$container_name" \
--format='{{index .Config.Image}}:{{.Image}}' 2>/dev/null ||
echo "unknown"
}
# ── 拉取最新镜像 ──────────────────────────────────────────────────
pull_image() {
section "拉取最新镜像"
local image="${REGISTRY_IMAGE}:${TARGET_TAG}"
info "目标镜像: $image"
OLD_DIGEST=$(docker inspect "$image" --format='{{.Id}}' 2>/dev/null || echo "none")
info "当前本地摘要: ${OLD_DIGEST:0:19}"
info "拉取镜像…"
if ! docker pull "$image" 2>&1 | tee -a "$LOG_FILE"; then
err "镜像拉取失败"
return 1
fi
NEW_DIGEST=$(docker inspect "$image" --format='{{.Id}}' 2>/dev/null || echo "none")
info "最新远端摘要: ${NEW_DIGEST:0:19}"
if [ "$OLD_DIGEST" = "$NEW_DIGEST" ] && [ "$OLD_DIGEST" != "none" ]; then
warn "镜像未变更(摘要相同),无需更新"
if ! confirm "仍要重启服务吗?"; then
ok "跳过更新"
exit 0
fi
else
ok "检测到新版本镜像"
fi
}
# ── 本地重新构建镜像 ──────────────────────────────────────────────
build_image() {
section "本地重新构建镜像"
cd "$PROJECT_DIR"
# 尝试拉取最新代码
if command_exists git && [ -d .git ]; then
info "拉取最新代码…"
local branch
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
if git pull --ff-only 2>&1 | tee -a "$LOG_FILE"; then
ok "代码已更新(分支: $branch"
else
warn "git pull 失败,使用当前代码构建"
fi
fi
info "重新构建镜像…"
docker build -t resource-library:latest . 2>&1 | tee -a "$LOG_FILE"
ok "镜像构建完成"
}
# ── 备份当前容器镜像标签(用于回滚)─────────────────────────────
backup_current_image() {
section "备份当前镜像"
local rollback_tag
rollback_tag="rollback-$(date +%Y%m%d%H%M%S)"
ROLLBACK_TAG="$rollback_tag"
local current_image
if [ "$IMAGE_SOURCE" = "registry" ]; then
current_image="${REGISTRY_IMAGE}:${TARGET_TAG}"
else
current_image="resource-library:latest"
fi
if docker image inspect "$current_image" &>/dev/null; then
local target_tag
if [ "$IMAGE_SOURCE" = "registry" ]; then
target_tag="${REGISTRY_IMAGE}:${rollback_tag}"
else
target_tag="resource-library:${rollback_tag}"
fi
docker tag "$current_image" "$target_tag"
ok "备份标签: $target_tag"
ROLLBACK_IMAGE="$target_tag"
else
warn "当前镜像不存在,跳过备份(首次更新?)"
ROLLBACK_IMAGE=""
fi
}
# ── 滚动更新服务 ──────────────────────────────────────────────────
rolling_update() {
section "滚动更新服务"
cd "$PROJECT_DIR"
local compose_args=(--env-file "$ENV_FILE" -f "$COMPOSE_FILE")
[ "${USE_NGINX:-no}" = "yes" ] && compose_args+=(--profile nginx)
info "拉起新容器…"
$COMPOSE_CMD "${compose_args[@]}" up -d --remove-orphans 2>&1 | tee -a "$LOG_FILE"
ok "新容器已启动"
}
# ── 健康检查 ──────────────────────────────────────────────────────
health_check() {
section "健康检查"
local max_wait=90 elapsed=0 interval=5
local url="http://localhost:${APP_PORT:-5000}/auth/login"
info "等待服务就绪(最长 ${max_wait}s"
while [ "$elapsed" -lt "$max_wait" ]; do
if curl -fsSL --max-time 5 "$url" > /dev/null 2>&1; then
ok "服务健康!(${elapsed}s"
return 0
fi
printf '.'
sleep "$interval"
elapsed=$((elapsed + interval))
done
echo
err "健康检查超时"
return 1
}
# ── 自动回滚 ──────────────────────────────────────────────────────
rollback() {
section "⚠️ 触发自动回滚"
if [ -z "${ROLLBACK_IMAGE:-}" ]; then
err "无备份镜像,无法回滚,请手动排查"
exit 1
fi
warn "正在恢复到备份版本: $ROLLBACK_IMAGE"
cd "$PROJECT_DIR"
# 将备份镜像还原为目标标签
if [ "$IMAGE_SOURCE" = "registry" ]; then
docker tag "$ROLLBACK_IMAGE" "${REGISTRY_IMAGE}:${TARGET_TAG}"
else
docker tag "$ROLLBACK_IMAGE" "resource-library:latest"
fi
local compose_args=(--env-file "$ENV_FILE" -f "$COMPOSE_FILE")
[ "${USE_NGINX:-no}" = "yes" ] && compose_args+=(--profile nginx)
$COMPOSE_CMD "${compose_args[@]}" up -d 2>&1 | tee -a "$LOG_FILE"
# 等待回滚后服务就绪
local rb_wait=60 elapsed=0
while [ "$elapsed" -lt "$rb_wait" ]; do
if curl -fsSL --max-time 5 "http://localhost:${APP_PORT:-5000}/auth/login" > /dev/null 2>&1; then
ok "回滚成功,服务已恢复(${elapsed}s"
echo
err "本次更新失败,已回滚至备份版本: $ROLLBACK_IMAGE"
err "更新日志: $LOG_FILE"
exit 1
fi
sleep 5
elapsed=$((elapsed + 5))
done
die "回滚后服务仍不健康,请手动检查: $COMPOSE_CMD ${compose_args[*]} logs"
}
# ── 清理旧回滚标签(保留最近 3 个)──────────────────────────────
cleanup_old_backups() {
section "清理旧备份镜像"
local prefix
if [ "$IMAGE_SOURCE" = "registry" ]; then
prefix="${REGISTRY_IMAGE}:rollback-"
else
prefix="resource-library:rollback-"
fi
# 列出所有 rollback-* 标签,按时间排序,保留最新 3 个
local tags=()
while IFS= read -r tag; do
tags+=("$tag")
done < <(docker images --format '{{.Repository}}:{{.Tag}}' | grep "^${prefix}" | sort -r)
local keep=3
if [ "${#tags[@]}" -gt "$keep" ]; then
local to_delete=("${tags[@]:$keep}")
for tag in "${to_delete[@]}"; do
info "删除旧备份镜像: $tag"
docker rmi "$tag" 2>/dev/null || true
done
ok "已清理 ${#to_delete[@]} 个旧备份"
else
ok "备份数量未超限(${#tags[@]}/${keep}),无需清理"
fi
}
# ── 打印更新结果 ──────────────────────────────────────────────────
print_success() {
local local_ip
local_ip=$(hostname -I 2>/dev/null | awk '{print $1}') || local_ip="<服务器IP>"
echo
echo -e "${GREEN}${BOLD}═══════════════════════════════════════════════${NC}"
echo -e "${GREEN}${BOLD} 🚀 更新成功! ${NC}"
echo -e "${GREEN}${BOLD}═══════════════════════════════════════════════${NC}"
echo
echo -e " 访问地址: ${CYAN}http://${local_ip}:${APP_PORT:-5000}${NC}"
echo
if [ -n "${ROLLBACK_IMAGE:-}" ]; then
echo -e " 回滚备份: ${YELLOW}$ROLLBACK_IMAGE${NC}"
echo -e " 手动回滚: ${BLUE}docker tag $ROLLBACK_IMAGE ${REGISTRY_IMAGE:-resource-library}:latest${NC}"
echo -e " ${BLUE}$COMPOSE_CMD -f $COMPOSE_FILE up -d${NC}"
fi
echo
echo -e " 查看日志: ${BLUE}$COMPOSE_CMD -f $COMPOSE_FILE logs -f${NC}"
echo -e " 更新日志: $LOG_FILE"
echo -e "${GREEN}${BOLD}═══════════════════════════════════════════════${NC}"
echo
}
# ── 主流程 ────────────────────────────────────────────────────────
main() {
banner
log "更新日志: $LOG_FILE"
load_state
detect_compose
# 显示当前状态
section "当前运行状态"
$COMPOSE_CMD -f "$COMPOSE_FILE" ps 2>/dev/null | tee -a "$LOG_FILE" || true
# 确认更新
confirm "确认执行更新?" || { info "已取消"; exit 0; }
backup_current_image
# 根据镜像来源更新
if [ "$IMAGE_SOURCE" = "registry" ]; then
pull_image || { rollback; }
else
build_image
fi
rolling_update
if ! health_check; then
rollback
fi
# 更新状态文件中的时间戳
sed -i "s/^DEPLOYED_AT=.*/DEPLOYED_AT=$(date -u '+%Y-%m-%dT%H:%M:%SZ')/" \
"$STATE_FILE" 2>/dev/null || true
cleanup_old_backups
print_success
}
main "$@"