Node 框架接入 ELK 实践总结

欢迎你们前往腾讯云+社区,获取更多腾讯海量技术实践干货哦~数据库

本文由J2X发表于云+社区专栏浏览器

咱们都有过上机器查日志的经历,当集群数量增多的时候,这种原始的操做带来的低效率不只给咱们定位现网问题带来极大的挑战,同时,咱们也没法对咱们服务框架的各项指标进行有效的量化诊断,更无从谈有针对性的优化和改进。这个时候,构建具有信息查找,服务诊断,数据分析等功能的实时日志监控系统尤其重要。服务器

ELK (ELK Stack: ElasticSearch, LogStash, Kibana, Beats) 是一套成熟的日志解决方案,其开源及高性能在各大公司普遍使用。而咱们业务所使用的服务框架,如何接入 ELK 系统呢?app

业务背景

咱们的业务框架背景:框架

  • 业务框架是基于 NodeJs 的 WebServer
  • 服务使用 winston 日志模块将日志本地化
  • 服务产生的日志存储在各自机器的磁盘上
  • 服务部署在不一样地域多台机器

接入步骤

咱们将整个框架接入 ELK 简单概括为下面几个步骤:dom

  • 日志结构设计:由传统的纯文本日志改为结构化对象并输出为 JSON.
  • 日志采集:在框架请求生命周期的一些关键节点输出日志
  • ES 索引模版定义:创建 JSON 到 ES 实际存储的映射

1、日志结构设计

传统的,咱们在作日志输出的时候,是直接输出日志的等级(level)和日志的内容字符串(message)。然而咱们不只关注什么时间,发生了什么,可能还须要关注相似的日志发生了多少次,日志的细节与上下文,以及关联的日志。 所以咱们不仅是简单地将咱们的日志结构化一下为对象,还要提取出日志关键的字段。机器学习

1. 将日志抽象为事件

咱们将每一条日志的发生都抽像为一个事件。事件包含:性能

事件元字段

  • 事件发生时间:datetime, timestamp
  • 事件等级:level, 例如: ERROR, INFO, WARNING, DEBUG
  • 事件名称: event, 例如:client-request
  • 事件发生的相对时间(单位:纳秒):reqLife, 此字段为事件相对请求开始发生的时间(间隔)
  • 事件发生的位置: line,代码位置; server, 服务器的位置

请求元字段

  • 请求惟一ID: reqId, 此字段贯穿整个请求链路上发生的全部事件
  • 请求用户ID: reqUid, 此字段为用户标识,能够跟踪用户的访问或请求链路

数据字段

不一样类型的事件,须要输出的细节不尽相同,咱们将这些细节(非元字段)统一放到d -- data,之中。使咱们的事件结构更加清晰,同时,也能避免数据字段对元字段形成污染。学习

e.g. 如 client-init事件,该事件会在每次服务器接收到用户请求时打印,咱们将用户的 ip, url等事件独有的统一归为数据字段放到 d 对象中优化

举个完整的例子

{
    "datetime":"2018-11-07 21:38:09.271",
    "timestamp":1541597889271,
    "level":"INFO",
    "event":"client-init",
    "reqId":"rJtT5we6Q",
    "reqLife":5874,
    "reqUid": "999793fc03eda86",
    "d":{
        "url":"/",
        "ip":"9.9.9.9",
        "httpVersion":"1.1",
        "method":"GET",
        "userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36",
        "headers":"*"
    },
    "browser":"{"name":"Chrome","version":"70.0.3538.77","major":"70"}",
    "engine":"{"version":"537.36","name":"WebKit"}",
    "os":"{"name":"Mac OS","version":"10.14.0"}",
    "content":"(Empty)",
    "line":"middlewares/foo.js:14",
    "server":"127.0.0.1"
}
复制代码

一些字段,如:browser, os, engine为何在外层 有时候咱们但愿日志尽可能扁平(最大深度为2),以免 ES 没必要要的索引带来的性能损耗。在实际输出的时候,咱们会将深度大于1的值输出为字符串。而有时候一些对象字段是咱们关注的,因此咱们将这些特殊字段放在外层,以保证输出深度不大于2的原则。

通常的,咱们在打印输出日志的时候,只须关注事件名称数据字段便可。其余,咱们能够在打印日志的方法中,经过访问上下文统一获取,计算,输出。

2. 日志改造输出

前面咱们提到了如何定义一个日志事件, 那么,咱们如何基于已有日志方案作升级,同时,兼容旧代码的日志调用方式。

升级关键节点的日志

// 改造前
logger.info('client-init => ' + JSON.stringfiy({
    url,
    ip,
    browser,
    //...
}));

// 改造后
logger.info({
    event: 'client-init',
    url,
    ip,
    browser,
    //...
});
复制代码

兼容旧的日志调用方式

logger.debug('checkLogin');
复制代码

由于 winston 的 日志方法自己就支持 string 或者 object 的传入方式, 因此对于旧的字符串传入写法,formatter 接收到的其实是{ level: 'debug', message: 'checkLogin' }。formatter 是 winston 的日志输出前调整日志格式的一道工序, 这一点使咱们在日志输出前有机会将这类调用方式输出的日志,转为一个纯输出事件 -- 咱们称它们为raw-log事件,而不须要修改调用方式。

改造日志输出格式

前面提到 winston 输出日志前,会通过咱们预约义的formatter,所以除了兼容逻辑的处理外,咱们能够将一些公共逻辑统一放在这里处理。而调用上,咱们只关注字段自己便可。

  • 元字段提取及处理
  • 字段长度控制
  • 兼容逻辑处理

如何提取元字段,这里涉及上下文的建立与使用,这里简单介绍一下 domain 的建立与使用。

//--- middlewares/http-context.js
const domain = require('domain');
const shortid = require('shortid');

module.exports = (req, res, next) => {
    const d = domain.create();
    d.id =  shortid.generate(); // reqId;
    d.req = req;
    
    //...

    res.on('finish', () => process.nextTick(() => {
        d.id = null;
        d.req = null;
        d.exit();
    });

    d.run(() => next());
}

//--- app.js
app.use(require('./middlewares/http-context.js'));

//--- formatter.js
if (process.domain) {
    reqId = process.domain.id;
}
复制代码

这样,咱们就能够将 reqId 输出到一次请求中全部的事件, 从而达到关联事件的目的。

2、日志采集

如今,咱们知道怎么输出一个事件了,那么下一步,咱们该考虑两个问题:

  1. 咱们要在哪里输出事件?
  2. 事件要输出什么细节?

换句话说,整个请求链路中,哪些节点是咱们关注的,出现问题,能够经过哪一个节点的信息快速定位到问题?除此以外,咱们还能够经过哪些节点的数据作统计分析?

结合通常常见的请求链路(用户请求,服务侧接收请求,服务请求下游服务器/数据库(*屡次),数据聚合渲染,服务响应),以下方的流程图

img
流程图

那么,咱们能够这样定义咱们的事件:

用户请求

  • client-init: 打印于框架接收到请求(未解析), 包括:请求地址,请求头,Http 版本和方法,用户 IP 和 浏览器
  • client-request: 打印于框架接收到请求(已解析),包括:请求地址,请求头,Cookie, 请求包体
  • client-response: 打印于框架返回请求,包括:请求地址,响应码,响应头,响应包体

下游依赖

  • http-start: 打印于请求下游起始:请求地址,请求包体,模块别名(方便基于名字聚合并且域名)
  • http-success: 打印于请求返回 200:请求地址,请求包体,响应包体(code & msg & data),耗时
  • http-error: 打印于请求返回非 200,亦即链接服务器失败:请求地址,请求包体,响应包体(code & message & stack),耗时。
  • http-timeout: 打印于请求链接超时:请求地址,请求包体,响应包体(code & msg & stack),耗时。

字段这么多,该怎么选择? 一言以蔽之,事件输出的字段原则就是:输出你关注的,方便检索的,方便后期聚合的字段。

一些建议

  1. 请求下游的请求体和返回体有固定格式, e.g. 输入:{ action: 'getUserInfo', payload: {} } 输出: { code: 0, msg: '', data: {}} 咱们能够在事件输出 action,code 等,以便后期经过 action 检索某模块具体某个接口的各项指标和聚合。

一些原则

  1. 保证输出字段类型一致 因为全部事件都存储在同一个 ES 索引, 所以,相同字段不论是相同事件仍是不一样事件,都应该保持一致,例如:code不该该既是数字,又是字符串,这样可能会产生字段冲突,致使某些记录(document)没法被冲突字段检索到。
  2. ES 存储类型为 keyword, 不该该超过 ES mapping 设定的 ignore_above 中指定的字节数(默认4096个字节)。不然一样可能会产生没法被检索的状况

3、ES 索引模版定义

这里引入 ES 的两个概念,映射(Mapping)与模版(Template)。

首先,ES 基本的存储类型大概枚举下,有如下几种

  • String: keyword & text
  • Numeric: long, integer, double
  • Date: date
  • Boolean: boolean

通常的,咱们不须要显示指定每一个事件字段的在ES对应的存储类型,ES 会自动根据字段第一次出现的document中的值来决定这个字段在这个索引中的存储类型。但有时候,咱们须要显示指定某些字段的存储类型,这个时候咱们须要定义这个索引的 Mapping, 来告诉 ES 这此字段如何存储以及如何索引。

e.g.

还记得事件元字段中有一个字段为 timestamp ?实际上,咱们输出的时候,timestamp 的值是一个数字,它表示跟距离 1970/01/01 00:00:00 的毫秒数,而咱们指望它在ES的存储类型为 date 类型方便后期的检索和可视化, 那么咱们建立索引的时候,指定咱们的Mapping。

PUT my_logs
{
  "mappings": {
    "_doc": { 
      "properties": { 
        "title":    {
            "type": "date",
            "format": "epoch_millis"
         }, 
      }
    }
  }
}
复制代码

但通常的,咱们可能会按日期自动生成咱们的日志索引,假定咱们的索引名称格式为 my_logs_yyyyMMdd (e.g. my_logs_20181030)。那么咱们须要定义一个模板(Template),这个模板会在(匹配的)索引建立时自动应用预设好的 Mapping。

PUT _template/my_logs_template
{
  "index_patterns": "my_logs*",
  "mappings": {
    "_doc": { 
      "properties": { 
        "title":    {
            "type": "date",
            "format": "epoch_millis"
         }, 
      }
    }
  }
}
复制代码

提示:将全部日期产生的日志都存在一张索引中,不只带来没必要要的性能开销,也不利于按期删除比较久远的日志。

小结

至此,日志改造及接入的准备工做都已经完成了,咱们只须在机器上安装 FileBeat -- 一个轻量级的文件日志Agent, 它负责将日志文件中的日志传输到 ELK。接下来,咱们即可使用 Kibana 快速的检索咱们的日志。

相关阅读 【每日课程推荐】机器学习实战!快速入门在线广告业务及CTR相应知识

此文已由做者受权腾讯云+社区发布,更多原文请点击

搜索关注公众号「云加社区」,第一时间获取技术干货,关注后回复1024 送你一份技术课程大礼包!

海量技术实践经验,尽在云加社区

相关文章
相关标签/搜索