357 lines
9.1 KiB
Bash
Executable File
357 lines
9.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
COMPOSE_FILE="${ROOT_DIR}/deploy/docker-compose.prod.yml"
|
|
BACKEND_ENV="${ROOT_DIR}/backend/.env"
|
|
BACKEND_LOG_DIR="${ROOT_DIR}/backend/logs"
|
|
HEALTH_URL="${PROD_HEALTH_URL:-}"
|
|
PUBLIC_URL="${PROD_PUBLIC_URL:-}"
|
|
READY_TIMEOUT="${PROD_READY_TIMEOUT:-120}"
|
|
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
BLUE='\033[1;34m'
|
|
NC='\033[0m'
|
|
|
|
log() {
|
|
printf "${BLUE}[deploy]${NC} %s\n" "$*"
|
|
}
|
|
|
|
log_success() {
|
|
printf "${GREEN}[deploy]${NC} %s\n" "$*"
|
|
}
|
|
|
|
log_warn() {
|
|
printf "${YELLOW}[deploy]${NC} %s\n" "$*"
|
|
}
|
|
|
|
log_error() {
|
|
printf "${RED}[deploy]${NC} %s\n" "$*" >&2
|
|
}
|
|
|
|
need_cmd() {
|
|
if ! command -v "$1" >/dev/null 2>&1; then
|
|
log_error "缺少命令:$1"
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
env_value() {
|
|
local key="$1"
|
|
awk -F= -v key="${key}" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' "${BACKEND_ENV}"
|
|
}
|
|
|
|
require_env() {
|
|
local key="$1"
|
|
local value
|
|
value="$(env_value "${key}")"
|
|
if [[ -z "${value}" ]]; then
|
|
log_error "backend/.env 缺少或未填写 ${key}"
|
|
exit 1
|
|
fi
|
|
printf "%s" "${value}"
|
|
}
|
|
|
|
show_help() {
|
|
cat << EOF
|
|
用法: ./scripts/deploy-prod.sh [选项]
|
|
|
|
选项:
|
|
--no-build 不重新构建镜像,仅启动已有镜像
|
|
--no-migrate 不执行数据库迁移
|
|
--reset-db 重置数据库(删除并重建)
|
|
--logs 部署完成后跟随查看后端日志
|
|
-h, --help 显示帮助信息
|
|
|
|
环境变量:
|
|
PROD_HEALTH_URL=https://你的域名/api/health
|
|
部署后健康检查地址,默认使用 CADDY_DOMAIN
|
|
PROD_PUBLIC_URL=https://你的域名
|
|
输出给用户看的访问地址
|
|
PROD_READY_TIMEOUT=120
|
|
等待服务就绪秒数
|
|
|
|
示例:
|
|
./scripts/deploy-prod.sh
|
|
./scripts/deploy-prod.sh --no-build
|
|
./scripts/deploy-prod.sh --reset-db
|
|
./scripts/deploy-prod.sh --logs
|
|
EOF
|
|
}
|
|
|
|
NO_BUILD=0
|
|
NO_MIGRATE=0
|
|
RESET_DB=0
|
|
FOLLOW_LOGS=0
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--no-build)
|
|
NO_BUILD=1
|
|
shift
|
|
;;
|
|
--no-migrate)
|
|
NO_MIGRATE=1
|
|
shift
|
|
;;
|
|
--reset-db)
|
|
RESET_DB=1
|
|
shift
|
|
;;
|
|
--logs)
|
|
FOLLOW_LOGS=1
|
|
shift
|
|
;;
|
|
-h|--help)
|
|
show_help
|
|
exit 0
|
|
;;
|
|
*)
|
|
log_error "未知选项:$1"
|
|
show_help
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${RESET_DB}" == "1" && "${NO_MIGRATE}" == "1" ]]; then
|
|
log_error "--reset-db 会清空数据库,不能和 --no-migrate 同时使用"
|
|
exit 1
|
|
fi
|
|
|
|
validate_env() {
|
|
if [[ ! -f "${BACKEND_ENV}" ]]; then
|
|
log_error "未找到 backend/.env,请先执行:cp backend/.env.prod.example backend/.env 并填写生产配置"
|
|
exit 1
|
|
fi
|
|
|
|
local app_env caddy_domain caddy_email mysql_dsn redis_addr storage_endpoint
|
|
app_env="$(require_env APP_ENV)"
|
|
caddy_domain="$(require_env CADDY_DOMAIN)"
|
|
caddy_email="$(require_env CADDY_EMAIL)"
|
|
mysql_dsn="$(require_env MYSQL_DSN)"
|
|
redis_addr="$(require_env REDIS_ADDR)"
|
|
storage_endpoint="$(require_env STORAGE_ENDPOINT)"
|
|
require_env MYSQL_DATABASE >/dev/null
|
|
require_env MYSQL_ROOT_PASSWORD >/dev/null
|
|
require_env MYSQL_USER >/dev/null
|
|
require_env MYSQL_PASSWORD >/dev/null
|
|
require_env JWT_SECRET >/dev/null
|
|
require_env MINIO_ROOT_USER >/dev/null
|
|
require_env MINIO_ROOT_PASSWORD >/dev/null
|
|
require_env STORAGE_ACCESS_KEY_ID >/dev/null
|
|
require_env STORAGE_SECRET_ACCESS_KEY >/dev/null
|
|
|
|
if [[ "${app_env}" != "production" ]]; then
|
|
log_warn "APP_ENV 当前是 ${app_env},生产部署建议改为 production"
|
|
fi
|
|
|
|
if [[ "${caddy_domain}" == http://* || "${caddy_domain}" == https://* || "${caddy_domain}" == */* ]]; then
|
|
log_error "CADDY_DOMAIN 只填写域名本身,不要包含 http(s):// 或路径,当前值:${caddy_domain}"
|
|
exit 1
|
|
fi
|
|
if [[ "${caddy_domain}" == *"127.0.0.1"* || "${caddy_domain}" == *"localhost"* || "${caddy_domain}" != *"."* ]]; then
|
|
log_error "CADDY_DOMAIN 必须填写可公网解析到本机的正式域名,当前值:${caddy_domain}"
|
|
exit 1
|
|
fi
|
|
if [[ "${caddy_email}" == "admin@example.com" || "${caddy_email}" != *"@"* ]]; then
|
|
log_error "CADDY_EMAIL 需要填写真实邮箱,用于 ACME 证书通知"
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "${mysql_dsn}" == *"127.0.0.1"* || "${mysql_dsn}" == *"localhost"* ]]; then
|
|
log_error "MYSQL_DSN 仍指向本机,请改为 tcp(mysql:3306)"
|
|
exit 1
|
|
fi
|
|
if [[ "${redis_addr}" == "127.0.0.1:"* || "${redis_addr}" == "localhost:"* ]]; then
|
|
log_error "REDIS_ADDR 仍指向本机,请改为 redis:6379"
|
|
exit 1
|
|
fi
|
|
if [[ "${storage_endpoint}" == *"127.0.0.1"* || "${storage_endpoint}" == *"localhost"* ]]; then
|
|
log_error "STORAGE_ENDPOINT 仍指向本机,请改为 http://minio:9000"
|
|
exit 1
|
|
fi
|
|
if awk -F= '/^[A-Z0-9_]+=/{ if ($0 ~ /=(change-|保持你的|你的)/) found=1 } END { exit found ? 0 : 1 }' "${BACKEND_ENV}"; then
|
|
log_error "backend/.env 里还有占位值,请先替换后再部署"
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
compose() {
|
|
docker compose --env-file "${BACKEND_ENV}" -f "${COMPOSE_FILE}" "$@"
|
|
}
|
|
|
|
compose_user_cmd() {
|
|
printf "docker compose --env-file backend/.env -f deploy/docker-compose.prod.yml"
|
|
}
|
|
|
|
export_caddy_env() {
|
|
export CADDY_DOMAIN
|
|
export CADDY_EMAIL
|
|
CADDY_DOMAIN="$(require_env CADDY_DOMAIN)"
|
|
CADDY_EMAIL="$(require_env CADDY_EMAIL)"
|
|
}
|
|
|
|
public_url() {
|
|
if [[ -n "${PUBLIC_URL}" ]]; then
|
|
printf "%s" "${PUBLIC_URL}"
|
|
return
|
|
fi
|
|
printf "https://%s" "$(require_env CADDY_DOMAIN)"
|
|
}
|
|
|
|
health_url() {
|
|
if [[ -n "${HEALTH_URL}" ]]; then
|
|
printf "%s" "${HEALTH_URL}"
|
|
return
|
|
fi
|
|
printf "%s/api/health" "$(public_url)"
|
|
}
|
|
|
|
prepare_log_dir() {
|
|
mkdir -p "${BACKEND_LOG_DIR}"
|
|
}
|
|
|
|
wait_service_healthy() {
|
|
local service="$1"
|
|
local label="$2"
|
|
local attempt
|
|
|
|
log "等待 ${label} 就绪..."
|
|
for ((attempt = 1; attempt <= READY_TIMEOUT; attempt++)); do
|
|
local container_id status
|
|
container_id="$(compose ps -q "${service}" 2>/dev/null || true)"
|
|
if [[ -n "${container_id}" ]]; then
|
|
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${container_id}" 2>/dev/null || true)"
|
|
if [[ "${status}" == "healthy" || "${status}" == "running" ]]; then
|
|
log_success "${label} 已就绪"
|
|
return 0
|
|
fi
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
log_error "${label} 启动超时"
|
|
log_warn "最近 ${service} 日志:"
|
|
compose logs --tail=120 "${service}" || true
|
|
log_error "可继续执行:$(compose_user_cmd) logs ${service}"
|
|
exit 1
|
|
}
|
|
|
|
mysql_root_exec() {
|
|
compose exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot "$@"' sh "$@"
|
|
}
|
|
|
|
goose_dsn() {
|
|
local dsn="$1"
|
|
if [[ "${dsn}" != *"multiStatements="* ]]; then
|
|
if [[ "${dsn}" == *"?"* ]]; then
|
|
dsn="${dsn}&multiStatements=true"
|
|
else
|
|
dsn="${dsn}?multiStatements=true"
|
|
fi
|
|
fi
|
|
printf "%s" "${dsn}"
|
|
}
|
|
|
|
goose_cmd() {
|
|
compose run --rm --no-deps backend /app/goose -dir /app/migrations mysql "$(goose_dsn "$(require_env MYSQL_DSN)")" "$@"
|
|
}
|
|
|
|
reset_database() {
|
|
if [[ "${RESET_DB}" != "1" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
local database
|
|
database="$(require_env MYSQL_DATABASE)"
|
|
if [[ ! "${database}" =~ ^[A-Za-z0-9_]+$ ]]; then
|
|
log_error "MYSQL_DATABASE 只能包含字母、数字和下划线,当前值:${database}"
|
|
exit 1
|
|
fi
|
|
|
|
log_warn "重置数据库 ${database}..."
|
|
mysql_root_exec -e \
|
|
"DROP DATABASE IF EXISTS \`${database}\`; CREATE DATABASE \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
|
log_success "数据库已重建"
|
|
}
|
|
|
|
run_migrations() {
|
|
if [[ "${NO_MIGRATE}" == "1" ]]; then
|
|
log_warn "已跳过数据库迁移"
|
|
return 0
|
|
fi
|
|
|
|
log "使用 goose 执行数据库迁移..."
|
|
goose_cmd up
|
|
log_success "数据库迁移完成"
|
|
}
|
|
|
|
wait_http_ready() {
|
|
local attempt
|
|
local url
|
|
url="$(health_url)"
|
|
log "等待接口健康检查通过..."
|
|
for ((attempt = 1; attempt <= READY_TIMEOUT; attempt++)); do
|
|
if curl -fsS --max-time 5 "${url}" >/dev/null 2>&1; then
|
|
log_success "健康检查通过:${url}"
|
|
return 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
log_error "健康检查超时:${url}"
|
|
exit 1
|
|
}
|
|
|
|
main() {
|
|
cd "${ROOT_DIR}"
|
|
need_cmd docker
|
|
need_cmd curl
|
|
validate_env
|
|
export_caddy_env
|
|
prepare_log_dir
|
|
|
|
log "检查 Docker Compose 配置..."
|
|
compose config >/dev/null
|
|
|
|
if [[ "${NO_BUILD}" == "1" ]]; then
|
|
log "启动生产服务..."
|
|
compose up -d
|
|
else
|
|
log "构建后端镜像..."
|
|
compose build backend
|
|
|
|
log "构建 Caddy 镜像(含前端静态资源)..."
|
|
compose build caddy
|
|
|
|
log "启动生产服务..."
|
|
compose up -d --no-build
|
|
fi
|
|
|
|
wait_service_healthy mysql "MySQL"
|
|
wait_service_healthy redis "Redis"
|
|
wait_service_healthy minio "MinIO"
|
|
wait_service_healthy caddy "Caddy"
|
|
reset_database
|
|
run_migrations
|
|
|
|
log "重启后端以加载最新数据库状态..."
|
|
compose up -d --no-deps --force-recreate backend
|
|
wait_service_healthy backend "后端"
|
|
compose up -d --no-deps --force-recreate caddy
|
|
wait_service_healthy caddy "Caddy"
|
|
wait_http_ready
|
|
|
|
log_success "部署完成:$(public_url)"
|
|
log "常用命令:$(compose_user_cmd) logs -f caddy backend"
|
|
|
|
if [[ "${FOLLOW_LOGS}" == "1" ]]; then
|
|
compose logs -f backend
|
|
fi
|
|
}
|
|
|
|
main "$@"
|