From 2433b18fefbabcf5552b3bba91fd17824b335379 Mon Sep 17 00:00:00 2001 From: Kangmin Kim <76634341+amazon7737@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:22:06 +0900 Subject: [PATCH] Add limit-connections support --- .../kubernetes/ingress-nginx.md | 2 +- .../kubernetes/ingress-nginx/annotations.go | 1 + .../ingress-with-limit-connections.yml | 20 ++++ .../ingress-nginx/kubernetes_test.go | 99 +++++++++++++++++++ .../kubernetes/ingress-nginx/middleware.go | 15 +++ .../kubernetes/ingress-nginx/model.go | 3 + .../kubernetes/ingress-nginx/translator.go | 6 ++ 7 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-limit-connections.yml diff --git a/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md b/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md index 5f06356f11..2739305088 100644 --- a/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md +++ b/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md @@ -376,6 +376,7 @@ The following annotations are organized by category for easier navigation. | `nginx.ingress.kubernetes.io/limit-rps` | Exceeding the limit returns `429 Too Many Requests` instead of NGINX's default `503 Service Unavailable`. | | `nginx.ingress.kubernetes.io/limit-rpm` | Exceeding the limit returns `429 Too Many Requests` instead of NGINX's default `503 Service Unavailable`. | | `nginx.ingress.kubernetes.io/limit-burst-multiplier` | Default to a multiplier of 5 if the configured value is less than 1. Exceeding the limit returns `429 Too Many Requests` instead of NGINX's default `503 Service Unavailable`. | +| `nginx.ingress.kubernetes.io/limit-connections` | Exceeding the limit returns `429 Too Many Requests` instead of NGINX's default `503 Service Unavailable`. The concurrent connection limit is evaluated per client IP address. Values less than or equal to `0` are safely ignored. | ### Buffering @@ -454,7 +455,6 @@ In practice, Traefik is slightly more lenient under bursty load, as it smooths o | `nginx.ingress.kubernetes.io/limit-rate-after` | | | `nginx.ingress.kubernetes.io/limit-rate` | | | `nginx.ingress.kubernetes.io/limit-whitelist` | | -| `nginx.ingress.kubernetes.io/limit-connections` | | | `nginx.ingress.kubernetes.io/global-rate-limit` | | | `nginx.ingress.kubernetes.io/global-rate-limit-window` | | | `nginx.ingress.kubernetes.io/global-rate-limit-key` | | diff --git a/pkg/provider/kubernetes/ingress-nginx/annotations.go b/pkg/provider/kubernetes/ingress-nginx/annotations.go index d2d8f58ee2..3b8319c0bc 100644 --- a/pkg/provider/kubernetes/ingress-nginx/annotations.go +++ b/pkg/provider/kubernetes/ingress-nginx/annotations.go @@ -89,6 +89,7 @@ type IngressConfig struct { LimitRPM *int `annotation:"nginx.ingress.kubernetes.io/limit-rpm"` LimitRPS *int `annotation:"nginx.ingress.kubernetes.io/limit-rps"` LimitBurstMultiplier *int `annotation:"nginx.ingress.kubernetes.io/limit-burst-multiplier"` + LimitConnections *int `annotation:"nginx.ingress.kubernetes.io/limit-connections"` CustomHeaders *string `annotation:"nginx.ingress.kubernetes.io/custom-headers"` UpstreamVHost *string `annotation:"nginx.ingress.kubernetes.io/upstream-vhost"` diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-limit-connections.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-limit-connections.yml new file mode 100644 index 0000000000..1ac9eb7493 --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-limit-connections.yml @@ -0,0 +1,20 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-limit-connections + namespace: default + annotations: + nginx.ingress.kubernetes.io/limit-connections: "10" +spec: + ingressClassName: nginx + rules: + - host: whoami.localhost + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: whoami + port: + number: 80 diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go index ea3e78156e..1839fca436 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go @@ -14232,6 +14232,105 @@ func TestLoadIngresses(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "Limit connections", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-limit-connections.yml", + }, + expected: &dynamic.Configuration{ + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Services: map[string]*dynamic.TCPService{}, + }, + HTTP: &dynamic.HTTPConfiguration{ + Routers: map[string]*dynamic.Router{ + "default-ingress-with-limit-connections-rule-0-path-0": { + EntryPoints: []string{"http"}, + Rule: `Host("whoami.localhost") && PathPrefix("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-limit-connections-rule-0-path-0-limit-connections", "default-ingress-with-limit-connections-rule-0-path-0-retry"}, + Service: "default-ingress-with-limit-connections-whoami-80", + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-limit-connections", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + "default-ingress-with-limit-connections-rule-0-path-0-tls": { + EntryPoints: []string{"https"}, + Rule: `Host("whoami.localhost") && PathPrefix("/")`, + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-limit-connections-rule-0-path-0-tls-limit-connections", "default-ingress-with-limit-connections-rule-0-path-0-tls-retry"}, + Service: "default-ingress-with-limit-connections-whoami-80", + TLS: &dynamic.RouterTLSConfig{}, + Observability: &dynamic.RouterObservabilityConfig{ + Metadata: &dynamic.ObservabilityMetadata{ + Ingress: &dynamic.KubernetesIngressMetadata{ + Namespace: "default", + IngressName: "ingress-with-limit-connections", + ServiceName: "whoami", + ServicePort: "80", + }, + }, + }, + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-limit-connections-rule-0-path-0-retry": {Retry: &dynamic.Retry{Attempts: 3}}, + "default-ingress-with-limit-connections-rule-0-path-0-tls-retry": {Retry: &dynamic.Retry{Attempts: 3}}, + "default-ingress-with-limit-connections-rule-0-path-0-limit-connections": { + InFlightReq: &dynamic.InFlightReq{ + Amount: 10, + SourceCriterion: &dynamic.SourceCriterion{ + IPStrategy: &dynamic.IPStrategy{}, + }, + }, + }, + "default-ingress-with-limit-connections-rule-0-path-0-tls-limit-connections": { + InFlightReq: &dynamic.InFlightReq{ + Amount: 10, + SourceCriterion: &dynamic.SourceCriterion{ + IPStrategy: &dynamic.IPStrategy{}, + }, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "unavailable-service": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{ + FlushInterval: dynamic.DefaultFlushInterval, + }, + }, + }, + "default-ingress-with-limit-connections-whoami-80": { + LoadBalancer: &dynamic.ServersLoadBalancer{ + Servers: []dynamic.Server{{URL: "http://10.10.0.1:80"}, {URL: "http://10.10.0.2:80"}}, + Strategy: "wrr", + PassHostHeader: ptr.To(true), + ResponseForwarding: &dynamic.ResponseForwarding{FlushInterval: dynamic.DefaultFlushInterval}, + ServersTransport: "default-ingress-with-limit-connections", + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-limit-connections": { + ForwardingTimeouts: &dynamic.ForwardingTimeouts{DialTimeout: ptypes.Duration(60 * time.Second), ReadTimeout: ptypes.Duration(60 * time.Second), WriteTimeout: ptypes.Duration(60 * time.Second), IdleConnTimeout: ptypes.Duration(60 * time.Second)}, + }, + }, + }, + TLS: &dynamic.TLSConfiguration{}, + }, + }, { desc: "Use Regex with Prefix pathType and StrictValidatePathType enabled", paths: []string{ diff --git a/pkg/provider/kubernetes/ingress-nginx/middleware.go b/pkg/provider/kubernetes/ingress-nginx/middleware.go index 1f49d5d6d0..cb38a5cb1f 100644 --- a/pkg/provider/kubernetes/ingress-nginx/middleware.go +++ b/pkg/provider/kubernetes/ingress-nginx/middleware.go @@ -34,6 +34,7 @@ func (p *Provider) buildMiddlewares(ctx context.Context, loc *location, hostname p.buildRewriteTarget(loc) p.buildUpstreamVhost(loc) p.buildRateLimit(loc) + p.buildLimitConnections(loc) p.buildAuthTLSPassCert(loc) p.buildCustomHeaders(loc) p.buildSnippetAuth(loc) @@ -310,6 +311,20 @@ func (p *Provider) buildRateLimit(loc *location) { } } +func (p *Provider) buildLimitConnections(loc *location) { + limit := ptr.Deref(loc.Config.LimitConnections, 0) + if limit <= 0 { + return + } + + loc.LimitConnections = &dynamic.InFlightReq{ + Amount: int64(limit), + SourceCriterion: &dynamic.SourceCriterion{ + IPStrategy: &dynamic.IPStrategy{}, + }, + } +} + func (p *Provider) buildAuthTLSPassCert(loc *location) { if !ptr.Deref(loc.Config.AuthTLSPassCertificateToUpstream, false) || loc.Config.AuthTLSSecret == nil { return diff --git a/pkg/provider/kubernetes/ingress-nginx/model.go b/pkg/provider/kubernetes/ingress-nginx/model.go index 6a5031216a..74dd701fc2 100644 --- a/pkg/provider/kubernetes/ingress-nginx/model.go +++ b/pkg/provider/kubernetes/ingress-nginx/model.go @@ -193,6 +193,9 @@ type location struct { // RateLimitRPS, if non-nil, applies a per-second request rate limit. RateLimitRPS *dynamic.RateLimit + // LimitConnections, if non-nil, caps concurrent in-flight requests per source IP. + LimitConnections *dynamic.InFlightReq + // AuthTLSPassCert, if non-nil, forwards the client TLS certificate to the backend. AuthTLSPassCert *dynamic.AuthTLSPassCertificateToUpstream diff --git a/pkg/provider/kubernetes/ingress-nginx/translator.go b/pkg/provider/kubernetes/ingress-nginx/translator.go index d68b01901c..cd3e7b6de8 100644 --- a/pkg/provider/kubernetes/ingress-nginx/translator.go +++ b/pkg/provider/kubernetes/ingress-nginx/translator.go @@ -501,6 +501,12 @@ func (p *Provider) applyMiddlewares(mc *model, loc *location, routerKey string, rt.Middlewares = append(rt.Middlewares, name) } + if loc.LimitConnections != nil { + name := routerKey + "-limit-connections" + conf.HTTP.Middlewares[name] = &dynamic.Middleware{InFlightReq: loc.LimitConnections} + rt.Middlewares = append(rt.Middlewares, name) + } + if loc.AuthTLSPassCert != nil && rt.TLS != nil { name := routerKey + "-pass-certificate-to-upstream" conf.HTTP.Middlewares[name] = &dynamic.Middleware{AuthTLSPassCertificateToUpstream: loc.AuthTLSPassCert}