28 分で読了 #homelab
このページの目次

フルスタック監視:Prometheus + Grafana + Loki + Tempo + Bark

指標(Prometheus)+ ログ(Loki)+ Trace(Tempo)の3本柱がGrafanaに集約され、アラートはBark経由でiPhoneにプッシュされます。指標はpull(ローカルネットワーク上のnode_exporter)とpush(リモートotel-collector fleet agent、ratholeトンネル+mTLS経由)の2つのモードで収集されます。ログはjournald → otelcol-logs → Lokiへ、Traceはotelcol-homeのtail sampling → Tempoへ流れます。本稿ではローカルホスト li-home-0 を实例として、フルスタックアーキテクチャ、アラートルール、収集手法の比較、タグ付け方針、レベル判定、およびトラブルシューティングの記録を解説します。

一、概要

監視アーキテクチャ:指標+ログ+Trace → Grafana、アラート → Bark → iPhone リモート fleet otel-collector host_metrics 30s file_storage キュー mTLS クライアント証明書 rathole TCP トンネル ↓ Caddy mTLS 終端 パス制限 + 失効 otelcol-home OTLP 受信 metrics パイプライン traces パイプライン tail sampling Prometheus Tempo :4317 node_exporter (pull) journald otelcol-logs Loki :3100 Grafana 指標 · ログ · Trace Bark APNs → iPhone metrics-fleet: リモート otel-collector(fleet agent) → OTLP+mTLS → ratholeトンネル → Caddyゲートウェイ → otelcol-home(metrics+traceパイプライン) ローカル: node_exporter → Prometheus(pull) · journald → otelcol-logs → Loki · otelcol-home traces → Tempo(gRPC, 720h保持) Grafanaで指標(Prometheus)+ログ(Loki)+Trace(Tempo)を統一クエリ · アラートはBark webhook → APNs → iPhone(ローカル bark-server、トンネル/パブリックIPに依存しない) 全コンポーネントは native systemd · bind 127.0.0.1 · Dockerはアプリケーションのみを実行

二、指標収集:2つのモデル

従来の pull (node_exporter)リモート push (metrics-fleet)
用途ローカルネットワークノード、ローカルホストパブリックIP上のリモートサーバー
収集エージェントnode_exporter / LHM exporterotel-collector-contrib (host_metrics)
メトリクス命名node_*, windows_*, lhm_*system_cpu_*, system_memory_*
ネットワークPrometheus が能動的に取得、ポート到達可能必要リモートが能動的に送信、インバウンドポート不要
転送セキュリティなし (LAN内では平文)mTLS + パス制限 (OTLP 書き込みのみ許可)
接続方法prometheus.yml の static_configs を手動編集add-server.sh <sshエイリアス>
ダッシュボード各ホストごとに独立した JSON単一テンプレート、$host ドロップダウンで切り替え
中継依存なしrathole トンネル → 中継 VPS → 自宅

Pull モデル:scrape_configs

Prometheus は15秒ごとにこれらのターゲットを取得します: ローカルホストの node_exporter(:9100)、llama-server(:18080)、Home Assistant(:8123)、Windows マシン(9182/9183)、N100 サイドルーター(:9100)、Xiaomi ルーター(:9184)、セルフモニタリング(:9090)。

Push モデル:OTLP 受信

リモートサーバーは otel-collector を通じて host_metrics を収集し、mTLS 認証と rathole 暗号化トンネルを経て、自宅の Prometheus の remote write エンドポイントに送信します:

Push モデル: リモート指標は mTLS + rathole トンネルを経て自宅にプッシュされる リモート otel-collector host_metrics 収集 OTLP+mTLS 証明書 パブリック入口 otlp.liz6.com:9443 DNS → 中継 VPS rathole トンネル TCP トランスパレンシー · 暗号文のみ表示 自宅 Caddy mTLS ゲートウェイ 証明書検証 + 失効チェック /api/v1/otlp/* のみ許可 otelcol-home :4318 batch 処理 Prometheus remote write :9090 mTLS は Caddy でのみ終端され、rathole は終始暗号文のみをトランスパシーするため、平文は見えません。 Prometheus 設定 `out_of_order_time_window=30m` は、パブリックネットワーク経由での到着順序の揺らぎに対応します。

Prometheus 設定 out_of_order_time_window: 30m は、パブリックネットワーク経由での到着順序の揺らぎに対応します。

Grafana ダッシュボード

Provisioning ディレクトリ: 7つのホストパネル(CPU / GPU / メモリ / ディスク / 冷却、ノードごとに JSON 分割) + 1つのサーバー状態テンプレート($host ドロップダウンで OTLP 送信元の host_name を自動検出) + ネットワーク監視パネル。

パネル式例:

# 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    # サーバー証明書 issuance → /etc/caddy/metrics-fleet/
├── issue-client-cert.sh    # クライアント証明書 issuance → out/clients/<name>/
├── revoke-client-cert.sh   # 失効 + Caddy revoked.caddy の更新 + リロード
└── out/clients/<name>/     # 発行済みのクライアント証明書 (各リモートマシンごとに1份)

Caddy は 127.0.0.1:9443 で mTLS ゲートウェイとして動作します: クライアント証明書の検証(require_and_verify, CA 信頼)、失効リストの参照 → 403、/api/v1/otlp/* のみ許可し strip_prefix → otelcol-home :4318。

各リモートサーバーのパブリックIPは、ローカルホストと N100 の両方の mihomo の DIRECT ルールに追加する必要があります。これにより、rathole が接続と SSH をプロキシ経由ではなく直接ルーティングすることを保証します(接続はネイティブルート経由でなければならない——rathole-tunnel.md 4.3節参照)。

四、アラート:Grafana + Bark による iPhone へのプッシュ

Prometheus は収集を担当し、Grafana は判定と通知を担当します。アラートチェーン:

アラートチェーン: Grafana ルール評価 → bark-server(ローカル) → APNs → iPhone Grafana ルール評価 30秒ごとに評価 webhook bark-server 127.0.0.1:8085 ローカル直結、トンネルを経ない APNs iPhone ロック画面プッシュ なぜローカル bark-server を使うのか: アラート通知はトンネル/パブリックIPに依存してはならない——リンクがダウンした際にアラートも送信できなくなるため、ローカル直結は外部依存がゼロである。

なぜローカル bark-server を使い、パブリック webhook を使わないのか⁠: アラート通知が rathole トンネルやパブリックIP経由だと、トンネルがダウンした際にアラートも送信できなくなります。ローカルの bark-server は iPhone と直結しており、外部依存がゼロで、インフラストラクチャに依存しません。

連絡先と通知ポリシー

# /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                           # 未解決の場合、4時間ごとに再通知

maxAlerts: 2 が鍵——bark は webhook JSON を APNs に透過的に渡すため、アラート1件あたり約1.1–1.4KB、2件を超えると APNs の4KB上限に達し、bark は直接500を返します。

アラートルール (計13件)

ルールレベル条件沈黙
サドルーターダウンcriticalup{job="li-home-router"}==03m
ルーター不通criticalping_loss{target="gateway"}>30%2m
外網高損失warningping_loss{target="223.5.5.5"}>20%3m
TCP 再送率高warningTcp_RetransSegs / Tcp_OutSegs > 10%5m
DNS 全遅延warning3ドメインの解決がすべて >100ms5m
mihomo TUN 無効criticalnikki ネットワークインターフェース指標消失2m
CN ノードディスク不足warningルートパーティション残り <5GB5m
給水ポンプ停止criticalfan7 RPM <15005m
VRM 過熱warningtemp5 >90°C2m
GPU ホットスポット過熱warning7900XTX ジンク温度 >100°C2m
N100 CPU 過負荷warningCPU >90%5m
ローカル exporter 切断criticalup{job="local-info"}==02m
rathole サービス到達不能criticalrathole_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 が N100 を scrape するにはローカルプロキシ経由が必要ですが、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日保持、ログダッシュボードは作成しない(直接 Explore を使用)。

データフロー

ログデータフロー: journald → otelcol-logs → Loki → Grafana カーネル dmesg systemd サービス Docker コンテナ journald 永続化 /var/log/journal SystemMaxUse=8G otelcol-logs journaldreceiver + OTTL resource_detection+batch User=otelcol-logs Loki 90日 Grafana Explore 重要: journaldreceiver はフィールドを body(Map) に配置し、OTTL は body から属性を抽出し、最後に `set(body, body["MESSAGE"])` で復元する。 Docker 側 /etc/docker/daemon.json: log-driver=journald + tag={{.Name}} + live-restore=true Loki 側: common.instance_addr=127.0.0.1 (否则 ring 广播 IP 自连失败),discover_log_levels=true

コンポーネント

コンポーネント場所備考
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.yamluid loki-local

タグ付け方針

低基数はインデックスタグに、高基数は構造化メタデータ⁠(検索可能だがインデックス化しない) に配置します:

インデックスタグ意味
host_nameresource host.name(マシン名、metrics と整合)
service_name= host.name、Loki 原生のグループ化フィールド
unitsystemd サービス名; Docker コンテナ = コンテナ名(OTTL によって CONTAINER_NAME から上書き)
containerDocker コンテナ名(コンテナログのみ)
level下掲「レベル判定」参照
transportkernel / 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)

{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 コンテキストで service.name = host.name を設定します。
  4. Docker コンテナの unit: journald ドライバー下では _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)を共有し、内部でパイプラインによって分流されます:

Trace チェーン: アプリ → otelcol-home → tail sampling → Tempo → Grafana アプリ/サービス OTLP trace exporter otelcol-home :4318(指標入口と同じ) パイプライン: memory_limiter → tail_sampling → batch Tempo :4317 · gRPC Grafana Explore tempo-local Trace と指標は同じ OTLP 入口(otelcol-home :4318)を共有し、内部でパイプラインによって分流され、互いに干渉しません。 tail_sampling は span ツリーを完全な状態に保ち、親 span と子 span のサンプリング不一致による断線を防ぎます。

サンプリングポリシー

tail sampling(head sampling ではなく) を使用し、span ツリーが完全に切り捨てられないようにします:

ポリシー条件サンプリングレート
errors-onlystatus_code == ERROR100%
latency-sampling応答時間 >500ms100%
probabilisticその他の正常リクエスト10%

tail sampling は head sampling よりも10秒の意思決定遅延がかかります(span ツリーが揃うまで待機してから判断するため)が、「親 span はサンプリングされたが子 span はサンプリングされなかった」という断線が発生しません——これは分散呼び出しチェーンのトラブルシューティングにおいて極めて重要です。

Tempo バックエンド

# /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 パイプラインを追加 → otlphttpotlp.liz6.com:9443 に送信
  2. 自宅の otelcol-home に logs パイプラインを追加 → otlphttp で Loki /otlp/v1/logs に送信
  3. add-server.sh/CA/命名規則をすべて流用し、host_name/service_name が各ノード名となります。

関連ドキュメント