Skip to content

メトリクスの集め方(push と pull)

実装: observability/collect/ / 実行: go test ./observability/collect/

値をプロセスから収集側へ運ぶ方法は、収集側が叩きに行くか、対象が送りつけるかの 2 通りしかない。叩きに行くほうには 1 周の予算があり、対象数 × 1 回のコストが間隔を超えた瞬間に取りこぼす。送りつけるほうに予算は無いが、届かないことから何も言えない。この差の正体は「叩くかどうか」ではなく、居るはずの一覧を持っているかどうかにある。

この章で作るもの

メトリクスとヒストグラムは 1 台のプロセスが値をどう持つかで、時系列の整列と集約は届いた後にどう潰すかだった。その間に、値をプロセスから収集側へ運ぶ段がある。

運び方は 2 通りしかない。収集側が対象を定期的に叩いて読むか(pull)、対象が収集側へ送りつけるか(push)。Prometheus が前者、StatsD や OTLP が後者になる。

どちらでも同じ数字が届くように見える。だが 3 か所で性質が割れ、しかもその割れ方は運用の判断に直結する。この章では両方を実装して、割れる場所を数える。

  pull(収集側が叩く)                push(対象が送る)

   ┌──────────┐                     ┌──────────┐
   │ 収集側    │                     │ 収集側    │
   └────┬─────┘                     └────▲─────┘
        │ 間隔ごとに叩く                   │ 対象の都合で送る
   ┌────▼─────┐                     ┌────┴─────┐
   │ 対象 ×N   │                     │ 対象 ×N   │
   └──────────┘                     └──────────┘

   叩くには「誰が居るか」の一覧が要る    一覧なしで始められる
   → 返らなければ落ちたと分かる         → 来なくても理由が分からない
   → 1 周に予算がある                  → 予算は無いが、受け口が詰まる
運び方は 2 通り。矢印の向きが違うだけに見えるが、収集側が対象の一覧を持つかどうかが変わる

順に見ていく。

  1. pull には 1 周の予算がある: 対象数 × 1 回のコストが間隔を超えると、超えたぶんは読めない
  2. 落ちた対象について、言えることが違う: 差の正体は一覧を持っているかどうかにある
  3. 系列はラベルの掛け算で増える: 運ぶ量も持つ量も、ここで桁が決まる
  4. どこに置くかで、失う範囲が変わる: 収集器の数と被害範囲は逆向きに動く

① pull には 1 周の予算がある

収集側が叩きに行く形を書く。間隔ごとに対象を順に叩き、読めたものを数える:

go

// 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 対象になる。

対象数読めた落とした
10100
60600
61601
1206060

境界がぴったり出る。60 までは全部読め、61 で 1 つ落ち、120 では半分が落ちる。テストでこの 3 点を固定した。

逃げ道は 2 つある。同時に叩く本数を増やすか、間隔を延ばすか。

同時本数上限の対象数
160
2120
4240
8480

どちらも上限を伸ばすが、意味が違う。同時本数を増やすのは収集側の資源を払う。間隔を延ばすのは時間の解像度を捨てることで、時系列の整列と集約で見たとおり、窓を広げれば短い事象は消える。60 秒間隔にすれば 30 秒の障害は見えないかもしれない。

push にはこの予算が無い。対象が自分の都合で送るので、収集側は「1 周」を持たない。かわりに受け口の処理能力が上限になり、超えれば詰まる。予算が消えるのではなく、置き場所が変わる

② 落ちた対象について、言えることが違う

10 対象のうち 2 つを落として、両方に同じ入力を与える:

go

// 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
}

結果はこうなった。

読めた/届いた無音落ちたと判定
pull8t3, t7
push82(言えない)

pull は叩きに行くので、返らなければ落ちたと分かる。しかもどれが落ちたかを名指しできる。push は届かないことしか分からず、それが落ちたせいなのか、送る設定が無いのか、経路が切れたのかを区別できない。分散はなぜ難しいかで見た部分故障と同じ形で、無音の原因が 3 通りあって見分けられない。

だが、これを「pull のほうが優れている」と読むと間違える。差の出どころはもう一段深い。

go

// 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 つを名指しできる。テストで、一覧があれば t3t7 を特定でき、一覧が無ければ同じ入力から何も言えないことを固定した。

つまり pull が落ちた対象を検出できるのは、叩きに行くからではなく、叩くために一覧を持たざるを得ないからになる。pull は一覧が無いと動けないので、副産物として検出能力が付いてくる。push は一覧なしで始められる手軽さと引き換えに、その副産物を失う。

ここから運用の形が決まる。push で「落ちた」を言いたければ、一覧を別に持つことになる。サービスディスカバリや台帳をどこかに置く必要があり、pull ならそれが最初から要求されている。手軽さの代償は、後から一覧を用意する手間として戻ってくる

③ 系列はラベルの掛け算で増える

運ぶ量を決めるのは対象の数ではなく、系列の数になる。そして系列はラベルの組み合わせで増える:

go

// 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 つ落ちたときに失うものが変わる:

go

// 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 つ15,0005,000
ノードごと(DaemonSet)10500500
対象ごと(サイドカー)1005050

収集器の数と失う範囲が、きれいに逆向きに動く。テストで、この単調性を両方向とも固定した。

読み方はこうなる。中央に 1 つ置くのは運用が最も楽だが、そこが落ちると観測が全部止まる。監視が落ちたことに監視で気づけないという、いちばん困る形になる。サイドカーは被害を 1 対象に閉じ込められるが、収集器がプロセス数だけ増え、それぞれが資源を食う。init container と sidecarで見たとおり起動と停止の順序も要る。

ノードごとは中間で、Kubernetes では DaemonSet として置く。ノードが落ちればそのノードの対象は元々死んでいるので、失う範囲が、もともと壊れる単位と一致する。ここが選ばれやすい理由になる。

  失う系列                      収集器の数
  5000 ┤●中央                            1 ┤●中央
       │                                   │
   500 ┤    ●ノードごと                  10 ┤    ●ノードごと
    50 ┤        ●サイドカー             100 ┤        ●サイドカー
       └────────────────                    └────────────────

  どちらを選んでも「両方小さい」にはならない
収集器を増やすほど、1 つ落ちたときに失う範囲は小さくなる。運用の手間はその逆に増える

動かす

下のデモは、対象数と間隔を動かして 1 周の予算を確かめ、pull と push で落ちた対象の見え方を比べる。ラベルを足すと系列数が掛け算で伸び、置き方を切り替えると収集器の数と失う範囲が逆に動く。

デモメトリクスをどう集めるか間隔 60 / 同時 1 本 → 上限 60
1周の予算落ちた対象系列数置き方
対象数10306061120240
同時本数124

間隔 60 ・ 1 対象を叩くコスト 1

読めた 60落とした 601周の時間 60 / 間隔 60
上限は 60 対象。120 対象あるので 60 個がこの周で読めない。 同時本数を増やすか、間隔を延ばすしかない

pull は収集側が叩きに行くので、間隔の中で全対象を叩き切る必要がある。push は対象が送るのでその予算が無いかわりに、 届かないことから理由を言えない。差の出どころは矢印の向きではなく、居るはずの相手を知っているかどうかになる。

設計の観点

  • 間隔は予算として読む: 「60 秒ごとに集める」は希望ではなく、60 秒で全対象を叩き切れという要求になる。対象が増えたときに最初に壊れるのはここ
  • 一覧を持つかどうかが能力を決める: 落ちたことを言えるのは、居るはずの相手を知っているからになる。push を選ぶなら、一覧を別に用意する手間が後から来る
  • 無音は 3 通りある: 落ちた、送っていない、経路が切れた。区別したいなら、区別できる仕組みを別に足す
  • ラベルは掛け算: 1 つ足す判断が桁を変える。値域の広いものはラベルでなくログへ回す
  • 収集器の数と被害範囲は逆向き: 両方を小さくはできない。壊れる単位に合わせるのが落としどころ
  • 監視の穴は監視で見つからない: 収集が止まったことを、その収集で気づくことはできない。外から見る経路を別に持つ

対照と実例

pullpush
一覧必須(サービスディスカバリ)不要で始められる
落ちた対象名指しできる一覧を別に持てば言える
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 つの収集器が何系列まで抱えられるかは、置き方の比較に必要な範囲だけ

参考資料