文章目录
- 一:扩缩容窗口如何收集数据计算Bucket
-
- 1.关键方法调用路径顺序
- [2. 关键代码解析](#2. 关键代码解析)
- 二:如何根据扩缩容窗口计算目标副本数
注意:本文所有代码说明均以"并发数"为例进行讲解。
本文将深入解析 Knative Serving 中并发指标的采集与扩缩容计算机制,主要内容分为以下两部分:# 一:如何采集并发指标并保留窗口供扩缩容计算
一:扩缩容窗口如何收集数据计算Bucket
1.关键方法调用路径顺序
注意:核心的指标采集逻辑在 Scrape 方法中执行。
① Reconcile
serving-release-1.21/pkg/client/injection/reconciler/autoscaling/v1alpha1/metric/reconciler.go:188
Metric 协调入口:按 key 取出 Metric,DeepCopy 后选择并执行具体协调方法。
② reconcileMethodFor
serving-release-1.21/pkg/client/injection/reconciler/autoscaling/v1alpha1/metric/state.go:86
根据是否删除、是否 leader,选出要跑的方法(通常是 ReconcileKind,这里以并发数为例)。
③ ReconcileKind
serving-release-1.21/pkg/reconciler/metric/metric.go:42-61
业务协调:调用 collector.CreateOrUpdate,并更新 Metric Ready/Failed 状态。
④ CreateOrUpdate
serving-release-1.21/pkg/autoscaler/metrics/collector.go:119-145
为该 Metric 创建或更新采集集合(collection)和 scraper。
⑤ newCollection
serving-release-1.21/pkg/autoscaler/metrics/collector.go:273-344
创建稳定/恐慌窗口的并发与 RPS 桶,并启动周期 scrape 的 goroutine。
⑥ Scrape
serving-release-1.21/pkg/autoscaler/metrics/stats_scraper.go:218-264
按 mesh 模式分发:直连 Pod 或走 Service 抓取 metrics。
⑦ scrapePods
serving-release-1.21/pkg/autoscaler/metrics/stats_scraper.go:266-373
抽样直连 Pod IP 的 :9090/metrics,聚合并外推得到整体 Stat。
⑧ record
serving-release-1.21/pkg/autoscaler/metrics/collector.go:405-414
扣掉 activator 代理部分后,把并发 写入对应桶(真正入桶点)。
2. 关键代码解析
1.newCollection
- 调用
newCollection方法:创建指标采集集合。bucketCtor函数 :根据聚合算法配置,调用NewWeightedFloat64Buckets或NewTimedFloat64Buckets返回TimedFloat64Buckets类型的windowAverager接口,后续通过该接口计算并发平均值。- 参数传入 :使用 metric 资源中记录的
stableWindow和panicWindow值作为时间窗口,config.BucketSize使用默认值 1 秒。- 启动采集协程 :启动一个 Go 协程,每隔
scrapeTickInterval(默认为 1 秒)调用Scrape方法拉取指标,并将结果存入对应的桶中。- 指标记录 :指标拉取完成后,调用
record方法将返回的当前时间并发值记录到对应的 buckets 桶中,供后续计算并发数使用,record具体实现原理在后边。
go
// newCollection creates a new collection, which uses the given scraper to
// collect stats every scrapeTickInterval.
func newCollection(metric *autoscalingv1alpha1.Metric, scraper StatsScraper, clock clock.WithTicker,
callback func(types.NamespacedName), logger *zap.SugaredLogger,
) *collection {
// Pick the constructor to use to build the buckets.
// NB: this relies on the fact that aggregation algorithm is set on annotation of revision
// and as such is immutable.
bucketCtor := func(w time.Duration, g time.Duration) windowAverager {
return aggregation.NewTimedFloat64Buckets(w, g)
}
if metric.AggregationAlgorithm() == autoscaling.MetricAggregationAlgorithmWeightedExponential {
bucketCtor = func(w time.Duration, g time.Duration) windowAverager {
return aggregation.NewWeightedFloat64Buckets(w, g)
}
}
c := &collection{
metric: metric,
concurrencyBuckets: bucketCtor(// 窗口传参
metric.Spec.StableWindow, config.BucketSize),
concurrencyPanicBuckets: bucketCtor(
metric.Spec.PanicWindow, config.BucketSize),
...
scraper: scraper,
stopCh: make(chan struct{}),
}
key := types.NamespacedName{Namespace: metric.Namespace, Name: metric.Name}
logger = logger.Named("collector").With(zap.String(logkey.Key, key.String()))
c.grp.Add(1)
go func() { // 启用协程
defer c.grp.Done()
scrapeTicker := clock.NewTicker(scrapeTickInterval)
defer scrapeTicker.Stop()
for {
select {
case <-c.stopCh:
return
case <-scrapeTicker.C():
scraper := c.getScraper()
if scraper == nil {
// Don't scrape empty target service.
if c.updateLastError(nil) {
callback(key)
}
continue
}
// 拉取指标并计算
stat, err := scraper.Scrape(c.currentMetric().Spec.StableWindow)
if err != nil {
logger.Errorw("Failed to scrape metrics", zap.Error(err))
}
if c.updateLastError(err) {
callback(key)
}
if stat != emptyStat {
// 记录指标
c.record(clock.Now(), stat)
}
}
}
}()
return c
}
// ----------
// NewTimedFloat64Buckets generates a new TimedFloat64Buckets with the given
// granularity.
func NewTimedFloat64Buckets(window, granularity time.Duration) *TimedFloat64Buckets {
// Number of buckets is `window` divided by `granularity`, rounded up.
// e.g. 60s / 2s = 30.
nb := math.Ceil(float64(window) / float64(granularity))
return &TimedFloat64Buckets{
buckets: make([]float64, int(nb)),
granularity: granularity,
window: window,
}
}
2.Scape
- 指标拉取模式判断 :根据是否配置了 Service Mesh(
meshMode)来决定指标拉取方式------直连 Pod 或通过 Service 端点。- 示例说明 :这里以直连 Pod 模式(调用
scrapePods方法)为例,传入的window参数值是 ksvc 配置的扩缩容计算窗口(默认为 60 秒,可配置)。
go
// Scrape calls the destination service then sends it
// to the given stats channel.
// 指标抓取入口,从revision的queue-proxy拿并发数,计算自动扩缩容
// 直连pod ip
// 开启service mesh 走service endpoint
// 按当前网络模式,选一种方式去拉metrics,返回一个聚合后的stat
// stat 命名返回值,聚合后的指标(并发、请求数)
func (s *serviceScraper) Scrape(window time.Duration) (stat Stat, err error) {
startTime := s.clock.Now()
defer func() {
// No errors and an empty stat? We didn't scrape at all because
// we're scaled to 0.
if stat == emptyStat && err == nil {
return
}
scrapeTime := s.clock.Since(startTime)
scrapeTimeSec := scrapeTime.Seconds()
s.duration.Record(context.Background(), scrapeTimeSec, metric.WithAttributeSet(s.attrs))
}()
switch s.meshMode {
case netcfg.MeshCompatibilityModeEnabled:
s.logger.Debug("Scraping via service due to meshMode setting")
return s.scrapeService(window) // service endpoint:9090/metrics
case netcfg.MeshCompatibilityModeDisabled:
s.logger.Debug("Scraping pods directly due to meshMode setting")
return s.scrapePods(window) // pod ip:9090/metrics
default:
if s.podsAddressable || s.usePassthroughLb {
stat, err := s.scrapePods(window)
// Return here if some pods were scraped, but not enough or if we're using a
// passthrough loadbalancer and want no fallback to service-scrape logic.
if !errors.Is(err, errDirectScrapingNotAvailable) || s.usePassthroughLb {
return stat, err
}
// Else fall back to service scrape.
}
stat, err = s.scrapeService(window)
if err == nil && s.podsAddressable {
s.logger.Info("Direct pod scraping off, service scraping, on")
// If err == nil, this means that we failed to scrape all pods, but service worked
// thus it is probably a mesh case.
s.podsAddressable = false
}
return stat, err
}
}
3.scrapePods
抽样采集 Pod 指标 :通过向 Pod 的 metrics 接口发送 HTTP 请求并解析返回数据。
每一秒钟都会采集计算
第一步:按 Pod 创建时间分组
调用
PodIPsSplitByAge方法,根据传入的window参数将所有就绪 Pod 分为"老 Pod"和"新 Pod"两组。返回的结果中,老 Pod 指创建时间早于截止时间(即window值)的 Pod,新 Pod 则指创建时间晚于截止时间的 Pod。第二步:计算采样数量
将总 Pod 数转换为浮点数,调用
populationMeanSampleSize函数计算出具有统计意义的最小采样数量。第三步:随机打乱并合并
如果老 Pod 数量不足采样数,则随机打乱新 Pod 列表;否则随机打乱老 Pod 列表。然后将两组 Pod 合并。此步骤旨在保证采样的随机性,并避免因请求过多 Pod 而可能引发的性能问题。
第四步:并发采集
使用
errgroup启动多个 goroutine,数量等于采样数。每个 goroutine 通过原子计数器获取一个 Pod IP,构造 HTTP 请求访问该 Pod 的:9090/metrics路径。若请求成功,则将结果发送到resultschannel;若失败,则尝试下一个 Pod。第五步:错误处理
采集完成后关闭 channel。如果所有采样均失败且错误与网格无关,则返回特殊错误,让上层回退到 Service 采集模式。如果部分成功但成功数量不足,同样返回错误。
第六步:汇总与放大
调用
computeAverages函数,从 channel 中读取所有成功的统计数据并累加,然后通过average方法按比例放大:将采集值除以实际采样数,再乘以总 Pod 数,从而用少数 Pod 的数据估算整体负载。
go
var portAndPath = strconv.Itoa(networking.AutoscalingQueueMetricsPort) + "/metrics"
// 列出 Pod IP,拼 URL,发 GET
// 抓取指标并解析
func (s *serviceScraper) scrapePods(window time.Duration) (Stat, error) {
pods, youngPods, err := s.podAccessor.PodIPsSplitByAge(window, time.Now())
if err != nil {
s.logger.Infow("Error querying pods by age", zap.Error(err))
return emptyStat, err
}
lp := len(pods)
lyp := len(youngPods)
s.logger.Debugf("|OldPods| = %d, |YoungPods| = %d", lp, lyp)
total := lp + lyp
if total == 0 {
return emptyStat, nil
}
// 浮点数副本数
frpc := float64(total)
// Pod 数 N 采样数 n
// ──────────────────────────
// 1~3 全采(即 N)
// 4 4(刚好全采)
// 5 4
// 10 7
// 100 14
// 1000 16 -- 趋近于16
sampleSizeF := populationMeanSampleSize(frpc) // 统计样本:要估算总体的均值,至少需要采多少个样本
sampleSize := int(sampleSizeF)
//一个容量为 sampleSize 的缓冲 channel,用来存放每个被采样 Pod 的统计数据(Stat 结构体)
results := make(chan Stat, sampleSize)
// 1. If not enough: shuffle young pods and expect to use N-lp of those
// no need to shuffle old pods, since all of them are expected to be used.
// 2. If enough old pods: shuffle them and use first N, still append young pods
// as backup in case of errors, but without shuffling.
if lp < sampleSize {
rand.Shuffle(lyp, func(i, j int) {
youngPods[i], youngPods[j] = youngPods[j], youngPods[i]
})
} else {
rand.Shuffle(lp, func(i, j int) {
pods[i], pods[j] = pods[j], pods[i]
})
}
pods = append(pods, youngPods...)
grp, egCtx := errgroup.WithContext(context.Background())
var idx atomic.Int32
idx.Store(-1)
var sawNonMeshError atomic.Bool
// Start |sampleSize| threads to scan in parallel.
for range sampleSize {
grp.Go(func() error {
// If a given pod failed to scrape, we want to continue
// scanning pods down the line.
for {
// Acquire next pod.
myIdx := int(idx.Add(1))
// All out?
if myIdx >= len(pods) {
return errPodsExhausted
}
// Scrape!
// :9090/metrics
target := "http://" + pods[myIdx] + ":" + portAndPath
req, err := http.NewRequestWithContext(egCtx, http.MethodGet, target, nil)
if err != nil {
return err
}
if s.usePassthroughLb {
req.Host = s.host
req.Header.Add("Knative-Direct-Lb", "true")
}
// 解析请求
// 具体实现在serving-release-1.21\pkg\autoscaler\metrics\http_scrape_client.go
stat, err := s.directClient.Do(req)
if err == nil {
results <- stat
return nil
}
if !isPotentialMeshError(err) {
sawNonMeshError.Store(true)
}
s.logger.Infow("Failed scraping pod "+pods[myIdx], zap.Error(err))
}
})
}
err = grp.Wait()
close(results)
// We only get here if one of the scrapers failed to scrape
// at least one pod.
if err != nil {
// Got some (but not enough) successful pods.
if len(results) > 0 {
s.logger.Warn("Too many pods failed scraping for meaningful interpolation")
return emptyStat, errPodsExhausted
}
// We didn't get any pods, but we don't want to fall back to service
// scraping because we saw an error which was not mesh-related.
if sawNonMeshError.Load() {
s.logger.Warn("0 pods scraped, but did not see a mesh-related error")
return emptyStat, errPodsExhausted
}
// No pods, and we only saw mesh-related errors, so infer that mesh must be
// enabled and fall back to service scraping.
s.logger.Warn("0 pods were successfully scraped out of ", strconv.Itoa(len(pods)))
return emptyStat, errDirectScrapingNotAvailable
}
return computeAverages(results, sampleSizeF, frpc), nil
}
func computeAverages(results <-chan Stat, sample, total float64) Stat {
ret := Stat{
PodName: scraperPodName,
}
// Sum the stats from individual pods.
for stat := range results {
ret.add(stat)
}
ret.average(sample, total)
return ret
}
// 样本外推全集群
// 全体估计并发 ≈ (采样 Pod 并发之和 / 采样数) × 总副本数
func (dst *Stat) average(sample, total float64) {
// 平均并发数 = 总并发数 / 采样数 * 总副本数
dst.AverageConcurrentRequests = dst.AverageConcurrentRequests / sample * total
// 平均代理并发数(经过activator代理的那部分并发) = 总代理并发数 / 采样数 * 总副本数
dst.AverageProxiedConcurrentRequests = dst.AverageProxiedConcurrentRequests / sample * total
// 平均请求数 = 总请求数 / 采样数 * 总副本数
dst.RequestCount = dst.RequestCount / sample * total
// 平均代理请求数 = 总代理请求数 / 采样数 * 总副本数
dst.ProxiedRequestCount = dst.ProxiedRequestCount / sample * total
}
4.record
- 调用
record方法 :调用"并发请求桶"的record方法,其中granularity值为 1 秒(来自BucketSize配置)。首先将当前时间按 1 秒粒度截断,得到bucketTime。- 同一秒内多次采集 :如果
bucketTime与最后一次写入时间相同,说明是同一秒内的多次采集,直接将值累加到对应桶中即可。- 新时间点写入(
bucketTime在lastWrite之后) :
- 数据过期判断 :如果
bucketTime加上窗口时间不大于lastWrite,说明数据已超出窗口范围,直接丢弃并返回(例如:bucketTime + window = 11:59:20 + 60秒 = 12:00:20,lastWrite是 12:00:30,无需计算)。- 更新最早写入时间 :若数据未过期,则更新
firstWrite。- 长时间空窗处理 :如果距离上次写入超过一个窗口(一个窗口时60s,60条数据)时间,说明中间有长时间空窗,所有历史数据失效,需要重置
firstWrite、清零所有桶并清空windowTotal。- 窗口内跳跃处理 :如果时间间隔在窗口内,则需要清理从上次写入位置到当前写入位置之间所有被跳过的过期桶,从
windowTotal中减去这些桶的值并将其置零。- 更新最近写入时间 :最后更新
lastWrite为当前的bucketTime。- 历史时间点写入(
bucketTime在lastWrite之前但在窗口范围内):无需清理,直接累加到对应桶中。- 最终结果 :所有未丢弃的数据(当前时间的并发数)都会累加到对应桶中,并更新
windowTotal。- 知识点:环形缓冲区
go
// record adds a stat to the current collection.
// Scrape → Stat(这一秒的瞬时值)
//
// │
// ▼
// record()
// │
// ┌─────────┴─────────┐
// ▼ ▼
// concur rps
// │ │
// ├─→ stable 桶 ├─→ stable 桶
// └─→ panic 桶 └─→ panic 桶
func (c *collection) record(now time.Time, stat Stat) {
// Proxied requests have been counted at the activator. Subtract
// them to avoid double counting.
concur := stat.AverageConcurrentRequests - stat.AverageProxiedConcurrentRequests
c.concurrencyBuckets.Record(now, concur)
c.concurrencyPanicBuckets.Record(now, concur)
rps := stat.RequestCount - stat.ProxiedRequestCount
c.rpsBuckets.Record(now, rps)
c.rpsPanicBuckets.Record(now, rps)
}
// Record 方法把某个时间点的并发数据值写入对应的时间桶中。
// 核心职责是维护滑动窗口的数据完整性------正确处理时间跳跃、数据缺失、窗口重置等各种边界情况
// buckets 环形数组(假设长度 30,索引 0~29):
// ┌───┬───┬───┬───┬───┬───┬───────────┬───┬───┐
// │ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ ... │28 │29 │
// └───┴───┴───┴───┴───┴───┴───────────┴───┴───┘
func (t *TimedFloat64Buckets) Record(now time.Time, value float64) {
bucketTime := now.Truncate(t.granularity)
t.bucketsMutex.Lock()
defer t.bucketsMutex.Unlock()
writeIdx := t.timeToIndex(now) // 将时间戳转换为桶索引
if t.lastWrite != bucketTime {
if bucketTime.Add(t.window).After(t.lastWrite) { // 丢弃过期数据
// If it is the first write or it happened before the first write which we
// have in record, update the firstWrite.
if t.firstWrite.IsZero() || t.firstWrite.After(bucketTime) {
t.firstWrite = bucketTime
}
// 新数据在最近一次写入数据之后
if bucketTime.After(t.lastWrite) {
if bucketTime.Sub(t.lastWrite) >= t.window { //超过一个窗口没写数据
// This means we had no writes for the duration of `window`. So reset the firstWrite time.
t.firstWrite = bucketTime
// Reset all the buckets. 桶清零
for i := range t.buckets {
t.buckets[i] = 0
}
t.windowTotal = 0
} else { // 删除同一个索引位置的旧值,添加上新值,更新windowTotal
// In theory we might lose buckets between stats gathering.
// Thus we need to clean not only the current index, but also
// all the ones from the last write. This is slower than the loop above
// due to possible wrap-around, so they are not merged together.
for i := t.timeToIndex(t.lastWrite) + 1; i <= writeIdx; i++ {
idx := i % len(t.buckets)
t.windowTotal -= t.buckets[idx]
t.buckets[idx] = 0
}
}
// Update the last write time.
t.lastWrite = bucketTime
}
// The else case is t.lastWrite - t.window < bucketTime < t.lastWrite, we can simply add
// the value to the bucket.
} else {
// Ignore this value because it happened a window size ago.
return
}
}
t.buckets[writeIdx%len(t.buckets)] += value
t.windowTotal += value
}
// ------ 结构体-----
TimedFloat64Buckets struct {
bucketsMutex sync.RWMutex
buckets []float64 // 环形缓冲区,每个元素是一个时间桶的累积值
firstWrite time.Time // 窗口中最早数据的时间
lastWrite time.Time // 最近一次写入数据的时间
granularity time.Duration // 每个桶的时间粒度(默认1s,值来自BucketSize)
// window is the total time represented by the buckets ring buffer.
window time.Duration // 窗口总长度(如 60s)
windowTotal float64 // 当前窗口内所有桶的总和
}