日志审计实战:Promtail 与 Loki 部署配置

适用场景

日志审计是入侵检测与事后溯源的基础能力。现实中大量中小站点仍停留在”grep 单机日志”阶段,一旦出现问题,排查要在几台机器之间反复登录、翻找历史文件,往往还没定位到攻击者,日志已经被轮转覆盖。本文要给出一套可在单机或小规模集群上落地的日志审计方案:用 Promtail 采集,用 Loki 存储索引,用 Grafana 查询与告警,全部组件以容器方式部署。

这套组合的特点是只索引标签、不索引正文,因此存储与内存开销远低于把全文索引的 ELK,适合日增日志在 GB 量级以内、又需要长期保留(30 天以上)的场景。典型用途包括:Nginx 访问日志中攻击特征检索、SSH 与系统认证失败分析、WebShell 落地痕迹追踪、按 IP 或 URL 还原完整时间线、异常频率自动告警。

如果站点需要的是复杂全文检索与大规模聚合分析,ELK 更合适;而”日志集中留存 + 按标签快速定位 + 轻量告警”这类需求,Loki 方案的部署与运维成本明显更低。

前置条件

  • Linux 服务器一台,具备 Docker 20.10 以上与 Docker Compose v2。
  • 磁盘可用空间不低于 20 GB(按保有量与压缩比估算)。
  • 待采集的日志文件路径与格式已明确(本文以 Nginx access.log 与 auth.log 为例)。
  • 服务器时间已同步(chrony 或 systemd-timesyncd),日志时间戳错乱会导致时间线还原失效。
  • 对外仅需暴露 Grafana 端口,Loki 与 Promtail 保持内网通信。

原理说明

三个组件的职责边界需要先分清:

  • Promtail 是采集代理,负责发现日志文件、按规则打标签、推送日志流。它替代了早期的通用日志采集方式,原生支持 Loki 的推送协议。
  • Loki 是存储与查询引擎,把日志按”标签集 + 时间戳”组织成日志流写入对象存储或本地文件系统,只对标签建立倒排索引。
  • Grafana 提供查询界面与告警,用 LogQL 检索日志、用指标查询生成告警。

关键设计约束是标签基数(cardinality)。标签会进入索引,因此像 iprequest_uriuser_agent 这类高基数字段绝不能做成标签,否则索引会迅速膨胀甚至压垮 Loki。正确做法是:只保留 jobhostservicelog_type 等低基数标签,高基数字段交给查询时的解析器(如 | pattern| json)在扫描阶段按需提取。

这也解释了 Loki 的查询模型:先用标签快速圈定日志流(必须提供至少一个标签匹配器),再在流内逐行过滤与解析。查询成本与命中的日志量成正比,所以标签设计直接决定了查询速度。

操作步骤

第一步:编写 docker-compose 编排文件

mkdir -p /opt/log-audit && cd /opt/log-audit
mkdir -p loki-data grafana-data promtail-positions loki-rules
# /opt/log-audit/docker-compose.yml
services:
  loki:
    image: grafana/loki:3.1.0
    container_name: loki
    restart: unless-stopped
    command: -config.file=/etc/loki/loki.yml
    volumes:
      - ./loki.yml:/etc/loki/loki.yml:ro
      - ./loki-data:/loki
      - ./loki-rules:/etc/loki/rules:ro
    ports:
      - "127.0.0.1:3100:3100"

  promtail:
    image: grafana/promtail:3.1.0
    container_name: promtail
    restart: unless-stopped
    command: -config.file=/etc/promtail/promtail.yml
    volumes:
      - ./promtail.yml:/etc/promtail/promtail.yml:ro
      - ./promtail-positions:/var/positions
      - /var/log:/var/log:ro          # 只读挂载,采集不修改原始日志
    depends_on:
      - loki

  grafana:
    image: grafana/grafana:11.1.0
    container_name: grafana
    restart: unless-stopped
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=ChangeMe_Strong_Pwd
      - GF_USERS_ALLOW_SIGN_UP=false
    volumes:
      - ./grafana-data:/var/lib/grafana
    ports:
      - "3000:3000"
    depends_on:
      - loki

要点:Loki 端口绑定 127.0.0.1,不暴露到公网;/var/logro 只读挂载,避免采集进程误改日志;promtail-positions 必须持久化,否则重启后会重复推送整个文件。

第二步:编写 Loki 配置

# /opt/log-audit/loki.yml
auth_enabled: false
server:
  http_listen_port: 3100
  grpc_listen_port: 9095
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: 2024-01-01
      store: tsdb
      object_store: filesystem
      schema: v13
      index:
        prefix: index_
        period: 24h
limits_config:
  reject_old_samples: true
  reject_old_samples_max_age: 168h        # 拒绝 7 天前的样本,抑制乱序写入
  max_query_series: 5000
  retention_period: 720h                  # 日志保留 30 天
compactor:
  working_directory: /loki/compactor
  retention_enabled: true                 # 保留策略必须由 compactor 执行
  delete_request_store: filesystem
ruler:
  storage:
    type: local
    local:
      directory: /etc/loki/rules
  rule_path: /loki/rules-temp
  enable_api: true

三个参数需要重点理解:retention_period: 720h 决定日志保留 30 天,是审计留存期的落地点;retention_enabled: true 必须开启,否则保留策略只是声明而不会真正删除;reject_old_samples_max_age 防止采集端时钟漂移时把过期样本灌入。

第三步:编写 Promtail 采集配置

# /opt/log-audit/promtail.yml
server:
  http_listen_port: 9080
positions:
  filename: /var/positions/positions.yml
clients:
  - url: http://loki:3100/loki/api/v1/push
scrape_configs:
  # Nginx 访问日志
  - job_name: nginx-access
    static_configs:
      - targets: [localhost]
        labels:
          job: nginx-access
          host: web-01
          log_type: access
          __path__: /var/log/nginx/access.log

  # Nginx 错误日志,含多行堆栈合并
  - job_name: nginx-error
    static_configs:
      - targets: [localhost]
        labels:
          job: nginx-error
          host: web-01
          log_type: error
          __path__: /var/log/nginx/error.log
    pipeline_stages:
      - multiline:
          firstline: '^\d{4}/\d{2}/\d{2} '
          max_wait_time: 3s

  # 系统认证日志
  - job_name: syslog-auth
    static_configs:
      - targets: [localhost]
        labels:
          job: syslog-auth
          host: web-01
          log_type: auth
          __path__: /var/log/auth.log

标签只保留 job/host/log_type 三个低基数字段,这正是前文所述的设计约束。若需按 IP 分析,放在查询阶段用解析器提取,不要写进 labels

为了让查询时能稳定解析字段,建议在 Nginx 侧改用统一格式,把关键字段固定位置输出:

log_format audit '$remote_addr - $remote_user [$time_iso8601] '
                 '"$request" $status $body_bytes_sent "$http_referer" '
                 '"$http_user_agent" rt=$request_time uct=$upstream_connect_time';
access_log /var/log/nginx/access.log audit;

第四步:启动并确认数据写入

cd /opt/log-audit
docker compose up -d
docker compose ps
docker compose logs --tail=30 promtail
docker compose logs --tail=30 loki

直接在 Loki 侧验证是否已收到日志流:

# 列出已知标签与值
curl -s http://127.0.0.1:3100/loki/api/v1/labels | head -c 400
curl -s 'http://127.0.0.1:3100/loki/api/v1/label/job/values'
curl -s 'http://127.0.0.1:3100/loki/api/v1/label/log_type/values'

Promtail 日志中出现 level=info msg="entry added"msg="compacting" 之外的报错(如 429、400)需要立即处理,429 通常是 Loki 摄入限流,400 多为标签或时间戳问题。

第五步:在 Grafana 中接入数据源并审计查询

# 添加数据源(也可在 Grafana Web 界面手动添加)
curl -s -X POST http://admin:ChangeMe_Strong_Pwd@127.0.0.1:3000/api/datasources \
  -H 'Content-Type: application/json' \
  -d '{"name":"Loki","type":"loki","url":"http://loki:3100","access":"proxy","isDefault":true}'

下面给出几组可直接使用的 LogQL,覆盖审计中最常见的检索需求:

# 1) 近 1 小时 5xx 错误,按 URL 聚类
sum by (uri) (
  count_over_time({job="nginx-access"}
    | pattern `<_> - <_> [<_>] "<method> <uri> <_>" <status> <_>` 
    | status =~ "5.." [1h])
)

# 2) 高请求 IP TOP10(解析后聚合,避免作为标签)
topk(10, sum by (ip) (
  count_over_time({job="nginx-access"}
    | pattern `<ip> - <_> [<_>] "<_>" <_> <_>` [1h])
))

# 3) 疑似 SQL 注入与路径遍历特征检索
{job="nginx-access"} |~ `(?i)(union\\s+select|\\.\\./|%2e%2e|/etc/passwd|information_schema)`

# 4) 扫描器与常见自动化工具指纹
{job="nginx-access"} |~ `(?i)(nikto|sqlmap|nmap|masscan|dirbuster|acunetix|nessus|zgrab)`

# 5) SSH 认证失败峰值,用于爆破告警
sum(count_over_time({job="syslog-auth"} |= "Failed password" [5m]))

# 6) 单 IP 完整时间线(溯源)
{job="nginx-access"} |= "203.0.113.45"

# 7) 日志写入速率,确认采集链路健康
sum(rate({job="nginx-access"}[5m]))

第 3、4 条是攻击特征检索的核心:正则过滤在流内扫描,不需要预先建索引,因此新增检测规则可即时生效。第 6 条用于把某个 IP 的全部行为按时间串起来,是溯源阶段最常用的查询。

第六步:配置自动告警

Loki 的 ruler 支持基于 LogQL 指标查询触发告警,规则文件放入 loki-rules/

# /opt/log-audit/loki-rules/audit-alerts.yml
groups:
  - name: security-audit
    rules:
      - alert: SSHBruteForceSuspected
        expr: |
          sum by (host) (count_over_time({job="syslog-auth"} |= "Failed password" [5m])) > 50
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "{{ $labels.host }} 5 分钟内 SSH 认证失败超过 50 次"

      - alert: WebScanDetected
        expr: |
          sum(count_over_time({job="nginx-access"}
            |~ `(?i)(sqlmap|nikto|acunetix|nessus)` [5m])) > 10
        for: 1m
        labels:
          severity: warning
        annotations:
          summary: "检测到扫描器特征请求,疑似自动化扫描"

告警规则修改后需要重载:curl -X POST http://127.0.0.1:3100/loki/api/v1/rules/security-audit/reload。确认规则已加载:curl -s http://127.0.0.1:3100/loki/api/v1/rules。Grafana 侧可在 Alerting 中指向 Loki 数据源创建更复杂的通知策略(邮件、Webhook 到企业内部 IM)。

配置验证

# 1. 三个容器均处于 running
docker compose ps --format 'table {{.Name}}\t{{.Status}}'

# 2. Loki 就绪
curl -s http://127.0.0.1:3100/ready

# 3. 日志流标签齐全
curl -s 'http://127.0.0.1:3100/loki/api/v1/label/job/values'

# 4. 实际查询返回最近 5 条记录
curl -s -G http://127.0.0.1:3100/loki/api/v1/query_range \
  --data-urlencode 'query={job="nginx-access"}' \
  --data-urlencode 'limit=5' | head -c 600

# 5. 生成一条测试日志并确认可检索(验证端到端链路)
logger -p auth.info 'LOG_AUDIT_SELFTEST failed password for testuser'
curl -s -G http://127.0.0.1:3100/loki/api/v1/query_range \
  --data-urlencode 'query={job="syslog-auth"} |= "LOG_AUDIT_SELFTEST"'

# 6. Grafana 存活与数据源连通
curl -s http://127.0.0.1:3000/api/health

验证顺序体现了一条排查原则:先确认采集端有无推送,再确认 Loki 有无写入,最后确认查询语句是否正确。第 5 步的端到端自测最有价值,它同时覆盖了文件权限、采集规则、推送链路与查询解析四个环节。如果第 5 步检不到而第 3 步正常,问题通常出在 __path__ 路径或容器内挂载点不一致。

常见问题

Q1:日志量上来之后 Loki 内存持续上涨,如何控制?

先判断是摄入压力还是查询压力。摄入侧:检查是否存在高基数标签(curl -s 'http://127.0.0.1:3100/loki/api/v1/label/<name>/values' | wc -c 可粗略判断值的数量),把 ipuriuser_agent 从 labels 中移出,改为查询期解析。查询侧:避免不带时间范围的全量查询,用 limits_config.max_query_seriesmax_query_parallelism 限制并发;在 Grafana 面板中把默认时间窗设为 1 小时而不是 7 天。另外可对 Nginx 侧做日志瘦身,例如关闭不必要的 $http_referer 记录、对健康检查路径使用 access_log off,这通常能削减 30% 以上的日志量。

Q2:容器重启后日志出现重复,或者历史日志丢失了一段,怎么处理?

重复的原因是 positions 文件未持久化。Promtail 依靠该文件记录每个文件已读到的偏移量,如果 /var/positions 没有挂载到宿主机目录,容器重建后偏移清零,就会从头重新推送。解决方法是确认 compose 中 ./promtail-positions:/var/positions 已生效,并保证目录可写。日志丢失则多为日志轮转导致:Promtail 默认能识别 rename 与 create 事件,但如果宿主机用了非标准轮转(如先删除再创建、或 inode 复用),需要在 pipeline 中调整 tail 行为,或改用按日期命名的日志文件(access-YYYY-MM-DD.log)配合通配路径采集。

Q3:能否用这套方案替代 WAF 或入侵检测系统?

不能,两者定位不同。Loki 方案属于事后审计与可见性:它的价值在于把证据集中留存、让异常可被检索与告警,但请求已经到达了后端,攻击是否成功也需要人工判断。WAF 与 IDS 属于事中拦截,在流量进入应用之前就完成阻断。正确的组合是三层:边界层用 WAF 拦已知特征与高频攻击,主机层用日志审计发现绕过边界的低频或慢速行为,最后用告警把可疑事件推给人。审计日志同时也是 WAF 规则调优的输入——从日志中提取真实被绕过的请求特征,回到 WAF 侧补规则,才能形成闭环。

总结

Promtail + Loki + Grafana 用三个容器即可搭起一套可持续运行的日志审计链路,核心收益是把分散在各机器上的日志变成可检索、可告警、可长期留存的证据。落地时把握四个要点:标签只保留低基数字段,高基数字段交给查询期解析;positions 与数据目录必须持久化,避免重启丢偏移或重复推送;保留策略依赖 compactor 真正启用,否则审计留存期无法保证;告警阈值从保守值起步,先观察一周真实基数再收紧。完成部署后,配合 Nginx 统一日志格式与 WAF 规则联动,就能把”出事再翻日志”变成”异常主动可见”。