Coder Social home page Coder Social logo

blog's Issues

child_process.exec接口引起的服务异常排查记录

问题描述

最近在用Beidou同构框架搭建一个SSR同构服务,本地开发时毫无问题,但部署到测试环境和线上环境后,服务会不定期进程会收到exit事件而异常退出,严重影响到服务的稳定性。

背景介绍

Beidou是由阿里开发的基于EggJS的同构框架,框架本身自带CLI工具拥有进程管理能力,启动方式为beidou start。但是公司内的发布平台对NodeJS的进程管理进行了规范:

  1. 目前只支持使用PM2进行进程管理
  2. 项目工程中必须拥有./src/index.js作为启动脚本

为了遵循规范,增加了./src/index.js并且通过child_process.exec接口执行beidou start来启动服务。核心代码如下:

const { exec } = require('child_process')
let command = 'npx beidou start --port=8080 --title=*** --env=test'
exec(command, (error, stdout, stderr) => {
    xxxx
});

排查过程

确认进程状况

推荐使用通过ps axjf指令进行查看,该指令可以将父子进程以树状的形式展示,非常直观,同构服务的状态

PM2 v5.1.0: God Daemon (/home/webedit/.pm2)
  \_ node /*/src/index.js
      \_ node /usr/local/bin/npx beidou start --port=8080 --title=* --env=test
          \_ node --no-deprecation /*/node_modules/egg-scripts/lib/start-cluster
              \_ /home/node/bin/node --no-deprecation /*/node_modules/egg-cluster/lib/agent_worker.js 
              \_ /home/node/bin/node --no-deprecation /*/node_modules/egg-cluster/lib/app_worker.js 
              \_ /home/node/bin/node --no-deprecation /*/node_modules/egg-cluster/lib/app_worker.js 
              \_ /home/node/bin/node --no-deprecation /*/node_modules/egg-cluster/lib/app_worker.js 
              \_ /home/node/bin/node --no-deprecation /*/node_modules/egg-cluster/lib/app_worker.js 

这里隐藏了一些项目的信息,但足矣说明进程情况:

  1. pm2启动了子进程来执行./src/index.js
  2. ./src/index.js脚本中用子进程来执行beidou start
  3. Beidou start cli指令中用子进程来执行egg-scripts/lib/start-cluster脚本
  4. 而最后的5个进程分别是EggJS中的agent_worker进程和app_worker进程,app_worker进程数量由CPU数量决定,对这块陌生的同学可查看官方文档

排查app_worker进程退出的原因

真正提供服务的是app_worker进程。如果服务异常,那么可以断定app_worker进程都退出了。要搞清楚app_worker退出的原因,首先要先了解EggJS启动方式,egg-scripts/lib/start-cluster 的源码很简单:

const options = JSON.parse(process.argv[2]);
require(options.framework).startCluster(options);

其实就是以EggJS cluster模式启动服务,的核心代码都在egg-cluster package中。app_worker进程启动的关键代码在egg-cluster/lib/master.js中:

forkAppWorkers() {
    this.appStartTime = Date.now();
    this.isAllAppWorkerStarted = false;
    this.startSuccessCount = 0;
    const args = [ JSON.stringify(this.options) ];
    this.log('[master] start appWorker with args %j', args);
    cfork({
      exec: this.getAppWorkerFile(),
      args,
      silent: false,
      count: this.options.workers,
      // don't refork in local env
      refork: this.isProduction,
      windowsHide: process.platform === 'win32',
    });
    
    ...
  }

cfork的作用就是启动指定数量的子进程用来执行app_worker的代码。了解了启动方式后就很简单了,只要监听process的exit事件和终止信号就能知道进程何时因为何种原因退出了。
通过日志分析发现,是由于mater进程收到 SIGTERM 信号后杀掉了所有的app_worker进程。

5/18/2021, 7:08:11 PM [start-cmd] Kill child 21539 with undefined
5/18/2021, 7:08:11 PM[master] receive signal SIGTERM, closing
5/18/2021, 7:08:11 PM [master] app_worker#1:undefined exit
5/18/2021, 7:08:11 PM [master] app_worker#4:undefined exit
5/18/2021, 7:08:16 PM [master] app_worker#3:undefined exit
5/18/2021, 7:08:16 PM [master] app_worker#2:undefined exit

谁发了SIGTERM信号

但谁发了SIGTERM信号?什么原因发送了SIGTERM信号?系统?还是PM2?难道要看PM2的源码?这些问题困扰了我很久。还真去了解了PM2的原理并研读了部分代码,但不是本文的重点,不展开。
根据进程的树状信息,顺腾摸瓜,当服务异常时,PM2进程却正常,初步推断是PM2内部发送的终端信号,比如内存不足等。
但是通过运维平台,并没有发现机器有内存不足的情况。所以我在./src/index.js中监听了exit事件和终止信号,当服务退出时,确实没有收到终止信号,思路好像又断了。
无奈只能求助谷歌,文章中提到可以用Audit工具排查哪个进程杀了指定进程。

audit工具是Linux系统中负责审计的进程,可以用来记录Linux系统的一些操作,比如系统调用,文件修改,执行的程序,系统登入登出和记录所有系统中所有的事件,我们可以通过配置aidutd规则来对Linux服务器中发生的一些用户行为和用户操作进行监控。

9031cc7d-b3ba-4f16-9702-f642e7a499ef

在SA同学的协助下,最终查到是由于一个node进程杀掉了beidou进程。

2f1e02e4-2604-4efd-9744-b5cba02c88d9

对audit工具不熟悉的同学看到这些日志可能一脸懵逼,大概的意思就是:一个node程序的pid为29904,kill进程的信号由pid为11779的node进程中发出,而这里29904就是app_worker,而11779就是
./src/index.js所在的进程,好像罪魁祸首是./src/index.js?index.js脚本中通篇没有发送信号相关的代码,最有可能就是child_process.exec接口。

发出SIGTERM信号的原因

通过分析NodeJS的child_process的源码可以发现,exec接口在启动子进程后会通过'data'事件监听子进程的输出,并且设置了输出的上限,一旦超过上限就kill调子进程,而默认的信号就是SIGTERM。上证据:

function exec(command, options, callback) {
  const opts = normalizeExecArgs(command, options, callback);
  return module.exports.execFile(opts.file,
                                 opts.options,
                                 opts.callback);
}

const MAX_BUFFER = 1024 * 1024;

function execFile(file /* , args, options, callback */) {
  ...
  options = {
    encoding: 'utf8',
    timeout: 0,
    maxBuffer: MAX_BUFFER,
    killSignal: 'SIGTERM',
    cwd: null,
    env: null,
    shell: false,
    ...options
  };
  ...

  const child = spawn(file, args, {
    cwd: options.cwd,
    env: options.env,
    gid: options.gid,
    shell: options.shell,
    signal: options.signal,
    uid: options.uid,
    windowsHide: !!options.windowsHide,
    windowsVerbatimArguments: !!options.windowsVerbatimArguments
  });
  
  ...

  function kill() {
    if (child.stdout)
      child.stdout.destroy();

    if (child.stderr)
      child.stderr.destroy();

    killed = true;
    try {
      child.kill(options.killSignal);
    } catch (e) {
      ex = e;
      exithandler();
    }
  }
  ...
  if (child.stdout) {
    if (encoding)
      child.stdout.setEncoding(encoding);

    child.stdout.on('data', function onChildStdout(chunk) {
      const encoding = child.stdout.readableEncoding;
      const length = encoding ?
        Buffer.byteLength(chunk, encoding) :
        chunk.length;
      const slice = encoding ? StringPrototypeSlice :
        (buf, ...args) => buf.slice(...args);
      stdoutLen += length;

      if (stdoutLen > options.maxBuffer) {
        const truncatedLen = options.maxBuffer - (stdoutLen - length);
        ArrayPrototypePush(_stdout, slice(chunk, 0, truncatedLen));

        ex = new ERR_CHILD_PROCESS_STDIO_MAXBUFFER('stdout');
        kill();
      } else {
        ArrayPrototypePush(_stdout, chunk);
      }
    });
  }

  return child;
}

而我们的服务一直在跑,日志一直在输出,所以服务退出是早晚的事儿。官方文档其实已经写得很清楚,只怪自己没仔细看文档。

Largest amount of data in bytes allowed on stdout or stderr. If exceeded, the child process is terminated and any output is truncated. See caveat at maxBuffer and Unicode. Default: 1024 * 1024.

20842fc3-1a08-49ca-a0a1-24b967192ca8

最后提一嘴,SIGTERM信号一般不会由系统发出,如果您遇到SIGTERM的情况,请先从自己应用着手排查。

解决方案

原因找到了,解决也就很简单了,使用不关心子进程stdout和stderr的接口即可,比如:child_process.spawn

let childProcess = spawn(`npx beidou start --port=${PORT} --title=* --env=${EGG_SERVER_ENV}`, [], {
  shell: true,
  stdio: 'inherit'
})

复盘总结

整个排查过程虽然一波三折,但也有很多收获:

  1. 首先是对ps指令有了更多实践,特别是通过ps axjf可以以树状的形式查看进程关系
  2. Egg CLI工具的设计原理和内部流程有了初步认识
  3. EggJSpm2cluster模式有了完整的认识,至少可以自己写一个cluster模式了
  4. 通读child_process模块源码,对每个接口(exec/execFile/fork/spawn)内部逻辑有了清晰的认知
  5. 运维知识也得到了扩充,学会使用Audit工具对Linux服务器中发生的一些用户行为和用户操作进行监控

感谢您的观看!

NPM package 版本管理最佳实践

背景

目前项目组内没有一套版本管理规范来对node package进行规范,每个人对版本号的理解差异导致package版本号混乱,本文档将给出一套规范来解决该问题,并且在次规范的基础上给出一套node package发包最佳实践。
如果您对node package的语义化版本号已经非常了解,可直接跳到最佳实践部分开始阅读

文档适用范围:包括所有发布到npm上的node和前端package

语义化版本号说明

major.minor.patch[-prerelease]

版本号组成

node package版本号由四部分组成:major.minor.patch[-prerelease],比如:1.0.2-beta.1,其中prerelease可选。

  • major:代表主版本号,通常在需要提交不能向下兼容的情况下对该版本号进行升级
  • minor:代表次版本号,通常在新增功能时才对该版本号进行升级
  • patch:代表修复版本号,升级该版本号通常代表修复一些bug,但没有新增功能或者存在不向下兼容的功能
  • prerelease:带有该版本号的包通常表示在测试阶段,尚未稳定,通常不建议用户安装。

prerelease说明

alpha、beta、rc

通常我们会看到三种类型的prerelease,分别是:alpha、beta、RC,如:

1.1.0-alpha.1
1.1.0-beta.1
1.1.0-rc.1

每种类型的prerelease都有其特殊的含义,请不要乱用。

  • alpha: 代表内部测试版,会有很多Bug,是比beta更早的版本,一般不建议对外发布
  • beta: 相对alpha版本已有了很大的改进,但还是存在一些缺陷,需要经过多次测试来进一步消除
  • rc:Release Candidate顾名思义就是正式发布的候选版本。和Beta版最大的差别在于Beta阶段会一直加入新的功能,但是到了RC版本,几乎就不会加入新的功能了,而主要着重于除错! RC版本是最终发放给用户的最接近正式版的版本,发行后改正bug就是正式版了,就是正式版之前的最后一个测试版

配合Tag灵活控制版本输出

思考一个问题:npm install ,会安装哪个版本的package 最新版本?
其实node package也有tag的功能,跟git的tag有点类似,目的就是给某个版本的package打标签。通过npm dist-tag ls指令可以查看某个package的所有tag,以vue为例:

> npm dist-tag ls vue
beta: 2.6.0-beta.3
csp: 1.0.28-csp
latest: 2.6.10

tag包括内置类型和自定义类型,其中latest就是内置tag,csp和beta为自定义tag。默认情况下latest指向最新版本的package,当然我们可以手动修改latest指向的版本,这个我们后面讲。每次npm publish发包时都会将latest指向当前发布的package版本。至于beta和csp具体指向哪个版本的package,完全由我们自己决定。那么tag具体有什么作用?

  1. 指定最新的稳定版本

npm install时除了指定某个version的package,也可以指定安装某个tag的package。以vue为例:

image

npm install vue@latest就会安装2.6.10版本。现在回答刚才的问题:“npm install ,会安装哪个版本的package?”答案是latest指向的version。因此,我们在版本迭代时始终让latest指向最新的稳定版本。

  1. 指定最新的公测版本

一般pkg在发新版之前都会发布一些公测版让用户先尝鲜,比如0.0.4-beta.0,一方面是让用户体验新功能,另一方面尽早发现bug修复上线。而在此期间更新版本是相对频繁的,我们不可能每发布一个内测版本都通知内测人员修改版本号,我们可以使用自定义标签解决此类问题。beta tag始终指向最新的带有prerelease的版本。那么用户通过npm install pkg@beta就可以安装最新的内测版。
除了在npm publish时通过--tag参数的方式指定tag,我们还可以通过npm dist-tag add指令增加或者移动tag。

// 方式一
npm publish --tag beta
// 方式二
npm dist-tag add [email protected] beta

有一点需要特别注意:npm publish 时会自动将latest指向最新的版本包括带有prerelease的版本。为了不改变latest总是指向最新稳定版本的属性,请在publish beta版本时使用 --tag beta参数。

最佳实践

参考了目前流行框架(Vue、React、Taro)的版本管理方案,得出以下最佳实践。

约定

为了规范发包流程,我们做如下约定:

  • 第一个稳定版本号为1.0.0
  • beta版本号从0开始,比如:1.0.0-beta.0/2.1.0-beta.0
  • 使用npm version工具进行版本升级
  • prerelease只保留beta
  • 只有 latestbeta 两个标签
  • latest tag永远指向最新的稳定版本
  • beta tag永远指向最新的公测版本
  • 提交beta版本时,npm publish时必须加上 --tag beta 参数
  • npm publish后需要给git仓库打tag,tag名称跟当前版本号一致

版本升级工具

npm version [ | major | minor | patch | premajor | preminor | prepatch | prerelease [--preid=] | from-git]

npm 提供了自动升级版本号的工具:npm version,该工具会自动修改package.json内的版本号并且会自动 git commit, 因此使用该工具时请保持git status是clear的。
假设我们当前版本号为0.0.1,我们需要升patch号:

npm version patch

那么版本号就会变成0.0.2
npm version majornpm version minor同理,具体使用方法参考官方文档
其中npm version prerelease比较特殊,需要扩展说明下。

prerelease 版本升级

npm version prerelease

假设当前版本号为0.0.1,执行 npm version prerelease 后,版本号将变为0.0.2-0,再执行npm version prerelease,版本号将变为0.0.2-1,以此类推。
但是如何升级成类似0.0.2-beta.1的形式?可以尝试使用 --preid 选项,但前提是您本地的npm版本需要大于6.4.0

// npm 6.4.0 以后可以使用 --preid 选项
npm version prerelease --preid=beta

0.0.1将变为0.0.2-beta.0,您也可以选择手动升级:

npm version prerelease 0.0.2-beta.0

这不是明智的选择,我们依然推荐您将npm升级到6.4.0以上的版本,升级方式:

npm i -g npm@latest

案例

第一个beta版本

我们目前有个package名称是ossa,第一个版本之前有两个beta版本,那么项目初始化时确保package.json里版本号为1.0.0-beta.0,publish指令:npm publish --tag beta

image

git tag:

git tag 1.0.0-beta.0

第二个beta版本

接下来会通过npm version prerelease --preid=beta进行beta版本升级,升级后版本号将变为1.0.0-beta.1,publish指令:npm publish --tag beta

image

git tag:

git tag 1.0.0-beta.1

major版

两个beta版本后需要发布稳定版本1.0.0,请使用指令npm version patch,版本号将变为1.0.0,publish指令:npm publish

image

git tag:

git tag 1.0.0

修复版本

接着会发布一个patch版本,请使用指令npm version patch,版本号将变为1.0.1,publish指令:npm publish

image

git tag:

git tag 1.0.1

minor版本

接着会发布一个minor版本,请使用指令npm version minor,版本号将变为1.1.0,publish指令:npm publish

image

git tag:

git tag 1.1.0

minor的beta版

发布1.2.0之前会发布一个1.2.0的beta版,此时请不要使用npm version prerelease --preid=beta,因为这会导致版本号变为1.1.1-beta.0,请使用指令npm version 1.2.0-beta.0直接指定,publish指令:npm publish --tag beta

image

git tag:

git tag 1.2.0-beta.0

第二个minor的beta版

接下来所有1.2.0的beta版都可以通过npm version prerelease --preid=beta指令自动升级,比如升级到1.2.0-beta.1,publish指令:npm publish --tag beta

image

git tag:

git tag 1.2.0-beta.1

第二个minor版

接着发布1.2.0稳定版,请使用指令npm version minor,版本号将变为1.2.0,publish指令:npm publish

image

git tag:

git tag 1.2.0

第二个major

接着发布2.0.0稳定版,请使用指令npm version major,版本号将变为2.0.0,publish指令:npm publish

image

git tag:

git tag 2.0.0

修正tag指向

如果发包时出现tag指向错误的情况,比如:当前包版本为1.0.0

image

发beta包时没有加--tag beta参数,tag指向将变为:

image

此时,可使用npm dist-tag add指令修改tag指向:

npm dist-tag add [email protected] latest
npm dist-tag add [email protected] beta

修改后tag指向:

1 0 1-beta 0

上面的例子已包括了常见的发包情形,后面的以此类推,请在发包时严格遵守。

参考文章

  1. node-semver
  2. npm-version

记一次域名备案的经历

背景

由于最近有个个人项目需要后端服务的支持,希望在国内提供稳定的服务,决定尝试购买阿里云的ECS云服务,而个人域名是通过name.com购买的。按理说只需要在添加一条A记录指向到阿里云机器的IP即可,但现实就是这么残酷,第一个晚上服务正常,隔天就被封了,提示我域名需要备案。备案是工信部的要求,跟阿里云并没有太大的关系,这一点需要搞清楚。
本文不会手把手教你如何备案,这类教程网上一大堆,而此文的重点是记录本次备案过程中遇到的问题,避免大家再次踩坑浪费时间。

备案限制

域名类型

不是所有顶级域名类型都可以在工信部备案的哦,我之前在name.com上购买的域名是canvast.me,它的顶级域名类型是.me,正好不在工信部支持的备案列表内,所以只能在阿里云上再购买一个支持备案的域名,我选择了.site的域名类型。点击此处查询哪些域名类型支持备案。

产品验证

通过阿里云备案的第一步就要进行产品验证,这个根据自己的需求决定,比如我的就是ECS
image
然而,在此之前我又给自己埋下了一个巨坑^_^。为了贪便宜,我用我弟的学生身份购买了一年的云服务,导致产品验证时没有服务实例可选,不得已以自己的名义再次购买了阿里云服务。如果您是学生或者24岁一下,那么可以考虑参与阿里云的云翼计划,只需要9.5元/月,太实惠了有没有?

服务实例累计时长

为了走完备案过程,我只购买了一个月的ECS服务,产品验证时就提示实例ID累计时长不足3个月
image
难道服务要先运行3个月后才能备案?这不合理啊。其实这里的不足指的是你购买的云服务服务时长,只需要再次购买2个月即可,阿里云真会赚钱...

备案补偿

备案成功后你会收到阿里云的补偿短信:

【阿里云】尊敬的用户:阿里云已为您的云服务器ECS 从2019-10-25 00:00:00免费续费到2019-11-05 00:00:00 。原因:备多久送多久。

本人从2019.6.25提交备案,2019.7.5工信部审核通过,刚好10个工作日,这一点阿里云还是很厚道的。

域名解析

阿里云控制台 --> 云解析DNS --> 解析设置 --> 添加记录 --> 选择A记录 --> 记录值填云服务实例的公网IP即可

总结

  1. 备案前请购买一个工信部支持备案的域名
  2. 至少购买3个月阿里云服务
  3. 确保该云服务在自己阿里云账号下

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.