---
title: 全栈监控:Prometheus + Grafana + Loki + Tempo + Bark
url: https://doc.liz6.com/homelab/monitoring
locale: zh
area: homelab
tags:
- homelab
date: 2026-07-01
modified: 2026-07-11
description: 指标(Prometheus)+ 日志(Loki)+ Trace(Tempo)三条线汇入 Grafana,告警经 Bark 推送到 iPhone。指标分 pull(局域网 node_exporter)和 push(远程 otel-collector fleet agent,经 rathole 隧道 + mTLS)两套;…
---

# 全栈监控:Prometheus + Grafana + Loki + Tempo + Bark

> 指标(Prometheus)+ 日志(Loki)+ Trace(Tempo)三条线汇入 Grafana,告警经 Bark 推送到 iPhone。指标分 pull(局域网 node_exporter)和 push(远程 otel-collector fleet agent,经 rathole 隧道 + mTLS)两套;日志走 journald → otelcol-logs → Loki;Trace 走 otelcol-home tail sampling → Tempo。以本机 li-home-0 为实例,覆盖全栈架构、告警规则、采集对比、标签方案、级别判定与踩坑记录。

## 一、总览

<svg viewBox="0 0 780 360" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,'Source Han Sans CN','Microsoft YaHei',sans-serif" role="img" aria-label="监控架构总览">
  <defs><marker id="mah" markerWidth="10" markerHeight="8" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#64748b"/></marker></defs>
  <rect width="780" height="360" fill="#ffffff"/>
  <text x="390" y="28" text-anchor="middle" font-size="17" font-weight="700" fill="#1f2933">监控架构:指标+日志+Trace → Grafana,告警 → Bark → iPhone</text>
  <!-- Remote fleet -->
  <rect x="20" y="50" width="130" height="90" rx="8" fill="#fef3c7" stroke="#f59e0b"/>
  <text x="85" y="72" text-anchor="middle" font-size="10" font-weight="700" fill="#b45309">远程 fleet</text>
  <text x="85" y="88" text-anchor="middle" font-size="9" fill="#d97706">otel-collector</text>
  <text x="85" y="102" text-anchor="middle" font-size="9" fill="#d97706">host_metrics 30s</text>
  <text x="85" y="116" text-anchor="middle" font-size="9" fill="#d97706">file_storage 队列</text>
  <text x="85" y="130" text-anchor="middle" font-size="8" fill="#b45309">mTLS 客户端证书</text>
  <line x1="150" y1="98" x2="200" y2="72" stroke="#f59e0b" stroke-width="1.3" marker-end="url(#mah)"/>
  <!-- rathole + Caddy -->
  <rect x="204" y="44" width="110" height="102" rx="6" fill="#e2e8f0" stroke="#94a3b8"/>
  <text x="259" y="68" text-anchor="middle" font-size="10" font-weight="700" fill="#475569">rathole</text>
  <text x="259" y="84" text-anchor="middle" font-size="9" fill="#64748b">TCP 隧道</text>
  <text x="259" y="100" text-anchor="middle" font-size="9" fill="#64748b">↓ Caddy</text>
  <text x="259" y="116" text-anchor="middle" font-size="9" fill="#64748b">mTLS 终止</text>
  <text x="259" y="132" text-anchor="middle" font-size="9" fill="#64748b">路径限定+吊销</text>
  <line x1="314" y1="95" x2="356" y2="95" stroke="#475569" stroke-width="1.3" marker-end="url(#mah)"/>
  <!-- otelcol-home -->
  <rect x="360" y="50" width="110" height="90" rx="8" fill="#e0e7ff" stroke="#818cf8"/>
  <text x="415" y="72" text-anchor="middle" font-size="10" font-weight="700" fill="#3730a3">otelcol-home</text>
  <text x="415" y="88" text-anchor="middle" font-size="9" fill="#4f46e5">OTLP 接收</text>
  <text x="415" y="102" text-anchor="middle" font-size="9" fill="#4f46e5">metrics pipeline</text>
  <text x="415" y="116" text-anchor="middle" font-size="9" fill="#4f46e5">traces pipeline</text>
  <text x="415" y="130" text-anchor="middle" font-size="9" fill="#4f46e5">tail sampling</text>
  <!-- metrics→Prom -->
  <line x1="470" y1="80" x2="532" y2="80" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#mah)"/>
  <!-- traces→Tempo -->
  <line x1="470" y1="118" x2="532" y2="118" stroke="#8b5cf6" stroke-width="1.5" marker-end="url(#mah)"/>
  <rect x="536" y="56" width="90" height="24" rx="6" fill="#dbeafe" stroke="#3b82f6"/>
  <text x="581" y="72" text-anchor="middle" font-size="10" font-weight="700" fill="#1e40af">Prometheus</text>
  <rect x="536" y="106" width="90" height="24" rx="6" fill="#ede9fe" stroke="#a78bfa"/>
  <text x="581" y="122" text-anchor="middle" font-size="10" font-weight="700" fill="#6d28d9">Tempo :4317</text>
  <!-- Local pull -->
  <rect x="646" y="56" width="116" height="22" rx="4" fill="#f1f5f9"/>
  <text x="704" y="71" text-anchor="middle" font-size="9" fill="#64748b">node_exporter (pull)</text>
  <line x1="646" y1="67" x2="630" y2="67" stroke="#94a3b8" stroke-width="1" stroke-dasharray="2 4"/>
  <!-- Logs row -->
  <rect x="20" y="176" width="82" height="24" rx="5" fill="#fce7f3"/><text x="61" y="192" text-anchor="middle" font-size="9" font-weight="700" fill="#9d174d">journald</text>
  <line x1="102" y1="188" x2="144" y2="188" stroke="#ec4899" stroke-width="1.3" marker-end="url(#mah)"/>
  <rect x="148" y="176" width="82" height="24" rx="5" fill="#fce7f3"/><text x="189" y="192" text-anchor="middle" font-size="9" font-weight="700" fill="#9d174d">otelcol-logs</text>
  <line x1="230" y1="188" x2="278" y2="188" stroke="#ec4899" stroke-width="1.3" marker-end="url(#mah)"/>
  <rect x="282" y="176" width="70" height="24" rx="5" fill="#fce7f3"/><text x="317" y="192" text-anchor="middle" font-size="9" font-weight="700" fill="#9d174d">Loki :3100</text>
  <!-- Grafana -->
  <rect x="440" y="166" width="110" height="44" rx="8" fill="#dcfce7" stroke="#22c55e"/>
  <text x="495" y="186" text-anchor="middle" font-size="11" font-weight="700" fill="#166534">Grafana</text>
  <text x="495" y="201" text-anchor="middle" font-size="9" fill="#15803d">指标 · 日志 · Trace</text>
  <!-- Bark -->
  <rect x="580" y="166" width="80" height="44" rx="8" fill="#fff7ed" stroke="#f97316"/>
  <text x="620" y="186" text-anchor="middle" font-size="11" font-weight="700" fill="#c2410c">Bark</text>
  <text x="620" y="201" text-anchor="middle" font-size="9" fill="#ea580c">APNs → iPhone</text>
  <!-- arrows to Grafana -->
  <line x1="626" y1="69" x2="545" y2="180" stroke="#94a3b8" stroke-width="1" stroke-dasharray="2 4"/>
  <line x1="626" y1="118" x2="545" y2="183" stroke="#94a3b8" stroke-width="1" stroke-dasharray="2 4"/>
  <line x1="352" y1="188" x2="436" y2="188" stroke="#94a3b8" stroke-width="1" stroke-dasharray="2 4"/>
  <!-- bottom notes -->
  <rect x="20" y="228" width="740" height="70" rx="8" fill="#f8fafc" stroke="#e2e8f0"/>
  <text x="40" y="250" font-size="10" fill="#475569">metrics-fleet:远程 otel-collector(fleet agent)→ OTLP+mTLS → rathole隧道 → Caddy网关 → otelcol-home(metrics+trace pipeline)</text>
  <text x="40" y="268" font-size="10" fill="#475569">本地:node_exporter → Prometheus(pull) · journald → otelcol-logs → Loki · otelcol-home traces → Tempo(gRPC,720h保留)</text>
  <text x="40" y="286" font-size="10" fill="#475569">Grafana统一查询指标(Prometheus)+日志(Loki)+Trace(Tempo) · 告警经 Bark webhook → APNs → iPhone(本地 bark-server,不依赖隧道/公网)</text>
  <rect x="20" y="310" width="740" height="22" rx="5" fill="#f1f5f9"/>
  <text x="390" y="326" text-anchor="middle" font-size="9" fill="#94a3b8">所有组件 native systemd · bind 127.0.0.1 · Docker仅跑应用</text>
</svg>

## 二、指标采集:两套模型

| | 传统 pull (node_exporter) | 远程 push (metrics-fleet) |
|---|---|---|
| 适用 | 局域网节点、本机 | 公网远程服务器 |
| 采集器 | node_exporter / LHM exporter | otel-collector-contrib (host_metrics) |
| 指标命名 | `node_*`, `windows_*`, `lhm_*` | `system_cpu_*`, `system_memory_*` |
| 网络 | Prometheus 主动拉,需可达端口 | 远程主动推,零入站端口 |
| 传输安全 | 无 (LAN 内裸奔) | mTLS + 路径限定(只放行 OTLP 写入) |
| 接入方式 | 手动改 prometheus.yml static_configs | `add-server.sh <ssh别名>` |
| Dashboard | 每台一个独立 JSON | 单个模板,`$host` 下拉切换 |
| 中转依赖 | 无 | rathole 隧道 → 中转 VPS → 家 |

### Pull 模型:scrape_configs

Prometheus 每 15s 拉取这些目标:本机 node_exporter(:9100)、llama-server(:18080)、Home Assistant(:8123)、Windows 机(9182/9183)、N100 旁路由(:9100)、小米路由器(:9184)、自监控(:9090)。

### Push 模型:OTLP 接收

远程服务器通过 otel-collector 采集 host_metrics,经 mTLS 认证 + rathole 加密隧道,推送到家里 Prometheus 的 remote write 端点:

<svg viewBox="0 0 720 330" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,'Source Han Sans CN','Microsoft YaHei',sans-serif" role="img" aria-label="Push 模型:远程指标经 mTLS 与 rathole 隧道推送到家">
  <defs><marker id="oah" markerWidth="10" markerHeight="8" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#475569"/></marker></defs>
  <rect width="720" height="330" fill="#ffffff"/>
  <text x="360" y="28" text-anchor="middle" font-size="17" font-weight="700" fill="#1f2933">Push 模型:远程指标经 mTLS + rathole 隧道推送到家</text>
  <!-- Row 1 -->
  <rect x="20" y="60" width="200" height="70" rx="8" fill="#fef3c7" stroke="#f59e0b"/>
  <text x="120" y="88" text-anchor="middle" font-size="11" font-weight="700" fill="#b45309">远程 otel-collector</text>
  <text x="120" y="106" text-anchor="middle" font-size="9" fill="#d97706">host_metrics 采集</text>
  <line x1="220" y1="95" x2="260" y2="95" stroke="#475569" stroke-width="1.6" marker-end="url(#oah)"/>
  <text x="240" y="85" text-anchor="middle" font-size="8" fill="#475569">OTLP+mTLS证书</text>
  <rect x="260" y="60" width="200" height="70" rx="8" fill="#e2e8f0" stroke="#94a3b8"/>
  <text x="360" y="86" text-anchor="middle" font-size="11" font-weight="700" fill="#475569">公网入口</text>
  <text x="360" y="103" text-anchor="middle" font-size="9" fill="#64748b">otlp.liz6.com:9443</text>
  <text x="360" y="117" text-anchor="middle" font-size="9" fill="#64748b">DNS → 中转 VPS</text>
  <line x1="460" y1="95" x2="500" y2="95" stroke="#475569" stroke-width="1.6" marker-end="url(#oah)"/>
  <rect x="500" y="60" width="200" height="70" rx="8" fill="#e2e8f0" stroke="#94a3b8"/>
  <text x="600" y="88" text-anchor="middle" font-size="11" font-weight="700" fill="#475569">rathole 隧道</text>
  <text x="600" y="106" text-anchor="middle" font-size="9" fill="#64748b">TCP 透传 · 只见密文</text>
  <!-- connector row1 -> row2 -->
  <path d="M600,130 L600,152 L130,152 L130,170" fill="none" stroke="#475569" stroke-width="1.6" marker-end="url(#oah)"/>
  <!-- Row 2 -->
  <rect x="20" y="170" width="220" height="70" rx="8" fill="#e2e8f0" stroke="#94a3b8"/>
  <text x="130" y="196" text-anchor="middle" font-size="11" font-weight="700" fill="#475569">家 Caddy mTLS 网关</text>
  <text x="130" y="213" text-anchor="middle" font-size="9" fill="#64748b">验证书 + 吊销检查</text>
  <text x="130" y="227" text-anchor="middle" font-size="9" fill="#64748b">只放行 /api/v1/otlp/*</text>
  <line x1="240" y1="205" x2="280" y2="205" stroke="#475569" stroke-width="1.6" marker-end="url(#oah)"/>
  <rect x="280" y="170" width="210" height="70" rx="8" fill="#e0e7ff" stroke="#818cf8"/>
  <text x="385" y="196" text-anchor="middle" font-size="11" font-weight="700" fill="#3730a3">otelcol-home</text>
  <text x="385" y="213" text-anchor="middle" font-size="9" fill="#4f46e5">:4318</text>
  <text x="385" y="227" text-anchor="middle" font-size="9" fill="#4f46e5">batch 处理</text>
  <line x1="490" y1="205" x2="530" y2="205" stroke="#475569" stroke-width="1.6" marker-end="url(#oah)"/>
  <rect x="530" y="170" width="170" height="70" rx="8" fill="#dbeafe" stroke="#3b82f6"/>
  <text x="615" y="196" text-anchor="middle" font-size="11" font-weight="700" fill="#1e40af">Prometheus</text>
  <text x="615" y="213" text-anchor="middle" font-size="9" fill="#1e40af">remote write</text>
  <text x="615" y="227" text-anchor="middle" font-size="9" fill="#1e40af">:9090</text>
  <!-- bottom note -->
  <rect x="20" y="258" width="680" height="50" rx="8" fill="#f8fafc" stroke="#e2e8f0"/>
  <text x="40" y="278" font-size="10" fill="#475569">mTLS 只在 Caddy 终止,rathole 全程只透传密文,自己看不见明文流量。</text>
  <text x="40" y="294" font-size="10" fill="#475569">Prometheus 配置 out_of_order_time_window=30m,兼容跨公网到达顺序抖动。</text>
</svg>

> Prometheus 配置 `out_of_order_time_window: 30m` 兼容跨公网到达顺序抖动。

### Grafana Dashboards

Provisioning 目录:7 个主机面板(CPU / GPU / 内存 / 磁盘 / 散热,按节点分 JSON)+ 1 个服务器状态模板(`$host` 下拉自动发现所有 OTLP 推送来的 host_name)+ 网络监控面板。

面板表达式例:
```promql
# CPU: 100*(1-avg by(host_name)(system_cpu_utilization_ratio{host_name="$host",state="idle"}))
# 内存: 100*system_memory_utilization_ratio{host_name="$host",state="used"}
# 存活检测(push 模型无 up 指标,用最近上报时间替代):
time()-max(timestamp(system_memory_usage_bytes{host_name="$host"}))
```

## 三、安全:CA 与 mTLS

远程 push 链路的安全由私有 CA 签发客户端证书保证:

```
~/infra/metrics-fleet/ca/
├── make-ca.sh              # 一次性创建 CA (EC P-256, 10年)
├── issue-server-cert.sh    # 签服务端证书 → /etc/caddy/metrics-fleet/
├── issue-client-cert.sh    # 签客户端证书 → out/clients/<name>/
├── revoke-client-cert.sh   # 吊销 + 更新 Caddy revoked.caddy + reload
└── out/clients/<name>/     # 已签发的客户端证书(每远程机一份)
```

Caddy 在 127.0.0.1:9443 做 mTLS 网关:验客户端证书(require_and_verify,trust CA)、查吊销名单 → 403、只放行 `/api/v1/otlp/*` 并 strip_prefix → otelcol-home :4318。

> 每台远程服务器的公网 IP 需加到本机 + N100 两边 mihomo 的 DIRECT 规则,确保 rathole 承载连接和 SSH 不走代理(承载连接必须走原生路由——见 [rathole-tunnel.md](rathole-tunnel.md) 4.3 节)。

## 四、告警:Grafana + Bark 推送到 iPhone

Prometheus 负责采集,Grafana 负责判定和通知。告警链路:

<svg viewBox="0 0 720 200" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,'Source Han Sans CN','Microsoft YaHei',sans-serif" role="img" aria-label="告警链路:Grafana 规则评估经本地 bark-server 推送到 iPhone">
  <defs><marker id="bah" markerWidth="10" markerHeight="8" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#475569"/></marker></defs>
  <rect width="720" height="200" fill="#ffffff"/>
  <text x="360" y="28" text-anchor="middle" font-size="17" font-weight="700" fill="#1f2933">告警链路:Grafana 规则评估 → bark-server(本地) → APNs → iPhone</text>
  <rect x="20" y="60" width="190" height="70" rx="8" fill="#dcfce7" stroke="#22c55e"/>
  <text x="115" y="92" text-anchor="middle" font-size="12" font-weight="700" fill="#166534">Grafana 规则评估</text>
  <text x="115" y="112" text-anchor="middle" font-size="10" fill="#15803d">每 30s 评估一次</text>
  <line x1="210" y1="95" x2="250" y2="95" stroke="#475569" stroke-width="1.6" marker-end="url(#bah)"/>
  <text x="230" y="85" text-anchor="middle" font-size="9" fill="#475569">webhook</text>
  <rect x="250" y="60" width="230" height="70" rx="8" fill="#fff7ed" stroke="#f97316"/>
  <text x="365" y="88" text-anchor="middle" font-size="12" font-weight="700" fill="#c2410c">bark-server</text>
  <text x="365" y="104" text-anchor="middle" font-size="10" fill="#ea580c">127.0.0.1:8085</text>
  <text x="365" y="119" text-anchor="middle" font-size="9" fill="#ea580c">本地直连,不经隧道</text>
  <line x1="480" y1="95" x2="520" y2="95" stroke="#475569" stroke-width="1.6" marker-end="url(#bah)"/>
  <text x="500" y="85" text-anchor="middle" font-size="9" fill="#475569">APNs</text>
  <rect x="520" y="60" width="180" height="70" rx="8" fill="#ccfbf1" stroke="#99f6e4"/>
  <text x="610" y="92" text-anchor="middle" font-size="12" font-weight="700" fill="#115e59">iPhone</text>
  <text x="610" y="112" text-anchor="middle" font-size="10" fill="#0f766e">锁屏推送</text>
  <rect x="20" y="150" width="680" height="40" rx="8" fill="#f0fdfa" stroke="#99f6e4"/>
  <text x="360" y="174" text-anchor="middle" font-size="12" fill="#115e59">为什么走本地 bark-server:告警通知不能依赖隧道/公网——链路挂了时告警也发不出去,本地直连零外部依赖。</text>
</svg>

**为什么走本地 bark-server,不用公网 webhook**:如果告警通知走 rathole 隧道或公网——隧道挂了的时候告警也发不出去。本地 bark-server 直连 iPhone,零外部依赖,不跟基础设施绑定。

### 联系人与通知策略

```yaml
# /etc/grafana/provisioning/alerting/contact-points.yaml
contactPoints:
  - name: bark-home
    receivers:
      - type: webhook
        settings:
          url: "http://127.0.0.1:8085/<token>"   # 本地 bark-server,绕开隧道
          maxAlerts: 2                            # APNs 4KB,超 2 条即 PayloadTooLarge
          title: "[{{ .Status }}] {{ .CommonLabels.grafana_folder }}{{ if .CommonLabels.alertname }} · {{ .CommonLabels.alertname }}{{ end }}"
          message: "{{ range .Alerts }}{{ .Annotations.summary }}\n{{ end }}"

policies:
  - receiver: bark-home
    group_by: ["grafana_folder", "alertname"]     # 同类合并,不同类各发各
    group_wait: 30s                               # 首批延迟等同类进来
    group_interval: 5m                            # 同组后续告警间隔
    repeat_interval: 4h                           # 未恢复每 4h 重提醒
```

> `maxAlerts: 2` 是关键——bark 把整个 webhook JSON 透传给 APNs,每条 alert 约 1.1–1.4KB,超过 2 条就超 APNs 4KB 上限,bark 直接回 500。

### 告警规则(共 13 条)

| 规则 | 级别 | 条件 | 静默 |
|------|------|------|------|
| 旁路由宕机 | critical | `up{job="li-home-router"}==0` | 3m |
| 路由器不通 | critical | `ping_loss{target="gateway"}>30%` | 2m |
| 外网高丢包 | warning | `ping_loss{target="223.5.5.5"}>20%` | 3m |
| TCP 重传率高 | warning | `Tcp_RetransSegs / Tcp_OutSegs > 10%` | 5m |
| DNS 全慢 | warning | 三个域名解析均 >100ms | 5m |
| mihomo TUN 失效 | critical | `nikki` 网卡指标消失 | 2m |
| CN 节点磁盘不足 | warning | 根分区剩余 <5GB | 5m |
| 水泵停转 | critical | fan7 RPM <1500 | 5m |
| VRM 过热 | warning | temp5 >90°C | 2m |
| GPU 热点过热 | warning | 7900XTX 结温 >100°C | 2m |
| N100 CPU 过载 | warning | CPU >90% | 5m |
| 本机 exporter 掉线 | critical | `up{job="local-info"}==0` | 2m |
| rathole 服务不可达 | critical | `rathole_service_up < 1`(按 service 分标签) | 3m |

> 规则用 Grafana provisioning 管理(`/etc/grafana/provisioning/alerting/home-network.yaml`),git 版本化,改规则不用进 Web UI。每条告警在 `annotations.summary` 描述现象,`annotations.runbook` 给出排查步骤——告警通知只推 summary,runbook 留在 Grafana 里展开看。

### 指标踩坑

- **N100 旁路由接入**:Prometheus scrape N100 需过本机代理才通,加 DIRECT 规则绕开。

## 五、日志监控(Loki + OpenTelemetry)

补齐"只有指标、没有日志"的缺口。本机所有系统日志(内核 + systemd 服务 + Docker 容器)统一进 journald → otelcol-logs → Loki → Grafana Explore 检索。

### 设计取舍

- **选 OpenTelemetry,不用 Promtail/Vector**:本机已跑 otelcol-home,再引入 Vector 会让采集器技术栈重复。OTel 还让远程主机日志接入几乎免费(复用同一 mTLS/rathole/Caddy 隧道)。
- **Loki 走原生 OTLP 端点** `/otlp/v1/logs`(旧 `lokiexporter` 已弃用)。
- **Docker 改 journald 日志驱动**:容器日志带 `CONTAINER_NAME` 进 journald,被同一个 `journaldreceiver` 收走 → 有真实容器名、单一数据源。
- **native systemd**、bind `127.0.0.1`、90 天保留、不做日志 dashboard(直接 Explore)。

### 数据流

<svg viewBox="0 0 760 240" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,'Source Han Sans CN','Microsoft YaHei',sans-serif" role="img" aria-label="日志数据流">
  <defs><marker id="lah" markerWidth="10" markerHeight="8" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#64748b"/></marker></defs>
  <rect width="760" height="240" fill="#ffffff"/>
  <text x="360" y="28" text-anchor="middle" font-size="17" font-weight="700" fill="#1f2933">日志数据流:journald → otelcol-logs → Loki → Grafana</text>
  <!-- Sources -->
  <rect x="20" y="54" width="88" height="28" rx="5" fill="#e2e8f0"/><text x="64" y="73" text-anchor="middle" font-size="11" fill="#334155">内核 dmesg</text>
  <rect x="20" y="86" width="88" height="28" rx="5" fill="#e2e8f0"/><text x="64" y="105" text-anchor="middle" font-size="11" fill="#334155">systemd 服务</text>
  <rect x="20" y="118" width="88" height="28" rx="5" fill="#e2e8f0"/><text x="64" y="137" text-anchor="middle" font-size="11" fill="#334155">Docker 容器</text>
  <!-- Merge lines -->
  <line x1="108" y1="68" x2="152" y2="60" stroke="#94a3b8" stroke-width="1"/>
  <line x1="108" y1="100" x2="148" y2="92" stroke="#94a3b8" stroke-width="1"/>
  <line x1="108" y1="132" x2="152" y2="124" stroke="#94a3b8" stroke-width="1"/>
  <!-- journald -->
  <rect x="156" y="58" width="140" height="76" rx="8" fill="#e0e7ff" stroke="#818cf8"/>
  <text x="226" y="80" text-anchor="middle" font-size="12" font-weight="700" fill="#3730a3">journald</text>
  <text x="226" y="97" text-anchor="middle" font-size="9" fill="#4f46e5">持久化 /var/log/journal</text>
  <text x="226" y="113" text-anchor="middle" font-size="9" fill="#4f46e5">SystemMaxUse=8G</text>
  <line x1="296" y1="96" x2="352" y2="96" stroke="#475569" stroke-width="1.8" marker-end="url(#lah)"/>
  <!-- otelcol-logs -->
  <rect x="356" y="58" width="156" height="76" rx="8" fill="#fef3c7" stroke="#f59e0b"/>
  <text x="434" y="78" text-anchor="middle" font-size="11" font-weight="700" fill="#b45309">otelcol-logs</text>
  <text x="434" y="95" text-anchor="middle" font-size="9" fill="#d97706">journaldreceiver + OTTL</text>
  <text x="434" y="109" text-anchor="middle" font-size="9" fill="#d97706">resource_detection+batch</text>
  <text x="434" y="123" text-anchor="middle" font-size="9" fill="#d97706">User=otelcol-logs</text>
  <line x1="512" y1="96" x2="570" y2="96" stroke="#475569" stroke-width="1.8" marker-end="url(#lah)"/>
  <!-- Loki -->
  <rect x="574" y="58" width="52" height="76" rx="6" fill="#fce7f3" stroke="#ec4899"/>
  <text x="600" y="86" text-anchor="middle" font-size="11" font-weight="700" fill="#be185d">Loki</text>
  <text x="600" y="103" text-anchor="middle" font-size="9" fill="#9d174d">90d</text>
  <line x1="626" y1="96" x2="678" y2="96" stroke="#475569" stroke-width="1.8" marker-end="url(#lah)"/>
  <!-- Grafana -->
  <rect x="682" y="58" width="52" height="76" rx="6" fill="#dcfce7" stroke="#22c55e"/>
  <text x="708" y="86" text-anchor="middle" font-size="11" font-weight="700" fill="#166534">Grafana</text>
  <text x="708" y="103" text-anchor="middle" font-size="9" fill="#15803d">Explore</text>
  <!-- bottom notes -->
  <rect x="60" y="148" width="640" height="60" rx="8" fill="#f8fafc" stroke="#e2e8f0"/>
  <text x="100" y="170" font-size="10" fill="#475569">关键:journaldreceiver 把字段放入 body(Map),OTTL 先从 body 抽属性再 set(body, body["MESSAGE"]) 还原。</text>
  <text x="100" y="186" font-size="10" fill="#475569">Docker 端 /etc/docker/daemon.json: log-driver=journald + tag={{.Name}} + live-restore=true</text>
  <text x="100" y="202" font-size="10" fill="#475569">Loki 端:common.instance_addr=127.0.0.1(否则 ring 广播 IP 自连失败),discover_log_levels=true</text>
</svg>

### 组件

| 组件 | 位置 | 备注 |
|------|------|------|
| Loki 3.7 | `/usr/local/bin/loki`,conf `/etc/loki` | `:3100`,OTLP `/otlp/v1/logs`,用户 `loki` |
| otelcol-logs | `/usr/local/bin/otelcol-contrib`(0.154,复用 fleet 二进制) | unit `/etc/systemd/system/otelcol-logs.service`,无需 root(group `systemd-journal`) |
| Grafana 数据源 | `/etc/grafana/provisioning/datasources/loki.yaml` | uid `loki-local` |

### 标签方案

低基数进**索引标签**,高基数进**structured metadata**(可查不进索引):

| 索引标签 | 含义 |
|----------|------|
| `host_name` | resource `host.name`(整机名,对齐 metrics) |
| `service_name` | = `host.name`,Loki 原生分组字段 |
| `unit` | systemd 服务名;Docker 容器 = 容器名(被 OTTL 从 `CONTAINER_NAME` 覆盖) |
| `container` | Docker 容器名(仅容器日志) |
| `level` | 见下「级别判定」 |
| `transport` | kernel / stdout / syslog / journal / audit |

### 级别判定(混合策略)

journald 的 `PRIORITY` 只在原生源(systemd-journal/syslog/kernel)才是真级别;容器日志和裸 stdout/stderr 的 `PRIORITY` 只代表流(stdout=6/stderr=3),无意义。

1. **基线**:容器和非 stdout 源按 PRIORITY 给级别(0–2 critical / 3 error / 4 warn / 5–6 info / 7 debug);容器日志默认 `info`。
2. **正文覆盖**:OTTL `IsMatch` 从消息正文识别 `[error]`/`level=info`/`"level":"warn"` 以及大写独立词 `WARNING`/`ERROR`。
3. Loki 侧 `discover_log_levels: true`——Loki 识别 `level` 属性并据此填 `detected_level`(Grafana 等级可视化用)。

### 查询示例(Grafana Explore)

```logql
{unit="sshd.service"}
{level="error"}
{container="adguardhome"}
{transport="kernel"}
{service_name="li-home-0"} |= "timeout"
```

## 六、日志踩坑

1. **Loki 单二进制 ring 自连失败**:默认广播网卡 IP,但 gRPC 只听 127.0.0.1 → 连接被拒。必须 `common.instance_addr: 127.0.0.1`。
2. **journaldreceiver 把整条日志放 body(Map)**:字段是 `body["PRIORITY"]` 不是 attributes。OTTL 先从 body 抽属性,最后 `set(body, body["MESSAGE"])`。
3. **service_name 语义**:要的是整机标识,是 resource 级。Loki `discover_service_name` 只读 resource 属性 → 在 OTTL resource context 设 `service.name = host.name`。
4. **Docker 容器的 unit**:journald driver 下 `_SYSTEMD_UNIT=docker.service` 没用 → OTTL 用 `CONTAINER_NAME` 覆盖。
5. **级别不能信 PRIORITY**:adguard/HA/owntracks 把 info+warning 全写 stderr → PRIORITY 全 3,若信就全变 error。`discover_log_levels` 别设 false——否则 `detected_level` 没了,Grafana 直方图塌成单条。
6. **彩色日志 = 字节数组**:含 ANSI 的 MESSAGE 被 journald 存成 int-slice,OTTL 无法解码。**治本 = 源头关色**:容器加 `NO_COLOR=1`+`PY_COLORS=0`;Rust 服务加 `Environment=NO_COLOR=1`+`RUST_LOG_STYLE=never`。
7. **node_exporter sgcc 刷屏**:`sgcc_ts.prom` 带时间戳(root 所有的孤儿文件),textfile collector 不支持。移出 `textfile_collector/` 即根治。
8. **runlike 重建容器丢数据**:`uvx runlike` 对匿名/命名卷输出错误,需按 `docker inspect .Mounts` 显式钉卷。

## 七、Trace:Tempo + tail sampling

Trace 和指标走同一条 OTLP 入口(otelcol-home :4318),在内部按 pipeline 分流:

<svg viewBox="0 0 720 220" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,'Source Han Sans CN','Microsoft YaHei',sans-serif" role="img" aria-label="Trace 链路:应用经 otelcol-home tail sampling 进 Tempo 再到 Grafana">
  <defs><marker id="tah" markerWidth="10" markerHeight="8" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#475569"/></marker></defs>
  <rect width="720" height="220" fill="#ffffff"/>
  <text x="360" y="28" text-anchor="middle" font-size="17" font-weight="700" fill="#1f2933">Trace 链路:应用 → otelcol-home → tail sampling → Tempo → Grafana</text>
  <rect x="20" y="60" width="130" height="90" rx="8" fill="#e2e8f0" stroke="#94a3b8"/>
  <text x="85" y="100" text-anchor="middle" font-size="12" font-weight="700" fill="#334155">应用/服务</text>
  <text x="85" y="118" text-anchor="middle" font-size="9" fill="#475569">OTLP trace</text>
  <text x="85" y="131" text-anchor="middle" font-size="9" fill="#475569">exporter</text>
  <line x1="150" y1="105" x2="185" y2="105" stroke="#475569" stroke-width="1.6" marker-end="url(#tah)"/>
  <rect x="185" y="60" width="230" height="90" rx="8" fill="#e0e7ff" stroke="#818cf8"/>
  <text x="300" y="82" text-anchor="middle" font-size="12" font-weight="700" fill="#3730a3">otelcol-home</text>
  <text x="300" y="98" text-anchor="middle" font-size="9" fill="#4f46e5">:4318(同指标入口)</text>
  <text x="300" y="112" text-anchor="middle" font-size="9" fill="#4f46e5">pipeline: memory_limiter</text>
  <text x="300" y="126" text-anchor="middle" font-size="9" fill="#4f46e5">→ tail_sampling → batch</text>
  <line x1="415" y1="105" x2="450" y2="105" stroke="#475569" stroke-width="1.6" marker-end="url(#tah)"/>
  <rect x="450" y="60" width="120" height="90" rx="8" fill="#ede9fe" stroke="#a78bfa"/>
  <text x="510" y="100" text-anchor="middle" font-size="12" font-weight="700" fill="#6d28d9">Tempo</text>
  <text x="510" y="118" text-anchor="middle" font-size="9" fill="#7c3aed">:4317 · gRPC</text>
  <line x1="570" y1="105" x2="605" y2="105" stroke="#475569" stroke-width="1.6" marker-end="url(#tah)"/>
  <rect x="605" y="60" width="95" height="90" rx="8" fill="#dcfce7" stroke="#22c55e"/>
  <text x="652" y="95" text-anchor="middle" font-size="11" font-weight="700" fill="#166534">Grafana</text>
  <text x="652" y="111" text-anchor="middle" font-size="9" fill="#15803d">Explore</text>
  <text x="652" y="125" text-anchor="middle" font-size="8" fill="#15803d">tempo-local</text>
  <rect x="20" y="160" width="680" height="45" rx="8" fill="#eef2ff" stroke="#c7d2fe"/>
  <text x="360" y="180" text-anchor="middle" font-size="12" fill="#3730a3">Trace 与指标共用同一条 OTLP 入口(otelcol-home :4318),内部按 pipeline 分流,互不干扰。</text>
  <text x="360" y="196" text-anchor="middle" font-size="12" fill="#3730a3">tail_sampling 保证 span 树完整,不会出现父子 span 采样不一致的断链。</text>
</svg>

### 采样策略

使用 **tail sampling**(非 head sampling),保证 span 树完整不被截断:

| 策略 | 条件 | 采样率 |
|------|------|--------|
| errors-only | `status_code == ERROR` | 100% |
| latency-sampling | 耗时 >500ms | 100% |
| probabilistic | 其余正常请求 | 10% |

> tail sampling 比 head sampling 多 10s 决策延迟(等 span 树收齐再做决定),但不会出现"父 span 采样了、子 span 没采"的断链——这对排查分布式调用链至关重要。

### Tempo 后端

```yaml
# /etc/tempo/config.yaml
distributor:
  receivers:
    otlp:
      protocols:
        grpc:
          endpoint: 127.0.0.1:4317
ingester:
  max_block_duration: 5m
  lifecycler:
    ring:
      kvstore: { store: inmemory }
      replication_factor: 1
```

单实例 inmemory ring(无集群需求),720h(30 天)保留。与 Loki 相同的模式:native systemd,bind `127.0.0.1`,Grafana Explore 直接按 Trace ID 检索。

### 接入方式

应用侧设 `OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318`(本机)或 `https://otlp.liz6.com:9443`(远程,经 fleet 隧道)。SDK 自动产生 trace。远程应用复用现成 fleet collector 的 mTLS 证书——指标和 trace 走同一条管道、同一套安全体系。

## 八、未来扩展:远程 fleet 日志

复用现成 metrics-fleet 传输(无需改 Caddy,`/api/v1/otlp/*` 已覆盖 `v1/logs`):
1. 远程 otelcol-fleet agent 加 journald receiver + logs pipeline → `otlphttp` 到 `otlp.liz6.com:9443`
2. 家里 otelcol-home 加 logs pipeline → `otlphttp` 到 Loki `/otlp/v1/logs`
3. `add-server.sh`/CA/命名全部沿用,`host_name`/`service_name` 即各节点名

## 相关文档

- [network-architecture.md](network-architecture.md) — 网络拓扑与守护进程
- [rathole-tunnel.md](rathole-tunnel.md) — 隧道架构(承载连接绕 mihomo)
- [server-lifecycle.md](server-lifecycle.md) — 远程服务器接入脚本(add-server)
