云原生 PLG 云原生日志系统在压测中的落地实践

少年 · 2022年08月31日 · 最后由 cooling 回复于 2023年03月18日 · 44496 次阅读
本帖已被设为精华帖!

目录

前言

自研上云,降本增效。

这句话基本上能概括整个 2022 年的格调。

除了小白的科普文 《云原生系列专题分享合集》 去讲一些比较细的知识点和概念以外,也会单独分享一些我在性能压测上的云原生落地实践,希望是最佳实践。

业务背景

在压测的过程中,压测日志往往是绕不开的坎。

这个东西说重要也不重要,主要还是看需不需要用到。

如果能够顺顺利利压上 QPS 且 0% 错误率,那基本上不会有用户关心压测日志是什么。

然而很多时候现实和理想往往存在差距,比如怎么错误率一下子就上来了,怎么 QPS 又压不上去了,到底是什么地方有瓶颈有报错?

这个时候,往往就需要对压测日志做一个排查。

按照传统的排查方法:

  1. 客户反馈压测出现报错,想查看错误信息。
  2. 找到负责压测业务的开发运维倒出这个时间段压测机的全部日志。
  3. 手工筛选出对应的压测机,对应的压测日志。
  4. 手工提取到错误信息,反馈给客户。
  5. 客户再压测,再要求查看错误信息,重复上诉操作。

这个流程的繁杂,人工的损耗,成本是很高的。

所以,一般会在压测展示面板中,通过 Beanshell 的断言设置,写入到 Influxdb,再聚合到 Grafana 去查找。

但是这样还是会有两个重大的缺陷。

  1. 错误日志只囊括了 Beanshell 写入的断言异常,而对于 Jmeter 自己执行出错的日志,无法展示。
  2. 错误日志 Response 如果带上了 Timestamp,TraceId 等可变字段,将无法聚合统计,大量报错会瞬间卡死 Grafana 面板。

说到底,Influxdb 本就是个时序性数据库,不擅长处理日志,这也就有了想设计一套能落地到压测排障的日志检索聚合系统的初衷。

ELK

说起日志系统,不得不提 ELK( Elasticsearch + Logstash + Kibana )。

Elasticsearch 是个开源分布式搜索引擎,对非结构化的数据,也能进行分词,倒排索引,从而提供快速的全文搜索。

所以这里存储的索引,是一个巨大的量级。

更关键的是,Elasticsearch 需要的是内存!

Elasticsearch 底层存储引擎是基于 Lucene,Lucene 的倒排索引,需要先在内存里生成,然后定期以段文件的形式刷到硬盘里。

所以 Elasticsearch 的堆内存最好不要超过物理机内存的一半,需要预留一半的内存给 Lucene 使用。

好用是好用,就是真的贵,构建一个 ELK 都比压测机的成本高多了。

所以在降本增效的主题下,PLG 成为了新贵!

PLG

PLG( Promtail + Loki + Grafana ),一个轻量级的云原生日志检索系统。

来看下 Loki 的官方架构图:

这里我对相关组件做了简单的标记讲解,可以看到,Loki 虽小,但五脏俱全,支持分布式扩展,支持读写分离。

对于数据,Loki 不会构建全文索引,而是通过 Label 的方式去构建索引,并通过 Grep 查询匹配对应的 COS。

对于存储,Index 和 Chunks,是可以刷到廉价的第三方 COS 对象存储中。

这样跟 ELK 一对比,成本的优化是巨大的。

且在 Loki 2.0 版本之后,对于 Boltdb 存储索引做了较大的升级,采取 Boltdb-shipper 模式,可以直接让 Loki 索引存储在 S3 中,无需 Cassandra。

考虑到当前压测日志业务场景无需支撑 TB 级别的检索,所以设计一套读写分离架构的非分布式 Loki 服务即可。

且原压测项目中本身就是用了 CFS 作为多个压测机的数据共享,所以再省一笔 COS 的费用,直接用 Minio 挂 CFS 提供对 Loki 的 S3 支持。

以上便是我引入 Loki 之后,新设计的复合型数据存储方案。

它跟原生 Jmeter + Influxdb + Grafana 方案相比,避免了大量日志直接写入 Influxdb 导致内存激增,反而利用 Loki 对 Jmeter 的日志做 Label 聚合查询,实现了 Performance + Log 复合数据展示的 Grafana 图表。

落地实践

1-Promtail 持久化

Promtail 支持对 Pod 日志的采集,也支持静态文件路径的采集,根据我当前的业务场景,更需要的是后者。

positions:
  filename: /tmp/positions.yaml

scrape_configs:
  - job_name: jmeter
    static_configs:
      - targets:
          - localhost
        labels:
          __path__: /jmeter/*/*log

通过 scrape_configs 可以配置静态路径。

这里的 __path__ 可使用 * 来做正则匹配,但要注意,其他的正则表达式在此无法生效。

每次 Promtail 启动,便会使用 fsnotify 对 __path__ 下的文件做监听,并构建 goroutine 对活跃的文件进行 tail -f 的读取。

所以 Promtail 不会一次性读完大文件,需要一个文件来存储它对所读文件 offset 的标记,也就是所谓的 /tmp/positions.yaml

里面的数据类似这样的:

positions:
  /jmeter/1/10.0.0.10.log: "6616"
  /jmeter/1/10.0.0.11.log: "7717"

所以对这个文件需要做持久化挂载,不然重启之后,Promtail 会重新扫描全部的存量日志,如果量大会直接压垮 Loki-Gateway。

2-Label 自定义

Label 是我们查询的关键,我们需要利用 Promtail 的 Pipeline 来对数据进行过滤和打标签。

对于结构化的数据,Promtail 会有提供很多便捷的过滤器,比如 json, docker 等等。

但是很多时候我们往往是需要聚合处理非结构化的数据,或者是混合数据。

这种时候往往就需要自己去写正则表达式去匹配。

现在我们来看下 Jmeter 的日志:

2022-08-03 22:49:36,044 INFO o.a.j.u.JMeterUtils: Setting Locale to en_EN
2022-08-03 22:49:36,056 INFO o.a.j.JMeter: Loading user properties from: /jmeter/apache-jmeter-5.4.3/bin/user.properties
2022-08-03 22:49:36,056 INFO o.a.j.JMeter: Loading system properties from: /jmeter/apache-jmeter-5.4.3/bin/system.properties
2022-08-03 22:49:36,062 INFO o.a.j.JMeter: Copyright (c) 1998-2021 The Apache Software Foundation
2022-08-03 22:49:36,062 INFO o.a.j.JMeter: Version 5.4.3
2022-08-03 22:49:36,063 INFO o.a.j.JMeter: java.version=1.8.0_312
2022-08-03 22:49:36,063 INFO o.a.j.JMeter: java.vm.name=OpenJDK 64-Bit Server VM
2022-08-03 22:49:36,063 INFO o.a.j.JMeter: os.name=Linux
2022-08-03 22:49:36,063 INFO o.a.j.JMeter: os.arch=amd64
2022-08-03 22:49:36,063 INFO o.a.j.JMeter: os.version=4.14.105-1-tlinux3-0023
2022-08-03 22:49:36,063 INFO o.a.j.JMeter: file.encoding=UTF-8
2022-08-03 22:49:36,063 INFO o.a.j.JMeter: java.awt.headless=true
2022-08-03 22:49:36,063 INFO o.a.j.JMeter: Max memory     =8589934592
2022-08-03 22:49:36,063 INFO o.a.j.JMeter: Available Processors =8
2022-08-03 22:49:36,066 INFO o.a.j.JMeter: Default Locale=English (EN)
2022-08-03 22:49:36,066 INFO o.a.j.JMeter: JMeter  Locale=English (EN)
2022-08-03 22:49:36,066 INFO o.a.j.JMeter: JMeterHome=/jmeter/apache-jmeter-5.4.3
2022-08-03 22:49:36,066 INFO o.a.j.JMeter: user.dir  =/app
2022-08-03 22:49:36,066 INFO o.a.j.JMeter: PWD       =/app
2022-08-03 22:49:36,067 INFO o.a.j.JMeter: IP: 127.0.0.1 Name: jmeter FullName: jmeter
2022-08-03 22:49:36,070 INFO o.a.j.s.FileServer: Default base='/app'
2022-08-03 22:49:36,071 INFO o.a.j.s.FileServer: Set new base='/app/jmeter'
2022-08-03 22:49:36,163 INFO o.a.j.s.SaveService: Testplan (JMX) version: 2.2. Testlog (JTL) version: 2.2
2022-08-03 22:49:36,178 INFO o.a.j.s.SaveService: Using SaveService properties file encoding UTF-8
2022-08-03 22:49:36,181 INFO o.a.j.s.SaveService: Using SaveService properties version 5.0
2022-08-03 22:49:36,185 INFO o.a.j.s.SaveService: Loading file: /app/jmeter/x.jmx
2022-08-03 22:49:36,254 INFO o.a.j.p.h.s.HTTPSamplerBase: Parser for text/html is org.apache.jmeter.protocol.http.parser.LagartoBasedHtmlParser
2022-08-03 22:49:36,254 INFO o.a.j.p.h.s.HTTPSamplerBase: Parser for application/xhtml+xml is org.apache.jmeter.protocol.http.parser.LagartoBasedHtmlParser
2022-08-03 22:49:36,254 INFO o.a.j.p.h.s.HTTPSamplerBase: Parser for application/xml is org.apache.jmeter.protocol.http.parser.LagartoBasedHtmlParser
2022-08-03 22:49:36,254 INFO o.a.j.p.h.s.HTTPSamplerBase: Parser for text/xml is org.apache.jmeter.protocol.http.parser.LagartoBasedHtmlParser
2022-08-03 22:49:36,254 INFO o.a.j.p.h.s.HTTPSamplerBase: Parser for text/vnd.wap.wml is org.apache.jmeter.protocol.http.parser.RegexpHTMLParser
2022-08-03 22:49:36,255 INFO o.a.j.p.h.s.HTTPSamplerBase: Parser for text/css is org.apache.jmeter.protocol.http.parser.CssParser
2022-08-03 22:49:36,304 INFO o.a.j.s.SampleResult: Note: Sample TimeStamps are START times
2022-08-03 22:49:36,304 INFO o.a.j.s.SampleResult: sampleresult.default.encoding is set to ISO-8859-1
2022-08-03 22:49:36,304 INFO o.a.j.s.SampleResult: sampleresult.useNanoTime=true
2022-08-03 22:49:36,304 INFO o.a.j.s.SampleResult: sampleresult.nanoThreadSleep=5000
2022-08-03 22:49:36,370 INFO o.a.j.JMeter: Creating summariser <summary>
2022-08-03 22:49:36,375 INFO o.a.j.e.StandardJMeterEngine: Running the test!
2022-08-03 22:49:36,376 INFO o.a.j.s.SampleEvent: List of sample_variables: []
2022-08-03 22:49:36,376 INFO o.a.j.s.SampleEvent: List of sample_variables: []
2022-08-03 22:49:36,380 INFO o.a.j.e.u.CompoundVariable: Note: Function class names must contain the string: '.functions.'
2022-08-03 22:49:36,380 INFO o.a.j.e.u.CompoundVariable: Note: Function class names must not contain the string: '.gui.'
2022-08-03 22:49:36,641 INFO o.a.j.v.b.BackendListener: Backend Listener: Starting worker with class: class org.apache.jmeter.visualizers.backend.influxdb.InfluxdbBackendListenerClient and queue capacity: 5000
2022-08-03 22:49:36,642 INFO o.a.j.v.b.BackendListener: Backend Listener: Started  worker with class: class org.apache.jmeter.visualizers.backend.influxdb.InfluxdbBackendListenerClient
2022-08-03 22:49:36,812 INFO o.a.j.JMeter: Running test (1659538176812)
2022-08-03 22:49:36,823 INFO o.a.j.e.StandardJMeterEngine: Starting ThreadGroup: 1 : 获取接口-1
2022-08-03 22:49:36,823 INFO o.a.j.e.StandardJMeterEngine: Starting 2 threads for group 获取接口-1.
2022-08-03 22:49:36,823 INFO o.a.j.e.StandardJMeterEngine: Thread will continue on error
2022-08-03 22:49:36,823 INFO o.a.j.t.ThreadGroup: Starting thread group... number=1 threads=2 ramp-up=0 delayedStart=false
2022-08-03 22:49:36,855 INFO o.a.j.t.JMeterThread: Thread started: 获取接口-1 1-1
2022-08-03 22:49:36,858 INFO o.a.j.s.FileServer: Stored: /file/csv.txt
2022-08-03 22:49:36,862 INFO o.a.j.t.JMeterThread: Thread started: 获取接口-1 1-2
2022-08-03 22:49:36,872 INFO o.a.j.s.FileServer: Stored: /file/csv.txt
2022-08-03 22:49:36,911 INFO o.a.j.u.JsseSSLManager: Using default SSL protocol: TLS
2022-08-03 22:49:36,912 INFO o.a.j.u.JsseSSLManager: SSL session context: per-thread
2022-08-03 22:49:36,918 INFO o.a.j.u.SSLManager: JmeterKeyStore Location:  type JKS
2022-08-03 22:49:36,919 INFO o.a.j.u.SSLManager: KeyStore created OK
2022-08-03 22:49:36,919 WARN o.a.j.u.SSLManager: Keystore file not found, loading empty keystore

2022-08-03 22:49:37,858 INFO o.a.j.s.FileServer: 文件没有配置 csv,报错类似如下
2022-08-03 22:49:38,858 ERROR o.a.j.t.JMeterThread: Test failed!
java.lang.IllegalArgumentException: File csv.txt must exist and be readable
    at org.apache.jmeter.services.FileServer.createBufferedReader(FileServer.java:424) ~[ApacheJMeter_core.jar:5.4.3]
    at org.apache.jmeter.services.FileServer.readLine(FileServer.java:340) ~[ApacheJMeter_core.jar:5.4.3]
    at org.apache.jmeter.config.CSVDataSet.iterationStart(CSVDataSet.java:182) ~[ApacheJMeter_components.jar:5.4.3]
    at org.apache.jmeter.control.GenericController.fireIterationStart(GenericController.java:399) ~[ApacheJMeter_core.jar:5.4.3]
    at org.apache.jmeter.control.GenericController.fireIterEvents(GenericController.java:391) ~[ApacheJMeter_core.jar:5.4.3]
    at org.apache.jmeter.control.GenericController.next(GenericController.java:160) ~[ApacheJMeter_core.jar:5.4.3]
    at org.apache.jmeter.control.LoopController.next(LoopController.java:134) ~[ApacheJMeter_core.jar:5.4.3]
    at org.apache.jmeter.threads.AbstractThreadGroup.next(AbstractThreadGroup.java:91) ~[ApacheJMeter_core.jar:5.4.3]
    at org.apache.jmeter.threads.JMeterThread.run(JMeterThread.java:254) [ApacheJMeter_core.jar:5.4.3]
    at java.lang.Thread.run(Thread.java:748) [?:1.8.0_312]

2022-08-03 22:50:30,176 INFO o.a.j.u.BeanShellTestElement: beanshell 断言异常的多行报错
2022-08-03 22:50:30,276 INFO o.a.j.u.BeanShellTestElement: 获取 接口-1 请求失败,返回码:Non HTTP response code: java.net.ConnectException

2022-08-03 22:50:30,376 INFO o.a.j.u.BeanShellTestElement: =============================分割1==========================
2022-08-03 22:50:30,476 INFO o.a.j.u.BeanShellTestElement: java.net.ConnectException: 无法指定被请求的地址 (connect failed)
    at java.net.PlainSocketImpl.socketConnect(Native Method)
    at java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:350)
    at java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:206)
    at java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:188)
    at java.net.SocksSocketImpl.connect(SocksSocketImpl.java:392)
    at java.net.Socket.connect(Socket.java:607)
    at sun.security.ssl.SSLSocketImpl.connect(SSLSocketImpl.java:288)
    at sun.net.NetworkClient.doConnect(NetworkClient.java:175)
    at sun.net.www.http.HttpClient.openServer(HttpClient.java:463)
    at sun.net.www.http.HttpClient.openServer(HttpClient.java:558)
    at sun.net.www.protocol.https.HttpsClient.<init>(HttpsClient.java:264)
    at sun.net.www.protocol.https.HttpsClient.New(HttpsClient.java:367)
    at sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.getNewHttpClient(AbstractDelegateHttpsURLConnection.java:203)
    at sun.net.www.protocol.http.HttpURLConnection.plainConnect0(HttpURLConnection.java:1162)
    at sun.net.www.protocol.http.HttpURLConnection.plainConnect(HttpURLConnection.java:1056)
    at sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.connect(AbstractDelegateHttpsURLConnection.java:189)
    at sun.net.www.protocol.https.HttpsURLConnectionImpl.connect(HttpsURLConnectionImpl.java:167)
    at org.apache.jmeter.protocol.http.sampler.HTTPJavaImpl.sample(HTTPJavaImpl.java:536)
    at org.apache.jmeter.protocol.http.sampler.HTTPSamplerProxy.sample(HTTPSamplerProxy.java:66)
    at org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.sample(HTTPSamplerBase.java:1296)
    at org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.sample(HTTPSamplerBase.java:1285)
    at org.apache.jmeter.threads.JMeterThread.doSampling(JMeterThread.java:638)
    at org.apache.jmeter.threads.JMeterThread.executeSamplePackage(JMeterThread.java:558)
    at org.apache.jmeter.threads.JMeterThread.processSampler(JMeterThread.java:489)
    at org.apache.jmeter.threads.JMeterThread.run(JMeterThread.java:256)
    at java.lang.Thread.run(Thread.java:748)

2022-08-03 22:50:30,576 INFO o.a.j.u.BeanShellTestElement: 获取 接口-1 请求异常,返回码:Non HTTP response code: java.net.ConnectException

2022-08-03 22:50:30,676 INFO o.a.j.u.BeanShellTestElement: =============================分割1==========================
2022-08-03 22:50:30,776 INFO o.a.j.u.BeanShellTestElement: java.net.ConnectException: 无法指定被请求的地址 (connect failed)
    at java.net.PlainSocketImpl.socketConnect(Native Method)
    at java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:350)
    at java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:206)
    at java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:188)
    at java.net.SocksSocketImpl.connect(SocksSocketImpl.java:392)
    at java.net.Socket.connect(Socket.java:607)
    at sun.security.ssl.SSLSocketImpl.connect(SSLSocketImpl.java:288)
    at sun.net.NetworkClient.doConnect(NetworkClient.java:175)
    at sun.net.www.http.HttpClient.openServer(HttpClient.java:463)
    at sun.net.www.http.HttpClient.openServer(HttpClient.java:558)
    at sun.net.www.protocol.https.HttpsClient.<init>(HttpsClient.java:264)
    at sun.net.www.protocol.https.HttpsClient.New(HttpsClient.java:367)
    at sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.getNewHttpClient(AbstractDelegateHttpsURLConnection.java:203)
    at sun.net.www.protocol.http.HttpURLConnection.plainConnect0(HttpURLConnection.java:1162)
    at sun.net.www.protocol.http.HttpURLConnection.plainConnect(HttpURLConnection.java:1056)
    at sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.connect(AbstractDelegateHttpsURLConnection.java:189)
    at sun.net.www.protocol.https.HttpsURLConnectionImpl.connect(HttpsURLConnectionImpl.java:167)
    at org.apache.jmeter.protocol.http.sampler.HTTPJavaImpl.sample(HTTPJavaImpl.java:536)
    at org.apache.jmeter.protocol.http.sampler.HTTPSamplerProxy.sample(HTTPSamplerProxy.java:66)
    at org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.sample(HTTPSamplerBase.java:1296)
    at org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase.sample(HTTPSamplerBase.java:1285)
    at org.apache.jmeter.threads.JMeterThread.doSampling(JMeterThread.java:638)
    at org.apache.jmeter.threads.JMeterThread.executeSamplePackage(JMeterThread.java:558)
    at org.apache.jmeter.threads.JMeterThread.processSampler(JMeterThread.java:489)
    at org.apache.jmeter.threads.JMeterThread.run(JMeterThread.java:256)
    at java.lang.Thread.run(Thread.java:748)

2022-08-03 22:51:20,156 INFO o.a.j.u.BeanShellTestElement: beanshell 断言异常的单行报错
2022-08-03 22:51:20,276 INFO o.a.j.u.BeanShellTestElement: {"errcode":10001,"errmsg":"网关异常","data":{},"requestId":"abc-efg"}
2022-08-03 22:51:21,376 INFO o.a.j.u.BeanShellTestElement: {"errcode":10001,"errmsg":"网关异常","data":{},"requestId":"hij-klm"}
2022-08-03 22:51:22,476 INFO o.a.j.u.BeanShellTestElement: {"errcode":10002,"errmsg":"网关超时","data":{ "info": "timeout" },"requestId":"opq-usv"}

2022-08-03 22:51:23,576 INFO o.a.j.e.StandardJMeterEngine: Notifying test listeners of end of test
2022-08-03 22:51:24,676 INFO o.a.j.s.FileServer: Close: /file/csv.txt
2022-08-03 22:51:25,776 INFO o.a.j.v.b.BackendListener: Worker ended
2022-08-03 22:51:26,876 INFO o.a.j.v.b.i.InfluxdbBackendListenerClient: Sending last metrics to InfluxDB
2022-08-03 22:51:27,976 INFO o.a.j.v.b.i.HttpMetricsSender: Destroying
2022-08-03 22:51:28,776 INFO o.a.j.r.Summariser: summary +  15919 in 00:00:09 = 1844.2/s Avg:    66 Min:    34 Max:  1116 Err:     7 (0.04%) Active: 0 Started: 2 Finished: 2
2022-08-03 22:51:29,776 INFO o.a.j.r.Summariser: summary = 752280 in 00:03:18 = 3796.0/s Avg:   119 Min:    34 Max:  3791 Err:   367 (0.05%)

这里浓缩了 Jmeter 常见类型的日志输出:

  • 第一种是类似 ERROR o.a.j.t.JMeterThread: Test failed! 这类自带 LEVEL 等级的 Jmeter 组件输出日志。
  • 第二种是类似 INFO o.a.j.u.BeanShellTestElement 这类人为插入 Beanshell 写断言打出来的日志。

首先,Beanshell 的日志用户不一定会设置 ERROR 等级,其次,日志的输出,还区分了单行跟多行,这类的日志往往比普通 Nginx 的日志难聚合得多。

所以需要想办法把真正有用的错误信息过滤出来。

pipeline_stages:
  - multiline:
      firstline: '^\d{4}-\d{1,2}-\d{1,2}'
      max_wait_time: 10s
      max_lines: 256
  - regex:
      expression: '(?P<timestamp>^\d{4}-\d{1,2}-\d{1,2} \d{2}:\d{2}:\d{2},\d{3}) (?P<component>\S* \S*) (?P<message>(?s:.*))$'
  - labels:
      component:
      message:
  - drop:
      expression: '^2022-(0[1,7]-\d{2}|08-0\d|08-1[0,2])'

这里我给出的解决方案是:

  • 首先,利用 multiline ,以时间戳为标识,把多行日志统一成一行记录。
  • 其次,利用 regex,把 LEVEL + COMPONENT 的日志内容都归属到 component 这个标签,这样,通过 component=~"ERROR.*|.*BeanShellTestElement:",就可以把全部 ERROR 等级的日志以及 Beanshell 的日志检索出来。
  • 然后,利用 labels,把聚合出来的 componentmessage 作为标签,建立索引。
  • 最后,利用 drop,把不想要的过期数据直接丢弃。

其效果如下:

这里要注意的是 drop 并不会影响 Promtail 对文件的扫描和聚合,只是在最终检索展示的时候,对该数据打上了 drop 标签不被检索出来。

所以,如果有大量的存量日志,drop 不会减轻 loki-gateway 的压力,一样会报 429 Too Many Request

这种情况就不适合使用非分布式的单点 Loki,需要分布式 Loki 集群来做横向扩展。

3-Loki 读写分离

这里可以使用 Nginx 作为 Loki Gateway,进行读写分离。

...
http {
  resolver kube-dns.kube-system.svc.cluster.local;

  server {
    listen  3100;

    location = / {
      return 200 'OK';
      auth_basic off;
    }

    location = /api/prom/push {
      proxy_pass  http://loki-write.ns.svc.cluster.local:3100$request_uri;
    }

    location = /api/prom/tail {
      proxy_pass  http://loki-read.ns.svc.cluster.local:3100$request_uri;
      proxy_set_header  Upgrade $http_upgrade;
      proxy_set_header  Connection "upgrade";
    }

    location ~ /api/prom/.* {
      proxy_pass  http://loki-read.ns.svc.cluster.local:3100$request_uri;
    }

    location = /loki/api/v1/push {
      proxy_pass  http://loki-write.ns.svc.cluster.local:3100$request_uri;
    }

    location = /loki/api/v1/tail {
      proxy_pass  http://loki-read.ns.svc.cluster.local:3100$request_uri;
      proxy_set_header  Upgrade $http_upgrade;
      proxy_set_header  Connection "upgrade";
    }

    location ~ /loki/api/.* {
      proxy_pass  http://loki-read.ns.svc.cluster.local:3100$request_uri;
    }
  }
}
...

然后启动两个 Loki 实例,分别设置读写的模式。

---
...
containers:
  - name: loki-read
    image: grafana/loki:2.6.1
    command: [ "/bin/sh","-c" ]
    args: [ "/usr/bin/loki -config.file=/etc/loki/config.yaml -target=read" ]
...
---
...
containers:
  - name: loki-write
    image: grafana/loki:2.6.1
    command: [ "/bin/sh","-c" ]
    args: [ "/usr/bin/loki -config.file=/etc/loki/config.yaml -target=write" ]
...

4-Minio 持久化

部署 Minio 来提供 S3 COS。

...
containers:
  - name: minio
    image: minio/minio
    command: [ "/bin/sh","-euc" ]
    args: [ "mkdir -p /data/loki-data && mkdir -p /data/loki-ruler && minio server /data" ]
    volumeMounts:
      - name: cfs-pvc
        mountPath: /data
        subPath: cfs/minio
...

至此,非分布式 Loki 所有组件已部署完毕。

5-Config 参数调优

在生产环境中,有些日志生产的量比较大,需要对参数做调优,防止 429 Too Many Request & OOM

  • promtail config
server:
  http_server_read_timeout: 5m
  http_server_write_timeout: 10m
  • loki config
limits_config:
  max_label_value_length: 40960 # label value 字节最大限制
  ingestion_rate_mb: 32 # 每个用户每秒的采样率最大限制
  ingestion_burst_size_mb: 64 # 每个用户允许的采样突发最大限制
  per_stream_rate_limit: 32m # 每个流每秒的字节最大限制
  per_stream_rate_limit_burst: 64m # 每个流每秒的采样突发最大限制
  • nginx config
worker_processes 5; # 根据 CPU 核数配置

events {
  worker_connections  2048; # 最大连接数
  use epoll; # 根据系统适配模型
  ...
}

http {
  resolver kube-dns.kube-system.svc.cluster.local;
  client_max_body_size 100M;
  ...
}

还有很多可以优化的地方,这里就简单提一下,具体可以根据业务需求来调整。

6-Database 数据隔离

Jmeter Influxdb BackendListener 可以通过 application 对每次的压测数据做筛选隔离。

Loki 可以通过 tenant_id 对多租户隔离。

Grafana 可以通过 GF_SERVER_ROOT_URL 对多地域跨集群的服务网格做隔离。

Grafana 可以通过 GF_PATHS_PROVISIONING 对多数据源多租户做隔离。

所以可以根据自己的需求,来兼容适配所有的数据隔离。

这里分享一下我设计的多地域集群的数据隔离方案:

  • 对每个用户,用特定的 task-id 作为 application 重写 BackendListener。
  • 对每个团队,用特定的 tanant-id 并通过动态挂载 GF_PATHS_PROVISIONING 的方式去挂载多团队租户数据源。
  • 对每个集群,用特定的 GF_SERVER_ROOT_URL 设置特定集群的路由,比如广州 1 区 Grafana %(protocol)s://%(domain)s/grafana/mesh1-guangzhou1/ 和广州 2 区 Grafana %(protocol)s://%(domain)s/grafana/mesh1-guangzhou2/
  • 对每个地域,用特定的 mesh-id 设置每个地域网格名,比如广州区域 mesh-guangzhou,北京区域 mesh-beijing,通过 Istiodestination-rule & virtual-service,跨多地域网格访问。

粒度由小到大,层层治理,便是一个支持跨地域多集群的隔离及通信方案。

7-Dashboard 数据聚合

Loki 本身提供了很多 LogQL 方便我们去做聚合。

比如想查看某个压测任务所有的错误日志及 Beanshell 日志:

{filename=~"/var/log/$application/.*log",component=~"ERROR.*|.*BeanShellTestElement:", message!~".*不想要看到的无用信息.*"} | line_format "{{.time}} {{.message}}"

比如想查看某个压测任务的日志报错占比统计:

sum(count_over_time({filename=~"/var/log/$application/.*log",component=~"ERROR.*|.*BeanShellTestElement:",message!~".*不想要看到的无用信息.*"} | line_format "{{.message}}"[$__interval])) by (message)

效果类似下图:

现在我们再回想一下未引入 Loki 之前原生方案的两个大的缺陷:

  1. 错误日志只囊括了 Beanshell 写入的断言异常,而对于 Jmeter 自己执行出错的日志,无法展示。
  2. 错误日志 Response 如果带上了 Timestamp,TraceId 等可变字段,将无法聚合统计,大量报错会瞬间卡死 Grafana 面板。

第一点,此刻已经解决了,只要是写进 jmeter.log 的所有数据都可以检索,不再受限于 Beanshell 写入 Influxdb。

第二点,Beanshell 无需把日志写入 Influxdb,只需要数据写到 jmeter.log 即可,避免大量报错导致 Influxdb OOM。

其次,对于这些可变字段,我们可以通过 LogQL 做聚合。

比如我想统计 errcode 字段的比例,过滤掉 timestamp 的影响。

sum(count_over_time({filename=~"/var/log/$application/.*log",message=~"{.*"} | line_format "{{.message}}" | json | line_format "{{.errcode}}"[$__interval])) by (errcode)

这里首先对 message 做过滤,把可序列化的 json 日志过滤出来,避免 json 非结构化数据的时候出错。

其次,重新对 message 做调整,只打印 errcode,这样便把 json 里面可变字段给过滤了。

最后再对 errcode 作 group by 完成占比的统计即可。

如此,这两大缺陷,在引入 Loki 之后,便完美解决。

总结

一路踩了很多坑,最终完善了整个 Loki 云原生日志系统到压测日志的落地实现。

以极低的成本,既方便了用户的高效排障,也方便了对错误数据的聚合统计,同时优化了原生 Jmeter 方案的两大缺陷。

不仅如此,基于静态文件的识别,可以方便做各类压测引擎做扩展,甚至 k6 压测引擎,可以直接写入 Loki。

且 Loki 可以很好与 Prometheus 对接来做告警。

所以,如果你也需要一个可以结合业务场景高度定制的低成本高性能的云原生日志检索聚合系统,PLG 就是很好的选择。

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 5 条回复 时间 点赞


在改造完 jmeter 内核的情况下,我们的结构搞得很简单,理论上具备无限扩展性

杨杰 回复

挺好的。
不过这样就只能局限于 jmeter 这个引擎吧,其他压测引擎可以通用适配吗~
另外我比较好奇的是,你的很多非结构化日志数据存到了 mysql 里面,日志检索速度是很慢的,这里是怎么做全文检索和实时数据展示优化的呢?

陈恒捷 将本帖设为了精华贴 09月01日 09:26
少年 Istio 跨集群网络通信的落地实践 中提及了此贴 09月26日 10:44

这个思路对 K6 最友好 (K6+Loki+Grafana 现在是一家了) 同步扩展 Gatling JMeter 等常用压测引擎也很方便 赞

imath60 回复

对的,这套体系基于日志的路径,可以兼容 locust boomer k6 等各类压测引擎,无需做大改动。

imath60 回复

k6 不是要付费?

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册