706 lines
21 KiB
JavaScript
706 lines
21 KiB
JavaScript
/**
|
||
* 数据备份服务
|
||
* @file backupService.js
|
||
* @description 提供数据库备份、文件备份和故障恢复功能
|
||
*/
|
||
const fs = require('fs').promises;
|
||
const path = require('path');
|
||
const { spawn } = require('child_process');
|
||
const archiver = require('archiver');
|
||
const { sequelize } = require('../config/database-simple');
|
||
const logger = require('../utils/logger');
|
||
const moment = require('moment');
|
||
|
||
/**
|
||
* 备份配置
|
||
*/
|
||
const BACKUP_CONFIG = {
|
||
// 备份目录
|
||
backupDir: path.join(__dirname, '../../backups'),
|
||
|
||
// 数据库备份配置
|
||
database: {
|
||
host: process.env.DB_HOST || 'localhost',
|
||
port: process.env.DB_PORT || 3306,
|
||
database: process.env.DB_NAME || 'nxxm_farming',
|
||
username: process.env.DB_USER || 'root',
|
||
password: process.env.DB_PASS || ''
|
||
},
|
||
|
||
// 备份保留策略
|
||
retention: {
|
||
daily: 7, // 保留7天的日备份
|
||
weekly: 4, // 保留4周的周备份
|
||
monthly: 12 // 保留12个月的月备份
|
||
},
|
||
|
||
// 压缩级别 (0-9)
|
||
compressionLevel: 6
|
||
};
|
||
|
||
/**
|
||
* 备份服务类
|
||
*/
|
||
class BackupService {
|
||
constructor() {
|
||
this.isBackupRunning = false;
|
||
this.backupQueue = [];
|
||
this.initBackupDirectory();
|
||
}
|
||
|
||
/**
|
||
* 初始化备份目录
|
||
*/
|
||
async initBackupDirectory() {
|
||
try {
|
||
await fs.mkdir(BACKUP_CONFIG.backupDir, { recursive: true });
|
||
await fs.mkdir(path.join(BACKUP_CONFIG.backupDir, 'database'), { recursive: true });
|
||
await fs.mkdir(path.join(BACKUP_CONFIG.backupDir, 'files'), { recursive: true });
|
||
await fs.mkdir(path.join(BACKUP_CONFIG.backupDir, 'logs'), { recursive: true });
|
||
|
||
logger.info('备份目录初始化完成');
|
||
} catch (error) {
|
||
logger.error('备份目录初始化失败:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 执行完整系统备份
|
||
* @param {Object} options 备份选项
|
||
* @returns {Object} 备份结果
|
||
*/
|
||
async createFullBackup(options = {}) {
|
||
if (this.isBackupRunning) {
|
||
throw new Error('备份正在进行中,请稍后再试');
|
||
}
|
||
|
||
this.isBackupRunning = true;
|
||
const backupId = this.generateBackupId();
|
||
const backupPath = path.join(BACKUP_CONFIG.backupDir, backupId);
|
||
|
||
try {
|
||
logger.info(`开始创建完整备份: ${backupId}`);
|
||
|
||
await fs.mkdir(backupPath, { recursive: true });
|
||
|
||
const backupResult = {
|
||
id: backupId,
|
||
timestamp: new Date(),
|
||
type: 'full',
|
||
status: 'in_progress',
|
||
components: {
|
||
database: { status: 'pending', size: 0, path: '' },
|
||
files: { status: 'pending', size: 0, path: '' },
|
||
logs: { status: 'pending', size: 0, path: '' }
|
||
},
|
||
totalSize: 0,
|
||
duration: 0
|
||
};
|
||
|
||
const startTime = Date.now();
|
||
|
||
// 1. 备份数据库
|
||
logger.info('开始备份数据库...');
|
||
const dbBackupResult = await this.backupDatabase(backupPath);
|
||
backupResult.components.database = dbBackupResult;
|
||
|
||
// 2. 备份文件(上传文件、配置文件等)
|
||
logger.info('开始备份文件...');
|
||
const fileBackupResult = await this.backupFiles(backupPath);
|
||
backupResult.components.files = fileBackupResult;
|
||
|
||
// 3. 备份日志
|
||
logger.info('开始备份日志...');
|
||
const logBackupResult = await this.backupLogs(backupPath);
|
||
backupResult.components.logs = logBackupResult;
|
||
|
||
// 4. 创建压缩包
|
||
logger.info('开始压缩备份文件...');
|
||
const archivePath = await this.createArchive(backupPath, backupId);
|
||
|
||
// 5. 计算总大小和持续时间
|
||
const archiveStats = await fs.stat(archivePath);
|
||
backupResult.totalSize = archiveStats.size;
|
||
backupResult.duration = Date.now() - startTime;
|
||
backupResult.status = 'completed';
|
||
backupResult.archivePath = archivePath;
|
||
|
||
// 6. 清理临时目录
|
||
await this.cleanupDirectory(backupPath);
|
||
|
||
// 7. 保存备份元数据
|
||
await this.saveBackupMetadata(backupResult);
|
||
|
||
logger.info(`完整备份创建完成: ${backupId}, 大小: ${this.formatSize(backupResult.totalSize)}, 用时: ${backupResult.duration}ms`);
|
||
|
||
return backupResult;
|
||
} catch (error) {
|
||
logger.error(`创建备份失败: ${backupId}`, error);
|
||
|
||
// 清理失败的备份
|
||
try {
|
||
await this.cleanupDirectory(backupPath);
|
||
} catch (cleanupError) {
|
||
logger.error('清理失败备份目录出错:', cleanupError);
|
||
}
|
||
|
||
throw error;
|
||
} finally {
|
||
this.isBackupRunning = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 备份数据库
|
||
* @param {string} backupPath 备份路径
|
||
* @returns {Object} 备份结果
|
||
*/
|
||
async backupDatabase(backupPath) {
|
||
return new Promise((resolve, reject) => {
|
||
const timestamp = moment().format('YYYYMMDD_HHmmss');
|
||
const filename = `database_${timestamp}.sql`;
|
||
const outputPath = path.join(backupPath, filename);
|
||
|
||
const mysqldumpArgs = [
|
||
'-h', BACKUP_CONFIG.database.host,
|
||
'-P', BACKUP_CONFIG.database.port,
|
||
'-u', BACKUP_CONFIG.database.username,
|
||
`--password=${BACKUP_CONFIG.database.password}`,
|
||
'--single-transaction',
|
||
'--routines',
|
||
'--triggers',
|
||
'--set-gtid-purged=OFF',
|
||
BACKUP_CONFIG.database.database
|
||
];
|
||
|
||
const mysqldump = spawn('mysqldump', mysqldumpArgs);
|
||
const writeStream = require('fs').createWriteStream(outputPath);
|
||
|
||
mysqldump.stdout.pipe(writeStream);
|
||
|
||
let errorOutput = '';
|
||
mysqldump.stderr.on('data', (data) => {
|
||
errorOutput += data.toString();
|
||
});
|
||
|
||
mysqldump.on('close', async (code) => {
|
||
try {
|
||
if (code === 0) {
|
||
const stats = await fs.stat(outputPath);
|
||
resolve({
|
||
status: 'completed',
|
||
size: stats.size,
|
||
path: filename,
|
||
duration: Date.now() - Date.now()
|
||
});
|
||
} else {
|
||
reject(new Error(`mysqldump失败,退出代码: ${code}, 错误: ${errorOutput}`));
|
||
}
|
||
} catch (error) {
|
||
reject(error);
|
||
}
|
||
});
|
||
|
||
mysqldump.on('error', (error) => {
|
||
reject(new Error(`mysqldump执行失败: ${error.message}`));
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 备份文件
|
||
* @param {string} backupPath 备份路径
|
||
* @returns {Object} 备份结果
|
||
*/
|
||
async backupFiles(backupPath) {
|
||
try {
|
||
const fileBackupPath = path.join(backupPath, 'files');
|
||
await fs.mkdir(fileBackupPath, { recursive: true });
|
||
|
||
// 备份上传文件(如果存在)
|
||
const uploadsDir = path.join(__dirname, '../../uploads');
|
||
try {
|
||
await fs.access(uploadsDir);
|
||
await this.copyDirectory(uploadsDir, path.join(fileBackupPath, 'uploads'));
|
||
} catch (error) {
|
||
// 上传目录不存在,跳过
|
||
logger.warn('上传目录不存在,跳过文件备份');
|
||
}
|
||
|
||
// 备份配置文件
|
||
const configFiles = [
|
||
path.join(__dirname, '../config'),
|
||
path.join(__dirname, '../package.json'),
|
||
path.join(__dirname, '../.env')
|
||
];
|
||
|
||
for (const configPath of configFiles) {
|
||
try {
|
||
await fs.access(configPath);
|
||
const basename = path.basename(configPath);
|
||
const destPath = path.join(fileBackupPath, basename);
|
||
|
||
const stats = await fs.stat(configPath);
|
||
if (stats.isDirectory()) {
|
||
await this.copyDirectory(configPath, destPath);
|
||
} else {
|
||
await fs.copyFile(configPath, destPath);
|
||
}
|
||
} catch (error) {
|
||
logger.warn(`配置文件 ${configPath} 备份跳过:`, error.message);
|
||
}
|
||
}
|
||
|
||
const backupStats = await this.getDirectorySize(fileBackupPath);
|
||
|
||
return {
|
||
status: 'completed',
|
||
size: backupStats.size,
|
||
path: 'files',
|
||
fileCount: backupStats.fileCount
|
||
};
|
||
} catch (error) {
|
||
logger.error('文件备份失败:', error);
|
||
return {
|
||
status: 'failed',
|
||
error: error.message,
|
||
size: 0,
|
||
path: ''
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 备份日志
|
||
* @param {string} backupPath 备份路径
|
||
* @returns {Object} 备份结果
|
||
*/
|
||
async backupLogs(backupPath) {
|
||
try {
|
||
const logBackupPath = path.join(backupPath, 'logs');
|
||
await fs.mkdir(logBackupPath, { recursive: true });
|
||
|
||
const logsDir = path.join(__dirname, '../logs');
|
||
|
||
try {
|
||
await fs.access(logsDir);
|
||
await this.copyDirectory(logsDir, logBackupPath);
|
||
} catch (error) {
|
||
logger.warn('日志目录不存在,跳过日志备份');
|
||
return {
|
||
status: 'skipped',
|
||
size: 0,
|
||
path: '',
|
||
message: '日志目录不存在'
|
||
};
|
||
}
|
||
|
||
const backupStats = await this.getDirectorySize(logBackupPath);
|
||
|
||
return {
|
||
status: 'completed',
|
||
size: backupStats.size,
|
||
path: 'logs',
|
||
fileCount: backupStats.fileCount
|
||
};
|
||
} catch (error) {
|
||
logger.error('日志备份失败:', error);
|
||
return {
|
||
status: 'failed',
|
||
error: error.message,
|
||
size: 0,
|
||
path: ''
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建压缩包
|
||
* @param {string} sourcePath 源路径
|
||
* @param {string} backupId 备份ID
|
||
* @returns {string} 压缩包路径
|
||
*/
|
||
async createArchive(sourcePath, backupId) {
|
||
return new Promise((resolve, reject) => {
|
||
const archivePath = path.join(BACKUP_CONFIG.backupDir, `${backupId}.zip`);
|
||
const output = require('fs').createWriteStream(archivePath);
|
||
const archive = archiver('zip', { zlib: { level: BACKUP_CONFIG.compressionLevel } });
|
||
|
||
output.on('close', () => {
|
||
logger.info(`压缩包创建完成: ${archivePath}, 大小: ${archive.pointer()} bytes`);
|
||
resolve(archivePath);
|
||
});
|
||
|
||
archive.on('error', (error) => {
|
||
reject(error);
|
||
});
|
||
|
||
archive.pipe(output);
|
||
archive.directory(sourcePath, false);
|
||
archive.finalize();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 复制目录
|
||
* @param {string} src 源目录
|
||
* @param {string} dest 目标目录
|
||
*/
|
||
async copyDirectory(src, dest) {
|
||
await fs.mkdir(dest, { recursive: true });
|
||
const entries = await fs.readdir(src, { withFileTypes: true });
|
||
|
||
for (const entry of entries) {
|
||
const srcPath = path.join(src, entry.name);
|
||
const destPath = path.join(dest, entry.name);
|
||
|
||
if (entry.isDirectory()) {
|
||
await this.copyDirectory(srcPath, destPath);
|
||
} else {
|
||
await fs.copyFile(srcPath, destPath);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取目录大小
|
||
* @param {string} dirPath 目录路径
|
||
* @returns {Object} 大小统计
|
||
*/
|
||
async getDirectorySize(dirPath) {
|
||
let totalSize = 0;
|
||
let fileCount = 0;
|
||
|
||
try {
|
||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||
|
||
for (const entry of entries) {
|
||
const fullPath = path.join(dirPath, entry.name);
|
||
|
||
if (entry.isDirectory()) {
|
||
const subStats = await this.getDirectorySize(fullPath);
|
||
totalSize += subStats.size;
|
||
fileCount += subStats.fileCount;
|
||
} else {
|
||
const stats = await fs.stat(fullPath);
|
||
totalSize += stats.size;
|
||
fileCount++;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.warn(`获取目录大小失败: ${dirPath}`, error);
|
||
}
|
||
|
||
return { size: totalSize, fileCount };
|
||
}
|
||
|
||
/**
|
||
* 清理目录
|
||
* @param {string} dirPath 目录路径
|
||
*/
|
||
async cleanupDirectory(dirPath) {
|
||
try {
|
||
await fs.rmdir(dirPath, { recursive: true });
|
||
} catch (error) {
|
||
logger.warn(`清理目录失败: ${dirPath}`, error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成备份ID
|
||
* @returns {string} 备份ID
|
||
*/
|
||
generateBackupId() {
|
||
const timestamp = moment().format('YYYYMMDD_HHmmss');
|
||
const random = Math.random().toString(36).substring(2, 8);
|
||
return `backup_${timestamp}_${random}`;
|
||
}
|
||
|
||
/**
|
||
* 保存备份元数据
|
||
* @param {Object} backupResult 备份结果
|
||
*/
|
||
async saveBackupMetadata(backupResult) {
|
||
try {
|
||
const metadataPath = path.join(BACKUP_CONFIG.backupDir, 'metadata.json');
|
||
|
||
let metadata = [];
|
||
try {
|
||
const existing = await fs.readFile(metadataPath, 'utf8');
|
||
metadata = JSON.parse(existing);
|
||
} catch (error) {
|
||
// 文件不存在或格式错误,使用空数组
|
||
}
|
||
|
||
metadata.unshift(backupResult);
|
||
|
||
// 只保留最近100条记录
|
||
if (metadata.length > 100) {
|
||
metadata = metadata.slice(0, 100);
|
||
}
|
||
|
||
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2));
|
||
logger.info('备份元数据已保存');
|
||
} catch (error) {
|
||
logger.error('保存备份元数据失败:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取备份列表
|
||
* @returns {Array} 备份列表
|
||
*/
|
||
async getBackupList() {
|
||
try {
|
||
const metadataPath = path.join(BACKUP_CONFIG.backupDir, 'metadata.json');
|
||
|
||
try {
|
||
const data = await fs.readFile(metadataPath, 'utf8');
|
||
return JSON.parse(data);
|
||
} catch (error) {
|
||
return [];
|
||
}
|
||
} catch (error) {
|
||
logger.error('获取备份列表失败:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除备份
|
||
* @param {string} backupId 备份ID
|
||
* @returns {boolean} 删除结果
|
||
*/
|
||
async deleteBackup(backupId) {
|
||
try {
|
||
const archivePath = path.join(BACKUP_CONFIG.backupDir, `${backupId}.zip`);
|
||
|
||
try {
|
||
await fs.unlink(archivePath);
|
||
logger.info(`备份文件已删除: ${archivePath}`);
|
||
} catch (error) {
|
||
logger.warn(`备份文件删除失败或不存在: ${archivePath}`);
|
||
}
|
||
|
||
// 更新元数据
|
||
const metadata = await this.getBackupList();
|
||
const updatedMetadata = metadata.filter(backup => backup.id !== backupId);
|
||
|
||
const metadataPath = path.join(BACKUP_CONFIG.backupDir, 'metadata.json');
|
||
await fs.writeFile(metadataPath, JSON.stringify(updatedMetadata, null, 2));
|
||
|
||
return true;
|
||
} catch (error) {
|
||
logger.error(`删除备份失败: ${backupId}`, error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 自动清理过期备份
|
||
*/
|
||
async cleanupExpiredBackups() {
|
||
try {
|
||
const backups = await this.getBackupList();
|
||
const now = moment();
|
||
let deletedCount = 0;
|
||
|
||
for (const backup of backups) {
|
||
const backupDate = moment(backup.timestamp);
|
||
const daysDiff = now.diff(backupDate, 'days');
|
||
|
||
let shouldDelete = false;
|
||
|
||
// 根据备份类型和时间决定是否删除
|
||
if (backup.type === 'daily' && daysDiff > BACKUP_CONFIG.retention.daily) {
|
||
shouldDelete = true;
|
||
} else if (backup.type === 'weekly' && daysDiff > (BACKUP_CONFIG.retention.weekly * 7)) {
|
||
shouldDelete = true;
|
||
} else if (backup.type === 'monthly' && daysDiff > (BACKUP_CONFIG.retention.monthly * 30)) {
|
||
shouldDelete = true;
|
||
} else if (backup.type === 'full' && daysDiff > BACKUP_CONFIG.retention.daily) {
|
||
// 完整备份使用日备份的保留策略
|
||
shouldDelete = true;
|
||
}
|
||
|
||
if (shouldDelete) {
|
||
const deleted = await this.deleteBackup(backup.id);
|
||
if (deleted) {
|
||
deletedCount++;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (deletedCount > 0) {
|
||
logger.info(`自动清理完成,删除了 ${deletedCount} 个过期备份`);
|
||
}
|
||
|
||
return deletedCount;
|
||
} catch (error) {
|
||
logger.error('自动清理过期备份失败:', error);
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 恢复数据库
|
||
* @param {string} backupId 备份ID
|
||
* @returns {boolean} 恢复结果
|
||
*/
|
||
async restoreDatabase(backupId) {
|
||
try {
|
||
const backups = await this.getBackupList();
|
||
const backup = backups.find(b => b.id === backupId);
|
||
|
||
if (!backup) {
|
||
throw new Error('备份不存在');
|
||
}
|
||
|
||
const archivePath = path.join(BACKUP_CONFIG.backupDir, `${backupId}.zip`);
|
||
|
||
// 这里可以实现数据库恢复逻辑
|
||
// 由于涉及到解压和执行SQL脚本,需要谨慎实现
|
||
logger.info(`数据库恢复功能需要进一步实现: ${backupId}`);
|
||
|
||
return true;
|
||
} catch (error) {
|
||
logger.error(`恢复数据库失败: ${backupId}`, error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取备份统计
|
||
* @returns {Object} 统计信息
|
||
*/
|
||
async getBackupStats() {
|
||
try {
|
||
const backups = await this.getBackupList();
|
||
|
||
const stats = {
|
||
total: backups.length,
|
||
totalSize: backups.reduce((sum, backup) => sum + (backup.totalSize || 0), 0),
|
||
byType: {
|
||
full: backups.filter(b => b.type === 'full').length,
|
||
daily: backups.filter(b => b.type === 'daily').length,
|
||
weekly: backups.filter(b => b.type === 'weekly').length,
|
||
monthly: backups.filter(b => b.type === 'monthly').length
|
||
},
|
||
byStatus: {
|
||
completed: backups.filter(b => b.status === 'completed').length,
|
||
failed: backups.filter(b => b.status === 'failed').length,
|
||
inProgress: backups.filter(b => b.status === 'in_progress').length
|
||
},
|
||
latest: backups[0] || null,
|
||
oldestRetained: backups[backups.length - 1] || null
|
||
};
|
||
|
||
return stats;
|
||
} catch (error) {
|
||
logger.error('获取备份统计失败:', error);
|
||
return {
|
||
total: 0,
|
||
totalSize: 0,
|
||
byType: {},
|
||
byStatus: {},
|
||
latest: null,
|
||
oldestRetained: null
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 格式化文件大小
|
||
* @param {number} bytes 字节数
|
||
* @returns {string} 格式化的大小
|
||
*/
|
||
formatSize(bytes) {
|
||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
if (bytes === 0) return '0 B';
|
||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||
}
|
||
|
||
/**
|
||
* 检查备份系统健康状态
|
||
* @returns {Object} 健康状态
|
||
*/
|
||
async checkBackupHealth() {
|
||
try {
|
||
const stats = await this.getBackupStats();
|
||
const diskUsage = await this.getDiskUsage();
|
||
|
||
const health = {
|
||
status: 'healthy',
|
||
issues: [],
|
||
stats,
|
||
diskUsage,
|
||
lastBackup: stats.latest,
|
||
recommendations: []
|
||
};
|
||
|
||
// 检查磁盘空间
|
||
if (diskUsage.usagePercent > 90) {
|
||
health.status = 'warning';
|
||
health.issues.push('磁盘空间不足(使用率超过90%)');
|
||
health.recommendations.push('清理过期备份或扩展存储空间');
|
||
}
|
||
|
||
// 检查最近备份时间
|
||
if (stats.latest) {
|
||
const daysSinceLastBackup = moment().diff(moment(stats.latest.timestamp), 'days');
|
||
if (daysSinceLastBackup > 1) {
|
||
health.status = 'warning';
|
||
health.issues.push(`最近备份时间过久(${daysSinceLastBackup}天前)`);
|
||
health.recommendations.push('建议执行新的备份');
|
||
}
|
||
} else {
|
||
health.status = 'error';
|
||
health.issues.push('没有找到任何备份记录');
|
||
health.recommendations.push('立即创建第一个备份');
|
||
}
|
||
|
||
return health;
|
||
} catch (error) {
|
||
logger.error('检查备份健康状态失败:', error);
|
||
return {
|
||
status: 'error',
|
||
issues: ['无法检查备份状态'],
|
||
error: error.message
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取磁盘使用情况
|
||
* @returns {Object} 磁盘使用情况
|
||
*/
|
||
async getDiskUsage() {
|
||
try {
|
||
const stats = await fs.stat(BACKUP_CONFIG.backupDir);
|
||
// 简化的磁盘使用情况检查
|
||
// 在实际环境中可以使用更精确的磁盘空间检查
|
||
return {
|
||
total: 100 * 1024 * 1024 * 1024, // 假设100GB
|
||
used: stats.size || 0,
|
||
free: 100 * 1024 * 1024 * 1024 - (stats.size || 0),
|
||
usagePercent: ((stats.size || 0) / (100 * 1024 * 1024 * 1024)) * 100
|
||
};
|
||
} catch (error) {
|
||
logger.error('获取磁盘使用情况失败:', error);
|
||
return {
|
||
total: 0,
|
||
used: 0,
|
||
free: 0,
|
||
usagePercent: 0
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
// 创建单例实例
|
||
const backupService = new BackupService();
|
||
|
||
module.exports = backupService;
|