日志处理平台可以分成三个部分:
shipper:日志收集 broker:中间件/队列 indexer:日志存储
日志的功能侧重点:
1.快速定位线上log,快速差错,
2.对日志进行数据处理,提取整理日志中的重要数据
3.提取用户请求整个访问流程的日志,便于分析
最开始考虑了几种架构方案elk+kafka或者reids+mongodb,但后来根据实际业务场景考虑了一下,目前项目平台每日产生的日志不足1G,往极端方向考虑,假设用户每天都集中在4个小时处理相关业务,即1024/4*60*60=0.07M/S,每秒日志的吞吐量还不到100kb,使用kafka过于重量级,就算考虑业务爆发,日志吞吐量扩充10倍也不过0.7M/S,单台redis完全够用。
mongodb也比较适合日志存储,但不如elk来的专业,而且还需要手写查询接口对外发布,比较麻烦,检索功能及吞吐量比es查了几个量级,因此不予采用。
最后采用了slf4J+logstash+redis+logstash+elasticSearch+kibana,这里redis最好做一下主从,毕竟用redis做队列可靠性不是那么高,日志丢了就没了,不像es可以分片,当然如果日志不是那么重要也无所谓了。logstash号称支持一切input,日志组件、tcp、udp、各种队列都可以作为日志输入源,logstash支持多输入源,一个logstash实例可以监控整台web服务器。logstash主要分为三部分input、filter、output,input和output格式可以自定义,可以通过codec进行编码或直接使用ruby库解析键值对进行输入输出。filter插件也特别强大,grok插件解析时间或字符串,mutate解析数据格式,同时还支持ruby插件,导入ruby的库,直接在配置文件里用ruby解析数据,可以说是无所不能了。
规范整个日志业务之前第一步就是先把以前的日志格式统一起来,如果日志格式不统一,那下一步的日志解析存储就毫无意义了。elk搭好之后需要应用于多个项目,每个项目日志又分为业务日志、dao日志,也可能会把nginx的请求日志也导入es中,
另外老项目的dao层比较乱,最老的代码直接从mapper文件解析sql运行的,而新代码已经把dao层封装起来了,提供一些关键字拼接的api,最后也是通过mapper文件发送sql到db里执行的。这里会有一个兼容性的问题,即老的dao层日志是直接用
mybatis的日志模块输出的,和业务日志分成两个文件。而新的dao层日志应该和业务日志整合到一个文件,按天分割。所以这里在导入es时要考虑也要为mybatis日志模块输出的log配置映射模板。
最终采用的log格式为:调用时间-log等级-类名-web容器线程ID-hostName/ip-appName--requestIP-requestID-accountID-操作-出入参数
hostName/IP用于定位线上的web实例,当hsostName未设置时取服务器的局域网ip
appName用于区分多个项目
requestID是为用户请求生成的唯一ID,用于追踪用户的整个访问流程。生成策略为request的hashcode+当前时间戳&16进制
上面的log格式中需要手动填入的只有log操所和出入参数,其他均集成到log4j2中。
log4j里通过代码方式动态追加参数可以通过重写patternConverter实现:public class ExtPatternConverter extends PatternConverter { private String cfg; @Autowired public ExtPatternConverter(FormattingInfo fi, String cfg) { super(fi); this.cfg = cfg; } //重写patternConverter的日志参数转换方法 @Override protected String convert(LoggingEvent event) { System.out.println("start char convert"); Map<String, Object> valueMap = ExtPatternParser.TH_LOCAL.get(); if (valueMap != null) { Object value = valueMap.get(cfg); if (value != null) { return String.valueOf(value); } } return "no exist"; } }
public class ExtPatternLayout extends PatternLayout { public ExtPatternLayout(String pattern) { super(pattern); System.out.println("pattern is null ???:" + pattern); } public ExtPatternLayout() { super(DEFAULT_CONVERSION_PATTERN); } //获取pattern标签的日志格式,包含了我们自定义的字符 @Override protected PatternParser createPatternParser(String pattern) { System.out.println("pattern is:" + pattern); return new ExtPatternParser(pattern == null ? DEFAULT_CONVERSION_PATTERN : pattern); } }
public class ExtPatternParser extends PatternParser { public static final ThreadLocal<Map<String, Object>> TH_LOCAL = new ThreadLocal<Map<String, Object>>(){ @Override protected HashMap<String, Object> initialValue() { return new HashMap<String, Object>(); } }; //传入自定义日志参数 public static void setCurrentValue(String key, Object value) { Map<String, Object> map = TH_LOCAL.get(); map.put(key, value); } public ExtPatternParser(String pattern) { super(pattern); } //把自定义的参数转换进去 @Override protected void finalizeConverter(char c) { if (c == '#') { System.out.println("char handler"); String option = super.extractOption(); //传入自定义的patternConverter addConverter(new ExtPatternConverter(this.formattingInfo,option)); currentLiteral.setLength(0); } else { super.finalizeConverter(c); } } }
但在log4J2中该方法已经不行了,但可以通过log4J2的插件来实现,反而简单了一些:
@Plugin(name = "LogIdPatternConverter", category = PatternConverter.CATEGORY) @ConverterKeys({"y", "logParams"}) @Component public class LogParamAppender extends LogEventPatternConverter { private static final LogParamAppender INSTANCE = new LogParamAppender(); public static final String SPLIT ="|"; public static LogParamAppender newInstance(final String[] options) { return INSTANCE; } private LogParamAppender() { super("logParams", "logParams"); } @Override public void format(LogEvent event, StringBuilder toAppendTo) { toAppendTo.append(RequestIDBuilder.buildHostName()).append(SPLIT).append(RequestIDBuilder.buildAppName()).append(SPLIT).append(RequestIDBuilder.buildRequestID()); } }
“y”是在log4j2的xml配置文件里的patternLayout标签里自定义的pattern字符,运行时会将format方法中的toAppendTo参数替换进去。注意静态变量INSTANCE和newInstance方法一定要提供。
另外有一点是在生成requestID时,需要注意requestID是为每一个进入web server的请求生成一个唯一ID,是和request请求绑定的,所以这里可以先为request生成一个唯一ID,然后绑定到threadLocal上,需要注意的是,threadLocal是线程变量副本,正常情况下thread与request请求是一对一的关系,但由于web server的线程池的线程复用以及thread的生命周期较长,可能会产生一个thread同步处理多个request的情况,导致生成的requestID重复,无法正常定位用户的请求流程。所以可以使用一个与request生命周其较为吻合的filter,在dofilter方法中生成requestID并赋值到threadLocal中,destory时销毁该requestID。threadLocal中的变量销毁也要注意其内存泄漏的问题,threadLocal中的threadLocalMap的entry虽然实现了弱引用,但在可能会产生threadLocalMap中的key被GC掉为null,而value依然存在的情况,而如果这时该线程迟迟得不到结束,很可能会导致内存泄漏,因此threaLocal使用完成后一定要注意调用remove方法销毁。
总体的架构如下:
第一级logstash配置文件:
input { file { path => [ "/usr/local/apache-tomcat-8.5.31/logs/xxx-log/*.log" #start_position => "beginning" #sincedb_path => "/home/es/sincedb/apk" ] codec => multiline { #need deal with java stack exception with multiline pattern => "^\s" #negate:is used if cannot match this pattern,what:how to merge multiline message,like "previous" or "next" negate => false what =>"previous" #this is a problem in logstash lead to the end of this message like java stack exception cannot append to previous event,it will be print with next message #so you have to set auto_flush time,force logstash clear it buffer auto_flush_interval => 10 } } # stdin { #codec => plain{ charset => "GBK" } # } #tcp { # host => "0.0.0.0" # port => 9520 # #codec => plain { charset => "GBK" } # codec => json #} } filter { mutate { #add_field => { # "type"=>"daoLog" # } remove_field =>["tags"] } #json { # source => "message" # #add_field => ["type", "%{dtype}"] # remove_field => [ "endOfBatch", "loggerFqcn", "threadPriority" ] # } } output { redis { host => localhost port => 6379 db => 8 data_type => "channel" key => "logstash_db_0" # codec => plain{ charset => "GBK" } # codec =>rubydebug } stdout { codec =>rubydebug } }
java堆栈异常可以用multiLine插件处理,通过正则将日志首行以及以下的堆栈日志合并为一条message,需要注意可能会产生第一次请求不发送堆栈日志,第二次请求才追加堆栈日志的情况,原因是堆栈日志被存储在logstash的buffer区中未发送,而是随着第二条messge发送过去了,因此可以手动清空buffer区,强制发送日志。
logstash也可以通过tcp的形式直接发送日志信息,但需要在log4J2配置文件中配置socket根节点,之后日志将不会被追加到文件中,而是直接通过tcp发送到logstash。
stdout输出中的codec=>rebudebug即通过ruby将log输出为键值的格式。便于控制台调试。
input { #file { # path => [ # "/usr/local/apache-tomcat-8.5.31/logs/icu-log/*.log" # #start_position => "beginning" # #sincedb_path => "/home/es/sincedb/apk" # ] # } redis { host => "192.168.1.109" port => 6379 db => 8 data_type => "channel" key => "logstash_db_0" # codec => plain{ charset => "GBK" } # codec =>rubydebug } # stdin { #codec => plain{ charset => "GBK" } # } #tcp { # host => "0.0.0.0" # port => 9520 # #codec => plain { charset => "GBK" } # codec => json #} } filter { mutate { split => ["message", "|"] add_field => { "time"=>"%{[message][0]}" } add_field => { "logLevel"=>"%{[message][1]}" } add_field => { "location"=>"%{[message][2]}" } add_field => { "logType"=>"%{[message][3]}" } add_field => { "webThreadID"=>"%{[message][4]}" } add_field => { "hostName/IP"=>"%{[message][5]}" } add_field => { "appName"=>"%{[message][6]}" } add_field => { "requestIP"=>"%{[message][7]}" } add_field => { "requestID"=>"%{[message][8]}" } add_field => { "log"=>"%{[message][9]}" } remove_field =>["message"] } #json { # source => "message" # #add_field => ["type", "%{dtype}"] # remove_field => [ "endOfBatch", "loggerFqcn", "threadPriority" ] # } } output { stdout { codec =>rubydebug } elasticsearch { #template => "/usr/local/logstash-2.3.2/conf/mapper-template.json" template_name => "crawl" #codec => json hosts => "127.0.0.1:9200" } }
mutate插件可以直接从messge中切割字符串取值并拼成字段发送给es。完成后可以把原有的message字段remove掉。这是业务日志和dao层日志的处理。至于nginx的access日志,可以直接用grok插件匹配,grok插件提供了大量封装好的字段类型,直接通过表达式匹配即可。
默认从logstash拼成字段发送过去后,es自动根据动态模板映射你的数据,字段类型也是自动推导,但有时候数据的同一字段类型不同可能导致异常,所以也可以手写映射模板,根据你自己的意愿映射数据。但如果日志格式有多个规则,需要你根据不同规则添加不同字段以在logstash里区分开选择不同的模板。记得关闭es的自动映射manage_template =>false,或者用template_overwrite覆盖自动生成的模板。
output { if [type] == "log_01" { elasticsearch { cluster => 'elasticsearch' host => 'x.x.x.x' index => 'log_01-%{+YYYY-MM-dd}' port => '9300' workers => 1 template => "/data/logstash/conf/template_01.json" template_name => "template_01.json" template_overwrite => true } } if [type] == "log_02" { elasticsearch { cluster => 'elasticsearch' host => 'x.x.x.x' index => 'log_02-%{+YYYY-MM-dd}' port => '9300' workers => 1 template => "/data/logstash/conf/template_02.json" template_name => "template_01.json" template_overwrite => true } } }
Logstash及ElasticSearch安装:
https://blog.csdn.net/u012373815/article/details/51029826
ES默认禁止root用户启动的解决方法(ES2一下的版本没有这个问题):
https://blog.csdn.net/mengfei86/article/details/51210093
1.ES启动后可以在本地访问但无法通过公网访问解决方法:
在elasticsearch.yml配置文件中打开地址和端口号设置,否则ES默认以localhost运行
2.使用kibana的话注意其版本号与ES要对应,安装后修改kibana.yml中的ip/端口号/es地址即可运行
3.elasticSearch后台运行:
bin目录下执行./elasticSearch -d
查看是否启动成功:ps aux|grep elasticSearch
kibana后台运行:
nohup bin/kibana &
使用&可以ctrl+c停止,如果有大量elk实例的话最好写启动脚本。
4.es和kibana的安全问题:当时es被设计时是被考虑在内网环境下使用的,如果你的es部署在公网下,一定记得加密,可以使用es security插件(原shield),但是只有一个月试用期,过期后对集群收费,但对基本的api似乎没有影响。shield安装部署,但建议不要将es部署在公网上,参考es安全加固
5.如果es采用了每天动态生成索引的方法收集日志,记得定期删除不常用的索引,比如只索引三个月以内的日志,可以用curator插件实现。也可以用python做成定时任务,py脚本如下:
#!/usr/bin/env python # coding: utf-8 # author: yd import urllib import urllib2 import re import datetime import time def http_get(url_get): #获取elasticsearch的所有索引 request_get = urllib2.Request(url_get) response_get = urllib2.urlopen(request_get).read() print("get elasticSearch log server indexes") return response_get def match(response_get): pattern = re.compile(r'\d+\.\d+\.\d+') res_match = pattern.findall(response_get) date_now = datetime.date.today() #获取当前日期 days_before_30 = date_now - datetime.timedelta(days=30) #获取30天前的日期 date_format = days_before_30.__format__('%Y.%m.%d') #对日期进行格式化输出,例:2018.04.23 if date_format in res_match: print("delete index: logstash-"+date_format) http_delete(date_format) print("delete index success") else: pass def http_delete(url_delete_date): #删除30天前的索引 url_delete = 'http://localhost:9200/logstash-%s?pretty' % (url_delete_date) request_delete = urllib2.Request(url_delete) request_delete.get_method = lambda:'DELETE' #设置HTTP请求方式 response_delete = urllib2.urlopen(request_delete).read() return response_delete if __name__ == '__main__': print("start delete index more than 30 days") url_get = 'http://localhost:9200/_cat/indices?v' match(http_get(url_get)) #cron定时 #0 0 1 1/1 * ? * root py py path> py log path