Loki 日志聚合:AI 服务多副本日志不能散在各节点
一、背景与问题
AI 推理服务通常以多副本部署在 K8s 上,每个 Pod 的日志落在节点本地。当排查一次推理失败时,你需要:先找到请求路由到哪个 Pod(需要 Service 日志),再登录对应节点看 Pod 日志(需要节点访问权限),还要从数 GB 的日志里 grep 出对应 request_id(需要时间和耐心)。
这个过程在单副本时勉强可行,在 10+ 副本时不可接受。日志散在各节点不是"运维不方便"的问题,是故障响应时间的直接拖累。Loki 的定位就是解决这个问题:把分散在各节点的日志集中到一个可检索的存储后端,用 label 而非全文索引实现高效查询。
和 Elasticsearch 相比,Loki 不建全文索引,只索引 label。这意味着写入成本低(适合日志量大的推理服务),查询时依赖 label 过滤再做 grep(适合已知 Pod/Service 的定向排查)。这个取舍是合理的——日志排查 90% 的场景是"某个服务的某个 Pod 在某个时间段出了什么问题",不是全文搜索。
二、架构设计
flowchart TB subgraph K8s Cluster A1[Pod: inference-1] --> P1[Promtail] A2[Pod: inference-2] --> P2[Promtail] A3[Pod: inference-3] --> P3[Promtail] P1 --> L[Loki Distributor] P2 --> L P3 --> L end L --> I[Ingester] I --> S[Storage: OSS/S3] G[Grafana] --> Q[Loki Querier] Q --> S Q --> I style L fill:#ff6f00,color:#fff style I fill:#e65100,color:#fff style S fill:#bf360c,color:#fff style G fill:#ff8f00核心组件职责:
| 组件 | 职责 | 关键配置 |
|---|---|---|
| Promtail | 采集 Pod 日志,追加 K8s label | pipeline_stages定义日志解析规则 |
| Distributor | 接收日志流,按 label hash 分片 | ring配置决定分片策略 |
| Ingester | 写入 chunk,压缩后flush 到存储 | chunk_target_size控制压缩粒度 |
| Querier | 执行 LogQL 查询,先按 label 过滤再 grep | 并发度和缓存配置 |
| Storage | 长期存储(S3/OSS/本地磁盘) | boltdb-shipper或tsdb-index |
推理服务的日志特征:不是海量(通常几百 MB/天),但每次排查都很急。Loki 的配置要偏向查询性能而非写入吞吐。
三、实现细节
3.1 Promtail 日志采集与 Label 注入
Promtail 的核心价值不只是采集日志,更是在采集时注入 K8s label。这样在 Loki 中查询时,可以用{app="inference-service", namespace="ai"}直接定位目标 Pod,不需要全文搜索。
# promtail-config.yaml scrape_configs: - job_name: kubernetes-pods kubernetes_sd_configs: - role: pod pipeline_stages: - cri: {} # CRI 日志格式解析 - labels: model_name: source: labels label: model_name # 从 Pod label 提取 pod_name: source: labels label: __pod_name__ - timestamp: source: time format: RFC3339 - output: source: message关键点:model_namelabel 从 Pod 的 K8s label 提取。推理服务的 Pod 通常标注了模型名称(如model_name: llama-3-8b),这个 label 直接注入 Loki,查询时可以按模型过滤。
3.2 LogQL 查询实战
排查推理失败的核心查询模式:
# 查某模型最近1小时的错误日志 {app="inference-service", model_name="llama-3-8b"} |~ "error|ERROR|failed" | json | line_format "{{.timestamp}} {{.request_id}} {{.error_msg}}" # 查某个 request_id 的完整链路日志 {app=~"inference-service|gateway"} | json | search request_id="req-abc123" | line_format "{{.timestamp}} {{.level}} {{.message}}" # 统计各模型的错误率 sum(count_over_time({app="inference-service"} |~ "error"[5m])) by (model_name) / sum(count_over_time({app="inference-service"}[5m])) by (model_name)第一个查询用|~正则过滤 +| json解析结构化日志;第二个跨多个服务追踪 request_id;第三个做错误率统计。三个模式覆盖了日志排查 90% 的场景。
3.3 日志格式标准化
推理服务的日志必须是结构化的(JSON),否则 Promtail 的| jsonstage 无法解析,查询效率大幅下降。
{ "timestamp": "2026-07-15T10:23:45.678Z", "level": "error", "request_id": "req-abc123", "model_name": "llama-3-8b", "message": "inference timeout after 30s", "gpu_id": "gpu-0", "batch_size": 4 }request_id是日志关联的关键字段。它和分布式追踪的trace-id不同——trace-id在 Span 上,request_id在日志中,两者通过 Span attribute 关联。在 Grafana 中可以从 Trace 视图跳转到 Log 视图,用trace-id查对应日志,反过来也可以。
四、生产实践与踩坑
Label 数量控制。Loki 的查询性能依赖 label 过滤,但 label 过多会导致索引膨胀。原则:label 只放能缩小查询范围的字段(app、namespace、model_name、pod_name),不放高基数字段(request_id、user_id)。高基数字段在 LogQL 的| json | search中过滤,不走索引。
chunk flush 间隔。Ingester 的chunk_idle_period默认 5 分钟。推理服务的日志突发性高(批量推理请求可能短时间内产生大量日志),把 flush 间隔缩短到 1 分钟可以减少查询时的延迟(未 flush 的 chunk 只在 Ingester 内存中,Querier 需要额外查询)。代价是更频繁的写入,但推理服务的日志量不大,这个代价可以承受。
存储后端选择。生产环境用 S3/OSS,不要用本地磁盘。原因不是性能——本地磁盘的读写延迟更低——而是可靠性。Ingester 节点重启时,内存中的 chunk 可能丢失;S3 的数据不会因为节点重启而丢失。对于日志数据,可靠性优先于性能。
日志保留策略。推理服务的日志保留 30 天足够。更老的日志诊断价值极低,保留只会增加存储成本和查询时的扫描范围。Loki 的table_config.retention_period设为 720h(30 天),配合 S3 的 lifecycle rule 自动清理过期对象。
和 Trace 的关联。Loki 和 Tempo(或 Jaeger)可以通过trace-id互相跳转。在日志中输出trace_id字段,Grafana 会自动识别并生成跳转链接。这个关联不是可选功能,是必需——日志看内容,追踪看链路,两者配合才是完整排查。
五、总结
多副本推理服务的日志散在各节点,排查时需要逐节点登录 grep,这个流程在故障响应时不可接受。Loki 用 label 过滤 + grep 的模式,在不建全文索引的前提下实现了集中检索,写入成本低、查询定向快。
三个关键实践:
- Label 设计:低基数过滤字段做 label,高基数字段在 pipeline stage 中解析。这是 Loki 查询性能的基础。
- 日志必须结构化:JSON 格式让 Promtail 的
| jsonstage 能解析,否则查询退化为纯文本 grep。 - 和 Trace 体系关联:日志中输出
trace_id,在 Grafana 中实现 Trace→Log 双向跳转,内容+链路配合排查。
基础设施不需要漂亮话。日志聚合的价值不是集中存储本身,是故障发生时从"登录节点 grep"变成"一条 LogQL 查完",响应时间从 30 分钟变成 30 秒。