日志是你采过最便宜的遥测数据——直到你试着把它存上一周。一个繁忙的服务一天就能写 好几个 GB;一旦把十几个服务集中到一起,存储账单就不再是好笑的事了。Grafana Loki 跟 Elastic 那套走的是另一条路:它索引的是标签,不是日志行。正是这一个决定,让 日志聚合在自建预算里重新变成可能,而这篇就是对它的一次动手走查——真实的 Loki 3.4.1、真实的推送、真实的 LogQL 输出。
日志为什么会变贵,以及 Loki 的不同之处
传统的日志聚合方案(ELK、Splunk)本质上是一个搜索引擎。每一行日志都被解析、 切词,写进一个倒排索引,而这个索引经常和被它指向的原文一样大、甚至更大。你在写入 时为它付一次钱(CPU、内存、磁盘),查询时再付一次。
Loki 把这个取舍对调过来。它把日志行写进廉价存储上的压缩 chunk(本地磁盘、S3、
GCS),只为标签建索引,也就是描述「这一行从哪来」的那几个键值对(job、
service、level)。日志行本身从不进索引:你先按标签选中流,再用子串或正则去过滤
行。这跟 Prometheus 是同一套哲学,只是对象换成了日志。如果你读过我那篇
Prometheus 实战,心智模型可以直接搬过来——一条流
就是一个 series,LogQL 就是 PromQL 外挂一个行过滤器。对「哪个服务在十分钟前开始报
错、报了多少次」这类问题,它是我知道的最便宜的正确答案。
架构:agent 推送、Loki 存 chunk、Grafana 查询
数据路径只有三部分:
- Agent(Promtail、Fluent Bit、Grafana Alloy)tail 日志文件、打上标签,推送到
/loki/api/v1/push。 - Loki 去重、把日志行压成 chunk、写标签索引,两者一起落到配置好的后端上。
- Grafana 用 LogQL 查 Loki,渲染日志面板或指标图。
打标签是 agent 干的活,Loki 接近一个哑存储,所有智能都在查询语言里。这个切分就是 Loki 只是一个二进制、没有任何外部依赖(没有 ZooKeeper、没有 Elasticsearch 集群)的 原因。
跑一个真实的 Loki:配置、真实推送、标签索引
我用 Docker 跑了一个一次性的 Loki 3.4.1(单体、文件系统存储、只听 127.0.0.1、无认
证)。下面所有输出都来自一个数据目录为空的全新实例。这就是全部配置,没有别的:
auth_enabled: false
server:
http_listen_port: 3100
common:
instance_addr: 127.0.0.1
path_prefix: /loki-data
storage:
filesystem:
chunks_directory: /loki-data/chunks
rules_directory: /loki-data/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
reject_old_samples: true
reject_old_samples_max_age: 168h
allow_structured_metadata: true
retention_period: 168h
compactor:
working_directory: /loki-data/compactor
retention_enabled: true
delete_request_store: filesystem
推送日志行
推送接口就是普通 JSON——一个标签集合一条流,每条日志一个 [时间戳, 行] 二元组:
$ curl -sS -X POST http://127.0.0.1:3100/loki/api/v1/push \
-H "Content-Type: application/json" \
-d '{"streams":[{"stream":{"job":"blog-demo","level":"info","service":"api"},
"values":[["1791841400000000000","GET /posts/loki-log-aggregation 200 12ms"]]}]}' \
-w "HTTP %{http_code}\n"
HTTP 204
HTTP 204,空响应体。写完即忘——Loki 确认的是一批,不是每一行。
标签索引里到底存了什么
$ curl -s http://127.0.0.1:3100/loki/api/v1/labels
{"status":"success","data":["job","level","service","service_name"]}
$ curl -s http://127.0.0.1:3100/loki/api/v1/label/level/values
{"status":"success","data":["error","info"]}
$ curl -s -G http://127.0.0.1:3100/loki/api/v1/series \
--data-urlencode 'match[]={job="blog-demo"}'
{"status":"success","data":[
{"job":"blog-demo","level":"info","service":"nginx","service_name":"nginx"},
{"job":"blog-demo","level":"error","service":"api","service_name":"api"},
{"job":"blog-demo","level":"info","service":"api","service_name":"api"}]}
七个日志行,四个标签名字——整个索引就这些。其中三个是我自己给的(job、level、
service),service_name 是 Loki 3.x 默认的 discover_service_name 自动注入的。
查询结果里还会多出一个 detected_level(来自 discover_log_levels: true),但它不会
出现在 /labels 里。每一个唯一的标签组合就是一条流,而流是存储的单位:一个 chunk、
一条索引项。上面三组标签,三条流,七行日志。
LogQL:流选择器、行过滤器、指标查询
LogQL 有三层,从左到右拼起来:花括号里的流选择器、竖线后面的行过滤器,以及 可选地包在整个表达式外面的指标查询。
流选择器
$ curl -s -G http://127.0.0.1:3100/loki/api/v1/query_range \
--data-urlencode 'query={job="blog-demo"}' \
--data-urlencode "start=$((EPOCHSECONDS-900))000000000" \
--data-urlencode "end=$EPOCHSECONDS000000000" \
--data-urlencode 'limit=4' | python3 -m json.tool
{
"resultType": "streams",
"result": [
{
"stream": {
"detected_level": "error",
"job": "blog-demo",
"level": "error",
"service": "api",
"service_name": "api"
},
"values": [
["1791841405000000000", "ERROR upstream 502 bad gateway from 10.0.0.5:8080"],
["1791841404000000000", "ERROR database connection refused: timeout after 30s"]
]
},
{
"stream": {
"detected_level": "info",
"job": "blog-demo",
"level": "info",
"service": "nginx",
"service_name": "nginx"
},
"values": [
["1791841407000000000", "192.168.1.43 - - [13/Oct/2026:04:20:19 +0000] \"GET /posts/structured-logging HTTP/1.1\" 200 892"],
["1791841406000000000", "192.168.1.42 - - [13/Oct/2026:04:20:18 +0000] \"GET /zh/posts/loki-log-aggregation HTTP/1.1\" 200 1234"]
]
}
],
"stats": {
"summary": {
"totalLinesProcessed": 7,
"totalEntriesReturned": 4,
"totalBytesProcessed": 458,
"execTime": 0.006074
}
}
}
行的顺序是新的在前(默认 direction=backward)。stats 才是我真正会看的部分:
totalLinesProcessed: 7 对 totalEntriesReturned: 4,就是 limit=4 在起作用。
行过滤器
|= 子串、!= 排除、|~ 正则(加 (?i) 忽略大小写)。它们跑在流选择器之后,所
以标签集合可以保持便宜,精确匹配交给文本搜索:
$ curl -s -G http://127.0.0.1:3100/loki/api/v1/query_range \
--data-urlencode 'query={job="blog-demo"} |= "ERROR"' \
--data-urlencode "start=$((EPOCHSECONDS-900))000000000" \
--data-urlencode "end=$EPOCHSECONDS000000000" | python3 -m json.tool
{
"resultType": "streams",
"result": [
{
"stream": {"detected_level": "error", "job": "blog-demo", "level": "error",
"service": "api", "service_name": "api"},
"values": [
["1791841405000000000", "ERROR upstream 502 bad gateway from 10.0.0.5:8080"],
["1791841404000000000", "ERROR database connection refused: timeout after 30s"]
]
}
],
"stats": {"summary": {"totalLinesProcessed": 7, "totalEntriesReturned": 2}}
}
扫了七行,返回两行——这就是没有全文索引的代价(execTime: 0.004581,五毫秒),完
全可以接受。
指标查询
把日志查询包进一个 range 函数,日志文本就变成了一条 Prometheus 风格的时间序列。这 才是「哪怕你从不打开日志面板也值得跑 Loki」的理由:
$ curl -s -G http://127.0.0.1:3100/loki/api/v1/query_range \
--data-urlencode 'query=rate({job="blog-demo"} |= "ERROR" [1m])' \
--data-urlencode "start=$((EPOCHSECONDS-900))000000000" \
--data-urlencode "end=$EPOCHSECONDS000000000" \
--data-urlencode 'step=60' | python3 -m json.tool
{
"resultType": "matrix",
"result": [
{
"metric": {"detected_level": "error", "job": "blog-demo", "level": "error",
"service": "api", "service_name": "api"},
"values": [[1791841440, "0.03333333333333333"]]
}
]
}
60 秒窗口里两行 error:2 / 60 = 0.0333 行每秒——能手算复核,这正是仪表盘该有的样
子。聚合也一样能用:
{
"resultType": "matrix",
"result": [
{"metric": {"service": "api"}, "values": [[1791841440, "0.016666666666666666"],
[1791841500, "0.016666666666666666"]]},
{"metric": {"service": "nginx"}, "values": [[1791841440, "0.006666666666666667"],
[1791841500, "0.006666666666666667"]]}
]
}
这就是 sum by (service) (rate({job="blog-demo"}[5m]))——300 秒里 5 行 api、2 行
nginx,和原始条数对得上。而 | json | unwrap duration_ms 能把结构化日志里的数字字
段取出来,日志直接变延迟分位点:
$ curl -s -G .../loki/api/v1/query_range \
--data-urlencode 'query=quantile_over_time(0.95, {job="blog-demo"} | json | unwrap duration_ms [5m])' ...
{"resultType":"matrix",
"result":[{"metric":{"job":"blog-demo","service":"api","host":"demo-1","env":"test",
"level":"error","path":"/zh/posts/loki-log-aggregation","status":"500",
"duration_ms":"926","msg":"request handled"},
"values":[[1791841140,"926"]]}]}
日志变成了延迟分位点,不需要任何指标管道。注意解析出来的字段都回到了 metric 里——
它们是行级数据,从不进索引。
Promtail:打标签这件事是 agent 干的
Promtail 是默认的搬运工:tail 文件、在一个 positions 文件里记字节偏移、打上你配置的 标签。一个最小配置,外加一个只把低基数字段提升为标签的流水线阶段:
server:
http_listen_port: 9080
clients:
- url: http://loki:3100/loki/api/v1/push
positions:
filename: /run/promtail/positions.yaml
scrape_configs:
- job_name: blog-demo
static_configs:
- targets: [localhost]
labels:
job: blog-demo
service: api
host: demo-1
env: test
__path__: /var/log/blog-demo/*.log
pipeline_stages:
- json:
expressions:
level: level
status: status
path: path
duration_ms: duration_ms
- labels:
level: # 只把这一个提升为标签
- timestamp:
source: ts
format: RFC3339
它接手一个十行的 JSON 日志文件之后,positions 文件证明它读完了整个文件:
$ docker exec promtail-cat /run/promtail/positions.yaml
positions:
/var/log/blog-demo/api.log: "1152"
而 Loki 里的这些行,带着 agent 打的标签和 Loki 自动加的标签:
{
"stream": {
"detected_level": "info", "env": "test", "filename": "/var/log/blog-demo/api.log",
"host": "demo-1", "job": "blog-demo", "level": "info", "service": "api",
"service_name": "api"
},
"values": [
["1791841114074267075", "{\"ts\": \"2026-10-12T21:38:19Z\", \"level\": \"info\", \"status\": 201, \"path\": \"/api/v1/comments\", \"duration_ms\": 2230, \"msg\": \"request handled\"}"]
]
}
status 和 path 留在了日志行里——高基数,可以用 | json | status >= 500 搜,但永
远不进索引。整套纪律就在这一份配置里。(让这一切变便宜的是结构化日志,生产端的事见
结构化日志。)
Grafana 那一环我也是照样验证的:一个一次性 Grafana,数据源代理到 http://loki:3100,
通过 /api/ds/query 查询,返回的 Grafana 数据帧里是同样的 ERROR upstream 502 那
一行。
标签基数:最贵的那个错
上面这些都让人愉快,这部分才咬人。保持标签基数低:每一个唯一的标签组合就是一 条新流,而每条流都要吃内存、一个 chunk、一条索引项。
我量过。同一台机器、同一份配置,500 行日志推两次——一次用低基数标签,一次把
request_id 当成标签:
=== before cardA ===
loki_ingester_memory_streams=3 labels_bytes=203
=== after cardA (500 lines, 1 stream) ===
loki_ingester_memory_streams=4 labels_bytes=250
delta streams=1 delta label bytes=47
=== after cardB (the same 500 lines, request_id as a label) ===
loki_ingester_memory_streams=504 labels_bytes=35250
delta streams=500 delta label bytes=35000
日志内容完全一样。索引成本:47 字节对 35,000 字节——745 倍——同时活动流从 1 条
变成 500 条。标签元数据大约是每条流 70 字节,所以一天一百万个不同的 request_id
就是每天约 70 MB 的索引,而你还没存进任何一行日志。
Loki 也不会让你错得很离谱。max_label_names_per_series 默认是 15,推 18 个直接报
错:
$ curl -sS -X POST http://127.0.0.1:3100/loki/api/v1/push -H "Content-Type: application/json" -d @limit.json
entry for stream '{job="limit-demo", l00="v", ... l15="v", service="api", service_name="api"}' has 18 label names; limit 15
我实际在用的规则:
- 好标签:
job、service、level、env、instance、host——有界集合,最多 几十个值。 - 坏标签:
request_id、user_id、trace_id、url_path、ip——无界。把它们放 进行里,用|=/|~/| json过滤。 - 判据: SQL 里你不会
GROUP BY的东西,就别拿来做标签。 - 检查:
loki_ingester_memory_streams和..._labels_bytes是两个该告警的 gauge。流数跟着流量走而不是跟着服务数走,那就是这个 bug。
经典事故:有人为了让链路追踪「刚好能用」把 trace_id 做成标签,流数跟着流量垂直上
升,修复方式是把它降回日志行里。基数是 Loki 里唯一一个「做起来很贵、避开却免费」的
错误。
保留策略与存储
保留是 schema 周期的事,不是后补的。用 TSDB 索引(2.4 之后的默认)时,period: 24h
每天开一张新索引表,compactor 删掉比 retention_period: 168h 更老的表:
limits_config:
retention_period: 168h # 7 天,在 /config 里显示为 "1w"
compactor:
working_directory: /loki-data/compactor
retention_enabled: true
delete_request_store: filesystem # <- 必填,见下
两个真实的坑:第一,不写 delete_request_store 就开保留,验证直接失败——我第一遍就是
这么栽的:
level=error msg="error running loki" err="CONFIG ERROR: invalid compactor config:
compactor.delete-request-store should be configured when retention is enabled"
第二,reject_old_samples_max_age: 168h 会静默丢掉早于一周的行:它是时钟漂移的安全
网,也是 agent 重放 backlog 时「日志怎么少了」的困惑来源。
关于存储:chunk 是 gzip 压过的(默认 chunk_encoding: gzip),标签索引则随流数增
长——基数那套论证换个说法再说一遍。这个实例的实测:1,008 行、80,001 字节推送载荷、
504 条流、35,250 字节标签元数据、磁盘上 168 KB,其中大部分还是我为了论证故意造出来
的 500 条流。文件系统存储对单机足够;需要第二个写入方时上 S3/GCS。
Loki 对 ELK,以及 Loki 对 journald
ELK 是一个有主见的搜索引擎:Elasticsearch、Logstash 或 Beats、Kibana,再加上你 为之付费的倒排索引。当问题是「在 2 TB 日志里找出所有提到这个词的行」,或者你想对日 志内容做聚合时,它赢。其余场合都是 Loki 赢:一个二进制、一个存储后端、一门你从 PromQL 里已经会一半的查询语言,以及一份不需要 CFO 过目的存储账单。实话实说这个取 舍:Loki 不擅长临时性的全文搜索,多少标签都补不回来。
journald 不是竞品,它是本地的地板。在单台 Linux 上,
journalctl -u nginx --since "10 min ago" -p err 比任何聚合器都快,而且是自带的。等
到你有两台主机需要关联、需要一份轮转策略、或者有个不能 SSH 的人要看日志时,
journald 就不够了——而 Promtail 可以直接读 journald,所以集中化不用动应用。
几台服务器上,grep 什么时候真的够用
五台机器以下、团队不大的时候,组织良好的 SSH + grep 是一个真实策略,不是你要摆脱
的过渡阶段。ansible all -i inv -m shell -a 'grep -c ERROR /var/log/app.log' 几秒
钟就能回答「有没有在报错」,零新增基础设施。具体的临界点在这几处:
- 你需要把多个服务或多台主机的日志关联到一个视图里;
- 有个没有 SSH 权限的人需要读日志;
- 保留时长必须超过本地磁盘;
- 你想对一个模式告警(「5xx 比例五分钟内超过 1%」);
- 你想从 grep 的同一份数据里拿到
rate()和分位点。
越过前两条,一台跑在 5 美元 VPS 上的 Loki 容器就是最便宜的进阶;在这两条以下,grep
真的是对的工具,Loki 只是开销。
收尾
Grafana Loki 并不想成为 Elasticsearch。它想做的是回答「我的日志里现在正在发生什 么」的最便宜方式,靠的是索引标签而不是全文、把 chunk 压到对象存储上、以及复用你脑 子里已经有的 Prometheus 模型。只用低基数字段打标签,把有意思的值留在日志行里用 LogQL 过滤,除了日志速率也告警流数,保留策略写在 schema 加 compactor 里。照这个开 始,Loki 就会一直很无聊——这是我能给一套日志栈的最高评价。
同系列还有:结构化日志 和 Prometheus 实战。