// ============================================================================= // Grafana Alloy 采集配置 —— 只采集 zt-closet-backend 的本地日志文件 // // ⚠️ 本文件有两种「花括号」,语法规则相反,混用会直接启动失败: // 1) 块(block) endpoint { } / stage.json { } / logging { } // → 参数之间用【换行】分隔,【不能加逗号】 // 加了会报:Argument definitions must be separated by newlines, not commas // 2) 对象字面量(= 号右边的表达式) // values = { } / expressions = { } / external_labels = { } / path_targets 里的 { } // → 字段之间【必须用逗号】分隔,否则报:missing ',' in field list // 判断方法:`名字 {` 后面直接换行 = 块;`名字 = {` 是对象字面量。 // 改动本文件时务必按此规则,否则 Alloy 起不来。 // // 部署方式:Alloy 以 systemd 服务运行(不是 Docker) // 配置路径 /etc/alloy/config.alloy (把本文件拷到这里) // 存储路径 --storage.path=/var/lib/alloy/data(见 systemctl cat alloy) // // 采集对象:lumberjack 滚动写出的 JSON 日志(见 main.go setupSlogLogger) // 宿主路径 /opt/zt-closet/storage/logs/app.log // 容器/服务内读取路径由你挂载或直接指向该目录 // 归档 app-*.log.gz(Compress: true,默认不采集,见 loki.source.file 注释) // 实测格式 {"time":"2026-09-09T01:52:52.772742663+08:00","level":"INFO", // "source":{"function":"...","file":"...","line":44}, // "msg":"GIN_REQUEST","request_id":"...","method":"GET","path":"/pods", // "query":"","status":404,"ip":"...","latency":"4.559µs","user_agent":""} // ============================================================================= // 若 Alloy 与应用不在同一文件系统视图下(比如 Alloy 装在别的主机/容器), // 请把下面 local.file_match 里的 __path__ 改成实际可见的路径。 // ----------------------------------------------------------------------------- // logging —— Alloy 自身的运行日志(不是被采集的业务日志) // ----------------------------------------------------------------------------- logging { level = "info" // Alloy 自身日志级别:debug / info / warn / error,排查采集问题时改 debug format = "logfmt" // Alloy 自身日志格式,logfmt 便于人读 } // ----------------------------------------------------------------------------- // local.file_match —— 「发现」要采集哪些文件,并给它们打上静态标签 // 只负责找文件、产出 target 列表,不读内容。 // ----------------------------------------------------------------------------- local.file_match "app_logs" { // ↓ 这是一个「对象字面量」,字段之间必须用逗号分隔 path_targets = [{ // ⚠️ 必须限定为 app*.log,不能用 *.log: // 同目录下还有 classification.log(Python 服务输出,是 // "INFO:__main__:..." 纯文本 + tqdm 进度条,不是 JSON)。 // 用 *.log 会把它一起采走,解析不出任何字段,还会混入 \r 进度条垃圾。 __path__ = "/opt/zt-closet/storage/logs/app*.log", // 以下三个是自定义标签,会附加到每一条日志上(低基数,可以安全做标签)。 job = "zt-closet-backend", // 采集任务名,多服务时用于区分 service_name = "zt-closet-backend", // 服务名,推荐保留(Loki 生态的通用标签名) env = "prod", // 环境标识,prod / test / dev }] // 每隔多久重新扫描一次目录,发现新建/轮转出来的新文件。 // 该值就是官方默认值,保留只为显式可见。 sync_period = "10s" } // ----------------------------------------------------------------------------- // loki.source.file —— 真正「读」文件内容,按行产出日志 // ----------------------------------------------------------------------------- loki.source.file "app_logs" { // 上游 local.file_match 发现的文件列表。 targets = local.file_match.app_logs.targets // 下游处理管道;解析 / 打标签 / 降噪都在 loki.process 里做。 forward_to = [loki.process.app_logs.receiver] // 从文件末尾开始读:首次部署时不重放历史日志, // 避免把几十 MB 旧日志一次性灌进 Loki。false 则会从头回放。 tail_from_end = true // 读取位置(offset)记录在 --storage.path 指定的目录,服务重启后接着读、不重复。 // 该目录必须持久化(systemd 下通常是 /var/lib/alloy/data,属主为 alloy 用户), // 否则 offset 丢失,配合 tail_from_end=true 会直接跳过一段日志。 // ↓↓↓ 可选:需要采集 lumberjack 压缩归档的 app-*.log.gz 时启用 // 注意:官方明确说明 tail_from_end 与 decompression 不可同时使用, // 启用下面这段时需要把上面的 tail_from_end 删掉。 // decompression { // enabled = true // 注意:块参数只用换行分隔,不要加逗号 // initial_delay = "10s" // 文件写入后延迟多久开始解压读取,避免读到半截文件 // format = "gz" // 合法值只有 gz / z / bz2 // } } // ----------------------------------------------------------------------------- // loki.process —— 日志处理管道:把 JSON 拆成字段,决定哪些做标签、哪些做元数据 // 这是整个配置里唯一需要按业务调整的地方。 // ----------------------------------------------------------------------------- loki.process "app_logs" { // ---- 第一步:解析 JSON:把一行 JSON 的每个 key 提取成可用字段 ---- // 左侧是管道内的字段名,右侧是 JMESPath 表达式(点号表示取嵌套字段)。 stage.json { // ↓ 对象字面量:字段之间必须用逗号 expressions = { time = "time", // 日志自身时间戳(RFC3339Nano),供 stage.timestamp 使用 level = "level", // 日志级别:DEBUG/INFO/WARN/ERROR msg = "msg", // 日志消息,如 GIN_REQUEST trace_id = "trace_id", // 链路 ID,仅 slog.XxxContext(ctx,...) 且 ctx 内有 span 时存在 span_id = "span_id", // span ID,同上 request_id = "request_id", // 单请求 ID,见 middleware/logger.go method = "method", // HTTP 方法 path = "path", // ⚠️ 高基数字段:打的是 c.Request.URL.Path 原始路径, // 不是 gin 路由模板,含 UUID 等动态段, // 所以只能做元数据,绝不能提升为标签 status = "status", // HTTP 状态码 ip = "ip", // 客户端 IP latency = "latency", // 处理耗时,字符串如 "4.559µs"(非数字,不能直接聚合) source_file = "source.file", // 因为 AddSource:true,日志自带 文件:行号 source_line = "source.line", // ↑ 同上,定位代码位置用 } } // ---- 第二步:用日志自身的时间戳,而不是 Alloy 读到它的时间 ---- // 不加这个 stage,Loki 会用「采集时刻」当作日志时间:一旦 Alloy 重启、 // 限流重试或追赶积压,历史日志会被写成"现在",时间范围查询与 // count_over_time 告警全部错位,且没有任何报错提示。 // format 用 RFC3339Nano 精确匹配实测的 "2026-09-09T01:52:52.772742663+08:00"。 // 注意:若配合 decompression 回放超过 14 天的归档,Loki 会以 // "entry too far behind" 拒收整批(reject_old_samples_max_age 默认 14d)。 // ⚠️ 注意这是「块」不是对象字面量,参数之间【只能用换行分隔,禁止加逗号】 stage.timestamp { source = "time" format = "RFC3339Nano" } // ---- 第三步:把 level / status 提升为「索引标签」---- // 标签参与索引、筛选最快,但每个不同的标签组合都会形成一个独立 stream。 // 这里 level(4 种) × status(约 8 种) ≈ 32 个 stream,量级很小可以接受; // 注意 path 是高基数字段,绝不能加进来。 stage.labels { values = { level = "", // 空字符串表示「沿用同名字段」,等价于 level = "level" status = "", } } // ---- 第四步:其余字段降级为「结构化元数据」(structured metadata) ---- // 元数据不建索引、不加剧 stream 基数,但 LogQL 里照样能用 // | trace_id="xxx" 过滤,也能点击跳转 Tempo 查链路。 // // ⚠️ 启用前提(缺一不可,否则 distributor 直接返回 400 拒收整批,外部 // 表现为「Alloy 不停重试、Loki 里一条日志都没有」,很难定位): // 1) Loki ≥ 3.0(2.9 起为实验特性) // 2) schema 版本 ≥ v13,且索引类型必须为 tsdb(不支持已废弃的 boltdb-shipper) // 3) limits_config.allow_structured_metadata: true // 确认不了就先整段注释掉:其余配置照常工作,只是查询时少了这些字段。 // 另外元数据上限为每行 64KB / 128 条,这里 9 个字段远未触及。 stage.structured_metadata { values = { trace_id = "", span_id = "", request_id = "", // 按 request_id 串起一次请求的全部日志 method = "", path = "", // 高基数字段放这里最合适:可过滤,但不产生新 stream ip = "", latency = "", source_file = "", // 出错时直接看是哪个文件哪一行打的 source_line = "", // 注意:status 已在上一 stage 提升为标签,此处不再重复设置, // 避免同一字段既是标签又是元数据导致语义混淆 } } // ---- 第五步(当前已启用):丢弃 Docker HEALTHCHECK 产生的探针噪音 ---- // 若服务以 systemd 直接运行而非 Docker,没有 HEALTHCHECK,这段可删除。 // 需要保留这层可用性信号时,也把本段整段删除即可。 // // 说明:stage.drop 的 source 只能引用「extracted map」中的字段 // (官方文档明确限定为 extracted data,且该 stage 没有 source_type 参数, // 读不到标签、也读不到 structured metadata)。path 正是第一步 // stage.json 提取出来的,因此这里可用。多个条件之间是 AND 关系。 // 丢弃计数见 Alloy UI:loki_process_dropped_lines_total{reason="healthcheck"}。 // ⚠️ 同样这是「块」,参数之间只用换行,禁止加逗号 stage.drop { source = "path" // 引用 stage.json 提取出的字段 value = "/ping" // 与 source 值完全相等时丢弃 drop_counter_reason = "healthcheck" } // 处理结果推给出口。 forward_to = [loki.write.default.receiver] } // ----------------------------------------------------------------------------- // loki.write —— 出口:把日志批量推送到 Loki 的 push 接口 // // 层级注意:顶层只有 external_labels / max_streams; // url、batch_*、*_backoff_*、remote_timeout、tenant_id、basic_auth 等 // 都必须写在 endpoint 块内。写到顶层 Alloy 启动即报 unrecognized attribute。 // ----------------------------------------------------------------------------- loki.write "default" { endpoint { // Loki 写入地址。 url = "http://119.45.6.212:3100/loki/api/v1/push" // ---- 批量:攒批降低请求数 ---- batch_wait = "60s" // 最多攒 60s 才发:请求数最省,但日志最多延迟 1 分钟才可见; // 真实延迟 = min(batch_wait, 攒满 batch_size)。 // 需要近实时排障就调到 1s~5s。 batch_size = "1MiB" // 攒够 1MiB 立即发,与 batch_wait 谁先满足用谁 // 请求超时,默认 10s;跨公网写入或 Loki 较慢时可放大。 remote_timeout = "10s" // ---- 重试:Loki 短暂不可用时退避重试,避免丢日志 ---- // 必须保证 min < max:退避实现中一旦 nextDelayMin >= nextDelayMax // 就直接返回 MinBackoff 且永不增长(max 被静默忽略)。 // ⚠️ endpoint 是「块」,参数只用换行分隔,禁止加逗号 min_backoff_period = "500ms" // 退避起点,官方默认 500ms max_backoff_period = "5m" // 退避上限,官方默认 5m max_backoff_retries = 10 // 重试 10 次后仍失败则丢弃该批 // ⚠️ 单位陷阱:Go duration 中 m=分钟、ms=毫秒, // "500m" 是 500 分钟(8h20m),"500ms" 才是 500 毫秒。 // // 提示:退避不是固定倍数,而是在 [min, max) 区间取随机抖动值、区间再翻倍。 // 以 min=500ms / retries=10 为例,实际发生的 9 次等待区间依次为 // [0.5s,1s) → [1s,2s) → … → [128s,256s),最大也只到 256s, // 即使把 max 设成 5m 或 500m 也不会触及上限。真正决定「最多重试多久」 // 的是 max_backoff_retries。 // Loki 开启多租户鉴权时才需要: // tenant_id = "zt-closet" // basic_auth { // username = "zt-closet" // password = sys.env("LOKI_PASSWORD") // 密钥只从环境变量读,不要写进本文件 // } } // 顶层参数:附加到所有日志的全局标签,便于区分集群/机房。 external_labels = { cluster = "prod", } }