430 lines
12 KiB
Bash
Executable File
430 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
set -Eeuo pipefail
|
||
|
||
# Ubuntu 服务器一键部署脚本。
|
||
# 用法:
|
||
# bash deploy/ubuntu-deploy.sh
|
||
# bash deploy/ubuntu-deploy.sh --reset-db
|
||
|
||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||
ENV_FILE="${PROJECT_ROOT}/.env"
|
||
ENV_EXAMPLE="${PROJECT_ROOT}/.env.server.example"
|
||
BACKEND_DATA_DIR="${PROJECT_ROOT}/apps/backend/data"
|
||
BACKEND_CONTAINER_UID=1001
|
||
BACKEND_CONTAINER_GID=1001
|
||
HEALTH_URL=""
|
||
READY_TIMEOUT="${READY_TIMEOUT:-120}"
|
||
RESET_DB=false
|
||
RESET_DB_CONFIRM="${RESET_DB_CONFIRM:-}"
|
||
|
||
log() {
|
||
printf '\033[1;34m[deploy]\033[0m %s\n' "$*"
|
||
}
|
||
|
||
warn() {
|
||
printf '\033[1;33m[deploy]\033[0m %s\n' "$*"
|
||
}
|
||
|
||
fail() {
|
||
printf '\033[1;31m[deploy]\033[0m %s\n' "$*" >&2
|
||
exit 1
|
||
}
|
||
|
||
print_usage() {
|
||
cat <<'EOF'
|
||
用法:
|
||
bash deploy/ubuntu-deploy.sh
|
||
bash deploy/ubuntu-deploy.sh --reset-db
|
||
|
||
参数:
|
||
--reset-db 删除 Compose PostgreSQL 数据卷 postgres_data,再按 migrations 目录全量重建(001 基线 + 全部增量)。
|
||
--yes 跳过 --reset-db 的交互确认,也可使用环境变量 RESET_DB_CONFIRM=1。
|
||
-h, --help 显示帮助。
|
||
|
||
注意:
|
||
--reset-db 会删除所有 PostgreSQL 数据,包括订单、任务、后台用户、审计日志和迁移记录。
|
||
日常结构变更请新增 002_xxx.sql 等渐进迁移,不要改已应用的历史迁移文件。
|
||
它不会删除 apps/backend/data 下的平台 JSON 配置,也不会删除 Caddy 证书卷。
|
||
|
||
图片存储使用阿里云 OSS(.env 的 STORAGE_OSS_*),生产编排不含 MinIO。
|
||
EOF
|
||
}
|
||
|
||
parse_args() {
|
||
while (($# > 0)); do
|
||
case "$1" in
|
||
--reset-db)
|
||
RESET_DB=true
|
||
;;
|
||
--yes)
|
||
RESET_DB_CONFIRM=1
|
||
;;
|
||
-h|--help)
|
||
print_usage
|
||
exit 0
|
||
;;
|
||
*)
|
||
fail "未知参数:$1。执行 bash deploy/ubuntu-deploy.sh --help 查看用法。"
|
||
;;
|
||
esac
|
||
shift
|
||
done
|
||
}
|
||
|
||
read_env_value() {
|
||
local key="$1"
|
||
awk -F= -v key="${key}" '
|
||
$0 !~ /^[[:space:]]*#/ && $1 == key {
|
||
value = substr($0, index($0, "=") + 1)
|
||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
|
||
gsub(/^"|"$/, "", value)
|
||
gsub(/^'\''|'\''$/, "", value)
|
||
print value
|
||
exit
|
||
}
|
||
' "${ENV_FILE}"
|
||
}
|
||
|
||
ensure_docker() {
|
||
command -v docker >/dev/null 2>&1 || fail "未找到 docker,请先在 Ubuntu 上安装 Docker Engine。"
|
||
docker compose version >/dev/null 2>&1 || fail "未找到 docker compose 插件,请安装 Docker Compose plugin。"
|
||
command -v curl >/dev/null 2>&1 || fail "未找到 curl,请先安装:sudo apt-get update && sudo apt-get install -y curl"
|
||
}
|
||
|
||
ensure_env_file() {
|
||
if [[ -f "${ENV_FILE}" ]]; then
|
||
return
|
||
fi
|
||
|
||
cp "${ENV_EXAMPLE}" "${ENV_FILE}"
|
||
fail "已生成 .env,请先编辑真实域名、数据库密码、后台账号和密钥后再重新执行。"
|
||
}
|
||
|
||
validate_env_file() {
|
||
if grep -nE 'change-me|your-domain|example\.com' "${ENV_FILE}" >/tmp/order-site-env-placeholders.txt; then
|
||
cat /tmp/order-site-env-placeholders.txt >&2
|
||
fail ".env 仍包含示例占位值,请替换后再部署。"
|
||
fi
|
||
|
||
[[ "${READY_TIMEOUT}" =~ ^[1-9][0-9]*$ ]] || fail "READY_TIMEOUT 必须是正整数秒数,当前值:${READY_TIMEOUT}"
|
||
|
||
local site_addr worker_site_addr
|
||
site_addr="$(read_env_value CADDY_SITE_ADDR)"
|
||
[[ -n "${site_addr}" ]] || fail ".env 缺少 CADDY_SITE_ADDR。"
|
||
worker_site_addr="$(read_env_value WORKER_SITE_ADDR)"
|
||
[[ -n "${worker_site_addr}" ]] || fail ".env 缺少 WORKER_SITE_ADDR。"
|
||
HEALTH_URL="${site_addr%/}/health/ready"
|
||
|
||
validate_database_url_for_compose
|
||
}
|
||
|
||
validate_database_url_for_compose() {
|
||
local database_url
|
||
database_url="$(read_env_value DATABASE_URL)"
|
||
|
||
if [[ -z "${database_url}" ]]; then
|
||
return
|
||
fi
|
||
|
||
if [[ "${database_url}" == *"@localhost:"* \
|
||
|| "${database_url}" == *"@127.0.0.1:"* \
|
||
|| "${database_url}" == *"@[::1]:"* ]]; then
|
||
fail ".env 的 DATABASE_URL 当前指向本机地址。Docker 部署时后端容器内的 localhost 不是 postgres 容器,请改为 postgres 服务名或外部数据库地址。"
|
||
fi
|
||
}
|
||
|
||
validate_reset_database_target() {
|
||
local database_url
|
||
database_url="$(read_env_value DATABASE_URL)"
|
||
|
||
if [[ -n "${database_url}" && "${database_url}" != *"@postgres:"* ]]; then
|
||
fail "--reset-db 只支持重置本脚本管理的 Compose PostgreSQL。当前 DATABASE_URL 未指向 postgres 服务,请手动确认外部数据库后再处理。"
|
||
fi
|
||
}
|
||
|
||
confirm_reset_database() {
|
||
if [[ "${RESET_DB}" != "true" ]]; then
|
||
return
|
||
fi
|
||
|
||
validate_reset_database_target
|
||
|
||
warn "即将重置 PostgreSQL 数据库:会删除订单、任务、后台用户、审计日志和所有迁移记录。"
|
||
warn "不会删除 apps/backend/data 下的平台 JSON 配置,也不会删除 Caddy 证书卷。"
|
||
|
||
if [[ "${RESET_DB_CONFIRM}" == "1" ]]; then
|
||
warn "检测到 RESET_DB_CONFIRM=1 或 --yes,跳过交互确认。"
|
||
return
|
||
fi
|
||
|
||
if [[ ! -t 0 ]]; then
|
||
fail "当前不是交互式终端。若确认要重置数据库,请执行:RESET_DB_CONFIRM=1 bash deploy/ubuntu-deploy.sh --reset-db"
|
||
fi
|
||
|
||
local answer
|
||
printf '请输入 RESET 确认重置数据库:'
|
||
read -r answer
|
||
[[ "${answer}" == "RESET" ]] || fail "未确认重置数据库,已停止。"
|
||
}
|
||
|
||
ensure_data_dir() {
|
||
mkdir -p "${BACKEND_DATA_DIR}"
|
||
|
||
if [[ "$(id -u)" == "0" ]]; then
|
||
chown -R "${BACKEND_CONTAINER_UID}:${BACKEND_CONTAINER_GID}" "${BACKEND_DATA_DIR}"
|
||
return
|
||
fi
|
||
|
||
if command -v sudo >/dev/null 2>&1; then
|
||
sudo chown -R "${BACKEND_CONTAINER_UID}:${BACKEND_CONTAINER_GID}" "${BACKEND_DATA_DIR}"
|
||
return
|
||
fi
|
||
|
||
warn "未找到 sudo,跳过目录属主调整。若后端无法写入日志或配置,请执行:"
|
||
warn "sudo chown -R ${BACKEND_CONTAINER_UID}:${BACKEND_CONTAINER_GID} ${BACKEND_DATA_DIR}"
|
||
}
|
||
|
||
check_compose_config() {
|
||
log "检查 Docker Compose 配置"
|
||
docker compose config >/dev/null || fail "Docker Compose 配置校验失败。"
|
||
}
|
||
|
||
pull_base_images() {
|
||
log "拉取基础镜像"
|
||
docker compose pull --ignore-buildable
|
||
}
|
||
|
||
build_images() {
|
||
log "构建后端镜像"
|
||
docker compose build backend || fail "后端镜像构建失败。"
|
||
|
||
log "构建 Web 镜像"
|
||
docker compose build web || fail "Web 镜像构建失败。"
|
||
}
|
||
|
||
resolve_compose_project_name() {
|
||
local project_name
|
||
project_name="$(read_env_value COMPOSE_PROJECT_NAME || true)"
|
||
|
||
if [[ -z "${project_name}" ]]; then
|
||
project_name="$(basename "${PROJECT_ROOT}")"
|
||
fi
|
||
|
||
printf '%s\n' "${project_name}"
|
||
}
|
||
|
||
resolve_postgres_volume_name() {
|
||
local container_id volume_name project_name
|
||
|
||
container_id="$(docker compose ps -aq postgres 2>/dev/null | head -n 1 || true)"
|
||
if [[ -n "${container_id}" ]]; then
|
||
volume_name="$(
|
||
docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/lib/postgresql/data"}}{{if eq .Type "volume"}}{{.Name}}{{end}}{{end}}{{end}}' "${container_id}" 2>/dev/null || true
|
||
)"
|
||
if [[ -n "${volume_name}" ]]; then
|
||
printf '%s\n' "${volume_name}"
|
||
return
|
||
fi
|
||
fi
|
||
|
||
volume_name="$(
|
||
docker compose volumes 2>/dev/null \
|
||
| awk '$2 ~ /(^|_)postgres_data$/ { print $2; exit }' || true
|
||
)"
|
||
if [[ -n "${volume_name}" ]]; then
|
||
printf '%s\n' "${volume_name}"
|
||
return
|
||
fi
|
||
|
||
project_name="$(resolve_compose_project_name)"
|
||
printf '%s_postgres_data\n' "${project_name}"
|
||
}
|
||
|
||
reset_database() {
|
||
if [[ "${RESET_DB}" != "true" ]]; then
|
||
return
|
||
fi
|
||
|
||
local volume_name
|
||
volume_name="$(resolve_postgres_volume_name)"
|
||
|
||
warn "开始重置数据库,将删除 Docker volume:${volume_name}"
|
||
docker compose stop web backend postgres >/dev/null 2>&1 || true
|
||
docker compose rm -f -s postgres >/dev/null 2>&1 || true
|
||
|
||
if docker volume inspect "${volume_name}" >/dev/null 2>&1; then
|
||
docker volume rm "${volume_name}" >/dev/null || fail "删除 PostgreSQL 数据卷失败:${volume_name}"
|
||
log "已删除 PostgreSQL 数据卷:${volume_name}"
|
||
return
|
||
fi
|
||
|
||
warn "未找到 PostgreSQL 数据卷:${volume_name},将按空库继续部署。"
|
||
}
|
||
|
||
wait_for_service() {
|
||
local service="$1"
|
||
local label="$2"
|
||
local attempt container_id status health_status
|
||
|
||
log "等待 ${label} 就绪"
|
||
|
||
for ((attempt = 1; attempt <= READY_TIMEOUT; attempt++)); do
|
||
container_id="$(docker compose ps -q "${service}" 2>/dev/null || true)"
|
||
if [[ -n "${container_id}" ]]; then
|
||
status="$(docker inspect -f '{{.State.Status}}' "${container_id}" 2>/dev/null || true)"
|
||
health_status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "${container_id}" 2>/dev/null || true)"
|
||
|
||
if [[ -n "${health_status}" && "${health_status}" == "healthy" ]]; then
|
||
log "${label} 已就绪"
|
||
return
|
||
fi
|
||
|
||
if [[ -z "${health_status}" && "${status}" == "running" ]]; then
|
||
log "${label} 已启动"
|
||
return
|
||
fi
|
||
|
||
if [[ "${status}" == "exited" || "${status}" == "dead" ]]; then
|
||
break
|
||
fi
|
||
fi
|
||
|
||
sleep 1
|
||
done
|
||
|
||
print_diagnostics
|
||
fail "${label} 启动超时,请根据上面的日志排查。"
|
||
}
|
||
|
||
start_postgres() {
|
||
log "启动 PostgreSQL"
|
||
if docker compose up -d --no-deps postgres; then
|
||
wait_for_service postgres "PostgreSQL"
|
||
return
|
||
fi
|
||
|
||
print_diagnostics
|
||
fail "PostgreSQL 启动失败,请根据上面的日志排查。"
|
||
}
|
||
|
||
run_database_migrations() {
|
||
log "执行数据库迁移"
|
||
if docker compose run --rm --no-deps backend node dist/db/migrate.js; then
|
||
log "数据库迁移完成"
|
||
return
|
||
fi
|
||
|
||
print_diagnostics
|
||
fail "数据库迁移失败,请根据上面的日志排查。"
|
||
}
|
||
|
||
restart_backend() {
|
||
log "重建并启动后端"
|
||
if docker compose up -d --no-deps --force-recreate backend; then
|
||
wait_for_service backend "后端"
|
||
return
|
||
fi
|
||
|
||
print_diagnostics
|
||
fail "后端启动失败,请根据上面的日志排查。"
|
||
}
|
||
|
||
restart_web() {
|
||
log "重建并启动 Web 入口"
|
||
if docker compose up -d --no-deps --force-recreate --remove-orphans web; then
|
||
wait_for_service web "Web"
|
||
return
|
||
fi
|
||
|
||
print_diagnostics
|
||
fail "Web 启动失败,请根据上面的日志排查。"
|
||
}
|
||
|
||
deploy_stack() {
|
||
check_compose_config
|
||
pull_base_images
|
||
build_images
|
||
reset_database
|
||
start_postgres
|
||
run_database_migrations
|
||
restart_backend
|
||
restart_web
|
||
}
|
||
|
||
wait_for_health() {
|
||
log "等待服务健康检查:${HEALTH_URL}"
|
||
|
||
for _ in $(seq 1 "${READY_TIMEOUT}"); do
|
||
if curl -fsS "${HEALTH_URL}" >/dev/null 2>&1; then
|
||
log "服务已就绪"
|
||
return
|
||
fi
|
||
sleep 1
|
||
done
|
||
|
||
print_diagnostics
|
||
fail "部署已启动但健康检查未通过,请根据日志排查。"
|
||
}
|
||
|
||
print_applied_migrations() {
|
||
log "已应用数据库迁移"
|
||
|
||
local migrations
|
||
if migrations="$(docker compose exec -T backend node --input-type=module -e '
|
||
const { query, closeDb } = await import("./dist/db/client.js");
|
||
try {
|
||
const result = await query("SELECT filename FROM schema_migrations ORDER BY filename");
|
||
console.log(result.rows.map((row) => row.filename).join("\n") || "(无迁移记录)");
|
||
} finally {
|
||
await closeDb();
|
||
}
|
||
' 2>&1)"; then
|
||
printf '%s\n' "${migrations}"
|
||
return
|
||
fi
|
||
|
||
warn "读取迁移记录失败:"
|
||
printf '%s\n' "${migrations}"
|
||
}
|
||
|
||
print_diagnostics() {
|
||
warn "当前容器状态:"
|
||
docker compose ps || true
|
||
|
||
warn "后端最近日志:"
|
||
local backend_logs
|
||
backend_logs="$(docker compose logs --tail=120 backend 2>&1 || true)"
|
||
printf '%s\n' "${backend_logs}"
|
||
|
||
if printf '%s\n' "${backend_logs}" | grep -q 'password authentication failed for user "postgres"'; then
|
||
warn "检测到 PostgreSQL 密码认证失败。通常是 postgres_data 已用旧密码初始化,而 .env 改成了新密码。"
|
||
warn "无生产数据时可执行:RESET_DB_CONFIRM=1 bash deploy/ubuntu-deploy.sh --reset-db"
|
||
warn "需要保留数据时,请进入 postgres 容器用当前 .env 的 POSTGRES_PASSWORD 修改数据库用户密码。"
|
||
fi
|
||
|
||
warn "后端 ready 接口:"
|
||
docker compose exec -T backend sh -lc 'curl -fsS http://127.0.0.1:3000/health/ready || true' || true
|
||
|
||
print_applied_migrations || true
|
||
}
|
||
|
||
main() {
|
||
parse_args "$@"
|
||
|
||
cd "${PROJECT_ROOT}"
|
||
|
||
ensure_docker
|
||
ensure_env_file
|
||
validate_env_file
|
||
confirm_reset_database
|
||
ensure_data_dir
|
||
deploy_stack
|
||
wait_for_health
|
||
print_applied_migrations
|
||
|
||
log "部署完成"
|
||
docker compose ps
|
||
}
|
||
|
||
main "$@"
|