部署优化

This commit is contained in:
wchino
2026-06-13 22:16:14 +08:00
parent 21dd9957f0
commit 73a94e6be3
28 changed files with 899 additions and 129 deletions

View File

@@ -6,6 +6,14 @@ COMPOSE_FILE="$ROOT/docker-compose.prod.yml"
ENV_FILE="$ROOT/.env.docker"
ENV_EXAMPLE_FILE="$ROOT/.env.docker.example"
BACKUP_DIR="$ROOT/backups"
DEPLOY_STATE_DIR="$ROOT/.deploy"
RELEASES_DIR="$DEPLOY_STATE_DIR/releases"
CURRENT_RELEASE_FILE="$DEPLOY_STATE_DIR/current-release.env"
PREVIOUS_RELEASE_FILE="$DEPLOY_STATE_DIR/previous-release.env"
DEPLOY_IMAGE_TAG="${DEPLOY_IMAGE_TAG:-}"
LAST_DB_BACKUP=""
LAST_UPLOADS_BACKUP=""
log() {
printf '%s\n' "[$(basename "$0")] $*"
@@ -29,8 +37,36 @@ require_docker() {
docker compose version >/dev/null 2>&1 || die "未找到 Docker Compose v2请确认可执行 docker compose"
}
validate_image_tag() {
local tag="$1"
printf '%s' "$tag" | grep -Eq '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$' ||
die "镜像 tag 不合法: $tag"
}
set_deploy_image_tag() {
local tag="$1"
[ -n "$tag" ] || die "镜像 tag 不能为空"
validate_image_tag "$tag"
DEPLOY_IMAGE_TAG="$tag"
}
infer_image_tag_from_tar() {
local image_tar="$1"
local base tag
base="$(basename "$image_tar")"
tag="${base#thebet365-images-}"
tag="${tag%.tar}"
if [ "$tag" != "$base" ] && [ -n "$tag" ]; then
printf '%s' "$tag"
fi
}
compose() {
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "$@"
local tag
tag="$(current_image_tag)"
IMAGE_TAG="$tag" docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "$@"
}
ensure_env_file() {
@@ -61,13 +97,61 @@ env_value() {
printf '%s' "$line"
}
set_env_value() {
local key="$1"
local value="$2"
local tmp
tmp="$(mktemp)"
if grep -Eq "^[[:space:]]*${key}=" "$ENV_FILE"; then
awk -v key="$key" -v value="$value" '
BEGIN { done = 0 }
$0 ~ "^[[:space:]]*" key "=" {
if (!done) {
print key "=" value
done = 1
}
next
}
{ print }
END {
if (!done) print key "=" value
}
' "$ENV_FILE" > "$tmp"
else
cp "$ENV_FILE" "$tmp"
printf '\n%s=%s\n' "$key" "$value" >> "$tmp"
fi
mv "$tmp" "$ENV_FILE"
}
current_image_tag() {
local tag="${DEPLOY_IMAGE_TAG:-}"
if [ -z "$tag" ] && [ -f "$ENV_FILE" ]; then
tag="$(env_value IMAGE_TAG)"
fi
tag="${tag:-latest}"
validate_image_tag "$tag"
printf '%s' "$tag"
}
persist_image_tag() {
local tag
tag="$(current_image_tag)"
set_env_value IMAGE_TAG "$tag"
log "当前发布镜像 tag: $tag"
}
validate_prod_env() {
local allow_defaults="${1:-false}"
local postgres_password jwt_secret seed_database
local postgres_password jwt_secret seed_database bind_addr
postgres_password="$(env_value POSTGRES_PASSWORD)"
jwt_secret="$(env_value JWT_SECRET)"
seed_database="$(env_value SEED_DATABASE)"
bind_addr="$(env_value BIND_ADDR)"
[ -n "$postgres_password" ] || die ".env.docker 缺少 POSTGRES_PASSWORD"
[ -n "$jwt_secret" ] || die ".env.docker 缺少 JWT_SECRET"
@@ -81,6 +165,10 @@ validate_prod_env() {
warn ".env.docker 中 SEED_DATABASE=true 会让 api 每次启动都执行 seed生产建议设为 false首次部署脚本会按需一次性 seed"
fi
if [ "${bind_addr:-127.0.0.1}" = "0.0.0.0" ]; then
warn "BIND_ADDR=0.0.0.0 会让 player/admin 端口直接对外监听;生产建议经宝塔反代访问"
fi
if [ "$(env_value CHUANGLAN_ACCOUNT)" = "your_account" ] || [ "$(env_value CHUANGLAN_PASSWORD)" = "your_password" ]; then
warn "创蓝短信账号仍是示例值,短信验证码功能上线前需要改为真实配置"
fi
@@ -103,35 +191,17 @@ wait_for_service_health() {
now="$(date +%s)"
if [ $((now - start)) -ge "$timeout" ]; then
compose logs --tail=80 "$service" || true
compose logs --tail=120 "$service" || true
die "$service${timeout}s 内未就绪"
fi
sleep 2
done
}
wait_for_service_running() {
local service="$1"
local timeout="${2:-120}"
local start now container_id running
start="$(date +%s)"
while true; do
container_id="$(compose ps -q "$service" 2>/dev/null || true)"
if [ -n "$container_id" ]; then
running="$(docker inspect -f '{{.State.Running}}' "$container_id" 2>/dev/null || true)"
if [ "$running" = "true" ]; then
return 0
fi
fi
now="$(date +%s)"
if [ $((now - start)) -ge "$timeout" ]; then
compose logs --tail=120 "$service" || true
die "$service${timeout}s 内未保持运行"
fi
sleep 2
done
wait_for_stack_ready() {
wait_for_service_health api 180
wait_for_service_health player 120
wait_for_service_health admin 120
}
start_infra() {
@@ -142,7 +212,7 @@ start_infra() {
}
build_app_images() {
log "构建 api / player / admin 镜像"
log "构建 api / player / admin 镜像: $(current_image_tag)"
compose build api player admin
}
@@ -153,6 +223,14 @@ load_image_tar() {
docker load -i "$image_tar"
}
require_images_for_current_tag() {
local tag
tag="$(current_image_tag)"
docker image inspect "thebet365-api:$tag" >/dev/null 2>&1 || die "缺少镜像: thebet365-api:$tag"
docker image inspect "thebet365-player:$tag" >/dev/null 2>&1 || die "缺少镜像: thebet365-player:$tag"
docker image inspect "thebet365-admin:$tag" >/dev/null 2>&1 || die "缺少镜像: thebet365-admin:$tag"
}
run_prisma_migrations() {
log "执行 Prisma 迁移"
compose run --rm --no-deps --entrypoint sh api -c 'cd /app/apps/api && npx prisma migrate deploy && npx prisma generate'
@@ -163,19 +241,81 @@ show_prisma_status() {
compose exec -T api sh -c 'cd /app/apps/api && npx prisma migrate status'
}
safe_backup_prefix() {
local prefix="${1:-manual}"
prefix="$(printf '%s' "$prefix" | tr -c 'A-Za-z0-9_.-' '-')"
prefix="${prefix:-manual}"
printf '%s' "$prefix"
}
write_checksum() {
local file="$1"
local dir base
dir="$(dirname "$file")"
base="$(basename "$file")"
if command -v sha256sum >/dev/null 2>&1; then
(cd "$dir" && sha256sum "$base" > "$base.sha256")
elif command -v shasum >/dev/null 2>&1; then
(cd "$dir" && shasum -a 256 "$base" > "$base.sha256")
else
warn "未找到 sha256sum 或 shasum跳过校验文件: $file"
return 0
fi
log "校验文件完成: $file.sha256"
}
backup_database() {
local prefix="${1:-manual}"
local stamp backup_file
require_command gzip
mkdir -p "$BACKUP_DIR"
prefix="$(safe_backup_prefix "$prefix")"
stamp="$(date +%Y%m%d-%H%M%S)"
backup_file="$BACKUP_DIR/thebet365-${prefix}-${stamp}.sql"
backup_file="$BACKUP_DIR/thebet365-db-${prefix}-${stamp}.sql.gz"
log "备份 PostgreSQL -> $backup_file"
compose exec -T postgres pg_dump -U thebet365 -d thebet365 -F p > "$backup_file"
compose exec -T postgres pg_dump -U thebet365 -d thebet365 -F p | gzip -c > "$backup_file"
write_checksum "$backup_file"
LAST_DB_BACKUP="$backup_file"
log "数据库备份完成: $backup_file"
}
backup_uploads() {
local prefix="${1:-manual}"
local stamp backup_file
mkdir -p "$BACKUP_DIR"
prefix="$(safe_backup_prefix "$prefix")"
stamp="$(date +%Y%m%d-%H%M%S)"
backup_file="$BACKUP_DIR/thebet365-uploads-${prefix}-${stamp}.tar.gz"
log "备份 uploads 卷 -> $backup_file"
compose exec -T api sh -c 'cd /app && mkdir -p uploads && tar -czf - uploads' > "$backup_file"
write_checksum "$backup_file"
LAST_UPLOADS_BACKUP="$backup_file"
log "uploads 备份完成: $backup_file"
}
prune_old_backups() {
local days
days="$(env_value BACKUP_RETENTION_DAYS)"
[ -n "$days" ] || return 0
case "$days" in
*[!0-9]*)
warn "BACKUP_RETENTION_DAYS 不是数字,跳过自动清理: $days"
return 0
;;
esac
[ "$days" -gt 0 ] || return 0
[ -d "$BACKUP_DIR" ] || return 0
log "清理 ${days} 天前的备份"
find "$BACKUP_DIR" -type f \( -name '*.sql.gz' -o -name '*.tar.gz' -o -name '*.sha256' \) -mtime +"$days" -delete
}
admin_user_count() {
compose exec -T postgres psql -U thebet365 -d thebet365 -tAc "select count(*) from users where username = 'admin';" 2>/dev/null | tr -d '[:space:]'
}
@@ -193,17 +333,59 @@ seed_production_if_missing_admin() {
fi
}
current_release_tag() {
if [ -f "$CURRENT_RELEASE_FILE" ]; then
grep -E '^tag=' "$CURRENT_RELEASE_FILE" | tail -n 1 | cut -d= -f2- || true
fi
}
record_release_state() {
local source="${1:-deploy}"
local tag stamp safe_tag manifest previous_tag
tag="$(current_image_tag)"
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
safe_tag="$(printf '%s' "$tag" | tr -c 'A-Za-z0-9_.-' '-')"
manifest="$RELEASES_DIR/${stamp}-${safe_tag}.env"
previous_tag="$(current_release_tag)"
mkdir -p "$RELEASES_DIR"
if [ -f "$CURRENT_RELEASE_FILE" ]; then
cp "$CURRENT_RELEASE_FILE" "$PREVIOUS_RELEASE_FILE"
fi
{
printf 'tag=%s\n' "$tag"
printf 'previous_tag=%s\n' "$previous_tag"
printf 'deployed_at=%s\n' "$stamp"
printf 'source=%s\n' "$source"
printf 'api_image=thebet365-api:%s\n' "$tag"
printf 'player_image=thebet365-player:%s\n' "$tag"
printf 'admin_image=thebet365-admin:%s\n' "$tag"
[ -n "$LAST_DB_BACKUP" ] && printf 'db_backup=%s\n' "$LAST_DB_BACKUP"
[ -n "$LAST_UPLOADS_BACKUP" ] && printf 'uploads_backup=%s\n' "$LAST_UPLOADS_BACKUP"
} > "$manifest"
cp "$manifest" "$CURRENT_RELEASE_FILE"
log "发布状态已记录: $manifest"
}
print_stack_urls() {
local player_port admin_port
local player_port admin_port bind_addr tag
player_port="$(env_value PLAYER_PORT)"
admin_port="$(env_value ADMIN_PORT)"
bind_addr="$(env_value BIND_ADDR)"
tag="$(current_image_tag)"
player_port="${player_port:-8082}"
admin_port="${admin_port:-8081}"
bind_addr="${bind_addr:-127.0.0.1}"
printf '\n'
printf '%s\n' "部署完成:"
printf '%s\n' " 玩家端: http://服务器IP:${player_port}"
printf '%s\n' " 管理端: http://服务器IP:${admin_port}"
printf '%s\n' " 镜像 tag: ${tag}"
printf '%s\n' " 玩家端: http://${bind_addr}:${player_port}"
printf '%s\n' " 管理端: http://${bind_addr}:${admin_port}"
printf '%s\n' " API: 仅在 Docker 网络内暴露,由 player/admin 容器和宝塔反代链路访问"
printf '%s\n' " 状态: docker compose -f docker-compose.prod.yml --env-file .env.docker ps"
printf '%s\n' " 当前发布: .deploy/current-release.env"
}