カスタム指標とKEDA
実装:
orchestration/custommetrics// 実行:go test ./orchestration/custommetrics/
水平オートスケールの章は CPU 使用率ひとつで決めた。だが CPU には上限があり、100% に達した後はどれだけ足りないかを言えない。目標が半分なら、どんなに遅れていても倍にしかならない。待ち行列の長さには上限が無いので、必要な数がその場で出る。そして 0 個のときは1個あたりの使用率を計算できない。0 にするには、値でなく仕事の有無を見ることになる。
この章で作るもの
水平オートスケールの章では、ceil(現在の数 × 現在値 ÷ 目標) という1行の式でレプリカ数を決めた。あの章で扱ったのは、その式に従いすぎないための歯止め(許容誤差・上下限・縮小の遅らせ方)だった。
式の外には、もう1つ大きな問題が残っている。何で測るか、という問題になる。
CPU 使用率には上限がある。100% を超えられない。ということは、100% に達した後は「どれだけ足りないか」を教えてくれない。10 倍の負荷が来ても 100% だし、100 倍の負荷が来ても 100% になる。式は現在値と目標の比を取るので、目標が 50% なら、どれだけ遅れていても倍にしかならない。追いつくまでに何周期もかかる。
待ち行列の長さには上限が無い。1000 件溜まっていれば 1000 と出る。1 つの Pod が 10 件を持つのが適正なら、必要なのは 100 個だと、その場で分かる。
そしてもう1つ、CPU では 0 にできない。レプリカが 0 のとき、1 個あたりの使用率は計算しようがない。だから CPU で回す限り、常に1つは動かしておくことになる。
同じ跳ねに 2 つの目標を当てて、各 tick に何が起きたかを並べる。到着は t=5 で 20 件/tick から 200 件/tick へ跳ね、1 個が捌けるのは 10 件/tick なので、 本当に要るのは 20 個になる。
t 4 5 6 7 8 9 10 11
────────────────────────────────────────────────────────
到着 20 200 200 200 200 200 200 200
個数 4 4 8 16 32 64 80 40
捌いた 20 40 80 160 320 400 200 200
CPU% 50 100 100 100 100 62 25 50
行列 0 160 280 320 200 0 0 0
t=5 から t=8 まで、CPU% は 100 に貼り付いたまま動かない
10 倍足りなくても 100 としか出ないので、式は毎回「今の倍」を返す
→ 個数は 4 → 8 → 16 → 32 と倍々にしか進めない
捌いた量が到着に並ぶ(320 ≥ 200)のは t=8。行列は最大 320 まで伸びる t 4 5 6 7 8 9 10 11
────────────────────────────────────────────────────────
到着 20 200 200 200 200 200 200 200
個数 2 2 20 20 20 20 20 20
捌いた 20 20 200 200 200 200 200 200
行列 20 200 200 200 200 200 200 200
t=5 に行列が 200 と見えた時点で ceil(200 ÷ 10) = 20 個と出る
捌いた量が到着に並ぶのは t=6。行列は 200 より伸びない2 つの表で、追いつくまでの速さが違うことが読み取れる。もう 1 つ、落ち着いた先も 違っているのだが、そちらは ② で扱う。
順に見ていく。
- 上限のある指標は、足りない量を言えない: 100% で頭打ちになるので、倍々でしか追えない
- 目標は定常状態の宣言: 「CPU 50%」は半分空けておけ、「1 個 10 件」は 10 件並んでいてよい
- 0 にするには値でなく有無を見る: 0 個では割り算が成立しない。別の判断が要る
① 上限のある指標は、足りない量を言えない
まず、目標の与え方を3つに分ける:
// Kind は目標の与え方。同じ数でも、何と比べるかで式が変わる。
type Kind int
const (
// Utilization は 1 個あたりの使用率(%)を目標にする。CPU がこれ。
Utilization Kind = iota
// AverageValue は 1 個あたりの絶対値を目標にする。「1 個が 10 件持つ」など。
AverageValue
// Value は全体の絶対値を目標にする。レプリカ数で割らない。
Value
)
func (k Kind) String() string {
return [...]string{"Utilization", "AverageValue", "Value"}[k]
}
// Metric は1つの判断材料。
type Metric struct {
Name string
Kind Kind
// Target は目標値。Utilization なら %、他なら件数など。
Target float64
// Saturates は、この指標に上限があるかを表す(観測用)。
// 上限のある指標は、上限に達した後どれだけ足りないかを言えない。
Saturates bool
}
// Desired は1つの指標から必要なレプリカ数を出す。
//
// 切り上げるのは[水平オートスケール](autoscaler)の章と同じ理由で、足りないより
// 多いほうが安全だからになる。current の意味だけが Kind で変わる。
func Desired(replicas int, m Metric, current float64) int {
if m.Target <= 0 {
return replicas
}
switch m.Kind {
case Utilization:
if replicas <= 0 {
// 1 個あたりの使用率は、0 個のときに計算できない。
// これが CPU で 0 にできない理由になる。
return replicas
}
return ceilDiv(float64(replicas)*current, m.Target)
case AverageValue:
// current は全体の合計。1 個あたりが Target になる数を出す。
return ceilDiv(current, m.Target)
default: // Value
return ceilDiv(current, m.Target)
}
}
// DesiredAll は複数の指標から必要なレプリカ数を出す。
//
// 最大を採る。どれか1つでも足りていなければ足りていないので、いちばん多くを
// 要求する指標に合わせることになる。逆に言えば、指標を足すことは減る方向には
// 働かない。1つ足すたびに、下がりにくくなる。
func DesiredAll(replicas int, ms []Metric, readings map[string]float64) (int, string) {
best, by := 0, ""
for _, m := range ms {
v, ok := readings[m.Name]
if !ok {
continue
}
// 同点なら先に宣言したほうを決め手として残す(結果を決定的にする)。
if d := Desired(replicas, m, v); by == "" || d > best {
best, by = d, m.Name
}
}
if by == "" {
return replicas, ""
}
return best, by
}
func ceilDiv(a, b float64) int {
n := int(a / b)
if float64(n)*b < a {
n++
}
return n
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}Utilization だけが現在のレプリカ数を掛けている。1 個あたりの使用率なので、全体の量に戻すには個数を掛ける必要があるからだ。この掛け算があるせいで、0 個のときに計算できない。
AverageValue は全体の合計を目標で割る。今の個数は式に出てこない。だから 2 個のときも 50 個のときも、同じ 200 件の行列からは同じ 20 個という答えが出る。テストで、現在のレプリカ数を変えても結果が変わらないことを固定した。
この違いが、追いつく速さの違いになる。
CPU が 100% のとき、目標が 50% なら答えは常に「今の倍」になる。10 倍足りなくても 100 倍足りなくても同じ答えが出る。テストで、この2つが区別できないことを固定した。だから必要な数へ行くのに、4 → 8 → 16 → 32 と何周期もかかる。
待ち行列なら 200 件が見えた時点で 20 個という答えが出る。1 周期で必要な数に届く。テストで、CPU 側は1周期に倍までしか増えないこと、待ち行列側は倍を超えて増えることを固定した。
追いついたかどうかの測り方には、少し注意が要る。待ち行列を目標にすると行列は 0 にならないので、「行列が空になったか」では測れない。「届く量に処理能力が並んだか」で測る。テストでは、CPU 側が t=8、待ち行列側が t=6 で並ぶことを固定した。
② 目標は定常状態の宣言
面白いのは、落ち着き先が違うところになる。
// Config は自動スケールの設定。
type Config struct {
Metrics []Metric
Min int
Max int
// Activation は 0 個のときに 1 個へ起こす閾値。仕事の量ではなく有無を見る。
// 0 を許さない設定(Min >= 1)では使われない。
Activation float64
// Cooldown は 0 へ落とすまでに、仕事が無い状態が続くべき tick 数。
Cooldown int
}
// AllowsZero は 0 まで縮められる設定かを返す。
func (c Config) AllowsZero() bool { return c.Min == 0 }
// Scaler は読み取りからレプリカ数を決める。
type Scaler struct {
cfg Config
idle int // 仕事が無い状態が続いた tick 数
}
// NewScaler は設定からスケーラを作る。
func NewScaler(cfg Config) *Scaler { return &Scaler{cfg: cfg} }
// Decision は1回の判断の結果と、その理由。
type Decision struct {
Replicas int
By string // 決め手になった指標名
Reason string
}
// Decide は現在のレプリカ数と読み取りから、次のレプリカ数を決める。
//
// 0 の扱いだけが別の判断になっている。0 個のときは指標の値を計算できないので、
// 仕事があるかどうかだけを見て 1 個へ起こす。1 個以上になれば、あとは普通の式で
// 決まる。この境目が、KEDA と HPA の分担になっている。
func (s *Scaler) Decide(replicas int, readings map[string]float64) Decision {
work := s.work(readings)
if replicas == 0 {
if !s.cfg.AllowsZero() {
return Decision{Replicas: s.clamp(s.cfg.Min), Reason: "0 を許さない設定なので下限まで戻す"}
}
if work > s.cfg.Activation {
s.idle = 0
return Decision{Replicas: s.clamp(1), Reason: "仕事が来たので 1 個起こす"}
}
return Decision{Replicas: 0, Reason: "仕事が無いので 0 のまま"}
}
if s.cfg.AllowsZero() && work <= s.cfg.Activation {
s.idle++
} else {
s.idle = 0
}
want, by := DesiredAll(replicas, s.cfg.Metrics, readings)
d := Decision{Replicas: s.clamp(want), By: by}
if d.Replicas == 0 {
if s.idle <= s.cfg.Cooldown {
// 式は 0 でよいと言っているが、まだ待ち時間の内側。落とさない。
return Decision{Replicas: 1, By: by, Reason: "仕事は無いが、落とすまでの待ち時間の内側"}
}
return Decision{Replicas: 0, By: by, Reason: "仕事が無い状態が続いたので 0 へ落とす"}
}
switch {
case d.Replicas > replicas:
d.Reason = by + " が目標を超えている"
case d.Replicas < replicas:
d.Reason = by + " に余裕がある"
default:
d.Reason = "変えない"
}
return d
}
// work は「仕事があるか」を1つの数にする。指標の値ではなく有無を見るための量で、
// 読み取りの中でいちばん大きいものを使う。
func (s *Scaler) work(readings map[string]float64) float64 {
keys := make([]string, 0, len(readings))
for k := range readings {
keys = append(keys, k)
}
sort.Strings(keys)
m := 0.0
for _, k := range keys {
if readings[k] > m {
m = readings[k]
}
}
return m
}
func (s *Scaler) clamp(n int) int {
if n < s.cfg.Min {
n = s.cfg.Min
}
if s.cfg.Max > 0 && n > s.cfg.Max {
n = s.cfg.Max
}
return n
}同じ負荷(到着 200 件、1 個が 10 件を捌く)に対して、必要な処理能力は 20 個ぶんになる。だが落ち着いた先は、目標の与え方で変わる:
| 目標 | 落ち着いた個数 | そのときの行列 | そのときの使用率 |
|---|---|---|---|
| CPU 使用率 50% | 40 個 | 0 件 | 50% |
| 待ち行列 1 個あたり 10 件 | 20 個 | 200 件 | 100% |
必要な 20 個に対して、CPU 目標の側は倍の 40 個を抱えて行列を 0 にし、待ち行列目標の側は ちょうど 20 個で 200 件並んだまま回り続ける。
どちらも指示どおりに動いている。「CPU 50% を保て」は「常に半分空けておけ」という意味なので、必要な数の倍を抱えるのが正解になる。「1 個あたり 10 件」は「10 件並んでいてよい」という意味なので、20 個で 200 件並んでいるのが正解になる。
テストで、待ち行列側の行列がちょうど個数×目標になること、CPU 側が目標 50% ぶんの倍を抱えることを固定した。
目標を決めるとき、自分がどちらを言っているのかは意識したほうがよい。余裕が欲しいなら比率の目標、無駄なく詰めたいなら絶対値の目標になる。
途中で行き過ぎるのも見える。CPU 側は 32 から 64、80 まで増えてから 40 へ戻る。行列が捌けた瞬間に使用率が落ち、次の周期で減らしすぎ、また増える。水平オートスケールの章で許容誤差と縮小の待ち時間を入れたのは、この揺れを止めるためだった。ここでは比較を濁さないよう外してあるので、素の式の揺れがそのまま出ている。
指標を複数置いたときの合成も、ここに入る。DesiredAll は最大を採る。どれか1つでも足りていなければ足りていないので、いちばん多くを要求する指標に合わせることになる。逆に言えば、指標を足すことは減る方向には働かない。テストで、指標を1つ足すと必要数が上がることはあっても下がらないことを固定した。1つ足すたびに、下がりにくい系になる。
③ 0 にするには値でなく有無を見る
Decide の最初にある replicas == 0 の分岐が、この章のもう1つの中心になる。
0 個のとき、1 個あたりの使用率は計算できない。待ち行列なら値は取れるが、そもそも HPA の枠組みは 0 を扱わない。0 から 1 へ起こす判断は、量の判断ではないからだ。「どれだけ足りないか」ではなく「仕事があるか無いか」だけで決まる。
だから別の判断として持たせてある。Activation を超える仕事が見えたら 1 個起こす。起きてしまえば、あとは普通の式で決まる。テストで、0 のまま仕事が来るまで待ち、来たら 1 個、その次からは式どおりに 10 個まで伸びることを固定した。
落とすほうには待ち時間を置いてある。式は「仕事が無いなら 0 でよい」と即座に言うが、そこで落とすと、次に仕事が来たときの立ち上がりを毎回払うことになる。Cooldown のぶんだけ様子を見る。テストで、待ち時間の内側では 1 個を保つこと、途中で仕事が来たら数え直しになることを固定した。
この境目が、実物では KEDA と HPA の分担になっている。KEDA は 0 と 1 の間だけを担当し、1 以上になったら HPA を作ってそちらに任せる。役割を分けているのは、0 の判断が他と種類の違う判断だからだ。調整ループの章の言い方をすれば、量を合わせるループと、存在を切り替えるループは別の問いに答えている。
動かす
下のデモは、同じ負荷の跳ねに CPU 目標と待ち行列目標を同時に当てる。上下2本の線が個数と行列で、追いつくまでの時間と落ち着き先が違う。0 を許す設定に切り替えると、仕事が止まったところで片方だけ 0 に落ちる。
CPU は 100% で頭打ちになるので、どれだけ遅れていても 1 周期で倍にしかならない。待ち行列は 200 件が見えた時点で「20 個要る」と出る。ただし落ち着き先は違い、CPU 50% は必要な数の倍を抱え、 1 個 10 件は行列が残る。「0 まで縮めてよい」で仕事を止めると、値でなく有無で 0 へ落ちるのが見える。
設計の観点
- 飽和する指標を制御に使わない: 上限に達した後は情報が出ない。使うなら、飽和しない指標を並べて置く
- 先行と遅行を意識する: 待ち行列は仕事が来た瞬間に増える。CPU は処理が始まってから上がる。順序がそのまま反応の速さになる
- 目標は定常状態の宣言: 比率の目標は余裕を確保し、絶対値の目標は無駄を削る。どちらが欲しいかを先に決める
- 指標を足すのは片道: 最大を採るので、足せば下がりにくくなる。減らしたければ指標を外すしかない
- 0 は別の問い: 量でなく有無。だから別の仕組みが要るし、立ち上がりの代償を誰が払うかを決める必要がある
- 時系列の整列と集約が前提: 判断に使う数は、どう潰したかで変わる。平均で潰した指標で自動スケールすると、刺さっている1台が見えない
対照と実例
| CPU 使用率 | 待ち行列の長さ | 外部の絶対値 | |
|---|---|---|---|
| 上限 | ある(100%) | 無い | 無い |
| 不足量が出るか | 出ない | 出る | 出る |
| 1周期の伸び | 目標との比まで | 必要な数まで | 必要な数まで |
| 反応の速さ | 遅行(処理が始まってから) | 先行(仕事が来た瞬間) | 先行 |
| 0 にできるか | できない | 仕組みを足せばできる | 仕組みを足せばできる |
| 落ち着き先 | 余裕を持った数 | 目標ぶん並んだ状態 | 目標ぶんの状態 |
裏どり:
- HPA の3つの型:
Resource(CPU/メモリの使用率)、Pods(1 個あたりの絶対値)、Object/External(全体の値)。式が変わるのはこの区別による - 複数指標は最大: HPA は各指標でレプリカ数を計算し、いちばん大きいものを採用する
- カスタム指標の経路:
custom.metrics.k8s.io/external.metrics.k8s.ioのアダプタを立て、Prometheus などの値を HPA に渡す - KEDA の役割:
ScaledObjectから HPA を生成する。KEDA 自身は 0 と 1 の間を担い、1 以上は生成した HPA が動かす。トリガーの種類ごとの読み方はKEDA のトリガーを読み替えるにまとめた - activationThreshold: 0 から起こす閾値は、スケールの目標値とは別に指定する。量の判断と有無の判断が別だから
- scale to zero の代償: 立ち上がりの遅れをリクエストが待つ。待たせてよい仕事(バッチ、キュー消化)に向く
簡略化したこと
- 指標の遅れなし: 実物では指標が集まるまでに時間がかかる。ここでは前の tick の値が即座に見える
- 起動時間なし: レプリカを増やすとその tick から捌ける。実物は起動を待つ(この遅さは後の Cluster Autoscaler の章で扱う)
- 安定化なし: 水平オートスケールの章の許容誤差と縮小の待ち時間は入れていない。比較を濁さないため
- 指標の取得元なし: アダプタや Prometheus は扱わない。読み取りは直接渡す
- 1つの系だけ: 複数の ScaledObject が同じ資源を取り合う状況は扱わない
参考資料
- Horizontal Pod Autoscaling — 3つの型と、複数指標で最大を採る規定
- KEDA — ScaledObject、activationThreshold、0 と 1 の担当
- 実装: orchestration/custommetrics