日志系统实践笔记

日志系统要先改哪一层?

上周五凌晨两点,生产环境一个接口报错,我在三台服务器之间来回切换,grep、tail、less 切换得手忙脚乱。

为什么要改

原来的日志系统简单粗暴:应用日志直接写本地文件,出问题时登录服务器 grep。这种方式在单机时代还能凑合,但上了集群之后问题就暴露出来了:

  • 多台服务器日志分散,排查问题要逐个查找
  • 日志没有统一格式,grep 时正则表达式写得很痛苦
  • 历史日志轮转后丢失,无法追溯问题
  • 无法实时监控,出了问题往往都是用户先发现

特别是那次凌晨两点的事故,三台服务器挨个 SSH 进去看日志,grep 的时候正则表达式还写错了,来回折腾了半小时才定位到问题。这种低效的排查方式,必须改。

技术选型

日志系统方案挺多,ELK Stack、Loki、Fluentd、Graylog,每个都有自己的优缺点。这次选型考虑了几个因素:

  • 团队熟悉度:之前用过 ELK,有一定的经验
  • 资源消耗:ELK 比较吃资源,Loki 轻量一些
  • 运维成本:ELK 组件多,Loki 相对简单
  • 查询需求:需要支持复杂查询,ELK 的 Elasticsearch 查询能力强

综合下来,这次选择了 ELK Stack:

# docker-compose.yml 核心组件
version: '3.8'
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
    environment:
      - discovery.type=single-node
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    volumes:
      - es_data:/usr/share/elasticsearch/data
    ports:
      - "9200:9200"

  kibana:
    image: docker.elastic.co/kibana/kibana:8.11.0
    ports:
      - "5601:5601"
    depends_on:
      - elasticsearch

  logstash:
    image: docker.elastic.co/logstash/logstash:8.11.0
    volumes:
      - ./logstash/pipeline:/usr/share/logstash/pipeline
    ports:
      - "5044:5044"
    depends_on:
      - elasticsearch

选完之后发现,这个组合在小团队里资源消耗确实有点大。如果后续资源紧张,可以考虑换成 Loki + Promtail + Grafana,那个方案轻量很多。

日志格式统一

日志格式不统一是之前的一大痛点。每条日志格式都不一样,grep 时正则表达式写得特别痛苦。这次统一了日志格式:

{
  "timestamp": "2026-07-16T21:15:00+08:00",
  "level": "ERROR",
  "service": "user-service",
  "environment": "production",
  "host": "web-server-01",
  "message": "Database connection failed",
  "trace_id": "abc123def456",
  "user_id": "user_789",
  "request_id": "req_456",
  "extra": {
    "database": "mysql-main",
    "error_code": "CONN_TIMEOUT"
  }
}

这个格式有几个关键点:

  • timestamp 统一使用 ISO 8601 格式,方便时间范围查询
  • level 使用标准日志级别:DEBUG、INFO、WARN、ERROR
  • service 标识服务名称,方便按服务过滤
  • environment 区分环境,避免测试和生产日志混在一起
  • trace_id 用于链路追踪,排查跨服务问题时很有用
  • request_id 用于追踪单个请求的完整生命周期

在代码中,使用 structured logging 库来生成这种格式。以 Python 为例:

import structlog
import json

logger = structlog.get_logger()

# 使用方式
logger.error(
    "database_connection_failed",
    service="user-service",
    environment="production",
    database="mysql-main",
    error_code="CONN_TIMEOUT",
    trace_id="abc123def456",
    user_id="user_789",
    request_id="req_456"
)

日志格式统一之后,查询效率提升很明显。之前 grep 一条错误日志要写复杂的正则表达式,现在直接按字段过滤就能找到。

日志采集

日志采集是整个系统的关键环节。这次使用了 Filebeat 来采集日志,它轻量、稳定,配置也相对简单。

# filebeat.yml
filebeat.inputs:
- type: log
  enabled: true
  paths:
    - /var/log/app/*.log
  json.keys_under_root: true
  json.add_error_key: true
  fields:
    environment: production
    data_center: cn-north-1
  fields_under_root: true

output.logstash:
  hosts: ["logstash:5044"]

processors:
  - add_host_metadata:
      when.not.contains.tags: forwarded
  - add_cloud_metadata: ~

这里有个坑:配置 json.keys_under_root: true 之后,如果日志格式不规范,解析会失败。刚开始有几条日志格式错误,导致整个日志流中断。后来加了 json.add_error_key: true,解析失败的日志会标记 error.message 字段,不会阻塞整个流程。

Logstash 的配置也需要注意:

# logstash/pipeline/logstash.conf
input {
  beats {
    port => 5044
  }
}

filter {
  if [message] =~ /^\{.*\}$/ {
    json {
      source => "message"
      target => "json_content"
    }
  }

  date {
    match => ["timestamp", "ISO8601"]
    target => "@timestamp"
  }

  mutate {
    rename => { "host" => "server_host" }
  }
}

output {
  elasticsearch {
    hosts => ["elasticsearch:9200"]
    index => "app-logs-%{+YYYY.MM.dd}"
  }
}

这里又踩了一个坑:date 插件的时间格式匹配。日志里的 timestamp 字段是 2026-07-16T21:15:00+08:00 这种格式,刚开始用了 ISO8601,但时区处理有问题。后来改成了 yyyy-MM-dd'T'HH:mm:ssXXX,时区才正确。

还有一个性能问题:刚开始所有的日志都经过 Logstash,在高并发场景下 Logstash 成了瓶颈。后来把非结构化的日志直接用 Filebeat 发送到 Elasticsearch,只让需要处理的日志走 Logstash,性能提升了大概 40%。

日志存储与查询

Elasticsearch 的索引设计很重要,直接影响查询性能和存储成本。

PUT _template/app-logs-template
{
  "index_patterns": ["app-logs-*"],
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 1,
    "refresh_interval": "30s",
    "index.lifecycle.name": "app-logs-policy",
    "index.lifecycle.rollover_alias": "app-logs"
  },
  "mappings": {
    "properties": {
      "timestamp": {
        "type": "date",
        "format": "yyyy-MM-dd'T'HH:mm:ssXXX"
      },
      "level": {
        "type": "keyword"
      },
      "service": {
        "type": "keyword"
      },
      "message": {
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "trace_id": {
        "type": "keyword"
      }
    }
  }
}

这里有个经验:字段类型要根据查询模式来设计。比如 levelservicetrace_id 这些主要用于过滤的字段,用 keyword 类型;而 message 这种需要全文搜索的字段,用 text 类型并配上中文分词器。

索引生命周期管理也很重要:

PUT _ilm/policy/app-logs-policy
{
  "policy": {
    "phases": {
      "hot": {
        "actions": {
          "rollover": {
            "max_size": "50GB",
            "max_age": "1d"
          }
        }
      },
      "warm": {
        "min_age": "7d",
        "actions": {
          "forcemerge": {
            "max_num_segments": 1
          }
        }
      },
      "delete": {
        "min_age": "30d",
        "actions": {
          "delete": {}
        }
      }
    }
  }
}

这个配置让索引在达到 50GB 或 1 天时自动滚动,7 天后合并分段,30 天后删除。这样既保证了查询性能,又控制了存储成本。

Kibana 的查询界面虽然功能强大,但有些操作还是比较繁琐。常用的查询可以保存成 Saved Queries:

GET .kibana/_search
{
  "query": {
    "term": {
      "type": "query"
    }
  }
}

这里踩过一个坑:Kibana 的查询语法和 Elasticsearch 的 DSL 不完全一样,有些查询在 Kibana 里能跑,但用 API 调用就不行。后来发现 Kibana 有自己的查询语言 KQL,和 Lucene 查询语法有差异。

日志分析

日志收集起来之后,分析就成了关键。Kibana 的可视化功能可以帮助快速发现问题:

POST _ml/anomaly_detectors/_create/log_error_rate
{
  "analysis_config": {
    "bucket_span": "15m",
    "detectors": [
      {
        "detector_description": "High error rate",
        "function": "high_count",
        "by_field_name": "service"
      }
    ]
  },
  "data_description": {
    "time_field": "@timestamp",
    "time_format": "epoch_ms"
  }
}

这个机器学习作业可以自动检测异常的错误率。当某个服务的错误率突然升高时,会自动告警。

还有一些实用的分析模式:

  • trace_id 聚合,查看完整请求链路
  • servicelevel 聚合,分析各服务的健康状况
  • user_id 聚合,分析单个用户的行为模式

这里有个经验:分析时要先看整体趋势,再钻取细节。不要一上来就盯着单条日志看,很容易迷失方向。

日志告警

日志告警是整个系统的最后一环。Elasticsearch 的 Watcher 功能可以实现告警:

PUT _watcher/watch/error_rate_alert
{
  "trigger": {
    "schedule": {
      "interval": "5m"
    }
  },
  "input": {
    "search": {
      "request": {
        "indices": ["app-logs-*"],
        "body": {
          "query": {
            "bool": {
              "must": [
                {
                  "range": {
                    "@timestamp": {
                      "gte": "now-5m"
                    }
                  }
                },
                {
                  "term": {
                    "level": "ERROR"
                  }
                }
              ]
            }
          },
          "aggs": {
            "by_service": {
              "terms": {
                "field": "service",
                "size": 10
              }
            }
          }
        }
      }
    }
  },
  "condition": {
    "compare": {
      "ctx.payload.hits.total": {
        "gt": 100
      }
    }
  },
  "actions": {
    "email_admin": {
      "email": {
        "to": "[email protected]",
        "subject": "High error rate detected",
        "body": "Error rate is too high: {{ctx.payload.hits.total}} errors in the last 5 minutes."
      }
    }
  }
}

这个告警规则监控过去 5 分钟内的错误数量,如果超过 100 条就发送邮件告警。

这里有个坑:告警规则太敏感会产生大量告警噪音,导致真正重要的告警被忽略。后来调整了告警策略:

  • 按服务分组告警,避免某个服务的问题淹没其他信息
  • 设置告警抑制,短时间内重复的告警只发送一次
  • 增加告警级别,ERROR 级别实时告警,WARN 级别定时汇总

踩过的坑

这次改造过程中踩了不少坑,记录下来避免再犯:

1. 日志丢失问题

刚开始配置 Filebeat 时,日志偶尔会丢失。排查发现是 Filebeat 的 registry 文件损坏导致。后来加了定期备份:

# 备份 registry 文件
cp /var/lib/filebeat/registry /var/lib/filebeat/registry.backup

2. 时区问题

日志里的时间和 Elasticsearch 的 @timestamp 不一致,导致时间范围查询不准确。最后统一使用 UTC 时间存储,查询时再转换:

filter {
  date {
    match => ["timestamp", "ISO8601"]
    target => "@timestamp"
    timezone => "UTC"
  }
}

3. 字段映射冲突

不同服务的日志有相同名字但类型不同的字段,导致索引映射冲突。解决方法是在索引模板里预先定义好所有字段的类型:

{
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "user_id": {
        "type": "keyword"
      },
      "score": {
        "type": "float"
      }
    }
  }
}

4. 存储成本问题

日志量增长很快,磁盘空间很快就不够了。后来优化了几个方面:

  • 删除不必要的字段,只保留关键字段
  • 压缩历史日志,使用 forcemerge 减少段数量
  • 设置合理的索引生命周期,及时删除过期数据

5. 查询性能问题

在大数据量下查询很慢。优化措施:

  • 合理设置分片数量,避免过多小分片
  • 使用 index sorting 加速时间范围查询
  • 对常用的查询字段设置合适的分词器

效果

这次改造完成之后,效果很明显:

  • 问题发现时间从 30 分钟降到 5 分钟
  • 日志查询效率提升 80%
  • 存储成本降低 40%(通过优化索引策略)
  • 告警准确率提升 60%(通过调整告警策略)

最重要的是,团队再也不用熬夜 SSH 到服务器上 grep 日志了。

后续优化方向

目前的方案还有改进空间:

  1. 考虑引入 Loki + Promtail + Grafana 替代 ELK,降低资源消耗
  2. 增加日志采样,在高并发场景下只采集部分日志
  3. 引入日志脱敏,避免敏感信息泄露
  4. 增加日志质量监控,及时发现格式异常的日志

日志系统是基础设施的一部分,搭建起来只是开始,持续优化才是关键。

一点余味

做完这次改造,发现日志系统其实是一个团队的"记性"。好的日志系统不仅能快速定位问题,还能记录系统的演进过程。每条日志都是系统运行的一个瞬间,把这些瞬间串起来,就是整个系统的生命轨迹。

技术选型没有绝对的好坏,只有适合不适合。ELK 重但功能全,Loki 轻但查询能力弱,关键是要根据团队的实际情况来做选择。这次选择了 ELK,是因为团队有使用经验,而且查询需求比较复杂。如果下次换个场景,可能就会选 Loki 了。

日志系统的搭建是个持续的过程,不是一次性项目。随着业务的发展,日志量会增长,查询需求会变化,监控系统也要跟着调整。保持敏感,及时发现问题,持续优化,这才是日志系统建设的正确方式。


这次日志系统改造花了两周时间,从文件到集群,踩了不少坑。但改造完成后,问题发现时间从 30 分钟降到 5 分钟,这个投入是值得的。日志系统是基础设施的一部分,搭建起来只是开始,持续优化才是关键。

可用性说明:本文发布于 2021 年 4 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。

版权声明: 本文首发于 指尖魔法屋-日志系统实践笔记https://blog.thinkmoon.cn/post/115-logging-system-cluster-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!