Files
order_site/deploy/ubuntu-deploy.sh
T

494 lines
14 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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:-}"
SKIP_BACKUP="${SKIP_BACKUP:-false}"
RESET_PRE_BACKUP_DONE=false
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 基线 + 全部增量)。
--skip-backup 跳过部署过程中的自动数据库备份(备份失败时确认为可接受后可用它紧急部署)。
--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。
每次部署在执行数据库迁移前会自动备份到 OSSdeploy/backup-db.sh);
--reset-db 在删除数据卷前也会先抓一份最后备份。备份与恢复见 docs/数据库备份与恢复.md。
EOF
}
parse_args() {
while (($# > 0)); do
case "$1" in
--reset-db)
RESET_DB=true
;;
--skip-backup)
SKIP_BACKUP=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 启动失败,请根据上面的日志排查。"
}
run_scheduled_backup() {
local label="$1"
if [[ "${SKIP_BACKUP}" == "true" ]]; then
warn "已指定 --skip-backup,跳过${label}前的数据库备份。"
return
fi
log "${label}前自动备份数据库到 OSS"
if bash "${PROJECT_ROOT}/deploy/backup-db.sh"; then
log "${label}前备份完成"
return
fi
fail "${label}前备份失败,已停止部署。确认为可接受的失败后可用 --skip-backup 继续。"
}
backup_before_reset() {
if [[ "${RESET_DB}" != "true" ]]; then
return
fi
if [[ "${SKIP_BACKUP}" == "true" ]]; then
warn "已指定 --skip-backup,跳过删除数据卷前的最后一份备份。"
return
fi
local container_id status
container_id="$(docker compose ps -q postgres 2>/dev/null | head -n 1 || true)"
status=""
[[ -n "${container_id}" ]] && status="$(docker inspect -f '{{.State.Status}}' "${container_id}" 2>/dev/null || true)"
if [[ "${status}" != "running" ]]; then
local volume_name
volume_name="$(resolve_postgres_volume_name)"
if docker volume inspect "${volume_name}" >/dev/null 2>&1; then
warn "检测到已有 PostgreSQL 数据卷但容器未运行,先启动数据库以完成重置前备份。"
start_postgres
else
warn "未检测到已有 PostgreSQL 数据卷,跳过重置前备份(首次部署属正常现象)。"
return
fi
fi
run_scheduled_backup "重置数据库"
RESET_PRE_BACKUP_DONE=true
}
backup_before_migrate() {
if [[ "${RESET_PRE_BACKUP_DONE}" == "true" ]]; then
log "已在重置数据库前完成最后备份,跳过重置后的空库备份。"
return
fi
run_scheduled_backup "执行迁移"
}
deploy_stack() {
check_compose_config
pull_base_images
build_images
backup_before_reset
reset_database
start_postgres
backup_before_migrate
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 "$@"