限流是一种常用的流量控制手段,可以有效的保护后端服务免受恶意请求的影响。nginx-ingress-controller 作为一款被广泛使用的 Ingress Controller 自然也支持限流功能,在 Ingress 加上 nginx.ingress.kubernetes.io/limit-rps 注解即可限制每秒请求的速率。limit-rps 注解使用的是 limit_req 模块来实现限流,限流状态保存在内存中,这意味着Nginx 实例与实例之间无法共享状态,如果我们部署了多个 nginx-ingress-controller Pod,那么每个 Pod 都会维护自己的限流状态,这样就无法实现全局限流。虽然我们可以通过 目标速率 / 副本数 计算出平均限流请求,但前提是确保每个 Pod 会分到相同的请求量并且 Pod 的数量不会发生变化,否则限流效果就会受到影响。

下面介绍一种基于 ngx_http_auth_request_module 模块实现全局限流的方法,它是 Nginx 的官方模块并且 nginx-ingress-controller 默认包含,因此我们无需对 nginx-ingress-controller 进行任何修改。

ngx_http_auth_request_module 模块的核心指令如下:

1
2
3
4
Syntax: 	auth_request uri | off;
Default: auth_request off;
Context: http, server, location
Enables authorization based on the result of a subrequest and sets the URI to which the subrequest will be sent.

auth_request 指令允许我们在请求被处理前向指定的 URI 发送一个 HTTP 子请求用于鉴权,返回的状态码会影响 Nginx 的行为:

  • 2xx 鉴权通过,请求继续处理
  • 401 403 鉴权未通过,终止请求并返回 Unauthorized 响应
  • 其它状态码,终止请求并返回 500 Internal Server Error 响应

虽然 auth_request 指令用于鉴权,但我们可以搭建一个 限流服务 并将它「伪装」成一个鉴权服务,没有超过请求速率时返回 200 状态码,反之则返回 401 状态码。不难看出这个方案中限流服务是非常关键的环节,它的可靠性和性能直接影响服务的吞吐量。我们可以运行一个单独的 Nginx 实例作为限流服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
limit_req_zone $arg_ratelimit_key zone=ingress_ratelimit:10m rate=10r/s;

server {
listen 80;

location / {
limit_req zone=ingress_ratelimit burst=10 nodelay;
limit_req_status 401;
try_files /noop /noop;
}

location =/noop {
return 200;
}
}

配置文件中定义了一个名为 ingress_ratelimit 的限流区域,根据 ratelimit_key URL 参数的值来限流,限流速率为 10 QPS,超过限流速率返回 401 状态码,否则返回 200 状态码。

限流服务部署好后就可以在 Ingress 中通过注解使用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app
annotations:
nginx.ingress.kubernetes.io/auth-url: 'http://ratelimit.nginx-ingress.svc.cluster.local/?ratelimit_key=$http_realip'
spec:
ingressClassName: nginx
rules:
- host: app
http:
paths:
- backend:
service:
name: app
port:
number: 80
pathType: Prefix
path: /

nginx.ingress.kubernetes.io/auth-url 注解用于配置 auth_request 指令,需要注意的是使用集群内部域名访问限流服务必须得是完全限定域名 (FQDN),否则 nginx-ingress-controller 无法解析域名。在这里我们使用 RealIP 头来作为限流的标识,所有合法的 Nginx 变量 都可以在 auth-url 注解中使用。

创建好 Ingress 后使用压测工具测试在部署 2 个 nginx-ingress-controller Pod 的情况下的限流效果:

20241011181900
20241011181900

压测请求数是 200/QPS,RealIP 头随机切换 3 个 IP 地址,从右侧的图表可以看到三个 IP 的请求通过数都保持在 10 QPS,初步实现了全局限流。

之所以说是「初步实现」是因为这个方案还存在一些问题,其中最致命的问题是限流服务是单点部署,如果它出现问题那么整个服务都将会瘫痪,想要解决这个问题我们需要从 auth-url 注解生成的 Nginx 配置入手:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
server {
server_name app;
...

location / {
# this location requires authentication
auth_request /_external-auth-Lw-Prefix;
...
proxy_pass http://upstream_balancer;
}

location = /_external-auth-Lw-Prefix {
...
proxy_pass_request_body off;
proxy_set_header Host ratelimit.nginx-ingress.svc.cluster.local;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Sent-From "nginx-ingress-controller";
proxy_set_header X-Real-IP $remote_addr;
set $target http://ratelimit.nginx-ingress.svc.cluster.local/10qps?ratelimit_key=$http_realip;
proxy_pass $target;
}
}

从配置可以看出 auth-url 注解并没有将 URL 直接传给 auth_request 指令,而是通过一个内部路由 /_external-auth-Lw-Prefix 进行反向代理,既然是反向代理那就可以用 proxy_intercept_errors + error_page 来处理异常情况:

1
2
3
4
5
6
7
8
9
10
11
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app
annotations:
nginx.ingress.kubernetes.io/auth-snippet: |
proxy_intercept_errors on;
error_page 503 502 500 404 =200 /;
nginx.ingress.kubernetes.io/auth-url: 'http://ratelimit.nginx-ingress.svc.cluster.local/?ratelimit_key=$http_realip'
spec:
...

auth-snippet 注解的配置会被注入到 /_external-auth-Lw-Prefix 路由内,这里对可能会出现的异常状态进行了处理统一返回 200 状态码,这样即使限流服务出现问题 nginx-ingress-controller 也会继续处理请求。

再来看一个非致命问题,被限流的请求会返回 401 Unauthorized 响应,很明显这不符合逻辑,我们可以 401 响应改为 429 Too Many Requests 状态并返回 JSON 数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app
annotations:
nginx.ingress.kubernetes.io/auth-url: 'http://ratelimit.nginx-ingress.svc.cluster.local/?ratelimit_key=$http_realip'
nginx.ingress.kubernetes.io/auth-snippet: |
proxy_intercept_errors on;
error_page 503 502 500 404 =200 /;
nginx.ingress.kubernetes.io/configuration-snippet: |
error_page 401 =429 /rejected_response.json;
nginx.ingress.kubernetes.io/server-snippet: |
location =/rejected_response.json {
internal;
return 200 '{"status":"ratelimit"}';
}
spec:
...

configuration-snippet 注解中使用 error_page 指令将 401 状态码修改为 429 并重定向到 /rejected_response.json,这样客户端就会收到 429 状态码以及 /rejected_response.json 的响应内容。在没有开启 proxy_intercept_errors on 配置的情况下 error_page 指令不会对反向代理的响应生效,因此不用担心后端服务返回的 401 状态会被重写。

需要注意的是 configuration-snippet 注解是 Critical 风险级别,但 nginx-ingress-controller 的默认风险级别为 High,因此需要在 nginx-ingress-controller 的 ConfigMap 中添加 annotations-risk-level: Critical

看到这里有些同学可能会觉得每个 Ingress 都要加这么多的注解很麻烦,实际上我们可以将这些配置放到 ConfigMap 中,这样可以大大减少重复配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-ingress-controller
data:
allow-snippet-annotations: 'true'
annotations-risk-level: Critical
location-snippet: |
error_page 401 =429 /rejected_response.json;
server-snippet: |
location =/rejected_response.json {
internal;
return 200 '{"status":"ratelimit"}';
}
global-auth-snippet: |
proxy_intercept_errors on;
error_page 503 502 500 404 =200 /;

配置放到 ConfigMap 后 Ingress 只需要配置 auth-url 注解即可,没有配置 auth-url 注解的情况下以上配置不会对请求产生任何影响。

有时候不同 API 接口需要应用不同的限流策略,但使用 Nginx 搭建的限流服务无法动态调整限流速率,我们可以创建多个限流区域来实现不同的限流策略,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
limit_req_zone $arg_ratelimit_key zone=10qps:1m rate=10r/s;
limit_req_zone $arg_ratelimit_key zone=20qps:1m rate=20r/s;
limit_req_zone $arg_ratelimit_key zone=30qps:1m rate=30r/s;
...

server {
listen 80;

location =/10qps {
limit_req zone=1qps nodelay burst=10;
limit_req_status 401;
try_files /noop /noop;
}

location =/20qps {
limit_req zone=1qps nodelay burst=10;
limit_req_status 401;
try_files /noop /noop;
}

...
}

之后我们就可以在 auth-url 中使用不同的 URI 来选择不同的限流策略。重复性的配置还可以通过模板化来减少工作量,例如使用 Helm:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
apiVersion: v1
kind: ConfigMap
metadata:
name: ratelimit-config
data:
nginx.conf: |
{{- $rate := list 10 20 50 100 }}

{{- range $rate }}
limit_req_zone $arg_ratelimit_key zone={{ . }}qps:1m rate={{ . }}r/s;
{{- end }}

server {
listen 80;

location =/noop {
return 200;
}

{{- range $rate }}
location =/{{ . }}qps {
limit_req zone={{ . }}qps nodelay burst=10;
limit_req_status 401;
try_files /noop /noop;
}
{{- end }}
}

最后来看我们最关心的性能问题,作为集群流量入口 ingress-controller 的性能会直接影响整个服务的吞吐量。为了方便对照创建两个 Ingress,其中一个设置 auth-url 一个不设置。限流服务增加了 limit_req_dry_run on; 指令,只对请求进行限流评估而不实施限流,这样所有请求都能通过 ingress-controller 并到达后端服务。压测启动后来看响应时间 P95 指标 (单位: ms):

20241012174212
20241012174212

可以看到经过限流服务(上)和没有经过限流服务(下)的请求响应时间非常接近,几乎可以忽略不计。