Files
order_site/deploy/ubuntu-deploy.sh
T
yml2213 784459232d 修复部署脚本 COMPOSE_PROFILES 跳过 MinIO 不生效的问题
- 仅在未设置 COMPOSE_PROFILES 时默认激活 storage profile,避免覆盖环境变量空值
- ensure_docker 改为按数组长度判断,移除恒为 false 的死代码
- start_minio 按 profile 值匹配,避免将来新增 profile 时误判
2026-08-05 12:36:16 +08:00

461 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:-}"
# 激活 Compose profile:默认启用 storage,让内置 MinIO 随部署自动启动。
# 若改用外部 S3/OSS,可在环境变量前置 COMPOSE_PROFILES= 赋空值跳过内置 MinIO。
if [[ -z "${COMPOSE_PROFILES+x}" ]]; then
COMPOSE_PROFILES=(--profile storage)
fi
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 证书卷。
默认激活 storage profile 启动内置 MinIO。若使用外部 S3/OSS,可在环境变量前置
COMPOSE_PROFILES= 赋空值跳过内置 MinIO,例如:COMPOSE_PROFILES= bash deploy/ubuntu-deploy.sh
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"
# 允许通过环境变量 COMPOSE_PROFILES= 跳过内置 MinIO(用外部 S3/OSS 时)。
if [[ "${#COMPOSE_PROFILES[@]}" -eq 0 ]]; then
log "COMPOSE_PROFILES 为空,跳过内置 MinIO(请确保 .env 的 STORAGE_ENDPOINT 指向外部存储)"
fi
}
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
site_addr="$(read_env_value CADDY_SITE_ADDR)"
[[ -n "${site_addr}" ]] || fail ".env 缺少 CADDY_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 "${COMPOSE_PROFILES[@]}" config >/dev/null || fail "Docker Compose 配置校验失败。"
}
pull_base_images() {
log "拉取基础镜像"
docker compose "${COMPOSE_PROFILES[@]}" pull --ignore-buildable
}
build_images() {
log "构建后端镜像"
docker compose "${COMPOSE_PROFILES[@]}" build backend || fail "后端镜像构建失败。"
log "构建 Web 镜像"
docker compose "${COMPOSE_PROFILES[@]}" 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 "${COMPOSE_PROFILES[@]}" 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 "${COMPOSE_PROFILES[@]}" 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 "${COMPOSE_PROFILES[@]}" stop web backend postgres >/dev/null 2>&1 || true
docker compose "${COMPOSE_PROFILES[@]}" 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 "${COMPOSE_PROFILES[@]}" 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 "${COMPOSE_PROFILES[@]}" up -d --no-deps postgres; then
wait_for_service postgres "PostgreSQL"
return
fi
print_diagnostics
fail "PostgreSQL 启动失败,请根据上面的日志排查。"
}
start_minio() {
# 未激活 storage profile 时跳过内置 MinIO(用外部 S3/OSS 的场景)。
local has_storage_profile=false
for arg in "${COMPOSE_PROFILES[@]}"; do
[[ "${arg}" == "storage" ]] && has_storage_profile=true
done
if [[ "${has_storage_profile}" != "true" ]]; then
log "跳过内置 MinIO(COMPOSE_PROFILES 未包含 storage)"
return
fi
log "启动 MinIO"
if docker compose "${COMPOSE_PROFILES[@]}" up -d --no-deps minio; then
wait_for_service minio "MinIO"
return
fi
print_diagnostics
fail "MinIO 启动失败,请根据上面的日志排查。"
}
run_database_migrations() {
log "执行数据库迁移"
if docker compose "${COMPOSE_PROFILES[@]}" run --rm --no-deps backend node dist/db/migrate.js; then
log "数据库迁移完成"
return
fi
print_diagnostics
fail "数据库迁移失败,请根据上面的日志排查。"
}
restart_backend() {
log "重建并启动后端"
if docker compose "${COMPOSE_PROFILES[@]}" up -d --no-deps --force-recreate backend; then
wait_for_service backend "后端"
return
fi
print_diagnostics
fail "后端启动失败,请根据上面的日志排查。"
}
restart_web() {
log "重建并启动 Web 入口"
if docker compose "${COMPOSE_PROFILES[@]}" 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
start_minio
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 "${COMPOSE_PROFILES[@]}" 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 "${COMPOSE_PROFILES[@]}" ps || true
warn "后端最近日志:"
local backend_logs
backend_logs="$(docker compose "${COMPOSE_PROFILES[@]}" 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 "${COMPOSE_PROFILES[@]}" 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 "${COMPOSE_PROFILES[@]}" ps
}
main "$@"