优化服务重构建速度
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -24,6 +24,10 @@ go.work.sum
|
|||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# 镜像导出目录
|
||||||
|
mirror/*.tar
|
||||||
|
mirror/*.tar.gz
|
||||||
|
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
|
|
||||||
.vscode
|
.vscode
|
||||||
|
|||||||
239
build-push.sh
Normal file
239
build-push.sh
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================
|
||||||
|
# YouduWiki 镜像构建 & 导出脚本
|
||||||
|
# 用法:
|
||||||
|
# ./build-push.sh # 只构建到本地 Docker
|
||||||
|
# ./build-push.sh --output ./images # 构建并导出为 tar 文件
|
||||||
|
# ./build-push.sh --output ./images --compress # 导出并 gzip 压缩
|
||||||
|
# ./build-push.sh --push registry.cn-hangzhou.aliyuncs.com/my-ns # 推送到远程仓库
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
TAG="${TAG:-latest}"
|
||||||
|
REGISTRY=""
|
||||||
|
DO_PUSH=false
|
||||||
|
PLATFORM="${PLATFORM:-linux/amd64}"
|
||||||
|
OUTPUT_DIR="mirror"
|
||||||
|
DO_COMPRESS=false
|
||||||
|
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'; BOLD='\033[1m'; DIM='\033[2m'; NC='\033[0m'
|
||||||
|
|
||||||
|
banner() {
|
||||||
|
echo -e "${CYAN}"
|
||||||
|
echo " ╔══════════════════════════════════════╗"
|
||||||
|
echo " ║ YouduWiki 镜像构建 & 导出 ║"
|
||||||
|
echo " ╚══════════════════════════════════════╝"
|
||||||
|
echo -e "${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo " 用法: $0 [选项]"
|
||||||
|
echo ""
|
||||||
|
echo " 选项:"
|
||||||
|
echo " --compress 导出时 gzip 压缩 (约150MB, 适合网盘分享)"
|
||||||
|
echo " --push URL 推送到远程镜像仓库"
|
||||||
|
echo " --tag TAG 镜像标签 (默认: latest)"
|
||||||
|
echo " --platform ARCH 目标平台 (默认: linux/amd64)"
|
||||||
|
echo " --help 显示帮助"
|
||||||
|
echo ""
|
||||||
|
echo " Linux 构建命令:"
|
||||||
|
echo " # 标准构建 (导出到 mirror/ 目录, 4个tar文件共约300MB)"
|
||||||
|
echo " $0"
|
||||||
|
echo ""
|
||||||
|
echo " # 压缩构建 (导出 .tar.gz, 约150MB, 适合网盘分享)"
|
||||||
|
echo " $0 --compress"
|
||||||
|
echo ""
|
||||||
|
echo " # 指定版本号"
|
||||||
|
echo " $0 --tag v1.0.0"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo -e " ${DIM}镜像预估大小:${NC}"
|
||||||
|
echo -e " ${DIM} youdu-wiki-api (Go + alpine) ~40MB${NC}"
|
||||||
|
echo -e " ${DIM} youdu-wiki-consumer (Go + alpine) ~40MB${NC}"
|
||||||
|
echo -e " ${DIM} youdu-wiki-admin (React + nginx) ~45MB${NC}"
|
||||||
|
echo -e " ${DIM} youdu-wiki-app (Next.js + node) ~180MB${NC}"
|
||||||
|
echo -e " ${DIM} ─────────────────────────────────────${NC}"
|
||||||
|
echo -e " ${DIM} 4个镜像 tar 合计 ~300MB, .tar.gz ~150MB${NC}"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# 解析参数
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--output) OUTPUT_DIR="$2"; shift 2 ;;
|
||||||
|
--compress) DO_COMPRESS=true; shift ;;
|
||||||
|
--push) REGISTRY="$2"; DO_PUSH=true; shift 2 ;;
|
||||||
|
--tag) TAG="$2"; shift 2 ;;
|
||||||
|
--platform) PLATFORM="$2"; shift 2 ;;
|
||||||
|
--help|-h) banner; usage; exit 0 ;;
|
||||||
|
*) echo -e "${RED}未知选项: $1${NC}"; usage; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# 构建函数
|
||||||
|
build_one() {
|
||||||
|
local dockerfile="$1"
|
||||||
|
local context="$2"
|
||||||
|
local image_name="$3"
|
||||||
|
local label="$4"
|
||||||
|
local full_image="${image_name}:${TAG}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}${CYAN}━━━ ${label} ━━━${NC}"
|
||||||
|
echo -e " ${DIM}Dockerfile: ${dockerfile}${NC}"
|
||||||
|
echo -e " ${DIM}镜像: ${full_image}${NC}"
|
||||||
|
|
||||||
|
local build_start; build_start=$(date +%s)
|
||||||
|
|
||||||
|
docker buildx build \
|
||||||
|
--platform "${PLATFORM}" \
|
||||||
|
--tag "${full_image}" \
|
||||||
|
--file "${dockerfile}" \
|
||||||
|
--progress=plain \
|
||||||
|
"${context}"
|
||||||
|
|
||||||
|
local elapsed=$(($(date +%s) - build_start))
|
||||||
|
echo -e " ${GREEN}✓${NC} ${label} 构建完成 ${DIM}(${elapsed}s)${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 推送到远程仓库
|
||||||
|
push_one() {
|
||||||
|
local image_name="$1"
|
||||||
|
local full_image="${image_name}:${TAG}"
|
||||||
|
|
||||||
|
[ -z "$REGISTRY" ] && return
|
||||||
|
|
||||||
|
local remote_image="${REGISTRY}/${image_name}:${TAG}"
|
||||||
|
echo -e " ${YELLOW}推送:${NC} ${full_image} → ${DIM}${remote_image}${NC}"
|
||||||
|
docker tag "${full_image}" "${remote_image}"
|
||||||
|
docker push "${remote_image}"
|
||||||
|
echo -e " ${GREEN}✓${NC} 推送完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 导出为 tar 文件
|
||||||
|
export_one() {
|
||||||
|
local image_name="$1"
|
||||||
|
local full_image="${image_name}:${TAG}"
|
||||||
|
local tar_file="${OUTPUT_DIR}/${image_name}-${TAG}.tar"
|
||||||
|
|
||||||
|
echo -e " ${YELLOW}导出:${NC} ${full_image} → ${DIM}${tar_file}${NC}"
|
||||||
|
docker save -o "${tar_file}" "${full_image}"
|
||||||
|
|
||||||
|
local size; size=$(du -h "${tar_file}" | cut -f1)
|
||||||
|
echo -e " ${GREEN}✓${NC} 已导出 (${size})"
|
||||||
|
|
||||||
|
if [ "$DO_COMPRESS" = true ]; then
|
||||||
|
echo -e " ${YELLOW}压缩:${NC} ${tar_file}.gz"
|
||||||
|
gzip -f "${tar_file}"
|
||||||
|
local csize; csize=$(du -h "${tar_file}.gz" | cut -f1)
|
||||||
|
echo -e " ${GREEN}✓${NC} 已压缩 (${csize})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 前置检查
|
||||||
|
check_prerequisites() {
|
||||||
|
echo -e " ${DIM}确认构建环境...${NC}"
|
||||||
|
|
||||||
|
if ! command -v docker &>/dev/null; then
|
||||||
|
echo -e "${RED}未找到 Docker${NC}"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker buildx version &>/dev/null || {
|
||||||
|
echo -e "${RED}需要 Docker Buildx (Docker 19.03+)${NC}"; exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -n "$OUTPUT_DIR" ]; then
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
echo -e " ${DIM}输出目录: ${OUTPUT_DIR}${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DO_PUSH" = true ]; then
|
||||||
|
local reg_host; reg_host=$(echo "$REGISTRY" | cut -d'/' -f1)
|
||||||
|
echo -e " ${YELLOW}确保已执行 docker login ${reg_host}${NC}"
|
||||||
|
read -rp " 已登录? [Y/n]: " confirm
|
||||||
|
[ "$confirm" = "n" ] || [ "$confirm" = "N" ] && { echo -e "${RED}请先登录${NC}"; exit 1; }
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 主流程
|
||||||
|
main() {
|
||||||
|
banner
|
||||||
|
|
||||||
|
echo -e " ${BOLD}构建配置:${NC}"
|
||||||
|
echo -e " 标签: ${GREEN}${TAG}${NC}"
|
||||||
|
echo -e " 平台: ${GREEN}${PLATFORM}${NC}"
|
||||||
|
if [ -n "$OUTPUT_DIR" ]; then
|
||||||
|
echo -e " 导出目录: ${GREEN}${OUTPUT_DIR}${NC}"
|
||||||
|
[ "$DO_COMPRESS" = true ] && echo -e " 压缩: ${GREEN}gzip${NC}"
|
||||||
|
fi
|
||||||
|
[ "$DO_PUSH" = true ] && echo -e " 推送目标: ${GREEN}${REGISTRY}${NC}"
|
||||||
|
|
||||||
|
check_prerequisites
|
||||||
|
|
||||||
|
local total_start; total_start=$(date +%s)
|
||||||
|
local IMAGES=(
|
||||||
|
"backend/Dockerfile.api|./backend|youdu-wiki-api|1/4 后端 API 镜像 (Go 编译)"
|
||||||
|
"backend/Dockerfile.consumer|./backend|youdu-wiki-consumer|2/4 后端 Consumer 镜像"
|
||||||
|
"web/Dockerfile.admin|./web|youdu-wiki-admin|3/4 管理后台镜像 (React)"
|
||||||
|
"web/Dockerfile.app|./web|youdu-wiki-app|4/4 Wiki 前端镜像 (Next.js)"
|
||||||
|
)
|
||||||
|
|
||||||
|
for entry in "${IMAGES[@]}"; do
|
||||||
|
IFS='|' read -r dockerfile context image_name label <<< "$entry"
|
||||||
|
build_one "$dockerfile" "$context" "$image_name" "$label"
|
||||||
|
[ "$DO_PUSH" = true ] && push_one "$image_name"
|
||||||
|
[ -n "$OUTPUT_DIR" ] && export_one "$image_name"
|
||||||
|
done
|
||||||
|
|
||||||
|
local total_elapsed=$(($(date +%s) - total_start))
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo -e " ${GREEN}${BOLD} ✓ 全部完成${NC} ${DIM}总耗时: $((total_elapsed / 60))m$((total_elapsed % 60))s${NC}"
|
||||||
|
echo -e " ${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo " 本地镜像:"
|
||||||
|
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" \
|
||||||
|
| grep -E "youdu-wiki|REPOSITORY" \
|
||||||
|
| while IFS= read -r line; do echo " $line"; done
|
||||||
|
|
||||||
|
if [ -n "$OUTPUT_DIR" ]; then
|
||||||
|
echo ""
|
||||||
|
echo -e " ${GREEN}导出文件:${NC}"
|
||||||
|
|
||||||
|
local ext="tar"
|
||||||
|
[ "$DO_COMPRESS" = true ] && ext="tar.gz"
|
||||||
|
|
||||||
|
# 生成部署端使用说明
|
||||||
|
cat > "${OUTPUT_DIR}/LOAD_README.txt" << READEOF
|
||||||
|
YouduWiki Docker 镜像 - ${TAG}
|
||||||
|
构建时间: $(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
平台: ${PLATFORM}
|
||||||
|
|
||||||
|
部署端加载命令:
|
||||||
|
cd 此目录
|
||||||
|
docker load -i youdu-wiki-api-${TAG}.${ext}
|
||||||
|
docker load -i youdu-wiki-consumer-${TAG}.${ext}
|
||||||
|
docker load -i youdu-wiki-admin-${TAG}.${ext}
|
||||||
|
docker load -i youdu-wiki-app-${TAG}.${ext}
|
||||||
|
|
||||||
|
或使用一键部署脚本:
|
||||||
|
./deploy.sh --load ./mirror
|
||||||
|
READEOF
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
ls -lh "${OUTPUT_DIR}/" | grep -E "youdu-wiki|LOAD" | while IFS= read -r line; do echo " $line"; done
|
||||||
|
echo ""
|
||||||
|
echo -e " ${YELLOW}将 ${OUTPUT_DIR}/ 整个目录复制到部署服务器,然后:${NC}"
|
||||||
|
echo -e " ${BOLD} ./deploy.sh --load ./mirror${NC}"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
227
deploy.sh
227
deploy.sh
@@ -4,15 +4,21 @@
|
|||||||
# 用法: ./deploy.sh [选项]
|
# 用法: ./deploy.sh [选项]
|
||||||
#
|
#
|
||||||
# 选项:
|
# 选项:
|
||||||
# --build 强制重新构建所有镜像
|
# --registry URL 从镜像仓库拉取 (跳过源码构建, 2分钟部署)
|
||||||
# --skip-build 跳过镜像构建 (使用已有镜像)
|
# --tag TAG 指定镜像标签 (配合 --registry, 默认 latest)
|
||||||
# --pull 拉取最新的基础镜像
|
# --build 强制重新构建所有镜像
|
||||||
# --clean 停止并清理所有容器和数据 (危险!)
|
# --skip-build 跳过镜像构建 (使用已有镜像)
|
||||||
# --stop 停止所有服务
|
# --pull 拉取最新的基础镜像
|
||||||
# --restart 重启所有服务
|
# --clean 停止并清理所有容器和数据 (危险!)
|
||||||
# --logs 查看所有服务日志
|
# --stop 停止所有服务
|
||||||
# --status 查看服务运行状态
|
# --restart 重启所有服务
|
||||||
# --help 显示帮助信息
|
# --logs 查看所有服务日志
|
||||||
|
# --status 查看服务运行状态
|
||||||
|
# --help 显示帮助信息
|
||||||
|
#
|
||||||
|
# 示例:
|
||||||
|
# ./deploy.sh # 源码构建部署
|
||||||
|
# ./deploy.sh --registry registry.cn-hangzhou.aliyuncs.com/my-ns # 镜像部署
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@@ -22,6 +28,10 @@ cd "$SCRIPT_DIR"
|
|||||||
|
|
||||||
ENV_FILE=".env"
|
ENV_FILE=".env"
|
||||||
COMPOSE_FILE="docker-compose.yml"
|
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}"
|
ADMIN_PORT="${ADMIN_PORT:-2443}"
|
||||||
APP_PORT="${APP_PORT:-3010}"
|
APP_PORT="${APP_PORT:-3010}"
|
||||||
@@ -479,6 +489,109 @@ pull_base_images() {
|
|||||||
"$(format_time $(($(date +%s) - STAGE_START)))"
|
"$(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. 启动基础设施
|
# 7. 启动基础设施
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -487,25 +600,24 @@ start_infra() {
|
|||||||
next_stage
|
next_stage
|
||||||
stage_header "启动基础设施 (PostgreSQL, Redis, NATS, MinIO)" 60
|
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 "启动容器 ..."
|
start_spinner "启动容器 ..."
|
||||||
docker compose -f "$COMPOSE_FILE" up -d postgres redis nats minio >/dev/null 2>&1
|
eval "$compose_cmd up -d postgres redis nats minio" >/dev/null 2>&1
|
||||||
stop_spinner
|
stop_spinner
|
||||||
log_info "容器已创建"
|
log_info "容器已创建"
|
||||||
|
|
||||||
# 等待 PostgreSQL 就绪 (带进度条)
|
|
||||||
echo ""
|
echo ""
|
||||||
if wait_with_progress "PostgreSQL 启动" 60 \
|
if wait_with_progress "PostgreSQL 启动" 60 \
|
||||||
"docker compose -f $COMPOSE_FILE ps postgres 2>/dev/null | grep -q healthy"; then
|
"docker compose -f $COMPOSE_FILE ps postgres 2>/dev/null | grep -q healthy"; then
|
||||||
log_info "PostgreSQL 就绪"
|
log_info "PostgreSQL 就绪"
|
||||||
else
|
else
|
||||||
log_error "PostgreSQL 启动超时"
|
log_error "PostgreSQL 启动超时"; echo ""; echo " 排查: docker compose -f $COMPOSE_FILE logs postgres"; exit 1
|
||||||
echo ""
|
|
||||||
echo " 排查: docker compose logs postgres"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 等待 Redis 就绪
|
|
||||||
if wait_with_progress "Redis 启动 " 30 \
|
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
|
"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 就绪"
|
log_info "Redis 就绪"
|
||||||
@@ -526,22 +638,22 @@ start_app() {
|
|||||||
next_stage
|
next_stage
|
||||||
stage_header "启动业务服务 (API, Consumer, Admin, App)" 60
|
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 "启动容器 ..."
|
start_spinner "启动容器 ..."
|
||||||
docker compose -f "$COMPOSE_FILE" up -d api consumer admin app >/dev/null 2>&1
|
eval "$compose_cmd up -d api consumer admin app" >/dev/null 2>&1
|
||||||
stop_spinner
|
stop_spinner
|
||||||
log_info "容器已创建"
|
log_info "容器已创建"
|
||||||
|
|
||||||
# 等待 API (数据库迁移 + 启动)
|
|
||||||
echo ""
|
echo ""
|
||||||
if wait_with_progress "API 服务启动 (含数据库迁移)" 45 \
|
if wait_with_progress "API 服务启动 (含数据库迁移)" 45 \
|
||||||
"curl -sf http://localhost:${API_PORT}/api/v1/health 2>/dev/null"; then
|
"curl -sf http://localhost:${API_PORT}/api/v1/health 2>/dev/null"; then
|
||||||
log_info "API 服务就绪"
|
log_info "API 服务就绪"
|
||||||
else
|
else
|
||||||
log_error "API 服务启动超时"
|
log_error "API 服务启动超时"; echo ""; echo " 排查: docker compose -f $COMPOSE_FILE logs api | tail -30"; exit 1
|
||||||
echo ""
|
|
||||||
echo " 排查: docker compose logs api | tail -30"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local elapsed=$(( $(date +%s) - STAGE_START ))
|
local elapsed=$(( $(date +%s) - STAGE_START ))
|
||||||
@@ -659,36 +771,43 @@ clean_all() {
|
|||||||
main() {
|
main() {
|
||||||
local DO_BUILD=true
|
local DO_BUILD=true
|
||||||
local DO_PULL=false
|
local DO_PULL=false
|
||||||
|
local LOAD_DIR=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--build) DO_BUILD=true; shift ;;
|
--load) LOAD_DIR="${2:-mirror}"; DO_BUILD=false; [ $# -gt 1 ] && [[ "$2" != --* ]] && shift; shift ;;
|
||||||
--skip-build) DO_BUILD=false; shift ;;
|
--registry) USE_REGISTRY=true; REGISTRY_URL="$2"; DO_BUILD=false; COMPOSE_FILE="$COMPOSE_FILE_REGISTRY"; shift 2 ;;
|
||||||
--pull) DO_PULL=true; shift ;;
|
--tag) IMAGE_TAG="$2"; shift 2 ;;
|
||||||
--clean) banner; clean_all; exit 0 ;;
|
--build) DO_BUILD=true; shift ;;
|
||||||
--stop) banner; stop_services; exit 0 ;;
|
--skip-build) DO_BUILD=false; shift ;;
|
||||||
--restart) banner; restart_services; show_info; exit 0 ;;
|
--pull) DO_PULL=true; shift ;;
|
||||||
--logs) show_logs; exit 0 ;;
|
--clean) banner; clean_all; exit 0 ;;
|
||||||
--status) banner; show_status; 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)
|
--help|-h)
|
||||||
banner
|
banner
|
||||||
echo " 用法: $0 [选项]"
|
echo " 用法: $0 [选项]"
|
||||||
echo ""
|
echo ""
|
||||||
echo " 选项:"
|
echo " 选项:"
|
||||||
echo " --build 强制重新构建所有镜像 (默认)"
|
echo " --load [DIR] 从本地目录加载镜像 tar (默认 ./mirror, 约1分钟)"
|
||||||
echo " --skip-build 跳过镜像构建步骤"
|
echo " --registry URL 从远程镜像仓库拉取 (约2分钟)"
|
||||||
echo " --pull 拉取最新的基础镜像"
|
echo " --tag TAG 指定镜像标签 (配合 --load/--registry)"
|
||||||
echo " --clean 停止并清理所有容器和数据 (危险!)"
|
echo " --build 源码构建所有镜像 (首次 20-30min)"
|
||||||
echo " --stop 停止所有服务"
|
echo " --skip-build 跳过镜像构建/加载步骤"
|
||||||
echo " --restart 重启所有服务"
|
echo " --pull 拉取最新的基础镜像"
|
||||||
echo " --logs 查看所有服务日志"
|
echo " --clean 停止并清理所有容器和数据 (危险!)"
|
||||||
echo " --status 查看服务运行状态"
|
echo " --stop 停止所有服务"
|
||||||
echo " --help 显示此帮助信息"
|
echo " --restart 重启所有服务"
|
||||||
|
echo " --logs 查看所有服务日志"
|
||||||
|
echo " --status 查看服务运行状态"
|
||||||
|
echo " --help 显示此帮助信息"
|
||||||
echo ""
|
echo ""
|
||||||
echo " 示例:"
|
echo " 三种部署方式 (由快到慢):"
|
||||||
echo " $0 # 完整部署 (构建 + 启动)"
|
echo " $0 --load ./images # 本地镜像文件 (最快)"
|
||||||
echo " $0 --skip-build # 跳过构建,直接启动"
|
echo " $0 --registry registry.cn-hangzhou.aliyuncs.com/my-ns # 远程拉取"
|
||||||
echo " $0 --restart # 重启已部署的服务"
|
echo " $0 # 源码构建 (慢)"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*) log_error "未知选项: $1"; echo " 使用 --help 查看帮助"; exit 1 ;;
|
*) log_error "未知选项: $1"; echo " 使用 --help 查看帮助"; exit 1 ;;
|
||||||
@@ -709,8 +828,10 @@ main() {
|
|||||||
|
|
||||||
OVERALL_START=$(date +%s)
|
OVERALL_START=$(date +%s)
|
||||||
|
|
||||||
# 如果跳过构建,调整总阶段数
|
# 根据模式调整阶段数
|
||||||
if [ "$DO_BUILD" = false ]; then
|
if [ -n "$LOAD_DIR" ] || [ "$USE_REGISTRY" = true ]; then
|
||||||
|
TOTAL_STAGES=5
|
||||||
|
elif [ "$DO_BUILD" = false ]; then
|
||||||
TOTAL_STAGES=4
|
TOTAL_STAGES=4
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -722,18 +843,22 @@ main() {
|
|||||||
pull_base_images
|
pull_base_images
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$DO_BUILD" = true ]; then
|
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
|
build_images
|
||||||
else
|
else
|
||||||
next_stage
|
next_stage
|
||||||
stage_header "跳过镜像构建" 1
|
stage_header "跳过镜像步骤" 1
|
||||||
local missing=()
|
local missing=()
|
||||||
for img in youdu-wiki-api youdu-wiki-consumer youdu-wiki-admin youdu-wiki-app; do
|
for img in youdu-wiki-api youdu-wiki-consumer youdu-wiki-admin youdu-wiki-app; do
|
||||||
docker image inspect "${img}:latest" &>/dev/null || missing+=("$img")
|
docker image inspect "${img}:${IMAGE_TAG}" &>/dev/null || missing+=("$img")
|
||||||
done
|
done
|
||||||
if [ ${#missing[@]} -gt 0 ]; then
|
if [ ${#missing[@]} -gt 0 ]; then
|
||||||
log_error "缺少镜像: ${missing[*]}"
|
log_error "缺少镜像: ${missing[*]}"
|
||||||
echo " 请先运行 ./deploy.sh (不带 --skip-build) 构建镜像"
|
echo " 请先构建: ./build-push.sh --output ./images"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
log_info "所有镜像已存在"
|
log_info "所有镜像已存在"
|
||||||
|
|||||||
176
docker-compose.registry.yml
Normal file
176
docker-compose.registry.yml
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# ============================================
|
||||||
|
# YouduWiki - 预构建镜像部署配置
|
||||||
|
# 使用前请先构建并推送镜像到 Registry
|
||||||
|
# 参见: build-push.sh
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ============================================
|
||||||
|
# 基础设施
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: youdu-wiki-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: youdu-wiki
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ChangeMe123!}
|
||||||
|
POSTGRES_DB: youdu-wiki
|
||||||
|
volumes:
|
||||||
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- youdu-wiki
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U youdu-wiki"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: youdu-wiki-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-ChangeMe123!}
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- youdu-wiki
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-ChangeMe123!}", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
nats:
|
||||||
|
image: nats:2-alpine
|
||||||
|
container_name: youdu-wiki-nats
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
-js
|
||||||
|
-m 8222
|
||||||
|
--user ${NATS_USER:-youdu-wiki}
|
||||||
|
--pass ${NATS_PASSWORD:-ChangeMe123!}
|
||||||
|
networks:
|
||||||
|
- youdu-wiki
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: youdu-wiki-minio
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${S3_ACCESS_KEY:-s3youdu-wiki}
|
||||||
|
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-ChangeMe123!}
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
networks:
|
||||||
|
- youdu-wiki
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 后端服务 (从 Registry 拉取)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: ${REGISTRY:-your-registry}/youdu-wiki-api:${TAG:-latest}
|
||||||
|
container_name: youdu-wiki-api
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PG_DSN: "host=youdu-wiki-postgres user=youdu-wiki password=${POSTGRES_PASSWORD:-ChangeMe123!} dbname=youdu-wiki port=5432 sslmode=disable TimeZone=Asia/Shanghai"
|
||||||
|
MQ_NATS_SERVER: "nats://youdu-wiki-nats:4222"
|
||||||
|
NATS_USER: ${NATS_USER:-youdu-wiki}
|
||||||
|
NATS_PASSWORD: ${NATS_PASSWORD:-ChangeMe123!}
|
||||||
|
REDIS_ADDR: "youdu-wiki-redis:6379"
|
||||||
|
REDIS_PASSWORD: ${REDIS_PASSWORD:-ChangeMe123!}
|
||||||
|
S3_ENDPOINT: "youdu-wiki-minio:9000"
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-s3youdu-wiki}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY:-ChangeMe123!}
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-youdu-wiki-jwt-secret-change-me}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
|
||||||
|
RAG_CT_RAG_BASE_URL: ${RAG_BASE_URL:-http://host.docker.internal:5050}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-0}
|
||||||
|
ENV: ${ENV:-production}
|
||||||
|
SENTRY_ENABLED: "false"
|
||||||
|
ports:
|
||||||
|
- "${API_PORT:-8000}:8000"
|
||||||
|
networks:
|
||||||
|
- youdu-wiki
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
consumer:
|
||||||
|
image: ${REGISTRY:-your-registry}/youdu-wiki-consumer:${TAG:-latest}
|
||||||
|
container_name: youdu-wiki-consumer
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PG_DSN: "host=youdu-wiki-postgres user=youdu-wiki password=${POSTGRES_PASSWORD:-ChangeMe123!} dbname=youdu-wiki port=5432 sslmode=disable TimeZone=Asia/Shanghai"
|
||||||
|
MQ_NATS_SERVER: "nats://youdu-wiki-nats:4222"
|
||||||
|
NATS_USER: ${NATS_USER:-youdu-wiki}
|
||||||
|
NATS_PASSWORD: ${NATS_PASSWORD:-ChangeMe123!}
|
||||||
|
REDIS_ADDR: "youdu-wiki-redis:6379"
|
||||||
|
REDIS_PASSWORD: ${REDIS_PASSWORD:-ChangeMe123!}
|
||||||
|
S3_ENDPOINT: "youdu-wiki-minio:9000"
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-s3youdu-wiki}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY:-ChangeMe123!}
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-youdu-wiki-jwt-secret-change-me}
|
||||||
|
RAG_CT_RAG_BASE_URL: ${RAG_BASE_URL:-http://host.docker.internal:5050}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-0}
|
||||||
|
ENV: ${ENV:-production}
|
||||||
|
SENTRY_ENABLED: "false"
|
||||||
|
networks:
|
||||||
|
- youdu-wiki
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 前端 (从 Registry 拉取)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
admin:
|
||||||
|
image: ${REGISTRY:-your-registry}/youdu-wiki-admin:${TAG:-latest}
|
||||||
|
container_name: youdu-wiki-admin
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${ADMIN_PORT:-2443}:8080"
|
||||||
|
networks:
|
||||||
|
- youdu-wiki
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
app:
|
||||||
|
image: ${REGISTRY:-your-registry}/youdu-wiki-app:${TAG:-latest}
|
||||||
|
container_name: youdu-wiki-app
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-3010}:3010"
|
||||||
|
networks:
|
||||||
|
- youdu-wiki
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data:
|
||||||
|
driver: local
|
||||||
|
redis_data:
|
||||||
|
driver: local
|
||||||
|
minio_data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
youdu-wiki:
|
||||||
|
driver: bridge
|
||||||
@@ -76,7 +76,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile.api
|
dockerfile: Dockerfile.api
|
||||||
image: youdu-wiki-api:latest
|
image: youdu-wiki-api:${TAG:-latest}
|
||||||
container_name: youdu-wiki-api
|
container_name: youdu-wiki-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -111,7 +111,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile.consumer
|
dockerfile: Dockerfile.consumer
|
||||||
image: youdu-wiki-consumer:latest
|
image: youdu-wiki-consumer:${TAG:-latest}
|
||||||
container_name: youdu-wiki-consumer
|
container_name: youdu-wiki-consumer
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -146,7 +146,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./web
|
context: ./web
|
||||||
dockerfile: Dockerfile.admin
|
dockerfile: Dockerfile.admin
|
||||||
image: youdu-wiki-admin:latest
|
image: youdu-wiki-admin:${TAG:-latest}
|
||||||
container_name: youdu-wiki-admin
|
container_name: youdu-wiki-admin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -164,7 +164,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./web
|
context: ./web
|
||||||
dockerfile: Dockerfile.app
|
dockerfile: Dockerfile.app
|
||||||
image: youdu-wiki-app:latest
|
image: youdu-wiki-app:${TAG:-latest}
|
||||||
container_name: youdu-wiki-app
|
container_name: youdu-wiki-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
49
mirror/README.md
Normal file
49
mirror/README.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# YouduWiki Docker 镜像包
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
将整个 `mirror/` 目录放到项目根目录下,然后运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x deploy.sh
|
||||||
|
./deploy.sh --load ./mirror
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会自动加载 4 个镜像并启动全部服务。
|
||||||
|
|
||||||
|
## 手动加载
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker load -i mirror/youdu-wiki-api-latest.tar
|
||||||
|
docker load -i mirror/youdu-wiki-consumer-latest.tar
|
||||||
|
docker load -i mirror/youdu-wiki-admin-latest.tar
|
||||||
|
docker load -i mirror/youdu-wiki-app-latest.tar
|
||||||
|
|
||||||
|
# 加载完成后启动
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 更新到新版本
|
||||||
|
|
||||||
|
1. 下载新版 `mirror/` 目录
|
||||||
|
2. 替换旧的 `mirror/` 目录
|
||||||
|
3. 重新运行 `./deploy.sh --load ./mirror`
|
||||||
|
|
||||||
|
数据(数据库、文件)存储在 Docker Volume 中,替换镜像不会丢失数据。
|
||||||
|
|
||||||
|
## 镜像内容
|
||||||
|
|
||||||
|
| 文件 | 说明 | 大小 |
|
||||||
|
|------|------|------|
|
||||||
|
| youdu-wiki-api-latest.tar | 后端 API 服务 | ~40MB |
|
||||||
|
| youdu-wiki-consumer-latest.tar | 异步任务处理 | ~40MB |
|
||||||
|
| youdu-wiki-admin-latest.tar | 管理后台 | ~45MB |
|
||||||
|
| youdu-wiki-app-latest.tar | Wiki 用户端 | ~180MB |
|
||||||
|
|
||||||
|
## 系统要求
|
||||||
|
|
||||||
|
- Ubuntu 22.04 / Debian 12 / CentOS 7+
|
||||||
|
- Docker 20.x+
|
||||||
|
- CPU: 4 核以上
|
||||||
|
- 内存: 8 GB 以上
|
||||||
|
- 磁盘: 50 GB 以上
|
||||||
Reference in New Issue
Block a user