3-5. 監視 - ログ / メトリクス / トレースで本番を可視化する

所要時間: 50-70分 ゴール: nginx ログを読め、Prometheus + Grafana で Go アプリのメトリクスを可視化でき、アラート設計の方針が立てられる コミット内容: ~/learn/infra/monitoring/ に Go アプリのメトリクス実装、Prometheus / Grafana / Loki の docker-compose


このレッスンのゴール

  • Observability の3本柱 (Logs / Metrics / Traces) を語れる
  • nginx access.log を読んでエラー傾向を判断できる
  • Go アプリで Prometheus メトリクスをエクスポートできる
  • Four Golden Signals / RED / USE のメンタルモデルを区別できる
  • アラートを severity 別に設計できる

なぜ学ぶか(実務悩みベース)

  • ユーザーの「サイトが遅い」を数秒で原因切り分けしたい
  • 「先週から500増えてる」を機械的に検出したい
  • 攻撃を即座に検知してブロックしたい
  • SLO を数値で示せるエンジニアになりたい

前章とのつながり

3-4 でデプロイが自動化された。デプロイ後は 「動いてるか」を観測する フェーズ。観測ができないと「デプロイしたら本番が壊れていた」に気づけない。CI/CD と監視はセット。


大前提: 「動いている」を観測できないアプリは存在しないも同然

開発時のローカル動作確認では「ログを目で見る」「ブラウザで叩く」で十分。本番では違う

  • ユーザーが「サイトが遅い」と言ってきた → どこが遅い? DB? API? フロント?
  • 月曜の朝だけ 500 が出る → 何が起きている?
  • 「先週からエラー率が増えている」 → どこから?
  • 攻撃が来ている → どの IP?どのエンドポイント?

これらに 数秒で回答できる仕組み が監視 (Observability / オブザーバビリティ)。

監視の 3 本柱(Observability の Three Pillars):

  1. Logs(ログ) - 「何が起きたか」のテキスト記録
  2. Metrics(メトリクス) - 「どれくらい」の数値時系列
  3. Traces(トレース) - 「どの順序で」のリクエスト追跡

このレッスンでは、本番運用の入口として nginx ログ → Prometheus メトリクス → アラート までを通しで学ぶ。


セッション①: ログを読む(15-20分)

1. nginx access.log の構造

192.0.2.10 - - [14/May/2026:10:23:45 +0900] "GET /api/users HTTP/1.1" 200 1234 "https://example.com/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" "-"

/etc/nginx/nginx.conf でフォーマットが定義されている:

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                '$status $body_bytes_sent "$http_referer" '
                '"$http_user_agent" "$http_x_forwarded_for"';

各フィールドの意味

変数意味
$remote_addr192.0.2.10クライアント IP
$remote_user-Basic 認証ユーザー名(なければ -
$time_local[14/May/2026:10:23:45 +0900]アクセス時刻
$requestGET /api/users HTTP/1.1HTTP メソッド + パス + バージョン
$status200レスポンスステータス
$body_bytes_sent1234レスポンス本文のバイト数
$http_refererhttps://example.com/参照元 URL
$http_user_agentMozilla/5.0 ...UA
$http_x_forwarded_for-プロキシ経由時の元 IP

加えたい追加項目

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                '$status $body_bytes_sent "$http_referer" '
                '"$http_user_agent" "$http_x_forwarded_for" '
                'rt=$request_time uct=$upstream_connect_time '
                'uht=$upstream_header_time urt=$upstream_response_time';
  • $request_time: リクエスト全体の処理時間(秒)。遅延調査の必須項目
  • $upstream_*: バックエンド(Go アプリなど)への接続・応答時間。nginx 自身か上流かを切り分けられる

JSON フォーマットへの移行

構造化ログ(JSON)にしておくと、後で Loki / Elasticsearch でフィールド単位の検索ができる:

log_format json escape=json '{'
  '"time":"$time_iso8601",'
  '"remote_addr":"$remote_addr",'
  '"method":"$request_method",'
  '"path":"$request_uri",'
  '"status":$status,'
  '"bytes":$body_bytes_sent,'
  '"request_time":$request_time,'
  '"upstream_response_time":"$upstream_response_time",'
  '"user_agent":"$http_user_agent",'
  '"referer":"$http_referer"'
'}';
 
access_log /var/log/nginx/access.log json;

構造化ログは現代の必須grep で目視するだけでなく、機械処理を想定する。

2. error.log の見方

sudo tail -f /var/log/nginx/error.log
2026/05/14 10:23:50 [error] 1234#0: *5678 connect() failed (111: Connection refused) while connecting to upstream, client: 192.0.2.10, server: example.com, request: "GET /api/users HTTP/1.1", upstream: "http://127.0.0.1:8080/api/users", host: "example.com"

error.log のレベル

nginx.conferror_log /var/log/nginx/error.log warn; のように指定する。

レベル意味
debug詳細デバッグ開発時のみ
info情報通常運用には冗長
notice通知設定リロード等
warn警告一時的な問題、待機時間
errorエラー503, 502, upstream timeout
crit致命的起動失敗等

本番は warn 推奨error だけだと取りこぼす情報がある。

error.log で client closed connection が頻発

[info] *xxx client closed connection while waiting for request

これは正常動作。Keep-Alive 接続のタイムアウト時にクライアントが切ることで起きる。info レベルに出るので気にしない。

ただし upstream prematurely closed connection は別物。バックエンド(Go アプリ)が応答中に死んだ証拠。要調査。

3. ログから情報を引き出すワンライナー

# ステータスごとの件数
awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn
 
# 上位アクセス IP
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head
 
# 5xx を出しているリクエスト
awk '$9 ~ /^5/' /var/log/nginx/access.log
 
# 直近 1 時間のリクエスト数
awk -v t="$(date -d '1 hour ago' '+%d/%b/%Y:%H')" '$4 > "["t' /var/log/nginx/access.log | wc -l
 
# レスポンスタイム上位(JSON ログ前提)
jq -r 'select(.request_time > 1) | "\(.request_time) \(.path)"' /var/log/nginx/access.log | sort -rn | head

ログを grep で運用するのは限界がある

上のワンライナーで急場をしのげるが、本番で長期運用するには無理がある:

  • ファイルが大きすぎて grep に数十秒かかる(数 GB / 日)
  • 複数サーバーのログを横断検索できない
  • 数値の集計が遅い
  • アラート連携ができない

→ ログ集約 (Loki / Elasticsearch / Datadog Logs) と、メトリクス (Prometheus) の世界に進む。

4. logrotate でログをローテーション

# Ubuntu の nginx パッケージは標準で /etc/logrotate.d/nginx を入れている
cat /etc/logrotate.d/nginx
/var/log/nginx/*.log {
    daily
    missingok
    rotate 14
    compress
    delaycompress
    notifempty
    create 0640 www-data adm
    sharedscripts
    prerotate
        if [ -d /etc/logrotate.d/httpd-prerotate ]; then \
            run-parts /etc/logrotate.d/httpd-prerotate; \
        fi \
    endscript
    postrotate
        invoke-rc.d nginx rotate >/dev/null 2>&1
    endscript
}

logrotate の役割

ログファイルは何もしないと無限に肥大化し、ディスクを食い尽くしてサーバー停止を招く。

logrotate は:

  1. access.logaccess.log.1 にリネーム
  2. nginx に「ログファイルを開き直せ」とシグナルを送る
  3. 古いログを gzip で圧縮
  4. 14 日以上前のログを削除

頻度(daily/weekly)保持期間(rotate N) はディスク容量と監査要件で決める。法令上「3 年間保管」のような要件がある業界もある。

アプリ側でログを書きっぱなしの事故

Go アプリで log.Printlnos.Stdout ではなく独自にファイルに書いている場合、logrotate が rename しても旧 fd を掴んだまま → ログが空ファイルに書き込まれる事故。

対策:

  • アプリは stdout/stderr に出す(systemd や Docker が経路を管理)
  • どうしてもファイルに書くならSIGUSR1 などでログを開き直す機構を実装

セッション②: Prometheus + Grafana の世界(25-30分)

5. Prometheus のモデル

[アプリ] --(/metrics エンドポイントを公開)

   | scrape(15秒ごとに HTTP GET)
   |
[Prometheus] -- ストレージ(時系列DB)
   |
   | クエリ(PromQL)

[Grafana] -- グラフ描画
   |
   | アラート

[Alertmanager] -- Slack / Pager / メール通知

Prometheus の特異点: pull モデル

多くの監視システムは「アプリが計測値を push する」(StatsD, Datadog Agent 等)。

Prometheus は pull モデル: Prometheus サーバーがアプリの /metrics を取りに行く。

pull の利点:

  • アプリは「いつ送る」を意識しなくていい(Prometheus が定期取得)
  • スケジューリングが Prometheus 側で一元管理
  • アプリが生きているか自動的に分かる(取得失敗 = ダウン)

pull の欠点:

  • 短命なジョブ(cron 等)はメトリクスが取られる前に終わる → Pushgateway という仲介を経由する
  • ネットワーク到達性が必要(Prometheus → アプリへのアクセス権)

6. メトリクスの 4 タイプ

タイプ用途
Counter単調増加する数リクエスト総数、エラー総数
Gauge上下する数メモリ使用量、現在の接続数
Histogram値の分布レスポンスタイム、ペイロードサイズ
SummaryHistogram と似るが分位数計算レスポンスタイムの p99

Counter と Gauge の使い分け

  • Counter: 「累計回数」。http_requests_total のような。下がらない。再起動時にゼロに戻る
  • Gauge: 「今この瞬間の値」。memory_usage_bytes, active_connections のような。上下する

よくある初心者の誤り: 「現在のリクエスト数 / 秒」を Gauge にしてしまう。正解は Counter にして、PromQL の rate() で秒間変化率を計算する

Histogram vs Summary

どちらも「分位数(p50, p95, p99)」を出せるが、内部実装が違う:

  • Histogram: 事前定義された bucket(例: 0.1秒, 0.3秒, 1秒, 3秒)に区切ってカウント。サーバー側で分位数計算。複数インスタンスを合算できる
  • Summary: アプリ内で逐次分位数を計算。集約できない(複数インスタンスの p99 を平均しても本当の p99 にならない)

基本は Histogram を選ぶ。Summary を使うのは「1 インスタンスの厳密な分位数が欲しい時」のみ。

7. Go アプリにメトリクスを実装する

// metrics.go
package main
 
import (
    "net/http"
    "strconv"
    "time"
 
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)
 
var (
    httpRequestsTotal = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "HTTP リクエスト総数",
        },
        []string{"method", "path", "status"},
    )
 
    httpRequestDuration = promauto.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "HTTP リクエスト処理時間",
            Buckets: prometheus.DefBuckets, // 0.005, 0.01, ..., 10
        },
        []string{"method", "path"},
    )
 
    activeRequests = promauto.NewGauge(
        prometheus.GaugeOpts{
            Name: "http_active_requests",
            Help: "処理中のリクエスト数",
        },
    )
)
 
// メトリクス計測用のミドルウェア
func metricsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        activeRequests.Inc()
        defer activeRequests.Dec()
 
        rw := &responseWriter{ResponseWriter: w, status: 200}
        next.ServeHTTP(rw, r)
 
        duration := time.Since(start).Seconds()
        httpRequestsTotal.WithLabelValues(r.Method, r.URL.Path, strconv.Itoa(rw.status)).Inc()
        httpRequestDuration.WithLabelValues(r.Method, r.URL.Path).Observe(duration)
    })
}
 
type responseWriter struct {
    http.ResponseWriter
    status int
}
 
func (rw *responseWriter) WriteHeader(code int) {
    rw.status = code
    rw.ResponseWriter.WriteHeader(code)
}
 
func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", handleUsers)
    mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
        w.Write([]byte("ok"))
    })
 
    // メトリクスエンドポイント(Prometheus が scrape する)
    mux.Handle("/metrics", promhttp.Handler())
 
    handler := metricsMiddleware(mux)
    http.ListenAndServe(":8080", handler)
}
 
func handleUsers(w http.ResponseWriter, r *http.Request) {
    // ダミー処理
    time.Sleep(50 * time.Millisecond)
    w.Write([]byte(`[{"id":1,"name":"Alice"}]`))
}
# 起動後
curl http://localhost:8080/metrics
# HELP http_requests_total HTTP リクエスト総数
# TYPE http_requests_total counter
http_requests_total{method="GET",path="/api/users",status="200"} 42
 
# HELP http_request_duration_seconds HTTP リクエスト処理時間
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{method="GET",path="/api/users",le="0.005"} 0
http_request_duration_seconds_bucket{method="GET",path="/api/users",le="0.01"} 0
http_request_duration_seconds_bucket{method="GET",path="/api/users",le="0.025"} 0
http_request_duration_seconds_bucket{method="GET",path="/api/users",le="0.05"} 12
http_request_duration_seconds_bucket{method="GET",path="/api/users",le="0.1"} 42
...
http_request_duration_seconds_count{method="GET",path="/api/users"} 42
http_request_duration_seconds_sum{method="GET",path="/api/users"} 2.1

パスをそのままラベルにする事故

httpRequestsTotal.WithLabelValues(r.Method, r.URL.Path, ...).Inc()

/api/users/1, /api/users/2, /api/users/3 … の ID ごとに別ラベルが生成され、メトリクスがメモリ爆発する(cardinality explosion)。

正しくは: ルートパターンを使う。chi などのルーターから chi.RouteContext(r.Context()).RoutePattern()/api/users/{id} を取り出す。

8. docker-compose で Prometheus + Grafana を起動

# docker-compose.yml
services:
  app:
    build: .
    ports: ['8080:8080']
 
  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    ports: ['9090:9090']
 
  grafana:
    image: grafana/grafana:latest
    environment:
      GF_SECURITY_ADMIN_PASSWORD: admin
    ports: ['3000:3000']
    volumes:
      - grafana-data:/var/lib/grafana
 
volumes:
  grafana-data:
# prometheus.yml
global:
  scrape_interval: 15s
 
scrape_configs:
  - job_name: 'app'
    static_configs:
      - targets: ['app:8080']
docker compose up -d
# Prometheus UI: http://localhost:9090
# Grafana UI:    http://localhost:3000 (admin / admin)

9. PromQL 入門

Prometheus UI の Graph タブで以下を試す:

# 全体のリクエスト数(累計)
http_requests_total
 
# 秒間リクエスト数(最近 1 分の平均)
rate(http_requests_total[1m])
 
# ステータス別の秒間リクエスト数
sum by (status) (rate(http_requests_total[1m]))
 
# エラー率
sum(rate(http_requests_total{status=~"5.."}[5m]))
  / sum(rate(http_requests_total[5m]))
 
# レスポンスタイム p95
histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
 
# パス別 p95
histogram_quantile(0.95,
  sum by (le, path) (rate(http_request_duration_seconds_bucket[5m]))
)

PromQL は「時系列の数式」

普通の SQL とは違う。「ある瞬間のスナップショット」+「時間範囲ベクトル」+「演算子」 で表現する。

  • インスタント・ベクトル: http_requests_total{status="200"} → 各ラベル組み合わせの「今の値」
  • レンジ・ベクトル: http_requests_total[5m] → 過去 5 分間の値の列
  • 関数: rate(), sum(), histogram_quantile()

最初は難しいが、3 つのパターンを覚えれば 8 割をカバー:

  1. 秒間レート: rate(<counter>[5m])
  2. 合計集計: sum by (<ラベル>) (...)
  3. 分位数: histogram_quantile(0.95, sum by (le, ...) (rate(<histogram>_bucket[5m])))

セッション③: ログ集約とアラート(20-25分)

10. Loki でログ集約

# docker-compose.yml に追記
services:
  loki:
    image: grafana/loki:latest
    ports: ['3100:3100']
 
  promtail:
    image: grafana/promtail:latest
    volumes:
      - /var/log:/var/log
      - ./promtail-config.yml:/etc/promtail/config.yml
    command: -config.file=/etc/promtail/config.yml
# 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: nginx
    static_configs:
      - targets: [localhost]
        labels:
          job: nginx
          __path__: /var/log/nginx/*.log

Loki の設計思想

Grafana Labs が「Prometheus と同じ思想でログをやる」というコンセプトで作った。

  • インデックスはラベルのみ(Elasticsearch のように全文インデックスを作らない)
  • 本文はそのままチャンク保存
  • → ストレージ容量・コストが Elasticsearch の 1/10 以下になる事例多数

トレードオフ: 全文検索は速くない。「特定のラベルセットの中で grep する」が基本。Prometheus と同じラベル文化でログも扱えるので学習コストが低い。

LogQL(Loki のクエリ言語)

# nginx ジョブの最新ログ
{job="nginx"}
 
# 5xx だけフィルタ
{job="nginx"} |= " 5"
 
# JSON ログから status を抽出してフィルタ
{job="nginx"} | json | status >= 500
 
# ログから秒間エラー数を計算(メトリクス化)
sum(rate({job="nginx"} | json | status >= 500 [5m]))

ログをメトリクスに変換できる のが Loki + Grafana の強み。

11. Grafana ダッシュボード

ブラウザで http://localhost:3000 を開き、Data Source として Prometheus と Loki を追加。

「Dashboard → New」で:

  • Panel 1: rate(http_requests_total[1m]) (秒間リクエスト)
  • Panel 2: histogram_quantile(0.95, ...) (p95 レスポンス時間)
  • Panel 3: Loki クエリで nginx エラーログ

既製ダッシュボードを使う

Grafana の Dashboard ID は無料公開されている。例:

  • node_exporter Full (ID: 1860) - Linux サーバーメトリクス
  • Go Processes (ID: 6671) - Go ランタイム
  • nginx exporter (ID: 12708) - nginx メトリクス

「Dashboard → Import → ID 入力」で取り込める。自前でゼロから作る前に、既製を眺めると勉強になる

12. アラート設計

# alerts.yml
groups:
  - name: http
    rules:
      - alert: HighErrorRate
        expr: |
          sum(rate(http_requests_total{status=~"5.."}[5m]))
            / sum(rate(http_requests_total[5m])) > 0.05
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "エラー率が 5% を超過"
          description: "5xx エラー率が 5 分間 5% を超えています。"
 
      - alert: HighLatency
        expr: |
          histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m]))) > 1.0
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "p95 レスポンスタイムが 1 秒超"
 
      - alert: ServiceDown
        expr: up{job="app"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "アプリが応答していません"

アラート設計の原則

「人を起こす価値があるか」 を毎回問う。

  • critical: 今すぐ人を呼ぶ価値がある(サービス停止、決済停止)→ ページャー / 電話
  • warning: 今晩寝てから対応してよい(じわじわ悪化中)→ Slack 通知
  • info: 後で見ればいい(参考情報)→ ダッシュボードで表示のみ

for: 5m が重要: 5 分間継続してから発火。瞬間的なスパイクで起こされないため。

アラート疲労 (alert fatigue)

アラートが多すぎると人間は「またか」と無視するようになる。Boy Who Cried Wolf 現象

兆候:

  • 1 日に 20 件以上アラートが来る
  • 「これ前にも来たな、放置でいいやつ」と知っている
  • on-call エンジニアが燃え尽きる

対策:

  • アラートは「行動可能 (actionable)」なものだけ
  • 重要度を分ける(critical / warning / info)
  • 定期的にアラートを見直す(鳴り続けるが対処していないものは閾値を見直す or 削除)
  • SLI / SLO ベースの設計(次のレベル)

13. Alertmanager と通知

# alertmanager.yml
route:
  receiver: 'default'
  group_by: ['alertname', 'severity']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - matchers: ['severity="critical"']
      receiver: 'pagerduty'
    - matchers: ['severity="warning"']
      receiver: 'slack'
 
receivers:
  - name: 'default'
    slack_configs:
      - api_url: 'https://hooks.slack.com/services/XXX/YYY/ZZZ'
        channel: '#alerts'
 
  - name: 'slack'
    slack_configs:
      - api_url: 'https://hooks.slack.com/services/XXX/YYY/ZZZ'
        channel: '#alerts'
 
  - name: 'pagerduty'
    pagerduty_configs:
      - service_key: 'xxxxxxxxxxxxxxxx'

グループ化と抑制

似たアラートが同時多発するのを抑える機能:

  • group_by: 同じグループ内のアラートをまとめて 1 通知に
  • group_wait: グループ最初のアラート発火後、N 秒待ってまとめて送る
  • group_interval: 同じグループに新規追加があった時の通知間隔
  • repeat_interval: 解消されていない既存アラートを再通知する間隔

設定なしだと、サーバー 100 台で同時に「ディスク不足」が起きた瞬間に 100 通の Slack 通知 が来てパニック。


セッション④: 分散トレース入門(10-15分)

14. OpenTelemetry とは

[ユーザー] → [nginx] → [Go API] → [Auth Service] → [DB]
                          ↘ [Cache Service]

マイクロサービス構成では、1 リクエストが複数サービスを横断する。「どこで遅い?」「どこで失敗した?」 を追跡するのが分散トレース。

OpenTelemetry (OTel) の立ち位置

CNCF の業界標準仕様。Datadog / Jaeger / Tempo / Lightstep など、各社の独自規格を一本化する動き。

  • API: 言語ごとの計装インターフェース
  • SDK: 実装
  • OTLP: 収集・送信プロトコル
  • Collector: 中継エージェント

2025-2026 年で本格普及。新規プロジェクトは OTel 一択

15. Go での最小実装

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
    "go.opentelemetry.io/otel/sdk/trace"
)
 
func initTracer() (*trace.TracerProvider, error) {
    exp, err := otlptracehttp.New(context.Background())
    if err != nil {
        return nil, err
    }
 
    tp := trace.NewTracerProvider(
        trace.WithBatcher(exp),
    )
    otel.SetTracerProvider(tp)
    return tp, nil
}
 
// ハンドラ内でスパンを作る
func handleUsers(w http.ResponseWriter, r *http.Request) {
    tracer := otel.Tracer("my-app")
    ctx, span := tracer.Start(r.Context(), "handleUsers")
    defer span.End()
 
    // DB 呼び出しを子スパンに
    _, dbSpan := tracer.Start(ctx, "db.query")
    // ... DB クエリ
    dbSpan.End()
 
    w.Write([]byte(`...`))
}

トレース・ログ・メトリクスの統合

OpenTelemetry 経由で出すと:

  • TraceID をログにも含める → 「このリクエストのログ全部」を 1 クリックで集められる
  • Grafana Tempo + Loki + Prometheus が同じラベル体系で繋がる
  • アラート発火 → 該当時間帯のトレース → 該当ログ という連鎖デバッグが可能

ここまで来ると本物の Observability。


アンチパターン総まとめ

ログを全部 grep で運用

数 GB の日別ログを grep して数十秒待つ生活。集約ツールを入れる時間より遥かに無駄。

メトリクスがない

「先週から遅い気がする」を数値で言えない。後追いで導入すると「先週との比較」が永遠にできない。最初から仕込む

全てを critical アラートにする

どれが本当に緊急か分からなくなる。重要度を分ける。

cardinality 爆発

ユーザー ID、リクエスト ID、URL パスの ID 部分などを そのままラベルに入れると、メトリクス数が時間とともに膨張して Prometheus がメモリ溢れ。ラベルは 有限の値の組み合わせ だけにする。

自分のサーバーで監視サーバーを動かす

監視対象と監視サーバーが同居 → 監視対象がダウンすると同時に監視も死ぬ → ダウンが検知できない。監視は別サーバー or マネージドサービス

ログに個人情報・秘密情報をそのまま出す

log.Printf("user logged in: %+v", user)
// → パスワードハッシュ、メアド、住所が平文でログに

GDPR / 個人情報保護法違反。クレジットカード番号、パスワード、APIキー、メールアドレスはログに出さない、または *** マスクする。


セキュリティ視点

監視自体のセキュリティ

  • /metrics エンドポイントは外部公開しないlocalhost バインド、または認証
  • Grafana 管理画面に強いパスワード or SSO(admin/admin のまま放置事故が定番)
  • ログに認証情報を載せない(Authorization ヘッダのフィルタ)
  • Prometheus のスクレイプ先 IP を制限

Grafana 公開状態の事故

ベンチャー企業で Grafana を 0.0.0.0:3000 のままパスワード admin/admin で運用 → 外部からダッシュボード閲覧 → ビジネスメトリクスが丸見え → 競合に流出。Grafana は必ず認証 + IP 制限 or VPN 内


実例: Go アプリにメトリクスエンドポイント実装の完全コード

// main.go
package main
 
import (
    "log"
    "net/http"
    "strconv"
    "time"
 
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)
 
var (
    requestsTotal = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "HTTP リクエスト総数",
        },
        []string{"method", "route", "status"},
    )
 
    requestDuration = promauto.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "HTTP リクエスト所要時間(秒)",
            Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5},
        },
        []string{"method", "route"},
    )
 
    inflightRequests = promauto.NewGauge(
        prometheus.GaugeOpts{
            Name: "http_inflight_requests",
            Help: "処理中リクエスト数",
        },
    )
)
 
type metricsHandler struct {
    handler http.Handler
    route   string
}
 
func (m *metricsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    start := time.Now()
    inflightRequests.Inc()
    defer inflightRequests.Dec()
 
    rw := &statusRecorder{ResponseWriter: w, status: 200}
    m.handler.ServeHTTP(rw, r)
 
    duration := time.Since(start).Seconds()
    requestsTotal.WithLabelValues(r.Method, m.route, strconv.Itoa(rw.status)).Inc()
    requestDuration.WithLabelValues(r.Method, m.route).Observe(duration)
}
 
type statusRecorder struct {
    http.ResponseWriter
    status int
}
 
func (r *statusRecorder) WriteHeader(code int) {
    r.status = code
    r.ResponseWriter.WriteHeader(code)
}
 
func wrap(route string, handler http.HandlerFunc) http.Handler {
    return &metricsHandler{handler: handler, route: route}
}
 
func main() {
    mux := http.NewServeMux()
    mux.Handle("/api/users", wrap("/api/users", handleUsers))
    mux.Handle("/healthz", wrap("/healthz", func(w http.ResponseWriter, _ *http.Request) {
        w.Write([]byte("ok"))
    }))
    mux.Handle("/metrics", promhttp.Handler())
 
    log.Println("listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}
 
func handleUsers(w http.ResponseWriter, r *http.Request) {
    time.Sleep(50 * time.Millisecond)
    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`[{"id":1,"name":"Alice"}]`))
}
go mod init metrics-demo
go get github.com/prometheus/client_golang/prometheus
go run main.go
 
# 別端末で叩く
for i in $(seq 1 50); do curl -s http://localhost:8080/api/users > /dev/null; done
 
# メトリクス確認
curl http://localhost:8080/metrics | grep http_

練習課題

  1. 上の Go コードを動かし、/metrics で Prometheus 形式が見えることを確認
  2. docker-compose で Prometheus + Grafana を起動
  3. Grafana に Prometheus を Data Source として追加
  4. PromQL で「秒間リクエスト数」「p95 レスポンスタイム」「エラー率」のグラフを作る
  5. nginx のログをローカルファイルから読み、awk でステータス別件数を集計
  6. nginx のログを JSON 化(log_format 変更)して、jqrequest_time > 1 のリクエストを抽出
  7. Grafana でアラートを 1 つ作成(例: “p95 が 1 秒超で 5 分継続”)
  8. Loki + Promtail を docker-compose に追加し、ログを Grafana から見られるようにする
  9. Go アプリに /healthz を実装し、Prometheus の up{job="app"} でダウン検知できることを確認
  10. cardinality_explosion_demo として、わざと URL パスをラベルに入れたメトリクスを作り、ラベル数が膨張することを目視

締め: git で証跡を残す

cd ~/learn/infra/monitoring
 
# 構成一式
ls
# main.go                   # Go アプリ + メトリクス
# docker-compose.yml         # Prom + Grafana + Loki
# prometheus.yml             # scrape 設定
# alerts.yml                 # アラート定義
# promtail-config.yml        # ログ取り込み
 
git add .
git commit -m "feat(monitoring): Prometheus/Grafana/Loki によるアプリ可観測化"

チェックリスト

  • nginx access.log の各フィールドを説明できる
  • error.log と access.log の使い分けを理解
  • logrotate の目的とフローを説明できる
  • Prometheus の pull モデルと scrape の概念を理解
  • Counter / Gauge / Histogram の使い分けができる
  • Go アプリに 3 種のメトリクスを実装した
  • PromQL で rate(), histogram_quantile(), sum by () を使える
  • Grafana で自前ダッシュボードを 1 つ作った
  • Loki でログを集約してクエリした
  • アラートを 1 つ作り、severity の概念を理解
  • cardinality 爆発の罠を説明できる
  • OpenTelemetry の役割を説明できる

詰まった時のチートシート

やりたいこと方法
nginx ログをリアルタイム監視tail -f /var/log/nginx/access.log
5xx だけ抽出awk '$9 ~ /^5/' /var/log/nginx/access.log
上位 IPawk '{print $1}' access.log | sort | uniq -c | sort -rn | head
Go メトリクス公開mux.Handle("/metrics", promhttp.Handler())
Counter 増分counter.WithLabelValues(...).Inc()
Histogram 観測histogram.WithLabelValues(...).Observe(duration)
秒間レート(PromQL)rate(http_requests_total[1m])
p95(PromQL)histogram_quantile(0.95, sum by (le) (rate(*_bucket[5m])))
ダウン検知up == 0
Loki クエリ{job="nginx"} |= "error"

対比表: 3つの監視メンタルモデル

モデル観点主な指標適性
Four Golden Signals (Google SRE)ユーザー体験Latency / Traffic / Errors / SaturationWeb API / RPC
REDリクエスト視点Rate / Errors / Durationマイクロサービス
USEリソース視点Utilization / Saturation / Errorsインフラ・OS

対比表: メトリクス vs ログ vs トレース

観点MetricsLogsTraces
データ量小(時系列の数値)大(テキスト)中(リクエスト単位)
質問「どれくらい?」「何が起きた?」「どの順序で?」
集計得意苦手(grep 系)一部得意
即時アラート得意苦手
代表 OSSPrometheusLoki / ElasticsearchJaeger / Tempo

「実務OK」基準

  • 本番障害時に access.log を 5 分以内に切り分けられる: どの IP がどんなパスを叩いているか
  • Prometheus に Counter / Histogram を実装できる: 設計含めて即書ける
  • PromQL の rate / histogram_quantile / sum by を使える: 3 つで 8 割カバー
  • アラート設計の severity 分けができる: critical / warning / info
  • cardinality 爆発を避けるラベル設計ができる
  • 「観測できない」が意思決定の障害になることを理解している

自己評価チェックリスト

知識レベル

  • 3本柱 (Logs / Metrics / Traces) を場面別に使い分けられる
  • Four Golden Signals / RED / USE のどれが何向きか言える
  • cardinality 爆発を起こすラベル例(user_id 等)を即答できる

実行レベル

  • Go アプリで /metrics を立てた
  • Grafana で1つダッシュボードを作った
  • Alertmanager で Slack 通知まで通した

メタ認知

  • 自分のチームのアラート疲れ(無視されるアラート)が無いか棚卸しした
  • SLO を数値で示せる状態か振り返った

さらに深掘りするなら

  • 公式: Prometheus Documentation
  • 公式: Grafana Documentation
  • 公式: OpenTelemetry
  • 書籍: 『入門 監視』(Mike Julian, O’Reilly Japan)
  • 書籍: 『Site Reliability Engineering』(Google SRE 本, 無料公開: sre.google/books/)
  • ブログ: Brendan Gregg の “USE Method” / Google の “Four Golden Signals”

次のレッスン

3-6_バックアップ.md で、本番運用最後の砦「バックアップとリストア」へ。