From 8615a3578cf565034c0022dbe452952f736cf37c Mon Sep 17 00:00:00 2001 From: Francesco Torta <62566275+fra98@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:23:06 +0200 Subject: [PATCH 1/2] perf(vk): cache podmetrics and stats --- cmd/virtual-kubelet/root/flag.go | 4 + cmd/virtual-kubelet/root/http.go | 51 ++++-- cmd/virtual-kubelet/root/http_test.go | 100 ++++++++++++ cmd/virtual-kubelet/root/opts.go | 13 ++ cmd/virtual-kubelet/root/root.go | 2 + cmd/virtual-kubelet/root/root_suite_test.go | 27 ++++ pkg/utils/cache/cache.go | 137 ++++++++++++++++ pkg/utils/cache/cache_suite_test.go | 27 ++++ pkg/utils/cache/cache_test.go | 150 ++++++++++++++++++ pkg/utils/cache/doc.go | 17 ++ pkg/virtualKubelet/provider/provider.go | 5 + pkg/virtualKubelet/reflection/workload/pod.go | 63 ++++++-- .../reflection/workload/pod_test.go | 69 +++++++- .../reflection/workload/podns.go | 2 +- .../reflection/workload/podns_test.go | 9 +- 15 files changed, 642 insertions(+), 34 deletions(-) create mode 100644 cmd/virtual-kubelet/root/http_test.go create mode 100644 cmd/virtual-kubelet/root/root_suite_test.go create mode 100644 pkg/utils/cache/cache.go create mode 100644 pkg/utils/cache/cache_suite_test.go create mode 100644 pkg/utils/cache/cache_test.go create mode 100644 pkg/utils/cache/doc.go diff --git a/cmd/virtual-kubelet/root/flag.go b/cmd/virtual-kubelet/root/flag.go index 3f5787608d..0bc5154a8e 100644 --- a/cmd/virtual-kubelet/root/flag.go +++ b/cmd/virtual-kubelet/root/flag.go @@ -73,6 +73,10 @@ func InstallFlags(flags *pflag.FlagSet, o *Opts) { "Name of the real load balancer class to use for the actual load balancer") flags.BoolVar(&o.EnableMetrics, "metrics-enabled", false, "Enable the metrics server") flags.StringVar(&o.MetricsAddress, "metrics-address", ":8082", "The address to listen to for metrics requests") + flags.DurationVar(&o.MetricsProxyCacheTTL, "metrics-proxy-cache-ttl", o.MetricsProxyCacheTTL, + "The time-to-live of the cached responses of the metrics proxy routes (/metrics, /metrics/cadvisor, /metrics/resource, /metrics/probes)") + flags.DurationVar(&o.StatsSummaryCacheTTL, "stats-summary-cache-ttl", o.StatsSummaryCacheTTL, + "The time-to-live of the cached node stats summary served at /stats/summary") flags.StringVar(&o.HomeAPIServerHost, "home-api-server-host", "", "Home cluster API server HOST, this parameter is optional and required only to override the default values") flags.StringVar(&o.HomeAPIServerPort, "home-api-server-port", "", diff --git a/cmd/virtual-kubelet/root/http.go b/cmd/virtual-kubelet/root/http.go index d6bf632c3a..c8fa450da6 100644 --- a/cmd/virtual-kubelet/root/http.go +++ b/cmd/virtual-kubelet/root/http.go @@ -43,6 +43,7 @@ import ( "k8s.io/klog/v2" liqov1beta1 "github.com/liqotech/liqo/apis/core/v1beta1" + "github.com/liqotech/liqo/pkg/utils/cache" "github.com/liqotech/liqo/pkg/virtualKubelet/reflection/workload" "github.com/liqotech/liqo/pkg/vkMachinery" ) @@ -77,7 +78,7 @@ func setupHTTPServer(ctx context.Context, handler workload.PodHandler, localClie mux := http.NewServeMux() cl := kubernetes.NewForConfigOrDie(remoteConfig) - attachMetricsRoutes(ctx, mux, cl.RESTClient(), cfg.HomeCluster.GetClusterID(), metricsNodeSelector) + attachMetricsRoutes(ctx, mux, cl.RESTClient(), cfg.HomeCluster.GetClusterID(), metricsNodeSelector, cfg.MetricsProxyCacheTTL) podRoutes := api.PodHandlerConfig{ RunInContainer: handler.Exec, @@ -193,8 +194,22 @@ func setupHealthServer() { } } +const ( + // metricsProxyErrorCacheTTL is the time-to-live of failed metrics proxy responses. Errors are + // cached briefly (much shorter than successful ones), so that a persistently failing scraping + // is not retried at each request, while still allowing a prompt recovery once solved. + // The time-to-live of successful responses is configurable through the metrics-proxy-cache-ttl flag. + metricsProxyErrorCacheTTL = 5 * time.Second +) + +// scrapeResponse is the cached outcome of a proxied scrape: the response body and the status code. +type scrapeResponse struct { + data []byte + statusCode int +} + func attachMetricsRoutes(ctx context.Context, mux *http.ServeMux, cl rest.Interface, - localClusterID liqov1beta1.ClusterID, metricsNodeSelector string) { + localClusterID liqov1beta1.ClusterID, metricsNodeSelector string, cacheTTL time.Duration) { // Restrict the scraping to the remote nodes matching the given label selector (i.e., the ones // targeted by this virtual kubelet through its offloading patch), so that node and pod metrics // are not aggregated over the whole remote cluster. An empty selector preserves the aggregated @@ -204,6 +219,10 @@ func attachMetricsRoutes(ctx context.Context, mux *http.ServeMux, cl rest.Interf query.Add("nodeSelector", metricsNodeSelector) } + // scrapeCache collapses concurrent and near-duplicate scrapes into a single remote sweep. The + // key is the full outgoing URI, hence requests differing in path or query do not alias. + scrapeCache := cache.New[scrapeResponse](cacheTTL, metricsProxyErrorCacheTTL) + handlerFunc := func(w http.ResponseWriter, r *http.Request) { klog.Infof("Received request for %s from %s (user-agent: %q)", r.RequestURI, r.RemoteAddr, r.UserAgent()) @@ -221,26 +240,30 @@ func attachMetricsRoutes(ctx context.Context, mux *http.ServeMux, cl rest.Interf uri += "?" + reqQuery.Encode() } - res := cl.Get().RequestURI(uri).Do(ctx) - err := res.Error() - if err != nil { - klog.Error(err) - http.Error(w, "Server Error", http.StatusInternalServerError) - return - } + resp, err := scrapeCache.Do(ctx, uri, func(ctx context.Context) (scrapeResponse, error) { + res := cl.Get().RequestURI(uri).Do(ctx) + if err := res.Error(); err != nil { + return scrapeResponse{}, err + } - var statusCode int - res.StatusCode(&statusCode) + var statusCode int + res.StatusCode(&statusCode) + + data, err := res.Raw() + if err != nil { + return scrapeResponse{}, err + } - data, err := res.Raw() + return scrapeResponse{data: data, statusCode: statusCode}, nil + }) if err != nil { klog.Error(err) http.Error(w, "Server Error", http.StatusInternalServerError) return } - w.WriteHeader(statusCode) - if _, err = w.Write(data); err != nil { + w.WriteHeader(resp.statusCode) + if _, err = w.Write(resp.data); err != nil { klog.Error(err) } } diff --git a/cmd/virtual-kubelet/root/http_test.go b/cmd/virtual-kubelet/root/http_test.go new file mode 100644 index 0000000000..993ef71d03 --- /dev/null +++ b/cmd/virtual-kubelet/root/http_test.go @@ -0,0 +1,100 @@ +// Copyright 2019-2026 The Liqo Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package root + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/runtime/schema" + clientscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest/fake" +) + +var _ = Describe("Metrics proxy routes", func() { + const clusterID = "local-cluster-id" + + var ( + ctx context.Context + mux *http.ServeMux + calls atomic.Int64 + failScrape atomic.Bool + ) + + BeforeEach(func() { + ctx = context.Background() + calls.Store(0) + failScrape.Store(false) + + cl := &fake.RESTClient{ + NegotiatedSerializer: clientscheme.Codecs.WithoutConversion(), + GroupVersion: schema.GroupVersion{Group: "metrics.liqo.io", Version: "v1beta1"}, + Client: fake.CreateHTTPClient(func(*http.Request) (*http.Response, error) { + calls.Add(1) + if failScrape.Load() { + return nil, errors.New("scrape failure") + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/plain"}}, + Body: io.NopCloser(strings.NewReader("metrics-payload")), + }, nil + }), + } + + mux = http.NewServeMux() + attachMetricsRoutes(ctx, mux, cl, clusterID, "", 15*time.Second) + }) + + doRequest := func(path string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, http.NoBody)) + return rec + } + + It("should proxy the remote metrics", func() { + rec := doRequest("/metrics/resource") + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(rec.Body.String()).To(Equal("metrics-payload")) + }) + + It("should serve subsequent requests for the same path from the cache", func() { + doRequest("/metrics/resource") + doRequest("/metrics/resource") + Expect(calls.Load()).To(BeNumerically("==", 1)) + }) + + It("should not alias requests differing in path", func() { + doRequest("/metrics/resource") + doRequest("/metrics/cadvisor") + Expect(calls.Load()).To(BeNumerically("==", 2)) + }) + + It("should cache failures briefly", func() { + failScrape.Store(true) + + Expect(doRequest("/metrics/resource").Code).To(Equal(http.StatusInternalServerError)) + Expect(doRequest("/metrics/resource").Code).To(Equal(http.StatusInternalServerError)) + Expect(calls.Load()).To(BeNumerically("==", 1)) + }) +}) diff --git a/cmd/virtual-kubelet/root/opts.go b/cmd/virtual-kubelet/root/opts.go index 9778bb58dd..d6a736271a 100644 --- a/cmd/virtual-kubelet/root/opts.go +++ b/cmd/virtual-kubelet/root/opts.go @@ -44,6 +44,11 @@ const ( DefaultListenPort = 10250 DefaultNodePingTimeout = 1 * time.Second DefaultNodeCheckNetwork = true + + // DefaultMetricsProxyCacheTTL is the default time-to-live of the cached metrics proxy responses. + DefaultMetricsProxyCacheTTL = 15 * time.Second + // DefaultStatsSummaryCacheTTL is the default time-to-live of the cached node stats summary. + DefaultStatsSummaryCacheTTL = 20 * time.Second ) // DefaultReflectorsWorkers contains the default number of workers for each reflected resource. @@ -127,6 +132,11 @@ type Opts struct { EnableMetrics bool MetricsAddress string + // MetricsProxyCacheTTL is the time-to-live of the cached responses of the metrics proxy routes. + MetricsProxyCacheTTL time.Duration + // StatsSummaryCacheTTL is the time-to-live of the cached node stats summary. + StatsSummaryCacheTTL time.Duration + HomeAPIServerHost string HomeAPIServerPort string @@ -162,6 +172,9 @@ func NewOpts() *Opts { NodePingTimeout: DefaultNodePingTimeout, NodeCheckNetwork: DefaultNodeCheckNetwork, + MetricsProxyCacheTTL: DefaultMetricsProxyCacheTTL, + StatsSummaryCacheTTL: DefaultStatsSummaryCacheTTL, + VirtualKubeletLeaseEnabled: true, VirtualKubeletLeaseLeaseDuration: 15 * time.Second, VirtualKubeletLeaseRenewDeadline: 10 * time.Second, diff --git a/cmd/virtual-kubelet/root/root.go b/cmd/virtual-kubelet/root/root.go index 3df30ba031..f9a4803086 100644 --- a/cmd/virtual-kubelet/root/root.go +++ b/cmd/virtual-kubelet/root/root.go @@ -215,6 +215,8 @@ func runRootCommand(ctx context.Context, c *Opts) error { RemoteRealLoadBalancerClassName: c.RemoteRealLoadBalancerClassName, EnableMetrics: c.EnableMetrics, + StatsSummaryCacheTTL: c.StatsSummaryCacheTTL, + HomeAPIServerHost: c.HomeAPIServerHost, HomeAPIServerPort: c.HomeAPIServerPort, diff --git a/cmd/virtual-kubelet/root/root_suite_test.go b/cmd/virtual-kubelet/root/root_suite_test.go new file mode 100644 index 0000000000..ac7fa9b73f --- /dev/null +++ b/cmd/virtual-kubelet/root/root_suite_test.go @@ -0,0 +1,27 @@ +// Copyright 2019-2026 The Liqo Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package root + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestRoot(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Root Suite") +} diff --git a/pkg/utils/cache/cache.go b/pkg/utils/cache/cache.go new file mode 100644 index 0000000000..b8c16e3fbb --- /dev/null +++ b/pkg/utils/cache/cache.go @@ -0,0 +1,137 @@ +// Copyright 2019-2026 The Liqo Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cache + +import ( + "context" + "errors" + "sync" + "time" + + "golang.org/x/sync/singleflight" +) + +// entry is a cached computation outcome: either a value (successful computation) or an +// error (failed one) is set, together with its expiration time. +type entry[V any] struct { + value V + err error + expiresAt time.Time +} + +// Cache is a read-through cache keyed by string. Successful computations are cached for ttl, +// failed ones for errorTTL (when positive). Concurrent computations of the same key are +// deduplicated through singleflight, so that only one of them is actually performed. +// +// The values are stored and returned as-is: callers must treat the cached value as read-only. +type Cache[V any] struct { + ttl time.Duration + errorTTL time.Duration + + mu sync.Mutex + entries map[string]entry[V] + + group singleflight.Group +} + +// New returns a new Cache, caching successful results for ttl and failed ones for errorTTL. +// A non-positive errorTTL disables error caching. +func New[V any](ttl, errorTTL time.Duration) *Cache[V] { + return &Cache[V]{ + ttl: ttl, + errorTTL: errorTTL, + entries: map[string]entry[V]{}, + } +} + +// Do returns the cached value for key, if present and not expired. Otherwise, it computes it +// through fn and caches the outcome. Concurrent calls for the same key are collapsed into a +// single fn invocation, which receives the context of the caller that first triggered it. +// +// Errors are cached for errorTTL, except context cancellation and deadline-exceeded ones, +// which are never cached (they are not a property of the computation itself). +func (c *Cache[V]) Do(ctx context.Context, key string, fn func(context.Context) (V, error)) (V, error) { + if cached, found := c.get(key); found { + return cached.value, cached.err + } + + // Only one concurrent caller for the same key actually runs fn; the others wait for the + // shared result. + res, err, _ := c.group.Do(key, func() (interface{}, error) { + value, err := fn(ctx) + if err != nil { + c.setError(key, err) + return nil, err + } + c.setValue(key, value) + return value, nil + }) + if err != nil { + var zero V + return zero, err + } + return res.(V), nil +} + +// get returns the cache entry for the given key, if present and not expired. Expired entries +// are evicted, so that stale results do not linger in the cache. +func (c *Cache[V]) get(key string) (entry[V], bool) { + c.mu.Lock() + defer c.mu.Unlock() + + cached, found := c.entries[key] + if !found { + return entry[V]{}, false + } + if !time.Now().Before(cached.expiresAt) { + delete(c.entries, key) + return entry[V]{}, false + } + return cached, true +} + +// setValue stores a successful computation outcome for the given key. +func (c *Cache[V]) setValue(key string, value V) { + c.mu.Lock() + defer c.mu.Unlock() + + c.evictExpired() + c.entries[key] = entry[V]{value: value, expiresAt: time.Now().Add(c.ttl)} +} + +// setError stores a failed computation outcome for the given key, unless error caching is +// disabled or the error is a context cancellation/deadline one. +func (c *Cache[V]) setError(key string, err error) { + if c.errorTTL <= 0 || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return + } + + c.mu.Lock() + defer c.mu.Unlock() + + c.evictExpired() + c.entries[key] = entry[V]{err: err, expiresAt: time.Now().Add(c.errorTTL)} +} + +// evictExpired removes the expired entries, to bound the memory retained by keys not +// requested anymore. It must be called while holding the mutex. +func (c *Cache[V]) evictExpired() { + now := time.Now() + for key, cached := range c.entries { + if !now.Before(cached.expiresAt) { + delete(c.entries, key) + } + } +} diff --git a/pkg/utils/cache/cache_suite_test.go b/pkg/utils/cache/cache_suite_test.go new file mode 100644 index 0000000000..ca803db10d --- /dev/null +++ b/pkg/utils/cache/cache_suite_test.go @@ -0,0 +1,27 @@ +// Copyright 2019-2026 The Liqo Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cache_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCache(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Cache Suite") +} diff --git a/pkg/utils/cache/cache_test.go b/pkg/utils/cache/cache_test.go new file mode 100644 index 0000000000..6383913311 --- /dev/null +++ b/pkg/utils/cache/cache_test.go @@ -0,0 +1,150 @@ +// Copyright 2019-2026 The Liqo Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cache_test + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/liqotech/liqo/pkg/utils/cache" +) + +// countingFunc returns a computation function returning the given value and error, recording its +// invocations in calls. +func countingFunc(calls *atomic.Int64, value string, err error) func(context.Context) (string, error) { + return func(context.Context) (string, error) { + calls.Add(1) + return value, err + } +} + +// countingSleepFunc behaves like countingFunc, additionally sleeping for the given delay before +// returning, to keep concurrent invocations in flight. +func countingSleepFunc(calls *atomic.Int64, value string, err error, delay time.Duration) func(context.Context) (string, error) { + return func(context.Context) (string, error) { + calls.Add(1) + time.Sleep(delay) + return value, err + } +} + +var _ = Describe("Cache", func() { + var ctx context.Context + + BeforeEach(func() { ctx = context.Background() }) + + It("should invoke the function once for calls within the TTL", func() { + var calls atomic.Int64 + c := cache.New[string](time.Minute, 0) + fn := countingFunc(&calls, "value", nil) + + first, err := c.Do(ctx, "key", fn) + Expect(err).ToNot(HaveOccurred()) + second, err := c.Do(ctx, "key", fn) + Expect(err).ToNot(HaveOccurred()) + + Expect(first).To(Equal("value")) + Expect(second).To(Equal("value")) + Expect(calls.Load()).To(BeNumerically("==", 1)) + }) + + It("should recompute the value once the TTL expires", func() { + var calls atomic.Int64 + c := cache.New[string](10*time.Millisecond, 0) + fn := countingFunc(&calls, "value", nil) + + _, err := c.Do(ctx, "key", fn) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() int64 { + _, err := c.Do(ctx, "key", fn) + Expect(err).ToNot(HaveOccurred()) + return calls.Load() + }, time.Second).Should(BeNumerically(">", 1)) + }) + + It("should deduplicate concurrent computations of the same key", func() { + const goroutines = 20 + + var calls atomic.Int64 + c := cache.New[string](time.Minute, 0) + fn := countingSleepFunc(&calls, "value", nil, 20*time.Millisecond) + + start := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer GinkgoRecover() + defer wg.Done() + <-start + value, err := c.Do(ctx, "key", fn) + Expect(err).ToNot(HaveOccurred()) + Expect(value).To(Equal("value")) + }() + } + close(start) + wg.Wait() + + Expect(calls.Load()).To(BeNumerically("==", 1)) + }) + + It("should cache and re-serve errors within the error TTL", func() { + var calls atomic.Int64 + expected := errors.New("failure") + c := cache.New[string](time.Minute, time.Minute) + fn := func(context.Context) (string, error) { calls.Add(1); return "", expected } + + _, err := c.Do(ctx, "key", fn) + Expect(err).To(MatchError(expected)) + _, err = c.Do(ctx, "key", fn) + Expect(err).To(MatchError(expected)) + + Expect(calls.Load()).To(BeNumerically("==", 1)) + }) + + It("should not cache errors when the error TTL is not positive", func() { + var calls atomic.Int64 + expected := errors.New("failure") + c := cache.New[string](time.Minute, 0) + fn := func(context.Context) (string, error) { calls.Add(1); return "", expected } + + _, err := c.Do(ctx, "key", fn) + Expect(err).To(MatchError(expected)) + _, err = c.Do(ctx, "key", fn) + Expect(err).To(MatchError(expected)) + + Expect(calls.Load()).To(BeNumerically("==", 2)) + }) + + It("should not cache context cancellation errors", func() { + var calls atomic.Int64 + c := cache.New[string](time.Minute, time.Minute) + fn := func(context.Context) (string, error) { calls.Add(1); return "", context.Canceled } + + _, err := c.Do(ctx, "key", fn) + Expect(err).To(MatchError(context.Canceled)) + _, err = c.Do(ctx, "key", fn) + Expect(err).To(MatchError(context.Canceled)) + + Expect(calls.Load()).To(BeNumerically("==", 2)) + }) +}) diff --git a/pkg/utils/cache/doc.go b/pkg/utils/cache/doc.go new file mode 100644 index 0000000000..8ca1ab6dbb --- /dev/null +++ b/pkg/utils/cache/doc.go @@ -0,0 +1,17 @@ +// Copyright 2019-2026 The Liqo Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package cache provides a small read-through cache with a time-to-live and singleflight +// deduplication of concurrent computations for the same key. +package cache diff --git a/pkg/virtualKubelet/provider/provider.go b/pkg/virtualKubelet/provider/provider.go index c3091dc3a1..d5cdca7f2e 100644 --- a/pkg/virtualKubelet/provider/provider.go +++ b/pkg/virtualKubelet/provider/provider.go @@ -78,6 +78,9 @@ type InitConfig struct { RemoteRealLoadBalancerClassName string EnableMetrics bool + // StatsSummaryCacheTTL is the time-to-live of the cached node stats summary. + StatsSummaryCacheTTL time.Duration + HomeAPIServerHost string HomeAPIServerPort string @@ -144,6 +147,8 @@ func NewLiqoProvider(ctx context.Context, cfg *InitConfig, eb record.EventBroadc return result, nil }, NetConfiguration: cfg.NetConfiguration, + + StatsSummaryCacheTTL: cfg.StatsSummaryCacheTTL, } podreflector := workload.NewPodReflector(cfg.RemoteConfig, remoteMetricsClient, &podReflectorConfig, ptr.To(cfg.ReflectorsConfigs[resources.Pod])) diff --git a/pkg/virtualKubelet/reflection/workload/pod.go b/pkg/virtualKubelet/reflection/workload/pod.go index 570bb25e85..33cef025bf 100644 --- a/pkg/virtualKubelet/reflection/workload/pod.go +++ b/pkg/virtualKubelet/reflection/workload/pod.go @@ -18,6 +18,7 @@ import ( "context" "io" "sync" + "time" "github.com/virtual-kubelet/virtual-kubelet/node/api" "github.com/virtual-kubelet/virtual-kubelet/node/api/statsv1alpha1" @@ -39,6 +40,7 @@ import ( networkingv1beta1 "github.com/liqotech/liqo/apis/networking/v1beta1" offloadingv1beta1 "github.com/liqotech/liqo/apis/offloading/v1beta1" + "github.com/liqotech/liqo/pkg/utils/cache" "github.com/liqotech/liqo/pkg/utils/virtualkubelet" "github.com/liqotech/liqo/pkg/virtualKubelet/forge" "github.com/liqotech/liqo/pkg/virtualKubelet/reflection/generic" @@ -53,6 +55,21 @@ var _ manager.FallbackReflector = (*FallbackPodReflector)(nil) const ( // PodReflectorName -> The name associated with the Pod reflector. PodReflectorName = "Pod" + + // statsSummaryCacheKey is the cache key of the node stats summary, as a single summary is + // served by each virtual-kubelet instance. + statsSummaryCacheKey = "summary" + // statsSummaryCacheDefaultTTL is the fallback time-to-live of the cached stats summary, used + // when the configured value is not positive. The actual value is configurable through the + // stats-summary-cache-ttl flag. + statsSummaryCacheDefaultTTL = 20 * time.Second + // statsSummaryErrorCacheTTL is the time-to-live of failed stats summaries, kept much shorter + // than successful ones so that a persistent failure is not retried at each request, while + // still allowing a prompt recovery. + statsSummaryErrorCacheTTL = 5 * time.Second + // statsSummaryDetachTimeout bounds the time allotted to a deduplicated summary sweep once + // detached from the caller's context (see Stats). + statsSummaryDetachTimeout = 60 * time.Second ) // MetricsFactory represents a function to generate the interface to retrieve the pod metrics for a given namespace. @@ -85,6 +102,8 @@ type PodReflector struct { handlers sync.Map /* implicit signature: map[string]NamespacedPodHandler */ + statsCache *cache.Cache[*statsv1alpha1.Summary] + config *PodReflectorConfig } @@ -97,6 +116,9 @@ type PodReflectorConfig struct { KubernetesServiceIPMapper func(context.Context) ([]string, error) NetConfiguration *networkingv1beta1.Configuration + + // StatsSummaryCacheTTL is the time-to-live of the cached node stats summary. + StatsSummaryCacheTTL time.Duration } // FallbackPodReflector handles the "orphan" pods outside the managed namespaces. @@ -118,9 +140,15 @@ func NewPodReflector( remoteMetricsFactory MetricsFactory, /* required to retrieve the pod metrics from the remote cluster */ podReflectorconfig *PodReflectorConfig, reflectorConfig *offloadingv1beta1.ReflectorConfig) *PodReflector { + statsCacheTTL := podReflectorconfig.StatsSummaryCacheTTL + if statsCacheTTL <= 0 { + statsCacheTTL = statsSummaryCacheDefaultTTL + } + reflector := &PodReflector{ remoteRESTConfig: remoteRESTConfig, remoteMetricsFactory: remoteMetricsFactory, + statsCache: cache.New[*statsv1alpha1.Summary](statsCacheTTL, statsSummaryErrorCacheTTL), config: podReflectorconfig, } @@ -226,23 +254,32 @@ func (pr *PodReflector) Logs(ctx context.Context, namespace, pod, container stri return nil, kerrors.NewNotFound(corev1.Resource(corev1.ResourcePods.String()), klog.KRef(namespace, pod).String()) } -// Stats retrieves the stats of the reflected pods. +// Stats retrieves the stats of the reflected pods. The resulting summary is cached for a short +// time and its computation is deduplicated through singleflight, so that concurrent and +// near-duplicate scrapes are collapsed into a single sweep of the remote pod metrics. func (pr *PodReflector) Stats(ctx context.Context) (*statsv1alpha1.Summary, error) { - var pods []statsv1alpha1.PodStats - var err error + return pr.statsCache.Do(ctx, statsSummaryCacheKey, func(context.Context) (*statsv1alpha1.Summary, error) { + // Detach the sweep from the caller's context: a client disconnecting early must not abort + // the shared sweep the other waiters depend on, nor prevent the result from being cached. + ctx, cancel := context.WithTimeout(context.Background(), statsSummaryDetachTimeout) + defer cancel() - pr.handlers.Range(func(_, handler interface{}) bool { - var stats []statsv1alpha1.PodStats - stats, err = handler.(NamespacedPodHandler).Stats(ctx) - pods = append(pods, stats...) - return err == nil - }) + var pods []statsv1alpha1.PodStats + var err error - if err != nil { - return nil, err - } + pr.handlers.Range(func(_, handler interface{}) bool { + var stats []statsv1alpha1.PodStats + stats, err = handler.(NamespacedPodHandler).Stats(ctx) + pods = append(pods, stats...) + return err == nil + }) - return forge.LocalNodeStats(pods), nil + if err != nil { + return nil, err + } + + return forge.LocalNodeStats(pods), nil + }) } // KubernetesServiceIPGetter returns a function to retrieve the IP associated with the kubernetes.default service. diff --git a/pkg/virtualKubelet/reflection/workload/pod_test.go b/pkg/virtualKubelet/reflection/workload/pod_test.go index 013a149e72..18b0e51e80 100644 --- a/pkg/virtualKubelet/reflection/workload/pod_test.go +++ b/pkg/virtualKubelet/reflection/workload/pod_test.go @@ -20,6 +20,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/virtual-kubelet/virtual-kubelet/node/api/statsv1alpha1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -33,6 +34,9 @@ import ( networkingv1beta1 "github.com/liqotech/liqo/apis/networking/v1beta1" offloadingv1beta1 "github.com/liqotech/liqo/apis/offloading/v1beta1" "github.com/liqotech/liqo/cmd/virtual-kubelet/root" + liqoclient "github.com/liqotech/liqo/pkg/client/clientset/versioned" + liqoclientfake "github.com/liqotech/liqo/pkg/client/clientset/versioned/fake" + liqoinformers "github.com/liqotech/liqo/pkg/client/informers/externalversions" cidrutils "github.com/liqotech/liqo/pkg/utils/cidr" . "github.com/liqotech/liqo/pkg/utils/testutil" "github.com/liqotech/liqo/pkg/virtualKubelet/forge" @@ -50,7 +54,8 @@ var _ = Describe("Pod Reflection Tests", func() { Type: root.DefaultReflectorsTypes[resources.Pod], } reflector := workload.NewPodReflector(nil, nil, - &workload.PodReflectorConfig{forge.APIServerSupportDisabled, false, "", "", fakeAPIServerRemapping([]string{""}), nil}, &reflectorConfig) + &workload.PodReflectorConfig{forge.APIServerSupportDisabled, false, "", "", fakeAPIServerRemapping([]string{""}), nil, + time.Minute}, &reflectorConfig) Expect(reflector).ToNot(BeNil()) Expect(reflector.Reflector).ToNot(BeNil()) }) @@ -98,7 +103,7 @@ var _ = Describe("Pod Reflection Tests", func() { }, }, }, - }}, &reflectorConfig) + }, time.Minute}, &reflectorConfig) kubernetesServiceIPGetter = reflector.KubernetesServiceIPGetter() }) @@ -145,7 +150,8 @@ var _ = Describe("Pod Reflection Tests", func() { Type: root.DefaultReflectorsTypes[resources.Pod], } reflector = workload.NewPodReflector(nil, nil, - &workload.PodReflectorConfig{forge.APIServerSupportDisabled, false, "", "", fakeAPIServerRemapping([]string{""}), nil}, &reflectorConfig) + &workload.PodReflectorConfig{forge.APIServerSupportDisabled, false, "", "", fakeAPIServerRemapping([]string{""}), nil, + time.Minute}, &reflectorConfig) opts := options.New(client, factory.Core().V1().Pods()). WithHandlerFactory(FakeEventHandler). @@ -230,3 +236,60 @@ var _ = Describe("Pod Reflection Tests", func() { }) }) }) + +var _ = Describe("Pod Stats caching", func() { + var ( + reflector *workload.PodReflector + client *fake.Clientset + liqoClient liqoclient.Interface + podMetrics *fakePodMetrics + output *statsv1alpha1.Summary + err error + ) + + BeforeEach(func() { + client = fake.NewClientset() + liqoClient = liqoclientfake.NewSimpleClientset() //nolint:staticcheck // NewClientset is not generated in this repo (requires --with-applyconfig). + podMetrics = &fakePodMetrics{} + }) + + JustBeforeEach(func() { + factory := informers.NewSharedInformerFactory(client, 10*time.Hour) + liqoFactory := liqoinformers.NewSharedInformerFactory(liqoClient, 10*time.Hour) + broadcaster := record.NewBroadcaster() + + metricsFactory := func(string) metricsv1beta1.PodMetricsInterface { return podMetrics } + reflectorConfig := offloadingv1beta1.ReflectorConfig{ + NumWorkers: 0, + Type: root.DefaultReflectorsTypes[resources.Pod], + } + reflector = workload.NewPodReflector(nil, metricsFactory, + &workload.PodReflectorConfig{forge.APIServerSupportDisabled, false, "", "", fakeAPIServerRemapping([]string{""}), nil, + time.Minute}, &reflectorConfig) + reflector.Start(ctx, options.New(client, factory.Core().V1().Pods()).WithEventBroadcaster(broadcaster)) + reflector.NewNamespaced(options.NewNamespaced(). + WithLocal(LocalNamespace, client, factory).WithLiqoLocal(liqoClient, liqoFactory). + WithRemote(RemoteNamespace, client, factory).WithLiqoRemote(liqoClient, liqoFactory). + WithHandlerFactory(FakeEventHandler).WithEventBroadcaster(broadcaster).WithForgingOpts(FakeForgingOpts())) + + factory.Start(ctx.Done()) + liqoFactory.Start(ctx.Done()) + factory.WaitForCacheSync(ctx.Done()) + liqoFactory.WaitForCacheSync(ctx.Done()) + + output, err = reflector.Stats(ctx) + }) + + It("should succeed", func() { Expect(err).ToNot(HaveOccurred()) }) + It("should return a summary", func() { Expect(output).ToNot(BeNil()) }) + + When("retrieving the summary again within the cache TTL", func() { + JustBeforeEach(func() { output, err = reflector.Stats(ctx) }) + + It("should succeed", func() { Expect(err).ToNot(HaveOccurred()) }) + It("should collapse the two scrapes into a single remote metrics listing", func() { + Expect(podMetrics.listCalls).To(BeNumerically("==", 1)) + }) + It("should not return a nil summary", func() { Expect(output).ToNot(BeNil()) }) + }) +}) diff --git a/pkg/virtualKubelet/reflection/workload/podns.go b/pkg/virtualKubelet/reflection/workload/podns.go index eb7f3734b7..ef54b79ae2 100644 --- a/pkg/virtualKubelet/reflection/workload/podns.go +++ b/pkg/virtualKubelet/reflection/workload/podns.go @@ -713,7 +713,7 @@ func (npr *NamespacedPodReflector) Stats(ctx context.Context) ([]statsv1alpha1.P stats = append(stats, forge.LocalPodStats(local, &metrics.Items[idx])) } - klog.Infof("Stats for local namespace %q (remote %q) correctly retrieved", npr.LocalNamespace(), npr.RemoteNamespace()) + klog.V(2).Infof("Stats for local namespace %q (remote %q) correctly retrieved", npr.LocalNamespace(), npr.RemoteNamespace()) return stats, nil } diff --git a/pkg/virtualKubelet/reflection/workload/podns_test.go b/pkg/virtualKubelet/reflection/workload/podns_test.go index 9b179f09a9..7a60ff7510 100644 --- a/pkg/virtualKubelet/reflection/workload/podns_test.go +++ b/pkg/virtualKubelet/reflection/workload/podns_test.go @@ -58,13 +58,15 @@ import ( type fakePodMetrics struct { metricsv1beta1.PodMetricsInterface - items []metricsapi.PodMetrics - selector string + items []metricsapi.PodMetrics + selector string + listCalls int } //nolint:gocritic // hugeParam: signature imposed by the metricsv1beta1.PodMetricsInterface interface. func (f *fakePodMetrics) List(_ context.Context, opts metav1.ListOptions) (*metricsapi.PodMetricsList, error) { f.selector = opts.LabelSelector + f.listCalls++ return &metricsapi.PodMetricsList{Items: f.items}, nil } @@ -123,7 +125,8 @@ var _ = Describe("Namespaced Pod Reflection Tests", func() { Type: root.DefaultReflectorsTypes[resources.Pod], } rfl := workload.NewPodReflector(nil, metricsFactory, - &workload.PodReflectorConfig{forge.APIServerSupportTokenAPI, false, "", "", fakeAPIServerRemapping([]string{""}), netConfig}, &reflectorConfig) + &workload.PodReflectorConfig{forge.APIServerSupportTokenAPI, false, "", "", fakeAPIServerRemapping([]string{""}), netConfig, + time.Minute}, &reflectorConfig) rfl.Start(ctx, options.New(client, factory.Core().V1().Pods()).WithEventBroadcaster(broadcaster)) reflector = rfl.NewNamespaced(options.NewNamespaced(). WithLocal(LocalNamespace, client, factory).WithLiqoLocal(liqoClient, liqoFactory). From 9ebd783523f2b3e00efc20ba7b3806b4716d6d04 Mon Sep 17 00:00:00 2001 From: Francesco Torta <62566275+fra98@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:51:03 +0200 Subject: [PATCH 2/2] fixup! fixup! perf(vk): cache podmetrics and stats --- cmd/virtual-kubelet/root/http.go | 5 +++++ cmd/virtual-kubelet/root/opts.go | 2 +- pkg/utils/cache/cache.go | 6 +++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/cmd/virtual-kubelet/root/http.go b/cmd/virtual-kubelet/root/http.go index c8fa450da6..88b6cf1e89 100644 --- a/cmd/virtual-kubelet/root/http.go +++ b/cmd/virtual-kubelet/root/http.go @@ -240,6 +240,11 @@ func attachMetricsRoutes(ctx context.Context, mux *http.ServeMux, cl rest.Interf uri += "?" + reqQuery.Encode() } + // ctx is the server-lifetime context (not r.Context()), hence deliberately shared across all + // scrapes: the singleflight computation below is therefore never tied to a single client, so + // one disconnecting caller cannot abort it for the others waiting on the same key. Keep it + // this way: using r.Context() would let the first caller's cancellation poison the shared + // result for every waiter. resp, err := scrapeCache.Do(ctx, uri, func(ctx context.Context) (scrapeResponse, error) { res := cl.Get().RequestURI(uri).Do(ctx) if err := res.Error(); err != nil { diff --git a/cmd/virtual-kubelet/root/opts.go b/cmd/virtual-kubelet/root/opts.go index d6a736271a..cf812e253c 100644 --- a/cmd/virtual-kubelet/root/opts.go +++ b/cmd/virtual-kubelet/root/opts.go @@ -46,7 +46,7 @@ const ( DefaultNodeCheckNetwork = true // DefaultMetricsProxyCacheTTL is the default time-to-live of the cached metrics proxy responses. - DefaultMetricsProxyCacheTTL = 15 * time.Second + DefaultMetricsProxyCacheTTL = 10 * time.Second // DefaultStatsSummaryCacheTTL is the default time-to-live of the cached node stats summary. DefaultStatsSummaryCacheTTL = 20 * time.Second ) diff --git a/pkg/utils/cache/cache.go b/pkg/utils/cache/cache.go index b8c16e3fbb..3c21a06404 100644 --- a/pkg/utils/cache/cache.go +++ b/pkg/utils/cache/cache.go @@ -58,7 +58,11 @@ func New[V any](ttl, errorTTL time.Duration) *Cache[V] { // Do returns the cached value for key, if present and not expired. Otherwise, it computes it // through fn and caches the outcome. Concurrent calls for the same key are collapsed into a -// single fn invocation, which receives the context of the caller that first triggered it. +// single fn invocation, which receives the context passed by the caller that first triggered it. +// +// Callers whose context is tied to a single request must detach it before calling Do (e.g. by +// wrapping fn with context.WithTimeout(context.Background(), ...)): otherwise, the cancellation +// of that first caller would abort the shared computation awaited by every other caller. // // Errors are cached for errorTTL, except context cancellation and deadline-exceeded ones, // which are never cached (they are not a property of the computation itself).