软件技术学习笔记

个人博客,记录软件技术与程序员的点点滴滴。

NodeJS微服务APM

上微服务时,我们必需有全链路追踪、应用程序性能监测(APM)。开源的Elastic Stack是一个不错的选择,它包含日志、指标、APM、搜索、Kibana等。使用Elastic Stack做NodeJS APM时,使用起来非常方便,代码也无入侵。

只需在程序的前面添加一行代码: const apm = require('elastic-apm-node').start(); 然后添加环境变量ELASTIC_APM_SERVER_URL,正常启动程序: node index.js

能如此方便地使用,也是得益于NodeJS的事件模型、async_hookselastic-apm-node,其中的elastic-apm-node已经帮我们创建好同步或异步流程中的Transaction与Span。大部分时候,我们只需写好自己的业务代码。

样例代码:apm-nodejs-app-aapm-nodejs-app-bapm-nodejs-app-c

1. 一个简单的完整例子

// apm-nodejs-app-a/index.js
const axios = require('axios').default;
const apm = require('elastic-apm-node').start({
  ignoreUrls: ['/healthz'],
});

const app = require('express')();

function logErrors (err, req, res, next) {
  console.error(err.stack);
  next(err);
}

function clientErrorHandler (err, req, res, next) {
  if (req.xhr) {
    res.status(500).send({ error: 'Something failed!' });
  } else {
    next(err);
  }
}

function errorHandler (err, req, res, next) {
  res.status(500);
  res.render('error', { error: err });
}

app.use(logErrors);
app.use(clientErrorHandler);
app.use(errorHandler);

app.get('/healthz', function (req, res) {
  res.send({
    msg: 'OK',
  });
});

app.get('/', async function (req, res, next) {
  try {
    const resAll = await Promise.all([
      axios.get('http://apm-nodejs-app-b:8080/api/hello-a-b'),
      axios.get('http://apm-nodejs-app-c:8080/api/hello-a-c'),
    ]);

    res.send({
      a: 'Hello World!',
      b: resAll[0].data,
      c: resAll[1].data,
    });
  }
  catch (err) {
    next(err);
  }
});

app.listen(8080);

2. HTTP处理解读

我们就看看elastic-apm-node如何处理与发送http请求。主要源码是:

  • elastic-apm-node/lib/instrumentation/modules/http.js
  • elastic-apm-node/lib/instrumentation/http-shared.js
  • elastic-apm-node/lib/instrumentation/async-hooks.js

2.1. 处理HTTP Request

require('elastic-apm-node').start()函数中,会执行到http.js,包装http.Server.prototype.emit()方法,对event === 'request'的事件创建新的Transaction:

// http-shared.js
var traceparent = req.headers['elastic-apm-traceparent'] || req.headers.traceparent
var trans = agent.startTransaction(null, null, {
  childOf: traceparent
})

同时,绑定req与res的事件到新的Transaction:

// http-shared.js
ins.bindEmitter(req);
ins.bindEmitter(res);

2.2. 发送异步请求

主要是包装 http.request 与 http.get 方法,创建新的Span:

// http-shared.js
var span = agent.startSpan(null, 'external', moduleName, 'http')

NOTE: 看得这里很容易疑惑新的Span是否携带正确的parent。要解开这个疑惑,需看看下面的Async Hooks。

2.3. Async Hooks

NodeJS提供async_hooks,可以在异步资源创建时、执行前或销毁后执行一些动作。elastic-apm-node就是在init钩子函数中存储当前异步资源创建时的Transaction与Span。然后重新定义 ins 的currentTransaction、activeSpan的get/set方法,包装ins的addEndedTransaction方法。

对http.Server的网络请求处理,先执行emit event === 'request'创建Transaction,后续创建的异步资源都有了Transaction与Span上下文,最后执行我们业务代码express的handler,不会有异步处理脱离APM。

NOTE: Span必需在Transaction中创建。Express handler处理完成时,请求处理的Transaction将结束。如果我们需要执行额外的定时任务,需要创建独立Transaction。

3. 创建自己的Span

有的时候,我们需要关注某段的性能,而不仅仅是微服务间的调用性能。只需在开始的位置创建新的Span,在结束的位置span.end()。新建的Span自动关联parent。

因elastic-apm-node创建的APM是单例agent,新建的Span被当作激活Span。为了方便返回原来的Span,我们创建一个ApmContext来辅助。

// apmContext.js
const { getAPM } = require("./apm");

class ApmContext {
  constructor() {
    const ins = getAPM()._instrumentation;

    this.ins = ins;
    this.trans = ins.currentTransaction;
    this.span = ins.currentSpan;

    this.prevTrans = null;
    this.prevActiveSpan = null;
  }

  active(sync = false) {
    const ins = this.ins;
    this.prevTrans = ins.currentTransaction;
    this.prevActiveSpan = ins.activeSpan

    ins.currentTransaction = this.trans;
    ins.bindingSpan = null;
    ins.activeSpan = this.span;

    if (this.trans) {
      this.trans.sync = sync;
    }

    if (this.span) {
      this.span.sync = sync;
    }
  }

  inactive() {
    const ins = this.ins;

    ins.currentTransaction = this.prevTrans;
    ins.bindingSpan = null;
    ins.activeSpan = this.prevActiveSpan;
  }
}

module.exports = {
  ApmContext,
};

完整例子的代码:

// apm-nodejs-app-b/index.js
const { startAPM, getAPM } = require('./apm');
startAPM();

const axios = require('axios').default;
const app = require('express')();
const { ApmContext } = require('./apmContext');

function logErrors (err, req, res, next) {
  console.error(err.stack);
  next(err);
}

function clientErrorHandler (err, req, res, next) {
  if (req.xhr) {
    res.status(500).send({ error: 'Something failed!' });
  } else {
    next(err);
  }
}

function errorHandler (err, req, res, next) {
  res.status(500);
  res.render('error', { error: err });
}

app.use(logErrors);
app.use(clientErrorHandler);
app.use(errorHandler);

app.get('/healthz', function (req, res) {
  res.send({
    msg: 'OK',
  });
});

app.get('/api/:msg', async function (req, res, next) {
  try {
    const resAll = await Promise.all([
      axios.get('http://apm-nodejs-app-c:8080/api/hello-b-c-1'),
      axios.get('http://apm-nodejs-app-c:8080/api/hello-b-c-2'),
    ]);

    const amp = getAPM();
    const apmContext = new ApmContext();

    // outer span
    const spanOuter = amp.startSpan('outer');

    let num = 0;

    for (let i = 0; i < 1000; i++) {
      for (let j = 0; j < 1000; j++) {
        num += i + j;
      }
    }

    // inner span
    const spanInner = amp.startSpan('inner');

    for (let i = 0; i < 1000; i++) {
      for (let j = 0; j < 1000; j++) {
        num += i + j;
      }
    }

    if (spanInner) {
      spanInner.end();
    }

    // end inner span

    let c3;

    try {
      c3 = await axios.get('http://apm-nodejs-app-c:8080/api/hello-b-c-3');
    }
    catch (e) {
      throw e;
    }
    finally {
      if (spanOuter) {
        spanOuter.end();
      }
    }

    // recover old span
    apmContext.active();

    res.send({
      b: req.params.msg,
      c1: resAll[0].data,
      c2: resAll[1].data,
      c3: c3.data,
    });
  }
  catch (err) {
    next(err);
  }
});

app.listen(8080);
// apm-nodejs-app-c/index.js
const axios = require('axios').default;
const apm = require('elastic-apm-node').start({
  ignoreUrls: ['/healthz'],
});

const app = require('express')();

app.get('/healthz', function (req, res) {
  res.send({
    msg: 'OK',
  });
});

app.get('/api/:msg', async function (req, res) {
  res.send({
    msg: req.params.msg,
  });
});

app.listen(8080);

4. APM截图

我们的微服务调用流程如下:

a  -->  b  -->     c
        |  -->     c
        |     -->  c
|  -->             c

截图如下:

NodeJS APM