用 Nuxt + Webhooks + Docker 撸一套自动化部署的 Vue SSR 项目

3,538 阅读10分钟

本文永久链接:github.com/HaoChuan942…

前言

一个前端项目简单的开发部署流程通常是这样的:先本地开发,当完成某个功能后构建打包,然后将生成的静态文件通过 ftp 或者其他方式上传到服务器中。并将代码 pushGitHub 或者 码云 等远端仓库中进行托管(为了突出本文的重点,暂不考虑测试的环节)。这种工作流不免有些劳神费力,而且每天频繁的打包上传也会占用很多时间。

一种理想的方式是:你只需要在服务器上创建一个“脚本”,执行这个脚本,他就会自动从 git 服务器拉取你的项目代码,并启动你的项目,而当你每次向 git 服务器 push 代码时,它又会自动拉取最新的代码并重新编译,更新服务。

为了实现上述的“理想方式”,本文将详细介绍如何使用 Nuxt + Webhooks + Docker 来实现一个 Vue SSR 项目的自动化部署。但我们首先需要解决这么几个问题:

  1. 如果在服务器上安全的拉取私有仓库的代码?
  2. 如果以生产环境(production)启动你的项目?
  3. 如果“通知”服务器你的代码已经更新了?
  4. 如果在不停止服务的前提下自动重新构建项目,自动更新?

要解决上面的问题,你需要了解以下基础知识:

  1. SSH Key
  2. 基本的 Nuxt + Docker 知识。
  3. 了解 Webhooks
  4. 基本的 Node + express 知识。

如果你对上述知识不是很了解或者不知道如何将他们结合在一起来以达到所谓的“理想方式”,那么接下的内容将从项目创建到实际部署,一步步的带你完成这项工作。

一、使用 create-nuxt-app 脚手架创建项目

创建时的各种选项如下图所示,你可以根据自己项目的实际情况进行选择,但 server framework 请选择 express,本文也将以 express 作为服务端框架展开介绍。

二、修改 package.json 中的 npm scripts

Nuxt 脚手架生成的项目,默认在生产环境下需要先执行 npm run build 构建代码,然后再执行 npm start 启动服务,这略显繁琐,也不利于自动部署、重新构建等工作的展开,这里将两者的功能合二为一,执行 npm start,即可在编码中使用构建并启动服务。得益于 Nuxt 配置中的 dev 参数, 在不同的环境下(NODE_ENV),即使使用的都是 new Builder(nuxt).build() 来进行构建,但由于 dev 参数的不同,Nuxt 的构建行为也会相应的不同并进行针对性的优化。这里生产环境(production)下启动服务也不再是通过 node 命令而是使用 nodemon,它用于监听 server/index.js 文件的变化,在 server/index.js 更新时可以自动重启服务。调整前后的 npm scripts 如下:

// 前
"scripts": {
  "dev": "cross-env NODE_ENV=development nodemon server/index.js --watch server",
  "build": "nuxt build",
  "start": "cross-env NODE_ENV=production node server/index.js"
}
// 后
"scripts": {
  "dev": "cross-env NODE_ENV=development nodemon server/index.js --watch server",
  "start": "cross-env NODE_ENV=production nodemon server/index.js --watch server"
}

同时,删除 server/index.js 中原本的条件判断:

//if (config.dev) {
  const builder = new Builder(nuxt);
  await builder.build();
//}

调整之后,执行 npm run dev,就会在 3000 端口启动一个有代码热替换(HMR)等功能的一个开发(development)服务,而执行 npm start 就会构建出压缩后的代码,并启动一个带 gzip 压缩等功能的生产(production)服务。

三、添加 Webhooks 接口

Webhooks 是什么?简单来说:假如你向一个仓库添加了 Webhook ,那么当你 push 代码时,git 服务器就会自动向你指定的地址,发送一个带有更新信息(payload)的 post 请求。了解更多,请阅读 GitHub 关于 Webhooks 的介绍文档 或者 码云的文档。由于我们使用了 express 来创建 http 服务,所以我们可以像这样方便的添加一个接口,用于接收来自 git 服务器的 post 请求:

...
// 订阅来自 git 服务器 的 Webhooks 请求(post 类型)
app.post('/webhooks', function(req, res) {
  // 使用 secret token 对该 API 的调用进行鉴权, 详细文档: https://developer.github.com/webhooks/securing/
  const SECRET_TOKEN = 'b65c19b95906e027c5d8';
  // 计算签名
  const signature = `sha1=${crypto
    .createHmac('sha1', SECRET_TOKEN)
    .update(JSON.stringify(req.body))
    .digest('hex')}`;
  // 验证签名和 Webhooks 请求中的签名是否一致
  const isValid = signature === req.headers['x-hub-signature'];
  // 如果验证通过,返回成功状态并更新服务
  if (isValid) {
    res.status(200).end('Authorized');
    upgrade();
  } else {
    // 鉴权失败,返回无权限提示
    res.status(403).send('Permission Denied');
  }
});
...

这里的 app 是一个 express 应用,我们通过了 Nodecrypto 模块计算签名并和 Webhooks 请求中的签名比对来进行鉴权,以保证接口调用的安全性(这里的能够获取到 Webhooks 请求的请求体 —— req.body 是由于使用了 body-parser 中间件)。如果鉴权通过则返回成功状态,并执行 upgrade 函数来更新服务,如果鉴权失败,则返回无权限提示。同时,你需要向仓库添加 Webhook,如下图:

四、如何无缝更新服务

如果你的项目已经在 http://www.example.com/ 下启动成功,那么当你每次向 GitHub 仓库 push 代码时,你的接口都会收到一个来自 GitHubpost 请求,并在鉴权通过后执行 upgrade 函数来更新服务。关于如何在服务器上启动项目我们按下不表,先介绍 upgrade 函数都做了什么。

/**
 * 从 git 服务器拉取最新代码,更新 npm 依赖,并重新构建项目
 */
function upgrade() {
  execCommand('git pull -f && npm install', true);
}

execCommand 函数如下,这里我们使用了 Nodechild_process 模块,用以创建子进程,来执行拉取代码, 更新 npm 依赖等命令:

const { execSync } = require('child_process');
/**
 * 创建子进程,执行命令
 * @param {String} command 需要执行的命令
 * @param {Boolean} reBuild 是否重新构建应用
 * @param {Function} callback 执行命令后的回调
 */
function execCommand(command, reBuild, callback) {
  command && execSync(command, { stdio: [0, 1, 2] }, callback);
  // 根据配置文件,重新构建项目
  reBuild && build();
}

build 函数,会根据配置文件,重新构建项目,这里的 upgrading 是一个标记应用是否正在升级的 flag

/**
 * 根据配置,构建项目
 */
async function build() {
  if (upgrading) {
    return;
  }
  upgrading = true;
  // 导入 Nuxt.js 参数
  let config = require('../nuxt.config.js');
  // 根据环境变量 NODE_ENV,设置 config.dev 的值
  config.dev = !(process.env.NODE_ENV === 'production');
  // 初始化 Nuxt.js
  const nuxt = new Nuxt(config);
  // 构建应用,得益于环境变量 NODE_ENV,在开发环境和生产环境下这个构建的表现会不同
  const builder = new Builder(nuxt);
  // 等待构建
  await builder.build();
  // 构建完成后,更新 render 中间件
  render = nuxt.render;
  // 将 flag 置反
  upgrading = false;
  // 如果是初次构建,则创建 http server
  server || createServer();
}

createServer 函数如下,这里有两个全局变量,renderserver,其中 render 变量保存了最新构建后的 nuxt.render 中间件,而 server 变量是应用的 http server 实例。

/**
 * 创建应用的 http server
 */
function createServer() {
  // 向 express 应用添加 nuxt 中间件,重新构建之后,中间件会发生变化
  // 这种处理方式的好处就在于 express 使用的总是最新的 nuxt.render
  app.use(function() {
    render.apply(this, arguments);
  });
  // 启动服务
  server = app.listen(port, function(error) {
    if (error) {
      return;
    }
    consola.ready({
      message: `Server listening on http://localhost:${port}`,
      badge: true
    });
  });
}

访问这里,查看完整的 server/index.js 文件。但这里存在一个问题☝️,就是每次执行 build 函数,重新构建时,由于 Nuxt 会删除上一次构建生成的文件(清空.nuxt/dist/client.nuxt/dist/server 文件夹),而构建完成之后才会生成新的文件,那么如果用户恰好在这个空档期访问网站怎么办?一种解决方案是干预 webpack 的这种行为,不去清空这两个文件夹,不过我目前没有找到 Nuxt 中可以修改这个配置的地方(欢迎评论),另一种解决方案就是在项目重新构建的时候,给用户返回一个友好的提示页,告诉他系统正在升级中。这也是我设置 upgrading 变量来标记应用是否正在升级中的意义所在,下面这段代码将展示,如果实现这种效果:

const express = require('express');
const app = express();
// 拦截所以 get 请求,如果系统正在升级中,则返回提示页面
app.get('*', function(req, res, next) {
  if (upgrading) {
    res.sendFile('./upgrading.html', { root: __dirname });
  } else {
    next();
  }
});

要说明的一点是:app.get('*', ...) 必须写在前面,你可以在这里Description 中找到解释。如此一来,当用户恰好在应用重新构建时访问网站,就会出现一个友好的提示页,而当构建完成后,用户再次访问网站,就是一个升级后的应用,整个过程,服务器始终是保持在线的状态,http server 并没有停止或者重启。

至此,你已经可以把项目代码上传到 GitHub 或者 码云了(不同的服务商对 Webhooks 的鉴权方式可能会有所不同,你需要参考他们的文档对接口的鉴权方式进行一点调整)。

五、部署公钥管理

为私有项目添加部署公钥,使得项目在服务器上或者在 Docker 中可以安全的进行代码克隆和后续的拉取更新,参考链接1参考链接2。这里以 GitHub 为例进行介绍:

  1. 生成一个 GitHub 用的 SSH key

    ssh-keygen -t rsa -C 'hc199421@gmail.com' -f ~/.ssh/github_id_rsa
    

    一般情况下,是不需要使用 -f ~/.ssh/github_id_rsa 来指定生成 SSH Key 的文件名的,默认生成的是 id_rsa。但考虑到一台机器同时使用不同的 git 服务器的可能性,所以这里对生成的 SSH key 名称进行了自定义。这里的邮箱是你的 git 服务器 (GitHub)登录邮箱。

  2. ~/.ssh 目录下新建一个 config 文件,添加如下内容,参考文档

    # github
    Host github.com
    HostName github.com
    StrictHostKeyChecking no
    PreferredAuthentications publickey
    IdentityFile ~/.ssh/github_id_rsa
    

    其中 HostHostName 填写 git 服务器的域名,IdentityFile 指定私钥的路径,StrictHostKeyChecking 设置为 no 可以跳过下图中 (yes/no) 的询问,这一点对于 Docker 流畅的创建镜像很有必要(否则可能要写 expect 脚本),当然你也可以通过执行 ssh-keyscan github.com > ~/.ssh/known_hostshost keys 提前添加到 known_hosts 文件中。

  3. 在项目仓库添加部署公钥

  4. 测试公钥是否可用

    ssh -T git@github.com
    

    如果出现下图所示内容则表明大功告成,可以执行下一步了。👏👏👏🎉🎉🎉

至此,如果你不需要使用 Docker 部署,而是使用传统的部署方式,那么你只需要在服务器上安装 Nodegit,并把仓库代码克隆到服务器上,然后执行 npm start 在 80 端口启动服务就可以了。你可以使用 nohup 命令或者 forever 等使服务常驻后台。

六、Docker 部署

1. 安装 Docker CE (阿里云 Ubuntu 18.04 已亲试)

2. 创建 Dockerfile

# 添加 node 镜像,:8 是指定 node 的版本,默认会拉取最新的
FROM node:8
# 定义 SSH 私钥变量
ARG ssh_prv_key
# 定义 SSH 公钥变量
ARG ssh_pub_key
# 在 /home 下创建名为 webhooks-nuxt-demo 的文件夹
RUN mkdir -p /home/webhooks-nuxt-demo
# 为 RUN, CMD 等命令指定工作区
WORKDIR /home/webhooks-nuxt-demo
# 创建 .ssh 目录
RUN mkdir -p /root/.ssh
# 生成 github_id_rsa、github_id_rsa.pub 和 config 文件
RUN echo "$ssh_prv_key" > /root/.ssh/github_id_rsa && \
    echo "$ssh_pub_key" > /root/.ssh/github_id_rsa.pub && \
    echo "Host github.com\nHostName github.com\nStrictHostKeyChecking no\nPreferredAuthentications publickey\nIdentityFile /root/.ssh/github_id_rsa" > /root/.ssh/config
# 修改私钥的用户权限
RUN chmod 600 /root/.ssh/github_id_rsa
# 克隆远端 git 仓库代码到工作区,注意最后的 . 不能省略
RUN git clone git@github.com:HaoChuan9421/webhooks-nuxt-demo.git .
# 安装依赖
RUN npm install
# 对外暴露 3000 端口
EXPOSE 3000
# 启动时的执行脚本
CMD npm start

3. 创建 Docker Image

通过 cat 命令读取之前创建的 SSH 公钥和私钥的内容并作为变量传递给 Docker。由于 build 镜像的过程需要执行 git clonenpm install,取决于机器性能和带宽,可能需要花费一定的时间。一个正常的 build 过程如下图:

docker build \
-t webhooks-nuxt-demo \
--build-arg ssh_prv_key="$(cat ~/.ssh/github_id_rsa)" \
--build-arg ssh_pub_key="$(cat ~/.ssh/github_id_rsa.pub)" \
.

4. 启动容器

在后台启动容器,并把容器内的 3000 端口 发布到主机的 80 端口。

sudo docker run -d -p 80:3000 webhooks-nuxt-demo

5. 进入执行中的容器

必要的时候可以进入容器中执行一些操作:

# 列出所有容器
docker container ls -a
# 进入指定的容器中
docker exec -i -t 容器名称或者容器ID bash

七、留个后门

有时候我们可能需要执行一些命令,来对项目进行更佳灵活的操作,比如切换项目的分支、进行版本回滚等。但如果只是为了执行一行命令就需要连接服务器,再进入容器内,难免有些繁琐,启发于 Webhooks,我们不妨留个后门👻:

// 预留一个接口,必要时可以通过调取这个接口,来执行命令。
// 如:通过发起下面这个 AJAX 请求,来进行 npm 包的升级并重新构建项目。
// var xhr = new XMLHttpRequest();
// xhr.open('post', '/command');
// xhr.setRequestHeader('access_token', 'b65c19b95906e027c5d8');
// xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
// xhr.send(
//   JSON.stringify({
//     command: 'npm update',
//     reBuild: true
//   })
// );
app.post('/command', function(req, res) {
  // 如果必要的话可以进行更严格的鉴权,这里只是一个示范
  if (req.headers['access_token'] === 'b65c19b95906e027c5d8') {
    // 执行命令,并返回命令的执行结果
    execCommand(req.body.command, req.body.reBuild, function(
      error,
      stdout,
      stderr
    ) {
      if (error) {
        res.status(500).send(error);
      } else {
        res.status(200).json({ stdout, stderr });
      }
    });
    // 如果是纯粹的重新构建,没有需要执行的命令,直接结束请求,不需要等待命令的执行结果
    if (!req.body.command && req.body.reBuild) {
      res.status(200).end('Authorized and rebuilding!');
    }
  } else {
    res.status(403).send('Permission Denied');
  }
});

八、总结

如果你按照上述步骤成功了部署了你的 Vue SSR 项目,那么当你每次 push 代码到 git 服务器,它都会自动拉取并更新。👏👏👏🎉🎉🎉

虽然我试图全面详细的介绍如何撸一套自动化部署的前端项目,但这对于一个真实的项目来说,可能远远不够。

例如,对于测试而言,可能我们需要创建两个的 Docker 镜像(或者使用两台服务器),一个启动在 80 端口,一个启动在 3000 端口,分别拉取 master 分支和 dev 分支的代码,通过对 Webhookspayload 进行判断,来决定这次的 push 行为应该更新哪个服务,通常我们在 dev 上进行频繁的提交,由测试人员测试通过之后,我们将 dev 分支的代码阶段性地合并到 master 分支,来进行正式版的更新。

又比如日志监控的完善等等,所以我的这篇博客权当抛砖迎玉,欢迎各位大佬指正不足之处,评论交流,或者给我的这个项目提交 PR,大家一起来完善这个事情。