Lolipop's Studio.

基于原生 Node 备份软路由上的 Minecraft 服务器存档,并通过 Alist 上传到云端

字数统计: 2k阅读时长: 9 min
2024/05/11
loading

笔者最近在 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.propertiesworld_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; // 保留最新的 N 个备份文件,此处为 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 管理后台可以看到上传的进度:

upload-to-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;
}
});

// 由于新的备份文件正在上传中,因此应当保留最新的 BACKUP_MAX_NUM - 1 个备份文件
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
===========================

啊,满满的安心感!收工。

CATALOG
  1. 1. 编写备份脚本
    1. 1.1. 前置准备
    2. 1.2. 生成备份文件
    3. 1.3. 移除历史备份文件
  2. 2. 设置定时任务
  3. 3. (可选)通过 Alist 上传到云端
    1. 3.1. 获取 Alist token
    2. 3.2. 上传备份文件到云盘
    3. 3.3. 移除云盘历史备份文件
  4. 4. 结尾