From 469ee709d1212922e0a32cfd58a6b59ba5501345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20Por=C3=A9e?= <37620083+Ph4rell@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:42:04 +0100 Subject: [PATCH] Support limit-rpm annotation for ingress-nginx --- .../kubernetes/ingress-nginx.md | 4 +- .../kubernetes/ingress-nginx/annotations.go | 1 + .../ingress-nginx/annotations_test.go | 2 + .../ingresses/ingress-with-limit-rpm.yml | 42 ++++++++ .../kubernetes/ingress-nginx/kubernetes.go | 20 ++++ .../ingress-nginx/kubernetes_test.go | 98 +++++++++++++++++++ 6 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-limit-rpm.yml diff --git a/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md b/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md index b096e4c53..8b60b7a4a 100644 --- a/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md +++ b/docs/content/reference/routing-configuration/kubernetes/ingress-nginx.md @@ -381,6 +381,7 @@ The following annotations are organized by category for easier navigation. | Annotation | Limitations / Notes | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |-----------------------------------------------------------------------------------------------------------| | `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`. | ### Buffering @@ -453,8 +454,7 @@ In practice, Traefik is slightly more lenient under bursty load, as it smooths o | `nginx.ingress.kubernetes.io/disable-proxy-intercept-errors` | | | `nginx.ingress.kubernetes.io/limit-rate-after` | | | `nginx.ingress.kubernetes.io/limit-rate` | | -| `nginx.ingress.kubernetes.io/limit-whitelist` | | | -| `nginx.ingress.kubernetes.io/limit-rpm` | | +| `nginx.ingress.kubernetes.io/limit-whitelist` | | | `nginx.ingress.kubernetes.io/limit-burst-multiplier` | | | `nginx.ingress.kubernetes.io/limit-connections` | | | `nginx.ingress.kubernetes.io/global-rate-limit` | | diff --git a/pkg/provider/kubernetes/ingress-nginx/annotations.go b/pkg/provider/kubernetes/ingress-nginx/annotations.go index c9c61f51d..25a1d54ba 100644 --- a/pkg/provider/kubernetes/ingress-nginx/annotations.go +++ b/pkg/provider/kubernetes/ingress-nginx/annotations.go @@ -80,6 +80,7 @@ type ingressConfig struct { WhitelistSourceRange *string `annotation:"nginx.ingress.kubernetes.io/whitelist-source-range"` AllowlistSourceRange *string `annotation:"nginx.ingress.kubernetes.io/allowlist-source-range"` + LimitRPM *int `annotation:"nginx.ingress.kubernetes.io/limit-rpm"` LimitRPS *int `annotation:"nginx.ingress.kubernetes.io/limit-rps"` CustomHeaders *string `annotation:"nginx.ingress.kubernetes.io/custom-headers"` diff --git a/pkg/provider/kubernetes/ingress-nginx/annotations_test.go b/pkg/provider/kubernetes/ingress-nginx/annotations_test.go index 22b225c53..a6abdbee2 100644 --- a/pkg/provider/kubernetes/ingress-nginx/annotations_test.go +++ b/pkg/provider/kubernetes/ingress-nginx/annotations_test.go @@ -37,6 +37,7 @@ func Test_parseIngressConfig(t *testing.T) { "nginx.ingress.kubernetes.io/proxy-buffer-size": "16k", "nginx.ingress.kubernetes.io/proxy-buffers-number": "8", "nginx.ingress.kubernetes.io/proxy-max-temp-file-size": "100m", + "nginx.ingress.kubernetes.io/limit-rpm": "120", }, expected: ingressConfig{ SSLPassthrough: ptr.To(true), @@ -59,6 +60,7 @@ func Test_parseIngressConfig(t *testing.T) { ProxyBufferSize: ptr.To("16k"), ProxyBuffersNumber: ptr.To(8), ProxyMaxTempFileSize: ptr.To("100m"), + LimitRPM: ptr.To(120), }, }, { diff --git a/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-limit-rpm.yml b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-limit-rpm.yml new file mode 100644 index 000000000..9c96520bd --- /dev/null +++ b/pkg/provider/kubernetes/ingress-nginx/fixtures/ingresses/ingress-with-limit-rpm.yml @@ -0,0 +1,42 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-limit-rpm + namespace: default + annotations: + nginx.ingress.kubernetes.io/limit-rpm: "10" +spec: + ingressClassName: nginx + rules: + - host: whoami.localhost + http: + paths: + - path: / + pathType: Exact + backend: + service: + name: whoami + port: + number: 80 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-with-limit-rpm-zero + namespace: default + annotations: + nginx.ingress.kubernetes.io/limit-rpm: "0" +spec: + ingressClassName: nginx + rules: + - host: whoami-zero.localhost + http: + paths: + - path: / + pathType: Exact + backend: + service: + name: whoami + port: + number: 80 diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go index 7ee954727..0a00ab833 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes.go @@ -1203,6 +1203,8 @@ func (p *Provider) applyMiddlewares(namespace, ingressName, routerKey, rulePath, applyUpstreamVhost(routerKey, ingressConfig, rt, conf) + applyLimitRPMConfiguration(routerKey, ingressConfig, rt, conf) + applyLimitRPSConfiguration(routerKey, ingressConfig, rt, conf) if err := p.applyAuthTLSPassCertificateToUpstream(namespace, routerKey, ingressConfig, rt, conf); err != nil { @@ -1307,6 +1309,24 @@ func (p *Provider) applyCustomHTTPErrors(namespace, ingressName, routerName stri return nil } +func applyLimitRPMConfiguration(routerName string, ingressConfig ingressConfig, rt *dynamic.Router, conf *dynamic.Configuration) { + limitRPM := ptr.Deref(ingressConfig.LimitRPM, 0) + if limitRPM <= 0 { + return + } + + rateLimitMiddlewareName := routerName + "-limit-rpm" + conf.HTTP.Middlewares[rateLimitMiddlewareName] = &dynamic.Middleware{ + RateLimit: &dynamic.RateLimit{ + Average: int64(limitRPM), + Period: ptypes.Duration(time.Minute), + Burst: int64(limitRPM) * defaultLimitBurstMultiplier, + }, + } + + rt.Middlewares = append(rt.Middlewares, rateLimitMiddlewareName) +} + func applyLimitRPSConfiguration(routerName string, ingressConfig ingressConfig, rt *dynamic.Router, conf *dynamic.Configuration) { limitRPS := ptr.Deref(ingressConfig.LimitRPS, 0) if limitRPS <= 0 { diff --git a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go index a8beab8ea..91f58d2b5 100644 --- a/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go +++ b/pkg/provider/kubernetes/ingress-nginx/kubernetes_test.go @@ -6638,6 +6638,104 @@ func TestLoadIngresses(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, + { + desc: "Limit RPM", + paths: []string{ + "services.yml", + "ingressclasses.yml", + "ingresses/ingress-with-limit-rpm.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-rpm-rule-0-path-0": { + Rule: "Host(`whoami.localhost`) && Path(`/`)", + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-limit-rpm-rule-0-path-0-limit-rpm", "default-ingress-with-limit-rpm-rule-0-path-0-retry"}, + Service: "default-ingress-with-limit-rpm-whoami-80", + }, + "default-ingress-with-limit-rpm-zero-rule-0-path-0": { + Rule: "Host(`whoami-zero.localhost`) && Path(`/`)", + RuleSyntax: "default", + Middlewares: []string{"default-ingress-with-limit-rpm-zero-rule-0-path-0-retry"}, + Service: "default-ingress-with-limit-rpm-zero-whoami-80", + }, + }, + Middlewares: map[string]*dynamic.Middleware{ + "default-ingress-with-limit-rpm-rule-0-path-0-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + "default-ingress-with-limit-rpm-rule-0-path-0-limit-rpm": { + RateLimit: &dynamic.RateLimit{ + Average: 10, + Burst: 50, + Period: ptypes.Duration(time.Minute), + }, + }, + "default-ingress-with-limit-rpm-zero-rule-0-path-0-retry": { + Retry: &dynamic.Retry{ + Attempts: 3, + }, + }, + }, + Services: map[string]*dynamic.Service{ + "default-ingress-with-limit-rpm-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-rpm", + }, + }, + "default-ingress-with-limit-rpm-zero-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-rpm-zero", + }, + }, + }, + ServersTransports: map[string]*dynamic.ServersTransport{ + "default-ingress-with-limit-rpm": { + 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), + }, + }, + "default-ingress-with-limit-rpm-zero": { + 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{}, + }, + }, } for _, test := range testCases {