单机 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. 自测
- 聚合系统要解决的三件事里,哪一件最容易被忽略,为什么?
- Loki 不做全文索引,那它的查询靠什么把范围缩小?
- grok 解析失败时日志为什么还能正常入库,风险在哪?
- 为什么
traceId不能作为 Loki 的标签?低版本怎么办? - ES 索引"分片过多"会以什么方式表现出来?
- 判断自建还是托管,你会先看哪两个量?