873 lines
29 KiB
Bash
873 lines
29 KiB
Bash
#!/usr/bin/env bash
|
||
# ============================================
|
||
# YouduWiki 一键部署脚本
|
||
# 用法: ./deploy.sh [选项]
|
||
#
|
||
# 选项:
|
||
# --registry URL 从镜像仓库拉取 (跳过源码构建, 2分钟部署)
|
||
# --tag TAG 指定镜像标签 (配合 --registry, 默认 latest)
|
||
# --build 强制重新构建所有镜像
|
||
# --skip-build 跳过镜像构建 (使用已有镜像)
|
||
# --pull 拉取最新的基础镜像
|
||
# --clean 停止并清理所有容器和数据 (危险!)
|
||
# --stop 停止所有服务
|
||
# --restart 重启所有服务
|
||
# --logs 查看所有服务日志
|
||
# --status 查看服务运行状态
|
||
# --help 显示帮助信息
|
||
#
|
||
# 示例:
|
||
# ./deploy.sh # 源码构建部署
|
||
# ./deploy.sh --registry registry.cn-hangzhou.aliyuncs.com/my-ns # 镜像部署
|
||
# ============================================
|
||
|
||
set -e
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
cd "$SCRIPT_DIR"
|
||
|
||
ENV_FILE=".env"
|
||
COMPOSE_FILE="docker-compose.yml"
|
||
COMPOSE_FILE_REGISTRY="docker-compose.registry.yml"
|
||
USE_REGISTRY=false
|
||
REGISTRY_URL=""
|
||
IMAGE_TAG="${TAG:-latest}"
|
||
|
||
ADMIN_PORT="${ADMIN_PORT:-2443}"
|
||
APP_PORT="${APP_PORT:-3010}"
|
||
API_PORT="${API_PORT:-8000}"
|
||
|
||
# 颜色
|
||
RED='\033[0;31m'
|
||
GREEN='\033[0;32m'
|
||
YELLOW='\033[1;33m'
|
||
BLUE='\033[0;34m'
|
||
CYAN='\033[0;36m'
|
||
MAGENTA='\033[0;35m'
|
||
BOLD='\033[1m'
|
||
DIM='\033[2m'
|
||
NC='\033[0m'
|
||
|
||
# 终端宽度
|
||
TERM_WIDTH=${COLUMNS:-80}
|
||
((TERM_WIDTH < 60)) && TERM_WIDTH=60
|
||
|
||
# ============================================
|
||
# 阶段定义 (用于进度追踪)
|
||
# ============================================
|
||
# 总共几个阶段 (不含子步骤):
|
||
# 1. 环境检查
|
||
# 2. 配置环境变量
|
||
# 3. 构建 api 镜像
|
||
# 4. 构建 consumer 镜像
|
||
# 5. 构建 admin 镜像
|
||
# 6. 构建 app 镜像
|
||
# 7. 启动基础设施 + 等待就绪
|
||
# 8. 启动业务服务 + 等待就绪
|
||
TOTAL_STAGES=8
|
||
CURRENT_STAGE=0
|
||
|
||
# 首次构建预估耗时 (秒)
|
||
# api: ~3min, consumer: ~2min, admin: ~10min (pnpm install), app: ~8min
|
||
# 基础设施: ~30s, 业务服务: ~30s
|
||
EST_FIRST_BUILD=$((180 + 120 + 600 + 480 + 30 + 30))
|
||
# 缓存构建预估耗时 (秒)
|
||
EST_CACHED_BUILD=$((30 + 20 + 60 + 50 + 30 + 30))
|
||
|
||
OVERALL_START=0
|
||
STAGE_START=0
|
||
IS_FIRST_BUILD=true
|
||
|
||
# ============================================
|
||
# 进度条绘制
|
||
# ============================================
|
||
|
||
# 清空当前行
|
||
clear_line() { printf "\r\033[K"; }
|
||
|
||
# 格式化时间
|
||
format_time() {
|
||
local seconds=$1
|
||
if [ "$seconds" -lt 60 ]; then
|
||
printf "%ds" "$seconds"
|
||
elif [ "$seconds" -lt 3600 ]; then
|
||
printf "%dm%ds" $((seconds / 60)) $((seconds % 60))
|
||
else
|
||
printf "%dh%dm" $((seconds / 3600)) $(((seconds % 3600) / 60))
|
||
fi
|
||
}
|
||
|
||
# 绘制进度条
|
||
# 参数: $1=当前值 $2=最大值 $3=前缀文本 $4=后缀文本
|
||
draw_progress_bar() {
|
||
local current=$1
|
||
local total=${2:-100}
|
||
local prefix="${3:-}"
|
||
local suffix="${4:-}"
|
||
|
||
(( total < 1 )) && total=1
|
||
(( current > total )) && current=$total
|
||
local pct=$((current * 100 / total))
|
||
|
||
# 计算可用宽度
|
||
local bar_width=$((TERM_WIDTH - 45))
|
||
((bar_width < 10)) && bar_width=10
|
||
|
||
local filled=$((pct * bar_width / 100))
|
||
local empty=$((bar_width - filled))
|
||
|
||
# 颜色随进度变化
|
||
local bar_color="${GREEN}"
|
||
if [ "$pct" -lt 30 ]; then bar_color="${RED}"; fi
|
||
if [ "$pct" -ge 30 ] && [ "$pct" -lt 70 ]; then bar_color="${YELLOW}"; fi
|
||
|
||
# 已填充的块
|
||
local filled_bar=""
|
||
local i
|
||
for ((i = 0; i < filled; i++)); do filled_bar+="█"; done
|
||
for ((i = 0; i < empty; i++)); do filled_bar+="░"; done
|
||
|
||
printf "\r\033[K ${DIM}%s${NC} ${bar_color}%s${NC} ${BOLD}%3d%%${NC} ${DIM}%s${NC}" \
|
||
"$prefix" "$filled_bar" "$pct" "$suffix"
|
||
}
|
||
|
||
# ============================================
|
||
# 旋转动画 (用于不确定进度的步骤)
|
||
# ============================================
|
||
|
||
SPINNER_CHARS="⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
||
SPINNER_PID=""
|
||
SPINNER_ACTIVE=false
|
||
|
||
start_spinner() {
|
||
local message="$1"
|
||
SPINNER_ACTIVE=true
|
||
(
|
||
local i=0
|
||
local elapsed=0
|
||
while $SPINNER_ACTIVE; do
|
||
local c="${SPINNER_CHARS:$((i % 10)):1}"
|
||
local elapsed_str
|
||
elapsed_str=$(format_time $elapsed)
|
||
|
||
# 估算剩余时间
|
||
local suffix="已运行 ${elapsed_str}"
|
||
if [ -n "${STAGE_EST_SEC:-}" ] && [ "$STAGE_EST_SEC" -gt 0 ]; then
|
||
local remaining=$((STAGE_EST_SEC - elapsed))
|
||
((remaining < 0)) && remaining=0
|
||
suffix="已运行 ${elapsed_str} | 预计剩余 $(format_time $remaining)"
|
||
fi
|
||
|
||
printf "\r\033[K ${CYAN}%s${NC} ${DIM}%s${NC} %s" "$c" "$message" "$suffix"
|
||
sleep 0.1
|
||
elapsed=$((elapsed + 1))
|
||
i=$((i + 1))
|
||
done
|
||
) &
|
||
SPINNER_PID=$!
|
||
}
|
||
|
||
stop_spinner() {
|
||
SPINNER_ACTIVE=false
|
||
[ -n "$SPINNER_PID" ] && wait "$SPINNER_PID" 2>/dev/null
|
||
SPINNER_PID=""
|
||
}
|
||
|
||
# ============================================
|
||
# 阶段计数器
|
||
# ============================================
|
||
|
||
next_stage() {
|
||
CURRENT_STAGE=$((CURRENT_STAGE + 1))
|
||
STAGE_START=$(date +%s)
|
||
}
|
||
|
||
# 打印阶段标题
|
||
stage_header() {
|
||
local stage_name="$1"
|
||
local est_sec="${2:-0}"
|
||
STAGE_EST_SEC=$est_sec
|
||
|
||
local elapsed_total=0
|
||
if [ "$OVERALL_START" -gt 0 ]; then
|
||
elapsed_total=$(($(date +%s) - OVERALL_START))
|
||
fi
|
||
|
||
echo ""
|
||
clear_line
|
||
printf " ${BOLD}${BLUE}[%d/%d]${NC} ${BOLD}%s${NC}" "$CURRENT_STAGE" "$TOTAL_STAGES" "$stage_name"
|
||
if [ "$est_sec" -gt 0 ]; then
|
||
printf " ${DIM}(预计 %s)${NC}" "$(format_time $est_sec)"
|
||
fi
|
||
if [ "$elapsed_total" -gt 0 ]; then
|
||
printf " ${DIM}总耗时: %s${NC}" "$(format_time $elapsed_total)"
|
||
fi
|
||
echo ""
|
||
}
|
||
|
||
# ============================================
|
||
# 带进度的等待函数
|
||
# ============================================
|
||
|
||
# 等待某个条件成立,显示进度条
|
||
# $1: 等待描述 $2: 最大等待秒数 $3: 检查命令
|
||
wait_with_progress() {
|
||
local desc="$1"
|
||
local max_wait="${2:-60}"
|
||
local check_cmd="$3"
|
||
|
||
local waited=0
|
||
while [ $waited -lt $max_wait ]; do
|
||
if eval "$check_cmd" &>/dev/null; then
|
||
draw_progress_bar "$max_wait" "$max_wait" "$desc" "就绪 ✓"
|
||
echo ""
|
||
return 0
|
||
fi
|
||
draw_progress_bar "$waited" "$max_wait" "$desc" "$(format_time $waited)"
|
||
sleep 2
|
||
waited=$((waited + 2))
|
||
done
|
||
draw_progress_bar "$max_wait" "$max_wait" "$desc" "超时 ✗"
|
||
echo ""
|
||
return 1
|
||
}
|
||
|
||
# ============================================
|
||
# 构建单个镜像 (带旋转动画)
|
||
# ============================================
|
||
|
||
build_single_image() {
|
||
local service="$1"
|
||
local label="$2"
|
||
local est_sec="${3:-120}"
|
||
|
||
next_stage
|
||
stage_header "$label" "$est_sec"
|
||
|
||
# 创建临时文件捕获构建日志
|
||
local logfile
|
||
logfile=$(mktemp /tmp/youdu-wiki-build-XXXXXX.log)
|
||
|
||
# 在后台构建
|
||
docker compose -f "$COMPOSE_FILE" build --progress=plain "$service" >"$logfile" 2>&1 &
|
||
local build_pid=$!
|
||
|
||
# 显示旋转动画,监控构建日志中的关键行
|
||
start_spinner "构建中 (可另开终端查看: tail -f $logfile)"
|
||
local last_summary=""
|
||
while kill -0 "$build_pid" 2>/dev/null; do
|
||
# 尝试从日志中提取当前步骤
|
||
local current_step
|
||
current_step=$(grep -E "^\s*#\d+|^\s*\[.*\]|^#\d+.*RUN|DONE|CACHED|ERROR" "$logfile" 2>/dev/null | tail -1 || true)
|
||
if [ -n "$current_step" ] && [ "$current_step" != "$last_summary" ]; then
|
||
last_summary="$current_step"
|
||
# 清理 spinner 行,打印步骤信息
|
||
printf "\r\033[K ${DIM}→ %s${NC}\n" "$(echo "$current_step" | sed 's/^[[:space:]]*//' | cut -c1-80)"
|
||
start_spinner "构建中"
|
||
fi
|
||
sleep 1
|
||
done
|
||
stop_spinner
|
||
|
||
# 检查构建结果
|
||
wait "$build_pid"
|
||
local exit_code=$?
|
||
|
||
local elapsed=$(( $(date +%s) - STAGE_START ))
|
||
|
||
if [ $exit_code -eq 0 ]; then
|
||
clear_line
|
||
printf " ${GREEN}✓${NC} %s ${DIM}(耗时 %s)${NC}\n" "$label 构建成功" "$(format_time $elapsed)"
|
||
else
|
||
clear_line
|
||
printf " ${RED}✗${NC} %s ${DIM}(耗时 %s)${NC}\n" "$label 构建失败" "$(format_time $elapsed)"
|
||
echo ""
|
||
echo -e " ${RED}构建日志 (最后 30 行):${NC}"
|
||
echo " ─────────────────────────────────────────"
|
||
tail -30 "$logfile" | while IFS= read -r line; do echo " $line"; done
|
||
echo " ─────────────────────────────────────────"
|
||
echo " 完整日志: $logfile"
|
||
rm -f "$logfile"
|
||
exit 1
|
||
fi
|
||
rm -f "$logfile"
|
||
}
|
||
|
||
# ============================================
|
||
# 检查构建缓存状态
|
||
# ============================================
|
||
|
||
check_build_cache() {
|
||
# 检查是否已有构建缓存 (通过 docker layer 检查)
|
||
if docker image inspect youdu-wiki-api:latest &>/dev/null && \
|
||
docker image inspect youdu-wiki-admin:latest &>/dev/null; then
|
||
IS_FIRST_BUILD=false
|
||
fi
|
||
}
|
||
|
||
# ============================================
|
||
# 日志函数
|
||
# ============================================
|
||
|
||
log_info() { echo -e " ${GREEN}✓${NC} $1"; }
|
||
log_warn() { echo -e " ${YELLOW}⚠${NC} $1"; }
|
||
log_error() { echo -e " ${RED}✗${NC} $1"; }
|
||
|
||
banner() {
|
||
clear_line
|
||
echo -e "${BLUE}"
|
||
echo " ╔══════════════════════════════════════╗"
|
||
echo " ║ YouduWiki 部署工具 ║"
|
||
echo " ║ v3.85.0 (功能全解锁版) ║"
|
||
echo " ╚══════════════════════════════════════╝"
|
||
echo -e "${NC}"
|
||
}
|
||
|
||
# ============================================
|
||
# 1. 环境检查
|
||
# ============================================
|
||
|
||
check_prerequisites() {
|
||
next_stage
|
||
stage_header "环境检查" 3
|
||
|
||
local checks=()
|
||
|
||
if command -v docker &>/dev/null; then
|
||
checks+=("Docker: $(docker --version 2>/dev/null | cut -d' ' -f3 | tr -d ',')")
|
||
else
|
||
log_error "未找到 Docker,请先安装 Docker Engine 20.x+"
|
||
echo " Ubuntu: sudo apt-get install docker-ce"
|
||
exit 1
|
||
fi
|
||
|
||
if docker compose version &>/dev/null; then
|
||
checks+=("Compose: v$(docker compose version --short 2>/dev/null)")
|
||
elif command -v docker-compose &>/dev/null; then
|
||
checks+=("Compose: $(docker-compose --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+')")
|
||
else
|
||
log_error "未找到 Docker Compose"
|
||
exit 1
|
||
fi
|
||
|
||
if [ -f "$COMPOSE_FILE" ]; then
|
||
checks+=("配置文件: ${COMPOSE_FILE}")
|
||
else
|
||
log_error "未找到 ${COMPOSE_FILE}"
|
||
exit 1
|
||
fi
|
||
|
||
local available_gb
|
||
available_gb=$(df -BG . 2>/dev/null | awk 'NR==2 {print $4}' | sed 's/G//')
|
||
if [ -n "$available_gb" ]; then
|
||
if [ "${available_gb:-0}" -lt 10 ]; then
|
||
checks+=("磁盘空间: ${available_gb}GB (偏小, 建议≥10GB)")
|
||
else
|
||
checks+=("磁盘空间: ${available_gb}GB")
|
||
fi
|
||
fi
|
||
|
||
local mem_mb
|
||
mem_mb=$(free -m 2>/dev/null | awk '/^Mem:/{print $2}')
|
||
if [ -n "$mem_mb" ]; then
|
||
checks+=("内存: $((mem_mb / 1024))GB")
|
||
fi
|
||
|
||
for check in "${checks[@]}"; do
|
||
log_info "$check"
|
||
done
|
||
clear_line
|
||
printf " ${GREEN}✓${NC} 环境检查通过 ${DIM}(耗时 %s)${NC}\n" "$(format_time $(($(date +%s) - STAGE_START)))"
|
||
}
|
||
|
||
# ============================================
|
||
# 2. 环境变量
|
||
# ============================================
|
||
|
||
setup_env() {
|
||
next_stage
|
||
stage_header "配置环境变量" 2
|
||
|
||
if [ -f "$ENV_FILE" ]; then
|
||
set -a; source "$ENV_FILE"; set +a
|
||
log_info "已加载 ${ENV_FILE}"
|
||
return
|
||
fi
|
||
|
||
local pg_pass; pg_pass=$(openssl rand -base64 16 2>/dev/null || head -c 16 /dev/urandom | base64 | tr -d '\n')
|
||
local redis_pass; redis_pass=$(openssl rand -base64 16 2>/dev/null || head -c 16 /dev/urandom | base64 | tr -d '\n')
|
||
local nats_pass; nats_pass=$(openssl rand -base64 16 2>/dev/null || head -c 16 /dev/urandom | base64 | tr -d '\n')
|
||
local s3_pass; s3_pass=$(openssl rand -base64 16 2>/dev/null || head -c 16 /dev/urandom | base64 | tr -d '\n')
|
||
local jwt_secret; jwt_secret=$(openssl rand -base64 32 2>/dev/null || head -c 32 /dev/urandom | base64 | tr -d '\n')
|
||
|
||
cat > "$ENV_FILE" << EOF
|
||
# YouduWiki 环境变量 - $(date '+%Y-%m-%d %H:%M:%S')
|
||
POSTGRES_PASSWORD=${pg_pass}
|
||
REDIS_PASSWORD=${redis_pass}
|
||
NATS_USER=youdu-wiki
|
||
NATS_PASSWORD=${nats_pass}
|
||
S3_ACCESS_KEY=s3youdu-wiki
|
||
S3_SECRET_KEY=${s3_pass}
|
||
JWT_SECRET=${jwt_secret}
|
||
ADMIN_PASSWORD=admin123
|
||
LOG_LEVEL=0
|
||
ADMIN_PORT=2443
|
||
APP_PORT=3010
|
||
API_PORT=8000
|
||
EOF
|
||
|
||
chmod 600 "$ENV_FILE"
|
||
set -a; source "$ENV_FILE"; set +a
|
||
log_info "已生成随机密码并保存至 ${ENV_FILE}"
|
||
}
|
||
|
||
# ============================================
|
||
# 3-6. 构建镜像
|
||
# ============================================
|
||
|
||
build_images() {
|
||
# 检查是否有缓存 → 决定预估时间
|
||
check_build_cache
|
||
local est
|
||
if [ "$IS_FIRST_BUILD" = true ]; then
|
||
est=$((EST_FIRST_BUILD - 30 - 30)) # 仅构建部分
|
||
else
|
||
est=$((EST_CACHED_BUILD - 30 - 30))
|
||
fi
|
||
|
||
echo ""
|
||
clear_line
|
||
if [ "$IS_FIRST_BUILD" = true ]; then
|
||
printf " ${YELLOW}首次构建${NC} ${DIM}需要下载依赖包, 预计 %s${NC}\n" "$(format_time $est)"
|
||
else
|
||
printf " ${GREEN}检测到已有镜像${NC} ${DIM}增量构建, 预计 %s${NC}\n" "$(format_time $est)"
|
||
fi
|
||
|
||
# api
|
||
build_single_image "api" "构建后端 API 镜像 (Go 编译)" 180
|
||
|
||
# consumer
|
||
build_single_image "consumer" "构建后端 Consumer 镜像 (Go 编译)" 120
|
||
|
||
# admin (最慢: pnpm install 几百个包)
|
||
build_single_image "admin" "构建管理后台镜像 (React + pnpm)" 600
|
||
|
||
# app
|
||
build_single_image "app" "构建 Wiki 前端镜像 (Next.js + pnpm)" 480
|
||
|
||
echo ""
|
||
clear_line
|
||
printf " ${GREEN}✓${NC} 全部镜像构建完成\n"
|
||
|
||
# 显示镜像列表
|
||
echo ""
|
||
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" 2>/dev/null \
|
||
| grep -E "youdu-wiki|REPOSITORY" \
|
||
| while IFS= read -r line; do echo " $line"; done
|
||
}
|
||
|
||
# ============================================
|
||
# 拉取基础镜像 (带并行进度)
|
||
# ============================================
|
||
|
||
pull_base_images() {
|
||
next_stage
|
||
stage_header "拉取基础镜像" 60
|
||
|
||
local pulls=()
|
||
docker pull postgres:16-alpine & pulls+=($!)
|
||
docker pull redis:7-alpine & pulls+=($!)
|
||
docker pull nats:2-alpine & pulls+=($!)
|
||
docker pull minio/minio:latest & pulls+=($!)
|
||
|
||
start_spinner "并行拉取 postgres, redis, nats, minio ..."
|
||
for pid in "${pulls[@]}"; do wait "$pid" 2>/dev/null; done
|
||
stop_spinner
|
||
|
||
clear_line
|
||
printf " ${GREEN}✓${NC} 基础镜像拉取完成 ${DIM}(耗时 %s)${NC}\n" \
|
||
"$(format_time $(($(date +%s) - STAGE_START)))"
|
||
}
|
||
|
||
# ============================================
|
||
# Registry 模式: 拉取预构建镜像
|
||
# ============================================
|
||
|
||
pull_registry_images() {
|
||
next_stage
|
||
stage_header "从镜像仓库拉取预构建镜像" 120
|
||
|
||
COMPOSE_FILE="$COMPOSE_FILE_REGISTRY"
|
||
|
||
echo -e " ${DIM}仓库: ${REGISTRY_URL}${NC}"
|
||
echo -e " ${DIM}标签: ${IMAGE_TAG}${NC}"
|
||
echo ""
|
||
|
||
# 并行拉取
|
||
local pulls=()
|
||
local images=(
|
||
"youdu-wiki-api"
|
||
"youdu-wiki-consumer"
|
||
"youdu-wiki-admin"
|
||
"youdu-wiki-app"
|
||
)
|
||
|
||
for img in "${images[@]}"; do
|
||
local full_img="${REGISTRY_URL}/${img}:${IMAGE_TAG}"
|
||
echo -e " ${DIM}拉取 ${full_img}${NC}"
|
||
docker pull "$full_img" &
|
||
pulls+=($!)
|
||
done
|
||
|
||
# 等待全部拉取完成
|
||
start_spinner "并行拉取 4 个镜像 ..."
|
||
for pid in "${pulls[@]}"; do
|
||
wait "$pid" 2>/dev/null
|
||
done
|
||
stop_spinner
|
||
|
||
local elapsed=$(($(date +%s) - STAGE_START))
|
||
clear_line
|
||
printf " ${GREEN}✓${NC} 镜像拉取完成 ${DIM}(耗时 %s)${NC}\n" "$(format_time $elapsed)"
|
||
|
||
echo ""
|
||
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" \
|
||
| grep -E "youdu-wiki|REPOSITORY" \
|
||
| while IFS= read -r line; do echo " $line"; done
|
||
}
|
||
|
||
# ============================================
|
||
# 本地镜像文件加载
|
||
# ============================================
|
||
|
||
load_local_images() {
|
||
local dir="$1"
|
||
local tag="${2:-latest}"
|
||
|
||
next_stage
|
||
stage_header "从本地文件加载镜像" 60
|
||
|
||
if [ ! -d "$dir" ]; then
|
||
log_error "目录不存在: $dir"
|
||
exit 1
|
||
fi
|
||
|
||
echo -e " ${DIM}目录: ${dir}${NC}"
|
||
echo -e " ${DIM}标签: ${tag}${NC}"
|
||
echo ""
|
||
|
||
local images=(
|
||
"youdu-wiki-api"
|
||
"youdu-wiki-consumer"
|
||
"youdu-wiki-admin"
|
||
"youdu-wiki-app"
|
||
)
|
||
|
||
for img in "${images[@]}"; do
|
||
local tar_file="${dir}/${img}-${tag}.tar"
|
||
local gz_file="${tar_file}.gz"
|
||
|
||
if [ -f "$gz_file" ]; then
|
||
echo -e " ${YELLOW}加载 (gzip):${NC} ${gz_file}"
|
||
gunzip -c "$gz_file" | docker load
|
||
elif [ -f "$tar_file" ]; then
|
||
echo -e " ${YELLOW}加载:${NC} ${tar_file}"
|
||
docker load -i "$tar_file"
|
||
else
|
||
log_error "未找到: ${tar_file} 或 ${gz_file}"
|
||
echo " 请确认文件名格式: ${img}-${tag}.tar[.gz]"
|
||
exit 1
|
||
fi
|
||
echo -e " ${GREEN}✓${NC} ${img} 加载完成"
|
||
echo ""
|
||
done
|
||
|
||
local elapsed=$(($(date +%s) - STAGE_START))
|
||
clear_line
|
||
printf " ${GREEN}✓${NC} 镜像加载完成 ${DIM}(耗时 %s)${NC}\n" "$(format_time $elapsed)"
|
||
|
||
echo ""
|
||
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" \
|
||
| grep -E "youdu-wiki|REPOSITORY" \
|
||
| while IFS= read -r line; do echo " $line"; done
|
||
}
|
||
|
||
# ============================================
|
||
# 7. 启动基础设施
|
||
# ============================================
|
||
|
||
start_infra() {
|
||
next_stage
|
||
stage_header "启动基础设施 (PostgreSQL, Redis, NATS, MinIO)" 60
|
||
|
||
local compose_cmd="docker compose -f $COMPOSE_FILE"
|
||
if [ "$USE_REGISTRY" = true ]; then
|
||
compose_cmd="REGISTRY=${REGISTRY_URL} TAG=${IMAGE_TAG} docker compose -f $COMPOSE_FILE"
|
||
fi
|
||
|
||
start_spinner "启动容器 ..."
|
||
eval "$compose_cmd up -d postgres redis nats minio" >/dev/null 2>&1
|
||
stop_spinner
|
||
log_info "容器已创建"
|
||
|
||
echo ""
|
||
if wait_with_progress "PostgreSQL 启动" 60 \
|
||
"docker compose -f $COMPOSE_FILE ps postgres 2>/dev/null | grep -q healthy"; then
|
||
log_info "PostgreSQL 就绪"
|
||
else
|
||
log_error "PostgreSQL 启动超时"; echo ""; echo " 排查: docker compose -f $COMPOSE_FILE logs postgres"; exit 1
|
||
fi
|
||
|
||
if wait_with_progress "Redis 启动 " 30 \
|
||
"docker compose -f $COMPOSE_FILE exec -T redis redis-cli -a ${REDIS_PASSWORD:-ChangeMe123!} ping 2>/dev/null | grep -q PONG"; then
|
||
log_info "Redis 就绪"
|
||
else
|
||
log_warn "Redis 健康检查未通过,继续..."
|
||
fi
|
||
|
||
local elapsed=$(( $(date +%s) - STAGE_START ))
|
||
clear_line
|
||
printf " ${GREEN}✓${NC} 基础设施就绪 ${DIM}(耗时 %s)${NC}\n" "$(format_time $elapsed)"
|
||
}
|
||
|
||
# ============================================
|
||
# 8. 启动业务服务
|
||
# ============================================
|
||
|
||
start_app() {
|
||
next_stage
|
||
stage_header "启动业务服务 (API, Consumer, Admin, App)" 60
|
||
|
||
local compose_cmd="docker compose -f $COMPOSE_FILE"
|
||
if [ "$USE_REGISTRY" = true ]; then
|
||
compose_cmd="REGISTRY=${REGISTRY_URL} TAG=${IMAGE_TAG} docker compose -f $COMPOSE_FILE"
|
||
fi
|
||
|
||
start_spinner "启动容器 ..."
|
||
eval "$compose_cmd up -d api consumer admin app" >/dev/null 2>&1
|
||
stop_spinner
|
||
log_info "容器已创建"
|
||
|
||
echo ""
|
||
if wait_with_progress "API 服务启动 (含数据库迁移)" 45 \
|
||
"curl -sf http://localhost:${API_PORT}/api/v1/health 2>/dev/null"; then
|
||
log_info "API 服务就绪"
|
||
else
|
||
log_error "API 服务启动超时"; echo ""; echo " 排查: docker compose -f $COMPOSE_FILE logs api | tail -30"; exit 1
|
||
fi
|
||
|
||
local elapsed=$(( $(date +%s) - STAGE_START ))
|
||
clear_line
|
||
printf " ${GREEN}✓${NC} 业务服务就绪 ${DIM}(耗时 %s)${NC}\n" "$(format_time $elapsed)"
|
||
}
|
||
|
||
# ============================================
|
||
# 部署完成信息
|
||
# ============================================
|
||
|
||
show_info() {
|
||
local total_elapsed
|
||
total_elapsed=$(($(date +%s) - OVERALL_START))
|
||
|
||
local server_ip
|
||
if command -v hostname &>/dev/null; then
|
||
server_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||
fi
|
||
[ -z "$server_ip" ] && server_ip="服务器IP"
|
||
|
||
echo ""
|
||
echo -e " ${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||
echo -e " ${BOLD}${GREEN} ✓ YouduWiki 部署成功!${NC} ${DIM}总耗时: $(format_time $total_elapsed)${NC}"
|
||
echo -e " ${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||
echo ""
|
||
echo -e " ${BOLD}访问地址:${NC}"
|
||
echo -e " ┌─────────────────────────────────────────────────┐"
|
||
printf " │ 管理后台: %-37s │\n" "http://${server_ip}:${ADMIN_PORT}"
|
||
printf " │ Wiki 前端: %-37s │\n" "http://${server_ip}:${APP_PORT}"
|
||
printf " │ API 服务: %-37s │\n" "http://${server_ip}:${API_PORT}"
|
||
printf " │ MinIO 控制台: %-35s │\n" "http://${server_ip}:9001"
|
||
echo -e " └─────────────────────────────────────────────────┘"
|
||
echo ""
|
||
echo -e " ${BOLD}登录凭据:${NC}"
|
||
echo -e " ┌─────────────────────────────────────────────────┐"
|
||
printf " │ 用户名: %-38s │\n" "admin"
|
||
printf " │ 密码: %-38s │\n" "${ADMIN_PASSWORD:-admin123}"
|
||
echo -e " │ ${YELLOW}(首次登录后请在管理后台修改密码)${NC} │"
|
||
echo -e " └─────────────────────────────────────────────────┘"
|
||
echo ""
|
||
echo -e " ${BOLD}${YELLOW}⚡ 下一步:${NC}"
|
||
echo -e " 1. 访问管理后台 → 登录"
|
||
echo -e " 2. 配置 AI 大模型 (设置 → 模型管理)"
|
||
echo -e " 3. 创建知识库 → 上传文档 → 等待学习"
|
||
echo -e " 4. 访问 Wiki 前端体验 AI 问答"
|
||
echo ""
|
||
echo -e " ${BOLD}常用命令:${NC}"
|
||
echo -e " ./deploy.sh --status 查看运行状态"
|
||
echo -e " ./deploy.sh --logs 查看实时日志"
|
||
echo -e " ./deploy.sh --restart 重启全部服务"
|
||
echo -e " ./deploy.sh --stop 停止全部服务"
|
||
echo ""
|
||
}
|
||
|
||
# ============================================
|
||
# 辅助命令
|
||
# ============================================
|
||
|
||
stop_services() {
|
||
echo -e "\n ${YELLOW}停止所有服务...${NC}"
|
||
docker compose -f "$COMPOSE_FILE" down 2>/dev/null
|
||
echo -e " ${GREEN}✓${NC} 已停止\n"
|
||
}
|
||
|
||
restart_services() {
|
||
echo -e "\n ${YELLOW}重启所有服务...${NC}"
|
||
docker compose -f "$COMPOSE_FILE" restart 2>/dev/null
|
||
echo -e " ${GREEN}✓${NC} 已重启\n"
|
||
}
|
||
|
||
show_logs() {
|
||
echo -e "\n ${DIM}实时日志 (Ctrl+C 退出)${NC}\n"
|
||
docker compose -f "$COMPOSE_FILE" logs -f --tail=50
|
||
}
|
||
|
||
show_status() {
|
||
echo -e "\n ${BOLD}服务运行状态${NC}\n"
|
||
docker compose -f "$COMPOSE_FILE" ps 2>/dev/null
|
||
echo ""
|
||
|
||
if curl -sf http://localhost:${API_PORT}/api/v1/health &>/dev/null; then
|
||
echo -e " API 健康检查: ${GREEN}正常${NC}"
|
||
else
|
||
echo -e " API 健康检查: ${RED}异常${NC}"
|
||
fi
|
||
|
||
echo ""
|
||
echo " 容器资源占用:"
|
||
docker stats --no-stream \
|
||
--format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}" \
|
||
$(docker compose -f "$COMPOSE_FILE" ps -q) 2>/dev/null || true
|
||
}
|
||
|
||
clean_all() {
|
||
echo -e "\n ${RED}${BOLD}⚠ 危险操作: 将删除所有容器、数据卷和镜像${NC}"
|
||
echo -e " ${RED}数据库中的所有数据将永久丢失!${NC}\n"
|
||
read -rp " 输入 DELETE 确认: " confirm
|
||
if [ "$confirm" != "DELETE" ]; then
|
||
echo -e " ${GREEN}✓${NC} 已取消\n"; exit 0
|
||
fi
|
||
|
||
echo -e " ${YELLOW}清理中...${NC}"
|
||
docker compose -f "$COMPOSE_FILE" down -v --remove-orphans 2>/dev/null
|
||
docker images --format "{{.Repository}}:{{.Tag}}" 2>/dev/null \
|
||
| grep "youdu-wiki" \
|
||
| xargs -r docker rmi 2>/dev/null || true
|
||
echo -e " ${GREEN}✓${NC} 清理完成\n"
|
||
}
|
||
|
||
# ============================================
|
||
# 主流程
|
||
# ============================================
|
||
|
||
main() {
|
||
local DO_BUILD=true
|
||
local DO_PULL=false
|
||
local LOAD_DIR=""
|
||
|
||
while [[ $# -gt 0 ]]; do
|
||
case "$1" in
|
||
--load) LOAD_DIR="${2:-mirror}"; DO_BUILD=false; [ $# -gt 1 ] && [[ "$2" != --* ]] && shift; shift ;;
|
||
--registry) USE_REGISTRY=true; REGISTRY_URL="$2"; DO_BUILD=false; COMPOSE_FILE="$COMPOSE_FILE_REGISTRY"; shift 2 ;;
|
||
--tag) IMAGE_TAG="$2"; shift 2 ;;
|
||
--build) DO_BUILD=true; shift ;;
|
||
--skip-build) DO_BUILD=false; shift ;;
|
||
--pull) DO_PULL=true; shift ;;
|
||
--clean) banner; clean_all; exit 0 ;;
|
||
--stop) banner; stop_services; exit 0 ;;
|
||
--restart) banner; restart_services; show_info; exit 0 ;;
|
||
--logs) show_logs; exit 0 ;;
|
||
--status) banner; show_status; exit 0 ;;
|
||
--help|-h)
|
||
banner
|
||
echo " 用法: $0 [选项]"
|
||
echo ""
|
||
echo " 选项:"
|
||
echo " --load [DIR] 从本地目录加载镜像 tar (默认 ./mirror, 约1分钟)"
|
||
echo " --registry URL 从远程镜像仓库拉取 (约2分钟)"
|
||
echo " --tag TAG 指定镜像标签 (配合 --load/--registry)"
|
||
echo " --build 源码构建所有镜像 (首次 20-30min)"
|
||
echo " --skip-build 跳过镜像构建/加载步骤"
|
||
echo " --pull 拉取最新的基础镜像"
|
||
echo " --clean 停止并清理所有容器和数据 (危险!)"
|
||
echo " --stop 停止所有服务"
|
||
echo " --restart 重启所有服务"
|
||
echo " --logs 查看所有服务日志"
|
||
echo " --status 查看服务运行状态"
|
||
echo " --help 显示此帮助信息"
|
||
echo ""
|
||
echo " 三种部署方式 (由快到慢):"
|
||
echo " $0 --load ./images # 本地镜像文件 (最快)"
|
||
echo " $0 --registry registry.cn-hangzhou.aliyuncs.com/my-ns # 远程拉取"
|
||
echo " $0 # 源码构建 (慢)"
|
||
exit 0
|
||
;;
|
||
*) log_error "未知选项: $1"; echo " 使用 --help 查看帮助"; exit 1 ;;
|
||
esac
|
||
done
|
||
|
||
banner
|
||
|
||
# 检测已有运行中的服务
|
||
if docker compose -f "$COMPOSE_FILE" ps --services --filter "status=running" 2>/dev/null | grep -q .; then
|
||
echo -e " ${YELLOW}⚠ 检测到已有服务在运行${NC}"
|
||
read -rp " 是否重新部署? [y/N]: " redeploy
|
||
if [ "$redeploy" != "y" ] && [ "$redeploy" != "Y" ]; then
|
||
echo -e " ${GREEN}✓${NC} 已取消\n"; exit 0
|
||
fi
|
||
docker compose -f "$COMPOSE_FILE" down 2>/dev/null
|
||
fi
|
||
|
||
OVERALL_START=$(date +%s)
|
||
|
||
# 根据模式调整阶段数
|
||
if [ -n "$LOAD_DIR" ] || [ "$USE_REGISTRY" = true ]; then
|
||
TOTAL_STAGES=5
|
||
elif [ "$DO_BUILD" = false ]; then
|
||
TOTAL_STAGES=4
|
||
fi
|
||
|
||
# ─── 执行部署阶段 ───
|
||
check_prerequisites
|
||
setup_env
|
||
|
||
if [ "$DO_PULL" = true ]; then
|
||
pull_base_images
|
||
fi
|
||
|
||
if [ -n "$LOAD_DIR" ]; then
|
||
load_local_images "$LOAD_DIR" "$IMAGE_TAG"
|
||
elif [ "$USE_REGISTRY" = true ]; then
|
||
pull_registry_images
|
||
elif [ "$DO_BUILD" = true ]; then
|
||
build_images
|
||
else
|
||
next_stage
|
||
stage_header "跳过镜像步骤" 1
|
||
local missing=()
|
||
for img in youdu-wiki-api youdu-wiki-consumer youdu-wiki-admin youdu-wiki-app; do
|
||
docker image inspect "${img}:${IMAGE_TAG}" &>/dev/null || missing+=("$img")
|
||
done
|
||
if [ ${#missing[@]} -gt 0 ]; then
|
||
log_error "缺少镜像: ${missing[*]}"
|
||
echo " 请先构建: ./build-push.sh --output ./images"
|
||
exit 1
|
||
fi
|
||
log_info "所有镜像已存在"
|
||
fi
|
||
|
||
start_infra
|
||
start_app
|
||
show_info
|
||
}
|
||
|
||
main "$@"
|