Skip to content

Commit cb42312

Browse files
authored
Add annotations for controlling request rate limiting (#4660)
1 parent 0b0c658 commit cb42312

File tree

11 files changed

+787
-0
lines changed

11 files changed

+787
-0
lines changed

docs/content/configuration/ingress-resources/advanced-configuration-with-annotations.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,22 @@ The table below summarizes the available annotations.
175175
|``nginx.org/use-cluster-ip`` | N/A | Enables using the Cluster IP and port of the service instead of the default behavior of using the IP and port of the pods. When this field is enabled, the fields that configure NGINX behavior related to multiple upstream servers (like ``lb-method`` and ``next-upstream``) will have no effect, as NGINX Ingress Controller will configure NGINX with only one upstream server that will match the service Cluster IP. | ``False`` | |
176176
{{% /table %}}
177177

178+
### Rate limiting
179+
180+
{{% table %}}
181+
|Annotation | ConfigMap Key | Description | Default | Example |
182+
| ---| ---| ---| ---| --- |
183+
|``nginx.org/limit-req-rate`` | N/A | Enables request-rate-limiting for this ingress by creating a [limit_req_zone](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html#limit_req_zone) and matching [limit_req](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html#limit_req) for each location. All servers/locations of one ingress share the same zone. Must have unit r/s or r/m. | N/A | 200r/s |
184+
|``nginx.org/limit-req-key`` | N/A | The key to which the rate limit is applied. Can contain text, variables, or a combination of them. Variables must be surrounded by ${}. | ${binary_remote_addr} | ${binary_remote_addr} |
185+
|``nginx.org/limit-req-zone-size`` | N/A | Configures the size of the created [limit_req_zone](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html#limit_req_zone). | 10m | 20m |
186+
|``nginx.org/limit-req-delay`` | N/A | Configures the delay-parameter of the [limit_req](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html#limit_req) directive. | 0 | 100 |
187+
|``nginx.org/limit-req-no-delay`` | N/A | Configures the nodelay-parameter of the [limit_req](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html#limit_req) directive. | false | true |
188+
|``nginx.org/limit-req-burst`` | N/A | Configures the burst-parameter of the [limit_req](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html#limit_req) directive. | N/A | 100 |
189+
|``nginx.org/limit-req-dry-run`` | N/A | Enables the dry run mode. In this mode, the rate limit is not actually applied, but the number of excessive requests is accounted as usual in the shared memory zone. | false | true |
190+
|``nginx.org/limit-req-log-level`` | N/A | Sets the desired logging level for cases when the server refuses to process requests due to rate exceeding, or delays request processing. Allowed values are info, notice, warn or error. | error | info |
191+
|``nginx.org/limit-req-reject-code`` | N/A | Sets the status code to return in response to rejected requests. Must fall into the range 400..599. | 429 | 503 |
192+
{{% /table %}}
193+
178194
### Snippets and Custom Templates
179195

180196
{{% table %}}

internal/configs/annotations.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package configs
22

33
import (
4+
"fmt"
5+
"slices"
6+
47
"github.com/golang/glog"
58
)
69

@@ -78,6 +81,15 @@ var minionInheritanceList = map[string]bool{
7881
"nginx.org/max-fails": true,
7982
"nginx.org/max-conns": true,
8083
"nginx.org/fail-timeout": true,
84+
"nginx.org/limit-req-rate": true,
85+
"nginx.org/limit-req-key": true,
86+
"nginx.org/limit-req-zone-size": true,
87+
"nginx.org/limit-req-delay": true,
88+
"nginx.org/limit-req-no-delay": true,
89+
"nginx.org/limit-req-burst": true,
90+
"nginx.org/limit-req-dry-run": true,
91+
"nginx.org/limit-req-log-level": true,
92+
"nginx.org/limit-req-reject-code": true,
8193
}
8294

8395
var validPathRegex = map[string]bool{
@@ -413,9 +425,81 @@ func parseAnnotations(ingEx *IngressEx, baseCfgParams *ConfigParams, isPlus bool
413425
cfgParams.UseClusterIP = useClusterIP
414426
}
415427
}
428+
429+
for _, err := range parseRateLimitAnnotations(ingEx.Ingress.Annotations, &cfgParams, ingEx.Ingress) {
430+
glog.Error(err)
431+
}
432+
416433
return cfgParams
417434
}
418435

436+
// parseRateLimitAnnotations parses rate-limiting-related annotations and places them into cfgParams. Occurring errors are collected and returned, but do not abort parsing.
437+
//
438+
//gocyclo:ignore
439+
func parseRateLimitAnnotations(annotations map[string]string, cfgParams *ConfigParams, context apiObject) []error {
440+
errors := make([]error, 0)
441+
if requestRateLimit, exists := annotations["nginx.org/limit-req-rate"]; exists {
442+
if rate, err := ParseRequestRate(requestRateLimit); err != nil {
443+
errors = append(errors, fmt.Errorf("Ingress %s/%s: Invalid value for nginx.org/limit-req-rate: got %s: %w", context.GetNamespace(), context.GetName(), requestRateLimit, err))
444+
} else {
445+
cfgParams.LimitReqRate = rate
446+
}
447+
}
448+
if requestRateKey, exists := annotations["nginx.org/limit-req-key"]; exists {
449+
cfgParams.LimitReqKey = requestRateKey
450+
}
451+
if requestRateZoneSize, exists := annotations["nginx.org/limit-req-zone-size"]; exists {
452+
if size, err := ParseSize(requestRateZoneSize); err != nil {
453+
errors = append(errors, fmt.Errorf("Ingress %s/%s: Invalid value for nginx.org/limit-req-zone-size: got %s: %w", context.GetNamespace(), context.GetName(), requestRateZoneSize, err))
454+
} else {
455+
cfgParams.LimitReqZoneSize = size
456+
}
457+
}
458+
if requestRateDelay, exists, err := GetMapKeyAsInt(annotations, "nginx.org/limit-req-delay", context); exists {
459+
if err != nil {
460+
errors = append(errors, err)
461+
} else {
462+
cfgParams.LimitReqDelay = requestRateDelay
463+
}
464+
}
465+
if requestRateNoDelay, exists, err := GetMapKeyAsBool(annotations, "nginx.org/limit-req-no-delay", context); exists {
466+
if err != nil {
467+
errors = append(errors, err)
468+
} else {
469+
cfgParams.LimitReqNoDelay = requestRateNoDelay
470+
}
471+
}
472+
if requestRateBurst, exists, err := GetMapKeyAsInt(annotations, "nginx.org/limit-req-burst", context); exists {
473+
if err != nil {
474+
errors = append(errors, err)
475+
} else {
476+
cfgParams.LimitReqBurst = requestRateBurst
477+
}
478+
}
479+
if requestRateDryRun, exists, err := GetMapKeyAsBool(annotations, "nginx.org/limit-req-dry-run", context); exists {
480+
if err != nil {
481+
errors = append(errors, err)
482+
} else {
483+
cfgParams.LimitReqDryRun = requestRateDryRun
484+
}
485+
}
486+
if requestRateLogLevel, exists := annotations["nginx.org/limit-req-log-level"]; exists {
487+
if !slices.Contains([]string{"info", "notice", "warn", "error"}, requestRateLogLevel) {
488+
errors = append(errors, fmt.Errorf("Ingress %s/%s: Invalid value for nginx.org/limit-req-log-level: got %s", context.GetNamespace(), context.GetName(), requestRateLogLevel))
489+
} else {
490+
cfgParams.LimitReqLogLevel = requestRateLogLevel
491+
}
492+
}
493+
if requestRateRejectCode, exists, err := GetMapKeyAsInt(annotations, "nginx.org/limit-req-reject-code", context); exists {
494+
if err != nil {
495+
errors = append(errors, err)
496+
} else {
497+
cfgParams.LimitReqRejectCode = requestRateRejectCode
498+
}
499+
}
500+
return errors
501+
}
502+
419503
func getWebsocketServices(ingEx *IngressEx) map[string]bool {
420504
if value, exists := ingEx.Ingress.Annotations["nginx.org/websocket-services"]; exists {
421505
return ParseServiceList(value)

internal/configs/annotations_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import (
44
"reflect"
55
"sort"
66
"testing"
7+
8+
networking "k8s.io/api/networking/v1"
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
710
)
811

912
func TestParseRewrites(t *testing.T) {
@@ -159,3 +162,56 @@ func TestMergeMasterAnnotationsIntoMinion(t *testing.T) {
159162
t.Errorf("mergeMasterAnnotationsIntoMinion returned %v, but expected %v", minionAnnotations, expectedMergedAnnotations)
160163
}
161164
}
165+
166+
func TestParseRateLimitAnnotations(t *testing.T) {
167+
context := &networking.Ingress{
168+
ObjectMeta: metav1.ObjectMeta{
169+
Namespace: "default",
170+
Name: "context",
171+
},
172+
}
173+
174+
if errors := parseRateLimitAnnotations(map[string]string{
175+
"nginx.org/limit-req-rate": "200r/s",
176+
"nginx.org/limit-req-key": "${request_uri}",
177+
"nginx.org/limit-req-burst": "100",
178+
"nginx.org/limit-req-delay": "80",
179+
"nginx.org/limit-req-no-delay": "true",
180+
"nginx.org/limit-req-reject-code": "429",
181+
"nginx.org/limit-req-zone-size": "11m",
182+
"nginx.org/limit-req-dry-run": "true",
183+
"nginx.org/limit-req-log-level": "info",
184+
}, NewDefaultConfigParams(false), context); len(errors) > 0 {
185+
t.Error("Errors when parsing valid limit-req annotations")
186+
}
187+
188+
if errors := parseRateLimitAnnotations(map[string]string{
189+
"nginx.org/limit-req-rate": "200",
190+
}, NewDefaultConfigParams(false), context); len(errors) == 0 {
191+
t.Error("No Errors when parsing invalid request rate")
192+
}
193+
194+
if errors := parseRateLimitAnnotations(map[string]string{
195+
"nginx.org/limit-req-rate": "200r/h",
196+
}, NewDefaultConfigParams(false), context); len(errors) == 0 {
197+
t.Error("No Errors when parsing invalid request rate")
198+
}
199+
200+
if errors := parseRateLimitAnnotations(map[string]string{
201+
"nginx.org/limit-req-rate": "0r/s",
202+
}, NewDefaultConfigParams(false), context); len(errors) == 0 {
203+
t.Error("No Errors when parsing invalid request rate")
204+
}
205+
206+
if errors := parseRateLimitAnnotations(map[string]string{
207+
"nginx.org/limit-req-zone-size": "10abc",
208+
}, NewDefaultConfigParams(false), context); len(errors) == 0 {
209+
t.Error("No Errors when parsing invalid zone size")
210+
}
211+
212+
if errors := parseRateLimitAnnotations(map[string]string{
213+
"nginx.org/limit-req-log-level": "foobar",
214+
}, NewDefaultConfigParams(false), context); len(errors) == 0 {
215+
t.Error("No Errors when parsing invalid log level")
216+
}
217+
}

internal/configs/config_params.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,16 @@ type ConfigParams struct {
113113
SSLPorts []int
114114

115115
SpiffeServerCerts bool
116+
117+
LimitReqRate string
118+
LimitReqKey string
119+
LimitReqZoneSize string
120+
LimitReqDelay int
121+
LimitReqNoDelay bool
122+
LimitReqBurst int
123+
LimitReqDryRun bool
124+
LimitReqLogLevel string
125+
LimitReqRejectCode int
116126
}
117127

118128
// StaticConfigParams holds immutable NGINX configuration parameters that affect the main NGINX config.
@@ -191,6 +201,10 @@ func NewDefaultConfigParams(isPlus bool) *ConfigParams {
191201
MainKeepaliveRequests: 100,
192202
VariablesHashBucketSize: 256,
193203
VariablesHashMaxSize: 1024,
204+
LimitReqKey: "${binary_remote_addr}",
205+
LimitReqZoneSize: "10m",
206+
LimitReqLogLevel: "error",
207+
LimitReqRejectCode: 429,
194208
}
195209
}
196210

internal/configs/ingress.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ func generateNginxCfg(p NginxCfgParams) (version1.IngressNginxConfig, Warnings)
128128
allWarnings := newWarnings()
129129

130130
var servers []version1.Server
131+
var limitReqZones []version1.LimitReqZone
131132

132133
for _, rule := range p.ingEx.Ingress.Spec.Rules {
133134
// skipping invalid hosts
@@ -265,6 +266,27 @@ func generateNginxCfg(p NginxCfgParams) (version1.IngressNginxConfig, Warnings)
265266
allWarnings.Add(warnings)
266267
}
267268

269+
if cfgParams.LimitReqRate != "" {
270+
zoneName := p.ingEx.Ingress.Namespace + "/" + p.ingEx.Ingress.Name
271+
loc.LimitReq = &version1.LimitReq{
272+
Zone: zoneName,
273+
Burst: cfgParams.LimitReqBurst,
274+
Delay: cfgParams.LimitReqDelay,
275+
NoDelay: cfgParams.LimitReqNoDelay,
276+
DryRun: cfgParams.LimitReqDryRun,
277+
LogLevel: cfgParams.LimitReqLogLevel,
278+
RejectCode: cfgParams.LimitReqRejectCode,
279+
}
280+
if !limitReqZoneExists(limitReqZones, zoneName) {
281+
limitReqZones = append(limitReqZones, version1.LimitReqZone{
282+
Name: zoneName,
283+
Key: cfgParams.LimitReqKey,
284+
Size: cfgParams.LimitReqZoneSize,
285+
Rate: cfgParams.LimitReqRate,
286+
})
287+
}
288+
}
289+
268290
locations = append(locations, loc)
269291

270292
if loc.Path == "/" {
@@ -317,6 +339,7 @@ func generateNginxCfg(p NginxCfgParams) (version1.IngressNginxConfig, Warnings)
317339
SpiffeClientCerts: p.staticParams.NginxServiceMesh && !cfgParams.SpiffeServerCerts,
318340
DynamicSSLReloadEnabled: p.staticParams.DynamicSSLReload,
319341
StaticSSLPath: p.staticParams.StaticSSLPath,
342+
LimitReqZones: limitReqZones,
320343
}, allWarnings
321344
}
322345

@@ -609,6 +632,7 @@ func generateNginxCfgForMergeableIngresses(p NginxCfgParams) (version1.IngressNg
609632
var locations []version1.Location
610633
var upstreams []version1.Upstream
611634
healthChecks := make(map[string]version1.HealthCheck)
635+
var limitReqZones []version1.LimitReqZone
612636
var keepalive string
613637

614638
// replace master with a deepcopy because we will modify it
@@ -704,6 +728,7 @@ func generateNginxCfgForMergeableIngresses(p NginxCfgParams) (version1.IngressNg
704728
}
705729

706730
upstreams = append(upstreams, nginxCfg.Upstreams...)
731+
limitReqZones = append(limitReqZones, nginxCfg.LimitReqZones...)
707732
}
708733

709734
masterServer.HealthChecks = healthChecks
@@ -717,9 +742,19 @@ func generateNginxCfgForMergeableIngresses(p NginxCfgParams) (version1.IngressNg
717742
SpiffeClientCerts: p.staticParams.NginxServiceMesh && !p.baseCfgParams.SpiffeServerCerts,
718743
DynamicSSLReloadEnabled: p.staticParams.DynamicSSLReload,
719744
StaticSSLPath: p.staticParams.StaticSSLPath,
745+
LimitReqZones: limitReqZones,
720746
}, warnings
721747
}
722748

749+
func limitReqZoneExists(zones []version1.LimitReqZone, zoneName string) bool {
750+
for _, zone := range zones {
751+
if zone.Name == zoneName {
752+
return true
753+
}
754+
}
755+
return false
756+
}
757+
723758
func isSSLEnabled(isSSLService bool, cfgParams ConfigParams, staticCfgParams *StaticConfigParams) bool {
724759
return isSSLService || staticCfgParams.NginxServiceMesh && !cfgParams.SpiffeServerCerts
725760
}

0 commit comments

Comments
 (0)