#!/usr/bin/env bash set -Eeuo pipefail # Ubuntu 服务器一键部署脚本。 # 用法: # bash deploy/ubuntu-deploy.sh # 按 git 改动自动判断要重建哪一端 # bash deploy/ubuntu-deploy.sh --all # 强制两端全量重建 # bash deploy/ubuntu-deploy.sh --dry-run # 只打印计划,不做任何改动 # 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}" FORCE_BACKUP=false FORCE_ALL=false FORCE_BACKEND=false FORCE_WEB=false DRY_RUN=false RESET_PRE_BACKUP_DONE=false # 上次成功部署的 commit / .env 指纹;用来算"这次到底改了什么"。 DEPLOY_STATE_FILE="${DEPLOY_STATE_FILE:-${PROJECT_ROOT}/.deploy-state}" # 基础镜像源(registry 前缀)默认值,必须与 docker-compose.yml / Dockerfile 的默认值一致。 BASE_REGISTRY_DEFAULT="docker.m.daocloud.io" BUILD_BACKEND=false BUILD_WEB=false CHANGED_FILES="" CHANGE_REASON="" DIFF_KNOWN=false ENV_CHANGED=false LAST_DEPLOYED_SHA="" DEPLOY_STARTED_AT=${SECONDS} TIMING_LABELS=() TIMING_SECONDS=() 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 --dry-run bash deploy/ubuntu-deploy.sh --reset-db 参数: --all 强制两端镜像都重建(忽略改动检测)。 --backend-only / --web-only 只重建指定的一端(镜像与容器),适用于明确知道改在哪一侧时。 --dry-run 打印本次部署计划(改了什么、重建哪一端、要不要备份)后退出,不做任何改动。 --reset-db 删除 Compose PostgreSQL 数据卷 postgres_data,再按 migrations 目录全量重建(001 基线 + 全部增量)。 --skip-backup 跳过本次部署的自动数据库备份。 --backup 强制备份(即使本次改动不含数据库迁移)。 --yes 跳过 --reset-db 的交互确认,也可使用环境变量 RESET_DB_CONFIRM=1。 -h, --help 显示帮助。 按需重建: 脚本用 .deploy-state 记录"上次成功部署的 commit + .env 指纹",再按 `git diff --name-only 上次commit..HEAD` 决定重建哪一端: apps/backend/**(含 migrations) → 只重建 backend apps/frontend/** / deploy/caddy/** / 前端 Dockerfile → 只重建 web 文档、README、部署脚本本身 → 不重建任何镜像 compose 文件、.env 变化、未知路径 → 两端都重建(保守兜底) 不需要重建的一端既不跑构建、也不重建容器,直接保持运行。 备份策略: 默认在"执行迁移"前备份到 OSS;本次改动不含 apps/backend/src/db/migrations/** 时 自动跳过备份并提示(--backup 强制,--skip-backup 无条件跳过)。 算不出改动范围时(首次使用本脚本、force push 等)不做自动跳过,仍会备份。 注意: --reset-db 会删除所有 PostgreSQL 数据,包括订单、任务、后台用户、审计日志和迁移记录。 日常结构变更请新增 002_xxx.sql 等渐进迁移,不要改已应用的历史迁移文件。 它不会删除 apps/backend/data 下的平台 JSON 配置,也不会删除 Caddy 证书卷。 图片存储使用阿里云 OSS(.env 的 STORAGE_OSS_*),生产编排不含 MinIO。 备份与恢复见 docs/数据库备份与恢复.md。 EOF } parse_args() { while (($# > 0)); do case "$1" in --reset-db) RESET_DB=true ;; --skip-backup) SKIP_BACKUP=true ;; --backup) FORCE_BACKUP=true ;; --all) FORCE_ALL=true ;; --backend-only) FORCE_BACKEND=true ;; --web-only) FORCE_WEB=true ;; --dry-run) DRY_RUN=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}" } # 基础镜像源(registry 前缀):与 docker-compose.yml / Dockerfile 的 BASE_REGISTRY 同一套默认值。 # 写进 .env 即可整体换源(例如加速源抽风时改成 docker.io,白拿 daemon 的多源故障转移)。 resolve_base_registry() { local value value="$(read_env_value BASE_REGISTRY 2>/dev/null || true)" printf '%s' "${value:-${BASE_REGISTRY_DEFAULT}}" } 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" } # 阶段计时:每次部署结束打印各阶段耗时,回答"这次怎么比上次慢"不用靠推算。 time_step() { local label="$1" shift local started=${SECONDS} "$@" local elapsed=$((SECONDS - started)) TIMING_LABELS+=("${label}") TIMING_SECONDS+=("${elapsed}") log "⏱ ${label}:${elapsed}s" } print_timing_summary() { local i total=0 if [[ ${#TIMING_LABELS[@]} -eq 0 ]]; then return fi log "本次部署各阶段耗时(总计 $((SECONDS - DEPLOY_STARTED_AT))s):" for i in "${!TIMING_LABELS[@]}"; do printf ' - %s:%ss\n' "${TIMING_LABELS[$i]}" "${TIMING_SECONDS[$i]}" total=$((total + TIMING_SECONDS[$i])) done printf ' - %s:%ss\n' "已计时阶段合计" "${total}" } # 生成文件指纹(.env 变了要全量重建,否则改了 DATABASE_URL 之类的部署不会生效)。 file_sha256() { local file="$1" [[ -f "${file}" ]] || return 0 if command -v sha256sum >/dev/null 2>&1; then sha256sum "${file}" | awk '{print $1}' return fi shasum -a 256 "${file}" 2>/dev/null | awk '{print $1}' } read_state_value() { local key="$1" [[ -f "${DEPLOY_STATE_FILE}" ]] || return 0 awk -F= -v key="${key}" '$1 == key { print substr($0, index($0, "=") + 1); exit }' \ "${DEPLOY_STATE_FILE}" 2>/dev/null || true } write_deploy_state() { local head_sha env_hash head_sha="$(git -C "${PROJECT_ROOT}" rev-parse HEAD 2>/dev/null || true)" env_hash="$(file_sha256 "${ENV_FILE}")" { printf 'commit=%s\n' "${head_sha}" printf 'env_sha256=%s\n' "${env_hash}" printf 'deployed_at=%s\n' "$(date -Iseconds)" } >"${DEPLOY_STATE_FILE}" || warn "写入部署状态失败:${DEPLOY_STATE_FILE}" log "已记录部署状态:${DEPLOY_STATE_FILE}(commit=${head_sha:0:12})" } # 只回答"自上次成功部署以来改了什么",不决定重建范围。 # 拆成两步的原因:--backend-only/--all 这类显式参数只该覆盖"重建哪一端", # 不该把"改动范围未知"一起带进来——否则会连"无迁移改动就跳过备份"的判断也一起退化成"必须备份"。 compute_changed_files() { local head_sha last_sha env_hash last_env_hash CHANGED_FILES="" CHANGE_REASON="" DIFF_KNOWN=false ENV_CHANGED=false LAST_DEPLOYED_SHA="" head_sha="$(git -C "${PROJECT_ROOT}" rev-parse HEAD 2>/dev/null || true)" last_sha="$(read_state_value commit)" if [[ -z "${head_sha}" ]]; then CHANGE_REASON="项目不是 git 仓库或读不到 HEAD,无法判断改动" return fi if [[ -z "${last_sha}" ]]; then CHANGE_REASON="没有 ${DEPLOY_STATE_FILE##*/} 记录(首次使用按需重建),先全量重建一次" return fi if ! git -C "${PROJECT_ROOT}" cat-file -e "${last_sha}^{commit}" 2>/dev/null; then CHANGE_REASON="上次部署的提交 ${last_sha:0:12} 已不在仓库中(force push?),全量重建" return fi if ! git -C "${PROJECT_ROOT}" merge-base --is-ancestor "${last_sha}" "${head_sha}" 2>/dev/null; then CHANGE_REASON="上次部署的提交 ${last_sha:0:12} 不是 HEAD 的祖先(回滚/分支切换?),全量重建" return fi # 走 classify-changes.sh 的 --git-diff 而不是直接 git diff: # 那里关掉了 core.quotePath,中文路径(docs/xxx.md)不会被转义成 "\351..." 而匹配不上规则。 CHANGED_FILES="$(bash "${PROJECT_ROOT}/deploy/lib/classify-changes.sh" --git-diff "${last_sha}" "${head_sha}" 2>/dev/null || true)" DIFF_KNOWN=true LAST_DEPLOYED_SHA="${last_sha}" env_hash="$(file_sha256 "${ENV_FILE}")" last_env_hash="$(read_state_value env_sha256)" if [[ -n "${env_hash}" && -n "${last_env_hash}" && "${env_hash}" != "${last_env_hash}" ]]; then ENV_CHANGED=true CHANGE_REASON=".env 与上次部署时不一致(运行时环境/构建参数可能变化)" return fi if [[ -z "${CHANGED_FILES}" ]]; then CHANGE_REASON="自 ${last_sha:0:12} 以来没有代码改动" else CHANGE_REASON="自 ${last_sha:0:12} 以来改动 $(printf '%s\n' "${CHANGED_FILES}" | grep -c .) 个文件" fi } # 只决定"重建哪一端":显式参数 > 改动范围 > 保守兜底。 detect_changed_services() { local services BUILD_BACKEND=false BUILD_WEB=false if [[ "${FORCE_BACKEND}" == "true" || "${FORCE_WEB}" == "true" ]]; then [[ "${FORCE_BACKEND}" == "true" ]] && BUILD_BACKEND=true [[ "${FORCE_WEB}" == "true" ]] && BUILD_WEB=true CHANGE_REASON="${CHANGE_REASON};命令行指定了 --backend-only/--web-only" return fi if [[ "${FORCE_ALL}" == "true" ]]; then BUILD_BACKEND=true BUILD_WEB=true CHANGE_REASON="${CHANGE_REASON};命令行指定了 --all" return fi if [[ "${RESET_DB}" == "true" ]]; then BUILD_BACKEND=true BUILD_WEB=true CHANGE_REASON="${CHANGE_REASON};--reset-db 需要整套服务重建" return fi # 算不出改动范围、或 .env 变了:两端都重建(保守兜底) if [[ "${DIFF_KNOWN}" != "true" || "${ENV_CHANGED}" == "true" ]]; then BUILD_BACKEND=true BUILD_WEB=true return fi services="$(printf '%s\n' "${CHANGED_FILES}" | bash "${PROJECT_ROOT}/deploy/lib/classify-changes.sh")" if [[ " ${services} " == *" backend "* ]]; then BUILD_BACKEND=true fi if [[ " ${services} " == *" web "* ]]; then BUILD_WEB=true fi } migrations_changed() { [[ -n "${CHANGED_FILES}" ]] || return 1 printf '%s\n' "${CHANGED_FILES}" | grep -q '^apps/backend/src/db/migrations/' } print_plan() { local head_sha targets="(无)" skipped head_sha="$(git -C "${PROJECT_ROOT}" rev-parse HEAD 2>/dev/null || true)" log "部署计划:" printf ' 上次成功部署:%s\n' "${LAST_DEPLOYED_SHA:-(无记录)}" printf ' 本次 HEAD :%s\n' "${head_sha:-(非 git 仓库)}" printf ' 改动依据 :%s\n' "${CHANGE_REASON:-未判断}" if [[ -n "${CHANGED_FILES}" ]]; then printf ' 改动文件(最多列 15 个):\n' printf '%s\n' "${CHANGED_FILES}" | head -n 15 | sed 's/^/ - /' fi if [[ "${BUILD_BACKEND}" == "true" || "${BUILD_WEB}" == "true" ]]; then targets="" [[ "${BUILD_BACKEND}" == "true" ]] && targets="${targets} backend" [[ "${BUILD_WEB}" == "true" ]] && targets="${targets} web" printf ' 重建镜像 :%s\n' "${targets# }" else printf ' 重建镜像 :无(改动进不了镜像,容器保持运行)\n' fi skipped="" [[ "${BUILD_BACKEND}" != "true" ]] && skipped="${skipped} backend" [[ "${BUILD_WEB}" != "true" ]] && skipped="${skipped} web" [[ -n "${skipped}" ]] && printf ' 跳过重建 :%s\n' "${skipped# }" if [[ "${SKIP_BACKUP}" == "true" ]]; then printf ' 数据库备份 :跳过(--skip-backup)\n' elif [[ "${FORCE_BACKUP}" == "true" ]]; then printf ' 数据库备份 :执行(--backup)\n' elif [[ "${DIFF_KNOWN}" == "true" ]] && ! migrations_changed; then printf ' 数据库备份 :自动跳过(本次无 migrations 改动;--backup 可强制)\n' else printf ' 数据库备份 :执行\n' fi } # 只读地打印基础镜像 digest:下次"为什么又全量重下"时,一看就能区分是上游 tag 漂了还是缓存被清了。 print_base_image_digests() { local image digest command -v docker >/dev/null 2>&1 || return 0 for image in "$@"; do if command -v timeout >/dev/null 2>&1; then digest="$(timeout 20 docker buildx imagetools inspect "${image}" --format '{{.Manifest.Digest}}' 2>/dev/null || true)" else digest="$(docker buildx imagetools inspect "${image}" --format '{{.Manifest.Digest}}' 2>/dev/null || true)" fi printf ' %-46s %s\n' "${image}" "${digest:-(查询失败,可能无外网)}" done } 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 配置校验失败。" } # 部署前磁盘空间预检:备份目录所在文件系统至少要有首份备份 + 现有备份量 2 倍的余量。 # 不足时直接失败,避免备份或迁移过程中磁盘写满导致数据库不可用。 ensure_disk_space() { local backup_root="${PROJECT_ROOT}/backups" local floor_kib=1048576 # 1GiB local existing_kib=0 require_kib free_kib mkdir -p "${backup_root}" 2>/dev/null || true if [[ -d "${backup_root}" ]]; then existing_kib="$(du -sk "${backup_root}" 2>/dev/null | awk '{print $1}' || true)" existing_kib="${existing_kib:-0}" fi require_kib=$((existing_kib * 2)) [[ ${require_kib} -lt ${floor_kib} ]] && require_kib=${floor_kib} free_kib="$(df -Pk "${PROJECT_ROOT}" 2>/dev/null | awk 'NR==2 {print $4}' || true)" free_kib="${free_kib:-0}" log "磁盘空间预检:可用 ${free_kib} KiB,备份目录已占用 ${existing_kib} KiB,要求至少 ${require_kib} KiB" if [[ ${free_kib} -lt ${require_kib} ]]; then fail "磁盘可用空间不足(可用 ${free_kib} KiB,需 ≥ ${require_kib} KiB)。请清理磁盘或调小 DB_BACKUP_KEEP 后重试。" fi } pull_base_images() { # --policy missing:只在本地没有时拉取。默认的 always 语义会在每次部署时重新校验 # postgres 这类 tag,既多一次联网,也可能让数据库镜像在"随手一部署"时被静默升级。 log "检查基础镜像(仅缺失时拉取)" if docker compose pull --ignore-buildable --policy missing; then : else warn "compose pull 不支持 --policy missing,回退为默认拉取策略。" docker compose pull --ignore-buildable fi ensure_dockerfile_base_images } # Dockerfile 里 `FROM` 的基础镜像**不归 `docker compose pull` 管**:本地缺失时要到构建阶段才报错, # 而且报错很晦涩(`failed to resolve source metadata ... 502 Bad Gateway`), # 看不出是"镜像源挂了"还是"代码写错了"(生产就这么卡过一次)。 # 这里提前补齐:已存在则跳过(构建命中本地镜像,完全不访问远端); # 缺失且拉不到时给出明确的排障指引。 ensure_dockerfile_base_images() { if [[ "${BUILD_BACKEND}" != "true" && "${BUILD_WEB}" != "true" ]]; then return fi local registry images=() image registry="$(resolve_base_registry)" if [[ "${BUILD_BACKEND}" == "true" ]]; then images+=("${registry}/library/node:22-bookworm" "${registry}/library/node:22-bookworm-slim") fi if [[ "${BUILD_WEB}" == "true" ]]; then images+=("${registry}/library/node:22-bookworm" "${registry}/library/caddy:2-alpine") fi while IFS= read -r image; do [[ -n "${image}" ]] || continue if docker image inspect "${image}" >/dev/null 2>&1; then continue fi log "基础镜像本地缺失,尝试拉取:${image}" docker pull "${image}" || fail "基础镜像拉取失败:${image} 当前镜像源 BASE_REGISTRY=${registry} 不可用。可选处理: 1) 在 .env 换一个可用源后重跑,例如 BASE_REGISTRY=docker.io (docker.io 会走 daemon.json 的 registry-mirrors 列表自动故障转移) 2) 或在能联网的机器上 docker pull 同名镜像,再 docker save | docker load 搬到本机 注意:Dockerfile/compose 里显式写死的 registry 会绕过 daemon.json 的 registry-mirrors。" done < <(printf '%s\n' "${images[@]}" | sort -u) } build_images() { local targets=() [[ "${BUILD_BACKEND}" == "true" ]] && targets+=("backend") [[ "${BUILD_WEB}" == "true" ]] && targets+=("web") if [[ ${#targets[@]} -eq 0 ]]; then log "两端镜像都不需要重建(本次改动进不了镜像)" return fi log "构建镜像:${targets[*]}(未变化的一端跳过)" # 一次调用交给 compose bake:两端同时构建,共享的基础镜像层只解析一次。 docker compose build "${targets[@]}" || fail "镜像构建失败:${targets[*]}" } 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() { if [[ "${BUILD_BACKEND}" != "true" ]]; then ensure_service_running backend "后端" return fi log "重建并启动后端" if docker compose up -d --no-deps --force-recreate backend; then wait_for_service backend "后端" return fi print_diagnostics fail "后端启动失败,请根据上面的日志排查。" } restart_web() { if [[ "${BUILD_WEB}" != "true" ]]; then ensure_service_running web "Web" return fi 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 启动失败,请根据上面的日志排查。" } # 未变化的一端:不重建容器,但如果它没在跑(服务器重启过、被手工停过)就拉起来。 ensure_service_running() { local service="$1" label="$2" container_id status container_id="$(docker compose ps -q "${service}" 2>/dev/null | head -n 1 || true)" status="" if [[ -n "${container_id}" ]]; then status="$(docker inspect -f '{{.State.Status}}' "${container_id}" 2>/dev/null || true)" fi if [[ "${status}" == "running" ]]; then log "${label} 本次无改动,保持运行(10s 内不重建、不中断)" return fi warn "${label} 本次无改动但当前未在运行(状态:${status:-不存在}),用现有镜像拉起" if docker compose up -d --no-deps "${service}"; then wait_for_service "${service}" "${label}" return fi print_diagnostics fail "${label} 启动失败,请根据上面的日志排查。" } 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 if [[ "${FORCE_BACKUP}" == "true" ]]; then run_scheduled_backup "执行迁移" return fi # 无 schema 变更的部署没必要每次 pg_dump + 传 OSS(生产上这一步接近 12 分钟)。 # 只有"确认算得出改动范围、且改动里没有 migrations"时才自动跳过; # 首次部署、force push 这类算不出范围的情况照旧备份。 if [[ "${DIFF_KNOWN}" == "true" ]] && ! migrations_changed; then log "本次改动不含 apps/backend/src/db/migrations/**,自动跳过迁移前备份(--backup 可强制)" return fi run_scheduled_backup "执行迁移" } deploy_stack() { time_step "检查 Compose 配置" check_compose_config time_step "检查基础镜像" pull_base_images time_step "构建镜像" build_images backup_before_reset reset_database time_step "启动 PostgreSQL" start_postgres time_step "迁移前备份" backup_before_migrate time_step "执行数据库迁移" run_database_migrations time_step "启动后端" restart_backend time_step "启动 Web 入口" 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 ensure_disk_space compute_changed_files detect_changed_services print_plan if [[ "${DRY_RUN}" == "true" ]]; then if [[ "${BUILD_BACKEND}" == "true" || "${BUILD_WEB}" == "true" ]]; then log "基础镜像当前 digest(用于事后判断是否上游 tag 漂移导致全量重建):" print_base_image_digests \ "$(resolve_base_registry)/library/node:22-bookworm" \ "$(resolve_base_registry)/library/node:22-bookworm-slim" \ "$(resolve_base_registry)/library/caddy:2-alpine" fi log "(--dry-run)以上是计划,未做任何改动。" return 0 fi deploy_stack wait_for_health print_applied_migrations write_deploy_state log "部署完成" print_timing_summary docker compose ps } main "$@"