基于 Travis CI + PM2 实现 NodeJS 应用的持续集成和部署
May 13, 2020
前言
我发现一旦手头的项目变多,且随着项目复杂度的提升,本来编码就已经是个够头痛的问题,再加上部署到生产环境就更心累了 😵。
之前在公司实习时,有一个依据用户输入网址进行截屏的项目,同时包含了 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 无密登录,达到以下所示关系:
js
local => GitHub;remote => GitHub;local => remote;
js
local => GitHub;remote => GitHub;local => remote;
local 连接 GitHub
首先生成 ssh key 私钥公钥对,一路回车,无需 passphrase.
bash
$ ssh-keygen -t rsa -b 4096 -C "<your-github-email>"
bash
$ ssh-keygen -t rsa -b 4096 -C "<your-github-email>"
在 local 的 ~/.ssh 目录下,会生成以下文件:
bash
├── id_rsa├── id_rsa.pub
bash
├── id_rsa├── id_rsa.pub
id_rsa
是私钥文件,代表 🔑;id_rsa.pub
是公钥文件,代表 🔒.
只有私钥才能打开公钥。
打开 https://github.com/settings/keys,点击 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.
bash
$ ssh-copy-id root@47.106.87.3
bash
$ ssh-copy-id root@47.106.87.3
❗️ 如果 win 系统无法识别该命令,请使用 git bash.
查看 remote 的 ~/.ssh 目录,id_rsa.pub
中内容与 authorized_keys 一致。
bash
├── authorized_keys
bash
├── authorized_keys
尝试连接 remote.
bash
$ ssh root@47.106.87.3
bash
$ ssh root@47.106.87.3
如果无需输入密码,则说明实现了 local 和 remote 的 SSH 连接。
remote 连接 GitHub
思路和 local 连接 GitHub 一致,由于我们已经在 GitHub 上存放了公钥,我们只需将私钥:id_rsa
上传到 remote 即可。
上传完毕后,remote 的 ~/.ssh 目录存在以下文件:
bash
├── authorized_keys├── id_rsa
bash
├── authorized_keys├── id_rsa
同理,你可以尝试在 remote 上使用 Clone with SSH 下载 GitHub 仓库来验证是否连接成功。
至此,我们实现了三者的 SSH 互通。
搭建 NodeJS 应用
先在 GitHub 上新建一个仓库,随后 Clone 到本地。
由于该应用基于 koa 框架来实现 API 服务,所以进行一些初始化配置:
bash
$ yarn init -y$ yarn add koa
bash
$ yarn init -y$ yarn add koa
为了后续编码,你应该拥有以下目录:
bash
├── lib│ ├── app.js├── server.js├── package.json└── yarn.lock
bash
├── lib│ ├── app.js├── server.js├── package.json└── yarn.lock
开始编写代码:
js
// lib/app.jsconst Koa = require("koa");const app = new Koa();app.use(ctx => {if (ctx.method == "GET" && ctx.path == "/user") {ctx.body = "hello, friend";}});module.exports = app;
js
// lib/app.jsconst Koa = require("koa");const app = new Koa();app.use(ctx => {if (ctx.method == "GET" && ctx.path == "/user") {ctx.body = "hello, friend";}});module.exports = app;
js
// server.jsconst app = require("./lib/app");app.listen("8888", () => {console.log("server is running at http://localhost:8888");});
js
// server.jsconst app = require("./lib/app");app.listen("8888", () => {console.log("server is running at http://localhost:8888");});
启动 Node 应用:
bash
$ node server.js
bash
$ node server.js
我创建了一个最最简单的 API 服务,当用户访问 http://localhost:8888/user 时,返回 "hello, friend".
使用 Travis CI
在这之前,你需要创建 Travis CI 的配置文件,在根目录下新建 .travis.yml
.
yaml
# 构建环境language: node_js# node_js 版本node_js:- 12after_success:- echo 'I successfully done'
yaml
# 构建环境language: node_js# node_js 版本node_js:- 12after_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
的存在,则分别替换命令为 yarn
和 yarn test
.
所以,我们还需提供测试(test)脚本,在 package.json 中添加:
json
{"scripts": {"test": "echo just test it"}}
json
{"scripts": {"test": "echo just test it"}}
最后,确保你在 /account/repositories 中,开启了对该仓库的监听。
一切就绪,只需将修改后的代码推送到远程仓库,来触发 Travis CI.
bash
$ git push
bash
$ git push
来到 travis-ci/dashboard,在 Active repositories 面板中选择 travis-test,可以看到以下信息:
查看下方日志信息,关键的地方我用文字标注了:
持续集成已经跑通,但感觉少了点什么?对,访问 remote 的命令还未添加。
由于 Travis CI 相当于开启了一个虚拟化容器来执行整个构建过程,所以有必要将私钥:id_rsa
传递给它,来支持 remote 的 SSH 连接。那也总不能直接将 id_rsa
放到我们的仓库中吧,那岂不是泄露了私钥,后果非常严重!
Travis CI 早就想到了这一点,它提供了针对私钥的加密方案。
加密私钥文件需要使用 travis 这个命令行工具,它是一个 ruby 包,使用 gem 安装:
bash
$ gem install travis$ travis login
bash
$ gem install travis$ travis login
如果你安装 travis 失败,可以查阅 https://github.com/travis-ci/travis.rb/issues/391.
输入账号密码登录成功后,使用 travis encrypt-file 加密:
bash
$ 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.
bash
$ 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 中:
bash
before_install:- openssl aes-256-cbc -K $encrypted_9b2d7e19d83c_key -iv $encrypted_9b2d7e19d83c_iv-in id_rsa.enc -out ~/.ssh/id_rsa -d
bash
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_iv
和 encrypted_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 配置如下:
yaml
# 构建环境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'
yaml
# 构建环境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 包:
bash
$ yarn add jest supertest --dev
bash
$ yarn add jest supertest --dev
更改 package.json:
json
{"scripts": {"test": "jest"}}
json
{"scripts": {"test": "jest"}}
在根目录下新建 __test__/app.test.js
,并编写测试代码:
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();});
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();});
执行测试脚本:
bash
$ yarn test
bash
$ yarn test
测试通过:
还可以通过 --coverage
参数来提供覆盖率报告:
添加 ESlint
这一节,继续完善 NodeJS 应用,为它添加 ESlint.
ESLint 是一个插件化并且可配置的 JavaScript 语法规则和代码风格的检查工具。ESLint 能够帮你轻松写出高质量的 JavaScript 代码
安装 npm 包:
bash
$ yarn add eslint eslint-config-google --dev
bash
$ yarn add eslint eslint-config-google --dev
更改 package.json:
json
{"scripts": {"lint": "eslint .","test": "jest","pretest": "yarn run lint"}}
json
{"scripts": {"lint": "eslint .","test": "jest","pretest": "yarn run lint"}}
❗️pretest 脚本会在 yarn test 之前自动执行。
在根目录下创建配置文件 .eslintrc.json
.
json
{"extends": ["eslint:recommended", "google"],"env": {"node": true},"parserOptions": {"ecmaVersion": 6},"rules": {"eqeqeq": 2},"ignorePatterns": ["ecosystem.config.js", "__tests__"]}
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 命令:
bash
$ yarn run lint
bash
$ yarn run lint
发生了以下错误:
可以尝试运行 yarn run lint --fix
命令, ESlint 会自动修复错误。对于不能自动修复的,需手动修改。
使用 PM2 Deployment
经过上述步骤,已经基于 Travis CI 实现了 CI(持续集成)。
只差最后一步:将 NodeJS 应用部署到远程服务器上。
参照官方文档 PM2 Deployment,我们只需创建配置文件即可,剩下的交给 PM2 来做。
在根目录下创建 ecosystem.config.js
.
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",},},};
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
.
yaml
# 构建环境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 deployafter_success:- npm i -g pm2 && pm2 deploy ecosystem.config.js prod update
yaml
# 构建环境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 deployafter_success:- npm i -g pm2 && pm2 deploy ecosystem.config.js prod update
⚠️ 在首次部署时,我们需要先在远程服务器初始化项目。
bash
$ pm2 deploy ecosystem.config.js prod setup
bash
$ 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 运维,需要掌握一定的实践能力。
由于篇幅有限,还有很多坑、细节来不及去讲,如有错误请联系我.