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):
- Logs(ログ) - 「何が起きたか」のテキスト記録
- Metrics(メトリクス) - 「どれくらい」の数値時系列
- 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.log2026/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.confでerror_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 は:
access.logをaccess.log.1にリネーム- nginx に「ログファイルを開き直せ」とシグナルを送る
- 古いログを
gzipで圧縮- 14 日以上前のログを削除
頻度(daily/weekly) と 保持期間(rotate N) はディスク容量と監査要件で決める。法令上「3 年間保管」のような要件がある業界もある。
アプリ側でログを書きっぱなしの事故
Go アプリで
log.Printlnをos.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 | 値の分布 | レスポンスタイム、ペイロードサイズ |
| Summary | Histogram と似るが分位数計算 | レスポンスタイムの 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 割をカバー:
- 秒間レート:
rate(<counter>[5m])- 合計集計:
sum by (<ラベル>) (...)- 分位数:
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/*.logLoki の設計思想
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_練習課題
- 上の Go コードを動かし、
/metricsで Prometheus 形式が見えることを確認 - docker-compose で Prometheus + Grafana を起動
- Grafana に Prometheus を Data Source として追加
- PromQL で「秒間リクエスト数」「p95 レスポンスタイム」「エラー率」のグラフを作る
- nginx のログをローカルファイルから読み、
awkでステータス別件数を集計 - nginx のログを JSON 化(log_format 変更)して、
jqでrequest_time > 1のリクエストを抽出 - Grafana でアラートを 1 つ作成(例: “p95 が 1 秒超で 5 分継続”)
- Loki + Promtail を docker-compose に追加し、ログを Grafana から見られるようにする
- Go アプリに
/healthzを実装し、Prometheus のup{job="app"}でダウン検知できることを確認 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 |
| 上位 IP | awk '{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 / Saturation | Web API / RPC |
| RED | リクエスト視点 | Rate / Errors / Duration | マイクロサービス |
| USE | リソース視点 | Utilization / Saturation / Errors | インフラ・OS |
対比表: メトリクス vs ログ vs トレース
| 観点 | Metrics | Logs | Traces |
|---|---|---|---|
| データ量 | 小(時系列の数値) | 大(テキスト) | 中(リクエスト単位) |
| 質問 | 「どれくらい?」 | 「何が起きた?」 | 「どの順序で?」 |
| 集計 | 得意 | 苦手(grep 系) | 一部得意 |
| 即時アラート | 得意 | 中 | 苦手 |
| 代表 OSS | Prometheus | Loki / Elasticsearch | Jaeger / 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 で、本番運用最後の砦「バックアップとリストア」へ。