eggjs 单机热更新解决方案

后端采用 eggjs 作为框架,由于项目体量小,没有采用集群部署,而是将 nginx 和 eggjs、前端都部署到了一台机器上,由于更新频繁,导致用户经常断线、响应错误,用户体验非常不好,造成程序不稳定的印象。

用户内心 OS:什么垃圾程序员,写的程序经常出问题,还强制退出,烦死了~

因此,不论如何,为了尊严,一定得实现热更新!

百度大法

要解决问题,按照习俗,肯定是先问下百度。

一通查找,发现官方建议使用 SLB 来实现热更新,那我不具备这个条件咋办,然后又发现一个新的思路 eggjs 的单机热部署,但作者已不再维护,因此根据 egg-deploy 思路,自己进行一些优化实现了eggjs 的单机热更新。

解决思路

热更新的总体思路与集群热更新的方式一致,只不是实现方式不一样,核心思想是:

在服务器上启动临时 eggjs 实例,然后通过 nginx 的 reload 将流量切到临时实例后,更新主服务,再将 nginx 切换加主服务上。

按思路,首先要考虑 2 个问题:

  1. schedule 任务可能在临时实例上执行
  2. 在关闭服务时,可能还存在未完成的连接,导致用户端响应错误

对于第 1 个问题,可以将 schedule 单独用一个实例去承载。由于是一些定时任务,不会频繁地去更新,即使关闭重启,对用户的使用完全没有影响。

对于第 2 个问题,据 官方回复,eggjs 有做优雅退出,因此该问题不需要进行处理。

完整方案

独立 Schedule

schedule 服务单独使用一个实例来承载。因此,在使用 egg-scripts start启动时,要向 eggjs 传递启动参数,来区分实例的类型。可以通过下列两个方式来实现:

  1. 如果是 eggjs3.x 的话,可以在启动时,传递一个 --env 来指定环境变量,从而调用指定的配置文件来初始化 eggjs,这个时候就可以在指定的配置文件中增加配置来表明当前实例的类型
  2. 在 eggjs2.x 中,则无法修改 --env,因此只能通过 process.argv 的第 3 个参数来进行判断

下面介绍一下在 eggjs2.x 的实例类型识别方法

在 eggjs 实例中,process.argv[2] 是由 egg-scripts 传递的参数,它是一个 json 字符串,我们可以通过其中的 title 或者 port 来区分实例的类型,在 configWillLoad() 钩子函数中将增加实例类型的配置。

最后在定义 schedule 时,根据配置来判断是否启动该 schedule。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// app.js

configWillLoad() {
// 此时 config 文件已经被读取并合并,但是还并未生效
// 这是应用层修改配置的最后时机
// 注意:此函数只支持同步调用
// // 例如:参数中的密码是加密的,在此处进行解密
// this.app.config.mysql.password = decrypt(this.app.config.mysql.password);
// // 例如:插入一个中间件到框架的 coreMiddleware 之间
// const statusIdx = this.app.config.coreMiddleware.indexOf('status');
// this.app.config.coreMiddleware.splice(statusIdx + 1, 0, 'limit');

// 根据启动命令设置 deploy 环境
setStartupEnv(this.app.config)
}

1
2
3
4
5
6
7
8
9
10
11
12
13
// setStartupEnv 定义

function setStartupEnv(config) {
const startupEnv = JSON.parse(process.argv[2])
// 不覆盖设置
if (config.deploy || config.env === 'local') return

if (startupEnv.port === 7010) {
config.deploy = {
env: 'schedule'
}
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// shedule 定义
// 如果不是采用这种方式的,可以参考思路

'use strict'
const { schedule } = require('../utils/scheduleBase')

module.exports = app => {
return {
schedule: schedule(
{
interval: '1s', // 每天凌晨4点执行
type: 'worker', // 指定某一个 worker 执行
env: ['local'],

immediate: true, // 不开机启动
disable: true, // 不启动,只采用手动调用
running: false, // 是否正在运行
lastTime: 0 // 上次触发时间
},
app
),

async task(ctx) {
console.log('testSchedule start()')
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// scheduleBase.js

/**
* 包裹 schedule 设置定义
* @param {*} scheduleOptions
* @param {*} app
* @return
*/
function schedule(scheduleOptions, app) {
const enable = enableSchedule(app.config)
if (enable) return scheduleOptions

// 修改 disable 属性
scheduleOptions.disable = true
return scheduleOptions
}

主服务热更新流程

1
2
3
4
5
6
7
8
9
sequenceDiagram

Nginx ->> Eggjs—Instance1: 连接主服务
Eggjs—Instance2 -->> Eggjs—Instance2: 启动临时服务
Nginx -->> Eggjs—Instance2: 切换到临时服务
Eggjs—Instance1 ->> Eggjs—Instance1: 重启主服务
Nginx ->> Eggjs—Instance1: 切换到主服务
Eggjs—Instance2 -->> Eggjs—Instance2: 关闭临时服务

Nginx 流量切换实现

nginx 中使用 upstream 来进行流量切换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
upstream backend_stream {
// eggjs 主服务
server 127.0.0.1:7001;
// eggjs 临时服务
server 127.0.0.1:7002 down;
}

server {
listen 443;
server_name test.demo.com;
ssl_session_cache shared:SSL:1m;
ssl_session_timeout 5m;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# 头信息
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# location请求映射规则,/ 代表一切请求路径
location / {
proxy_connect_timeout 600;
proxy_read_timeout 600;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_pass http://backend_stream;
}
}

通过修改 upstream 中的服务启用或关闭来进行流量切换,切换后,需要使用 nginx -s reload 来重载配置。

具体的实施代码可以参考:egg-deploy

参考

本文参考以下文章,在此致以诚挚谢意!

eggjs生产环境可以热更新吗?

eggjs 的单机热部署

egg-deploy

大公司里怎样开发和部署前端代码? - 知乎 (zhihu.com)

前端非覆盖式发布 - 掘金 (juejin.cn)

eggjs优雅重启策略