完善数据库备份恢复链路
This commit is contained in:
@@ -57,6 +57,19 @@ STORAGE_OSS_ACCESS_KEY_ID=change-me-oss-access-key
|
|||||||
STORAGE_OSS_SECRET_ACCESS_KEY=change-me-oss-secret-key
|
STORAGE_OSS_SECRET_ACCESS_KEY=change-me-oss-secret-key
|
||||||
STORAGE_OSS_REGION=oss-cn-hangzhou
|
STORAGE_OSS_REGION=oss-cn-hangzhou
|
||||||
|
|
||||||
|
# 数据库备份 → OSS(deploy/backup-db.sh 与 dist/db-backup.js 使用)。
|
||||||
|
# 默认复用上面的 STORAGE_OSS_* 上传到同桶 backups/ 前缀,以下仅需按需调整。
|
||||||
|
DB_BACKUP_OSS_PREFIX=backups/postgres
|
||||||
|
# 服务器本地保留的备份份数(OSS 端过期请配置桶生命周期规则)
|
||||||
|
DB_BACKUP_KEEP=7
|
||||||
|
# 建议为备份单独创建 RAM 用户、只授予 backups/ 前缀权限(策略见 docs/数据库备份与恢复.md);
|
||||||
|
# 使用独立桶/密钥时取消注释覆盖:
|
||||||
|
# DB_BACKUP_OSS_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com
|
||||||
|
# DB_BACKUP_OSS_BUCKET=change-me-backup-bucket
|
||||||
|
# DB_BACKUP_OSS_ACCESS_KEY_ID=change-me-backup-access-key
|
||||||
|
# DB_BACKUP_OSS_SECRET_ACCESS_KEY=change-me-backup-secret-key
|
||||||
|
# DB_BACKUP_OSS_REGION=oss-cn-hangzhou
|
||||||
|
|
||||||
# Admin
|
# Admin
|
||||||
ADMIN_SESSION_SECRET=change-me-long-random-session-secret
|
ADMIN_SESSION_SECRET=change-me-long-random-session-secret
|
||||||
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}]
|
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}]
|
||||||
|
|||||||
@@ -33,3 +33,6 @@ send_code/
|
|||||||
.reasonix/
|
.reasonix/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.zcode/
|
.zcode/
|
||||||
|
|
||||||
|
# 服务器本地备份暂存目录(deploy/backup-db.sh)
|
||||||
|
/backups/
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
|
|||||||
'BACKEND_PORT',
|
'BACKEND_PORT',
|
||||||
'CADDY_SITE_ADDR',
|
'CADDY_SITE_ADDR',
|
||||||
'CHOKIDAR_USEPOLLING',
|
'CHOKIDAR_USEPOLLING',
|
||||||
|
'DB_BACKUP_KEEP',
|
||||||
|
'DB_BACKUP_OSS_PREFIX',
|
||||||
'KUASHOU_INDUSTRY_RATE_LIMIT_MAX',
|
'KUASHOU_INDUSTRY_RATE_LIMIT_MAX',
|
||||||
'MINIO_API_PORT',
|
'MINIO_API_PORT',
|
||||||
'MINIO_CONSOLE_PORT',
|
'MINIO_CONSOLE_PORT',
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { Readable } from 'node:stream'
|
||||||
|
|
||||||
|
import {
|
||||||
|
manifestKeyFor,
|
||||||
|
parseBackupArgs,
|
||||||
|
pickLatestDumpKey,
|
||||||
|
resolveBackupStorageConfig,
|
||||||
|
runDownload,
|
||||||
|
runLatest,
|
||||||
|
runList,
|
||||||
|
runUpload,
|
||||||
|
} from './db-backup.js'
|
||||||
|
import type { StorageObjectSummary } from './services/file-storage/object-storage.js'
|
||||||
|
|
||||||
|
test('resolveBackupStorageConfig 优先 DB_BACKUP_OSS_* 并按字段回退 STORAGE_OSS_*', () => {
|
||||||
|
const config = resolveBackupStorageConfig({
|
||||||
|
DB_BACKUP_OSS_ENDPOINT: 'https://backup.example.com',
|
||||||
|
STORAGE_OSS_ENDPOINT: 'https://storage.example.com',
|
||||||
|
STORAGE_OSS_BUCKET: 'order-site',
|
||||||
|
STORAGE_OSS_ACCESS_KEY_ID: 'storage-key',
|
||||||
|
DB_BACKUP_OSS_ACCESS_KEY_ID: 'backup-key',
|
||||||
|
STORAGE_OSS_SECRET_ACCESS_KEY: 'storage-secret',
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(config.endpoint, 'https://backup.example.com')
|
||||||
|
assert.equal(config.bucket, 'order-site')
|
||||||
|
assert.equal(config.accessKeyId, 'backup-key')
|
||||||
|
assert.equal(config.secretAccessKey, 'storage-secret')
|
||||||
|
assert.equal(config.region, 'oss-cn-hangzhou')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveBackupStorageConfig 全部未配置时返回空值由调用方校验', () => {
|
||||||
|
const config = resolveBackupStorageConfig({})
|
||||||
|
assert.equal(config.endpoint, '')
|
||||||
|
assert.equal(config.bucket, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseBackupArgs 解析命令与参数并去掉前缀尾部斜杠', () => {
|
||||||
|
const args = parseBackupArgs([
|
||||||
|
'upload',
|
||||||
|
'--key',
|
||||||
|
'backups/postgres/order_site-20260824-030000.dump',
|
||||||
|
'--prefix',
|
||||||
|
'backups/postgres/',
|
||||||
|
'--kind',
|
||||||
|
'app-configs',
|
||||||
|
'--size',
|
||||||
|
'1234',
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.equal(args.command, 'upload')
|
||||||
|
assert.equal(args.key, 'backups/postgres/order_site-20260824-030000.dump')
|
||||||
|
assert.equal(args.prefix, 'backups/postgres')
|
||||||
|
assert.equal(args.kind, 'app-configs')
|
||||||
|
assert.equal(args.size, 1234)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseBackupArgs 无参数时使用默认前缀与类型', () => {
|
||||||
|
const args = parseBackupArgs(['list'])
|
||||||
|
assert.equal(args.command, 'list')
|
||||||
|
assert.equal(args.prefix, 'backups/postgres')
|
||||||
|
assert.equal(args.kind, 'postgres-dump')
|
||||||
|
assert.equal(args.key, '')
|
||||||
|
assert.equal(args.allowUnverified, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseBackupArgs 支持显式兼容未校验备份', () => {
|
||||||
|
assert.equal(parseBackupArgs(['latest', '--allow-unverified']).allowUnverified, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('manifestKeyFor 在备份 key 后追加清单后缀', () => {
|
||||||
|
assert.equal(manifestKeyFor('backups/postgres/a.dump'), 'backups/postgres/a.dump.manifest.json')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pickLatestDumpKey 只认 dump 并取最近修改的对象', () => {
|
||||||
|
const key = pickLatestDumpKey([
|
||||||
|
{
|
||||||
|
key: 'backups/postgres/a.dump',
|
||||||
|
size: 1,
|
||||||
|
etag: '',
|
||||||
|
lastModified: new Date('2026-08-01T00:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backups/postgres/a.dump.manifest.json',
|
||||||
|
size: 1,
|
||||||
|
etag: '',
|
||||||
|
lastModified: new Date('2026-08-23T00:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backups/configs/a.tar.gz',
|
||||||
|
size: 1,
|
||||||
|
etag: '',
|
||||||
|
lastModified: new Date('2026-08-23T00:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backups/postgres/b.dump',
|
||||||
|
size: 1,
|
||||||
|
etag: '',
|
||||||
|
lastModified: new Date('2026-08-22T00:00:00Z'),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.equal(key, 'backups/postgres/b.dump')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pickLatestDumpKey 无备份时返回 null,无时间时按 key 兜底', () => {
|
||||||
|
assert.equal(pickLatestDumpKey([]), null)
|
||||||
|
assert.equal(
|
||||||
|
pickLatestDumpKey([
|
||||||
|
{ key: 'backups/postgres/order_site-1.dump', size: 1, etag: '' },
|
||||||
|
{ key: 'backups/postgres/order_site-2.dump', size: 1, etag: '' },
|
||||||
|
]),
|
||||||
|
'backups/postgres/order_site-2.dump',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('runUpload 支持流式输入并生成带长度和 sha256 的 manifest', async () => {
|
||||||
|
const uploads: Array<{ key: string; content: Buffer; contentLength?: number }> = []
|
||||||
|
const storage = {
|
||||||
|
putObject: async (input: {
|
||||||
|
key: string
|
||||||
|
content: Buffer | Readable
|
||||||
|
contentLength?: number
|
||||||
|
}) => {
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
if (Buffer.isBuffer(input.content)) {
|
||||||
|
chunks.push(input.content)
|
||||||
|
} else {
|
||||||
|
for await (const chunk of input.content) chunks.push(Buffer.from(chunk))
|
||||||
|
}
|
||||||
|
uploads.push({
|
||||||
|
key: input.key,
|
||||||
|
content: Buffer.concat(chunks),
|
||||||
|
contentLength: input.contentLength,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
} as never
|
||||||
|
|
||||||
|
await runUpload(
|
||||||
|
storage,
|
||||||
|
parseBackupArgs([
|
||||||
|
'upload',
|
||||||
|
'--key',
|
||||||
|
'backups/configs/a.tar.gz',
|
||||||
|
'--kind',
|
||||||
|
'app-configs',
|
||||||
|
'--size',
|
||||||
|
'5',
|
||||||
|
]),
|
||||||
|
Readable.from([Buffer.from('hello')]),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(uploads.length, 2)
|
||||||
|
assert.equal(uploads[0]?.content.toString(), 'hello')
|
||||||
|
assert.equal(uploads[0]?.contentLength, 5)
|
||||||
|
assert.match(uploads[1]?.content.toString() || '', /"size": 5/)
|
||||||
|
assert.match(uploads[1]?.content.toString() || '', /sha256/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('runLatest 跳过没有 manifest 的新对象并选择最近的有效备份', async () => {
|
||||||
|
const objects: StorageObjectSummary[] = [
|
||||||
|
{
|
||||||
|
key: 'backups/postgres/new.dump',
|
||||||
|
size: 5,
|
||||||
|
etag: '',
|
||||||
|
lastModified: new Date('2026-08-23T03:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backups/postgres/valid.dump',
|
||||||
|
size: 5,
|
||||||
|
etag: '',
|
||||||
|
lastModified: new Date('2026-08-22T03:00:00Z'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const storage = {
|
||||||
|
listObjects: async () => objects,
|
||||||
|
getObject: async (key: string) => {
|
||||||
|
if (key.endsWith('new.dump.manifest.json')) {
|
||||||
|
const error = Object.assign(new Error('missing'), { name: 'NoSuchKey' })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
reader: Readable.from([
|
||||||
|
JSON.stringify({
|
||||||
|
key: 'backups/postgres/valid.dump',
|
||||||
|
kind: 'postgres-dump',
|
||||||
|
size: 5,
|
||||||
|
sha256: 'a'.repeat(64),
|
||||||
|
createdAt: '2026-08-22T03:00:00Z',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as never
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
await runLatest(storage, parseBackupArgs(['latest', '--prefix', 'backups/postgres'])),
|
||||||
|
'backups/postgres/valid.dump',
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import fsPromises from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
import { Transform, type Readable } from 'node:stream'
|
||||||
|
import { pipeline } from 'node:stream/promises'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createConfiguredObjectStorage,
|
||||||
|
type ObjectStorage,
|
||||||
|
type StorageObjectSummary,
|
||||||
|
} from './services/file-storage/object-storage.js'
|
||||||
|
|
||||||
|
// 数据库备份传输工具:在 backend 容器内运行,负责备份文件与阿里云 OSS 之间的传输与校验。
|
||||||
|
// 由 deploy/backup-db.sh 与 deploy/restore-db.sh 通过 docker compose 调用。
|
||||||
|
// 日志一律走 stderr,stdout 只输出机器可读内容(下载的二进制、latest 的 key、list 的行),
|
||||||
|
// 便于宿主机脚本重定向捕获。
|
||||||
|
//
|
||||||
|
// 用法:
|
||||||
|
// node dist/db-backup.js upload --key backups/postgres/xxx.dump [--kind postgres-dump|app-configs] [--size bytes]
|
||||||
|
// 从 stdin 读取备份内容上传,并生成 <key>.manifest.json(size/sha256/迁移记录)。
|
||||||
|
// node dist/db-backup.js download --key backups/postgres/xxx.dump [--allow-unverified]
|
||||||
|
// 下载到 stdout,并按清单校验 sha256 与大小。
|
||||||
|
// node dist/db-backup.js latest [--prefix backups/postgres] [--allow-unverified]
|
||||||
|
// 在 stdout 打印最新 .dump 备份的 key(一行)。
|
||||||
|
// node dist/db-backup.js list [--prefix backups/postgres]
|
||||||
|
// 按时间倒序列出 key、大小、时间。
|
||||||
|
//
|
||||||
|
// OSS 配置:DB_BACKUP_OSS_* 优先,未设置的字段回退 STORAGE_OSS_*。
|
||||||
|
|
||||||
|
const USAGE = `用法:
|
||||||
|
node dist/db-backup.js upload --key <oss-key> [--kind postgres-dump|app-configs] [--size bytes]
|
||||||
|
node dist/db-backup.js download --key <oss-key> [--allow-unverified]
|
||||||
|
node dist/db-backup.js latest [--prefix backups/postgres] [--allow-unverified]
|
||||||
|
node dist/db-backup.js list [--prefix backups/postgres]
|
||||||
|
|
||||||
|
环境变量:DB_BACKUP_OSS_{ENDPOINT,BUCKET,ACCESS_KEY_ID,SECRET_ACCESS_KEY,REGION} 优先,
|
||||||
|
缺省字段复用 STORAGE_OSS_*;默认前缀 backups/postgres。`
|
||||||
|
|
||||||
|
const DEFAULT_BACKUP_PREFIX = 'backups/postgres'
|
||||||
|
|
||||||
|
export type BackupStorageConfig = {
|
||||||
|
endpoint: string
|
||||||
|
bucket: string
|
||||||
|
accessKeyId: string
|
||||||
|
secretAccessKey: string
|
||||||
|
region: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackupCliArgs = {
|
||||||
|
command: string
|
||||||
|
key: string
|
||||||
|
prefix: string
|
||||||
|
kind: string
|
||||||
|
allowUnverified: boolean
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type BackupManifest = {
|
||||||
|
key: string
|
||||||
|
kind: string
|
||||||
|
size: number
|
||||||
|
sha256: string
|
||||||
|
createdAt: string
|
||||||
|
database: string | null
|
||||||
|
serverVersion: string | null
|
||||||
|
migrations: string[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function logInfo(message: string): void {
|
||||||
|
process.stderr.write(`[db-backup] ${message}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
process.stderr.write(`[db-backup] ${message}\n`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBackupStorageConfig(
|
||||||
|
env: Record<string, string | undefined>,
|
||||||
|
): BackupStorageConfig {
|
||||||
|
return {
|
||||||
|
endpoint: readWithFallback(env, 'DB_BACKUP_OSS_ENDPOINT', 'STORAGE_OSS_ENDPOINT'),
|
||||||
|
bucket: readWithFallback(env, 'DB_BACKUP_OSS_BUCKET', 'STORAGE_OSS_BUCKET'),
|
||||||
|
accessKeyId: readWithFallback(env, 'DB_BACKUP_OSS_ACCESS_KEY_ID', 'STORAGE_OSS_ACCESS_KEY_ID'),
|
||||||
|
secretAccessKey: readWithFallback(
|
||||||
|
env,
|
||||||
|
'DB_BACKUP_OSS_SECRET_ACCESS_KEY',
|
||||||
|
'STORAGE_OSS_SECRET_ACCESS_KEY',
|
||||||
|
),
|
||||||
|
region:
|
||||||
|
readWithFallback(env, 'DB_BACKUP_OSS_REGION', 'STORAGE_OSS_REGION') || 'oss-cn-hangzhou',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readWithFallback(
|
||||||
|
env: Record<string, string | undefined>,
|
||||||
|
primary: string,
|
||||||
|
fallback: string,
|
||||||
|
): string {
|
||||||
|
return String(env[primary] || '').trim() || String(env[fallback] || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBackupArgs(argv: string[]): BackupCliArgs {
|
||||||
|
const args: BackupCliArgs = {
|
||||||
|
command: String(argv[0] || ''),
|
||||||
|
key: '',
|
||||||
|
prefix: DEFAULT_BACKUP_PREFIX,
|
||||||
|
kind: 'postgres-dump',
|
||||||
|
allowUnverified: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 1; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index]
|
||||||
|
const value = argv[index + 1]
|
||||||
|
if (arg === '--allow-unverified') {
|
||||||
|
args.allowUnverified = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (arg === '--size' && value !== undefined) {
|
||||||
|
const size = Number(value)
|
||||||
|
if (Number.isSafeInteger(size) && size >= 0) args.size = size
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ((arg === '--key' || arg === '--prefix' || arg === '--kind') && value !== undefined) {
|
||||||
|
if (arg === '--key') args.key = value
|
||||||
|
if (arg === '--prefix') args.prefix = value.replace(/\/+$/, '')
|
||||||
|
if (arg === '--kind') args.kind = value
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
export function manifestKeyFor(key: string): string {
|
||||||
|
return `${key}.manifest.json`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickLatestDumpKey(objects: StorageObjectSummary[]): string | null {
|
||||||
|
let latest: { key: string; time: number } | null = null
|
||||||
|
for (const item of objects) {
|
||||||
|
if (!item.key.endsWith('.dump')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const time = item.lastModified ? item.lastModified.getTime() : 0
|
||||||
|
if (!latest || time > latest.time || (time === latest.time && item.key > latest.key)) {
|
||||||
|
latest = { key: item.key, time }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest ? latest.key : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBackupStorage(): ObjectStorage {
|
||||||
|
const config = resolveBackupStorageConfig(process.env)
|
||||||
|
const missing = (['endpoint', 'bucket', 'accessKeyId', 'secretAccessKey'] as const)
|
||||||
|
.filter((field) => !config[field])
|
||||||
|
.map((field) => `DB_BACKUP_OSS_${field.replace(/([A-Z])/g, '_$1').toUpperCase()}`)
|
||||||
|
if (missing.length) {
|
||||||
|
logInfo(`缺少 OSS 配置:${missing.join(', ')}(或对应的 STORAGE_OSS_*)`)
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createConfiguredObjectStorage(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runUpload(
|
||||||
|
storage: ObjectStorage,
|
||||||
|
args: BackupCliArgs,
|
||||||
|
content: Buffer | Readable,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!args.key) {
|
||||||
|
fail(`upload 需要 --key。${USAGE}`)
|
||||||
|
}
|
||||||
|
if (args.size === 0) {
|
||||||
|
fail('输入为空,没有可上传的备份内容。')
|
||||||
|
}
|
||||||
|
const hash = crypto.createHash('sha256')
|
||||||
|
let size = 0
|
||||||
|
let uploadContent: Buffer | Readable = content
|
||||||
|
if (Buffer.isBuffer(content)) {
|
||||||
|
size = content.length
|
||||||
|
hash.update(content)
|
||||||
|
} else {
|
||||||
|
const hashingStream = new HashingStream(hash, (chunkSize) => {
|
||||||
|
size += chunkSize
|
||||||
|
})
|
||||||
|
content.pipe(hashingStream)
|
||||||
|
uploadContent = hashingStream
|
||||||
|
}
|
||||||
|
if (size === 0 && Buffer.isBuffer(content)) {
|
||||||
|
fail('输入为空,没有可上传的备份内容。')
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = args.kind === 'postgres-dump' ? await readDatabaseSnapshot() : null
|
||||||
|
|
||||||
|
await storage.putObject({
|
||||||
|
key: args.key,
|
||||||
|
content: uploadContent,
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
...(Buffer.isBuffer(content)
|
||||||
|
? { contentLength: content.length }
|
||||||
|
: args.size === undefined
|
||||||
|
? {}
|
||||||
|
: { contentLength: args.size }),
|
||||||
|
})
|
||||||
|
if (size === 0) {
|
||||||
|
fail('输入为空,没有可上传的备份内容。')
|
||||||
|
}
|
||||||
|
if (args.size !== undefined && args.size !== size) {
|
||||||
|
fail(`输入大小与 --size 不一致:期望 ${args.size} 字节,实际 ${size} 字节。`)
|
||||||
|
}
|
||||||
|
const sha256 = hash.digest('hex')
|
||||||
|
|
||||||
|
const manifest: BackupManifest = {
|
||||||
|
key: args.key,
|
||||||
|
kind: args.kind,
|
||||||
|
size,
|
||||||
|
sha256,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
database: snapshot ? snapshot.database : null,
|
||||||
|
serverVersion: snapshot ? snapshot.serverVersion : null,
|
||||||
|
migrations: snapshot ? snapshot.migrations : null,
|
||||||
|
}
|
||||||
|
await storage.putObject({
|
||||||
|
key: manifestKeyFor(args.key),
|
||||||
|
content: Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`),
|
||||||
|
contentType: 'application/json',
|
||||||
|
})
|
||||||
|
|
||||||
|
logInfo(
|
||||||
|
`上传完成:${args.key}(${formatSize(size)},sha256=${sha256.slice(0, 12)}…),清单 ${manifestKeyFor(args.key)}`,
|
||||||
|
)
|
||||||
|
if (args.kind === 'postgres-dump' && !snapshot) {
|
||||||
|
logInfo('未能读取数据库迁移记录(数据库暂不可达不影响备份本身,清单相应字段为空)。')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runDownload(storage: ObjectStorage, args: BackupCliArgs): Promise<Buffer> {
|
||||||
|
if (!args.key) {
|
||||||
|
fail(`download 需要 --key。${USAGE}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = await storage.getObject(args.key)
|
||||||
|
const hash = crypto.createHash('sha256')
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of stored.reader) {
|
||||||
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||||
|
hash.update(buffer)
|
||||||
|
chunks.push(buffer)
|
||||||
|
}
|
||||||
|
const content = Buffer.concat(chunks)
|
||||||
|
const sha256 = hash.digest('hex')
|
||||||
|
|
||||||
|
const manifest = await readManifest(storage, args.key)
|
||||||
|
if (!manifest) {
|
||||||
|
if (!args.allowUnverified) {
|
||||||
|
fail(
|
||||||
|
`缺少 ${manifestKeyFor(args.key)},拒绝使用未校验备份。需要兼容旧备份时显式传 --allow-unverified。`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
logInfo(`未找到 ${manifestKeyFor(args.key)},已显式允许跳过 sha256 校验。`)
|
||||||
|
} else {
|
||||||
|
validateManifest(manifest, args.key, content.length, sha256, args.kind)
|
||||||
|
logInfo(`sha256 校验通过:${args.key}(${formatSize(content.length)})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runLatest(storage: ObjectStorage, args: BackupCliArgs): Promise<string> {
|
||||||
|
const objects = (await storage.listObjects(`${args.prefix}/`))
|
||||||
|
.filter((item) => item.key.endsWith('.dump'))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftTime = left.lastModified ? left.lastModified.getTime() : 0
|
||||||
|
const rightTime = right.lastModified ? right.lastModified.getTime() : 0
|
||||||
|
return rightTime - leftTime || right.key.localeCompare(left.key)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const item of objects) {
|
||||||
|
try {
|
||||||
|
const manifest = await readManifest(storage, item.key)
|
||||||
|
if (manifest) {
|
||||||
|
validateManifestMetadata(manifest, item.key, 'postgres-dump')
|
||||||
|
const migrations = manifest.migrations ? `,迁移 ${manifest.migrations.length} 个` : ''
|
||||||
|
logInfo(
|
||||||
|
`最新备份:${item.key}(${formatSize(manifest.size)},${manifest.createdAt}${migrations})`,
|
||||||
|
)
|
||||||
|
return item.key
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logInfo(`跳过无效备份 ${item.key}:${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
}
|
||||||
|
if (args.allowUnverified) {
|
||||||
|
logInfo(`最新备份:${item.key}(已显式允许无清单备份)。`)
|
||||||
|
return item.key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!objects.length) {
|
||||||
|
fail(`前缀 ${args.prefix}/ 下没有 .dump 备份。`)
|
||||||
|
}
|
||||||
|
fail(`前缀 ${args.prefix}/ 下没有带有效 manifest 的 .dump 备份。`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runList(storage: ObjectStorage, args: BackupCliArgs): Promise<string[]> {
|
||||||
|
const objects = (await storage.listObjects(`${args.prefix}/`))
|
||||||
|
.filter((item) => !item.key.endsWith('.manifest.json'))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftTime = left.lastModified ? left.lastModified.getTime() : 0
|
||||||
|
const rightTime = right.lastModified ? right.lastModified.getTime() : 0
|
||||||
|
return rightTime - leftTime
|
||||||
|
})
|
||||||
|
|
||||||
|
const lines = objects.map((item) => {
|
||||||
|
const time = item.lastModified ? item.lastModified.toISOString() : '-'
|
||||||
|
return `${item.key}\t${item.size}\t${time}`
|
||||||
|
})
|
||||||
|
logInfo(`前缀 ${args.prefix}/ 下共 ${objects.length} 个备份。`)
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readManifest(storage: ObjectStorage, key: string): Promise<BackupManifest | null> {
|
||||||
|
try {
|
||||||
|
const stored = await storage.getObject(manifestKeyFor(key))
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of stored.reader) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||||
|
}
|
||||||
|
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as BackupManifest
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingObjectError(error)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateManifestMetadata(
|
||||||
|
manifest: BackupManifest,
|
||||||
|
key: string,
|
||||||
|
expectedKind: string,
|
||||||
|
): void {
|
||||||
|
if (manifest.key !== key || manifest.kind !== expectedKind) {
|
||||||
|
throw new Error(`manifest 与备份 key/type 不匹配`)
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(manifest.size) || manifest.size <= 0) {
|
||||||
|
throw new Error(`manifest size 无效`)
|
||||||
|
}
|
||||||
|
if (!/^[a-f0-9]{64}$/i.test(manifest.sha256)) {
|
||||||
|
throw new Error(`manifest sha256 无效`)
|
||||||
|
}
|
||||||
|
if (!manifest.createdAt || Number.isNaN(Date.parse(manifest.createdAt))) {
|
||||||
|
throw new Error(`manifest createdAt 无效`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateManifest(
|
||||||
|
manifest: BackupManifest,
|
||||||
|
key: string,
|
||||||
|
size: number,
|
||||||
|
sha256: string,
|
||||||
|
expectedKind: string,
|
||||||
|
): void {
|
||||||
|
validateManifestMetadata(manifest, key, expectedKind)
|
||||||
|
if (manifest.sha256.toLowerCase() !== sha256.toLowerCase()) {
|
||||||
|
fail(`sha256 校验失败:${key} 内容与清单不一致,请勿使用该备份。`)
|
||||||
|
}
|
||||||
|
if (manifest.size !== size) {
|
||||||
|
fail(`大小校验失败:${key} 期望 ${manifest.size} 字节,实际 ${size} 字节。`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingObjectError(error: unknown): boolean {
|
||||||
|
const candidate = error as {
|
||||||
|
name?: string
|
||||||
|
Code?: string
|
||||||
|
$metadata?: { httpStatusCode?: number }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
candidate?.$metadata?.httpStatusCode === 404 ||
|
||||||
|
['NotFound', 'NoSuchKey', 'NoSuchBucket'].includes(
|
||||||
|
String(candidate?.name || candidate?.Code || ''),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class HashingStream extends Transform {
|
||||||
|
constructor(
|
||||||
|
private readonly hash: crypto.Hash,
|
||||||
|
private readonly onChunk: (size: number) => void,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
_transform(
|
||||||
|
chunk: Buffer,
|
||||||
|
_encoding: BufferEncoding,
|
||||||
|
callback: (error?: Error | null, data?: Buffer) => void,
|
||||||
|
): void {
|
||||||
|
this.hash.update(chunk)
|
||||||
|
this.onChunk(chunk.length)
|
||||||
|
callback(null, chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadToStdout(storage: ObjectStorage, args: BackupCliArgs): Promise<void> {
|
||||||
|
if (!args.key) {
|
||||||
|
fail(`download 需要 --key。${USAGE}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = await storage.getObject(args.key)
|
||||||
|
const hash = crypto.createHash('sha256')
|
||||||
|
const tempPath = path.join('/tmp', `order-site-backup-${process.pid}-${Date.now()}.dump`)
|
||||||
|
let size = 0
|
||||||
|
try {
|
||||||
|
const output = fs.createWriteStream(tempPath, { mode: 0o600 })
|
||||||
|
for await (const chunk of stored.reader) {
|
||||||
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||||
|
hash.update(buffer)
|
||||||
|
size += buffer.length
|
||||||
|
if (!output.write(buffer)) {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
output.once('drain', resolve)
|
||||||
|
output.once('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
output.once('close', resolve)
|
||||||
|
output.once('error', reject)
|
||||||
|
output.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
const sha256 = hash.digest('hex')
|
||||||
|
const manifest = await readManifest(storage, args.key)
|
||||||
|
if (!manifest) {
|
||||||
|
if (!args.allowUnverified) {
|
||||||
|
fail(
|
||||||
|
`缺少 ${manifestKeyFor(args.key)},拒绝使用未校验备份。需要兼容旧备份时显式传 --allow-unverified。`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
logInfo(`未找到 ${manifestKeyFor(args.key)},已显式允许跳过 sha256 校验。`)
|
||||||
|
} else {
|
||||||
|
validateManifest(manifest, args.key, size, sha256, args.kind)
|
||||||
|
logInfo(`sha256 校验通过:${args.key}(${formatSize(size)})`)
|
||||||
|
}
|
||||||
|
await pipeline(fs.createReadStream(tempPath), process.stdout)
|
||||||
|
} finally {
|
||||||
|
await fsPromises.rm(tempPath, { force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尽力而为地补充数据库快照信息;数据库不可达时返回 null,不影响备份传输。
|
||||||
|
async function readDatabaseSnapshot(): Promise<{
|
||||||
|
database: string | null
|
||||||
|
serverVersion: string | null
|
||||||
|
migrations: string[] | null
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
const { runtimeConfig } = await import('./config/runtime.js')
|
||||||
|
const { query, closeDb } = await import('./db/client.js')
|
||||||
|
try {
|
||||||
|
let database: string | null = null
|
||||||
|
try {
|
||||||
|
const url = new URL(String(runtimeConfig.database?.url || ''))
|
||||||
|
database = decodeURIComponent(url.pathname.replace(/^\//, '')) || null
|
||||||
|
} catch {
|
||||||
|
database = null
|
||||||
|
}
|
||||||
|
const [versionResult, migrationsResult] = await Promise.all([
|
||||||
|
query<{ server_version: string }>('SHOW server_version'),
|
||||||
|
query<{ filename: string }>('SELECT filename FROM schema_migrations ORDER BY filename'),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
database,
|
||||||
|
serverVersion: versionResult.rows[0]?.server_version || null,
|
||||||
|
migrations: migrationsResult.rows.map((row) => row.filename),
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await closeDb()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logInfo(`读取数据库快照失败:${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(size: number): string {
|
||||||
|
if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(2)} MB`
|
||||||
|
if (size >= 1024) return `${(size / 1024).toFixed(2)} KB`
|
||||||
|
return `${size} B`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = parseBackupArgs(process.argv.slice(2))
|
||||||
|
if (!['upload', 'download', 'latest', 'list'].includes(args.command)) {
|
||||||
|
logInfo(USAGE)
|
||||||
|
process.exitCode = 2
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = createBackupStorage()
|
||||||
|
|
||||||
|
switch (args.command) {
|
||||||
|
case 'upload':
|
||||||
|
await runUpload(storage, args, process.stdin)
|
||||||
|
break
|
||||||
|
case 'download':
|
||||||
|
await downloadToStdout(storage, args)
|
||||||
|
break
|
||||||
|
case 'latest':
|
||||||
|
process.stdout.write(`${await runLatest(storage, args)}\n`)
|
||||||
|
break
|
||||||
|
case 'list':
|
||||||
|
for (const line of await runList(storage, args)) {
|
||||||
|
process.stdout.write(`${line}\n`)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInvokedAsScript(entryPath: string | undefined): boolean {
|
||||||
|
if (!entryPath) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return import.meta.url === pathToFileURL(path.resolve(entryPath)).href
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInvokedAsScript(process.argv[1])) {
|
||||||
|
await main()
|
||||||
|
}
|
||||||
@@ -70,9 +70,10 @@ export class ObjectStorage {
|
|||||||
|
|
||||||
async putObject(input: {
|
async putObject(input: {
|
||||||
key: string
|
key: string
|
||||||
content: Buffer
|
content: Buffer | Readable
|
||||||
contentType: string
|
contentType: string
|
||||||
metadata?: Record<string, string>
|
metadata?: Record<string, string>
|
||||||
|
contentLength?: number
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await this.ensureBucketReady()
|
await this.ensureBucketReady()
|
||||||
await this.client.send(
|
await this.client.send(
|
||||||
@@ -81,6 +82,7 @@ export class ObjectStorage {
|
|||||||
Key: input.key,
|
Key: input.key,
|
||||||
Body: input.content,
|
Body: input.content,
|
||||||
ContentType: input.contentType,
|
ContentType: input.contentType,
|
||||||
|
...(input.contentLength === undefined ? {} : { ContentLength: input.contentLength }),
|
||||||
...(input.metadata ? { Metadata: sanitizeObjectMetadata(input.metadata) } : {}),
|
...(input.metadata ? { Metadata: sanitizeObjectMetadata(input.metadata) } : {}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -125,13 +127,17 @@ export class ObjectStorage {
|
|||||||
return stat
|
return stat
|
||||||
}
|
}
|
||||||
|
|
||||||
async listObjects(): Promise<StorageObjectSummary[]> {
|
async listObjects(prefix?: string): Promise<StorageObjectSummary[]> {
|
||||||
await this.ensureBucketReady()
|
await this.ensureBucketReady()
|
||||||
const objects: StorageObjectSummary[] = []
|
const objects: StorageObjectSummary[] = []
|
||||||
let continuationToken: string | undefined
|
let continuationToken: string | undefined
|
||||||
do {
|
do {
|
||||||
const output = await this.client.send(
|
const output = await this.client.send(
|
||||||
new ListObjectsV2Command({ Bucket: this.bucket, ContinuationToken: continuationToken }),
|
new ListObjectsV2Command({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
...(prefix ? { Prefix: prefix } : {}),
|
||||||
|
ContinuationToken: continuationToken,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
for (const item of output.Contents || []) {
|
for (const item of output.Contents || []) {
|
||||||
if (!item.Key) {
|
if (!item.Key) {
|
||||||
@@ -166,7 +172,9 @@ export class ObjectStorage {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
if (!this.pathStyle) {
|
if (!this.pathStyle) {
|
||||||
throw new Error(`OSS bucket ${this.bucket} 不存在,请先在阿里云控制台创建`)
|
throw new Error(`OSS bucket ${this.bucket} 不存在,请先在阿里云控制台创建`, {
|
||||||
|
cause: error,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
await this.client.send(new CreateBucketCommand({ Bucket: this.bucket }))
|
await this.client.send(new CreateBucketCommand({ Bucket: this.bucket }))
|
||||||
}
|
}
|
||||||
@@ -197,6 +205,9 @@ export class StorageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async putObject(input: Parameters<ObjectStorage['putObject']>[0]): Promise<void> {
|
async putObject(input: Parameters<ObjectStorage['putObject']>[0]): Promise<void> {
|
||||||
|
if (!Buffer.isBuffer(input.content) && this.mode === 'dual') {
|
||||||
|
throw new Error('dual 存储模式不支持同一可读流双写,请传入 Buffer')
|
||||||
|
}
|
||||||
if (this.mode === 'minio') {
|
if (this.mode === 'minio') {
|
||||||
await this.legacy.putObject(input)
|
await this.legacy.putObject(input)
|
||||||
return
|
return
|
||||||
|
|||||||
Executable
+175
@@ -0,0 +1,175 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
# 数据库备份:postgres 容器内 pg_dump(custom 格式,自带压缩,MVCC 快照无需停机)
|
||||||
|
# → 本地暂存 → 上传阿里云 OSS;同时打包 apps/backend/data 下的平台 JSON 配置(排除日志与缓存)。
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# bash deploy/backup-db.sh # 手动或 cron 调用
|
||||||
|
# bash deploy/backup-db.sh --keep 14 # 本地保留最近 14 份(默认读 .env 的 DB_BACKUP_KEEP,再默认 7)
|
||||||
|
#
|
||||||
|
# 说明:
|
||||||
|
# - 上传由 backend 镜像内的 dist/db-backup.js 完成;OSS 凭证优先读 .env 的 DB_BACKUP_OSS_*,
|
||||||
|
# 未配置时复用 STORAGE_OSS_*。备份 key 形如 backups/postgres/order_site-<时间戳>.dump。
|
||||||
|
# - OSS 端的过期删除请配置桶生命周期规则,见 docs/数据库备份与恢复.md。
|
||||||
|
# - ubuntu-deploy.sh 在执行数据库迁移前会自动调用本脚本(--skip-backup 可跳过)。
|
||||||
|
|
||||||
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
ENV_FILE="${PROJECT_ROOT}/.env"
|
||||||
|
BACKUP_ROOT="${PROJECT_ROOT}/backups"
|
||||||
|
DB_DIR="${BACKUP_ROOT}/postgres"
|
||||||
|
CONFIG_DIR="${BACKUP_ROOT}/configs"
|
||||||
|
KEEP=""
|
||||||
|
BACKUP_PREFIX=""
|
||||||
|
|
||||||
|
log() { printf '\033[1;34m[backup]\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[1;33m[backup]\033[0m %s\n' "$*"; }
|
||||||
|
fail() { printf '\033[1;31m[backup]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
while (($# > 0)); do
|
||||||
|
case "$1" in
|
||||||
|
--keep)
|
||||||
|
[[ $# -ge 2 ]] || fail "--keep 需要一个正整数参数。"
|
||||||
|
KEEP="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '4,15p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "未知参数:$1。执行 bash deploy/backup-db.sh --help 查看用法。"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "${KEEP}" ]]; then
|
||||||
|
KEEP="${DB_BACKUP_KEEP:-$(read_env_value DB_BACKUP_KEEP || true)}"
|
||||||
|
fi
|
||||||
|
KEEP="${KEEP:-7}"
|
||||||
|
[[ "${KEEP}" =~ ^[1-9][0-9]*$ ]] || fail "本地保留份数必须是正整数,当前值:${KEEP}"
|
||||||
|
|
||||||
|
BACKUP_PREFIX="${DB_BACKUP_OSS_PREFIX:-$(read_env_value DB_BACKUP_OSS_PREFIX || true)}"
|
||||||
|
BACKUP_PREFIX="${BACKUP_PREFIX%/}"
|
||||||
|
BACKUP_PREFIX="${BACKUP_PREFIX:-backups/postgres}"
|
||||||
|
[[ "${BACKUP_PREFIX}" != /* && "${BACKUP_PREFIX}" != *'..'* ]] || fail "DB_BACKUP_OSS_PREFIX 不能是绝对路径或包含 ..。"
|
||||||
|
}
|
||||||
|
|
||||||
|
read_env_value() {
|
||||||
|
local key="$1"
|
||||||
|
[[ -f "${ENV_FILE}" ]] || return 0
|
||||||
|
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}"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_backup_tool() {
|
||||||
|
# 用一次性容器而不是 exec,部署过程中 backend 未运行时也能上传。
|
||||||
|
docker compose run --rm --no-deps -T backend node dist/db-backup.js "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_pg_dump() {
|
||||||
|
local database_url
|
||||||
|
database_url="$(read_env_value DATABASE_URL)"
|
||||||
|
if [[ -z "${database_url}" || "${database_url}" == *"@postgres:"* ]]; then
|
||||||
|
docker compose exec -T postgres sh -c 'pg_dump -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-order_site}" -Fc'
|
||||||
|
else
|
||||||
|
log "DATABASE_URL 指向外部数据库,从 postgres 容器经网络直连导出"
|
||||||
|
docker compose exec -T -e DB_DUMP_URL="${database_url}" postgres sh -c 'pg_dump "$DB_DUMP_URL" -Fc'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_database() {
|
||||||
|
local stamp dump_file
|
||||||
|
stamp="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
dump_file="${DB_DIR}/order_site-${stamp}.dump"
|
||||||
|
|
||||||
|
log "导出数据库(pg_dump custom 格式,无需停机)"
|
||||||
|
if ! run_pg_dump > "${dump_file}"; then
|
||||||
|
rm -f "${dump_file}"
|
||||||
|
fail "pg_dump 失败。可先用 docker compose logs postgres 排查数据库状态。"
|
||||||
|
fi
|
||||||
|
[[ -s "${dump_file}" ]] || fail "pg_dump 输出为空:${dump_file}"
|
||||||
|
|
||||||
|
log "上传数据库备份到 OSS:${BACKUP_PREFIX}/$(basename "${dump_file}")"
|
||||||
|
local dump_size
|
||||||
|
dump_size="$(wc -c < "${dump_file}" | tr -d ' ')"
|
||||||
|
if ! run_backup_tool upload --key "${BACKUP_PREFIX}/$(basename "${dump_file}")" --size "${dump_size}" < "${dump_file}"; then
|
||||||
|
fail "备份上传 OSS 失败。数据库未受影响,本地已保留:${dump_file}"
|
||||||
|
fi
|
||||||
|
log "数据库备份完成:${dump_size} 字节"
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_configs() {
|
||||||
|
local stamp tar_file sample
|
||||||
|
sample="$(
|
||||||
|
find "${PROJECT_ROOT}/apps/backend/data" -type f \
|
||||||
|
! -path '*/logs/*' ! -path '*/cache/*' \
|
||||||
|
! -path '*/browser-sessions/*' ! -path '*/redeem-screenshots/*' 2>/dev/null | head -n 1
|
||||||
|
)"
|
||||||
|
if [[ -z "${sample}" ]]; then
|
||||||
|
warn "apps/backend/data 下没有需要备份的配置文件,跳过配置打包"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
stamp="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
tar_file="${CONFIG_DIR}/order_site-${stamp}.tar.gz"
|
||||||
|
|
||||||
|
if ! tar -czf "${tar_file}" \
|
||||||
|
--exclude='./logs' --exclude='./cache' \
|
||||||
|
--exclude='./browser-sessions' --exclude='./redeem-screenshots' \
|
||||||
|
-C "${PROJECT_ROOT}/apps/backend/data" .; then
|
||||||
|
rm -f "${tar_file}"
|
||||||
|
fail "打包 apps/backend/data 失败。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local config_prefix
|
||||||
|
config_prefix="${BACKUP_PREFIX%/postgres}/configs"
|
||||||
|
log "上传配置备份到 OSS:${config_prefix}/$(basename "${tar_file}")"
|
||||||
|
local config_size
|
||||||
|
config_size="$(wc -c < "${tar_file}" | tr -d ' ')"
|
||||||
|
if ! run_backup_tool upload --key "${config_prefix}/$(basename "${tar_file}")" --kind app-configs --size "${config_size}" < "${tar_file}"; then
|
||||||
|
fail "配置备份上传 OSS 失败。数据库备份已上传,本地配置备份已保留:${tar_file}"
|
||||||
|
fi
|
||||||
|
log "配置备份完成:${config_size} 字节"
|
||||||
|
}
|
||||||
|
|
||||||
|
prune_local() {
|
||||||
|
local dir="$1" removed
|
||||||
|
removed="$(cd "${dir}" && ls -1t order_site-*.dump order_site-*.tar.gz 2>/dev/null | tail -n +$((KEEP + 1)) || true)"
|
||||||
|
if [[ -z "${removed}" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
while IFS= read -r file; do
|
||||||
|
rm -f -- "${file}"
|
||||||
|
log "清理本地旧备份:${file}(本地保留 ${KEEP} 份,OSS 仍由生命周期规则管理)"
|
||||||
|
done <<<"${removed}"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
parse_args "$@"
|
||||||
|
cd "${PROJECT_ROOT}"
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 || fail "未找到 docker。"
|
||||||
|
docker compose version >/dev/null 2>&1 || fail "未找到 docker compose 插件。"
|
||||||
|
if [[ -z "$(docker compose images backend -q 2>/dev/null)" ]]; then
|
||||||
|
fail "backend 镜像不存在,请先完成部署:bash deploy/ubuntu-deploy.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${DB_DIR}" "${CONFIG_DIR}"
|
||||||
|
backup_database
|
||||||
|
backup_configs
|
||||||
|
prune_local "${DB_DIR}"
|
||||||
|
prune_local "${CONFIG_DIR}"
|
||||||
|
log "备份完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+376
@@ -0,0 +1,376 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
# 数据库恢复与备份验证。
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# bash deploy/restore-db.sh --list # 列出 OSS 中的数据库备份(倒序)
|
||||||
|
# bash deploy/restore-db.sh --verify # 用最新备份做恢复演练(临时容器,不影响线上库)
|
||||||
|
# bash deploy/restore-db.sh --verify --key backups/postgres/order_site-xxx.dump
|
||||||
|
# bash deploy/restore-db.sh # 交互确认后,恢复最新备份到线上库
|
||||||
|
# bash deploy/restore-db.sh --key backups/postgres/order_site-xxx.dump --yes
|
||||||
|
# bash deploy/restore-db.sh --restore-configs # 同时恢复最新 data 配置备份
|
||||||
|
# bash deploy/restore-db.sh --allow-unverified # 仅兼容历史无 manifest 备份
|
||||||
|
#
|
||||||
|
# 恢复流程:下载备份(校验 sha256)→ 停 backend/web → pg_restore --clean → 执行迁移 → 重启并等待健康检查。
|
||||||
|
# 详见 docs/数据库备份与恢复.md。
|
||||||
|
|
||||||
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
ENV_FILE="${PROJECT_ROOT}/.env"
|
||||||
|
TMP_DIR="${PROJECT_ROOT}/backups/tmp"
|
||||||
|
MODE="restore"
|
||||||
|
KEY=""
|
||||||
|
CONFIG_KEY=""
|
||||||
|
RESTORE_CONFIGS=false
|
||||||
|
ASSUME_YES="${RESTORE_CONFIRM:-}"
|
||||||
|
ALLOW_UNVERIFIED=false
|
||||||
|
BACKUP_PREFIX=""
|
||||||
|
CONFIG_PREFIX=""
|
||||||
|
RESTORE_ACTIVE=false
|
||||||
|
RESTORE_SUCCEEDED=false
|
||||||
|
BACKEND_WAS_RUNNING=false
|
||||||
|
WEB_WAS_RUNNING=false
|
||||||
|
RESTORE_TARGET=""
|
||||||
|
CONFIG_TARGET=""
|
||||||
|
VERIFY_CONTAINER=""
|
||||||
|
|
||||||
|
log() { printf '\033[1;34m[restore]\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[1;33m[restore]\033[0m %s\n' "$*"; }
|
||||||
|
fail() { printf '\033[1;31m[restore]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
while (($# > 0)); do
|
||||||
|
case "$1" in
|
||||||
|
--list|--verify)
|
||||||
|
[[ "${MODE}" == "restore" ]] || fail "--list 与 --verify 互斥。"
|
||||||
|
MODE="${1#--}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--key)
|
||||||
|
[[ $# -ge 2 ]] || fail "--key 需要一个备份 key 参数。"
|
||||||
|
KEY="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--config-key)
|
||||||
|
[[ $# -ge 2 ]] || fail "--config-key 需要一个配置备份 key 参数。"
|
||||||
|
CONFIG_KEY="$2"
|
||||||
|
RESTORE_CONFIGS=true
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--restore-configs)
|
||||||
|
RESTORE_CONFIGS=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--allow-unverified)
|
||||||
|
ALLOW_UNVERIFIED=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--yes)
|
||||||
|
ASSUME_YES=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '4,13p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "未知参数:$1。执行 bash deploy/restore-db.sh --help 查看用法。"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
read_env_value() {
|
||||||
|
local key="$1"
|
||||||
|
[[ -f "${ENV_FILE}" ]] || return 0
|
||||||
|
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}"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_backup_tool() {
|
||||||
|
docker compose run --rm --no-deps -T backend node dist/db-backup.js "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_cleanup() {
|
||||||
|
local exit_code=$?
|
||||||
|
if [[ "${RESTORE_ACTIVE}" == "true" && "${RESTORE_SUCCEEDED}" != "true" && "${exit_code}" -ne 0 ]]; then
|
||||||
|
warn "恢复失败,尝试恢复恢复前正在运行的业务服务。数据库可能处于部分恢复状态,请立即检查。"
|
||||||
|
if [[ "${BACKEND_WAS_RUNNING}" == "true" ]]; then
|
||||||
|
docker compose up -d --no-deps backend >/dev/null 2>&1 || warn "backend 自动重启失败。"
|
||||||
|
fi
|
||||||
|
if [[ "${WEB_WAS_RUNNING}" == "true" ]]; then
|
||||||
|
docker compose up -d --no-deps web >/dev/null 2>&1 || warn "web 自动重启失败。"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[[ -n "${RESTORE_TARGET}" ]] && rm -f -- "${RESTORE_TARGET}"
|
||||||
|
[[ -n "${CONFIG_TARGET}" ]] && rm -f -- "${CONFIG_TARGET}"
|
||||||
|
[[ -n "${VERIFY_CONTAINER}" ]] && docker rm -f -v "${VERIFY_CONTAINER}" >/dev/null 2>&1 || true
|
||||||
|
trap - EXIT
|
||||||
|
exit "${exit_code}"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_external_database() {
|
||||||
|
local database_url
|
||||||
|
database_url="$(read_env_value DATABASE_URL)"
|
||||||
|
[[ -n "${database_url}" && "${database_url}" != *"@postgres:"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_key() {
|
||||||
|
if [[ -n "${KEY}" ]]; then
|
||||||
|
printf '%s\n' "${KEY}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local latest
|
||||||
|
local latest_args=(latest --prefix "${BACKUP_PREFIX}")
|
||||||
|
[[ "${ALLOW_UNVERIFIED}" == "true" ]] && latest_args+=(--allow-unverified)
|
||||||
|
if ! latest="$(run_backup_tool "${latest_args[@]}")"; then
|
||||||
|
fail "未能获取最新备份 key。可先用 bash deploy/restore-db.sh --list 查看已有备份。"
|
||||||
|
fi
|
||||||
|
printf '%s\n' "${latest}"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_config_key() {
|
||||||
|
if [[ -n "${CONFIG_KEY}" ]]; then
|
||||||
|
printf '%s\n' "${CONFIG_KEY}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local latest
|
||||||
|
latest="$(run_backup_tool list --prefix "${CONFIG_PREFIX}" | awk -F '\t' '$1 ~ /\.tar\.gz$/ { print $1; exit }' || true)"
|
||||||
|
[[ -n "${latest}" ]] || fail "未找到配置备份。可使用 --config-key 指定配置备份,或不启用 --restore-configs。"
|
||||||
|
printf '%s\n' "${latest}"
|
||||||
|
}
|
||||||
|
|
||||||
|
download_backup() {
|
||||||
|
local key="$1" target="$2" kind="${3:-postgres-dump}"
|
||||||
|
log "下载备份并校验 sha256:${key}"
|
||||||
|
local verification_args=(--key "${key}" --kind "${kind}")
|
||||||
|
[[ "${ALLOW_UNVERIFIED}" == "true" ]] && verification_args+=(--allow-unverified)
|
||||||
|
if ! run_backup_tool download "${verification_args[@]}" > "${target}"; then
|
||||||
|
rm -f "${target}"
|
||||||
|
fail "下载或校验失败:${key}"
|
||||||
|
fi
|
||||||
|
[[ -s "${target}" ]] || fail "下载内容为空:${key}"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_pg_restore_stdin() {
|
||||||
|
local database_url
|
||||||
|
database_url="$(read_env_value DATABASE_URL)"
|
||||||
|
if [[ -z "${database_url}" || "${database_url}" == *"@postgres:"* ]]; then
|
||||||
|
docker compose exec -T postgres sh -c 'pg_restore -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-order_site}" --clean --if-exists --no-owner'
|
||||||
|
else
|
||||||
|
log "DATABASE_URL 指向外部数据库,从 postgres 容器经网络直连恢复"
|
||||||
|
docker compose exec -T -e DB_RESTORE_URL="${database_url}" postgres sh -c 'pg_restore --dbname="$DB_RESTORE_URL" --clean --if-exists --no-owner'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
service_is_running() {
|
||||||
|
local service="$1" container_id status
|
||||||
|
container_id="$(docker compose ps -q "${service}" 2>/dev/null || true)"
|
||||||
|
[[ -n "${container_id}" ]] || return 1
|
||||||
|
status="$(docker inspect -f '{{.State.Status}}' "${container_id}" 2>/dev/null || true)"
|
||||||
|
[[ "${status}" == "running" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_configs() {
|
||||||
|
local source="$1" staging="${TMP_DIR}/config-extract-$$"
|
||||||
|
log "恢复配置备份到 apps/backend/data"
|
||||||
|
while IFS= read -r entry; do
|
||||||
|
case "${entry}" in
|
||||||
|
/*|../*|*/../*|..)
|
||||||
|
fail "配置备份包含不安全路径:${entry}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done < <(tar -tzf "${source}")
|
||||||
|
rm -rf -- "${staging}"
|
||||||
|
mkdir -p "${staging}"
|
||||||
|
if ! tar -xzf "${source}" --no-same-owner -C "${staging}"; then
|
||||||
|
rm -rf -- "${staging}"
|
||||||
|
fail "配置备份解包失败,未修改现有配置。"
|
||||||
|
fi
|
||||||
|
cp -a "${staging}/." "${PROJECT_ROOT}/apps/backend/data/"
|
||||||
|
rm -rf -- "${staging}"
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_live_database() {
|
||||||
|
local key="$1" target="$2" config_target="${3:-}"
|
||||||
|
|
||||||
|
warn "即将用备份覆盖当前数据库:${key}"
|
||||||
|
warn "当前库中的订单、后台用户、审计日志等数据都会被备份内容替换。"
|
||||||
|
if [[ "${ASSUME_YES}" != "1" ]]; then
|
||||||
|
if [[ ! -t 0 ]]; then
|
||||||
|
fail "当前不是交互式终端。确认恢复请执行:RESTORE_CONFIRM=1 bash deploy/restore-db.sh --key ${key}"
|
||||||
|
fi
|
||||||
|
local answer
|
||||||
|
printf '请输入 RESTORE 确认恢复数据库:'
|
||||||
|
read -r answer
|
||||||
|
[[ "${answer}" == "RESTORE" ]] || fail "未确认恢复,已停止。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKEND_WAS_RUNNING=false
|
||||||
|
WEB_WAS_RUNNING=false
|
||||||
|
service_is_running backend && BACKEND_WAS_RUNNING=true
|
||||||
|
service_is_running web && WEB_WAS_RUNNING=true
|
||||||
|
RESTORE_ACTIVE=true
|
||||||
|
log "停止 backend/web,避免恢复期间产生写入"
|
||||||
|
docker compose stop backend web >/dev/null 2>&1 || true
|
||||||
|
if service_is_running backend || service_is_running web; then
|
||||||
|
fail "无法确认 backend/web 已停止,为避免恢复期间写入已中止。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "pg_restore 恢复中(--clean --if-exists)"
|
||||||
|
if ! run_pg_restore_stdin < "${target}"; then
|
||||||
|
fail "pg_restore 失败。数据库处于中间状态,请根据日志处理;必要时用 --list 选择更早的备份重试。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "执行数据库迁移(补齐备份之后新增的迁移)"
|
||||||
|
if ! docker compose run --rm --no-deps backend node dist/db/migrate.js; then
|
||||||
|
fail "迁移失败。请查看日志;数据已恢复到备份时点。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${config_target}" ]]; then
|
||||||
|
restore_configs "${config_target}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "重启 backend/web"
|
||||||
|
docker compose up -d --no-deps backend >/dev/null || fail "backend 启动失败。"
|
||||||
|
wait_backend_healthy
|
||||||
|
docker compose up -d --no-deps web >/dev/null || fail "web 启动失败。"
|
||||||
|
|
||||||
|
local health_url site_addr
|
||||||
|
site_addr="$(read_env_value CADDY_SITE_ADDR)"
|
||||||
|
health_url="${site_addr%/}/health/ready"
|
||||||
|
if curl -fsS "${health_url}" >/dev/null 2>&1; then
|
||||||
|
log "健康检查通过:${health_url}"
|
||||||
|
else
|
||||||
|
fail "健康检查未通过:${health_url},请检查 docker compose logs backend。"
|
||||||
|
fi
|
||||||
|
RESTORE_SUCCEEDED=true
|
||||||
|
log "恢复完成。建议抽查近期订单与后台登录确认数据正确。"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_backend_healthy() {
|
||||||
|
local attempt container_id status
|
||||||
|
for ((attempt = 1; attempt <= 60; attempt++)); do
|
||||||
|
container_id="$(docker compose ps -q backend 2>/dev/null || true)"
|
||||||
|
if [[ -n "${container_id}" ]]; then
|
||||||
|
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "${container_id}" 2>/dev/null || true)"
|
||||||
|
if [[ "${status}" == "healthy" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
fail "等待 backend 健康超时(120 秒),请检查 docker compose logs backend。"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_postgres_image() {
|
||||||
|
local container_id image
|
||||||
|
container_id="$(docker compose ps -q postgres 2>/dev/null || true)"
|
||||||
|
if [[ -n "${container_id}" ]]; then
|
||||||
|
image="$(docker inspect -f '{{.Config.Image}}' "${container_id}" 2>/dev/null || true)"
|
||||||
|
if [[ -n "${image}" ]]; then
|
||||||
|
printf '%s\n' "${image}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
image="$(docker compose images postgres -q 2>/dev/null | head -n 1 || true)"
|
||||||
|
[[ -n "${image}" ]] || fail "无法确定 postgres 镜像(线上 postgres 未运行)。"
|
||||||
|
printf '%s\n' "${image}"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_backup() {
|
||||||
|
local key="$1" target="$2" image cid attempt
|
||||||
|
|
||||||
|
image="$(resolve_postgres_image)"
|
||||||
|
log "使用镜像 ${image} 启动临时验证容器(不接入任何业务网络)"
|
||||||
|
|
||||||
|
docker rm -f order-site-backup-verify >/dev/null 2>&1 || true
|
||||||
|
cid="$(docker run -d --name order-site-backup-verify -e POSTGRES_PASSWORD=verify-only "${image}")"
|
||||||
|
VERIFY_CONTAINER="order-site-backup-verify"
|
||||||
|
|
||||||
|
for ((attempt = 1; attempt <= 30; attempt++)); do
|
||||||
|
if docker exec "${cid}" pg_isready -U postgres >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
docker exec "${cid}" pg_isready -U postgres >/dev/null 2>&1 || fail "临时验证容器启动超时。"
|
||||||
|
|
||||||
|
log "在临时容器中恢复备份"
|
||||||
|
docker cp "${target}" "${cid}:/tmp/verify.dump" >/dev/null
|
||||||
|
docker exec "${cid}" createdb -U postgres order_site >/dev/null
|
||||||
|
if ! docker exec "${cid}" pg_restore -U postgres -d order_site --no-owner /tmp/verify.dump; then
|
||||||
|
fail "备份无法恢复:pg_restore 报错,该备份不可用。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "备份可恢复。已应用迁移:"
|
||||||
|
docker exec "${cid}" psql -U postgres -d order_site -At -c 'SELECT filename FROM schema_migrations ORDER BY filename'
|
||||||
|
|
||||||
|
log "各表行数:"
|
||||||
|
docker exec "${cid}" psql -U postgres -d order_site -At -F '=' \
|
||||||
|
-c "SELECT format('SELECT %L, count(*) FROM %I;', tablename) FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename \\gexec"
|
||||||
|
|
||||||
|
log "验证通过,已清理临时容器。建议每月执行一次。"
|
||||||
|
docker rm -f -v "${VERIFY_CONTAINER}" >/dev/null 2>&1 || true
|
||||||
|
VERIFY_CONTAINER=""
|
||||||
|
}
|
||||||
|
|
||||||
|
list_backups() {
|
||||||
|
log "OSS 中的数据库备份(key/字节/时间):"
|
||||||
|
run_backup_tool list --prefix "${BACKUP_PREFIX}"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
parse_args "$@"
|
||||||
|
cd "${PROJECT_ROOT}"
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 || fail "未找到 docker。"
|
||||||
|
docker compose version >/dev/null 2>&1 || fail "未找到 docker compose 插件。"
|
||||||
|
|
||||||
|
BACKUP_PREFIX="${DB_BACKUP_OSS_PREFIX:-$(read_env_value DB_BACKUP_OSS_PREFIX || true)}"
|
||||||
|
BACKUP_PREFIX="${BACKUP_PREFIX%/}"
|
||||||
|
BACKUP_PREFIX="${BACKUP_PREFIX:-backups/postgres}"
|
||||||
|
[[ "${BACKUP_PREFIX}" != /* && "${BACKUP_PREFIX}" != *'..'* ]] || fail "DB_BACKUP_OSS_PREFIX 不能是绝对路径或包含 ..。"
|
||||||
|
CONFIG_PREFIX="${BACKUP_PREFIX%/postgres}/configs"
|
||||||
|
|
||||||
|
if [[ "${MODE}" == "list" ]]; then
|
||||||
|
list_backups
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${TMP_DIR}"
|
||||||
|
local key target config_key
|
||||||
|
key="$(resolve_key)"
|
||||||
|
target="${TMP_DIR}/$(basename "${key}")"
|
||||||
|
RESTORE_TARGET="${target}"
|
||||||
|
|
||||||
|
download_backup "${key}" "${target}" postgres-dump
|
||||||
|
|
||||||
|
if [[ "${RESTORE_CONFIGS}" == "true" && "${MODE}" == "restore" ]]; then
|
||||||
|
config_key="$(resolve_config_key)"
|
||||||
|
CONFIG_TARGET="${TMP_DIR}/$(basename "${config_key}")"
|
||||||
|
download_backup "${config_key}" "${CONFIG_TARGET}" app-configs
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${MODE}" == "verify" ]]; then
|
||||||
|
verify_backup "${key}" "${target}"
|
||||||
|
rm -f -- "${target}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if is_external_database; then
|
||||||
|
warn "DATABASE_URL 指向外部数据库:pg_restore 将经网络直连外部库并覆盖其数据,请确认目标正确。"
|
||||||
|
fi
|
||||||
|
restore_live_database "${key}" "${target}" "${CONFIG_TARGET:-}"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap restore_cleanup EXIT
|
||||||
|
main "$@"
|
||||||
@@ -16,6 +16,8 @@ HEALTH_URL=""
|
|||||||
READY_TIMEOUT="${READY_TIMEOUT:-120}"
|
READY_TIMEOUT="${READY_TIMEOUT:-120}"
|
||||||
RESET_DB=false
|
RESET_DB=false
|
||||||
RESET_DB_CONFIRM="${RESET_DB_CONFIRM:-}"
|
RESET_DB_CONFIRM="${RESET_DB_CONFIRM:-}"
|
||||||
|
SKIP_BACKUP="${SKIP_BACKUP:-false}"
|
||||||
|
RESET_PRE_BACKUP_DONE=false
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
printf '\033[1;34m[deploy]\033[0m %s\n' "$*"
|
printf '\033[1;34m[deploy]\033[0m %s\n' "$*"
|
||||||
@@ -38,6 +40,7 @@ print_usage() {
|
|||||||
|
|
||||||
参数:
|
参数:
|
||||||
--reset-db 删除 Compose PostgreSQL 数据卷 postgres_data,再按 migrations 目录全量重建(001 基线 + 全部增量)。
|
--reset-db 删除 Compose PostgreSQL 数据卷 postgres_data,再按 migrations 目录全量重建(001 基线 + 全部增量)。
|
||||||
|
--skip-backup 跳过部署过程中的自动数据库备份(备份失败时确认为可接受后可用它紧急部署)。
|
||||||
--yes 跳过 --reset-db 的交互确认,也可使用环境变量 RESET_DB_CONFIRM=1。
|
--yes 跳过 --reset-db 的交互确认,也可使用环境变量 RESET_DB_CONFIRM=1。
|
||||||
-h, --help 显示帮助。
|
-h, --help 显示帮助。
|
||||||
|
|
||||||
@@ -47,6 +50,8 @@ print_usage() {
|
|||||||
它不会删除 apps/backend/data 下的平台 JSON 配置,也不会删除 Caddy 证书卷。
|
它不会删除 apps/backend/data 下的平台 JSON 配置,也不会删除 Caddy 证书卷。
|
||||||
|
|
||||||
图片存储使用阿里云 OSS(.env 的 STORAGE_OSS_*),生产编排不含 MinIO。
|
图片存储使用阿里云 OSS(.env 的 STORAGE_OSS_*),生产编排不含 MinIO。
|
||||||
|
每次部署在执行数据库迁移前会自动备份到 OSS(deploy/backup-db.sh);
|
||||||
|
--reset-db 在删除数据卷前也会先抓一份最后备份。备份与恢复见 docs/数据库备份与恢复.md。
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +61,9 @@ parse_args() {
|
|||||||
--reset-db)
|
--reset-db)
|
||||||
RESET_DB=true
|
RESET_DB=true
|
||||||
;;
|
;;
|
||||||
|
--skip-backup)
|
||||||
|
SKIP_BACKUP=true
|
||||||
|
;;
|
||||||
--yes)
|
--yes)
|
||||||
RESET_DB_CONFIRM=1
|
RESET_DB_CONFIRM=1
|
||||||
;;
|
;;
|
||||||
@@ -340,12 +348,68 @@ restart_web() {
|
|||||||
fail "Web 启动失败,请根据上面的日志排查。"
|
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() {
|
deploy_stack() {
|
||||||
check_compose_config
|
check_compose_config
|
||||||
pull_base_images
|
pull_base_images
|
||||||
build_images
|
build_images
|
||||||
|
backup_before_reset
|
||||||
reset_database
|
reset_database
|
||||||
start_postgres
|
start_postgres
|
||||||
|
backup_before_migrate
|
||||||
run_database_migrations
|
run_database_migrations
|
||||||
restart_backend
|
restart_backend
|
||||||
restart_web
|
restart_web
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# 数据库备份与恢复
|
||||||
|
|
||||||
|
生产数据库(PostgreSQL,Compose 容器)每日备份到阿里云 OSS,与图片存储共用 OSS 基础设施,但使用独立的 `backups/` 前缀与(建议)独立的 RAM 凭证。
|
||||||
|
|
||||||
|
## 备份内容与存放
|
||||||
|
|
||||||
|
| 内容 | 方式 | OSS key 前缀 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| PostgreSQL 全库 | `pg_dump -Fc`(custom 格式,自带压缩,MVCC 快照无需停机) | `backups/postgres/` |
|
||||||
|
| 平台 JSON 配置(`apps/backend/data`,排除日志/缓存) | `tar -czf` | `backups/configs/` |
|
||||||
|
|
||||||
|
- 每个备份对象附带 `<key>.manifest.json` 清单:大小、sha256、创建时间、数据库版本、已应用迁移列表。没有有效清单的备份默认不会被 `latest` 选择,恢复也会拒绝;只有明确使用 `--allow-unverified` 才兼容历史备份。
|
||||||
|
- 不备份的内容:图片(已在 OSS)、Caddy 证书(自动重新签发)、日志(`apps/backend/data/logs`)。
|
||||||
|
- PostgreSQL dump 是数据库内部一致快照,但数据库与 OSS 不共享事务;备份时刻附近的新图片可能只存在一侧。恢复后应按 `file_assets.object_key` 抽查 OSS 对象,并为图片桶开启版本控制/保留策略。
|
||||||
|
- 服务器本地在 `backups/` 目录保留最近 `DB_BACKUP_KEEP` 份(默认 7),OSS 端过期由桶生命周期规则管理。
|
||||||
|
- 涉及脚本:`deploy/backup-db.sh`(备份)、`deploy/restore-db.sh`(恢复/演练)、后端镜像内 `dist/db-backup.js`(OSS 传输与校验)。
|
||||||
|
|
||||||
|
## 首次开通
|
||||||
|
|
||||||
|
以下步骤在阿里云控制台与服务器上各执行一次。
|
||||||
|
|
||||||
|
### 1. RAM 授权(建议独立,可直接复用现有凭证)
|
||||||
|
|
||||||
|
现有 `STORAGE_OSS_*` 的 RAM 用户拥有整个桶的权限,也能直接跑备份。但备份包含全部订单与用户数据,是最敏感的资产,建议单独创建一个 RAM 用户 `order-site-backup`,只授予 `backups/` 前缀,自定义权限策略:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Version": "1",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["oss:PutObject", "oss:GetObject"],
|
||||||
|
"Resource": ["acs:oss:*:*:你的桶名/backups/*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["oss:HeadBucket"],
|
||||||
|
"Resource": ["acs:oss:*:*:你的桶名"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["oss:ListObjects"],
|
||||||
|
"Resource": ["acs:oss:*:*:你的桶名"],
|
||||||
|
"Condition": {
|
||||||
|
"StringLike": { "oss:Prefix": ["backups/", "backups/*"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
为该用户创建 AccessKey,填入服务器 `.env` 的 `DB_BACKUP_OSS_ACCESS_KEY_ID` / `DB_BACKUP_OSS_SECRET_ACCESS_KEY`(不填则回退 `STORAGE_OSS_*`)。若用独立备份桶,同时覆盖 `DB_BACKUP_OSS_ENDPOINT` / `DB_BACKUP_OSS_BUCKET` / `DB_BACKUP_OSS_REGION`。
|
||||||
|
|
||||||
|
### 2. 桶生命周期规则(控制存储成本)
|
||||||
|
|
||||||
|
在 OSS 控制台为目标桶添加生命周期规则:
|
||||||
|
|
||||||
|
- 前缀 `backups/`:最后一次访问 90 天后删除(按需调整,数据库每日一份,90 份足够覆盖一个季度)。
|
||||||
|
- 建议同时开启版本控制:误删备份时可找回旧版本;给非当前版本配置 7 天过期,避免版本堆积。
|
||||||
|
|
||||||
|
### 3. 手动执行一次,验证链路
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/backup-db.sh
|
||||||
|
bash deploy/restore-db.sh --list
|
||||||
|
bash deploy/restore-db.sh --verify
|
||||||
|
```
|
||||||
|
|
||||||
|
`--verify` 会把最新带有效清单的备份恢复到一个隔离的临时 PostgreSQL 容器(不接入业务网络,不影响线上库),并打印迁移列表与各表行数。建议之后每月执行一次演练——没验证过的备份等于没有备份。
|
||||||
|
|
||||||
|
### 4. 配置每日定时备份
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -e
|
||||||
|
```
|
||||||
|
|
||||||
|
```cron
|
||||||
|
# 每天 03:00 备份数据库与配置到 OSS(目录需先存在,首次开通时脚本已创建)
|
||||||
|
0 3 * * * cd /srv/order_site && bash deploy/backup-db.sh >> backups/backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
把 `/srv/order_site` 换成实际项目目录。日志追加在 `backups/backup.log`,可用 logrotate 管理;备份失败时 cron 不通知,请定期查看该文件,或后续接入告警 webhook。
|
||||||
|
|
||||||
|
## 日常运维
|
||||||
|
|
||||||
|
- **部署自动备份**:`bash deploy/ubuntu-deploy.sh` 在每次执行数据库迁移前会自动备份一次;`--reset-db` 会在删除数据卷前抓最后一份旧库备份,并跳过重置后空库备份,避免空库被误选为 latest。备份失败会中止部署,确认为可接受后可用 `--skip-backup` 继续。
|
||||||
|
- **手动备份**:`bash deploy/backup-db.sh`(可加 `--keep N` 覆盖本地保留份数)。
|
||||||
|
- **查看备份列表**:`bash deploy/restore-db.sh --list`。
|
||||||
|
- **恢复演练**:`bash deploy/restore-db.sh --verify`(可加 `--key` 指定备份)。
|
||||||
|
- **兼容历史备份**:仅在确认对象来源可信时加 `--allow-unverified`,不要作为生产默认参数。
|
||||||
|
|
||||||
|
## 恢复
|
||||||
|
|
||||||
|
### 场景 A:整库恢复到最新备份(最常用)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/restore-db.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
流程:下载最新备份并强制校验 sha256 → 停 backend/web 并确认已停止 → `pg_restore --clean --if-exists` → 执行迁移补差 → 重启并等待健康检查。任一步失败会尝试恢复原先运行的服务,并明确报告数据库可能处于部分恢复状态。执行前需交互输入 `RESTORE` 确认(非交互终端用 `RESTORE_CONFIRM=1` 或 `--yes`)。
|
||||||
|
|
||||||
|
如需同时恢复 `apps/backend/data` 配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/restore-db.sh --restore-configs
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以用 `--config-key` 指定配置归档。配置归档会先校验路径,再解包到 data 目录。
|
||||||
|
|
||||||
|
### 场景 B:恢复到指定时间点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/restore-db.sh --list
|
||||||
|
bash deploy/restore-db.sh --key backups/postgres/order_site-20260823-030000.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 C:服务器全损(数据卷丢失/换机)
|
||||||
|
|
||||||
|
在新服务器完成部署(`docs/部署启动与数据库重建.md`)后,把旧服务器 `.env`(含 OSS 凭证)恢复到新服务器,再执行场景 A。图片不受影响(在 OSS),平台 JSON 配置可从 `backups/configs/order_site-<时间戳>.tar.gz` 解回 `apps/backend/data/`。
|
||||||
|
|
||||||
|
### 场景 D:外部数据库(DATABASE_URL 非 postgres 服务)
|
||||||
|
|
||||||
|
`backup-db.sh` 与 `restore-db.sh` 会自动识别 `.env` 的 `DATABASE_URL`:指向外部库时,从 postgres 容器经网络直连执行 pg_dump/pg_restore,因此服务器仍需有可用的 postgres 镜像/容器作为客户端。恢复外部库前会额外告警确认目标。
|
||||||
|
|
||||||
|
### 恢复后检查
|
||||||
|
|
||||||
|
- 打开后台登录、近期订单列表,抽查数据到备份时间点为止。
|
||||||
|
- `bash deploy/restore-db.sh --list` 确认下一次定时备份已恢复产生新对象。
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
| 现象 | 处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| `[db-backup] HeadBucket 403` | RAM 策略缺 `oss:HeadBucket`,按上文策略补齐 |
|
||||||
|
| `ListObjects 403` | 策略缺 `oss:ListObjects` 或 `oss:Prefix` 条件与实际前缀不匹配 |
|
||||||
|
| `PutObject 403` | `Resource` 里的桶名/前缀与 `.env` 的 `DB_BACKUP_OSS_BUCKET` / `DB_BACKUP_OSS_PREFIX` 不一致 |
|
||||||
|
| `缺少 OSS 配置` | `.env` 里 `DB_BACKUP_OSS_*` 与 `STORAGE_OSS_*` 都为空;改完后备份脚本用的是镜像内程序,若同时改了代码需重新构建 |
|
||||||
|
| 下载校验 `sha256 校验失败` | 备份对象被篡改或截断,换更早的备份重试 |
|
||||||
|
| `pg_restore` 报版本错误 | 不会发生在本编排内(pg_dump/pg_restore 与库同镜像);仅外部库场景需保证客户端 ≥ 服务端大版本 |
|
||||||
|
|
||||||
|
## 安全注意
|
||||||
|
|
||||||
|
- 备份 RAM 凭证只进服务器 `.env`(已 gitignore),不要提交到仓库。
|
||||||
|
- 备份桶/前缀保持私有,不要开公共读写;`backups/` 下的对象不要通过前端暴露。
|
||||||
|
- 若备份桶与图片桶相同,确认图片访问逻辑只读图片前缀,不会枚举 `backups/`。
|
||||||
Reference in New Issue
Block a user