为什么需要专门的日志方案

服务器上的日志天然长在两个地方:一个是本机磁盘上的文件,一个是标准输出。机器少的时候,ssh 上去 tail -f 就能搞定;一旦服务扩到几十台、上百个容器,登录每台机器翻日志这件事就破产了——你既不知道问题落在哪台机器上,也没法跨机器按时间线把一次请求拼起来。

一套专门的日志方案要解决三件事:

  • 集中:所有服务、所有节点的日志都汇到同一个地方,有统一的时间线。
  • 可查:能按服务、按错误级别、按 traceId 秒级捞出想要的那几行,而不是 grep 一整晚。
  • 可告警:错误日志突增、关键字刷屏时,主动把人叫起来,而不是等用户投诉。
日志是系统的黑匣子。平时它沉默地占着磁盘,事故时它是唯一不会替你「记忆美化」的证人。
本文核心知识点
  • 采集分工Fluent Bit / Filebeat 跑在节点侧,轻量、低侵入地把日志搬上管道。
  • Loki 哲学只索引标签、不索引正文,用对象存储存原始日志,成本远低于 ES 全文索引。
  • 结构化日志在产生时就写成 JSON,比事后正则解析可靠十倍。
  • 成本按价值分层保留:ERROR 留 30 天、DEBUG 按天滚动,用生命周期策略自动清理。
日志收集分析配图:光点条带汇入漏斗再分发到圆柱存储节点的蓝色扁平科技插画
采集端负责「搬得动」,存储端负责「存得起」,查询端负责「找得到」——三段缺一不可。

采集端:Fluent Bit 与 Filebeat 的分工

采集端是整个日志链路里离业务最近的一环,它跑在每台机器或每个节点上,盯着日志文件的尾部,把新产生的行一行行读出来、加上标签、发往后端。两个主流选手:

  • Fluent Bit:CNCF 毕业项目,C 语言编写,内存占用几 MB,单机万级日志吞吐毫无压力。Kubernetes 里通常以 DaemonSet 形式每节点一个。
  • Filebeat:Elastic 出品,Go 编写,成熟稳定、配置生态丰富。吃内存比 Fluent Bit 大一些,但和 ELK 栈天然亲和。

两者干的事一样:tail → 解析 → 打标签 → 输出。下面是一段 Fluent Bit 配置,采集容器日志并打上 namespace、container 标签后发往 Loki:

[INPUT]
    Name              tail
    Path              /var/log/containers/*.log
    Parser            json
    Tag               kube.*

[FILTER]
    Name              parser
    Match             kube.*
    Key_Name          log
    Reserve_Data      On
    Preserve_Key      On

[OUTPUT]
    Name              loki
    Match             *
    Host              loki.gateway.svc
    Port              3100
    Labels            job=fluentbit, namespace=$kubernetes['namespace_name'], container=$kubernetes['container_name']

采集端最容易被忽视的是背压处理:后端短暂不可用时,采集器必须把日志缓存在本地磁盘而不是直接丢。Fluent Bit 的 storage.type filesystem 就是干这个的——日志先落本地,后端恢复后自动补传,保证「不丢一条」。

云原生日志栈:Loki 的设计哲学

ELK 强大但重:Elasticsearch 要给日志正文建倒排索引,磁盘和内存都吃得很凶。Grafana Labs 推出的 Loki 走了一条相反的路——只给标签建索引,日志正文原样压缩存在对象存储里。

维度ElasticsearchLoki
索引对象全文倒排索引仅标签(labels)
存储本地磁盘 / SSDS3 等对象存储,成本低一个量级
查询方式全文 DSLLogQL:先标签过滤,再正文匹配
擅长复杂全文检索、聚合分析按标签快速定位后 grep 正文
资源占用重,需要大量堆内存轻,可小规格起步

它的查询模型因此有个重要推论:标签设计就是查询设计。你不可能像在 ES 里那样对任意字段做全文聚合,必须在采集时就想清楚「未来我会按什么维度查日志」——服务名、环境、命名空间、实例 ID。把这些打进标签,正文里的细节靠 grep 过滤。

这也引出一条纪律:标签基数要克制。traceId、用户 ID 这类每个值都不同的东西绝对不能进标签,否则时序索引会爆炸——和 Prometheus 指标的高基数陷阱是同一个道理。

结构化与解析:把文本变成可查询数据

同一条错误,写成「支付超时,订单 SO10086 耗时 3000ms」和写成一行 JSON,事后分析的难度天差地别。前者要靠正则去猜字段,后者字段天然就有。

# 反例:纯文本日志,靠正则解析,脆弱
2026-10-14 09:12:33 ERROR order payment timeout orderId=SO10086 costMs=3000

# 正例:结构化 JSON(单行),字段直接可查
{"ts":"2026-10-14T09:12:33Z","level":"ERROR","service":"order","traceId":"a1b2c3","msg":"payment timeout","orderId":"SO10086","costMs":3000}

实在改不动老应用的输出格式时,才在采集端补一层解析——用正则或 Grok 把文本切成字段。但要明白:事后解析永远是次优解,正则会因为多一个空格就整个崩掉。

结构化日志要点原因
时间戳用 ISO8601 / RFC3339跨时区、跨机器可对齐,避免本地时区混乱
带 traceId跨服务串联同一次请求的全部日志
级别枚举固定DEBUG / INFO / WARN / ERROR,不要自创级别
错误对象单独字段堆栈放 error 字段,msg 保持简短
不打印大对象日志量翻倍的元凶,必要时只打 ID

LogQL 查询与日志告警实战

Loki 的查询语言叫 LogQL,语法上和 PromQL 一脉相承,分成两段:先用花括号选标签流,再用管道对正文做过滤和指标聚合。

# 1. 选出 order 服务在 prod 环境的全部错误日志
{namespace="prod", container="order"} |= "ERROR"

# 2. 在上面基础上进一步过滤:只看支付超时
{namespace="prod", container="order"} |= "ERROR" |~ "payment.*timeout"

# 3. 把错误数转成指标:每分钟错误日志条数
sum(count_over_time({container="order"} |= "ERROR" [1m]))

# 4. 提取字段后再聚合:按 orderId 统计报错次数
sum by (orderId) (count_over_time({container="order"} | json | __error__=="" [5m]))

日志告警的思路和指标告警一致:先把日志流聚合成一个数值序列,再对数值设阈值。下面这条规则的意思是——order 服务每分钟错误日志超过 50 条就触发:

groups:
  - name: log-alerts
    rules:
      - alert: OrderErrorSpike
        expr: sum(count_over_time({container="order"} |= "ERROR" [1m])) > 50
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "order 服务错误日志每分钟超过 50 条"

实践经验:日志告警别直接挂「含 ERROR 就报警」——那样一天响几百次。正确做法是对错误量做聚合、设速率阈值、加持续窗口,把偶发抖动挡在门外。