接手团队第一件事,就是把那套动不动就吞日志、查个报错要等半天的EFK给换掉。不是EFK不好,而是对于日均几个GB日志量的中小型业务来说,它太重了。我们换成了Loki + Promtail + Grafana,这套组合轻量、原生支持Prometheus标签体系,接入成本低到离谱。如果你也被日志系统折磨过,或者正打算自建一套,这篇文章应该能帮你少踩几个坑。我会从选型理由、架构思路、部署细节、告警配置到真实踩坑记录,全流程捋一遍。
1. 方案选型:为什么是Loki而不是Elasticsearch
1.1 当EFK变成负担时,就该换思路了
先交代一下背景。原来团队用的是一套标准的EFK:Elasticsearch存索引、Filebeat采集、Kibana展示。单机部署,每天日志量大概4-6GB,保留7天。听起来不算大对吧,但实际情况是,Elasticsearch经常出现分片分配缓慢、JVM堆内存告警,偶尔还会出现索引变红的情况。为了压住ES的稳定性,专门分了8核16G内存给它,可它依旧吃紧,尤其是大批量查询的时候CPU直接飙到90%以上。
后来认真分析了一下业务场景:我们90%以上的日志查询,都是按某个服务名加时间范围,去查某几个关键字段。根本没有必要做全文索引,也没多少全文检索的需求。那Elasticsearch最引以为傲的倒排索引和分词能力,在这里就变成了纯粹的负担——索引写得慢、存储占用大、查询还要过一层DSL,心智负担也不小。
当时也评估过ClickHouse,性能确实很强,但运维成本不比ES低多少,而且对于我们要接Prometheus告警生态来说,ClickHouse的链路不够顺。Loki出现在备选清单里,很大程度上是因为它的设计理念:不为日志建立全文索引,而是为日志流打上标签(label),查询时先根据标签缩小范围,再直接扫描日志内容。这个思路放在我们的场景里,简直是量身定制。
1.2 三个开源日志系统核心机制对比
为了把这次选型讲清楚,我直接拿我们当时的评估结果表来说话。这张表花了一个下午整理出来的,当时比对的就是现在主流的三个方案:Elasticsearch、Loki、ClickHouse。
| 对比维度 | Elasticsearch | Loki | ClickHouse |
|---|---|---|---|
| 索引机制 | 倒排索引,分词/全文检索能力强 | 仅索引标签(Label),日志内容不建索引 | 列式存储,稀疏索引 |
| 部署资源占用 | 高,JVM堆内存通常16G起步 | 极低,单机几百MB即可运行 | 中等,依赖ZooKeeper/ClickHouse Keeper |
| 查询语法 | DSL复杂,学习成本高 | LogQL,类似PromQL,容易上手 | SQL,灵活但需要优化表引擎 |
| 与Prometheus集成 | 需要额外搭桥 | 原生同属Grafana生态 | 需要自研桥接 |
| 存储成本 | 三副本,磁盘占用大 | 对象存储或本地文件系统,压缩率高 | 高压缩比,但副本策略要自己搞 |
| 适合场景 | 全文检索、复杂聚合分析 | Kubernetes/微服务日志监控 | 海量日志分析、审计报表 |
从对比来看,Loki在“轻量运维”和“监控生态集成”两个维度上有明显优势。它不需要JVM、不依赖一堆插件,一个二进制文件就能跑起来,内存占用低到令人发指——我们生产环境给了它2G内存上限,跑满都用不到1G。
对了,很多人听到“日志不建索引”会担心查询速度。Loki的查询流程是:先匹配标签,找到对应的存储块(Chunk),再在Chunk内扫描内容。只要你的标签设计到位,扫描量就非常小,几GB日志在秒级出结果很正常。
1.3 标签体系设计:决定Loki性能的分水岭
Loki里最重要的一件事就是标签设计。这一点我必须放在最前面说,因为标签配得不好,后面神仙难救。我第一次部署Loki时,把Pod名、容器名、镜像版本全部设成了标签,结果标签基数(Cardinality)瞬间爆炸,查询性能差到令人崩溃。
Loki的设计哲学里,标签应该具备两个特征:第一,基数要低;第二,用于粗粒度筛选。一个标签的取值越多,基数越高,索引膨胀越严重。Pod名这种每次重启都可能变化的值,绝对不能做标签——第一次用的时候没意识到,导致index暴涨,后面清理重建索引清理到想哭。
我们最终的标签体系只保留了五个维度:
app:服务名,比如order、user、pay,取值不超过20个env:环境,比如prod、staging,取值不超过3个level:日志级别,error、info、debug等host:服务器IP,几十个节点,可控container:容器名,某些服务有多容器场景
像TraceID、RequestID、订单号这种高基数的值,全部放在日志内容里,通过LogQL的过滤表达式去匹配,不参与标签索引。这个设计原则用一句话概括就是:标签能粗尽量粗,内容查询交给LogQL过滤,别拿标签当索引用。
2. 部署与配置:从单机到集群踩过的路
2.1 单机部署是起步最快的方式
Loki的单机部署极其简单,官方提供了all-in-one的二进制包和Docker镜像。我们起步阶段就是一台4核8G的虚拟机,把Loki、Promtail、Grafana三个服务全部跑在这台机器上。你没看错,Promtail(日志采集端)可以和Loki在同一台机器上,只是它采集的是其他服务器转发过来的日志流。
单机部署的Loki配置,核心部分我先把当时用的配置贴出来,然后逐一解释每一段的作用。这是基于官方local-config.yaml模板修改来的:
auth_enabled: false server: http_listen_port: 3100 common: path_prefix: /loki storage: filesystem: chunks_directory: /loki/chunks rules_directory: /loki/rules replication_factor: 1 ring: kvstore: store: inmemory schema_config: configs: - from: 2023-01-01 store: boltdb-shipper object_store: filesystem schema: v11 index: prefix: index_ period: 24h limits_config: retention_period: 336h reject_old_samples: true reject_old_samples_max_age: 168h ingestion_burst_size_mb: 16 ingestion_rate_mb: 8 compactor: working_directory: /loki/compactor compactor_ring: kvstore: store: inmemory几个关键点说一下。schema_config里指定了存储引擎,现在用boltdb-shipper可以兼容将来的对象存储迁移,到时候不用动schema。retention_period: 336h就是保留14天的日志,按自己的需求调整。reject_old_samples这个参数很重要,它决定了Loki是否接受时间戳太老的日志,我设成了拒绝超过7天的旧日志,这样可以防止日志客户端时间错乱导致存储被垃圾数据占满。
这只是单机版。如果是多副本集群部署,需要考虑ring的存储后端换成etcd或Consul,还要开启memberlist来实现节点发现。这些内容不是这次的重点,先按下不表。
2.2 Promtail采集端配置:别小看这层皮
Promtail是Loki官方的日志采集器,职责就是读日志文件、加标签、推送到Loki。我们用的是Promtail的Docker部署方式,每个宿主机上跑一个Promtail容器,通过挂载宿主机目录来采集所有容器日志。
Promtail的核心配置是scrape_configs,它的工作方式有点像Prometheus。下面这段配置是我们生产环境的简化版:
server: http_listen_port: 9080 grpc_listen_port: 0 positions: filename: /tmp/positions.yaml clients: - url: http://loki-server:3100/loki/api/v1/push scrape_configs: - job_name: docker-container-logs docker_sd_configs: - host: unix:///var/run/docker.sock refresh_interval: 5s relabel_configs: - source_labels: ['__meta_docker_container_name'] regex: '/(.*)' target_label: 'container' - source_labels: ['__meta_docker_container_log_stream'] target_label: 'stream' - source_labels: ['__meta_docker_container_label_log_style'] target_label: 'logstyle' - source_labels: ['__meta_docker_container_label_com_docker_swarm_service_name'] target_label: 'swarm_service'关键点在于docker_sd_configs,它会自动发现宿主机上所有运行中的容器,然后通过relabel规则提取容器的元信息为Loki标签。这样做的好处是,新服务上线时不需要手动改Promtail配置,它会自动采集新容器的日志,而且自动加上container标签,极大减少了运维工作量。
有段时间我们直接用static_configs指定日志文件路径,每次新服务上线都要改配置重启Promtail,烦人。后来切换到docker_sd_configs之后,世界清净了。如果你的环境不是Docker而是裸机进程,那对应的采集方式就是static_configs加__path__通配符,原理一致,只是少了容器自动发现这一步。
2.3 Grafana接入与提速的隐藏参数
Grafana接入Loki很简单,在数据源里选择Loki,填上API地址和端口就行了。但这里有个小细节,如果Grafana和Loki之间网络不是特别稳定,建议在Grafana配置里调一下超时时间。默认超时偏短,日志量大时查询容易报超时,导致前端体验很差。
然后就是提速的关键参数了。Grafana连接Loki有一个隐藏的HTTP参数max_look_back_period,在配置数据源时你可以在"Additional headers"里加X-Scope-OrgID,也可以在下方的URL参数区域加上?max_look_back_period=168h,这样默认查询的时间范围可以设得更宽,避免前端默认只查最近6小时而错过问题。
另外,Grafana查询Loki时,建议打开"Instant"查询选项。Instant查询和Range查询的区别在于:Range查询返回的是一段时间内的所有数据点,Instant查询只返回时间戳对应的单个数据点。对于日志趋势图和告警查询,Instant模式速度会快很多,因为减少了数据传输和渲染的压力。
3. 日志采集进阶:从Docker到Kubernetes的迁移之路
3.1 容器日志采集的两种标准化姿势
承接上一部分的内容,容器日志采集在Docker环境用docker_sd_configs确实方便,但到了Kubernetes环境,这套方案就要让位了。我们在业务迁K8s的过程中,对日志采集做过一次比较完整的重构,这里给大家分享一下最终稳定的方案。
Kubernetes下日志采集的主流姿势有两种:第一种是DaemonSet方式,每个节点跑一个Promtail,采集节点上所有Pod的日志文件;第二种是Sidecar方式,每个Pod里塞一个Promtail容器,只采集这个Pod的日志。
我們最终选了DaemonSet方式,原因很简单——省资源。如果一个节点上有30个Pod,每个Pod都跑一个Sidecar Promtail,那一台机器上就白白多了30个进程。而DaemonSet方式,一个节点只需要一个Promtail实例,通过读取Pod的日志文件路径来采集所有Pod的日志。
K8s环境下的Promtail配置会复杂一些,需要用到kubernetes_sd_configs来解决服务发现:
scrape_configs: - job_name: kubernetes-pods kubernetes_sd_configs: - role: pod relabel_configs: - action: replace source_labels: - __meta_kubernetes_pod_label_app target_label: app - action: replace source_labels: - __meta_kubernetes_pod_container_name target_label: container - action: replace source_labels: - __meta_kubernetes_namespace target_label: namespace - action: replace source_labels: - __meta_kubernetes_pod_name target_label: pod - action: replace source_labels: - __meta_kubernetes_pod_container_log_file_path target_label: __path__这段配置的核心就是通过__meta_kubernetes_pod_container_log_file_path拿到日志文件的完整路径,然后赋给__path__变量。Promtail就会自动去读取这个日志文件并采集。
实际上__path__这个内部标签最终不会出现在存储的日志流标签里,它只负责告诉采集器“去哪读日志”。最终在Loki里看到的标签是app、container、namespace、pod这些我们主动设置的。
3.2 把K8s事件日志纳入采集范围
Kubernetes环境除了业务日志,还有一个很容易被忽略的日志源——K8s事件。比如Pod被驱逐、镜像拉取失败、健康检查探针失败,这些事件通常会写到kube-system命名空间下的事件对象里,而不是标准输出。如果只采集了容器stdout日志,这类重要事件就会漏掉。
我们把K8s事件也纳入了存储,方法是用kubernetes-events这个采集器,它会把事件作为日志流写入Loki。步骤很简单,在Promtail配置里增加一个新的scrape_config,目标指向event API:
- job_name: kubernetes-events kubernetes_sd_configs: - role: event relabel_configs: - action: replace source_labels: - __meta_kubernetes_event_object target_label: object - action: replace source_labels: - __meta_kubernetes_event_reason target_label: reason开启之后,Grafana里可以建一个K8s事件面板,按reason维度聚合展示,排障的时候真的能救命。有一次线上服务大规模重启,我们就是这个面板第一时间发现是镜像仓库拉取超时导致CrashLoopBackOff,省去了逐个看Pod详情的痛苦。
3.3 日志清洗与多行日志处理的坑
使用容器日志时,有一个细节必须提及:多行日志。比如Java异常堆栈,通常是一条日志跨多行,默认情况下Promtail会把每一行当作一条独立日志,异常堆栈会被拆成N条独立记录,不仅难读,而且扰乱了时间顺序,排查问题时看到破碎的堆栈信息会非常头痛。
解决办法是用Promtail的multiline配置。下面这个配置块加到Promtail的scrape_configs里,就能把以空格或Tab开头的行合并到前一行:
pipeline_stages: - multiline: firstline: '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}' max_lines: 100firstline用正则表示每一条新日志的第一行应该以什么开头。如果日志格式是2024-12-01 10:00:00开头,那这个正则就是写死的。max_lines限制一条日志最多合并多少行,防止某次异常堆栈特别长导致存储被一个超大日志块占满。
还有个清洗的小技巧。如果日志里包含敏感信息,比如手机号、身份证号、Token,建议在Promtail的pipeline_stages里用regex和replace做脱敏。我当时给底层的支付服务增加了一步脱敏,把日志里的卡号字段正则替换成****,不然日志系统很容易成为数据泄露的重灾区。
4. 基于LogQL的查询与告警实战
4.1 LogQL查询语法速成与实例拆解
Loki的查询语言LogQL和PromQL同源,如果你之前用过Prometheus,上手会非常快。如果没有也没关系,这里直接讲几个最常用的查询场景。
最基础的就是按标签过滤加关键字搜索:
{app="order-service"} |= "Exception" |= "NullPointerException"这条查询的意思是:在app标签为order-service的所有日志流中,找出包含Exception关键字,且同时包含NullPointerException的日志。|=这个符号表示“包含”,相对的还有!=表示不包含,|~表示正则匹配,!~表示正则不匹配。
如果要统计每分钟的错误日志数量,可以加上聚合函数:
sum(count_over_time({app="order-service"} |= "ERROR" [1m])) by (level)这个逻辑是:先按标签和关键字筛出错误日志,然后count_over_time统计每分钟的日志条数,再用by (level)按日志级别分组展示结果。用在Grafana的图表面板上,就是一条实时的错误日志趋势线。
还有一个很实用的场景:按接口维度聚合访问量。假设你的日志格式是time=... level=info msg="GET /api/order/123" status=200这类,用pattern解析器可以把msg里的URL路径抽取出来作为标签,再做聚合:
{app="api-gateway"} |= "GET" | pattern "<method> <url> <status>" | sum by (url) (count_over_time({app="api-gateway"}[5m]))LogQL语法的核心思路我总结一下:先靠标签缩小范围,再用过滤符精准定位内容,最后用函数做统计。不要一上来就试那些高级函数,先掌握count_over_time和sum by,能应付80%以上的查询需求。
4.2 告警规则配置与前端通知打通
日志告警是Loki的一个重要使用场景。我们在Grafana里为多个核心服务配置了告警规则,比如“错误日志超过阈值”、“某个服务日志量骤降为零”等等。之前遇到过服务挂掉但日志量没触发告警的情况,后来把告警规则分成两类:一类按错误数量告警,另一类按日志总量、心跳日志缺失来告警,双管齐下才算靠谱。
告警规则的配置在Grafana的Alerting模块里自然编辑即可,不需要写代码。关键步骤是设置好查询条件:
- 查询表达式:
sum(count_over_time({app="order-service"} |= "ERROR" [5m])) > 50 - 评估周期:每1分钟执行一次
- 持续时间:超过5分钟才触发告警
- 通知渠道:Webhook到企业微信/钉钉群
这里有个经验,评估间隔建议设置短一些,但持续时间一定要设置长一点,不然高峰期日志短时抖动就会触发一堆告警,到头来全是误报,真正的告警反而被淹没了。我们最初持续时间设置1分钟,结果一天收到上百条告警;后来调整为5分钟,告警量降到了每天几条,但是每条都有价值。
Webhook接通知的方式也很简单,Grafana Alerting里配置Contact Point,选Webhook类型,填上企业微信机器人的回调地址,再把默认通知策略指向这个Contact Point。注意,企业微信机器人在接收Grafana发送的JSON格式消息时,默认格式可能无法直接显示,建议用Grafana的模板变量把消息格式整理成文本内容。
4.3 告警风暴抑制与静默处理
告警配置好之后,还有一个跨不过去的坎——告警风暴。比如某个依赖的数据库故障,会导致上游十几个服务同时报错,一晚上几千条告警轰炸下来,值班人根本看不过来。Grafana Alerting自带静默(Silence)功能,可以按标签快速屏蔽某个服务、某个环境的告警。这个功能在确定是依赖故障时非常管用。
另外Grafana还支持聚合通知,可以把同一时间段的告警合并成一条汇总消息。我个人建议把通知间隔设置成30分钟以上,确保告警通知不是每5分钟轰炸一次,而是凑成一条消息发出去,让人有精力去排查根因而不是一直在刷群聊。
这里还有个非常实用的技巧:告警URL可以直接连到某个服务的Loki查询页面,这样值班人员在看到告警信息时,点一下链接就能看到当时的日志,省去了手动跳转Grafana再去选数据源的步骤。配置方法是在告警规则里自定义Message字段,加上[查看日志](http://grafana/explore?left={"datasource":"Loki","queries":"{app=\"order-service\"}"})这种带查询参数的链接。
5. 性能调优与常见故障排查实录
5.1 存储占用爆炸的排查与根治
Loki单机版最让人意外的坑,就是存储占用会突然飙升。有次我们检查磁盘,发现Loki数据目录在48小时内从20GB涨到了180GB,差点把磁盘打满。后来定位到原因:有个服务的日志格式里有一个高基数的ID字段,我不小心把它加成了标签,导致Loki为每一组标签组合都建立了索引,存储瞬间膨胀。
解决方法是把高基数标签从标签列表里去掉,只保留在日志内容里。同时调整了Promtail配置里的pipeline_stages,用drop删掉不需要的字段。以下是当时加的清洗配置:
pipeline_stages: - regex: expression: '^(?P<level>\w+)\s+(?P<message>.*)$' - labels: level: - drop: expression: 'request_id|trace_id'drop这个stage会自动匹配并删除日志内容里的request_id和trace_id字段,让它们不出现在Loki的标签和索引中。这样既保留了日志的完整性(原日志内容不会删除,只是不做索引),又避免了存储膨胀。
从那次之后,我对标签设计做了一个硬性规定:上线前必须review标签基数,新增标签前先评估这个标签的可能取值数,超过100个就不允许作为标签。
5.2 日志断流与时间戳乱跳
第二个高频问题是日志断流。现象是Grafana里某个服务突然没有日志了,但服务本身正常运行。排查思路是这样一步步来的:
- 先看Promtail的状态接口:
curl http://promtail:9080/metrics,查看promtail_files_active指标,确认Promtail是否还在挂载日志文件。 - 如果
promtail_files_active为0,说明Promtail没有发现日志文件,检查__path__路径是否匹配、Pod名是否变化。 - 如果
promtail_files_active正常但Loki没数据,检查Promtail的positions文件,确认日志采集位点是否卡死。 - 重启Promtail容器看是否恢复。
这里重点说下positions文件,Promtail用它来记录每个日志文件已读取到的字节偏移量。如果positions文件损坏或者被删除,Promtail会重新从头读取日志文件,造成重复日志大量写入。处理方式是,确认日志文件写入偏移量后,删掉positions文件再重启Promtail,让它从当前位置重新开始。
还有一个时间戳乱跳的问题也值得提一下。容器重启后,某个服务的日志时间戳比当前时间晚了几小时,排查发现是宿主机时区没有同步到容器。解决方案是在Promtail的pipeline里增加一个timestampstage,指定日志解析后的时间字段来源;如果解析失败,就用fail_on_missing: false让Promtail退回容器运行时的时间戳。
5.3 查询慢到怀疑人生的排查与优化
Loki查询慢通常不是Loki本身的问题,而是查询语句和标签设计的问题。我处理过的三个典型case分享一下:
case 1:查询范围太大。用{job="varlogs"}这种不带app标签的查询,相当于全量扫描所有日志。优化方式是给每个查询加上针对性强的标签约束,比如{app="order-service", env="prod"}。
case 2:正则表达式设计太差。|~ "error|exception|failed"这种正则看起来跑得挺快,但在日志量大的场景下性能极差。优化方式是把正则拆成多个|=条件,或者调整正则的结构避免复杂的回溯。
case 3:Grafana仪表盘拖拽时间范围太大。有同事在Grafana上面查日志时,习惯性地把时间范围拖到30天,导致Loki要扫描巨量存储块。优化方式是给Grafana数据源设置max_look_back_period,限制查询上限。
5.4 一套巡检命令与检查清单
最后整理一套我平时巡检Loki时用的命令,直接抄作业就好:
# 查看Loki各组件状态 curl -s http://loki:3100/ready | jq . # 查看Loki存储块状态 curl -s http://loki:3100/compactor/ring | jq . # 查看Promtail的采集状态 curl -s http://promtail:9080/metrics | grep promtail_files_active curl -s http://promtail:9080/metrics | grep promtail_read_bytes_total # 查看Loki当前告警 curl -s http://loki:3100/prometheus/api/v1/rules | jq .这套命令配合自动化监控,基本能提前预警90%的Loki故障。我现在的习惯是每周扫一遍这些指标,看一眼文件采集数量有没有骤降、读取字节数有没有归零,基本就能知道系统是否健康。
再分享一个最后的巡检经验:建议定期检查Loki的ready接口。如果返回502或504,大概率是存储磁盘快满了或者compactor正在做重压缩,这时候要及时处理磁盘空间,否则Loki会停止接收新的日志数据。
我个人在实际操作中的体会是:日志系统的本质问题不是“怎么存”,而是“怎么查”。把标签设计做扎实、查询语句写规范,Loki的体验会好到让你忘了它的存在。如果你想把这个方案继续往外扩展,可以试试把Loki的数据接入对象存储做长期归档,或者用Loki的metric_quantile函数分析接口延迟分布,这些都是复杂度可控、收益明显的方向。