在 Spring Boot 应用中使用 Loki 记录日志

spring boot & Loki

在本文中,你将学习如何收集 Spring Boot 应用程序日志并将其发送到 Grafana Loki。为此,我们将使用 Loki4j Logback appender 功能。Loki 是一个受 Prometheus 启发的可水平扩展、高度可用的日志聚合系统。我将逐步展示如何配置应用程序与 Loki 之间的集成。不过,你也可以使用我自动配置的用于记录 HTTP 请求和响应的库,它将为你完成所有这些步骤。

源码

如果你想自己尝试,可以克隆我的 GitHub 仓库。点击 此处 查看包含我的自定义 Spring Boot 日志库的源代码仓库。然后按照我的说明操作即可。

使用 Loki4j Logback Appender

为了使用 Loki4j Logback Appender,我们需要在 Maven pom.xml 中加入一个依赖。该库的当前版本为 1.4.1

<dependency>
    <groupId>com.github.loki4j</groupId>
    <artifactId>loki-logback-appender</artifactId>
    <version>1.4.1</version>
</dependency>

然后,我们需要在 src/main/resources 目录下创建 logback-spring.xml 文件。我们的 Loki 实例在 http://localhost:3100 地址 (1) 下可用。Loki 不会索引日志内容,只会索引元数据标签。有一些静态标签,如应用程序名称、日志级别或主机名。我们可以在 format.label 字段 (2) 中设置它们。我们还将设置一些动态标签,因此要启用日志回溯标记功能 (3)。最后,我们将设置日志格式模式 (4)。为了简化 LogQL(Loki 查询语言)的潜在转换,我们将使用 JSON 符号。

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

  <springProperty name="name" source="spring.application.name" />

  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>
        %d{HH:mm:ss.SSS} %-5level %logger{36} %X{X-Request-ID} - %msg%n
      </pattern>
    </encoder>
  </appender>

  <appender name="LOKI" class="com.github.loki4j.logback.Loki4jAppender">
    <!-- (1) -->
    <http>
      <url>http://localhost:3100/loki/api/v1/push</url>
    </http>
    <format>
      <!-- (2) -->
      <label>
        <pattern>app=${name},host=${HOSTNAME},level=%level</pattern>
        <!-- (3) -->
        <readMarkers>true</readMarkers>
      </label>
      <message>
        <!-- (4) -->
        <pattern>
{
   "level":"%level",
   "class":"%logger{36}",
   "thread":"%thread",
   "message": "%message",
   "requestId": "%X{X-Request-ID}"
}
        </pattern>
      </message>
    </format>
  </appender>

  <root level="INFO">
    <appender-ref ref="CONSOLE" />
    <appender-ref ref="LOKI" />
  </root>

</configuration>

除了静态标签外,我们还可以发送动态数据,例如仅针对当前请求的特定数据。假设我们有一个管理人员的服务,我们希望记录请求中目标人员的 id。正如我之前提到的,使用 Loki4j,我们可以使用 Logback 标记来实现这一点。在经典的 Logback 中,标记主要用于过滤日志记录。使用 Loki,我们只需定义包含动态字段 key/value MapLabelMarker 对象 (1)。然后将该对象传递给当前日志行 (2)

@RestController
@RequestMapping("/persons")
public class PersonController {

    private final Logger LOG = LoggerFactory
       .getLogger(PersonController.class);
    private final List<Person> persons = new ArrayList<>();

    @GetMapping
    public List<Person> findAll() {
        return persons;
    }

    @GetMapping("/{id}")
    public Person findById(@PathVariable("id") Long id) {
        Person p = persons.stream().filter(it -> it.getId().equals(id))
                .findFirst()
                .orElseThrow();
        LabelMarker marker = LabelMarker.of("personId", () -> 
           String.valueOf(p.getId())); // (1)
        LOG.info(marker, "Person successfully found"); // (2)
        return p;
    }

    @GetMapping("/name/{firstName}/{lastName}")
    public List<Person> findByName(
       @PathVariable("firstName") String firstName,
       @PathVariable("lastName") String lastName) {
       
       return persons.stream()
          .filter(it -> it.getFirstName().equals(firstName)
                        && it.getLastName().equals(lastName))
          .toList();
    }

    @PostMapping
    public Person add(@RequestBody Person p) {
        p.setId((long) (persons.size() + 1));
        LabelMarker marker = LabelMarker.of("personId", () -> 
           String.valueOf(p.getId()));
        LOG.info(marker, "New person successfully added");
        persons.add(p);
        return p;
    }

    @DeleteMapping("/{id}")
    public void delete(@PathVariable("id") Long id) {
        Person p = persons.stream()
           .filter(it -> it.getId().equals(id))
           .findFirst()
           .orElseThrow();
        persons.remove(p);
        LabelMarker marker = LabelMarker.of("personId", () -> 
           String.valueOf(id));
        LOG.info(marker, "Person successfully removed");
    }

    @PutMapping
    public void update(@RequestBody Person p) {
        Person person = persons.stream()
                .filter(it -> it.getId().equals(p.getId()))
                .findFirst()
                .orElseThrow();
        persons.set(persons.indexOf(person), p);
        LabelMarker marker = LabelMarker.of("personId", () -> 
            String.valueOf(p.getId()));
        LOG.info(marker, "Person successfully updated");
    }

}

假设我们在单个日志行中有多个动态字段,我们就必须以这种方式创建 LabelMarker 对象:

LabelMarker marker = LabelMarker.of(() -> Map.of("audit", "true",
                    "X-Request-ID", MDC.get("X-Request-ID"),
                    "X-Correlation-ID", MDC.get("X-Correlation-ID")));

使用 Spring Boot 运行 Loki

在本地计算机上运行 Loki 的最简单方法是使用 Docker 容器。除了 Loki 实例,我们还将运行 Grafana 来显示和搜索日志。下面是包含所有所需服务的 docker-compose.yml。你可以使用 docker compose up 命令来运行它们。不过,还有另一种方法 - 直接使用 Spring Boot 应用程序。

docker-compose.yml:

version: "3"

networks:
  loki:

services:
  loki:
    image: grafana/loki:2.8.2
    ports:
      - "3100:3100"
    command: -config.file=/etc/loki/local-config.yaml
    networks:
      - loki

  grafana:
    environment:
      - GF_PATHS_PROVISIONING=/etc/grafana/provisioning
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
    entrypoint:
      - sh
      - -euc
      - |
        mkdir -p /etc/grafana/provisioning/datasources
        cat <<EOF > /etc/grafana/provisioning/datasources/ds.yaml
        apiVersion: 1
        datasources:
        - name: Loki
          type: loki
          access: proxy
          orgId: 1
          url: http://loki:3100
          basicAuth: false
          isDefault: true
          version: 1
          editable: false
        EOF
        /run.sh        
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    networks:
      - loki

为了利用 Spring Boot Docker Compose 支持,我们需要将 docker-compose.yml 放在应用程序根目录下。然后,我们必须在 Maven pom.xml 中加入 Spring-boot-docker-compose 依赖:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-docker-compose</artifactId>
  <optional>true</optional>
</dependency>

完成所有必要步骤后,我们就可以运行应用程序了。例如,使用以下 Maven 命令:

$ mvn spring-boot:run

此时,在应用程序运行之前,Spring Boot 会启动在 docker-compose.yml 中定义的容器。

spring-boot-loki-docker-compose

让我们只显示正在运行的容器列表。正如你所看到的,由于 Loki 监听的是本地端口 3100,所以一切正常:

$ docker ps            
CONTAINER ID   IMAGE                    COMMAND                  CREATED         STATUS         PORTS                    NAMES
d23390fbee06   grafana/loki:2.8.2       "/usr/bin/loki -conf…"   4 minutes ago   Up 2 minutes   0.0.0.0:3100->3100/tcp   sample-spring-boot-web-loki-1
84a47637a50b   grafana/grafana:latest   "sh -euc 'mkdir -p /…"   2 days ago      Up 2 minutes   0.0.0.0:3000->3000/tcp   sample-spring-boot-web-grafana-1

测试 Spring Boot REST 应用的日志记录

运行应用程序后,我们可以对 REST API 进行一些测试调用。首先,让我们添加一些 person:

$ curl 'http://localhost:8080/persons' \
  -H 'Content-Type: application/json' \
  -d '{"firstName": "AAA","lastName": "BBB","age": 20,"gender": "MALE"}'

$ curl 'http://localhost:8080/persons' \
  -H 'Content-Type: application/json' \
  -d '{"firstName": "CCC","lastName": "DDD","age": 30,"gender": "FEMALE"}'

$ curl 'http://localhost:8080/persons' \
  -H 'Content-Type: application/json' \
  -d '{"firstName": "EEE","lastName": "FFF","age": 40,"gender": "MALE"}'

然后,我们可以用不同的条件多次调用 “查询” 端点:

$ curl http://localhost:8080/persons/1
$ curl http://localhost:8080/persons/2
$ curl http://localhost:8080/persons/3

下面是控制台中的应用程序日志。只有简单的日志行,没有 JSON 格式。

简单的日志行

现在,让我们切换到 Grafana。我们已经配置了与 Loki 的集成。在新的仪表板中,我们需要选择 Loki。

spring-boot-loki-grafana-datasource

以下是存储在 Loki 中的应用程序日志的历史记录。

存储在 Loki 中的应用程序日志的历史记录

如你所见,我们使用 JSON 格式记录日志。某些日志行包含 Loki4j Logback appender 中的动态标签。

某些日志行包含 Loki4j Logback appender 中的动态标签

我们在一些日志中添加了 personId 标签,这样就可以轻松过滤只包含特定人员请求的记录。下面是过滤 personId=1 的记录的 LogQL 查询:

{app="first-service"} |= `` | personId = `1`

下面是 Grafana 面板上显示的结果:

Grafana 面板上显示的结果

我们还可以使用 LogQL 对日志进行格式化。由于采用了 JSON 格式,我们可以准备一个解析整个日志信息的查询。

{app="first-service"} |= `` | json

如你所见,现在 Loki 将所有 JSON 字段都视为元数据标签:

Loki 将所有 JSON 字段都视为元数据标签

使用 Spring Boot Loki Starter

如果你不想自己配置这些东西,可以使用我的 Spring Boot 库,它提供了自动配置功能。此外,它还会自动记录所有传入的 HTTP 请求和传出的 HTTP 响应。如果默认设置已经足够,你只需将单个 Spring Boot starter 作为依赖项即可:

<dependency>
  <groupId>com.github.piomin</groupId>
  <artifactId>logstash-logging-spring-boot-starter</artifactId>
  <version>2.0.2</version>
</dependency>

该库用多个默认标签记录每个请求和响应,例如 requestIdcorrelationId

logstash-logging-spring-boot-starter


参考:https://piotrminkowski.com/2023/07/05/logging-in-spring-boot-with-loki/