应用容器化部署已经成为一个趋势,依托容器云自动调度平台(如k8s)能够快速实现应用的扩容和发布,本文简要介绍了在Kubernetes平台上,SpringBoot应用日志的一种解决方案。方案依托平台优势,优雅、简介、快速的实现应用日志的采集和分析。同时,对生产环境下日志的输出,详细介绍了生产环境下采用JSON格式输出日志配置全过程。

一、目标

  1. 依托Kubernetes平台日志采集管理能力(Loki + Promtail的云原生日志收集方案),将应用日志也纳入综合管理。
  2. 生产环境采用JSON输出简化日志解析,使得日志的后续处理、分析或查询变得方便高效,开发测试环境仍然扁平化输出
  3. 自定义JSON日志输出内容,微服务环境下日志包含链路信息。

二、应用日志架构设计

在这里插入图片描述

2.1 概要

本设计方案是在Kubernetes环境下,通过集成日志工具Loki+Promtail,使得容器云环境能够自动化采集集群内各Pod日志。Grafana作为可视化终端,通过链接Loki数据源,能够对采集的日志进行搜索和分析。其中:

  • Promtail: 日志收集工具,类比ELK中的Logstash
  • Loki: 日志聚合工具,类似ELK中Elasticsearch
  • Grafana:可视化工具,类比ELK中Kibana

应用通过输出日志到控制台,Promtail实时采集应用输出到控制台的日志,并发送至Loki。

这种方案

2.2 方案优势

轻量化

与ELK相比,大大减少了硬件资源的使用。适合中小集群监控。

在这里插入图片描述

与k8s原生结合

日志搜索可以通过k8s中资源label标签进行筛选。

三、实施

3.1 前置条件

3.1.1 环境准备

  • Kubernetes集群环境
  • Loki+Promtail+Grafana已集成到Kubernetes,并且能够采集到Pod日志
  • Spring Boot应用已部署到Kubernetes

3.2 日志JSON处理

**Logstash Logback Encoder **开源项目提供了Logback JSON encoder 和 appenders,这个类库最新详细用法参考项目文档介绍

FormatProtocolFunctionLoggingEventAccessEvent
Logstash JSONSyslog/UDPAppenderLogstashUdpSocketAppenderLogstashAccessUdpSocketAppender
Logstash JSONTCPAppenderLogstashTcpSocketAppenderLogstashAccessTcpSocketAppender
anyanyAppenderLoggingEventAsyncDisruptorAppenderAccessEventAsyncDisruptorAppender
Logstash JSONanyEncoderLogstashEncoderLogstashAccessEncoder
Logstash JSONanyLayoutLogstashLayoutLogstashAccessLayout
General JSONanyEncoderLoggingEventCompositeJsonEncoderAccessEventCompositeJsonEncoder
General JSONanyLayoutLoggingEventCompositeJsonLayoutAccessEventCompositeJsonLayout

根据文档说明,我们使用 LoggingEventCompositeJsonEncoder 来自定义Json Encoder。下面开始实战配置。

集成maven依赖

来源文档 https://github.com/logfellow/logstash-logback-encoder#including-it-in-your-project

<dependency>
    <groupId>net.logstash.logback</groupId>
    <artifactId>logstash-logback-encoder</artifactId>
    <version>7.2</version>
</dependency>
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
</dependency>
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-access</artifactId>
</dependency>
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-core</artifactId>
</dependency>
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
</dependency>

配置logback

在资源文件夹中创建logback-spring.xml文件,默认情况下将所有日志从控制台输出。

<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="5 seconds">
    <springProperty scope="context" name="appName" source="spring.application.name" defaultValue="unknown" />

    <!-- ConsoleAppender:把日志输出到控制台 -->
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
            <!--关键输出配置到这里-->
            ...
        </encoder>
    </appender>

    <!-- 控制台输出日志级别 -->
    <root level="INFO">
        <appender-ref ref="STDOUT"/>
    </root>
</configuration>

自定义LoggingEventCompositeJsonEncoder

配置说明:https://github.com/logfellow/logstash-logback-encoder#composite-encoderlayout

<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
    <providers>
        <timestamp>
            <timeZone>UTC+8</timeZone>
        </timestamp>
        <pattern>
            <omitEmptyFields>true</omitEmptyFields>
            <pattern>
                {
                    "timestamp": "%date{ISO8601}",
                    "service": "${appName}",
                    "level": "%level",
                    "pid": "${PID:-}",
                    "thread": "%thread",
                    "class": "%logger{60}",
                    "method": "%method",
                    "line": "%line",
                    "message": "#tryJson{%message}"
                }
            </pattern>
        </pattern>
        <stackTrace>
            <throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter">
                <maxDepthPerThrowable>100</maxDepthPerThrowable>
                <maxLength>20480</maxLength>
                <rootCauseFirst>true</rootCauseFirst>
            </throwableConverter>
        </stackTrace>
    </providers>
</encoder>

示例说明:

配置完成后,从控制台打印的日志

在这里插入图片描述

3.3 自定义日志信息

除了默认的日志输出内容外,在web应用场景下,我们希望将用户请求时来源IP和请求编号记录到日志中。

Mapped Diagnostic Context (MDC)
是Slf4j提供的一个API,主要功能就是在多线程环境下进行日志调用链路跟踪,使用起来也简单。

3.3.1 实现思路

  1. 通过在SpringBoot中定义拦截器,获取web请求的IP,初始化请求编号
  2. 在logback中定义日志输出,打印mdc附带的信息

3.3.2 代码实现

拦截器配置MDC

在Spring中定义拦截器的过程较为简单

import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;

@Slf4j
@Component
public class LogInterceptor implements HandlerInterceptor {

    private final static String REQUEST_ID = "requestId";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String xForwardedForHeader = request.getHeader("X-Forwarded-For");
        String remoteIp = request.getRemoteAddr();
        String uuid = UUID.randomUUID().toString();
        log.info("put requestId ({}) to logger", uuid);
        log.info("request id:{}, client ip:{}, X-Forwarded-For:{}", uuid, remoteIp, xForwardedForHeader);
        MDC.put(REQUEST_ID, uuid);
        MDC.put("remoteIp", remoteIp);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        String uuid = MDC.get(REQUEST_ID);
        log.info("remove requestId ({}) from logger", uuid);
        MDC.remove(REQUEST_ID);
        HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
    }
}

preHandle方法中,获取remoteIp并放入MDC中,同时初始化了请求ID,这里使用的是uuid。

注册拦截器到Spring

SpringMvc注册拦截器,不多解释,主要代码如下:

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
    private final LogInterceptor logInterceptor;
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(logInterceptor);
    }
}
配置MDC输出到日志

修改logback-spring.xml配置输出模版, 通过添加输出项"requestId": "%mdc{requestId}""remoteIP": "%mdc{remoteIp}"到模版

<pattern>
    {
        "timestamp": "%date{ISO8601}",
        ...
        "requestId": "%mdc{requestId}",
        "remoteIP": "%mdc{remoteIp}",
        ...
        "message": "#tryJson{%message}"
    }
</pattern>

重新部署应用,观察日志输出:

在这里插入图片描述

可以看到,日志输出中,已经包含了我们在mdc中自定义的属性。

3.4 日志多环境配置

使用多环境配置,在当前解决方案下,主要用来实现,生产环境日志输出JSON格式,开发环境日志输出采用默认的行日志,多环境配置比较简单,通过定义springProfile标签,name属性为环境名称,配置如下:

<springProfile name="default">
    <root level="INFO">
        <appender-ref ref="STDOUT"/>
    </root>
</springProfile>
<springProfile name="kubernetes">
    <root level="INFO">
        <appender-ref ref="PROD-STDOUT"/>
    </root>
</springProfile>

3.5 链路信息输出

在分布式环境下,应用之间的调用链路信息,我们希望也集成到JSON日志输出中,例如是使用Spring-Cloud-Sleuth,需要新增额外的链路信息到模版中,需要注意的是在Sleuth 3.0中,属性名称已经发生了一些变化。参考文档: https://github.com/spring-cloud/spring-cloud-sleuth/wiki/Spring-Cloud-Sleuth-3.0-Migration-Guide#x-b3–mdc-fields-names-are-no-longer-set

3.6 日志采集监控

在这里插入图片描述

上图是Grafana集成Loki后日志查询的搜索页面,支持JSON格式化输出。

3.7 日志搜索与分析

LogQL是Grafana Loki的promql启发的查询语言。https://grafana.com/docs/loki/latest/logql/

它提供了2种查询能力:

  • 查询返回的日志行
  • 对查询结果进行统计计算

四、完整配置

本方案 logback-spring.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="5 seconds">
    <springProperty scope="context" name="appName" source="spring.application.name" defaultValue="unknown"/>

    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %green(%-5level) %blue(%property{PID}) --- [%thread] %cyan(%-50logger{50}) : %msg%n</pattern>
        </encoder>
    </appender>

    <appender name="PROD-STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
            <providers>
                <timestamp>
                    <timeZone>UTC+8</timeZone>
                </timestamp>
                <pattern>
                    <omitEmptyFields>true</omitEmptyFields>
                    <pattern>
                        {
                            "timestamp": "%date{ISO8601}",
                            "requestId": "%mdc{requestId}",
                            "remoteIP": "%mdc{remoteIp}",
                            "service": "${appName}",
                            "level": "%level",
                            "pid": "${PID:-}",
                            "trace": "%X{X-B3-TraceId:-}",
                            "span": "%X{X-B3-SpanId:-}",
                            "parent": "%X{X-B3-ParentSpanId:-}",
                            "thread": "%thread",
                            "class": "%logger{60}",
                            "method": "%method",
                            "line": "%line",
                            "message": "#tryJson{%message}"
                        }
                    </pattern>
                </pattern>
                <stackTrace>
                    <throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter">
                        <maxDepthPerThrowable>100</maxDepthPerThrowable>
                        <maxLength>20480</maxLength>
                        <rootCauseFirst>true</rootCauseFirst>
                    </throwableConverter>
                </stackTrace>
            </providers>
        </encoder>
    </appender>

    <springProfile name="default">
        <root level="INFO">
            <appender-ref ref="STDOUT"/>
        </root>
    </springProfile>
    <springProfile name="kubernetes">
        <root level="INFO">
            <appender-ref ref="PROD-STDOUT"/>
        </root>
    </springProfile>

</configuration>

参考

Logo

开源、云原生的融合云平台

更多推荐