Files
yml2213 56ca479c6b chore(deploy): 基础镜像源改为可配置 BASE_REGISTRY,并提前补齐 FROM 基础镜像
## 修的是什么

线上部署卡死过一次:daocloud 加速源返回 502 Bad Gateway,整轮构建在 load metadata
阶段就失败,而服务器 daemon.json 里另外两个 registry-mirrors 完全帮不上忙。

原因是**显式写死的 registry 会绕过 daemon.json 的 registry-mirrors**:
Dockerfile 里 `FROM docker.m.daocloud.io/library/node:...` 属于显式指定 registry,
镜像列表对它就失效了,于是这个源成了构建的单点故障。

## 修法

1. 四个 Dockerfile(backend / frontend / backend-dev / dev-caddy)的 `FROM` 改用
   `${BASE_REGISTRY}`(registry 前缀语义),默认值 docker.m.daocloud.io 保持不变;
2. docker-compose.yml / docker-compose.dev.yml 的 `build.args` 接上
   `${BASE_REGISTRY:-docker.m.daocloud.io}`,并把 postgres / minio / node / caddy
   这几处 `image:` 也改成同一变量,整套编排只有一个换源开关;
3. 部署脚本:新增 resolve_base_registry()(读 .env 的 BASE_REGISTRY,缺省同 compose),
   --dry-run 的基础镜像 digest 预检沿用同一变量;
   新增 ensure_dockerfile_base_images(),在 pull_base_images 阶段把 Dockerfile 里
   `FROM` 用到的基础镜像提前补齐 —— 这类镜像不归 `docker compose pull` 管,
   本地缺失要拖到构建阶段才报错,且报错信息晦涩,看不出来是"源挂了"还是"代码写错了";
   现在缺失时明确提示并给出换源/离线搬运两条排障路径;
4. .env.server.example 增加 BASE_REGISTRY 说明(推荐多源容灾时设 docker.io,
   走 daemon 的 registry-mirrors 列表自动故障转移)。

## 验证

- docker compose config:默认值下所有镜像引用与改动前逐字一致(不会触发重新下载);
  BASE_REGISTRY=docker.1panel.live / docker.io 时全部引用正确替换;
- docker buildx build --call=check -f deploy/docker/backend.Dockerfile:Check complete, no warnings;
- deploy/ubuntu-deploy.sh bash -n 通过;
- 生产实测:预拉三个基础镜像后,buildkit 解析基础镜像 1s 命中本地镜像、不再访问远端。
2026-10-12 16:33:45 +08:00

914 lines
30 KiB
Bash
Executable File
Raw Permalink 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 # 按 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 "$@"