记录一次MongoDB数据备份与技术方案的抉择,了解一下「全栈」的魅力:)
文章首发:fujia.site
前言
随着最近几次大的更新,fujia.site这个个人站点终于可以见人了,主要更新包括:支持GitHub登录、分页组件的优化、添加广告以及开启HTTP2等等。一切看上去还不错,一个维护了5年的个人站点真正有点东西了。
让小编感到不安是什么时候呢?是随着博文的页码数突破10的时候,这意味着小编一点点已经积攒了100多篇文章。你开始不得不去思考一个问题,对一个内容网站(或任何网站)来说,真正的核心是什么?对,是数据。
小编知道,网站崩溃或服务器数据被清空的可能性微乎其微,但是,万一呢?万一网站受到某个萌萌哒的黑客入侵了,删除了所有的数据。那么真正的损失是什么?你如何快速(如:10分钟)恢复站点运营?
损失的是源码吗?不是的,源码已经托管在gitee的私库上了,站点部署也不过是一条命令的事情。多说一句,大部分源码是3年前写的,如今去看总觉得有些辣眼睛,每次改动都忍着恶心一点点重构。当然,要小编整体重构是不存在的,将就着用吧!
真正的损失是哪一篇篇小编一个字一个字敲出来的,然后变成了一条条数据的文章。 一旦损失,基本很难复原,只能哭了。
站点安全下的数据安全保障就成了一个势在必行的行动,只有有了基础的数据安全上的保障,即数据库备份,这个站点才具备真正的完备性。
方案选型
在讨论方案之前,我们先来看下实现后的结果(如下图),站点会在每周三和周日的凌晨3点做一次备份,备份完成后将数据自动上传到腾讯云的OSS上。
事实上,在做数据备份之前,小编一度以为这是一个很简单的功能,可以很快实现。但当真正操作起来,各种各样的问题都冒出来了。
了解下服务端的环境和架构:
环境说明:
- server OS: Ubuntu 20.04.2 LTS (GNU/Linux 5.4.0-72-generic x86_64);
- docker: version 20.10.2;
- mognodb: version 4.4;
- egg.js: version 2.29.1。
架构说明:
- 数据库:MongoDB+docker swarm搭建了一个简单的一主二从的复制集;
- 后台服务:egg.js+docker swarm搭建了两个节点的服务集群;
- 代理服务:nginx+docker swarm搭建了两个节点的代理服务集群。
后台服务和数据库之间通过docker network连通,并使用了docker secret对数据库密码加密处理。
基于此,我们来聊聊可行的方案,如下:
1. eggjs自带的schedule
最简单的方式是使用eggjs自带的schedule,它可以简单、快速的实现定时任务。
一种方式是是在后台服务中启用定时任务,这样做的好处是:
- 实现简单,且相关的逻辑都放在了后台服务中,便于管理;
- 定时任务和后台服务是一致的,即当后台服务终止时,定时任务也清除了,这样就不必花费精力去管理定时任务。
那实现的难点是什么呢?
我们知道数据库和后台服务都包裹在docker container中,需要通过跨容器通信。额...,听着有点难!
一种折中的方法是在服务器使用egg.js起一个本地服务来做数据库的备份,方法是可行的,但我们的期望是所有的应用服务都包裹在docker中,节省服务器资源。
2. 手动备份
在站点早期或网站PV较少的情况下,如果想做数据备份,这也是一个不错的选择,手动备份虽然麻烦,但是因为没有必要频繁的备份操作,也可以接受。
MongoDB备份命令如下:
# ubuntu 下安装mongo-tools,若已安装则跳过
sudo apt install mongo-tools
mongodump --uri="mongodb://[用户名]:[密码]@[IP]/[数据库名]" -o [输出目录] --forceTableScan
备注:为什么使用--forceTableScan,见:fujia.site/articles/60…
备份完成后,使用scp命令将文件从服务器上拷贝到本地宿主机,使用man scp查看scp的使用手册。
3. 使用shell + crontab
一般来说,数据备份是「运维工程师」的职责,这也是他们采用的方法,但对小编来说,简单的shell编写还行,复杂点的话,就需要系统深入的学习了,成本太高,这里就不展开了。
4. 使用shell + crontab + node.js
这是目前我们使用的方案:
- shell:做一些自动化的处理;
- crontab:执行定时任务;
- node.js:实现业务逻辑,如:备份、上传OSS以及删除备份等。
这种方案的好处是:
- 不需要太高的学习成本,核心逻辑使用node.js实现;
- 多种技术的最佳组合,对前端来说,有很大的想象空间。
不好的地方是:
- 仍需要学习shell和crontab,相关资料见「参考资料」部分;
- 需要自行管理定时任务,如:服务已停止,需要手动清理定时任务。
对前端工程师来说,将业务功能使用node.js来实现,再与其它技术相结合,可以有很多的玩法,进一步拓宽前端的边界。
实现
服务器需要安装node,推荐使用nvm安装。
实现原理
- 使用shell做一些自动化处理,见代码实现:
- 将项目拷贝从本地拷贝到服务上;
- 执行定时任务。
-
使用crontab制定定时任务,见代码实现。
-
使用node .js完成业务功能,见代码实现。
代码实现
- 使用下面的命令在本地新建一个npm项目:
mkdir tencent-schedule;cd $_;npm init -y
说明:下面所有的文件和文件夹都是相对tencent-schedule来说的。
- 创建两个脚本文件:
scp.sh
#!/bin/bash
ssh -tt -p [端口] [用户名]@[ip] << EOF
if [ ! -d "/home/ubuntu/schedules/dump/mongodb" ]; then
mkdir -p /home/ubuntu/schedules/dump/mongodb
else
rm -rf /home/ubuntu/schedules/dump/mongodb/lib
rm -rf /home/ubuntu/schedules/dump/mongodb/bin
fi
exit
EOF
npm run build
CUR_DIR=$(pwd)
scp -P[端口] -r $CUR_DIR/lib $CUR_DIR/bin $CUR_DIR/package.json $CUR_DIR/dump.sh [用户名]@[ip]:/home/ubuntu/schedules/dump/mongodb
dump.sh
#!/bin/bash
source /etc/profile; mongo-dump --start
编辑/etc/profile,在最后加上下面的语句。
为什么呢?Crontab 有自己的运行环境(/etc/crontab),会自动设置可构成最小环境的环境变量, 不会自动加载当前用户的环境变量!这点很重要,小编在这个地方卡很久,需要注意下。
PATH=$PATH:/home/ubuntu/.nvm/versions/node/v16.14.2/bin
export PATH
- 修改package.json,只列出部分:
{
// ...
"main": "./lib/index.js",
"bin": {
"mongo-dump": "./bin/index.js"
},
"scripts": {
"build": "tsc",
"test": "jest"
},
// ...
}
- 新建bin/index.js
#!/usr/bin/env node
require('../lib');
- 新建src目录,并创建index.ts和config.ts文件,实现如下:
index.ts
import { CommonSpawnOptions, spawn } from 'child_process';
import path from 'path';
import process from 'process';
import COS from 'cos-nodejs-sdk-v5';
import { pathExist } from '@fujia/check-path';
import conf from './config';
const SERVER_DUMP_DIR = '/home/ubuntu/data/mongodb/dump';
const DB_NAME = 'fujiaSite';
const spawnAsync = (
command: string,
args: readonly string[],
options: CommonSpawnOptions
): Promise<number | null> => {
return new Promise((resolve, reject) => {
const cp = spawn(command, args, options);
cp.on('error', (err) => {
reject(err);
});
cp.on('exit', (chunk) => {
resolve(chunk);
});
});
};
const args = process.argv.slice(2);
const isStart = args.includes('--start') ? true : false;
const isDev = args.includes('--dev') ? true : false;
if (!isStart) {
console.warn(
'warning: ',
'invalid options! you should provide "-start" option.'
);
process.exit(0);
}
async function dumpMongoData() {
const cmd = isDev ? 'touch' : 'mongodump';
const descDir = path.join(SERVER_DUMP_DIR);
const cmdArgs = isDev
? [`${SERVER_DUMP_DIR}/test.txt`]
: [
'--uri="mongodb://[用户名]:[密码]@[IP]/[数据库名]"',
`-o ${descDir}`,
'--forceTableScan',
];
isDev && console.log('info: ', `Starting run ${cmd} command`);
const execCode = await spawnAsync(cmd, cmdArgs, {
stdio: 'inherit',
shell: true,
cwd: descDir,
});
if (execCode === 0) {
console.log('dump mongo data successful!');
await zipData();
}
}
async function zipData() {
const now = Date.now();
const tarName = isDev ? `test_${now}.tar.gz` : `${DB_NAME}_${now}.tar.gz`;
const sourceDir = path.join(SERVER_DUMP_DIR, DB_NAME);
const execCode = await spawnAsync('tar', ['-zcvf', tarName, sourceDir], {
stdio: 'inherit',
shell: true,
cwd: SERVER_DUMP_DIR,
});
if (execCode === 0) {
console.log('zip mongo data successful.');
await uploadTencentOSS(tarName);
await delHostData();
}
}
async function uploadTencentOSS(fileName: string) {
const filePath = path.join(SERVER_DUMP_DIR, fileName);
const { cos: cosConf } = conf;
const { datadump } = cosConf;
const { Bucket, Region, uploadDir } = datadump;
if (!(await pathExist(filePath))) return;
const cos = new COS({
SecretId: conf.cos.SecretId,
SecretKey: conf.cos.SecretKey,
});
return new Promise((resolve, reject) => {
cos.uploadFile(
{
Bucket /* 填入您自己的存储桶,必须字段 */,
Region /* 存储桶所在地域,例如ap-beijing,必须字段 */,
Key: `${uploadDir}/${fileName}` /* 存储在桶里的对象键(例如1.jpg,a/b/test.txt),必须字段 */,
FilePath: filePath /* 必须 */,
SliceSize:
1024 * 1024 * 5 /* 触发分块上传的阈值,超过5MB使用分块上传,非必须 */,
onTaskReady: function (taskId) {
/* 非必须 */
console.log(taskId);
},
onProgress: function (progressData) {
/* 非必须 */
console.log(JSON.stringify(progressData));
},
onFileFinish: function (err, data, options) {
console.log(options.Key + '上传' + (err ? '失败' : '完成'));
},
},
function (err, data) {
if (err) {
console.log(err);
reject(err);
return;
}
isDev && console.log('upload data successful!');
resolve(data);
}
);
});
}
async function delHostData() {
const execCode = await spawnAsync('rm', ['-rf', `${SERVER_DUMP_DIR}/*`], {
stdio: 'inherit',
shell: true,
});
if (execCode === 0) {
isDev && console.log('deleted data successful.');
}
}
function main() {
if (isStart) {
console.log('Starting dump mongodb data...');
dumpMongoData();
}
}
main();
config.ts
const defaultConfig = {
cos: {
SecretId: '',
SecretKey: '',
datadump: {
Bucket: '',
Region: '',
uploadDir: '',
},
},
};
export default defaultConfig;
-
执行scp.sh脚本将项目拷贝到服务器上。
-
进入服务器,在项目根目录下执行npm link,使mongo-dump(在package.json中定义的)命令全局可用。
-
使用下面命令编辑crontab定时器
crontab -e
# 插入下面的定时任务
0 3 * * 0,3 bash /home/ubuntu/schedules/dump/mongodb/dump.sh >> /home/ubuntu/dump.out
# 查看定时任务
crontab -l
备注:如果定时命令命令未生效,可以重启下服务sudo service crontab restart .
如此,一个简单的定时数据库备份任务就完成了,整体的逻辑并不复杂。
小结
-
对前端工程师来说,掌握好技术深度和技术广度之间的平衡是非常重要的,稍不小心就会走很大的弯路,这一点,小编是深有体会且吃过大亏。
-
说实话,纯粹的技术前端天花板是相对较低的,作为跟终端最近的工程师,做一个功能完备的产品是大有可能的。但是,这里也不是建议你去走「全栈」的路子,坑是真的多,你需要认真思考,自己技术的前行路径究竟是什么?时间真的过的很快。
-
建议学好node.js,这样就有了和其它技术组合的基础,从而衍生出不同技术玩法,可想象的空间也大得多。
-
不要自我设限,大胆想象,元宇宙和web3.0正在路上,作为前端工程师的我们,且行且看。最重要的是,积极参与并做好准备。
参考资料
-
nvm - github.com/nvm-sh/nvm .
-
shell - www.imooc.com/course/list… .
-
crontab - www.imooc.com/learn/1009 .
大家加油 :)