搭建loki日志聚合

loki

据我不严谨的搜索,loki发布于2020年左右,截至本文落笔记目前仍在不断变化中。所以在开始前非常有必要提醒读者,本文内容用到的loki组件版本:

此前,我没有搭建聚合日志平台的经验。本次我主要是利用 AI 翻译和讲解和反复阅读官方 loki 3.5.x 教程。 如果你阅读过quick-starttutorial,你会发现这两篇教程并没有手把手教你搭建,更多是界面使用教程。对于期望自己搭建 loki 的新手来说,并不是一个好的开始。

本文的初衷,是给我司的游戏项目多节点部署(非 docker、非 k8s)场景,搭建一个日志聚合平台。

  • 可根据项目 app_name、分发渠道 channel、节点ID、日志等级和时间范围查询日志
  • 节点分布在多台机器

下文示例是简化后的代码,设计目标

  • 过滤日志路径
  • 给工程标识、分发渠道、节点打标签
  • 得日志对象信息(即loki官方文log enity)
    • 给日志等级打标签
    • 重写日志时间戳(满足时间范围查询)
      • 从日志中捕获时间
      • 给非标准时间格式,转换为loki约定的格式
    • 处理日志跨行

我必须申明一下,loki文档内容着实太多,我并没有理解完所有内容,所以我的方案可能并不不一定是最佳实践。若读者有更好的建议,非常期望读者反馈。

如果你正在使用的loki各组件版本和我下面提到的不一致,特别是在脚本配置和API相关内容上,务必以对应版本的官方文档为准。

下面用的的知识,我的理解也是源于官方,除非我特别说明。我并不打算再做具体说,请阅者自行阅读官方。我建议先了解以下部分概念

  • docker
    • docker-compose 的启动和停止
    • docker 的目录挂载
    • docker 容器加入指定docket网络
  • loki
    • 各组件的作用组件的关系阅读它可以了解以下示例中,docker 配置启动了哪些服务及其之间的关系。比如由 alloy 或其它前端采集日志,经过 Gateway(NGINX)转发给 Loki。
    • loki 支持非常灵活的部署方式。3种部署模式,官方的大多数示例采用 simple scalable 模式。
  • alloy(重要!!)

    alloy 作为日志采集器,关于如何控制它采集日志官方文档已经很详细,我这里不作另外说明。

  • grafana
    • 当搭建完成后,需要用log explore来查询日志

改造说明

官方 docker 示例改造

# docker 示例
wget https://raw.githubusercontent.com/grafana/loki/v3.5.7/examples/getting-started/docker-compose.yaml -O docker-compose.yaml
wget https://raw.githubusercontent.com/grafana/loki/v3.5.7/examples/getting-started/alloy-local-config.yaml -O alloy-local-config.yaml
wget https://raw.githubusercontent.com/grafana/loki/v3.5.7/examples/getting-started/loki-config.yaml -O loki-config.yaml

# 启动示例
docker compose -f docker-compose.yaml up

为了便给本示例观察/对比修改。我另起了仓库,main分支就是原官方 docker 示例new分支是本示例的修改。它们的修改大致如下:

  • 将采集端 alloy 将从 docker-compose.yaml 抽离到docker-compose-alloy.yaml
  • 多机部署日志统一放在var/log(注意权限问题),该目录下以工程名区分各工程日志,如/var/log/my_app_name
  • 工程名,分放渠道和节点的目录关系
    • /var/log/my_app_name/
      • /var/log/my_app_name/my_channel1/my_node1/
      • /var/log/my_app_name/my_channel1/my_node2/
      • /var/log/my_app_name/my_channel2/my_node1/
  • 日志格式 “[日期]-[级别] 内容” 样例:[20250923143226869] [INFO] start service
  • 使用 shell 脚本start_write_log.sh定期写入日志,模拟输出日志。

改造开始

alloy采集指定目录的日志

docker-compose-alloy.yaml里,将本机工程my_app_name日志目录挂载给 alloy 容器。

services:
  alloy:
    image: grafana/alloy:v1.11.3
    volumes:
      - ./alloy-local-config.yaml:/etc/alloy/config.alloy:ro
      - /var/log/my_app_name:/var/log/my_app_name:ro
    command:  run --server.http.listen-addr=0.0.0.0:12345 --storage.path=/var/lib/alloy/data /etc/alloy/config.alloy
    ports:
      - 12345:12345

alloy采集脚本配置

alloy 表达式过滤日志文件

local.file_match 用来筛选过滤文件路径。 | Key | Description | Required | | :— | :— | :— | | __path__ | doublestar glob pattern specifying which files to discover. | Yes | | __path_exclude__ | doublestar glob pattern specifying which files to exclude from the __path__ matches. | No | | additional keys | Any other labels to attach to discovered files. The component preserves these labels in the exported targets. | No |

targets 参数里,除了 __path____path_exclude__ 参数外,其他参数都会作为 [labels](上面提到需要阅读的重要概念)。 本脚本配置是 my_app_name 专用的,各个工程日志结构和格式不一样,也不可能通用。但是 /var/log 是系统日志目录,我们只期望采集 /var/log/my_app_name 下的日志文件。my_app_name = "my_app_name" 给这些日志内容打 [labels]。优势类似于索引分片,能加快查询效率。

// 筛选日志文件
local.file_match "my_file_match" {
	path_targets = [{
		__path__ = "/var/log/my_app_name/*/*/*.log",
		my_app_name = "my_app_name",
	}]
	
	// 同步间隔(多久扫描一次新文件)
	sync_period = "10s"
}

alloy读取日志内容

loki.source.file 用来读取文件内容。它的输入 targets 来自上面 local.file_match 组件筛选的文件路径,会把以 ‘\n’ 结尾的 内容 forward_to 到后面提到的内容处理 loki.process.read_line.receiver。请注意这里说的是 内容,而不是整个日志文件内容。

// 读取文件内容
loki.source.file "my_file" {
  // 
	targets = local.file_match.my_file_match.targets
	
	// 转发到日志处理管道
	forward_to = [loki.process.read_line.receiver]

	// 文件读取配置
	tail_from_end = true  // 默认为false, 设为 true 则只读取新追加的内容
}

处理日志行

loki.process,它是内置组件库中最复杂的组件之一。它的参数只有两个,但需要定义的 stage 却非常多。

loki.process "<LABEL>" {
  stage.<STAGENAME> {
    ...
  }

  forward_to = <RECEIVER_LIST>
  ...
}
- stage.***: 日志处理阶段,可以有多个,按编写顺序执行
- forward_to: 下游接收器列表

下面我们将用到以下这些 stage

从日志文件路径中捕获关键信息

为了减少信息冗余,日志行有时并不会输出一些信息,如 my_app_namemy_channelmy_node。而它们一般定义在日志路径中。stage.regex 默认是捕获 行日志,若要捕获日志路径中的内容,需要指定内容来源:source = "filename"

- `/var/log/my_app_name/my_channel1/my_node1/`
- `/var/log/my_app_name/my_channel1/my_node2/`
- `/var/log/my_app_name/my_channel2/my_node1/`
- ...
	stage.regex {
		// 提取 /var/log/{my_app_name}/{my_channel}/{my_node}/ 中的 instance_name
		source = "filename"
		expression = "/var/log/(?P<my_app_name>[^/]+)/(?P<my_channel>[^/]+)/(?P<my_node>[^/]+)/.*"
	}

  stage.labels {
		values = {
			my_app_name = "my_app_name",
			my_channel = "my_channel",
			my_node = "my_node",
		}
	}

处理跨行日志

alloy 默认每行都是一个日志对象,但如果中途出现跨行,会让后续的日志行变得混乱。我们需要告诉 alloy 如何判断新日志行。firstline 参数用于指定匹配新日志行的正则表达式,不匹配的行(比如没有时间戳的报错堆栈)会自动拼接到上一个日志对象。

	stage.multiline {
		// 匹配以 [yyyyMMddHHmmssSSS] 开头的行(17位数字)
		firstline = `^\[\d{17}\]`
	}

从日志行中捕获关键信息

  • 日志时间戳。alloy 默认以运行时读取日志的时间作为日志对象的产生时间。但是读取日志的时间与日志产生的时间不可能一致,比如将很久之前的日志导入给 alloy 采集,就不可能以开始采集的时间作为日志产生时间。所以这里依据日志行提供的时间戳重新写入。alloy 对时间格式有要求,需要符合 Go 语言 API time.Parse 参数要求。
	// 日志格式 "[日期]-[级别] 内容" 样例:`[25/09/23 14:32:26:869] [INFO] start service`
	stage.regex {
		expression = "\\[(?P<date>[^\\]]+)\\].*?\\[(?P<level>[^\\]]+)\\]"
	}

	// 重写日志时间戳
	stage.timestamp {
    	source = "date"
  		format  = "06/02/01 15:04:05.000"
  		location = "Asia/Shanghai"
	}

但是,如果老项目的时间格式与 time.Parse 不相符,你必须先用 stage.regex 过滤出时间信息(年月日等),再用 stage.template 重新拼接为符合要求的时间字符串。

	stage.regex {
		expression = "\\[(?P<year>\\d{4})(?P<month>\\d{2})(?P<day>\\d{2})(?P<hour>\\d{2})(?P<minute>\\d{2})(?P<second>\\d{2})(?P<ms>\\d{3})\\]\\s+\\[(?P<level>[^\\]]+)\\]"
	}

	// 重组时间以符合go time.parser的格式要求
	stage.template {
 	   	source = "time"
    	template = ""
	}

	stage.timestamp {
    	source = "time"
  		format  = "2006/02/01 15:04:05.000"
  		location = "Asia/Shanghai"
	}

将处理好的日志对象发送到loki

  • 由于在本示例中,alloy 和 nginx 是在两个 docker-compose 中启动的,为了演示本例,这里特意将 alloy 加入到 nginx 所在的 docker 网络。

查看你本地 nginx 所在的网络,我这里输出 loki_simple_demo_loki。

sudo docker inspect -f '' loki_simple_demo-gateway-1
loki_simple_demo_loki

将 alloy 容器加入到网络:

sudo docker network connect loki_simple_demo_loki loki_simple_demo-alloy-1

然后将日志的接收地址修改为 nginx 容器名称 loki_simple_demo-gateway-1,即 http://loki_simple_demo-gateway-1:3100/loki/api/v1/push

  • 如果 nginx 部署在另一台物理机,应该将 “loki_simple_demo-gateway-1:3100” 修改为具体环境的地址和端口,如 http://192.168.2.125:3100/loki/api/v1/push
loki.process "read_line" {
	// 将处理好的日志对象,发给loki
	forward_to = [loki.write.write_loki.receiver]

   // 这里是以上讨论的各个stage
|

// 写入 Loki
loki.write "write_loki" {
	endpoint {
		// 将loki_simple_demo-gateway-1:3100,修改具体环境的地址和端口
		url = "http://loki_simple_demo-gateway-1:3100/loki/api/v1/push"

	}
}

启动

分别在3个bash,依次启动

  • bash start_all.sh
  • bash start_alloy.sh
  • bash start_write_log.sh

从 Grafana 查询日志

http://localhost:3000/explore

关于调试

你会alloy-local-config.yaml看到被我注释掉的这个代码,注释上我附上了链接,看链接吧

// 调式日志
// https://grafana.com/docs/alloy/v1.11/reference/config-blocks/logging/#logging
logging {
  level  = "debug"
  format = "logfmt"
}

// 打印日志到控制台,方便调试
// https://grafana.com/docs/alloy/v1.11/reference/components/loki/loki.echo/#lokiecho
loki.echo "print" { }

// 打alloy ui输出日志
// https://grafana.com/docs/alloy/v1.11/reference/config-blocks/livedebugging/#livedebugging
livedebugging {
  enabled = true
}

原文:
https://lizijie.github.io/2025/12/21/%E6%90%AD%E5%BB%BAloki%E6%97%A5%E5%BF%97%E8%81%9A%E5%90%88.html

作者github:
https://github.com/lizijie </b>

PREVIOUS杂谈
NEXTmongodb笔记