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_hooks
与elastic-apm-node
,其中的elastic-apm-node
已经帮我们创建好同步或异步流程中的Transaction与Span。大部分时候,我们只需写好自己的业务代码。
样例代码:apm-nodejs-app-a、apm-nodejs-app-b、apm-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
截图如下: