用 Loki + Promtail 替代 ELK:个人站做轻量日志聚合,标签设计、保留策略与 LogQL 实战

日志这件事,个人站长最容易走两个极端

我见过太多个人站长的日志处理方式,基本落在两个极端上。一种是完全不管:日志写在文件里,靠 logrotate 每天切一刀,出问题的时候 SSH 上去 grep,几十兆的 nginx 日志还搜得动,等到文件系统里攒了几百兆就开始头疼。另一种是用力过猛:照着网上的教程上一套 ELK(Elasticsearch + Logstash + Kibana),结果 60% 的服务器内存被 Java 吃掉,网站本身的 PHP-FPM 反而因为内存不足被 OOM Killer 干掉。

中间的这条路线,是 Grafana 官方出的 Loki。它的设计哲学和 ES 完全不同,一句就能说清:ES 索引日志的全文,Loki 只索引日志的标签。你把日志推给 Loki 的时候,自己不写「全文索引」这件事,Loki 把每行日志当成一段不可变的文本块压缩存起来,只在元数据(标签)上建索引。带来的直接好处是存储成本极低、内存占用极小——一台 1G 内存的小机器跑 Loki + Promtail,占用通常在 100MB 量级,而同样的日志量给 ES 至少要预留 2G。代价是:你没有 ES 那种「任意字段随意切面聚合」的能力,查询必须先按标签筛出一个范围,再在范围内做文本匹配。对个人站长来说,这个取舍完全划算,因为你的查询需求 95% 就是「最近一小时里 500 错误出现在哪些 URL」。

三件套各自干什么,别搞混

Loki 的部署形态经常让人困惑,先把三个组件的职责分开:

  • Promtail:跑在你每一台被采集的机器上,读本地的日志文件(或 Docker 日志),给它打上标签,然后推送到 Loki。它是「采集器」,不吃资源,一个二进制文件几十兆内存。
  • Loki:中心存储与查询服务,接收 Promtail 推来的数据,压缩落盘,对外提供查询 API。它是「后端」,应该单独放一台机器,或者和 Grafana 同机。
  • Grafana:查询与展示界面,Loki 作为数据源接进去,用 LogQL 查日志,和指标图放同一个面板里。

最常见的错误是把 Promtail 和 Loki 装在同一台、然后拿它去采集自己——这本身没错(单机场景就该这么干),错的是把 Loki 的配置文件当成 Promtail 的配置去改。两者的配置文件长得像但不通用,Promtail 有 scrape_configs 和 clients,Loki 有 schema_config 和 storage_config,混用会报一堆看不懂的 YAML 解析错误。记住这个区别,能省半小时。

Docker Compose 最小可用部署

不用 Helm、不用分布式,四行服务搞定。以下配置我按「一台 2G 内存 VPS,同时跑 Nginx + PHP + Loki + Grafana」的规格调过:

# docker-compose.yml
version: "3.8"
services:
  loki:
    image: grafana/loki:2.9.4
    container_name: loki
    command: -config.file=/etc/loki/local-config.yaml
    ports:
      - "127.0.0.1:3100:3100"
    volumes:
      - ./loki-data:/loki
      - ./loki-config.yaml:/etc/loki/local-config.yaml
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 512M

  promtail:
    image: grafana/promtail:2.9.4
    container_name: promtail
    volumes:
      - /var/log:/var/log:ro
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
      - ./promtail-config.yaml:/etc/promtail/config.yaml
      - /etc/machine-id:/etc/machine-id:ro
    command: -config.file=/etc/promtail/config.yaml
    restart: unless-stopped
    depends_on:
      - loki

  grafana:
    image: grafana/grafana:10.4.1
    container_name: grafana
    ports:
      - "127.0.0.1:3000:3000"
    volumes:
      - ./grafana-data:/var/lib/grafana
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=change-this-now
      - GF_USERS_ALLOW_SIGN_UP=false
    restart: unless-stopped
    depends_on:
      - loki

注意几个刻意的选择。端口全部绑 127.0.0.1,不暴露到公网——Loki 和 Grafana 都没有为公网裸奔设计,谁都能查你的日志、改你的面板。要用的时候通过 Nginx 加 basic auth 反代,或者干脆用 SSH 本地端口转发:ssh -L 3000:127.0.0.1:3000 user@server,然后本地浏览器开 localhost:3000。这个做法比花时间配 Grafana 的 HTTPS 和登录鉴权快得多,也更安全。

Grafana 的默认账号密码是 admin/admin,第一次登录会强制改密;我在环境变量里直接设了 GF_SECURITY_ADMIN_PASSWORD,省掉这一步。强烈建议同时加 GF_USERS_ALLOW_SIGN_UP=false,否则默认配置下 Grafana 允许任何人自助注册账号,暴露到公网就是安全事故。

Loki 配置:把 schema 和 retention 设对

# loki-config.yaml
auth_enabled: false          # 单机不需要多租户

server:
  http_listen_port: 3100
  grpc_listen_port: 9096
  log_level: warn            # 别用 info,Loki 自己的日志会刷屏

common:
  instance_addr: 127.0.0.1
  path_prefix: /loki
  storage:
    filesystem:
      chunks_directory: /loki/chunks
      rules_directory: /loki/rules
  replication_factor: 1
  ring:
    kvstore:
      store: inmemory

schema_config:
  configs:
    - from: 2024-01-01
      store: tsdb            # 新版本用 tsdb,旧的 boltdb-shipper 已不推荐
      object_store: filesystem
      schema: v12
      index:
        prefix: index_
        period: 24h

limits_config:
  retention_period: 720h     # 保留 30 天,按需调整
  reject_old_samples: true
  reject_old_samples_max_age: 168h
  max_query_series: 500      # 防止查询被高基数标签打爆
  ingestion_rate_mb: 4
  ingestion_burst_size_mb: 8

compactor:
  working_directory: /loki/compactor
  retention_enabled: true    # 关键!不开启的话 retention_period 不生效
  delete_request_store: filesystem

这份配置里有两个「不写就静默失效」的点,值得单独强调。

第一,retention_period 单独写在 limits_config 里是不生效的。日志会一直堆积,直到你发现磁盘满了。必须同时在 compactor 段里打开 retention_enabled: true,Loki 才会真正定期去删除过期数据。这是 Loki 文档里最容易漏掉的一句话。

第二,schema_config 里的 store 决定了索引的存储引擎。boltdb-shipper 是旧写法,新部署(2.9 以上)应该用 tsdb,查询性能更好、索引更紧凑。schema 一旦写入就不能改——改了会导致新老数据对不上,索引查不出来。如果你在建库时就写错了,只能把 loki-data 目录清空重来。

Promtail 配置:标签设计决定查询体验

# promtail-config.yaml
server:
  http_listen_port: 9080
  log_level: warn

positions:
  filename: /tmp/positions.yaml     # 记录读到哪个文件的哪一行,避免重复采集

clients:
  - url: http://loki:3100/loki/api/v1/push

scrape_configs:
  - job_name: nginx-access
    static_configs:
      - targets: [localhost]
        labels:
          job: nginx
          host: web01
          __path__: /var/log/nginx/*access.log
    pipeline_stages:
      # 用正则从 nginx combined 格式里抽出状态码和方法
      - regex:
          expression: '^(?P<remote_addr>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] "(?P<method>\S+) (?P<path>\S+)[^"]*" (?P<status>\d{3}) (?P<bytes>\d+)'
      - labels:
          method:
          status:

  - job_name: nginx-error
    static_configs:
      - targets: [localhost]
        labels:
          job: nginx-error
          host: web01
          __path__: /var/log/nginx/*error.log

  - job_name: system
    static_configs:
      - targets: [localhost]
        labels:
          job: syslog
          __path__: /var/log/syslog

Promtail 的标签设计是整件事里最需要动脑子的一步,因为它直接决定你后面查询方不方便,同时也决定 Loki 的索引会不会被高基数标签撑爆。原则只有一条:标签要低基数。所谓基数,就是这个标签的取值有多少种。

  • host、job、method、status——这些取值都很少(几十台机器、几个方法、几十个状态码),是合格的标签。
  • remote_addr(访客 IP)、path(请求路径)、request_id——这些取值可以上百万,是绝对不能当标签的。把它们设成标签,Loki 会为每个不同的值建一条时间序列,几小时内就能把内存和索引撑爆,表现为 Loki 自己 OOM 重启。

上面配置里我只把 method 和 status 提升成了标签,remote_addr 和 path 保留在日志行里靠文本匹配搜——这是有意的。想查某个 IP 干了什么,就 |= "1.2.3.4" 做文本包含,虽然比标签查询慢一点,但绝不会把系统搞崩。

LogQL:日常真正会用到的几条查询

Grafana 里选了 Loki 数据源之后,查询语法叫 LogQL。不需要学全,掌握下面几条就够日常排查了:

# 1. 最近 1 小时所有 nginx 500 错误,按状态码筛选标签
{job="nginx"} |= " 500 "

# 2. 某个 IP 的所有请求(文本包含,注意加引号)
{job="nginx"} |= "203.0.113.45"

# 3. 排除掉健康检查,只看真实请求
{job="nginx"} != "/healthz" != "/ping"

# 4. 把所有 5xx 按 URL 聚合计数,直接用柱状图看哪个页面在报错
sum by (path) (
  count_over_time({job="nginx-error"} |~ "5\\d\\d" [5m])
)

# 5. 每 1 分钟统计一次请求量,画成时间序列图
sum(count_over_time({job="nginx"}[1m]))

# 6. 用 Parser 在查询时提取字段(不用改 Promtail 配置),计算 P95 响应时间
quantile_over_time(0.95,
  {job="nginx"} | regexp `(?P<rt>[\d.]+)$` | unwrap rt [5m]
) by (path)

第 5 条是最实用的一个:把 sum(count_over_time(...)) 放进 Grafana 的时间序列面板,你就有了一张实时的网站请求量曲线。配合对 {job="nginx"} |= " 500 " 的计数,一旦 5xx 数量超过阈值就触发 Grafana 告警——这套东西的成本,比你买一个第三方 uptime 监控服务低得多,而且数据在自己手里。

第 6 条的 unwrap 是 Loki 做指标聚合的关键字:它把日志里提取出来的数值「解开」,交给 quantile_over_time、avg_over_time 这类函数统计。这样你就能在不引入 Prometheus 的情况下,用日志本身算出请求延迟的百分位数。

几个必踩的坑与验证方法

  1. Promtail 说「no such file」但文件明明存在。多半是容器里没挂载对应目录,或者 __path__ 通配符在容器内的路径对不上。挂载 /var/log 之后,容器内看到的路径和宿主机一致,这个坑就避开了。用 docker logs promtail | grep -i 'no such file' 确认。
  2. 日志推上去了但 Grafana 查不到。先确认时间范围选对(Loki 默认按日志自身时间戳,服务器时区不对会出现「日志跑到未来」),再确认标签值拼错——标签是精确匹配的,job="nginx" 和 job="Nginx" 是两回事。用 curl -s 'localhost:3100/loki/api/v1/labels' | jq 列出所有实际存在的标签名。
  3. 磁盘涨得飞快。检查 compactor 段的 retention_enabled 是否为 true,以及 retention_period 单位写没写对(720h 是 30 天,写成 720 会被当成纳秒,等于不保留)。用 du -sh loki-data/chunks 观察目录大小变化。
  4. Loki 内存持续上涨。八成是标签基数失控。查当时的标签组合数量:curl -s 'localhost:3100/metrics' | grep loki_ingester_memory_streams,这个值应该在几百到几千,如果上万,说明有人往标签里塞了 IP 或 URL。

日志系统的价值不在于你部署了它,而在于出事的那一刻你能多快找到原因。我的实际体验是:有了 Loki 之后,排查一次「昨晚某个时间段 500 突然升高」的时间,从原来 SSH 上去翻日志的十几分钟,缩短到 Grafana 里点两下、三十秒出结论。对个人站长来说,这个投入产出比是相当高的——前提是别一上来就上 ELK。

Last modification:September 29th, 2026 at 12:27 pm

Leave a Comment