基于 Travis CI + PM2 实现 NodeJS 应用的持续集成和部署

2,814 阅读8分钟

前言

我发现一旦手头的项目变多,且随着项目复杂度的提升,本来编码就已经是个够头痛的问题,再加上部署到生产环境就更心累了 😵。

之前在公司实习时,有一个依据用户输入网址进行截屏的项目,同时包含了 React 应用和 Node 应用。

部署 React 应用比较方便,只要通过 scp 将 build 后的 dist 目录放置在服务器上。

而 Node 应用则较为复杂:

  • 由于它使用 TS 编写,同样需要将 build 后 dist 目录放置在服务器上
  • 在根目录下新建目录并使用 chmod 修改权限,用于暂时放置截屏快照
  • 更新 npm 包
  • 重启 PM2(Node 进程管理工具)

在项目初期,版本迭代非常快,我每天都要反复执行以上步骤数次,waste time!

何况,在标准的开发流程中,我们还需引入 单元测试覆盖率报告代码风格检测 ……,并将应用部署到 不同环境的服务器(开发、测试、生产)中,这无疑是一项繁琐的工作,本着 不想当运维的前端不是一个好全栈 的核心思想,我迫切需要解放我的双手。

TIP:结尾有源码链接

CI & CD

所谓前人栽树,后人乘凉,我的诉求早就在开发领域中被定义为两个专有名词:

  • 持续集成(Continuous Integration),简称 CI
  • 持续部署(Continuous Deployment),简称 CD

听起来很高大上,我尝试通过一张图来解释:

一个完整项目的迭代需要经历:编码 ➡️ 打包构建 ➡️ 测试 ➡️ 新代码和原有代码正确地集成在一起。

这一过程称为集成,而 持续集成强调了开发人员提交了新代码(git push)之后,立刻进行以上步骤,无需人为干预

同理,持续部署在持续集成的基础上,加了一个步骤: 将应用自动部署到指定环境(服务器)

试想,当你提交代码后,CI/CD 服务会按照你的预设命令自动化以上步骤,那是多美妙的一件事!

为了提高软件开发的效率,我们有必要使用 CI/CD,而市面上熟知的 CI/CD 服务有:Jenkins、gitlab,不过它们的使用成本很高。

我要推荐的是 Travis CI,它绑定 Github 上面的项目,只要有新的代码,就会自动抓取。然后,提供一个运行环境,执行测试,完成构建,还能部署到服务器。

准备工作

为了确保你能顺利进行实践部分,请做好以下准备工作:

  • 一台远程 Linux 服务器,用作部署
  • 使用 Github 账户登入 Travis CI,并让 Travis 监听 GitHub 仓库的更新

我会从零开始搭建一个用于 API 服务的 NodeJS 应用,并引入单元测试和 ESLint,最终实现 CI/CD。

整个思路如下:

  • 新建 Github 仓库,用于存放 NodeJS 应用代码
  • 被监听的 Github 仓库收到 git push 命令,触发 Travis CI 进行构建(yarn run test、yarn run eslint)
  • Travis CI 构建无误后,调用 PM2 的 deploy 命令,使远程服务器拉取 Github 仓库最新代码,安装依赖包,然后自动重启服务

实现本机、GitHub、远程(部署)服务器三者互通

为了简便,我将本机称为 local,远程服务器称为 remote

万事开头难,首先意识到以下两点:

  • PM2 Deployment 要求 remote 能拉取 Github 仓库的代码
  • 如果 Travis CI 有访问 remote 的需求,则确保 local 能访问 remote

由于 Travis CI 和 PM2 Deployment 在运行时不提供交互式界面,它只会按照预设的脚本命令去依次执行,当需要你输入密码时就会卡住,所以我们需要 SSH 无密登录,达到以下所示关系:

local => GitHub;
remote => GitHub;
local => remote;

local 连接 GitHub

首先生成 ssh key 私钥公钥对,一路回车,无需 passphrase.

$ ssh-keygen -t rsa -b 4096 -C "<your-github-email>"

在 local 的 ~/.ssh 目录下,会生成以下文件:

├── id_rsa
├── id_rsa.pub

id_rsa 是私钥文件,代表 🔑;id_rsa.pub 是公钥文件,代表 🔒.

只有私钥才能打开公钥

打开 github.com/settings/ke…,点击 New SSH key,复制 id_rsa.pub 中的内容。

之后选择你的任一仓库,点击 clone or download && Clone with SSH,如果能成功 clone,说明实现了 local 和 GitHub 的 SSH 连接。

local 连接 remote

ssh-copy-id 命令会默认将之前生成的公钥:id_rsa.pub 复制到 remote 中。

⚠️ 换成你自己的 remote IP.

$ ssh-copy-id root@47.106.87.3

❗️ 如果 win 系统无法识别该命令,请使用 git bash.

查看 remote 的 ~/.ssh 目录,id_rsa.pub 中内容与 authorized_keys 一致。

├── authorized_keys

尝试连接 remote.

$ ssh root@47.106.87.3

如果无需输入密码,则说明实现了 local 和 remote 的 SSH 连接。

remote 连接 GitHub

思路和 local 连接 GitHub 一致,由于我们已经在 GitHub 上存放了公钥,我们只需将私钥:id_rsa 上传到 remote 即可。

上传完毕后,remote 的 ~/.ssh 目录存在以下文件:

├── authorized_keys
├── id_rsa

同理,你可以尝试在 remote 上使用 Clone with SSH 下载 GitHub 仓库来验证是否连接成功。

至此,我们实现了三者的 SSH 互通。

搭建 NodeJS 应用

先在 GitHub 上新建一个仓库,随后 Clone 到本地。

由于该应用基于 koa 框架来实现 API 服务,所以进行一些初始化配置:

$ yarn init -y
$ yarn add koa

为了后续编码,你应该拥有以下目录:

├── lib
│   ├── app.js
├── server.js
├── package.json
└── yarn.lock

开始编写代码:

// lib/app.js
const Koa = require("koa");

const app = new Koa();

app.use(ctx => {
  if (ctx.method == "GET" && ctx.path == "/user") {
    ctx.body = "hello, friend";
  }
});

module.exports = app;
// server.js
const app = require("./lib/app");

app.listen("8888", () => {
  console.log("server is running at http://localhost:8888");
});

启动 Node 应用:

$ node server.js

我创建了一个最最简单的 API 服务,当用户访问 http://localhost:8888/user 时,返回 "hello, friend".

使用 Travis CI

在这之前,你需要创建 Travis CI 的配置文件,在根目录下新建 .travis.yml

# 构建环境
language: node_js
# node_js 版本
node_js:
  - 12
after_success:
  - echo 'I successfully done'

⚠️ Travis CI 默认会执行 install、script 这两个生命周期,即使没有显式在配置文件中定义。

就当前的配置文件而言,启动构建后,Travis CI 将执行 install ➡️ script ➡️ after_success.

而按照官方文档 Building a JavaScript and Node.js project

  • install 会默认执行 npm install
  • script 会默认执行 npm test

并且,如果 Travis CI 检测到 yarn.lock 的存在,则分别替换命令为 yarnyarn test.

所以,我们还需提供测试(test)脚本,在 package.json 中添加:

"scripts": {
  "test": "echo just test it",
},

最后,确保你在 /account/repositories 中,开启了对该仓库的监听。

一切就绪,只需将修改后的代码推送到远程仓库,来触发 Travis CI。

$ git push

来到 travis-ci/dashboard,在 Active repositories 面板中选择 travis-test,可以看到以下信息:

查看下方日志信息,关键的地方我用文字标注了:

持续集成已经跑通,但感觉少了点什么?对,访问 remote 的命令还未添加。

由于 Travis CI 相当于开启了一个虚拟化容器来执行整个构建过程,所以有必要将私钥:id_rsa 传递给它,来支持 remote 的 SSH 连接。那也总不能直接将 id_rsa 放到我们的仓库中吧,那岂不是泄露了私钥,后果非常严重!

Travis CI 早就想到了这一点,它提供了针对私钥的加密方案。

加密私钥文件需要使用 travis 这个命令行工具,它是一个 ruby 包,使用 gem 安装:

$ gem install travis
$ travis login

如果你安装 travis 失败,可以查阅 github.com/travis-ci/t….

输入账号密码登录成功后,使用 travis encrypt-file 加密:

$ travis encrypt-file ~/.ssh/id_rsa --add
# Detected repository as B2D1/travis-test, is this correct? |yes| yes
# Overwrite the config file /root/travis-test/.travis.yml with the content below? (y/N) y

# Make sure to add id_rsa.enc to the git repository.
# Make sure not to add /root/.ssh/id_rsa to the git repository.
# Commit all changes to your .travis.yml.

上面命令执行完后,会生成一段解密命令并添加到 .travis.yml 中:

before_install:
- openssl aes-256-cbc -K $encrypted_9b2d7e19d83c_key -iv $encrypted_9b2d7e19d83c_iv
  -in id_rsa.enc -out ~/.ssh/id_rsa -d

并且提示 ❗️,一定要把加密后的 id_rsa.enc 复制到仓库中,一定不要把未加密的 id_rsa 复制到仓库中。

有可能你生成的是 -out ~\/.ssh/id_rsa -d,切记改成 -out ~/.ssh/id_rsa -d

before_install 阶段发生在 install 阶段之前,这段代码的意思是:用 encrypted_9b2d7e19d83c_ivencrypted_9b2d7e19d83c_key 这两个环境变量,对仓库中的 id_rsa.enc 进行解密,并在虚拟容器中的 ~/.ssh 目录下生成私钥:id_rsa

你可以在 travis-ci.org ➡️ 你的仓库 ➡️ More options ➡️ settings 中找到这对环境变量:

基本完成对 remote 的连接工作,但还有一些坑要填:

  • 降低 id_rsa 文件的权限,否则 ssh 处于安全方面的原因会拒绝读取秘钥
  • 将 remote IP 加入到 Travis CI 虚拟容器的信任列表中,否则连接 remote 时会询问是否信任 remote

更改后的 .travis.yml 配置如下:

# 构建环境
language: node_js
# node_js 版本
node_js:
  - 12
# 将远程服务器加入信任列表
addons:
  ssh_known_hosts: 47.106.87.3
# 解密 id_rsa.enc,并修改 id_rsa 权限
before_install:
  - openssl aes-256-cbc -K $encrypted_9b2d7e19d83c_key -iv $encrypted_9b2d7e19d83c_iv
    -in id_rsa.enc -out ~/.ssh/id_rsa -d
  - chmod 600 ~/.ssh/id_rsa
# 连接远程服务器,并打印系统版本
after_success:
  - ssh root@47.106.87.3 'cat /etc/issue'

提交代码(git push),查看构建结果:

成功打印了我远程服务器的版本信息:

添加单元测试

在上一节,为了快速通过测试命令(yarn test),只是简单使用了 echo 命令。

现在要正式为 NodeJS 应用添加单元测试,建议选择 Jest + SuperTest 来实现。

Jest 是 Facebook 的一套开源的 JavaScript 测试框架,它自动集成了断言、JSDom、覆盖率报告等开发者所需要的所有测试工具,是一款几乎零配置的测试框架。

SuperTest: HTTP assertions made easy via superagent.

安装 npm 包:

$ yarn add jest supertest --dev

更改 package.json:

"scripts": {
  "test": "jest"
},

在根目录下新建 __test__/app.test.js,并编写测试代码:

const app = require("../lib/app");
const supertest = require("supertest");
const server = app.listen();
const request = supertest(server);

test("GET /user", async done => {
  const res = await request.get("/user");
  expect(res.status).toBe(200);
  expect(res.text).toBe("hello, friend");
  done();
});

afterAll(done => {
  server.close();
  done();
});

执行测试脚本:

$ yarn test

测试通过:

还可以通过 --coverage 参数来提供覆盖率报告:

添加 ESlint

这一节,继续完善 NodeJS 应用,为它添加 ESlint.

ESLint 是一个插件化并且可配置的 JavaScript 语法规则和代码风格的检查工具。ESLint 能够帮你轻松写出高质量的 JavaScript 代码

安装 npm 包:

$ yarn add eslint eslint-config-google --dev

更改 package.json:

"scripts": {
  "lint": "eslint .",
  "test": "jest",
  "pretest": "yarn run lint"
},

❗️pretest 脚本会在 yarn test 之前自动执行。

在根目录下创建配置文件 .eslintrc.json

{
  "extends": ["eslint:recommended", "google"],
  "env": {
    "node": true
  },
  "parserOptions": {
    "ecmaVersion": 6
  },
  "rules": {
    "eqeqeq": 2
  },
  "ignorePatterns": ["ecosystem.config.js", "__tests__"]
}

这里采用了预设的 lint 规则:recommended & google.

并新增一条规则:非严格相等符(==)的存在,会导致程序退出(0 代表关闭,1 代表警告,2 代表错误)。

其他的配置项为:设置代码环境、ECMA 版本、指定哪些文件不参与检查。

执行 lint 命令:

$ yarn run lint

发生了以下错误:

可以尝试运行 yarn run lint --fix 命令, ESlint 会自动修复错误。对于不能自动修复的,需手动修改。

使用 PM2 Deployment

经过上述步骤,已经基于 Travis CI 实现了 CI(持续集成)。

只差最后一步:将 NodeJS 应用部署到远程服务器上。

参照官方文档 PM2 Deployment,我们只需创建配置文件即可,剩下的交给 PM2 来做。

在根目录下创建 ecosystem.config.js

module.exports = {
  apps: [
    {
      // PM2 应用名称
      name: "travis-test-deploy",
      // node 启动文件
      script: "server.js",
    },
  ],
  deploy: {
    // "prod" 是环境名称
    prod: {
      // 私钥目录
      key: "~/.ssh/id_rsa",
      // 登录用户
      user: "root",
      // 远程服务器
      host: ["47.106.87.3"],
      // 自动将 github 加入远程服务器的信任列表
      ssh_options: "StrictHostKeyChecking=no",
      // git 分支
      ref: "origin/master",
      // git 仓库地址(ssh)
      repo: "git@github.com:B2D1/travis-test.git",
      // 项目在远程服务器的存放路径
      path: "/root/travis-test-deploy",
      // PM2拉取最新分支后,安装 npm 包,并启动(重启)NodeJS 应用
      "post-deploy":
        "source ~/.nvm/nvm.sh && yarn install && pm2 startOrRestart ecosystem.config.js",
    },
  },
};

同时修改 .travis.yml

# 构建环境
language: node_js
# node_js 版本
node_js:
  - 12
# 将远程服务器加入信任列表
addons:
  ssh_known_hosts: 47.106.87.3
# 解密 id_rsa.enc,并修改 id_rsa 权限
before_install:
  - openssl aes-256-cbc -K $encrypted_9b2d7e19d83c_key -iv $encrypted_9b2d7e19d83c_iv
    -in id_rsa.enc -out ~/.ssh/id_rsa -d
  - chmod 600 ~/.ssh/id_rsa
# PM2 deploy
after_success:
  - npm i -g pm2 && pm2 deploy ecosystem.config.js prod update

⚠️ 在首次部署时,我们需要先在远程服务器初始化项目

$ pm2 deploy ecosystem.config.js prod setup

❗️ 如果 win 系统出错,请使用 git bash.

随后提交代码(git push),等待 Travis CI 构建 和 PM2 部署完毕。

访问 Travis CI 显示构建成功,登录远程服务器,输入 pm2 list,如图所示:

访问 http://<your remote ip>:8888/user,显示 "hello,friend".

总结

这个 NodeJS 应用虽然简单,但涉及的知识点非常之多:创建 API 服务、单元测试、ESLint、CI/CD、SSH、Linux 运维,需要掌握一定的实践能力。

由于篇幅有限,还有很多坑、细节来不及去讲,如有错误请联系我 📧.

项目源码地址