メトリクスの集め方(push と pull)
実装:
observability/collect// 実行:go test ./observability/collect/
値をプロセスから収集側へ運ぶ方法は、収集側が叩きに行くか、対象が送りつけるかの 2 通りしかない。叩きに行くほうには 1 周の予算があり、対象数 × 1 回のコストが間隔を超えた瞬間に取りこぼす。送りつけるほうに予算は無いが、届かないことから何も言えない。この差の正体は「叩くかどうか」ではなく、居るはずの一覧を持っているかどうかにある。
この章で作るもの
メトリクスとヒストグラムは 1 台のプロセスが値をどう持つかで、時系列の整列と集約は届いた後にどう潰すかだった。その間に、値をプロセスから収集側へ運ぶ段がある。
運び方は 2 通りしかない。収集側が対象を定期的に叩いて読むか(pull)、対象が収集側へ送りつけるか(push)。Prometheus が前者、StatsD や OTLP が後者になる。
どちらでも同じ数字が届くように見える。だが 3 か所で性質が割れ、しかもその割れ方は運用の判断に直結する。この章では両方を実装して、割れる場所を数える。
pull(収集側が叩く) push(対象が送る)
┌──────────┐ ┌──────────┐
│ 収集側 │ │ 収集側 │
└────┬─────┘ └────▲─────┘
│ 間隔ごとに叩く │ 対象の都合で送る
┌────▼─────┐ ┌────┴─────┐
│ 対象 ×N │ │ 対象 ×N │
└──────────┘ └──────────┘
叩くには「誰が居るか」の一覧が要る 一覧なしで始められる
→ 返らなければ落ちたと分かる → 来なくても理由が分からない
→ 1 周に予算がある → 予算は無いが、受け口が詰まる順に見ていく。
- pull には 1 周の予算がある: 対象数 × 1 回のコストが間隔を超えると、超えたぶんは読めない
- 落ちた対象について、言えることが違う: 差の正体は一覧を持っているかどうかにある
- 系列はラベルの掛け算で増える: 運ぶ量も持つ量も、ここで桁が決まる
- どこに置くかで、失う範囲が変わる: 収集器の数と被害範囲は逆向きに動く
① pull には 1 周の予算がある
収集側が叩きに行く形を書く。間隔ごとに対象を順に叩き、読めたものを数える:
// PullResult は pull で 1 周したときの結果。
type PullResult struct {
// RoundCost は 1 周にかかった時間。
RoundCost int
// Scraped は読めた対象の数。
Scraped int
// Series は読めた系列の合計。
Series int
// DownDetected は「落ちている」と判定できた対象の ID。
DownDetected []string
// Dropped は間隔に間に合わず、この周で読めなかった対象の数。
Dropped int
}
// Pull は収集側が対象を順に叩く。
//
// 1 周のコストは「対象数 × 1 回のコスト ÷ 同時本数」で決まる。これが間隔を超えると、
// 超えたぶんの対象はこの周で読めない。**pull には 1 周の予算がある**。
func Pull(targets []Target, cfg Config) PullResult {
w := cfg.Workers
if w < 1 {
w = 1
}
// 間隔の中で叩ける上限。
capacity := cfg.Interval * w / cfg.ScrapeCost
r := PullResult{}
for i, t := range targets {
if i >= capacity {
r.Dropped++
continue
}
if t.Up {
r.Scraped++
r.Series += t.Series
} else {
// 叩いて返らないので、落ちていると分かる。
r.DownDetected = append(r.DownDetected, t.ID)
}
}
scrapes := len(targets)
if scrapes > capacity {
scrapes = capacity
}
r.RoundCost = scrapes * cfg.ScrapeCost / w
sort.Strings(r.DownDetected)
return r
}
// Fits は、その対象数が 1 周の予算に収まるかを返す。
func Fits(n int, cfg Config) bool {
w := cfg.Workers
if w < 1 {
w = 1
}
return n*cfg.ScrapeCost <= cfg.Interval*w
}
// MaxTargets は間隔に収まる対象数の上限を返す。
func MaxTargets(cfg Config) int {
w := cfg.Workers
if w < 1 {
w = 1
}
return cfg.Interval * w / cfg.ScrapeCost
}1 周にかかる時間は「対象数 × 1 回のコスト ÷ 同時本数」で決まる。これが間隔を超えると、超えたぶんの対象はその周で読めない。間隔は予算であって、希望ではない。
間隔 60、1 回のコスト 1、同時 1 本で測るとこうなった。上限は 60 対象になる。
| 対象数 | 読めた | 落とした |
|---|---|---|
| 10 | 10 | 0 |
| 60 | 60 | 0 |
| 61 | 60 | 1 |
| 120 | 60 | 60 |
境界がぴったり出る。60 までは全部読め、61 で 1 つ落ち、120 では半分が落ちる。テストでこの 3 点を固定した。
逃げ道は 2 つある。同時に叩く本数を増やすか、間隔を延ばすか。
| 同時本数 | 上限の対象数 |
|---|---|
| 1 | 60 |
| 2 | 120 |
| 4 | 240 |
| 8 | 480 |
どちらも上限を伸ばすが、意味が違う。同時本数を増やすのは収集側の資源を払う。間隔を延ばすのは時間の解像度を捨てることで、時系列の整列と集約で見たとおり、窓を広げれば短い事象は消える。60 秒間隔にすれば 30 秒の障害は見えないかもしれない。
push にはこの予算が無い。対象が自分の都合で送るので、収集側は「1 周」を持たない。かわりに受け口の処理能力が上限になり、超えれば詰まる。予算が消えるのではなく、置き場所が変わる。
② 落ちた対象について、言えることが違う
10 対象のうち 2 つを落として、両方に同じ入力を与える:
// PushResult は push で 1 周ぶん受けたときの結果。
type PushResult struct {
// Received は届いた対象の数。
Received int
// Series は届いた系列の合計。
Series int
// Silent は「何も来なかった」対象の数。
Silent int
// DownDetected は落ちていると判定できた対象の ID。
//
// push では常に空になる。届かないことからは、落ちたのか、送る設定が無いのか、
// 経路が切れたのかを区別できない。
DownDetected []string
}
// Push は対象が送りつけてくる。収集側は受けるだけで、叩きに行かない。
//
// 対象がいくつ居るかを収集側は知らないので、1 周の予算という概念が無い。
// そのかわり、来なかったものについて何も言えない。
func Push(targets []Target, _ Config) PushResult {
r := PushResult{}
for _, t := range targets {
if t.Up {
r.Received++
r.Series += t.Series
} else {
r.Silent++
}
}
return r
}結果はこうなった。
| 読めた/届いた | 無音 | 落ちたと判定 | |
|---|---|---|---|
| pull | 8 | — | t3, t7 |
| push | 8 | 2 | (言えない) |
pull は叩きに行くので、返らなければ落ちたと分かる。しかもどれが落ちたかを名指しできる。push は届かないことしか分からず、それが落ちたせいなのか、送る設定が無いのか、経路が切れたのかを区別できない。分散はなぜ難しいかで見た部分故障と同じ形で、無音の原因が 3 通りあって見分けられない。
だが、これを「pull のほうが優れている」と読むと間違える。差の出どころはもう一段深い。
// KnownTargets は、収集側が「居るはずの対象」の一覧を持っているかどうかを表す。
//
// pull は叩きに行くために一覧が要る(サービスディスカバリ)。一覧があるから、
// 来ないことを異常と判定できる。push は一覧を持たずに始められるが、
// そのぶん「来ないこと」を異常と言えない。
type KnownTargets []string
// SilentButExpected は、一覧に居るのに届かなかった対象を返す。
//
// push でも一覧を別に持てば、落ちたことを言えるようになる。つまり pull と push の
// 差は「叩くかどうか」ではなく、**一覧を持っているかどうか**にある。
func SilentButExpected(known KnownTargets, received []string) []string {
got := map[string]bool{}
for _, id := range received {
got[id] = true
}
var out []string
for _, id := range known {
if !got[id] {
out = append(out, id)
}
}
sort.Strings(out)
return out
}SilentButExpected に「居るはずの一覧」を渡すと、push でも同じ 2 つを名指しできる。テストで、一覧があれば t3 と t7 を特定でき、一覧が無ければ同じ入力から何も言えないことを固定した。
つまり pull が落ちた対象を検出できるのは、叩きに行くからではなく、叩くために一覧を持たざるを得ないからになる。pull は一覧が無いと動けないので、副産物として検出能力が付いてくる。push は一覧なしで始められる手軽さと引き換えに、その副産物を失う。
ここから運用の形が決まる。push で「落ちた」を言いたければ、一覧を別に持つことになる。サービスディスカバリや台帳をどこかに置く必要があり、pull ならそれが最初から要求されている。手軽さの代償は、後から一覧を用意する手間として戻ってくる。
③ 系列はラベルの掛け算で増える
運ぶ量を決めるのは対象の数ではなく、系列の数になる。そして系列はラベルの組み合わせで増える:
// Label はラベル 1 つと、それがとる値の種類の数。
type Label struct {
Name string
Values int
}
// Cardinality はラベルの組み合わせから系列数を返す。
//
// 足し算ではなく掛け算になる。ラベルを 1 つ足すと、その値の種類の数だけ倍になる。
func Cardinality(labels []Label) int {
n := 1
for _, l := range labels {
n *= l.Values
}
return n
}
// Bytes は系列数と 1 系列あたりのバイト数から、保持に要る量を返す。
func Bytes(series, perSeries int) int { return series * perSeries }足し算ではなく掛け算だ。ラベルを 1 つ足すと、その値の種類の数だけ倍になる。
| ラベル | 系列数 |
|---|---|
| pod(30) | 30 |
| pod × endpoint(20) | 600 |
| pod × endpoint × status(5) | 3,000 |
| さらに version(4) | 12,000 |
| さらに user_id(1万) | 30,000,000 |
3 つで 3,000 だったものが、user_id を 1 つ足しただけで 3,000 万になる。1 系列 8 バイトでも 240 MB で、これは 1 時点ぶんでしかない。テストで、ラベルを足すと倍率どおりに増えること、この 3,000 万という数字を固定した。
値域の広いものをラベルにしない、という定石はここから出てくる。ユーザ ID、リクエスト ID、メールアドレス、URL のクエリ文字列。どれも 1 つ入れるだけで桁が変わる。カーディナリティ爆発と呼ばれる事故は、たいてい「デバッグのために一時的に足した」ラベルから起きる。
pull と push でここは変わらない。だが効き方が違って、pull は 1 周の予算に効き(系列が多いほど 1 回のコストが上がる)、push は受け口と保存側に効く。
④ どこに置くかで、失う範囲が変わる
収集側をどこに置くかで、1 つ落ちたときに失うものが変わる:
// Placement は収集側をどこに置くか。
type Placement int
const (
// Central は 1 か所に集める。対象は全部そこへ送る、または 1 つの収集器が全部を叩く。
Central Placement = iota
// Sidecar は対象ごとに 1 つ置く。
Sidecar
// PerNode はノードごとに 1 つ置く(DaemonSet)。
PerNode
)
func (p Placement) String() string {
switch p {
case Sidecar:
return "sidecar"
case PerNode:
return "per-node"
default:
return "central"
}
}
// Layout は配置したときの姿。
type Layout struct {
// Collectors は収集器の数。
Collectors int
// MaxSeriesPerCollector は 1 つの収集器が抱える系列の最大。
MaxSeriesPerCollector int
// LostOnOneFailure は収集器が 1 つ落ちたときに失う系列の最大。
LostOnOneFailure int
}
// Place は対象の並びと置き方から、収集器の数と失う範囲を出す。
func Place(targets []Target, p Placement) Layout {
total := 0
byNode := map[string]int{}
for _, t := range targets {
total += t.Series
byNode[t.Node] += t.Series
}
switch p {
case Sidecar:
max := 0
for _, t := range targets {
if t.Series > max {
max = t.Series
}
}
// 1 対象に 1 つ。落ちてもその対象ぶんしか失わない。
return Layout{Collectors: len(targets), MaxSeriesPerCollector: max, LostOnOneFailure: max}
case PerNode:
max := 0
for _, n := range byNode {
if n > max {
max = n
}
}
// ノードに 1 つ。落ちるとそのノードの対象ぶんを失う。
return Layout{Collectors: len(byNode), MaxSeriesPerCollector: max, LostOnOneFailure: max}
default:
// 1 か所。落ちると全部失う。
return Layout{Collectors: 1, MaxSeriesPerCollector: total, LostOnOneFailure: total}
}
}100 対象、10 ノード、1 対象 50 系列(合計 5,000 系列)で測った。
| 置き方 | 収集器の数 | 1 つが抱える系列 | 1 つ落ちて失う系列 |
|---|---|---|---|
| 中央に 1 つ | 1 | 5,000 | 5,000 |
| ノードごと(DaemonSet) | 10 | 500 | 500 |
| 対象ごと(サイドカー) | 100 | 50 | 50 |
収集器の数と失う範囲が、きれいに逆向きに動く。テストで、この単調性を両方向とも固定した。
読み方はこうなる。中央に 1 つ置くのは運用が最も楽だが、そこが落ちると観測が全部止まる。監視が落ちたことに監視で気づけないという、いちばん困る形になる。サイドカーは被害を 1 対象に閉じ込められるが、収集器がプロセス数だけ増え、それぞれが資源を食う。init container と sidecarで見たとおり起動と停止の順序も要る。
ノードごとは中間で、Kubernetes では DaemonSet として置く。ノードが落ちればそのノードの対象は元々死んでいるので、失う範囲が、もともと壊れる単位と一致する。ここが選ばれやすい理由になる。
失う系列 収集器の数
5000 ┤●中央 1 ┤●中央
│ │
500 ┤ ●ノードごと 10 ┤ ●ノードごと
50 ┤ ●サイドカー 100 ┤ ●サイドカー
└──────────────── └────────────────
どちらを選んでも「両方小さい」にはならない動かす
下のデモは、対象数と間隔を動かして 1 周の予算を確かめ、pull と push で落ちた対象の見え方を比べる。ラベルを足すと系列数が掛け算で伸び、置き方を切り替えると収集器の数と失う範囲が逆に動く。
間隔 60 ・ 1 対象を叩くコスト 1
pull は収集側が叩きに行くので、間隔の中で全対象を叩き切る必要がある。push は対象が送るのでその予算が無いかわりに、 届かないことから理由を言えない。差の出どころは矢印の向きではなく、居るはずの相手を知っているかどうかになる。
設計の観点
- 間隔は予算として読む: 「60 秒ごとに集める」は希望ではなく、60 秒で全対象を叩き切れという要求になる。対象が増えたときに最初に壊れるのはここ
- 一覧を持つかどうかが能力を決める: 落ちたことを言えるのは、居るはずの相手を知っているからになる。push を選ぶなら、一覧を別に用意する手間が後から来る
- 無音は 3 通りある: 落ちた、送っていない、経路が切れた。区別したいなら、区別できる仕組みを別に足す
- ラベルは掛け算: 1 つ足す判断が桁を変える。値域の広いものはラベルでなくログへ回す
- 収集器の数と被害範囲は逆向き: 両方を小さくはできない。壊れる単位に合わせるのが落としどころ
- 監視の穴は監視で見つからない: 収集が止まったことを、その収集で気づくことはできない。外から見る経路を別に持つ
対照と実例
| pull | push | |
|---|---|---|
| 一覧 | 必須(サービスディスカバリ) | 不要で始められる |
| 落ちた対象 | 名指しできる | 一覧を別に持てば言える |
| 1 周の予算 | ある(対象数 × コスト ≤ 間隔) | 無い。かわりに受け口が詰まる |
| 短命なプロセス | 苦手(叩く前に消える) | 得意(消える前に送れる) |
| 経路 | 収集側から対象へ入る | 対象から収集側へ出る |
| 実例 | Prometheus、Google Cloud の一部 | StatsD、OTLP、Datadog Agent、CloudWatch |
実例:
- Prometheus: pull の代表。Kubernetes のサービスディスカバリと組んで対象の一覧を自動で作り、
upという指標で各対象の生死を出す。この章の「一覧があるから検出できる」がそのまま指標になっている - Pushgateway: バッチのように叩く前に終わるジョブのために、値を預けておく置き場。pull の枠組みに push の口を足す形で、短命なプロセスという弱点への答えになる
- OpenTelemetry Collector: push で受けて push で送る中継。サイドカーにもノードごとにも中央にも置けて、この章の 3 通りがそのまま配置の選択肢になる
- Datadog Agent: ノードごとに置く形が標準。DaemonSet の位置づけと同じ
裏どり:
upは pull の副産物: Prometheus は叩けたかどうかをupという指標にして保存する。対象が自分で「私は生きている」と報告しているのではなく、叩いた側が結果を記録している。だから対象が完全に死んでも記録が残る- スクレイプ間隔と保持は別: 間隔を短くすると点が増え、保存量も比例して増える。1 周の予算と保存費用の両方に効くので、間隔は 2 か所を同時に動かすつまみになる
- カーディナリティの上限は実装が決める: 系列数そのものに規格上の上限は無く、収集側のメモリが先に尽きる。事故は「上限に当たって止まる」でなく「じわじわ重くなって落ちる」形で来る
- 短命プロセスは pull の弱点: 数秒で終わるジョブは、次のスクレイプが来る前に消える。Pushgateway や、終了時に送る仕組みで埋めることになる
- 経路の向きは組織の制約になる: pull は収集側から対象へ入るので、ネットワークやファイアウォールの向きが問題になる。相手の環境に入れないなら push しか選べない、という決まり方をすることがある
簡略化したこと
- 時間は単位時間の整数: 実物は秒とタイムアウト。ここでは 1 周の予算が見える最小限にした
- 失敗の再試行なし: 叩いて失敗したら落ちていると即断する。実物は数回試してから判定する
- 受け口の詰まりは数えない: push 側の上限は文章で触れるに留め、キューの溢れは実装していない
- 一覧の作り方は扱わない: サービスディスカバリそのもの(どうやって対象を見つけるか)は範囲外
- 転送の形式なし:
/metricsのテキスト形式も OTLP も扱わない。運ぶ量は系列数で数える - 収集器の資源は数えない: 1 つの収集器が何系列まで抱えられるかは、置き方の比較に必要な範囲だけ
参考資料
- Prometheus: Instrumentation and pull — pull を選んだ理由についての公式の説明
- Prometheus:
upmetric — 叩けたかどうかが指標になる仕組み - Pushgateway の使いどころ — 使ってよい場合と、避けるべき場合
- OpenTelemetry Collector の配置 — サイドカー / ノードごと / 中央の 3 通り
- 実装: observability/collect