分布式日志系统搭建实战:从ELK到链路追踪
说到分布式日志系统我最早是踩了一个大坑才被逼着去搞的。当时线上服务从单机拆成十几个微服务每次线上出问题排查得靠运维一台台机器翻日志先 ssh 上去再 grep运气好几分钟运气不好半小时起步。更要命的是订单和库存两边日志时间线对不上扯皮半天才定位到是服务间调用超时。后来我花了一周多时间把整个日志链路从采集、传输、存储到查询完整搭了一遍这才算把分布式系统的眼睛给补上了。这篇内容我尽量讲得接地气一些把我实际搭建的过程、踩过的问题、以及每个关键环节为什么这么选都写清楚。不管你是刚开始接触微服务还是已经在做系统运维相信都能从中找到可以直接抄作业的部分。1. 整体设计与技术选型为什么日志系统非要分布式1.1 单机日志方案在分布式场景下到底卡在哪很多人一开始会觉得日志不就是程序里打几个 print写到文件里就完事了吗单机或者小规模项目确实可以但一旦服务拆开、实例变多原本写文件这个动作会带来三个很现实的问题。第一个是日志文件太分散。你 20 个服务实例部署在 10 台机器上每个实例都在本地磁盘写日志出问题的时候你需要同时去 10 台机器上找线索这中间的时间成本是惊人的。第二个问题是日志量大了之后本地磁盘很快会被占满尤其是 debug 级别日志开起来的时候大促期间一天几个 GB 是很常见的。第三个问题是没法做全局的关联检索比如一个用户请求从网关进来经过订单服务、库存服务、支付服务整个过程被分散在好几台机器的不同文件里你要把这条链路串起来看只能靠时间戳硬凑还得靠运气。所以分布式日志系统的核心诉求就三个集中收集、统一存储、快速检索。这不算什么新概念但真正落地的时候技术选型和工作量比想象中要多得多。1.2 主流方案对比ELK、EFK、Loki 怎么选现在提到日志系统大家首先想到的肯定是 ELK。ELK 是 Elasticsearch、Logstash、Kibana 三件套。后来因为 Logstash 比较吃内存很多人把采集端换成了 Filebeat变成 EFK。再后来 Grafana 出了 Loki主打轻量和高性价比。我这次最终选的是 Filebeat Kafka Logstash Elasticsearch Kibana 这套组合。为什么没直接用 Logstash 做采集端因为 Logstash 基于 JVM默认启动就是 1G 以上的内存在业务机器上再塞一个这玩意儿业务还没跑起来内存先吃紧了。Filebeat 是 Go 写的内存占用通常在几十 MB更适合作为每台机器上的常驻采集器。至于 Loki它确实很轻量而且只对标签建索引日志内容用压缩块存储成本低很多。但它有个我不太能接受的限制全文检索能力和聚合分析能力比 Elasticsearch 弱不少。如果你主要是看日志排错Loki 够用但如果你想基于日志做一些统计报表比如接口错误率趋势、状态码分布这些Elasticsearch 的聚合能力就明显更香。这套架构里的 Kafka 不是必须的但它让整个系统从容许了一波流量高峰。业务日志产生往往是突发性的尤其是高峰期如果 Filebeat 直接往 Logstash 甚至是 Elasticsearch 里灌一旦后端处理不过来日志就会丢。Kafka 相当于一个缓冲池数据先落到 Kafka消费端根据自己的处理能力去吞吐。1.3 整体架构数据流是怎么走的我搭的系统整体数据链路是这样的业务容器或服务进程把日志写到本地文件Filebeat 监听文件变化读完新内容之后按行发送到 Kafka 的指定 topicLogstash 作为消费者从 Kafka 拉数据做字段解析、格式统一、时间戳处理然后批量写入 Elasticsearch最后通过 Kibana 做查询和可视化。这个链路上每一步都有讲究。Filebeat 要处理多行日志的合并比如 Java 异常堆栈信息是跨多行的如果一行一行发到 ES 里就是碎片根本没法看。Logstash 要做字段映射比如把 Nginx 访问日志拆成 method、uri、status、response_time 这些独立字段这样才能在 Kibana 里做筛选和聚合。这些细节在下文实操部分会展开讲。2. 搭建实录从零部署一套可用的分布式日志链路2.1 环境准备与版本选择先说明一下我使用的环境三台 4 核 8G 的云主机操作系统是 CentOS 7.9Java 版本是 OpenJDK 11Docker 版本是 20.10。整个 Elastic 技术栈用的是 7.10.2 这个版本因为我实际测试下来7.x 系列的稳定性和兼容性都比较好网上资料也多出了问题好查。如果你在版本选择上比较纠结我的建议是不要追求最新版。Elastic 官方从 6.x 到 7.x 做了很多破坏性改动比如索引类型 concept 的移除、默认分片数调整等。如果你是按老教程学习很容易被版本差异卡住。选一个生态内互相兼容的稳定版本比追新更重要。分配方案上Elasticsearch 我部署了三节点集群每台机器各一个节点其中一台同时兼任 Kibana。Kafka 也部署了三节点跟 Elasticsearch 共用机器。Logstash 单独部署了一台 4 核 8G 的机器因为这个东西在解析复杂日志的时候 CPU 消耗不小。我的建议是如果你的资源有限至少要把 Elasticsearch 的数据节点跟 Logstash 分开不要放在同一个 JVM 进程里跟 ES 抢内存。2.2 Filebeat 配置采集端的核心参数Filebeat 的安装很简单下载对应版本的 tar 包解压就能用。重点在配置文件 filebeat.yml 上。filebeat.inputs: - type: log enabled: true paths: - /data/logs/*.log multiline.pattern: ^\d{4}-\d{2}-\d{2} multiline.negate: true multiline.match: after tail_files: true fields: service_name: order-service env: prod fields_under_root: false output.kafka: hosts: [kafka1:9092, kafka2:9092, kafka3:9092] topic: app-log partition.hash: reachable_only: true compression: gzip max_message_bytes: 1048576 required_acks: 1先说 paths 和 tail_files。paths 支持通配符但我个人建议还是按服务分目录比如 /data/logs/order-service/ 下面只放这个服务的日志这样路径清晰后续加过滤规则也方便。tail_files 设为 true 表示 Filebeat 启动时从文件末尾开始读不回溯历史文件。这个参数在重启 Filebeat 的时候特别重要如果设置成 false它会从头把整个文件重新读一遍直接造成日志重复堆积。multiline 相关配置是处理 Java 异常堆栈的关键。Java 里的 exception 日志一般第一行以日期开头后面跟着多行堆栈信息。我这里的multiline.pattern: ^\d{4}-\d{2}-\d{2}意思是只有以 2025-05-01 这种格式开头的行才算是新日志的开始其他行都追加到上一条日志后面。negate: true和match: after配合起来就是不匹配这个正则的行就把它合并到前一条记录后面。这样在 Kibana 里搜到一个异常就能看到完整的堆栈而不是某一行孤零零的报错。fields 这块是用来给日志打标签的。因为一个日志系统里会有多个服务统一收进来之后需要区分来源。我加了 service_name 和 env 两个字段后面在 Logstash 里把它们提取出来作为 ES 字段Kibana 里就可以按服务名筛选。注意 fields_under_root 我设为 false这样这些自定义字段会放在fields这个子对象下避免跟日志本身解析出来的字段混淆。2.3 Kafka Topic 设计分区数决定了你的消费并行度Kafka 的 topic 设计和后续的消费性能直接相关。我建 topic 时用的命令是kafka-topics.sh --create \ --topic app-log \ --partitions 6 \ --replication-factor 2 \ --bootstrap-server kafka1:9092,kafka2:9092,kafka3:9092分区数我设置了 6。这个数字不是拍脑袋定的它主要参考了两个因素一是下游 Logstash 消费者的并发数二是有多少个业务服务往这个 topic 写。Kafka 的消费并行度上限等于分区数也就是说如果分区只有 3 个哪怕你起了 6 个 Logstash 实例也只会用上 3 个。我这里日志总量不算特别大6 个分区对应 6 个 Logstash 消费线程已经能支撑每秒几万条的吞吐了。副本数设为 2意味着每个分区的数据在集群中有两份。这样即使一台 Kafka 机器宕机数据还是能从另一台副本上读出来。如果你的环境是单机 Kafka副本数设置成 1 就行因为多副本也复制不到别的地方去。还有一点值得注意日志类数据对顺序性要求并不高所以不需要针对某个 key 做严格分区。但如果后续你打算做链路追踪比如根据 traceId 把同一条请求链路的日志串在一起看那你就需要按 traceId 做 hash 分区保证同一个 traceId 的日志进到同一个分区这样才能保证被同一个消费者按顺序处理。2.4 Logstash 管道配置解析和统一的最后一公里Logstash 的配置核心是 input、filter、output 三块。input 从 Kafka 消费filter 做解析output 写到 ES。input { kafka { bootstrap_servers kafka1:9092,kafka2:9092,kafka3:9092 topics [app-log] consumer_threads 6 codec json group_id logstash-log-group auto_offset_reset latest } } filter { if [fields][service_name] order-service { grok { match { message %{TIMESTAMP_ISO8601:log_time} %{LOGLEVEL:level} %{GREEDYDATA:msg} } } } date { match [log_time, yyyy-MM-dd HH:mm:ss.SSS] target timestamp timezone Asia/Shanghai } } output { elasticsearch { hosts [es1:9200, es2:9200, es3:9200] index app-log-%{YYYY.MM.dd} user elastic password your_password } }这里面我要重点说两个地方。第一个是 codec。我在 Filebeat 端输出到 Kafka 时如果没有指定 codec默认是行文本。但 Filebeat 实际上在输出的时候会把日志内容封装成一个包含 timestamp、fields、message 等结构的 JSON 对象。所以我在这里用codec json让 Logstash 直接把这条消息当作 JSON 来解析这样也能拿到 Filebeat 端添加的 fields 信息。如果你忘记加这个Logstash 会把你整条消息当成一个 string后面 grok 解析的时候还要多做一层转换。第二个是 filter 里的 grok 和 date。grok 是 Logstash 最核心的解析插件它通过正则模式把非结构化的日志文本拆成结构化字段。我这里用了一个非常简单的规则时间 级别 信息。实际项目里你完全可以根据自己的日志格式做更复杂的解析。date 插件的作用是覆盖默认的时间戳。Logstash 和 ES 默认以当前系统时间作为时间戳但如果你的服务器时区是 UTC而业务日志写的是北京时间东八区如果不做处理查日志的时候就会发现时间对不上差 8 个小时非常容易误判。所以我在 date 插件里明确指定了 timezone 为 Asia/Shanghai。2.5 Elasticsearch 索引与生命周期管理Elasticsearch 这边主要的日常操作就是模板和索引生命周期策略ILM。日志数据的特点是量大、有时间属性、热度随时间降低所以一般按天建索引是最好的。比如今天产生的日志写到 app-log-2025.05.01 这个索引明天写到 app-log-2025.05.02。按天索引的好处有两个一个是查询时可以只查某几天的索引不用全量扫描另一个是清理老数据非常方便直接把对应索引删掉就行。我建议从一开始就把 ILM 策略配置好否则等索引越积越多磁盘被打满再去想清理方案就来不及了。ILM 策略可以这样写PUT _ilm/policy/log-ilm-policy { policy: { phases: { hot: { actions: { rollover: { max_size: 30GB, max_age: 1d } } }, delete: { min_age: 30d, actions: { delete: {} } } } } }这里的意思是当单个索引超过 30GB 或者超过 1 天时自动滚动创建一个新索引索引存在超过 30 天后自动删除。这样整个日志生命周期就不用手工干预了。注意 ILM 里的 min_age 是从索引滚动时间开始算的。如果你想让日志保留 30 天可查就把 delete 阶段的 min_age 设为 30 天。2.6 Kibana 接入让日志真正变得可查数据进了 ES 之后Kibana 的配置大概是整个流程里最简单的一步。你只需要进入 Kibana 管理界面创建 Index Pattern也就是索引模式比如填app-log-*Kibana 会把所有匹配这个模式的索引都拿过来。创建的时候选一个时间字段这里选timestamp这样在 Discover 页面上Kibana 会根据你选择的时间范围自动到对应索引里查询。索引模式创建好之后你就能在 Discover 页面里搜索日志了。我习惯用的搜索方式有两种一种是 KQL 语法直接输入service_name : order-service筛选服务或者level : ERROR筛错误级别另一种是直接用通配符搜全文比如error.*inventory查找所有包含 error 和 inventory 关键字的日志行。3. 链路追踪日志关联把分布式系统里的一条请求串起来3.1 为什么有了集中日志还不够整套系统上线后你会发现日志集中了搜索也方便了但你依然很难在几万条日志里快速定位某一次用户请求的完整链路。原因在于一个请求调用订单、库存、支付三个服务在日志系统里产生的记录是分散的它们之间没有一个共同的关联标识。你搜到一个订单异常想看这个请求在库存服务里发生了什么只能靠时间去猜测非常痛苦。解决这个问题最通用的方案是生成一个全局唯一的 traceId在请求入口处生成然后通过 RPC 调用链往下游传递所有服务打印日志时都把这个 traceId 打进去。这样你在 Kibana 里只要搜这个 traceId就能把这次请求的所有日志都捞出来。3.2 日志关联字段的设计与实现我这边服务多数是 Java 写的用的微服务框架是 Spring Cloud。为了把 traceId 自动加到日志里我用了 SLF4J 的 MDCMapped Diagnostic Context机制。MDC 本质上是一个 ThreadLocal你往里面放的值可以在同一个线程内的任意日志输出语句中被引用。配合 Spring Cloud 的拦截器在请求进入时生成或获取 traceId放进 MDC然后在 logback 的 pattern 里加上%X{traceId}日志就会自动带上这个字段。大体的代码思路是这样的public class TraceIdInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String traceId request.getHeader(X-Trace-Id); if (StringUtils.isEmpty(traceId)) { traceId UUID.randomUUID().toString().replace(-, ); } MDC.put(traceId, traceId); response.setHeader(X-Trace-Id, traceId); return true; } Override public void afterCompletion(...) { MDC.remove(traceId); } }这里注意一个细节afterCompletion里一定要移除 traceId否则线程池里线程复用的时候上一个请求的 traceId 会被下一个请求继承导致日志关联错乱。这个坑我刚开始没注意排查了好久才发现同一个 traceId 下混着不相干的请求。对于跨服务调用Feign 或 RestTemplate 要加一个 RequestInterceptor把当前 MDC 里的 traceId 放到下游请求的 Header 里。这套逻辑其实不复杂但它确实解决了一个核心问题从用户请求入口到所有下游服务日志之间有了血缘关系。3.3 全链路日志查询实践有了 traceId 之后使用习惯也要跟着变。我不是直接在 Kibana 里搜关键字而是先通过网关的访问日志找到某个异常请求的 traceId然后拿 traceId 再去搜其他服务。在 Kibana 里我会给 traceId 建立一个单独字段映射为 keyword 类型这样搜起来是精确匹配性能更快。默认情况下ES 会对字符串字段做全文索引如果直接搜 traceId 这种字符串容易把相近的 ID 也匹配出来反而不准。如果你不想自己在代码里逐个集成链路追踪逻辑也可以直接用开源的 SkyWalking 或 Zipkin它们能够自动完成 traceId 的生成和跨服务传递并且自带 UI 界面展示调用链路。不过它们的定位是 APM 系统侧重于调用关系和性能分析跟日志系统还是有点区别。我目前的做法是两套并行APM 负责看调用拓扑和性能瓶颈日志系统负责看具体的错误堆栈和业务上下文两者通过 traceId 关联。4. 常见问题与排查技巧实录4.1 日志收集断流Filebeat 该读的没读到搭建完之后我遇到的第一个问题是同事反馈某个服务的日志在 Kibana 里查不到。我去服务器上看文件里明明有日志而且 Filebeat 进程也活着但 Kafka 里就是没有新消息。排查思路是逐层看数据流。我先在 Kafka 端用命令行消费确认有没有数据进来发现没有然后看 Filebeat 的日志文件。结果发现 Filebeat 日志里报了错File is older than the first scan time或者是input file error。这类问题通常有两个原因一是 Filebeat 启动的时候没有读取文件权限二是因为文件路径配置不对。我那次的问题是权限。Filebeat 进程是用 root 跑的但日志目录挂在容器卷里Filebeat 是在宿主机上直接读容器挂载出来的目录中间 SELinux 拦截了。临时关闭 SELinux 后问题解决。后面我把 Filebeat 改成了容器方式部署挂载宿主机日志目录权限问题就彻底没有了。另外一个容易被忽略的是 Filebeat 的 registry 文件。Filebeat 把每个文件的读取位置记录在 data/registry 文件里如果这个文件损坏Filebeat 可能会重复发送日志或者完全不发送。所以升级 Filebeat 或者迁移目录时最好把 registry 一并备份否则你会面临日志重复或者丢数据的二选一局面。4.2 日志重复消费明明一条日志结果出现两遍有段时间我发现在 Kibana 里搜某条日志会出现两次内容一模一样时间戳也相同。一看就是消费端重复处理了。日志数据重复消费在中途有进程重启的情况下很容易发生因为 Kafka 的消费机制是至少一次如果消费者在消息处理完、提交 offset 之前挂掉了重启后就会重新消费一遍。这个问题我最后没有从根源上完全消除因为对日志场景来说做到恰好一次语义的代价太高。但对于日志检索场景重复几行的用户体验影响并不大。我做的优化是在 Logstash 输出的 ES 文档里加一个唯一 ID用消息的 Kafka offset 生成。这样即使同一条消息被重复消费写入 ES 时也只是覆盖写而不是新增一条文档。加这个逻辑的方式是在 output 里通过 document_id 指定output { elasticsearch { hosts [es1:9200, es2:9200, es3:9200] index app-log-%{YYYY.MM.dd} document_id %{[metadata][kafka][offset]} } }注意这个 offset 只有在同一索引里才是唯一的所以还要把 topic 和 partition 拼进去才不会跨分区冲突。我简化了演示实际操作时建议用%{[metadata][kafka][topic]}-%{[metadata][kafka][partition]}-%{[metadata][kafka][offset]}。4.3 时间字段差了 8 小时时区导致的数据错乱这个坑我相信做日志系统的人都踩过就是查询的时候发现日志时间跟实际业务时间差 8 小时。原因在于 ES 默认存的是 UTC 时间而 Kibana 默认展示时区是浏览器本地时间。如果 Logstash 在解析日志时间时没指定时区业务日志里的2025-05-01 12:00:00会被当作 UTC 时间存进去Kibana 展示时东八区就变成20:00:00了。解决方案有两个层面。第一在日志产生端尽量统一用 ISO8601 格式并带上时区偏移量比如2025-05-01T12:00:0008:00这样 Logstash 解析时不会产生歧义。第二在 Logstash 的 date 插件中明确指定 timezone 为 Asia/Shanghai这样它会把日志字符串当作东八区的时间来解析转成 UTC 存储Kibana 上展示的时候再按用户时区转回来结果就对了。4.4 Elasticsearch 集群变红日志量暴涨之后日志量超过预期之后我遇到整个 ES 集群状态变红部分主分片未分配。用curl localhost:9200/_cluster/health一看unassigned_shards 数量比较多。多半是磁盘水位线的问题ES 的默认配置是节点磁盘使用率超过 85% 时不再分配新分片。我当时的处理方法比较粗暴先清理掉最老的索引释放空间然后确认第三台机器磁盘已挂载正常。另外在投入正式使用前建议提前配置好滚动策略、单分片大小限制和磁盘水位线。举一个配置例子PUT _cluster/settings { transient: { cluster.routing.allocation.disk.watermark.low: 80%, cluster.routing.allocation.disk.watermark.high: 90%, cluster.routing.allocation.disk.watermark.flood_stage: 95% } }数值可以根据磁盘空间调整。这里要特别提醒transient 配置在集群重启后会丢失所以如果这是你的长期策略请改用 persistent 配置或者写进 elasticsearch.yml。4.5 检索性能很差要按 keyword 精确查就别用 text日志量大了之后Kibana 里随意一搜响应时间可能从几十毫秒涨到十几秒。除了需要加字段映射和索引调优之外最常见的问题是把所有字段都默认成 text 类型。ES 对 text 字段会做全文分词还会建倒排索引字段多、日志量大之后这部分存储开销和查询开销都很高。一般来说日志消息本身我们确实需要全文搜索但像 service_name、level、traceId、host_ip 这类字段应该映射成 keyword 类型只做精确匹配或范围筛选不参与分词。在索引模板里可以这样处理PUT _template/app-log-template { index_patterns: [app-log-*], mappings: { properties: { service_name: { type: keyword }, level: { type: keyword }, traceId: { type: keyword }, host_ip: { type: keyword }, message: { type: text, analyzer: standard } } } }这里还有一个隐藏的坑模板是在索引创建时生效的。如果你修改了模板但某个索引在这之前已经创建了那么 new 的映射不会应用到老索引。所以需要提前确认你的日期索引是否匹配模板。如果需要修改老索引映射可以用 reindex 重建那又是一个大工程。日志系统最好是一开始就把模板建好。5. 从能用走向好用分布式日志系统的进阶实践5.1 日志采样全量采集与成本控制的平衡日志系统跑起来后最直观的成本就是磁盘和 CPU。因为每条日志从 Filebeat 到 Kafka 再到 ES 最终落盘中间有序列化、网络传输、索引写入多个环节。全量采集当然最爽但海量 debug 日志的成本会非常可观。我的做法是按环境区分生产环境只保留 INFO 以上日志且对高吞吐的核心接口做采样比如按 10% 比例采样开发测试环境全量。Logstash 在 filter 阶段可以根据环境字段做丢弃或保留。这样做的好处是真正出了线上问题关键的 ERROR 和 WARN 日志一条不丢而大量无意义的 DEBUG 日志不会把磁盘撑爆。5.2 日志告警从被动查询到主动感知日志系统的价值不只是事后排查更在于事中告警。ES 本身提供了 Watcher 功能但那是白金版才有的。我一直在用 ElastAlert 2免费开源可以直接读 ES 索引做规则匹配。es_host: localhost es_port: 9200 name: error-log-alert type: frequency index: app-log-* num_events: 5 timeframe: minutes: 5 filter: - query: query_string: query: level: ERROR alert: - email email: - opsexample.com上面这段规则的作用是如果在 5 分钟内 ERROR 日志出现超过 5 条就发邮件通知。跟 AlertManager 相比ElastAlert 不需要额外部署一套监控系统直接对着 ES 查询就行部署成本很低。5.3 日志系统的后续演进搭建完这套系统后我又做了几件事一是把日志采集范围从 Java 服务扩展到了 Nginx 网关日志因为网关是流量的第一层很多问题在网关层就能看到端倪。二是把 Filebeat 的配置改成通过配置中心下发这样新增服务实例时不用手动去每台机器改配置。三是逐步把所有服务的日志格式做统一比如统一时间格式、统一 level 命名解析起来更省心。对于刚准备做日志系统的团队我的建议是先把采集链路跑通再逐步加上关联功能和告警小步快跑比一次性搞大而全要稳妥得多。实际做下来你就会发现日志系统真正难的不是安装部署而是对数据流每一个环节的把控以及随着业务变化持续调优。这套链路我跑了大半年现在线上出问题基本都能在几分钟内定位到根因再也不用半夜起来翻服务器了。