2930 字
约 9 分钟
17
S16-X09 · 日志聚合与查询:ELK、Loki 与自建方案
2024-12-31
2026-11-08

单机 grep 的阶段过得很快。服务上了多副本、容器化之后,日志散在十几台机器和几百个容器里,tail -f 与 grep 同时失效。聚合系统要解决的不是"把日志存起来",而是故障发生时,用最少的假设条件把那几行找出来。


1. 聚合要解决的三件事

目标 具体要求 不达标时的表现
集中存储 多主机、多容器统一入口,写入不丢 排障要逐台 ssh,事后日志已被轮转
按维度检索 按服务、级别、时间、traceId 过滤 只能全文搜,慢且不准
成本可控 按价值分级保留 存储账单追上机器账单

第三件最容易被忽略。日志写入量通常是业务请求量的十倍以上量级(访问日志 + 应用日志 + 框架 debug),这也是 ELK 与 Loki 分道的根本原因。


2. ELK/EFK 与 Loki 的架构差异

维度 ELK / EFK Loki
索引对象 正文分词后建倒排索引 只索引标签,不索引正文
存储形态 索引与原始数据同量级膨胀 压缩块 + 对象存储
全文检索 强,任意词、任意组合 弱,标签筛完再在块内扫
写入成本 高(分词、建索引、refresh) 低(压缩后写 chunk)
查询成本 索引命中快,聚合强 扫描量大时慢,强依赖时间范围
典型用途 审计、统计聚合、复杂条件组合 按服务/容器排障、近似 tail -f
# Loki 的存储配置:索引与数据分离,数据落对象存储
storage_config:
  tsdb_shipper:
    active_index_directory: /loki/tsdb-index
    cache_location: /loki/tsdb-cache
  aws:
    s3: s3://minio:9000/loki
    s3forcepathstyle: true
limits_config:
  retention_period: 720h

Loki 的"便宜"来自它不做全文索引:查询时先按标签把 stream 缩到几个,再在时间窗口内扫压缩块。所以标签设计错了,Loki 会比 ES 慢得多,而且慢在 CPU 和 IO 上,不在索引上。


3. 采集端选型

采集器 实现 常驻内存典型量级 配置方式 适合
Filebeat Go 几十 MB YAML + 模块 传统主机、日志文件、输出到 ES/Kafka
Fluent Bit C 十 MB 级 段式 conf 容器 sidecar/DaemonSet、资源紧张、多路输出
Promtail Go 几十 MB YAML + pipeline stages 只服务 Loki,需要从日志提取标签
# Filebeat:多行日志必须先合并,否则一条异常会被拆成几十条
filebeat.inputs:
  - type: filestream
    paths: [/app/logs/*.log]
    parsers:
      - multiline:
          pattern: '^\d{4}-\d{2}-\d{2}'
          negate: true
          match: after
output.kafka:
  hosts: ["kafka-1:9092"]
  topic: app-logs
# Fluent Bit:容器场景的极简管道
[INPUT]
    Name              tail
    Path              /var/log/containers/*.log
    Parser            docker
    Tag               kube.*
[FILTER]
    Name              kubernetes
    Match             kube.*
[OUTPUT]
    Name              loki
    Match             kube.*
    Host              loki
    Labels            job=fluentbit

选择顺序建议:已经有 ES 就用 Filebeat,全容器化且节点资源紧就用 Fluent Bit,目标端是 Loki 就用 Promtail。混用不是问题,但同一类日志不要有两个采集器同时读,重复写入会让账单翻倍。


4. 管道的五层与每层失败点

层 职责 典型组件 失败点与表现
采集 读文件、跟踪偏移量 Filebeat / Fluent Bit / Promtail 轮转丢行(registry/position 文件与实际不符)
缓冲 削峰、解耦 Kafka / Redis 磁盘满、消费组 rebalance 导致重复
解析 结构化、抽字段 Logstash / Vector / OTel Collector grok 不匹配,字段全空但不报错
存储 索引、压缩、保留 Elasticsearch / Loki 分片过多、动态 mapping 爆炸、水位告警
查询 过滤、聚合、关联 Kibana / Grafana 时间范围过大、前置通配、正则全表扫

解析层的问题最隐蔽:grok 匹配失败时默认走 _grokparsefailure 标签而不是报错,于是日志照常入库、字段全是空的,直到有人按字段查才发现。上线前必须验证一条真实日志能被解析成预期字段,而不是看"有数据进来了"。


5. 标签与字段设计

Loki 的标签等价于 stream 的主键。标签组合越多,stream 越多,ingester 的内存与索引条目跟着涨。

类别 例子 能否做标签 原因
低基数、稳定 app、env、namespace、level 可以 组合数有限
中基数 pod、host、container 谨慎 滚动发布后 pod 名不断新增
高基数 traceId、userId、requestId、path 不能 每个值一个 stream,索引爆炸
# Promtail:正确做法——标签只放低基数字段,其余留在正文
scrape_configs:
  - job_name: app
    static_configs:
      - targets: [localhost]
        labels:
          app: clarity-web
          env: prod
    pipeline_stages:
      - json:
          expressions:
            level: level
            traceId: traceId
      - labels:
          level:            # 低基数,可以作为标签
      - structured_metadata:
          traceId:          # 高基数,放结构化元数据,不进索引

structured_metadata(Loki 2.9 起提供)就是给这类"想过滤但不想建 stream"的字段准备的。低版本没有这个能力时,只能把这些字段留在正文里,用 |= "traceId=abc" 过滤。


6. 保留策略与冷热分层

层 介质 保留 查询特征
热 本地 SSD 3–7 天 秒级,排障主力
温 普通磁盘 / 低配节点 15–30 天 稍慢,能接受
冷 对象存储(S3/MinIO) 90 天–1 年 需先恢复或走外部表,分钟级
删除 — 到期即删 合规与成本双重需要
{
  "policy": {
    "phases": {
      "hot":    { "min_age": "0ms",  "actions": { "rollover": { "max_primary_shard_size": "30gb", "max_age": "1d" } } },
      "warm":   { "min_age": "7d",   "actions": { "shrink": { "number_of_shards": 1 }, "forcemerge": { "max_num_segments": 1 } } },
      "cold":   { "min_age": "30d",  "actions": { "freeze": {} } },
      "delete": { "min_age": "90d",  "actions": { "delete": {} } }
    }
  }
}

ES 侧的关键是按天滚动 + 单分片不超过几十 GB,而不是给小索引切十几个分片。分片数一旦多于数据量所需,查询会把时间耗在协调上。


7. 查询性能与三者的串联

优化动作 原理 代价
收窄时间范围 直接减少要扫的块/分片 无,永远先做这一步
先标签后正文 标签走索引,正文走扫描 需要前期标签设计正确
用 ` =` 精确串而非正则 子串匹配可跳块
避免 .*error.* 前置通配 无法利用索引/无法跳块 需要改写查询
解析后移 先过滤再 ` json` 解析
# Loki:标签先行,时间收窄到 15 分钟,正文用精确串
$ logcli query '{app="clarity-web",env="prod"} |= "ERROR" ' \
    --since=15m --limit=200

# Elasticsearch:按 traceId 拉一条完整链路
$ curl -s 'http://es:9200/app-logs-*/_search?pretty' -H 'Content-Type: application/json' -d '
{ "size": 200,
  "query": { "bool": { "filter": [
      { "term": { "traceId": "abc123" } },
      { "range": { "@timestamp": { "gte": "now-1h" } } } ] } },
  "sort": [ { "@timestamp": "asc" } ] }'

三者串联靠的是同一个 traceId:指标侧记录 exemplar 指向 trace,追踪侧存 span 树,日志侧在每行带上 traceId。Grafana 里用 Derived fields 从日志行提取 traceId 直接跳转追踪系统,比手工复制粘贴可靠得多。


8. 自建 vs 托管的判据

判据 倾向自建 倾向托管
日增量 几十 GB 以上,长期跑 几 GB,波动大
保留要求 有明确分级、可上对象存储 只需要 7–14 天
查询并发 少数人排障 多团队、多面板同时查
运维人力 有专人能处理分片与水位 无人专职
合规 数据必须留在自有环境 允许出网

自建的隐性成本集中在三处:ES 的 JVM 与堆外内存规划、分片与副本的容量估算、以及升级时的滚动重启。托管省掉的是这三块,代价是单价与查询额度限制。


9. 排错与常见坑

现象 原因 处理
有日志但字段全空 grok/json 解析失败,走了 failure 标签 用单条样本在解析器里试跑,检查字段名大小写
日志重复两遍 两个采集器读同一路径,或 Kafka 重复消费未幂等 一个路径只留一个采集器;写入端按 traceId+时间 去重
索引写入被拒 磁盘水位触顶(默认低水位 85%、高水位 90%、洪水位 95%) 先删旧索引或加节点,再调 cluster.routing.allocation
集群分片数暴涨 每天新建索引且分片数偏大 用 rollover + 单分片体积上限
Loki 查询超时 时间范围过大或标签过宽 收窄范围,改标签组合
采集器内存上涨 多行合并的缓冲过大、正则回溯 限制 multiline 缓冲与超时,简化正则

常见坑:

坑 表现 正确做法
日志量失控 磁盘与账单同步上涨 生产默认 INFO,debug 只在排障时按 logger 临时开
把结构化日志拼成一整条字符串 无法按字段聚合,只能全文搜 直接输出 JSON 行,让采集端解析
索引分片过多 查询慢、堆压力大 按数据量定分片,单分片几十 GB 量级
用 traceId 做 Loki 标签 stream 爆炸、写入被限 用 structured_metadata 或正文过滤
解析规则写错不报错 静默丢字段 加解析失败计数指标并告警
只在出事后才查保留期 关键日志已被删 保留期按"最长排障周期"定,不按账单定

10. 边界

场景 更合适的方案 理由
只想知道错误率趋势 指标(Prometheus) 日志算比率成本高一个量级
需要精确的请求链路耗时 分布式追踪(Tempo/Jaeger) 日志只能近似拼时间
单机临时排障 journalctl、grep、lnav 不值得为一次排查搭管道
审计与合规留痕 对象存储 + 只写不改的归档 查询不是主要诉求,完整性才是
超大全文检索需求 ES/OpenSearch Loki 的正文检索天生弱

一句话选择:要"能查任意词"选 ES,要"按服务快速排障且省钱"选 Loki,两者都别指望替代指标与追踪。


11. 自测

  1. 聚合系统要解决的三件事里,哪一件最容易被忽略,为什么?
  2. Loki 不做全文索引,那它的查询靠什么把范围缩小?
  3. grok 解析失败时日志为什么还能正常入库,风险在哪?
  4. 为什么 traceId 不能作为 Loki 的标签?低版本怎么办?
  5. ES 索引"分片过多"会以什么方式表现出来?
  6. 判断自建还是托管,你会先看哪两个量?
S16-X09 · 日志聚合与查询:ELK、Loki 与自建方案
https://www.clxhxhhr.top/posts/5808/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。