AL2023 上搭 ALGA 日志平台:日志已经进了 S3,告警为什么还是没响

平台上线第三天,Grafana 里查日志很顺:Nginx 访问日志、systemd 日志都能看到,S3 存储桶里也开始出现对象。大家都以为日志平台已经能用了。

接着做一次演练:让测试接口连续返回 5xx。Grafana 里能看到错误数在涨,告警群里却没有任何消息。Alertmanager 页面也是空的,看上去一切正常。

这类问题很少报错,所以更危险。日志采集、存储、查询、规则评估、告警投递是五段独立链路,每段都可能单独失败。查询正常,只能证明前三段大概率是通的。

我的核心观点是:日志平台上线那天,要验证的除了能不能查到日志,还有过期数据能不能删掉、出故障时能不能把人叫醒。

下面以 Amazon Linux 2023 为例,整理一套 ALGA 单机部署和验证方法:Alloy 负责采集,Loki 负责存储和查询,数据落到 AWS S3,Grafana 负责展示,Alertmanager 负责告警。Loki、Alloy、Alertmanager 使用二进制安装,Grafana 使用 dnf 安装。

先看清这套链路里谁负责什么

这里有一个常被误解的地方:Alertmanager 本身不查日志。它只接收别人推过来的告警,再负责分组、去重、抑制和发送。真正执行 LogQL 告警规则的是 Loki 里的 ruler。

所以告警不响时,不要一开始就改 Alertmanager 的接收人。先确认 Loki 是否加载了规则、规则是否评估成功、告警是否已经进入 firing 状态,再看 Loki 能否把告警推给 Alertmanager。

先在 ALG 主机上确认系统、架构和组件版本。这一步是只读操作:

cat /etc/os-release | grep -E '^(PRETTY_NAME|VERSION_ID)='
uname -m
getenforce
systemctl is-active firewalld 2>/dev/null || echo "firewalld not active"
aws --version
/usr/local/bin/loki -version
/usr/local/bin/alloy --version
/usr/local/bin/alertmanager --version
rpm -q grafana

结果按下面几项判断:

  • uname -m 返回 x86_64 时,下载 amd64 包;返回 aarch64 时,下载 arm64 包。Graviton 机型很容易下错包。
  • AL2023 默认通常是 SELinux Permissive。如果你们的基线改成了 Enforcing,服务启动失败时先查 SELinux 审计日志,再考虑调整权限。
  • AL2023 默认通常不启用 firewalld。端口访问主要由安全组控制。
  • 后面的配置写法按 Loki 3.x 和 TSDB 存储编写。如果 loki -version 显示的是 2.x,不要直接套用。

二进制安装时,我建议保留带版本号的文件,再用软链接指向当前版本。以 Loki 为例:

LOKI_VER=
cd /tmp
curl -fLO https://github.com/grafana/loki/releases/download/v${LOKI_VER}/loki-linux-amd64.zip
unzip -o loki-linux-amd64.zip
install -m 0755 loki-linux-amd64 /usr/local/bin/loki-${LOKI_VER}
ln -sfn /usr/local/bin/loki-${LOKI_VER} /usr/local/bin/loki

下载后,对照 Release 页面提供的校验文件核对 sha256。升级出问题时,把软链接切回旧版本文件,然后重启服务即可回滚。Alloy 和 Alertmanager 也可以按这个方法管理。

S3 这一层,先用实例角色把权限跑通

不要把 Access Key 写进 Loki 配置文件。给 EC2 绑定 IAM 实例角色后,Loki 会通过 AWS SDK 的默认凭证链读取临时凭证。下面是一个最小权限示例:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:ListBucket"],
      "Resource": "arn:aws:s3:::"
    },
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::/*"
    }
  ]
}

s3:DeleteObject 不能省。Loki 的 compactor 合并索引时,以及执行保留期删除时,都需要删除对象。如果存储桶使用 SSE-KMS,还要给实例角色和 KMS 密钥策略都放通 kms:GenerateDataKey 与 kms:Decrypt。

在 ALG 主机上验证身份和存储桶访问。以下命令都是只读操作:

TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
  -H "X-aws-ec2-metadata-token-ttl-seconds: 300")
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
  http://169.254.169.254/latest/meta-data/iam/security-credentials/

aws sts get-caller-identity
aws s3 ls s3:///
aws s3api get-public-access-block --bucket 

第二条 curl 只会返回角色名。不要继续访问后面的凭证路径,那里会输出临时密钥,容易被贴进工单或聊天记录。

判断方法如下:

  • get-caller-identity 中的 ARN 应该是预期的实例角色。
  • aws s3 ls 返回 AccessDenied 时,先检查角色策略、存储桶策略和 KMS 策略,再改 Loki 配置。
  • 存储桶的四项 Block Public Access 都应为 true。

如果 ALG 主机在私有子网,建议配置 S3 Gateway Endpoint,让 S3 流量不经过 NAT 网关。可以先只读检查是否已经存在:

aws ec2 describe-vpc-endpoints \
  --filters Name=service-name,Values=com.amazonaws..s3 \
  --query 'VpcEndpoints[].{id:VpcEndpointId,type:VpcEndpointType,rt:RouteTableIds}'

Loki 单机配置:存储、保留期和 ruler 放在一起看

先创建运行用户和数据目录。这一步会改变系统状态,但影响范围很小:

useradd --system --no-create-home --shell /sbin/nologin loki
mkdir -p /etc/loki/rules/fake /var/lib/loki
chown -R loki:loki /var/lib/loki /etc/loki/rules

下面是 /etc/loki/config.yaml 示例。存储桶名称、区域和日期都需要替换:

auth_enabled: false

server:
  http_listen_port: 3100
  grpc_listen_port: 9096
  log_level: info

common:
  instance_addr: 127.0.0.1
  path_prefix: /var/lib/loki
  replication_factor: 1
  ring:
    kvstore:
      store: inmemory
  storage:
    s3:
      region: 
      bucketnames: 

schema_config:
  configs:
    - from: "2026-11-01"
      store: tsdb
      object_store: s3
      schema: v13
      index:
        prefix: index_
        period: 24h

storage_config:
  tsdb_shipper:
    active_index_directory: /var/lib/loki/tsdb-index
    cache_location: /var/lib/loki/tsdb-cache

compactor:
  working_directory: /var/lib/loki/compactor
  compaction_interval: 10m
  retention_enabled: true
  retention_delete_delay: 2h
  delete_request_store: s3

limits_config:
  retention_period: 720h
  reject_old_samples: true
  reject_old_samples_max_age: 168h

ruler:
  storage:
    type: local
    local:
      directory: /etc/loki/rules
  rule_path: /var/lib/loki/rules-temp
  alertmanager_url: http://127.0.0.1:9093
  enable_api: true

这份配置里有几处值得重点看:

  • auth_enabled: false 时,Loki 使用固定租户 fake。所以规则文件要放在 /etc/loki/rules/fake/ 下面,不能直接放在 /etc/loki/rules/。
  • alertmanager_url 只写 Alertmanager 的根地址。Loki 会自己拼接告警接口路径。
  • schema_config 一旦写入数据就不要修改。以后要换存储结构时,新增一条 from 为未来日期的配置。直接改旧配置,可能导致历史数据查不出来。
  • 开启 retention_enabled 后,必须配置 delete_request_store,否则 Loki 3.x 会在启动校验阶段报错。

启动前,先校验配置:

loki -config.file=/etc/loki/config.yaml -verify-config
loki -config.file=/etc/loki/config.yaml -print-config-stderr 2>&1 | grep -E 'retention_period|alertmanager_url|bucketnames'

第二条命令显示的是 Loki 实际生效的配置。它可以帮你发现字段缩进错误:YAML 能解析,但字段放错层级后,Loki 可能按默认值运行。

下面是 systemd 服务文件 /etc/systemd/system/loki.service:

[Unit]
Description=Grafana Loki
After=network-online.target
Wants=network-online.target

[Service]
User=loki
Group=loki
ExecStart=/usr/local/bin/loki -config.file=/etc/loki/config.yaml
Restart=on-failure
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

启动服务并检查状态:

systemctl daemon-reload
systemctl enable --now loki
curl -s http://127.0.0.1:3100/ready
journalctl -u loki --since "10 min ago" --no-pager | grep -Ei 'error|denied|s3' | tail -20

/ready 返回 ready 才表示 Loki 已经可以接收和查询数据。

刚启动时 S3 里还没有对象,这通常正常。Loki 会先把日志保存在本地 WAL 和内存中,等 chunk 空闲或达到最大存活时间后,再上传到 S3。按默认配置,首批对象可能要等几十分钟到两小时才出现。

这也意味着本地磁盘里会有一段尚未上传的数据。实例和 EBS 卷一起丢失时,这部分日志也会丢失。这是单机方案需要接受的边界。

Alloy 采集:权限、标签和读取位置

Alloy 也建议用独立用户运行。读取 journald 需要加入 systemd-journal 组。读取 Nginx 日志可以用 ACL 授权:

useradd --system --no-create-home --shell /sbin/nologin alloy
mkdir -p /etc/alloy /var/lib/alloy/data
chown -R alloy:alloy /var/lib/alloy
setfacl -m u:alloy:rx /var/log/nginx
setfacl -d -m u:alloy:r /var/log/nginx
setfacl -m u:alloy:r /var/log/nginx/*.log
sudo -u alloy head -n 1 /var/log/nginx/access.log

最后一条命令能输出一行日志,才说明权限已经生效。setfacl -d 用于让 logrotate 新建的日志文件继承读取权限。轮转后再执行一次 sudo -u alloy head,确认新文件也能读取。

下面是 /etc/alloy/config.alloy 示例:

local.file_match "nginx" {
  path_targets = [
    {__path__ = "/var/log/nginx/access.log", job = "nginx_access", host = constants.hostname},
    {__path__ = "/var/log/nginx/error.log",  job = "nginx_error",  host = constants.hostname},
  ]
}

loki.source.file "nginx" {
  targets    = local.file_match.nginx.targets
  forward_to = [loki.process.drop_health.receiver]
}

loki.process "drop_health" {
  stage.drop {
    expression = ".*GET /healthz.*"
  }
  forward_to = [loki.write.default.receiver]
}

loki.relabel "journal" {
  forward_to = []
  rule {
    source_labels = ["__journal__systemd_unit"]
    target_label  = "unit"
  }
}

loki.source.journal "system" {
  max_age       = "12h"
  relabel_rules = loki.relabel.journal.rules
  labels        = {job = "systemd-journal", host = constants.hostname}
  forward_to    = [loki.write.default.receiver]
}

loki.write "default" {
  endpoint {
    url = "http://127.0.0.1:3100/loki/api/v1/push"
  }
}

这里把访问日志和错误日志拆成两个 job。如果把它们混在一起,后面计算 5xx 比例时,错误日志也会被算进总请求数,比例就不准了。

status、uri、trace_id 这类变化很多的字段不要做成 Loki 标签。它们会产生大量日志流,影响写入和查询。更合适的做法是在查询时再解析。

启动前先检查配置格式:

alloy fmt /etc/alloy/config.alloy

systemd 服务里的关键部分如下:

[Service]
User=alloy
Group=alloy
SupplementaryGroups=systemd-journal
ExecStart=/usr/local/bin/alloy run --server.http.listen-addr=127.0.0.1:12345 --storage.path=/var/lib/alloy/data /etc/alloy/config.alloy
Restart=on-failure

--storage.path 保存文件读取位置,也就是 positions。Alloy 重启后会从上次读到的位置继续读取。

修改组件名称要特别小心,例如把 loki.source.file "nginx" 改成其他名字。组件名变化后,Alloy 可能把它当成一个新组件,重新读取旧日志,造成重复写入。

服务启动后,验证 Alloy 是否在发送日志:

curl -s http://127.0.0.1:12345/metrics | grep -E 'loki_write_(sent|dropped)_entries_total'
journalctl -u alloy --since "10 min ago" --no-pager | grep -Ei '429|error|permission' | tail
logcli --addr=http://127.0.0.1:3100 query --since=10m --limit=3 '{job="nginx_access"}'

判断方法如下:

  • sent 持续增长,dropped 不增长,说明 Alloy 在正常发送。
  • Alloy 日志里出现 HTTP 429,通常是写入速度超过了 Loki 的租户限流。

单租户场景下,所有主机共享 fake 租户的写入额度。接入更多机器时,先接一台机器观察,再逐步扩大。必要时,在 limits_config 中小步调高 ingestion_rate_mb 和 ingestion_burst_size_mb。

告警链路:规则、投递和接收要分开验证

先配置 Alertmanager。单机部署时关闭集群端口,并只监听本机地址:

alertmanager --config.file=/etc/alertmanager/alertmanager.yml \
  --storage.path=/var/lib/alertmanager \
  --web.listen-address=127.0.0.1:9093 \
  --cluster.listen-address=

下面是 /etc/alertmanager/alertmanager.yml 示例:

route:
  receiver: default-webhook
  group_by: ['alertname', 'host']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - matchers: ['severity="critical"']
      receiver: oncall-webhook
      repeat_interval: 1h

receivers:
  - name: default-webhook
    webhook_configs:
      - url: http:///alertmanager/default
        send_resolved: true
  - name: oncall-webhook
    webhook_configs:
      - url: http:///alertmanager/oncall
        send_resolved: true

inhibit_rules:
  - source_matchers: ['severity="critical"']
    target_matchers: ['severity="warning"']
    equal: ['alertname', 'host']

修改配置后,先校验配置和路由,再热加载:

amtool check-config /etc/alertmanager/alertmanager.yml
amtool config routes test --config.file=/etc/alertmanager/alertmanager.yml severity=critical
curl -s -X POST http://127.0.0.1:9093/-/reload
curl -s http://127.0.0.1:9093/api/v2/status | jq '.config.original' | head

routes test 应该返回 oncall-webhook。如果返回的是默认接收人,说明 matchers 写错了。

热加载失败时,Alertmanager 会继续使用旧配置,并在日志中报错。所以 /-/reload 之后要查看日志或状态接口,确认新配置已经生效。

接着在 Loki 中写告警规则。规则文件路径是 /etc/loki/rules/fake/nginx.yaml:

groups:
  - name: nginx-log-alerts
    interval: 1m
    rules:
      - alert: NginxHigh5xxRatio
        expr: |
          sum by (host) (count_over_time({job="nginx_access"}
            | pattern `<_> - <_> [<_>] "<_> <_> <_>"  <_>`
            | status=~"5.." [5m]))
          /
          sum by (host) (count_over_time({job="nginx_access"}[5m])) > 0.05
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "{{ $labels.host }} 5xx 比例超过 5%"
      - alert: NginxAccessLogAbsent
        expr: absent_over_time({job="nginx_access"}[15m])
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "15 分钟没有收到 nginx 访问日志"

示例中的 5% 只是演示阈值。生产环境要按历史 5xx 比例和业务容忍度来设置。上线前,先用 logcli instant-query 单独执行分子和分母,确认数量级合理,再把表达式写进规则文件。

日志断流告警很有价值。采集端故障时,5xx 告警不会触发,因为根本没有日志参与计算,系统看起来会显得很安静。

最后做一次不影响业务的端到端演练。临时添加下面的规则组,并给它打上 severity: info 标签。演练前要确认 info 级别会发送到测试接收人:

  - name: alga-drill
    interval: 1m
    rules:
      - alert: AlgaDrill
        expr: sum(count_over_time({job="systemd-journal"} |= "ALGA_DRILL" [5m])) > 0
        labels:
          severity: info

演练过程分三段验证:

logger -t alga-drill "ALGA_DRILL $(date +%s)"
curl -s http://127.0.0.1:3100/prometheus/api/v1/rules | jq '.data.groups[].rules[] | {name,state,health,lastError}'
curl -s http://127.0.0.1:3100/metrics | grep -E 'notifications_(sent|errors|dropped)_total'
amtool alert query --alertmanager.url=http://127.0.0.1:9093 alertname=AlgaDrill

判断方法如下:

  • 规则的 health 为 ok,并在一两个评估周期内变成 firing,说明规则评估正常。
  • Loki 发送通知的计数增长,而且错误计数不增长,说明 Loki 已经把告警推给 Alertmanager。
  • amtool 能查到 AlgaDrill,并且测试接收人收到了消息,说明告警发送链路正常。

演练结束后,删除这组规则。

一次告警没响的复盘

下面用一个脱敏后的典型场景说明,时间和数值均为示例。

示例时间 14:00,团队做 5xx 演练。Grafana 中 nginx_access 的 5xx 曲线明显上升,按示例数据,比例约为 18%,持续 10 分钟。但是告警群没有收到消息,Alertmanager 页面也没有活动告警。

这次演练没有影响真实用户,但它暴露出一个风险:如果发生真实故障,值班人员不会收到通知。

第一个怀疑方向是 Alertmanager 或 Webhook。为了验证这一点,我用 amtool alert add alertname=AmPathTest severity=info --alertmanager.url=http://127.0.0.1:9093 直接向 Alertmanager 注入一条测试告警。测试接收人在一分钟内收到了消息,所以 Alertmanager 到 Webhook 这一段被排除。

第二步检查 Loki 是否加载了规则。查询 /prometheus/api/v1/rules 后,返回的 groups 是空数组。查看文件位置,规则文件被放在 /etc/loki/rules/nginx.yaml,缺少 fake/ 这一级租户目录。

把规则移动到 /etc/loki/rules/fake/ 后,规则组出现了。几分钟后,规则状态从 pending 变成 firing。

但是 Alertmanager 仍然没有收到告警。继续查看 Loki 指标,发送通知的错误计数持续增长。Loki 日志中出现发送告警失败的 404 记录。

最后检查配置,发现 alertmanager_url 写成了 http://127.0.0.1:9093/api/v2/alerts。Loki 会在这个地址后面继续拼接告警接口路径,最终请求到了一个不存在的地址。

修复步骤如下:

  • 备份配置:cp -a /etc/loki/config.yaml /etc/loki/config.yaml.bak-$(date +%F-%H%M)。
  • 把 alertmanager_url 改为根地址,然后执行 -verify-config。
  • 在低峰时段重启 Loki,并观察 /ready、Alloy 的 429 和 dropped 指标。
  • 如果 10 分钟内 Loki 不能恢复 ready,或者写入错误持续增长,就恢复备份配置并重启。

修复后再次演练,告警在 for: 5m 等待期结束后进入 Alertmanager,并发送到正确的接收人。为了防止同类问题再次发生,团队把 AlgaDrill 加入每次变更后的验收步骤,并增加了一条告警:Loki 发送告警通知的错误计数持续增长时通知值班人员。

这次复盘的结论有两点:Loki 规则目录缺少租户层级,导致规则没有加载;alertmanager_url 写了完整接口路径,导致告警投递返回 404。这两个结论分别由空规则组、通知错误计数和 404 日志支撑。

上线前收藏这份检查清单

  • 不要把 AK/SK 写进 Loki 配置:配置文件进入 Git、备份或工单后,长期凭证会扩散。务必改用实例角色。
  • 千万别漏掉 s3:DeleteObject 权限:缺少这个权限时,compactor 无法完成清理,保留期配置会失效,S3 费用会一直增加。
  • 务必确认 S3 生命周期规则只做兜底:过期天数要明显大于 retention_period。如果先删除了 chunk,查询时可能出现数据缺失。
  • 开启版本控制时,记得清理非当前版本:Loki 删除的对象会变成旧版本,存储容量不会真正下降。
  • 禁止直接修改已写入数据的 schema_config:要新增一条未来日期的配置,否则历史数据可能无法读取。
  • 不要把高基数字段做成标签:日志流数量暴涨后,写入、查询和内存都会受影响。
  • 务必把规则放进 fake/ 目录:在 auth_enabled: false 时,放错层级不会明显报错,规则只是不会加载。
  • alertmanager_url 只写根地址:写完整接口路径会导致路径重复,最终返回 404。
  • 不要只配置阈值告警:采集端中断时阈值告警不会触发,还需要配置 absent_over_time 断流告警。
  • 不要把 Loki 3100 端口暴露到公网:关闭认证后,任何能访问该端口的人都可以写入或查询日志。务必用安全组限制来源网段。
  • 务必锁定 Grafana 版本:执行 dnf versionlock add grafana,避免日常 dnf update 带来未经验证的大版本升级。

Grafana 使用 dnf 安装时,先按官方方式配置 rpm.grafana.com 仓库和 GPG key,再执行 dnf install grafana 和 systemctl enable --now grafana-server。数据源建议通过 /etc/grafana/provisioning/datasources/ 管理:Loki 数据源指向 http://127.0.0.1:3100,Alertmanager 数据源的 implementation 设置为 prometheus。这样重建 Grafana 时,可以减少手工配置。

这套 ALGA 方案的关键,是把每一段链路分别验证清楚:S3 能写入也能删除,Alloy 能读取文件并记住读取位置,Loki 能加载并评估规则,Alertmanager 能把告警发送给正确的人。

今天就可以做一件事:在你的环境里写入一条 logger -t alga-drill "ALGA_DRILL",从日志一路检查到告警消息。只有完成整条链路演练,日志平台上线才算真正收尾。

如果这篇文章对你有帮助,欢迎关注、点赞、点“在看”,也欢迎转发给正在搭日志平台的同事。

公众号:耕云躬行录

个人博客:躬行笔记

参与讨论