Egg.js 多机平滑重启实践
作者:图南
原文地址:https://zhuanlan.zhihu.com/p/84632879
前提
首先要声明的是,我们的应用都是在阿里云上多机部署的。当然这里不是安利文,而是给有相同问题的朋友一个实践的参考。
背景
我在公司处在一个侧重 js 技术方向的团队,后端项目也较多基于 node.js 开发。项目几经更迭也经历了 koa1 --> koa2 --> egg.js 的框架变更。
在早期项目依赖 koa 的时候,部署方案就是依赖 gitlab-ci + pm2 的方式做自动化部署和进程管理。pm2 可以管理进程的启动和监控,也可以在进程意外终止的时候重新拉起新的进程保证项目持续运作。但缺点也很明显,首先 pm2 本身会需要资源,这个资源与项目进程的负载是成线性关系的,也就是当我们的并发量大的时候,进程需要更多的资源来处理请求,而 pm2 作为资源的分配者,也需要更多的资源来管理请求和进程的资源调度。甚至出现 god deamon 进程占用了1个多g内存 。这本身来讲是额外的资源消耗,并不是我们所希望的。另一方面我们也遇到了在高并发的情况下,pm2 并不能实现 100% 的平滑重启。每当有新的代码被部署的时候,还是会出现一定的请求失败的情况。这与 pm2 本身有关,相关的问题不是单一的,这里不一一展开 ♂️。
切换到 egg.js 之后,请求的调度任务由项目本身的 master 进程来管理,可以尽可能让项目最大化的利用硬件资源。而且少了 pm2 作为媒介,不用去理会 pm2 造成的影响,可以更加关注项目本身的问题。
但是 egg.js 提供的启动方案只有简单的 start 和 stop。也就是当我要更新项目的时候,一定要关闭所有进程然后再启动项目。这样会造成服务的短暂不可用的情况,显然不是我们希望看见的。
所以,我们通过各种尝试来完善 egg.js 的重启问题 。
尝试:编写热重启脚本
在简单了解 pm2 的重启原理后,我们知道,pm2 先 fork 出一个新的进程,然后通过 ipc 通知一个进程关闭,当进程关闭后,pm2 再 fork 新的进程,这样逐个重启过去的,可以理解为串行。
通过 pm2 的这个方案,结合 egg-scripts 的源码,我们修改出了一个可以逐个启动进程的启动脚本 egg-cluster-script
起初在请求量低的时候,这个方案看似是可行的(因为错误少,没发现)。但是当我们把服务对接给公司其他业务方后,请求量激增,这个方案的问题就暴露出来了 :
- 每当我们 kill 一个进程的时候,在这个进程在真正退出之前依然会被 master 分配请求,这些请求并不能被消化,所以在重启的时候永远有一个不能处理请求的进程被分配了请求,造成大量的请求错误
- 当有新的 schedule 脚本上线的时候,无法添加到 master 进程中进行调度,你最后还是不得不重启整个项目
如果要深入到请求调度上的问题,这个改动的成本就相对较高了。最终,我们放弃了这个方案。转而寻求通过外部手段的方式来达到平滑重启的目的 。
新的方向:SLB 的利用
首先我们前提中提到我司的服务都是部署在阿里云上的,基本的部署情况差不多如图:
通常我们的服务是部署在多台 ecs 上的,每台 ecs 上部署多个进程的应用。通过 SLB 做负载均衡,把请求根据权重适当的分配给每个 ecs 。
在 SLB 中,定时的健康检查判断每个 ecs 上的服务是不是可用的,当不健康的检查超出了给定的阈值,SLB 就会将 ecs 摘除,不会再将请求分发给这个 ecs,直到这台 ecs 的健康检查恢复正常。
通过这个健康检查的原理,当 ecs 被摘除的时候,我们就可以任意去摆布这台 ecs 上的进程了。
有了思路后,接下来就是指定实现的方案 :
-
给 app 添加健康状态的属性,例如:app.running = true,当 process 接收到特定的信号量的时候,会改变健康状态。不挂在 app 上也行,只要能保证全局找得到这个唯一的状态值;
// app.js
module.exports = class AppBook {
/**
*
* @param {Egg.Application} app
*/
constructor(app) {
this.app = app;
app.running = true;
process.on('SIGINT', () => {
app.running = false;
});
}
} -
项目提供一个健康检查的接口 /devops/health ,通常情况下我们采取 head 请求直接返回 状态码,当 app.running = true 的时候返回 204,否则返回 500;
const { Controller } = require('egg');
module.exports = class DevopsController extends Controller {
healthCheck() {
const { ctx } = this;
if(this.app.running === true) {
ctx.body = null;
}
else {
ctx.status = 500;
ctx.body = '';
}
}
} -
编写信号发送脚本改变 app 的健康状态;
// scripts/health-down.js
// 这里的 findNodeProcess,appWorkerPath,titleTemplate 都可以从 egg-script 中找到
async function run () {
const processList = await findNodeProcess(item => {
const cmd = item.cmd;
const title = 'your-app-name'
return cmd.includes(appWorkerPath) && cmd.includes(util.format(titleTemplate, title));
});
for(const pro of processList) {
const pid = pro.pid;
process.kill(pid, 'SIGINT');
}
// 健康状态修改之后暂停 5s 让 slb 摘除 ecs 后再进行进程处理
await new Promise(resolve => setTimeout(resolve, 5000));
}
run(); -
给 package.json 添加 scripts: "health:down": ''node scripts/health-down.js", 我们是使用 pm2-depoly 执行的部署。所以在 ecosystem.config.js 中,应用的 deploy 做修改;
// ecosystem.config.js
module.exports = {
deploy: {
production: {
user: 'your-deploy-user',
host: [...'your-ecs-hosts'],
ref: 'deploy-ref',
repo: 'project-repo',
ssh_options: ['StrictHostKeyChecking=no'],
path: 'deploy-path-on-ecs',
'pre-deploy': 'git fetch && npm run health:down',
'post-deploy': 'npm install --production --no-save && npm stop && npm start'
}
}
}; -
设置健康检查策略,让 slb 可以动态摘除/添加 ecs;
我这里健康检查的频率设置的相对频繁,可以根据自己的需要修改这里的配置。大体的意思就是只要 2 次检查不通过就会把 ecs 摘除,之后只要连续两次检查正常就会把 ecs 重新添加回来。
之后就是结合自己的 ci 来自动部署了。通过这个方式,我们的项目可以在任何时候实现项目的平滑重启,经验证即使在高峰时段也没有出现异常。
当前已经应用的项目是日访问量在 2亿 左右的服务,正在逐步推广到其他服务中去。这个实践也并非针对 eggjs 项目,应该是具有相对通用性的方案,可以在任意语言和框架中采用 。
结尾:
一定有人问为什么一开始不直接采用 SLB 的方案而要绕这么大个弯子。其实原因挺多的
- 首先,我一开始是真的没想到这个方案
- 作为一个一线代码搬运工,什么事都希望能通过编码来解决,这是一种执着
- eggjs 在项目中也是第一次实践,多动手能更为理解其整个生态
如果你也对 eggjs,对 nodejs,对 js 有兴趣,有想探讨的问题,可以联系我的邮箱 tunan@gaoding.com。