原来本 blog 是在 GitHub Pages 上静态托管的,奈何速度过慢,CDN 效果一般(也许是我配置问题?)。又不想在托管在 Coding Pages 上,便计划将其通过 持续部署(Continuous Deployment)部署在阿里云的服务器上。

本文介绍两种实现方法,一种是通过 Jenkins 实现,一种是自己通过 Node.js 编码实现。

1. Jenkins 实现

Jenkins 是开源 CI&CD 软件领导者

在 Jenkins 官网上的介绍有这么一句话,简单粗暴地说明了 Jenkins 的用途及其地位。

1.1 Jenkins 安装

首先安装 Jenkins, 其实官方文档都很全面了,跟着做就好了。

这里使用 Docker 部署。对 Docker 不熟练的自行补课。

sudo docker run \
  -u root \
  -d \ 
  -p 9000:8080 \ 
  -v /www/wwwroot/jenkins:/var/jenkins_home \ 
  -v /var/run/docker.sock:/var/run/docker.sock \ 
  jenkinsci/blueocean 

Jenkins 安装完成后,访问其网址( docker run 里 -v 9000:8080 的 9000 就是其端口 )。

第一次进入会要求输入随机生成的解锁密码,访问页面上给出的路径文件得到初始密码,复制进去即可。

接下来是安装插件,使用推荐的设置即可。再创建管理员用户。

至此,Jenkins 的安装完成

1.2 新建 Jenkins 项目 与 配置

来到部署好的 Jenkins 主页

Jenkins主页

点击新建任务,我们的项目很简单,命名后选择 Freestyle Project 就可以了
新建任务

然后配置我们的项目:

描述随便填,下面的 Github 项目打钩,填仓库地址
配置1

下面源码管理同样选择 Git,配置仓库地址和分支。源码库浏览器选择 githubweb,这里的 URL 注意要是 .git 后缀的。构建被触发后 Jenkins 会自动从该地址 clone 源码到 Jenkins 目录下的 workspace/项目名 文件夹,也可以在项目详情中直接访问该文件夹。
配置2

构建触发器这里有两个比较常用,一个是 GiHub hook 触发,一个是轮询仓库,有更新就触发

Github hook 可以在 Github 仓库中配置,当执行某种操作(如push,releases)后就会自动访问指定的 URL,而 Jenkins 的这个 Github hook URL 被访问到后,就会触发构建。

轮询中的定时器语法类似 crontab,例如这里的就是每隔 5 分钟检测一次仓库。本项目我们只用到 Github hook,轮询只是个语法演示
配置3

最后面的构建是用来设置触发构建后执行的操作,因为 Jenkins 会自动 clone 代码,这里就只是简单的输出意思一下
配置4

点击保存,项目新建完成。当然还可以做别的设置,如自动化测试、构建成功\失败的邮件提醒等等等等,这里不做过多演示。

接下来我们去配置仓库的 Github Webhooks,进入仓库的 Settings -> Webhooks -> add webhook。URL 默认是 Jenkins地址/github-webhook/,其他设置默认值即可。

Github仓库Webhooks配置

尝试对仓库提交更新,若配置无误,下方的 Recent Deliveries 会显示 hook 的记录,并且左边有个绿勾表示成功执行,同样在 commits 里也能看到提交后面的绿勾,表示这次提交已经被成功 hook

Recent Deliveries

接下来回到 Jenkins,就会看到左下角的构建队列正在进行构建。

构建队列

点击构建ID,进入到构建详情,点击控制台输出。就能看到正在执行拉取,之后会执行之前的构建脚本

构建控制台输出

1.3 Jenkins 中 git clone 错误

有时候在构建过程中可能会发生如下错误:
clone错误

这可能是因为 Github 访问较慢而 clone 超时。回到项目设置里的源码管理,添加额外行为( Additional Behaviours),选择高级的克隆行为(Advanced clone behaviours),将超时时间设置为一个较长的值即可
设置超时时间

1.4 其他配置

Jenkins 已经构建完成,接下来该做些其它设置了。
构建完成

首先是域名,域名原本解析到 Github Pages 的指定 IP,现在更改回到本服务器

二是服务器的搭建,由于之前通过 Docker 安装 Jenkins 时指定了 Volume,所以可以直接在宿主机上同步容器内的对应目录数据。在 Nginx 中对该目录配置成静态站点即可
Nginx配置

再次访问 blog,可以看到主页加载速度已经从原来的将近 10s 提升到 28ms
主页加载耗时

至此,Jenkins 实现 Blog 持续部署的部分已经结束。往后只需要往 Github 仓库中 push,Jenkins 便会将剩下的一切做好,这是一个完全无感的过程,也算是践行了 DevOps(笑

2. Node.js 编码实现

自己实现的原理也很简单,和上面的 Jenkins 一样,只需要编写一个接收 Github Webhooks 的接口,接收到 hook 再执行操作即可。

先设计大致的功能:

  • 接收到 hook 时把项目 pull 下来
  • 有基础的配置,如运行端口、拉取路径、分支等
  • 拉取后可以执行指定 shell 文件

2.1 HTTP监听

翻阅 Github Webhooks文档 ,可以看到,Github Webhooks 在触发指定事件后,会发送一个 POST 请求。

所以我们只需要加载配置文件 config.json 获取配置,然后监听对应端口的 POST 请求

{
  "clone_path": "repos",
  "listen_port": 80
}
let http = require('http');
let querystring = require('querystring');
let config = require('./config');

// 默认clone路径
let gitClonePath = './';
// 默认端口
let listenPort = 80;

/**
 * 初始化配置
 */
let initConfig = function() {
    gitClonePath = config['clone_path'] || gitClonePath;
    listenPort = config['listen_port'] || listenPort;
};

initConfig();
http.createServer((req, res) => {
    if(req.method !== 'POST') {
        return;
    }

    let dataList = [];

    req.on('data', buffer => {
        dataList.push(buffer);
    });
    req.on('end', () => {
        // 接收到的数据二进制流
        let data = Buffer.concat(dataList).toString();
    });
    res.end();
}).listen(listenPort);

2.2 接收数据并拉取仓库

在文档的 Webhook payload example 里可以看到 POST 过来的数据格式,内容非常多,但我们的功能比较简单,只用获取其中的 clone_urlname 进行然后拉取项目到配置中的目录即可,添加以下代码:

let exec = require('child_process').exec;
let fs = require('fs');

/**
 * 执行命令并默认输出
 * @param command 执行的命令
 */
let simpleExec = function(command) {
    return new Promise((resolve, reject) => {
        exec(command, (err, stdout, stderr) => {
            if (err) {
                console.log(stderr);
            } else {
                console.log(stdout);
            }
            resolve();
        });
    });
};

/**
 * 拉取远程仓库
 * @param clonePath 本地clone地址
 * @param cloneUrl 远程仓库clone url
 * @param repoName 仓库名
 */
let cloneRepo = async function(clonePath, cloneUrl, repoName) {
    let repoPath = clonePath + '/' + repoName;
    let pullCommand = 'cd ' + repoPath + ' && git pull'
    // 存在就pull,不存在clone
    try {
        fs.statSync(repoPath + '/.git/').isDirectory()
    } catch (e) {
        await simpleExec('git clone '  + cloneUrl + ' ' + repoPath);
        return;
    }
    await simpleExec(pullCommand);
};

// ...中间省略

req.on('end', () => {
    // 接收到的数据二进制流
    let data = Buffer.concat(dataList).toString();
    // payload数据
    let payload = JSON.parse(querystring.parse(data)['payload']);
    // 仓库名和clone地址
    let repoName = payload['repository']['name'];
    let cloneUrl = payload['repository']['clone_url'];

    (async () => {
        await cloneRepo(gitClonePath, cloneUrl, repoName);
    })();
});

2.3 安全性校验

涉及到执行命令,而且命令内容是动态传入的,肯定会有命令注入的问题。翻阅文档可以看到,请求头里有 X-Hub-Signature 这么一个参数,内容是对数据的 HMAC-SHA1 加密,而如果 hook 里配置了 secret,就会将其作为加密的 token

所以我们可以添加一个 secret_token 的配置项,然后在收到请求后对其进行校验,不通过则拒绝执行接下来的操作。杜绝命令注入的风险。同时还可以添加仓库的配置项,只有配置过的仓库才会执行。

现在配置文件是下面这样的了:

{
  "clone_path": "repos",
  "listen_port": 80,
  "secret_token": "token",
  "repo": {
    "xxxuuu.github.io": {
    }
  }
}

在代码中添加校验:

let crypto = require('crypto');

/**
 * SHA1加密
 * @param str 待加密字符串
 * @param token 密钥
 * @return {string} 加密后字符串
 */
let sha1 = function(str, token) {
    return crypto.createHmac('sha1', token).update(str).digest('hex');
};

// ...中间省略

req.on('end', () => {
    // 获取数据
    // ...省略

    // 密钥
    let secret = req.headers['x-hub-signature'];

    // 校验密钥
    if(secret !== ('sha1='+ sha1(data, config['secret_token']))) {
        return;
    }

    // 配置中没有这个仓库
    if(!config.repo[repoName]) {
        return;
    }

    // 拉取
    // ...省略
});

2.4 拉取指定分支与执行 shell

再添加拉取指定分支和执行 shell 的功能,功能就全部完成了:

{
  "clone_path": "repos",
  "listen_port": 80,
  "secret_token": "token",
  "repo": {
    "xxxuuu.github.io": {
      "branch": "master",
      "shell": "./run.sh"
    }
  }
}

拉取指定分支只是在 cloneRepo 中增加一个参数,git pullclone 中指定分支。执行 shell 也只是执行命令运行配置路径中的 shell 文件

/**
 * 拉取远程仓库
 * @param clonePath 本地clone地址
 * @param cloneUrl 远程仓库clone url
 * @param repoName 仓库名
 * @param branch 远程仓库分支
 */
let cloneRepo = async function(clonePath, cloneUrl, repoName, branch) {
    let repoPath = clonePath + '/' + repoName;
    let pullCommand = 'cd ' + repoPath + ' && git pull origin ' + branch + ':master';
    // 存在就pull,不存在clone
    try {
        fs.statSync(repoPath + '/.git/').isDirectory()
    } catch (e) {
        await simpleExec('git clone -b ' + branch + ' ' + cloneUrl + ' ' + repoPath);
        return;
    }
    await simpleExec(pullCommand);
};

// ...中间省略

req.on('end', () => {
    // 获取数据和校验
    // ...省略

    (async () => {
        // 拉取
        await cloneRepo(gitClonePath, cloneUrl, repoName, config.repo[repoName]['branch']);

        // 执行shell
        if(config.repo[repoName]['shell']) {
            await simpleExec('sh ' + config.repo[repoName]['shell']);
        }

    })();
});

2.5 完善细节

添加一些提示输出,大功告成:

let http = require('http');
let querystring = require('querystring');
let config = require('./config');
let exec = require('child_process').exec;
let crypto = require('crypto');
let fs = require('fs');

// 默认clone路径
let gitClonePath = './';
// 默认端口
let listenPort = 80;

/**
 * 初始化配置
 */
let initConfig = function() {
    gitClonePath = config['clone_path'] || gitClonePath;
    listenPort = config['listen_port'] || listenPort;
};

/**
 * SHA1加密
 * @param str 待加密字符串
 * @param token 密钥
 * @return {string} 加密后字符串
 */
let sha1 = function(str, token) {
    return crypto.createHmac('sha1', token).update(str).digest('hex');
};

/**
 * 执行命令并默认输出
 * @param command 执行的命令
 */
let simpleExec = function(command) {
    return new Promise((resolve, reject) => {
        exec(command, (err, stdout, stderr) => {
            if (err) {
                console.log(stderr);
            } else {
                console.log(stdout);
            }
            resolve();
        });
    });
};

/**
 * 拉取远程仓库
 * @param clonePath 本地clone地址
 * @param cloneUrl 远程仓库clone url
 * @param repoName 仓库名
 * @param branch 远程仓库分支
 */
let cloneRepo = async function(clonePath, cloneUrl, repoName, branch) {
    let repoPath = clonePath + '/' + repoName;
    let pullCommand = 'cd ' + repoPath + ' && git pull origin ' + branch + ':master';
    // 存在就pull,不存在clone
    try {
        fs.statSync(repoPath + '/.git/').isDirectory()
    } catch (e) {
        await simpleExec('git clone -b ' + branch + ' ' + cloneUrl + ' ' + repoPath);
        return;
    }
    await simpleExec(pullCommand);
};


initConfig();
http.createServer((req, res) => {
    if(req.method !== 'POST') {
        return;
    }

    let dataList = [];

    req.on('data', buffer => {
        dataList.push(buffer);
    });
    req.on('end', () => {
        // 接收到的数据二进制流
        let data = Buffer.concat(dataList).toString();
        // payload数据
        let payload = JSON.parse(querystring.parse(data)['payload']);
        // 仓库名和clone地址
        let repoName = payload['repository']['name'];
        let cloneUrl = payload['repository']['clone_url'];
        // 密钥
        let secret = req.headers['x-hub-signature'];

        // 校验密钥
        if(secret !== ('sha1='+ sha1(data, config['secret_token']))) {
            return;
        }

        // 配置中没有这个仓库
        if(!config.repo[repoName]) {
            return;
        }

        (async () => {
            console.log('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>');
            console.log('[%s] %s正在部署...', new Date().toLocaleString(), repoName);

            // 拉取
            console.log('[%s] 拉取项目中...', new Date().toLocaleString());
            await cloneRepo(gitClonePath, cloneUrl, repoName, config.repo[repoName]['branch']);
            console.log('[%s] 拉取完毕', new Date().toLocaleString());

            // 执行shell
            if(config.repo[repoName]['shell']) {
                console.log('[%s] 执行shell %s中...', new Date().toLocaleString(), config.repo[repoName]['shell']);
                await simpleExec('sh ' + config.repo[repoName]['shell']);
                console.log('[%s] 执行shell 完毕', new Date().toLocaleString());
            }

            console.log('[%s] 仓库%s部署完毕:)', new Date().toLocaleString(), repoName);
            console.log('<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<');
        })();
    });
    res.end();
}).listen(listenPort);

2.6 其他配置

将项目部署到服务器后运行,在仓库中配置 webhooks(记得设置 secret ),之后的操作就和「1.4 其他配置」中的一样了。

当然我们也可以更进一步直接在本项目中实现这个静态网站服务器,但我认为这类 web 程序和 Nginx 等接近网络层的 HTTP 服务器的应用场景还是有很大差别的,在应用层面去实现这些东西显然不太合适、也不专业,在维护上也是一大麻烦

2.7 开源地址

使用 Node.js 的项目也上传到了 Github,有兴趣的同学可以参考一下