ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从选型到落地:基于Loki的分布式日志系统搭建实战与踩坑指南

从选型到落地:基于Loki的分布式日志系统搭建实战与踩坑指南 凌晨两点线上订单超时。我一手抓着手机另一手在 SSH 窗口里连续敲命令从第 4 台机器的/var/log里终于 grep 到一条异常栈又花了二十分钟才把整条链路的日志串起来。那天回去我就决定了不管排期怎么压一套能跨机器查询、能按服务过滤、能快速定位问题的分布式日志系统必须尽快落地。最终我选择的技术栈是 Grafana 开源全家桶Loki 负责日志存储与查询Promtail 负责日志采集Grafana 负责可视化。这套方案部署轻、成本低、查询体验好非常适合中等规模的分布式系统和云原生环境。这篇文章把我从选型、架构设计到上线踩坑的完整过程都记录下来希望给正准备做日志平台的团队一个可直接参考的样例。1. 选型对比为什么最后选了 Loki 而不是 ELK1.1 当时摆在桌上的三套方案决定自建分布式日志系统后我首先面对的不是写代码而是选型。内部讨论时提得最多的有三套EFKElasticsearch Filebeat Kibana、Loki 全家桶、以及基于 ClickHouse 自研。ELK 是当时的“标准答案”社区教程多、人才储备足、Kibana 的查询语法强大团队里用过的同事也不少。但它有三个让我头疼的地方一是资源占用高三个 ES 节点起步就要 16GB 内存对于一个日志量每天只有几十 GB 的中型业务来说太重了二是索引管理繁琐冷热分层、分片规划、索引生命周期管理都要专门维护三是成本不容易控制数据量上来后对磁盘和内存的要求几乎是线性增长。ClickHouse 自研方案听起来很极客查询性能也确实强悍。但落地一个生产级自研日志系统要处理的细节实在太多写入链路的高可用、数据冷热分层、查询语法设计、权限体系、前端界面哪一样都不是一个月能搞完的。对当时的团队来说这不是一个性价比高的选择。1.2 Loki 胜出的三个决定性因素真正让我下定决心选 Loki 的是三个非常具体的点。第一是存储成本低。Loki 的核心设计是“用廉价的对象存储换掉昂贵的全文索引”。它不为每条日志建倒排索引而是给日志打上标签后存储原始内容索引体积只有传统方案的几十分之一。我实测下来同样的日志量ES 可能需要几百 GB 磁盘Loki 只要几十 GB压缩比非常可观。第二是部署和运维足够简单。Loki 的架构天然向“单体优先”倾斜小规模部署时直接一个进程跑起来数据写本地磁盘等规模上去了再平滑拆成读写分离的多组件模式。Promtail 是一个很轻的采集进程配置文件是 YAML没有复杂的模板语法团队成员看了半天文档就能上手。第三是和 Grafana 生态无缝衔接。我们公司监控告警本来就在用 Prometheus Grafana接入 Loki 后在同一个 Grafana 实例里既能看指标又能看日志不再需要来回切换系统。对一个运维团队来说少维护一个可视化系统就是实实在在的减负。1.3 这套方案里每个组件的职责Promtail采集端部署在每台需要采集日志的机器上负责读取日志文件、解析内容、打上标签然后推送到 Loki。它本身无状态可以随时水平扩容。Loki核心服务接收日志写入、建立标签索引、压缩存储原始日志同时对外提供查询接口。它扮演的是日志聚合和存储的角色。Grafana展示端通过 Loki 数据源查询日志提供标签浏览器、LogQL 查询、面板可视化和告警规则配置。这套分工很清晰采集归采集存储归存储展示归展示。任何一个环节出问题都可以单独排查互不拖累。2. 整体架构与运行机制从一条日志到可视化面板的完整路径2.1 日志流转的完整链路理解 Loki 怎么工作对后面排查问题非常有帮助。我习惯把这条链路拆成六个步骤应用把日志写入本地文件这是最传统也是兼容性最好的日志输出方式。Promtail 通过tail方式监听该文件读取新增内容。在 Promtail 内部日志经过 pipeline 的解析、清洗、打标签进入批处理缓冲区。缓冲区达到batch_size或等待时间阈值后Promtail 通过 HTTP 接口把日志批量推送给 Loki 的 distributor 组件。distributor 负责校验和分发把日志交给 ingesteringester 在内存中积累日志并按照一定规则切分成 chunk随后刷入持久化存储。用户在 Grafana 发起查询请求querier 从存储中读取相关 chunk做过滤、聚合最后把结果渲染成日志流或图表。这里最核心的一点是写入和查询是分离的。ingester 只负责写入时的接收和分块querier 只负责读取和过滤。所以当查询量大的时候可以单独扩容 querier 而不影响写入链路当写入量大到内存吃紧时也可以独立扩 ingester。2.2 标签与流Loki 索引机制的最小单元Loki 不建全文索引它靠的是“流”stream。一个流就是一组标签组合的唯一标识比如{jobapp, serviceorder-service, levelerror}就是一个流。同一流内的日志被顺序组织在一起切分成块chunk后连续存储。这个设计对性能影响是决定性的流数量越少索引越小写入越快查询时扫描的开销也越小。Loki 的设计目标是把流控制在“千”这个量级它像 Prometheus 的 series 一样工作虽然也能支撑数量更多的流但代价是内存和查询时延都会明显上升。举个形象的类比全文索引相当于一本书后面附了每个词出现在哪一页的详细索引查到词就能立刻跳转到相关书页Loki 的做法是不做这种细粒度索引只按章节把书归档好等你要找某个章节里的某个词时它再进到对应章节里一页一页翻。前者单次查找快但建索引和维护成本极高后者初始开销低只要归档维度设计得好实际查找速度也能被接受。2.3 Chunk 的生成与压缩日志进入 ingester 后并不是来一行存一行。Loki 会先在内存里积累当 chunk 达到 1.5MB 左右或者超过max_chunk_age的时长默认 2 小时就把它压缩并刷到对象存储中。压缩算法默认是 snappy我实测常规文本日志的压缩率普遍在 5:1 到 10:1 之间这也是它省磁盘的根本原因之一。有一个容易被忽略的细节Loki 对 chunk 目录下的文件做了周期性合并compactioncompactor 组件会检查哪些旧 chunk 可以被合并压缩进一步减少存储碎片。如果你发现存储目录里小文件特别多先看看 compactor 是否正常工作而不是直接怪 Loki 不清理。2.4 一个反直觉的点牺牲“秒查任意内容”换整体效率很多第一次接触 LogQL 的同学会抱怨它没有 ES 那种全文检索能力。这正是 Loki 有意为之的取舍它不扫描每个词而是先通过标签锁定“流”再在流内做过滤。标签设计得合理查询效率一点不比 ES 差标签设计得不合理比如把高基数字段做成标签它就会迅速劣化。明白了这个核心机制后面所有关于性能优化和标签设计的经验都是围绕“如何让流数量可控、让查询尽快锁定目标”展开的。3. 环境搭建与配置落地Promtail Loki Grafana 部署记录3.1 先跑通一套端到端Docker Compose 快速起步我建议任何新团队第一次上手都用 Docker Compose 把整条链路先拉起来不要一上来就研究高可用和组件拆分。我当时的起步配置是这样version: 3.9 services: loki: image: grafana/loki:2.9.8 ports: - 3100:3100 volumes: - ./loki-config.yaml:/etc/loki/local-config.yaml - loki-data:/loki command: -config.file/etc/loki/local-config.yaml promtail: image: grafana/promtail:2.9.8 volumes: - /var/log:/var/log - ./promtail-config.yaml:/etc/promtail/config.yaml command: -config.file/etc/promtail/config.yaml grafana: image: grafana/grafana:11.2.0 ports: - 3000:3000 environment: - GF_SECURITY_ADMIN_PASSWORDadmin volumes: loki-data:跑起来后先用curl验证 Loki 本身是否健康curl http://localhost:3100/ready # 返回 ready 即正常然后在 Grafana 里添加 Loki 数据源URL 填http://loki:3100保存后到Explore页面就能看到采集到的日志了。这套 Composer 配置适合本地联调和功能验证不要直接照搬到生产。3.2 Promtail 配置详解如何把原始日志变成结构化的可查询数据Promtail 的配置核心是scrape_configs每个采集任务里可以定义读取哪些文件、打什么标签、以及如何用 pipeline 解析日志内容。下面是我在实际项目中用到的关键配置片段server: http_listen_port: 9080 grpc_listen_port: 0 clients: - url: http://loki:3100/loki/api/v1/push scrape_configs: - job_name: app-order-service pipeline_stages: - multiline: firstline: ^\d{4}-\d{2}-\d{2} max_lines: 200 max_wait_time: 3s - regex: expression: ^(?Ptimestamp\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3}) (?Plevel\\w) - timestamp: source: timestamp format: RFC3339Nano - labels: level: static_configs: - targets: - localhost labels: job: app service: order-service host: ${HOSTNAME} __path__: /var/log/order-service/*.log这里最值得展开讲的是 pipeline 的执行顺序。日志文件的原始内容先进入 pipeline依次经过每个 stagestage 之间的数据流转是统一的格式既包含原始日志行也包含一个临时的 map 结构用于存放从日志行中提取出的字段。regex阶段把时间戳和日志级别提取到临时 map 中timestamp阶段使用该字段覆盖默认时间labels阶段把level作为查询标签暴露出来。有一个实战经验很重要labels里能加的字段一定要是低基数的枚举值。日志级别DEBUG/INFO/WARN/ERROR没问题服务名和环境名没问题但绝对不要把trace_id、user_id这种每行都不一样的高基数字段加进去。原因我会在标签设计一节专门讲。3.3 Loki 服务端配置limits 和存储Loki 的默认配置能直接跑起来但要放到生产环境limits_config和存储配置必须仔细调。下面是我当时的配置文件核心部分auth_enabled: false common: path_prefix: /loki ring: instance_addr: 127.0.0.1 kvstore: store: inmemory replication_factor: 1 schema_config: configs: - from: 2024-01-01 store: tsdb object_store: filesystem schema: v13 index: prefix: index_ period: 24h storage_config: filesystem: directory: /loki/chunks limits_config: reject_old_samples: true reject_old_samples_max_age: 168h out_of_order_time_window: 4h retention_period: 720h max_streams_per_user: 5000 max_line_size: 256kb max_line_size_truncate: true compactor: working_directory: /loki/compactor compaction_interval: 10m retention_enabled: true这里几个参数直接决定了系统上限和运维成本。out_of_order_time_window是我在生产环境中被救过一次的关键参数。分布式环境下各节点时钟不同步、Promtail 网络重试导致日志写入顺序错乱是常态如果这个值设成 0Loki 会直接拒绝任何乱序日志导致数据丢失。我设置了 4 小时足以覆盖绝大多数网络抖动和批量补传场景。但注意不要贪心设太大因为乱序时间窗口越大内存中为查找乱序日志保留的结构就越多反而会拖垮性能。max_streams_per_user是防止流数量爆炸的保险丝。当标签设计失误导致每个请求都产生一个流时这个限制能阻止 ingester 内存被彻底打爆同时也会在日志里暴露问题方便及时定位。max_line_size配合max_line_size_truncate用来防止单行超大日志把 chunk 撑爆。如果某天应用打印一个巨大的 JSON 串或堆栈超限部分会被截断至少整个查询链路不受影响。对象存储方面中小团队用 filesystem 就够了如果日志量已经到每天几百 GB我建议把object_store切到 S3 兼容的对象存储Loki 本身支持得很成熟改配置和权限即可不需要改代码。3.4 Grafana 数据源接入和第一句 LogQL在 Grafana 的 Configuration → Data Sources 里添加 LokiURL 填http://loki:3100点击 Save Test 通过后就能用了。使用它的标签浏览器可以直观看到所有已采集的标签不需要手动敲查询语句。我用的第一个高频查询是{serviceorder-service, levelerror} | NullPointerException{}内是标签筛选|表示包含过滤后面的双引号里是要匹配的内容。LogQL 的基本逻辑和 PromQL 有些相似但过滤操作符更丰富|包含、!不包含、|~正则匹配、!~正则排除。学到这四个日常排查就够用了。4. 标签设计最容易被忽视的性能命门4.1 一个高基数标签引发的“血案”我在测试环境做过一次错误示范为了让日志能按请求 ID 查询我把trace_id直接放进了 Promtail 的labels里。刚开始一切正常但压测跑了二十分钟后Loki 的写入延迟开始飙升Grafana 查询日志要十几秒才能返回服务端频繁报max streams per user超限。事后分析原因非常简单每个请求都有不同的trace_id每个新的trace_id都会产生一个新的流。压测的 QPS 是 200二十分钟就产生了超过 20 万个流。ingester 要为每个流维护内存状态还要把每个流的小 chunk 都刷入磁盘磁盘上瞬间多出几万个小文件查询时还要扫描大量无效流整个链路都被拖垮了。后来我把trace_id从标签里移除改成只让日志内容保留该字段查询时用 LogQL 过滤{serviceorder-service} | trace_id3f7a9b1c2d效果立刻恢复正常。这也是 Loki 官方一直强调的原则标签应该像 Prometheus 的 label 一样是低基数、离散、有限的维度而不是一线一变的业务字段。4.2 我总结的标签设计三原则第一标签值的组合数量必须可控。每个标签新增一种取值流的数量就可能翻倍。优先使用枚举型字段环境prod/test/dev、服务名、实例 ID数量级可控时、日志级别。第二标签要能支撑高频查询的筛选路径。用户最常做的操作是“查看某服务在某时间段内的错误日志”“查看某实例的全部日志”所以service和level几乎必须作为标签。低频的、临时性的筛选需求交给日志内容和 LogQL 就好。第三标签越少越好。每增加一个标签Loki 索引里的条目就多一份。宁可少加一个标签用内容过滤也不要想着“先加上再说”因为后期删除标签需要重新推送数据才能彻底消除旧流代价很大。4.3 我最终采用的标签模板以下是我在多个项目里验证过的推荐标签集合标签名用途基数级别备注job采集任务名比如 app / system个位数区分业务日志和系统日志service应用服务名十位数内必须host主机名或容器 ID视节点规模而定配合后端名称使用level日志级别固定 5 种用 regex 从日志中提取如果是在 Kubernetes 环境我还会额外加namespace和pod但要确认 Pod 替换频率不会让基数膨胀到不可控。Promtail 的 Kubernetes 服务发现会自动生成这些标签不用手工维护。这是我简化后的配置思路生产环境可以再结合 K8s 的官方 CRD 来管理。5. 生产环境踩坑实录五类高频故障的完整排查链路5.1 坑一乱序日志把写入链路堵死现象某个凌晨Loki 日志里突然刷出大量entry out of order错误Grafana 里对应的服务日志从某个时间点开始断层。排查过程分三步走。第一步先看是哪台 Promtail 在报错确认是全部节点都报还是只有某一台报。我用 Grafana 把 Loki 的自身指标拉出来把loki_distributor_ingester_append_failures_total按原因拆开发现基本都是out_of_order。第二步查看那台报错机器的时钟同步情况发现它的系统时间和主时钟差了 45 分钟。第三步确认根因是节点时钟漂移导致日志时间戳早于 Loki 当前接收窗口Promtail 在重试补传时把一批时间戳明显偏旧的日志推了上来。修复方案是在 Loki 配置里开启out_of_order_time_window。limits_config: out_of_order_time_window: 4h设置后 Loki 会允许接收比当前时间晚最多 4 小时的日志乱序数据在查询时依然按照时间戳排序展示。同时我加了一条 NTP 时钟同步的定时任务把各节点时钟漂移控制到秒级以内。前端时间窗口不能设得无限大否则内存压力会上升4 小时足够覆盖网络抖动、批量补传和短时时钟漂移的组合场景。5.2 坑二流数量爆炸是缓慢发生的现象系统已经稳定运行了两周某天早上开始Grafana 面板上 Loki 的请求时延逐步上升但写入量并没有明显增加。这次没有立刻报错而是渐进式劣化排查难度更大。我先看loki_ingester_streams这个指标发现流数量从几百涨到了几十万直觉告诉我标签设计出了问题。再查看 Promtail 的样本标签发现新上线的推荐服务把user_id放进了标签里。修复分两步先在 Promtail 配置里把user_id标签去掉只保留在日志内容中然后对于已经产生的历史流由于旧流会随着 chunk 过期慢慢淘汰短期内让系统维持运行没有强制清理等所有 chunk 进入保留周期后自然回收。同时我把max_streams_per_user收紧到 5000给后续误操作上一道保险。这个坑给我们的教训是新服务接入时要审查它的标签设计是否和现有规范一致。我后来在代码仓库里加了一份简短的接入检查单凡是新接入的服务Promtail 配置必须过一遍 review避免同类问题再次出现。5.3 坑三堆栈日志被拆行上下文全丢现象在 Grafana 里搜索某个服务的OutOfMemoryError能看到异常信息但后面长长的堆栈被拆成了几十条独立日志连Caused by后面的关键信息都跟当前错误对不上号了。原因在于 Promtail 默认按行读取日志文件一行就是一条日志记录。Java 打印异常堆栈时通常第一行是时间戳和异常类型后续多行是at com.xxx.xxx.method(...)这样的缩进内容。如果没有做多行合并这些堆栈行就被当成独立日志一条条写进去了。修复方案是在 pipeline 里增加multiline阶段pipeline_stages: - multiline: firstline: ^\d{4}-\d{2}-\d{2} max_lines: 200 max_wait_time: 3sfirstline用正则匹配每条日志的第一行特征我这里匹配的是时间戳开头。Promtail 会持续往下读直到遇到下一个符合firstline的行才把之前累积的内容作为一条完整日志提交。max_lines防止某些极端堆栈无限膨胀max_wait_time防止日志流突然中断导致最后几行永远攒着不发送。这里要提醒一句firstline正则一定要根据你们应用的日志格式定制。如果你的日志不是时间戳开头而是[INFO]或者别的固定前缀正则就得换。写错了看起来不报错但多行合并完全不生效。5.4 坑四日志丢数据Promtail 内存暴涨现象高峰期 Grafana 里某台机器的日志明显少于预期同时这台机器的 Promtail 容器内存涨到接近 2GB。这个问题的根子在 Promtail 的背压机制。Promtail 从文件读取日志的速度通常很快但推送到 Loki 的 HTTP 请求受网络和 Loki 处理能力限制如果来不及推送就会在内存里积累缓冲区。Promtail 有一个stream_lag_bytes配置项当某个流在内存里积压的字节数超过阈值它会主动丢弃日志并记录一条丢弃日志防止自己 OOM。排查时我先看 Promtail 的/metrics接口重点看promtail_dropped_bytes_total和promtail_stream_lag_bytes这两个指标。前者非零说明确实发生了丢弃后者飙升说明背压来自 Loki 写入侧。修复动作有三点一是给 Promtail 所在机器适当调大 CPU/内存限制并配置 Pod 自动扩缩容二是把 Promtail 的client.batch_size从默认 1MB 提高到 2MBclient.batch_wait从 1s 提高到 3s减少请求次数、提高单批发送效率三是给 Promtail 自身加告警一旦promtail_dropped_bytes_total在 5 分钟内上涨立刻报警避免下次又是事后才发现丢数据。5.5 坑五磁盘占用只增不降Loki 不删数据现象跑了两个月后我盯了一下磁盘使用发现 chunks 目录还在持续增长想到自己明明配了retention_period: 720h30 天怎么老数据没有自动清理。研究之后发现Loki 的保留策略不是简单到期的数据立即删除。需要保证compactor的retention_enabled: true并且配置了schema_config使用的索引方式支持保留策略。在老的 boltdb-shipper 或新的 tsdb 模式下compactor 会定期扫描并删除超过retention_period的 chunk。我之前只设置了limits_config.retention_period但 compactor 没开retention_enabled老数据自然永远不会被清理。加上retention_enabled: true并重启后compactor 开始执行周期性的清理磁盘占用曲线终于在工作几天后降下来并稳定在预期水位。如果你们的存储后端是 S3 兼容对象存储也可以配合对象存储的生命周期策略做双重清理但注意别把 Loki 正在使用的活跃 chunk 前缀过早删除保留策略需要在 Loki 和对象存储两侧保持一致。6. 分布式场景中的实战技巧多节点采集、全链路关联与告警6.1 多节点采集统一 Agent 命名规范分布式系统的机器可能分布在多个机房、多个可用区各机的 Hostname 可能重复。我当时就遇到过两个不同机房的主机 Hostname 一样导致日志在 Grafana 里被混在一起。解决办法是在 Promtail 的标签里加入机器维度并且保证全局唯一labels: job: app service: order-service host: ${HOSTNAME} zone: cn-north-1用 shell 环境变量注入zone配合${HOSTNAME}保证每台机器生成的标签组合在全球范围内唯一。这样 Grafana 里host zone的组合键就可以准确定位到具体物理节点。还要在 Promtail 的启动命令里加上-config.expand-envtrue才能让配置文件里的${}被正确解析这个细节很容易忽略。6.2 通过 trace_id 关联全链路日志分布式系统最痛苦的排查场景是“一个请求经过网关、订单、支付、库存四个服务日志散落在四台机器上”。有了 Loki 之后我推荐的做法是每个服务在日志中打印自己的trace_id通过网关在请求入口生成并向下传递。线上排查时先用入口日志拿到 trace_id{serviceapi-gateway} | error然后在日志里找到这行日志中的trace_idxxxx复制出来查询全部服务的日志{jobapp} | trace_idxxxx因为jobapp是所有业务服务的公共标签这个查询会一次性把四个服务里包含该 trace_id 的所有日志按时间顺序拉出来。再配合日志格式里的${trace_id}字段可以在 Grafana 的 Log details 里快速跳转。这样即使没有接入专业的 APM 链路追踪系统日志侧也能完成跨服务排障。6.3 日志告警让系统主动发现问题Loki 数据源接入 Grafana 后可以配置基于日志的告警。我常用的两个场景是错误率突增和特定关键字出现。在 Grafana Alerting 里新建规则查询条件写成sum(rate({serviceorder-service, levelerror}[5m])) 0.5这个规则统计最近 5 分钟内错误日志的速率当超过每秒 0.5 条时触发告警。另一个常见需求是监控“支付超时”之类的关键字count_over_time({servicepayment-service} | payment timeout [5m]) 105 分钟内超过 10 条payment timeout日志就告警。这套机制配合企业微信或钉钉的 webhook 通知能在业务真正故障前提前介入。需要注意的是日志告警依赖的是日志内容质量和标签设计规范如果日志里没有适当的结构化字段告警规则会写得很痛苦所以很多工作要前置到日志规范制定阶段。6.4 关于后续扩展的一点建议这套 Loki 方案在最开始是单实例跑在虚拟机上的随着日志量增长我可以先按官方文档把 Loki 的读写组件拆开部署使用对象存储作为统一后端再逐步引入多副本。组件之间的数据一致性通过 ring 机制和 kvstore 管理只要 schema 保持一致拆拆合合都不需要改应用侧配置。我自己的体会是日志系统这类基础设施最怕一步到位和盲目追求规模。先把链路跑通、把标签设计规范固定下来再根据实际业务量一步步扩容比一开始就设计一个大而全的平台要可靠得多。
返回列表