8
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PLG Stack — 第1部:Prometheus・Loki・Grafanaを深く理解する

8
Posted at

あなたのシステムは動いている——でも、本当に何をしているか把握できていますか?

これは PLG Stack シリーズ全2回の 第1部 です。各コンポーネントがどのように動作し、何のために使うのかを詳しく解説します。第2部 では、Docker Compose・Kubernetes・Alerting・本番デバッグを実践します。


目次

  1. Observabilityとは何か、なぜ必要か
  2. PLG Stackの概要
  3. Prometheus — メトリクス収集システム
  4. Loki — ログ管理システム
  5. Grafana — 可視化・アラートプラットフォーム

1. Observabilityとは何か?

ツールの話をする前に、私たちが解決しようとしている問題 を理解しましょう。

リアルなシナリオ

金曜日の深夜11時、顧客からメッセージが届きます:「アプリが壊れてる、ログインできない」。サーバーにSSH接続して top を実行すると、CPUが90%——でも理由がわからない。ログを読むと、エラーが何千行も出ているが、どれが重要かわからない。問題がいつから始まったのか、何人のユーザーが影響を受けているのか、根本原因は何なのか——何もわからない。

これが Observabilityのないシステム です。

Observabilityの3本柱

Observabilityとは、外部シグナルからシステムの内部状態を理解する能力です。3種類のデータで構成されます:

┌─────────────────────────────────────────────────────────┐
│                   OBSERVABILITY                          │
│                                                          │
│  📊 METRICS          📝 LOGS           🔍 TRACES        │
│  「どのくらい?」     「何が起きた?」   「なぜ?」        │
│                                                          │
│  - CPU: 85%          - ERROR: DB       - リクエストが     │
│  - RAM: 12GB           connection        Service A       │
│  - 500 req/s           failed           → B → C → DB    │
│  - P99: 250ms        - User #123        Cで2.3秒かかった  │
│                        logged in                         │
└─────────────────────────────────────────────────────────┘

Metrics が答える質問:どのくらい?速い?エラーが出ている?
例:CPUが85%、毎秒500リクエスト、エラー率0.1%。

Logs が答える質問:何が起きた?いつ?誰に?
例:2025-05-20 02:13:41 ERROR user=123 action=login msg="DB connection timeout"

Traces が答える質問:リクエストはどこを通った?どのステップが遅い?
(本記事ではMetricsとLogsに集中します。TracesはTempo/Jaegerの別トピックです)

PLG StackはMetrics(Prometheus)とLogs(Loki)を処理し、Grafanaで表示します。


2. PLG Stackの概要

PLG Stackの構成

文字 ツール 役割 何をするか
P Prometheus Metricsバックエンド システム数値の収集・保存・クエリ
L Loki Logバックエンド ログの収集・保存・検索
G Grafana フロントエンド ダッシュボード・アラート・データ探索

サポートコンポーネントも存在します:

ツール 役割
node_exporter Linuxホストのメトリクスを収集するエージェント
cAdvisor コンテナのメトリクスを収集するエージェント
Promtail / Alloy ログを読み取ってLokiに送信するエージェント
Alertmanager Prometheusからのアラートを処理・送信

なぜ他の選択肢ではなくPLGを選ぶのか?

PLG Stack ELK Stack Datadog
コスト 無料 無料 ~$15-23/host
必要RAM 低い (~2GB) 高い (~8GB+) 軽量エージェント
Logインデックス ラベルのみ フルテキスト フルテキスト
Metrics 非常に強力 弱い 非常に強力
セットアップ 中程度 難しい 簡単
セルフホスト あり あり なし
ベンダーロックイン なし なし あり

PLGを使うべき場面:

  • セルフホストが必要、SaaSにお金を払いたくない
  • 多数のコンテナ/マイクロサービスを監視する必要がある
  • データを完全に管理したい
  • DevOps/SREが運用できるチームがある

他の選択肢を検討すべき場面:

  • 小規模チームでインフラ運用担当者がいない → Grafana Cloud(マネージド)
  • 超高速なフルテキストログ検索が必要 → ELK
  • 自己管理したくない → Datadog

データフロー全体像

Gemini_Generated_Image_9m57uu9m57uu9m57(1).png


3. Prometheus

Prometheusは何のために使うのか?

Prometheusは 時系列データベース で、時間とともに変化するメトリクスを保存するために特別に設計されています:

  • CPUが何%使われているか
  • 処理中のリクエストが何件あるか
  • データベースクエリにどのくらいかかっているか
  • ディスクの空き容量が何GB残っているか
  • 直近1分間でHTTP 500エラーが何件発生したか

重要な違い:Prometheusはデータを プッシュで受け取らず、HTTPエンドポイント /metrics からターゲットのデータを能動的に スクレイプ(プル) します。このプルモデルには多くの利点があります:Prometheusはどのターゲットが生きているか/死んでいるかを正確に把握でき、アプリケーションからPrometheusへのファイアウォール開放も不要で、デバッグも簡単です。

動作の詳細

ステップ1:ターゲットがHTTP経由でメトリクスを公開する
  → http://your-app:8080/metrics にアクセスすると、テキスト形式で表示:

  # HELP http_requests_total Total HTTP requests
  # TYPE http_requests_total counter
  http_requests_total{method="GET",status="200"} 12453
  http_requests_total{method="POST",status="500"} 47

  # HELP http_request_duration_seconds Request latency
  # TYPE http_request_duration_seconds histogram
  http_request_duration_seconds_bucket{le="0.05"} 8543
  http_request_duration_seconds_bucket{le="0.1"}  11234
  http_request_duration_seconds_bucket{le="+Inf"} 12453

ステップ2:Prometheusが定期的にスクレイプ(デフォルト15秒)
  → HTTP GET http://your-app:8080/metrics を実行
  → テキスト形式をパース
  → 現在のタイムスタンプとともにTSDBに保存

ステップ3:データをPromQLでクエリ
  → rate(http_requests_total[5m]) → 5分間のreq/s
  → histogram_quantile(0.99, ...) → P99レイテンシ

メトリクスの種類 — 必ず押さえておくこと

Prometheusには4種類のメトリクスがあり、それぞれ異なる目的で使います:

Counter — 増えるだけで減らない。「何回発生したかを数える」用途:

http_requests_total{status="200"} = 15234  # アプリ起動からの総リクエスト数
errors_total = 47                           # 総エラー数
bytes_sent_total = 1048576000               # 送信済み総バイト数

→ 使用時:意味を持たせるには常に rate() または increase() でラップする。カウンターを直接クエリしない

Gauge — 増減どちらもあり得る。「現在の状態」用途:

memory_usage_bytes = 2147483648   # 現在使用中のRAM
active_connections = 42           # 現在開いているコネクション数
queue_size = 150                  # キューのタスク数

→ 使用時:関数でラップせずそのままクエリ可能

Histogram — 値をバケットに分類する。「時間・サイズの計測」用途:

http_duration_seconds_bucket{le="0.1"}  = 9000   # 100ms未満のリクエスト9000件
http_duration_seconds_bucket{le="0.5"}  = 11000  # 500ms未満のリクエスト11000件
http_duration_seconds_bucket{le="+Inf"} = 12000  # 合計12000リクエスト
http_duration_seconds_sum = 1250.3               # 合計時間(秒)
http_duration_seconds_count = 12000              # 観測の総数

→ 使用時:histogram_quantile() でP50/P95/P99を計算

Summary — Histogramに似ているが、クライアント側でパーセンタイルを計算。複数インスタンスの集計ができないため、あまり使われない。

Prometheusの設定

# prometheus.yml

global:
  scrape_interval: 15s       # 15秒ごとにスクレイプ
  evaluation_interval: 15s   # 15秒ごとにアラートルールを評価
  external_labels:
    environment: 'production'
    region: 'asia-southeast1'

rule_files:
  - "/etc/prometheus/rules/*.yml"

alerting:
  alertmanagers:
    - static_configs:
        - targets: ['alertmanager:9093']

scrape_configs:

  # Prometheus自身を監視
  - job_name: 'prometheus'
    static_configs:
      - targets: ['localhost:9090']

  # Linuxホストのメトリクスを収集
  - job_name: 'node-exporter'
    static_configs:
      - targets: ['node-exporter:9100']

  # Dockerコンテナのメトリクスを収集
  - job_name: 'cadvisor'
    static_configs:
      - targets: ['cadvisor:8080']

  # 自作アプリのメトリクスを収集
  - job_name: 'my-web-app'
    static_configs:
      - targets:
        - 'web-app-1:8080'
        - 'web-app-2:8080'
    metrics_path: '/metrics'
    scrape_interval: 10s   # グローバル設定を上書き、重要なアプリは頻度を上げる

  # Docker Swarmによるサービスディスカバリ
  - job_name: 'docker-services'
    dockerswarm_sd_configs:
      - host: unix:///var/run/docker.sock
        role: tasks
    relabel_configs:
      # "prometheus.io/scrape=true" ラベルを持つコンテナのみスクレイプ
      - source_labels: [__meta_dockerswarm_service_label_prometheus_io_scrape]
        action: keep
        regex: true

PromQL — Prometheusのクエリ言語

PromQLは最も難しい部分ですが、最も重要でもあります。PromQLを理解する = システムに正しい質問を投げる方法がわかる、ということです。

基本:

# セレクタ — 現在値を取得
http_requests_total
http_requests_total{job="my-app"}
http_requests_total{status=~"5.."}         # 正規表現: 500, 501, 502...
http_requests_total{status!="200"}         # not equal

# レンジベクタ — 期間内のデータを取得
http_requests_total[5m]    # 直近5分間

# rate() — 秒あたりの増加速度(カウンターに使用)
rate(http_requests_total[5m])
# → 5分間の平均リクエスト数/秒

# increase() — 期間内の総増加数
increase(http_requests_total[1h])
# → 直近1時間の総リクエスト数

# 集計
sum(rate(http_requests_total[5m]))                    # 全体の合計
sum by (status) (rate(http_requests_total[5m]))       # ステータスコード別の合計
avg by (instance) (node_cpu_usage_percent)            # ホスト別の平均
topk(5, rate(http_requests_total[5m]))                # 上位5件

よく使う実践的なクエリ:

# ===== CPU =====
# ホスト別のCPU使用率%
100 - (avg by (instance) (
  rate(node_cpu_seconds_total{mode="idle"}[5m])
) * 100)

# ===== メモリ =====
# RAM使用率%
(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100

# ===== ディスク =====
# 残ディスク容量%
(node_filesystem_avail_bytes{mountpoint="/"} /
 node_filesystem_size_bytes{mountpoint="/"}) * 100

# ===== HTTPアプリケーション =====
# リクエストレート (req/s)
sum(rate(http_requests_total[5m]))

# エラーレート(リクエストのエラー率%)
sum(rate(http_requests_total{status=~"5.."}[5m])) /
sum(rate(http_requests_total[5m])) * 100

# P50、P95、P99レイテンシ
histogram_quantile(0.99,
  sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)

# ===== データベース =====
# アクティブなPostgreSQLコネクション
pg_stat_activity_count{state="active"}

# Redisキャッシュヒット率
rate(redis_keyspace_hits_total[5m]) /
(rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m]))

アプリケーションからメトリクスを公開する

主要な言語には、Prometheusクライアントライブラリが用意されています:

Go:

import "github.com/prometheus/client_golang/prometheus"

var requestsTotal = prometheus.NewCounterVec(
    prometheus.CounterOpts{
        Name: "http_requests_total",
        Help: "Total number of HTTP requests",
    },
    []string{"method", "status"},
)

func init() {
    prometheus.MustRegister(requestsTotal)
}

func handler(w http.ResponseWriter, r *http.Request) {
    // ... リクエスト処理 ...
    requestsTotal.WithLabelValues("GET", "200").Inc()
}

// エンドポイントを公開
http.Handle("/metrics", promhttp.Handler())

Python(FastAPI):

from prometheus_client import Counter, Histogram, Response, generate_latest
import time

REQUEST_COUNT = Counter(
    'http_requests_total', 'Total HTTP requests',
    ['method', 'endpoint', 'status']
)
REQUEST_LATENCY = Histogram(
    'http_request_duration_seconds', 'Request latency',
    buckets=[.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5]
)

@app.middleware("http")
async def metrics_middleware(request, call_next):
    start = time.time()
    response = await call_next(request)
    duration = time.time() - start
    REQUEST_COUNT.labels(request.method, request.url.path, response.status_code).inc()
    REQUEST_LATENCY.observe(duration)
    return response

@app.get("/metrics")
def metrics():
    return Response(generate_latest(), media_type="text/plain")

Node.js:

const promClient = require('prom-client');

const requestCount = new promClient.Counter({
  name: 'http_requests_total',
  help: 'Total HTTP requests',
  labelNames: ['method', 'status']
});

const requestDuration = new promClient.Histogram({
  name: 'http_request_duration_seconds',
  help: 'Request latency',
  buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5]
});

app.use((req, res, next) => {
  const end = requestDuration.startTimer();
  res.on('finish', () => {
    requestCount.inc({ method: req.method, status: res.statusCode });
    end();
  });
  next();
});

app.get('/metrics', async (req, res) => {
  res.set('Content-Type', promClient.register.contentType);
  res.end(await promClient.register.metrics());
});

知っておくべき主要なExporter

自作アプリを書かない場合は、既製のExporterを使います:

Exporter 監視対象
node_exporter LinuxホストのCPU、RAM、ディスク、ネットワーク
cAdvisor Dockerコンテナのメトリクス
kube-state-metrics Kubernetesオブジェクト(Pod、Deploymentなど)
blackbox_exporter 外部からのHTTP/TCPエンドポイントのヘルスチェック
postgres_exporter PostgreSQLデータベースのメトリクス
redis_exporter Redisのメトリクス
nginx_exporter Nginxのコネクション、リクエスト

4. Loki

Lokiは何のために使うのか?

Lokiは ログの保存・検索システム です。アプリケーションが何が起きているかを記録するために出力するテキスト行を扱います:

2025-05-20T14:23:41Z INFO  user=alice action=purchase product_id=789 amount=299000
2025-05-20T14:23:41Z ERROR user=bob   action=login  error="invalid password" attempt=3
2025-05-20T14:23:42Z WARN  worker=5  queue=emails   msg="queue is filling up" size=8420

ログがなければ、エラーが起きたときに「エラーがある」とはわかっても「どこで、誰が影響を受けて、なぜ」はわかりません。Prometheusはエラーが 何件 あるかを教えてくれます——Lokiは 具体的な各エラー を読み返せます。

なぜElasticsearchではなくLokiを使うのか?

これは多くの人が混乱するポイントです。Elasticsearchは各ログ行の 全内容をインデックス化 します——すべての単語がインデックスに入ります。これにより検索は非常に高速になりますが、RAMとディスクを大量に消費します。

Lokiは ラベル(メタデータ)のみをインデックス化 します。ログの内容は圧縮チャンクとして保存されます。クエリ時は、まずラベルで絞り込み(高速)、その後テキストをgrepします(やや遅いが許容範囲)。

Elasticsearch:
  各ログ行 → 全単語をインデックス → "ERROR"検索が超速 → RAM/ディスク消費大

Loki:
  各ログ行 → ラベルのみインデックス(job, host, container)→ "ERROR"検索はやや遅い → 大幅に節約

1日50GBのログの場合:
  ELKに必要なストレージ:  ~200GB(インデックス後)
  Lokiに必要なストレージ:  ~20-30GB(圧縮後)

Lokiのラベル — 最も重要なこと

ラベルは各ログストリームに付与するメタデータです。ラベルはLokiの パフォーマンスを決定します

# Lokiのログエントリの例(ラベルあり):
ラベル:
  job="my-app"
  environment="prod"
  host="server-01"
  container="api"

タイムスタンプ: 2025-05-20T14:23:41Z
ログ行: "ERROR user=123 msg=login_failed"

ラベルの黄金ルール — カーディナリティは低く保つ:

# ✅ 正しい — 固定値の数が少ない
{job="api", environment="prod", host="web-01"}

# ❌ 間違い — user_idは何百万種類もある
{job="api", user_id="12345"}

# ❌ 間違い — request_idはリクエストごとに一意 → 何百万ものストリームが生成されOOM
{job="api", request_id="abc-xyz-123"}

user_idrequest_idログの内容 に含め、ラベルには入れないでください。Lokiにプッシュした後、LogQLでパースします。

Promtailの設定

Promtailは各サーバーで動くエージェントで、ログを読み取ってLokiに送信します:

# promtail-config.yml

server:
  http_listen_port: 9080

positions:
  filename: /tmp/positions.yaml   # 再起動後に再読み込みしないよう読取位置を記憶

clients:
  - url: http://loki:3100/loki/api/v1/push

scrape_configs:

  # システムログを読み取る
  - job_name: system-logs
    static_configs:
      - targets: [localhost]
        labels:
          job: varlogs
          __path__: /var/log/{syslog,auth.log,kern.log}

  # 全Dockerコンテナのログを読み取る
  - job_name: docker-containers
    docker_sd_configs:
      - host: unix:///var/run/docker.sock
        refresh_interval: 5s
    relabel_configs:
      # コンテナ名をラベルとして使用
      - source_labels: [__meta_docker_container_name]
        regex: '/(.*)'
        target_label: container
      # docker-composeのサービス名を使用
      - source_labels: [__meta_docker_container_label_com_docker_compose_service]
        target_label: service
    pipeline_stages:
      # ログがJSONなら各フィールドをパース
      - json:
          expressions:
            level: level
            message: message
      # 素早くフィルタできるよう "level" フィールドをラベルとして使用
      - labels:
          level:
      # ストレージ節約のためdebugログは破棄
      - drop:
          source: level
          expression: "debug"

  # nginxのログファイルを読み取る
  - job_name: nginx
    static_configs:
      - targets: [localhost]
        labels:
          job: nginx
          __path__: /var/log/nginx/access.log
    pipeline_stages:
      # 正規表現でnginxアクセスログ形式をパース
      - regex:
          expression: '^(?P<remote_addr>\S+) .* "(?P<method>\S+) (?P<path>\S+) \S+" (?P<status>\d+) (?P<bytes_sent>\d+)'
      # HTTPステータスで素早くフィルタできるようラベルとして使用
      - labels:
          status:
          method:

LogQL — Lokiのクエリ言語

# ===== 基本 =====

# あるジョブの全ログを取得
{job="my-app"}

# 複数ラベルで絞り込む
{job="my-app", environment="prod"}

# フルテキスト検索
{job="my-app"} |= "ERROR"             # "ERROR"を含む
{job="my-app"} != "DEBUG"             # "DEBUG"を含まない
{job="my-app"} |~ "user=[0-9]+"       # 正規表現にマッチ


# ===== ログのパース =====

# JSONログをパース
{job="my-app"} | json

# パース後、フィールドで絞り込む
{job="my-app"} | json | level="error"
{job="my-app"} | json | status_code >= 500
{job="my-app"} | json | duration > 1000   # duration > 1000ms

# 正規表現でカスタム形式のログをパース
{job="nginx"} | pattern `<ip> - - [<_>] "<method> <path> <_>" <status> <size>`
              | status >= 400

# Logfmt(key=value形式)
{job="my-app"} | logfmt | level="error" | user != ""


# ===== ログからメトリクスを生成 =====

# ログ行数/秒を計算
rate({job="my-app"}[1m])

# エラー数/秒を計算
rate({job="my-app"} |= "ERROR" [1m])

# パース後にフィールド別で集計
sum by (status) (
  rate({job="nginx"} | pattern `<_> "<_> <_> <_>" <status> <_>` [1m])
)

# ブルートフォース検知 — 5分間に10回以上ログイン失敗したユーザー
sum by (user) (
  count_over_time(
    {job="auth-service"} | json | action="login" | status="failed" [5m]
  )
) > 10

5. Grafana

Grafanaは何のために使うのか?

GrafanaはPrometheusとLokiの上に乗る プレゼンテーション層 です。主に3つのことを行います:

  1. 可視化 — 生データをグラフ、表、読みやすい数値に変換
  2. アラート — しきい値を監視し、超えたときに通知を送信
  3. 探索 — インシデントのデバッグや調査のためのアドホッククエリ

Prometheusがメトリクス収集エンジン、Lokiがログ倉庫だとすると、Grafanaは 毎日実際に作業する場所 です。

パネルの種類と使い分け

Time series — 時間とともに変化するメトリクスに:

使う場面: CPU使用率、メモリ、リクエストレート、時間別エラーレート
クエリ例: rate(http_requests_total[5m])

Stat — トレンド付きの大きな単一数値に:

使う場面: 稼働率%、今日の総リクエスト数、エラー数
クエリ例: sum(rate(http_requests_total{status=~"5.."}[24h]))

Gauge — しきい値に応じた色付きでmin/max有りの数値に:

使う場面: CPU%、ディスク%、メモリ% — 0-100の上限がある値
しきい値: 0-70% → 緑、70-90% → 黄、90-100% → 赤

Table — 複数列のテーブル形式データに:

使う場面: 遅いエンドポイントTop10、サービスと状態の一覧
クエリ例: topk(10, sum by (endpoint) (rate(http_requests_total{status="500"}[1h])))

Logs — ダッシュボードにLokiのログを直接表示:

使う場面: メトリクスの隣で特定サービスのエラーログを見る
クエリ例: {job="my-app", environment="prod"} | json | level="error"

Heatmap — 時間ごとの値の分布に:

使う場面: リクエストレイテンシの分布 — 遅いリクエストが何件あるかを一目で把握
クエリ例: sum by (le) (rate(http_request_duration_seconds_bucket[5m]))

REDメソッドとUSEメソッド

何を監視すべきかを知るための2つの有名なフレームワーク:

REDメソッド — サービス/マイクロサービス向け:

  • Rate — 毎秒何リクエストあるか
  • Error — リクエストのエラー率
  • Duration — リクエストにかかる時間(P50/P95/P99)

USEメソッド — インフラ向け:

  • Utilization — リソースが何%使われているか(CPU%、ディスク%)
  • Saturation — キュー/バックログが詰まっていないか
  • Errors — ハードウェア/カーネルエラーが出ていないか

プロビジョニングによるデータソースの追加(コードで管理)

# grafana/provisioning/datasources/datasources.yml
apiVersion: 1

datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true
    jsonData:
      timeInterval: "15s"    # scrape_intervalと一致させる

  - name: Loki
    type: loki
    access: proxy
    url: http://loki:3100
    jsonData:
      # LokiとPrometheusを連携してCorrelationを有効にする
      derivedFields:
        - datasourceUid: prometheus
          matcherRegex: "request_id=(\\w+)"
          name: RequestID
          url: '${__value.raw}'

すぐにインポートすべきダッシュボードテンプレート

一から作らず、Grafana Labsの既製ダッシュボードをインポートしましょう(Dashboards → Import → IDを入力):

ID 内容
1860 Node Exporter Full — CPU、RAM、ディスク、ネットワークの詳細
13978 Node Exporter Quickstart — よりシンプル、スタートに最適
3662 Prometheus 2.0 Overview — Prometheus自身を監視
15141 Kubernetes Cluster Monitoring
14055 Loki Dashboard — Loki自身を監視
9628 PostgreSQL Database
11835 Redis Exporter

第1部のまとめ

ここまでで各コンポーネントをしっかり理解できたはずです:

  • Prometheus — ターゲットからメトリクスをプル、時系列として保存、PromQLでクエリ
  • Loki — Promtailからログを受信、フルテキストではなくラベルをインデックス化、LogQLでクエリ
  • Grafana — 両方のデータを可視化、しきい値超過時にアラート、デバッグ時に探索

第2部 では実践に直行します:Docker Composeでスタック全体をデプロイ、AlertmanagerでSlack通知を設定、実践的なアラートルールの構築、よくある本番デバッグシナリオを扱います。

8
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?