diff --git a/source/endpoint_benchmark_test.go b/source/endpoint_benchmark_test.go new file mode 100644 index 000000000..a178950d9 --- /dev/null +++ b/source/endpoint_benchmark_test.go @@ -0,0 +1,233 @@ +/* +Copyright 2025 The Kubernetes 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 source + +import ( + "context" + "encoding/binary" + "fmt" + "math/rand/v2" + "net" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kubeinformers "k8s.io/client-go/informers" + coreinformers "k8s.io/client-go/informers/core/v1" + "k8s.io/client-go/kubernetes/fake" + + v1alpha3 "istio.io/api/networking/v1alpha3" + istiov1a "istio.io/client-go/pkg/apis/networking/v1" + + "k8s.io/client-go/tools/cache" +) + +func BenchmarkEndpointTargetsFromServicesMedium(b *testing.B) { + svcInformer, err := svcInformerWithServices(36, 1000) + assert.NoError(b, err) + + sel := map[string]string{"app": "nginx", "env": "prod"} + + for b.Loop() { + targets, _ := EndpointTargetsFromServices(svcInformer, "default", sel) + assert.Equal(b, 36, targets.Len()) + } +} + +func BenchmarkEndpointTargetsFromServicesMediumIterateOverGateways(b *testing.B) { + svcInformer, err := svcInformerWithServices(36, 500) + assert.NoError(b, err) + + gateways := fixturesIstioGatewaySvcWithLabels(15, 70) + + for b.Loop() { + for _, gateway := range gateways { + _, _ = EndpointTargetsFromServices(svcInformer, gateway.Namespace, gateway.Spec.Selector) + } + } +} + +func BenchmarkEndpointTargetsFromServicesHigh(b *testing.B) { + svcInformer, err := svcInformerWithServices(36, 40000) + assert.NoError(b, err) + sel := map[string]string{"app": "nginx", "env": "prod"} + + for b.Loop() { + targets, _ := EndpointTargetsFromServices(svcInformer, "default", sel) + assert.Equal(b, 36, targets.Len()) + } +} + +// This benchmark tests the performance of EndpointTargetsFromServices with a high number of services and gateways. +func BenchmarkEndpointTargetsFromServicesHighIterateOverGateways(b *testing.B) { + svcInformer, err := svcInformerWithServices(36, 40000) + assert.NoError(b, err) + + gateways := fixturesIstioGatewaySvcWithLabels(50, 1000) + + for b.Loop() { + for _, gateway := range gateways { + _, _ = EndpointTargetsFromServices(svcInformer, gateway.Namespace, gateway.Spec.Selector) + } + } +} + +// helperToPopulateFakeClientWithServices populates a fake Kubernetes client with a specified services. +func svcInformerWithServices(toLookup, underTest int) (coreinformers.ServiceInformer, error) { + client := fake.NewClientset() + informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(client, 0, kubeinformers.WithNamespace("default")) + svcInformer := informerFactory.Core().V1().Services() + ctx := context.Background() + + _, err := svcInformer.Informer().AddEventHandler( + cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + }, + }, + ) + if err != nil { + return nil, fmt.Errorf("failed to add event handler: %w", err) + } + + services := fixturesSvcWithLabels(toLookup, underTest) + for _, svc := range services { + _, err := client.CoreV1().Services(svc.Namespace).Create(ctx, svc, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to create service %s: %w", svc.Name, err) + } + } + + stopCh := make(chan struct{}) + defer close(stopCh) + informerFactory.Start(stopCh) + cache.WaitForCacheSync(stopCh, svcInformer.Informer().HasSynced) + return svcInformer, nil +} + +// fixturesSvcWithLabels creates a list of Services for testing purposes. +// It generates a specified number of services with static labels and random labels. +// The first `toLookup` services have specific labels, while the next `underTest` services have random labels. +func fixturesSvcWithLabels(toLookup, underTest int) []*corev1.Service { + var services []*corev1.Service + + var randomLabels = func(input int) map[string]string { + if input%3 == 0 { + // every third service has no labels + return map[string]string{} + } + return map[string]string{ + "app": fmt.Sprintf("service-%d", rand.IntN(100)), + fmt.Sprintf("key%d", rand.IntN(100)): fmt.Sprintf("value%d", rand.IntN(100)), + } + } + + var randomIPs = func() []string { + ip := rand.Uint32() + buf := make([]byte, 4) + binary.LittleEndian.PutUint32(buf, ip) + return []string{net.IP(buf).String()} + } + + var createService = func(name string, namespace string, selector map[string]string) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: corev1.ServiceSpec{ + Selector: selector, + ExternalIPs: randomIPs(), + }, + } + } + + // services with specific labels + for i := 0; i < toLookup; i++ { + svc := createService("nginx-svc-"+strconv.Itoa(i), "default", map[string]string{"app": "nginx", "env": "prod"}) + services = append(services, svc) + } + + // services with random labels + for i := 0; i < underTest; i++ { + svc := createService("random-svc-"+strconv.Itoa(i), "default", randomLabels(i)) + services = append(services, svc) + } + + // Shuffle the services to ensure randomness + for i := 0; i < 3; i++ { + rand.Shuffle(len(services), func(i, j int) { + services[i], services[j] = services[j], services[i] + }) + } + + return services +} + +// fixturesIstioGatewaySvcWithLabels creates a list of Services for testing purposes. +// It generates a specified number of gateways with static labels and random labels. +// The first `toLookup` services have specific labels, while the next `underTest` services have random labels. +func fixturesIstioGatewaySvcWithLabels(toLookup, underTest int) []*istiov1a.Gateway { + var result []*istiov1a.Gateway + + var randomLabels = func(input int) map[string]string { + if input%3 == 0 { + // every third service has no labels + return map[string]string{} + } + return map[string]string{ + "app": fmt.Sprintf("service-%d", rand.IntN(100)), + fmt.Sprintf("key%d", rand.IntN(100)): fmt.Sprintf("value%d", rand.IntN(100)), + } + } + + var createGateway = func(name string, namespace string, selector map[string]string) *istiov1a.Gateway { + return &istiov1a.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: v1alpha3.Gateway{ + Selector: selector, + Servers: []*v1alpha3.Server{ + { + Port: &v1alpha3.Port{}, + Hosts: []string{"*"}, + }, + }, + }, + } + } + // services with specific labels + for i := 0; i < toLookup; i++ { + svc := createGateway("istio-gw-"+strconv.Itoa(i), "default", map[string]string{"app": "nginx", "env": "prod"}) + result = append(result, svc) + } + + // services with random labels + for i := 0; i < underTest; i++ { + svc := createGateway("istio-random-svc-"+strconv.Itoa(i), "default", randomLabels(i)) + result = append(result, svc) + } + + // Shuffle the services to ensure randomness + for i := 0; i < 3; i++ { + rand.Shuffle(len(result), func(i, j int) { + result[i], result[j] = result[j], result[i] + }) + } + + return result +} diff --git a/source/endpoints.go b/source/endpoints.go index c2b318721..ded87c4f2 100644 --- a/source/endpoints.go +++ b/source/endpoints.go @@ -85,6 +85,7 @@ func EndpointTargetsFromServices(svcInformer coreinformers.ServiceInformer, name targets := endpoint.Targets{} services, err := svcInformer.Lister().Services(namespace).List(labels.Everything()) + if err != nil { return nil, fmt.Errorf("failed to list labels for services in namespace %q: %w", namespace, err) } diff --git a/source/endpoints_test.go b/source/endpoints_test.go index a90b8656f..dcd3571d7 100644 --- a/source/endpoints_test.go +++ b/source/endpoints_test.go @@ -22,6 +22,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kubeinformers "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes/fake" + "sigs.k8s.io/external-dns/endpoint" ) @@ -137,7 +138,6 @@ func TestEndpointTargetsFromServices(t *testing.T) { namespace: "default", selector: map[string]string{"app": "nginx"}, expected: endpoint.Targets{}, - wantErr: false, }, { name: "matching service with external IPs", @@ -156,7 +156,23 @@ func TestEndpointTargetsFromServices(t *testing.T) { namespace: "default", selector: map[string]string{"app": "nginx"}, expected: endpoint.Targets{"192.0.2.1", "158.123.32.23"}, - wantErr: false, + }, + { + name: "no matching service as service without selector", + services: []*corev1.Service{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "svc1", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + ExternalIPs: []string{"192.0.2.1"}, + }, + }, + }, + namespace: "default", + selector: map[string]string{"app": "nginx"}, + expected: endpoint.Targets{}, }, { name: "matching service with load balancer IP", @@ -181,7 +197,6 @@ func TestEndpointTargetsFromServices(t *testing.T) { namespace: "default", selector: map[string]string{"app": "nginx"}, expected: endpoint.Targets{"192.0.2.2"}, - wantErr: false, }, { name: "matching service with load balancer hostname", @@ -206,7 +221,6 @@ func TestEndpointTargetsFromServices(t *testing.T) { namespace: "default", selector: map[string]string{"app": "nginx"}, expected: endpoint.Targets{"lb.example.com"}, - wantErr: false, }, { name: "no matching services", @@ -224,13 +238,12 @@ func TestEndpointTargetsFromServices(t *testing.T) { namespace: "default", selector: map[string]string{"app": "nginx"}, expected: endpoint.Targets{}, - wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - client := fake.NewSimpleClientset() + client := fake.NewClientset() informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(client, 0, kubeinformers.WithNamespace(tt.namespace)) serviceInformer := informerFactory.Core().V1().Services() @@ -253,3 +266,14 @@ func TestEndpointTargetsFromServices(t *testing.T) { }) } } + +func TestEndpointTargetsFromServicesWithFixtures(t *testing.T) { + svcInformer, err := svcInformerWithServices(2, 9) + assert.NoError(t, err) + + sel := map[string]string{"app": "nginx", "env": "prod"} + + targets, err := EndpointTargetsFromServices(svcInformer, "default", sel) + assert.NoError(t, err) + assert.Equal(t, 2, targets.Len()) +} diff --git a/source/f5_virtualserver_test.go b/source/f5_virtualserver_test.go index eb0866470..b285c21d5 100644 --- a/source/f5_virtualserver_test.go +++ b/source/f5_virtualserver_test.go @@ -329,7 +329,7 @@ func TestF5VirtualServerEndpoints(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - fakeKubernetesClient := fakeKube.NewSimpleClientset() + fakeKubernetesClient := fakeKube.NewClientset() scheme := runtime.NewScheme() scheme.AddKnownTypes(f5VirtualServerGVR.GroupVersion(), &f5.VirtualServer{}, &f5.VirtualServerList{}) fakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme) diff --git a/source/istio_gateway.go b/source/istio_gateway.go index 72f49f4f3..39d96d6b3 100644 --- a/source/istio_gateway.go +++ b/source/istio_gateway.go @@ -260,13 +260,7 @@ func (sc *gatewaySource) targetsFromGateway(ctx context.Context, gateway *networ return sc.targetsFromIngress(ctx, ingressStr, gateway) } - targets, err := EndpointTargetsFromServices(sc.serviceInformer, sc.namespace, gateway.Spec.Selector) - - if err != nil { - return nil, err - } - - return targets, nil + return EndpointTargetsFromServices(sc.serviceInformer, sc.namespace, gateway.Spec.Selector) } // endpointsFromGatewayConfig extracts the endpoints from an Istio Gateway Config object @@ -323,12 +317,3 @@ func (sc *gatewaySource) hostNamesFromGateway(gateway *networkingv1alpha3.Gatewa return hostnames, nil } - -func gatewaySelectorMatchesServiceSelector(gwSelector, svcSelector map[string]string) bool { - for k, v := range gwSelector { - if lbl, ok := svcSelector[k]; !ok || lbl != v { - return false - } - } - return true -} diff --git a/source/istio_gateway_test.go b/source/istio_gateway_test.go index 828353678..50f6e4059 100644 --- a/source/istio_gateway_test.go +++ b/source/istio_gateway_test.go @@ -46,7 +46,7 @@ type GatewaySuite struct { } func (suite *GatewaySuite) SetupTest() { - fakeKubernetesClient := fake.NewSimpleClientset() + fakeKubernetesClient := fake.NewClientset() fakeIstioClient := istiofake.NewSimpleClientset() var err error @@ -166,7 +166,7 @@ func TestNewIstioGatewaySource(t *testing.T) { _, err := NewIstioGatewaySource( context.TODO(), - fake.NewSimpleClientset(), + fake.NewClientset(), istiofake.NewSimpleClientset(), "", ti.annotationFilter, diff --git a/source/istio_virtualservice.go b/source/istio_virtualservice.go index 007021b4a..cd106b2f3 100644 --- a/source/istio_virtualservice.go +++ b/source/istio_virtualservice.go @@ -453,42 +453,16 @@ func (sc *virtualServiceSource) targetsFromIngress(ctx context.Context, ingressS return } -func (sc *virtualServiceSource) targetsFromGateway(ctx context.Context, gateway *networkingv1alpha3.Gateway) (targets endpoint.Targets, err error) { - targets = annotations.TargetsFromTargetAnnotation(gateway.Annotations) +func (sc *virtualServiceSource) targetsFromGateway(ctx context.Context, gateway *networkingv1alpha3.Gateway) (endpoint.Targets, error) { + targets := annotations.TargetsFromTargetAnnotation(gateway.Annotations) if len(targets) > 0 { - return + return targets, nil } ingressStr, ok := gateway.Annotations[IstioGatewayIngressSource] if ok && ingressStr != "" { - targets, err = sc.targetsFromIngress(ctx, ingressStr, gateway) - return + return sc.targetsFromIngress(ctx, ingressStr, gateway) } - services, err := sc.serviceInformer.Lister().Services(sc.namespace).List(labels.Everything()) - if err != nil { - log.Error(err) - return - } - - for _, service := range services { - if !gatewaySelectorMatchesServiceSelector(gateway.Spec.Selector, service.Spec.Selector) { - continue - } - - if len(service.Spec.ExternalIPs) > 0 { - targets = append(targets, service.Spec.ExternalIPs...) - continue - } - - for _, lb := range service.Status.LoadBalancer.Ingress { - if lb.IP != "" { - targets = append(targets, lb.IP) - } else if lb.Hostname != "" { - targets = append(targets, lb.Hostname) - } - } - } - - return + return EndpointTargetsFromServices(sc.serviceInformer, sc.namespace, gateway.Spec.Selector) } diff --git a/source/istio_virtualservice_test.go b/source/istio_virtualservice_test.go index cf7437367..014b1d891 100644 --- a/source/istio_virtualservice_test.go +++ b/source/istio_virtualservice_test.go @@ -2035,7 +2035,7 @@ func testGatewaySelectorMatchesService(t *testing.T) { }, } { t.Run(ti.title, func(t *testing.T) { - require.Equal(t, ti.expected, gatewaySelectorMatchesServiceSelector(ti.gwSelector, ti.lbSelector)) + require.Equal(t, ti.expected, MatchesServiceSelector(ti.gwSelector, ti.lbSelector)) }) } }