笔者最近在 OpenWRT 软路由上部署了一个 Minecraft 服务器,出于对数据安全的焦虑,于是折腾了一下存档备份的相关事宜,记录为此文。
在 CurseForge 等模组站上已有方便好用的 Minecraft 服务器存档备份插件,除非您喜欢折腾或高自由度的定制,不用像笔者这样编写一整个脚本。
完整的脚本可见此 。
编写备份脚本 前置准备 为了脚本编写方便,约定应该在 Minecraft 服务器的根目录执行脚本。校验当前脚本的执行目录:
1 2 3 4 5 6 const cwd = process.cwd ();if (!fs.existsSync (path.resolve (cwd, "eula.txt" ))) { throw new Error ( "You should execute this script at root dir of MineCraft server where `eula.txt` exists." , ); }
支持指定备份文件存储的目录 BACKUP_DIR
,同时 checkupDir()
确保目录存在:
1 2 3 4 5 6 7 8 9 10 const BACKUP_DIR = "backups" ;const checkupDir = (dir ) => { if (!fs.existsSync (dir)) { fs.mkdirSync (dir, { recursive : true }); } }; const backupDir = path.resolve (cwd, BACKUP_DIR );checkupDir (backupDir);
生成备份文件 支持指定备份文件的列表,除了最重要的 world/
以外,还可以备份 server.properties
、world_nether/
和 world_the_end/
等文件或目录。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 const BACKUP_FILES = [ "banned-ips.json" , "banned-players.json" , "config" , "mods" , "ops.json" , "server.properties" , "whitelist.json" , "world" , "world_nether" , "world_the_end" , ]; const resolvedBackupFiles = BACKUP_FILES .filter ((file ) => { if (fs.existsSync (path.resolve (cwd, file))) { return true ; } return false ; });
原生 Node 并没有提供打包压缩的方法,为了避免引入其它的依赖,考虑使用系统自带的 tar
命令实现。为此,需要使用到 Node 的 child_process
:
1 2 3 const child_process = require ("child_process" );const util = require ("util" );const exec = util.promisify (child_process.exec );
现在,可以通过 exec()
来执行系统上的命令了。可编写文件备份方法如下:
1 2 const backupFilename = genFilename (); await exec (`tar -czf ${backupFilename} ${resolvedBackupFiles.join(" " )} ` );
需注意的是,我们在执行 tar
命令时,常传入 -v
标识,在屏幕上打印压缩或解压的文件列表(很酷)。但是,在使用 exec()
时,子进程会将命令的标准输出或错误一并返回,如果文件数量过多,标准输出超过预设大小,会导致报错:RangeError [ERR_CHILD_PROCESS_STDIO_MAXBUFFER]: stdout maxBuffer length exceeded
。用 child_process.spawn()
可以避免这个问题,但考虑到我们并不在乎压缩命令的执行过程,最好的办法就是去掉 -v
标识。
到此为止,已经能够将所需的 Minecraft 服务器存档文件打包压缩,备份到系统本地了。
移除历史备份文件 即使每天执行一次备份任务,长久累积也将占用大量的空间,更何况一个备份的大小已然几百 MB 起步。因此需考虑本地保留的备份文件数量,及时移除历史的备份文件。
首先需要获取备份目录下已有的备份文件信息:
1 2 3 4 const filenames = fs.readdirSync (backupDir);const backupFileList = filenames.map ((filename ) => fs.statSync (path.resolve (backupDir, filename)), );
支持指定保存的备份文件数量,得到需要移除的文件列表:
1 2 3 4 5 6 7 8 const BACKUP_MAX_NUM = 7 ; const backupFiles = backupFileList .filter ((file ) => file.isFile ()) .sort ((a, b ) => { return b.mtimeMs - a.mtimeMs ; }); const oldBackupFiles = backupFiles.slice (BACKUP_MAX_NUM );
移除这些文件即可:
1 2 3 4 const oldBackupFilenames = oldBackupFiles.map ((file ) => file.name );oldBackupFilenames.forEach ((filename ) => { fs.rmSync (path.resolve (backupDir, filename)); });
这样在每次执行脚本时,都会自动清理掉本地多余的备份文件,保证文件系统容量健康。
设置定时任务 基于 crontab
实现定时任务调度,使用 crontab -e
命令编写任务列表:
1 0 4 * * * cd /path/to/mc-server && node /path/to/backup-mc-server.js
笔者发现定时任务实际执行时间是正午 12 点,而非预期的凌晨 4 点,推测系服务器使用的 UTC 时区导致。
尽管配置了 OpenWRT 的时区为 Asia/Shanghai
,但仍然不生效:
1 2 $ date -R Wed, 15 May 2024 07:00:00 +0000
笔者通过安装 zoneinfo-asia
解决了问题:
1 2 3 4 5 6 7 8 9 10 11 $ opkg update $ opkg install zoneinfo-asia Installing zoneinfo-asia (2023c-2) to root... Downloading https://mirrors.vsean.net/openwrt/releases/23.05.2/packages/x86_64/packages/zoneinfo-asia_2023c-2_x86_64.ipk Installing zoneinfo-core (2023c-2) to root... Downloading https://mirrors.vsean.net/openwrt/releases/23.05.2/packages/x86_64/packages/zoneinfo-core_2023c-2_x86_64.ipk Configuring zoneinfo-core. Configuring zoneinfo-asia. $ /etc/init.d/system restart $ date -R Wed, 15 May 2024 15:00:00 +0800
这样,在北京时间凌晨 4 点,系统将自动调用备份脚本。如果彼时仍有用户在游玩,脚本可能会运行失败,可以在执行脚本之前关闭 Minecraft 服务器,完成后重新启动。
(可选)通过 Alist 上传到云端 为了这盘醋,包了这顿饺子。
万一硬盘挂了呢?笔者认为保存在软路由本地丝毫没有安全感,于是决定在备份后即时上传到云端。
笔者已经在软路由上安装并配置好了 Alist,连接到了自己的 OneDrive。下面将进一步实现上传备份文件到 OneDrive 或任何其他的云盘。
获取 Alist token 调用 Alist 接口时需要传入 token,因此首先需要获取 token:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 const ALIST_ADDRESS = "YOUR_ALIST_ADDRESS" ;const ALIST_USERNAME = "YOUR_ALIST_USERNAME" ;const ALIST_PASSWORD = "YOUR_ALIST_PASSWORD" ;const headers = new Headers ();headers.append ("Content-Type" , "application/json" ); const raw = JSON .stringify ({ username : ALIST_USERNAME , password : ALIST_PASSWORD , }); const requestOptions = { method : "POST" , headers, body : raw, redirect : "follow" , }; const res = await fetch (`${ALIST_ADDRESS} /api/auth/login` , requestOptions);const resText = await res.text ();const resObj = JSON .parse (resText);const alistToken = resObj.data .token ;
上传备份文件到云盘 现在,编写 Alist 上传文件的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 const ALIST_BACKUP_DIR = "/path/to/mc-backups" ;const backupFile = fs.statSync (backupFilename);const backupFileBasename = path.basename (backupFilename);const alistFilePath = path.resolve (ALIST_BACKUP_DIR , backupFileBasename);const headers = new Headers ();headers.append ("Authorization" , alistToken); headers.append ("As-Task" , "true" ); headers.append ("Content-Length" , `${backupFile.size} ` ); headers.append ("File-Path" , encodeURIComponent (alistFilePath)); const fileStream = fs.createReadStream (backupFilename);const requestOptions = { method : "PUT" , headers, body : fileStream, redirect : "follow" , duplex : "half" , }; await fetch (`${ALIST_ADDRESS} /api/fs/put` , requestOptions);
通过 headers.append("As-Task", "true");
将文件上传设为任务,避免阻塞其它命令的执行。在 Alist 管理后台可以看到上传的进度:
到这一步,执行备份脚本时,将自动把新生成的备份文件上传到云盘。
移除云盘历史备份文件 同样,云盘的空间也不是无限的,我们采取与移除本地历史备份文件相同的策略。
首先获取云盘上已有的备份文件列表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 const headers = new Headers ();headers.append ("Authorization" , alistToken); headers.append ("Content-Type" , "application/json" ); const raw = JSON .stringify ({ path : ALIST_BACKUP_DIR , }); const requestOptions = { method : "POST" , headers : headers, body : raw, redirect : "follow" , }; const res = await fetch (`${ALIST_ADDRESS} /api/fs/list` , requestOptions);const resText = await res.text ();const resObj = JSON .parse (resText);const backupDirFileList = resObj.data .content || [];
获取需要移除的备份文件列表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const backupFiles = backupDirFileList .filter ((file ) => !file.is_dir ) .sort ((a, b ) => { if (a.modified > b.modified ) { return -1 ; } else if (a.modified < b.modified ) { return 1 ; } else { return 0 ; } }); const oldBackupFiles = backupFiles.slice (BACKUP_MAX_NUM - 1 );const oldBackupFilenames = oldBackupFiles.map ((file ) => file.name );
最后,移除这些文件即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 const headers = new Headers ();headers.append ("Authorization" , alistToken); headers.append ("Content-Type" , "application/json" ); const raw = JSON .stringify ({ names : oldBackupFilenames, dir : ALIST_BACKUP_DIR , }); const requestOptions = { method : "POST" , headers : headers, body : raw, redirect : "follow" , }; await fetch (`${ALIST_ADDRESS} /api/fs/remove` , requestOptions);
这样在每次执行脚本时,云盘的系统容量健康也得到了保障。
结尾 稍微润色优化一下备份脚本,执行的输出结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 $ node /path/to/backup-mc-server.js Create dir `/path/to/mc-server/backups` successfully. Creating backup file `/path/to/mc-server/backups/backup-mcserver-2024-05-11-11-10-51.tar.gz` ... Create backup file `/path/to/mc-server/backups/backup-mcserver-2024-05-11-11-10-51.tar.gz` successfully. Log in alist successfully. Start upload task successfully: local file `/path/to/mc-server/backups/backup-mcserver-2024-05-11-11-10-51.tar.gz` ==> alist `/path/to/alist/backups/backup-mcserver-2024-05-11-11-10-51.tar.gz`. =========================== Backup file is generated: true Old backup files are removed: true Task that upload backup file to alist is started: true Old backup files in alist are removed: true ===========================
啊,满满的安心感!收工。