Envoy 自定义授权和限流示例
译者注: 原 repo 因为 envoy 版本问题会报错,故对原文稍做修改,支持最新的 v3 API。
最近, 我所工作的一个团队选择 Envory 作为一个系统构建的核心组件。关于它的介绍以及包含或围绕它构建的开源工具数量之多给我留下了深刻的印象, 但实际上并没有进行任何深入的探讨。
我尤其对它如何作为边缘代理感到好奇, 本质上 Envoy 是作为一个更现代和可编程的组件, 在过去我一直使用 nginx 来实现。最终的成果是实现这个设计的一个小型的容器化的 playground。
整个请求的生命周期有以下几个阶段:
一个客户端发送资源请求到 Envory(作为网关)
利用Envory
的External Authorizer
接口:
验证调用, 如果无效则拒绝
设置用来限流的自定义的请求头
根据路由, 提供不同的信息以用于限流
使用Ratelimiter
接口, 应用限流, 如果超出限制则拒绝
最终, 请求通过后端, 返回一个response
给客户端
凭借有限的经验, 可以说的是, Envory
没有辜负我的期望。同时我发现虽然官方文档很完整, 但有些方面又太简洁, 这是我想要编写这篇文章的原因之一, 即很难找到这种模式的完整示例, 因此如果你正在阅读这篇文章, 想必可以节省你的一些精力。
在文章的其余部分, 我会一步步的介绍各个部分的工作方式。
Docker 环境
在这里我使用了docker-compose
, 因为它提供了围绕构建和运行一堆容器的简单编排, 并且有统一良好的的日志输出。这里我们讲创建五个容器:
envory
, 毫无悬念, 单纯的...envory
redis
, 用来存储限流服务的数据extauth
, 这是一个自定义的 Go 应用, 可实现Envoy的gRPC规范
以进行外部授权ratelimit
, envoy 官方的开源限流服务, 该服务实现了Envoy gRPC限流规范
backend
, 一个自定义的 Go 应用, 它本质上是一个“ hello world”
, 并且还会打印它收到的请求头, 以便于进行故障排查
docker-compose
还创建了一个network(envorymesh)
用以上面的所有服务的网络共享, 并且对外暴露了几个端口。其中最重要的是8010
端口 (或者localhost:8010
, 对大多数docker machines
来说), 它是公共的 HTTP 端点。
为了让它跑起来,
# 原作者仓库:git clone https://github.com/jbarratt/envoy_ratelimit_example
git clone https://github.com/cluas/envoy_ratelimit_example # 添加go mod支持 修复api v3 错误
你还需要ratelimi
t一个本地的副本。子模块在这里会很好, 但是作为简单验证, 直接
git clone https://github.com/envoyproxy/ratelimit.git
完成以上步骤后, 运行docker-compose up
。第一次启动会比较耗时, 因为它要从头构建所有内容。
你可以使用简单的 curl, 来确保整个环境工作正常, 同时展示所有调用的轨迹
作为集成真实身份认证提供方的替代方案, 所有的bearer tokens
只要是 3 个字符长度的这里都认为是有效的
授权服务设置请求头 (X-Ext-Auth-Ratelimit
), 该请求头可用于唯一的每个token
限流
在每个envoy
配置中, Authorization
请求头被剥离, 因此敏感的身份信息不会被推送到后端
$ curl -v -H "Authorization: Bearer foo" http://localhost:8010/
> GET / HTTP/1.1
> Authorization: Bearer foo
>
< HTTP/1.1 200 OK
< date: Tue, 21 May 2019 00:23:12 GMT
< content-length: 270
< content-type: text/plain; charset=utf-8
< x-envoy-upstream-service-time: 0
< server: envoy
Oh, Hello!
# The backend got these headers with the request
X-Request-Id: 6c03f5f4-e580-4d8f-aee1-7e62ba2c9b30
X-Ext-Auth-Ratelimit: LCa0a2j/xo/5m0U8HTBBNBNCLXBkg7+g+YpeiGJm564=
X-Envoy-Expected-Rq-Timeout-Ms: 15000
X-Forwarded-Proto: http
定义后端服务
backend是一个运行在容器中的非常简单的 Go 应用。它在envoy.yaml配置文件中多次显示 (命名为backend)。首先, 将其定义为 “cluster”(尽管作为单个容器, 它并不是集群的一部分)。
clusters:
- name: backend
connect_timeout: 0.25s
type: STRICT_DNS
lb_policy: round_robin
load_assignment:
cluster_name: backend
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: backend
port_value: 8123
这是一个envoy
的配置示例, 需要一点时间去理解。对于简单的 “单个节点” 后端, 它有一些相当重要的样板配置。它的功能也非常强大。我们可以定义如何查找节点, 应该如何负载均衡, 多个群集, 多个群集中的多个负载平衡器以及其中的多个节点。完全虽然可以简化此定义, 但是这个版本也可以运行的很好。很好且一致的是, 在定义任一集群时, 集群定义是相同的
“服务” 通过 envoy
代理“辅助服务” 通过 envoy
的filter
联系, 比如授权和限流服务
同样有帮助的是custers
(或者整个配置) 通过容易管理的数据结构定义, 实际上它被定义为protobufs
。这意味着当你使用YAML
文件配置Envoy
时, 或者在运行时通过配置界面, 可以相当一致地完成Envoy
的管理。既然已经定义好了backend
, 那么是时候让它获得一些流量了, 这是通过routes
来完成的。
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" }
route:
cluster: backend
再说一次, 配置具有相当不错的数据结构来表示 “将所有流量发送到我定义的称为backend
的cluster
”, 正如我们将看到的, 当需要添加条件限流时, 它提供了类似的有用位置来hook
其他配置。
自定义外部授权者
Envoy
具有用于外部授权的内置过滤器模块。
http_filters:
- name: envoy.filters.http.ext_authz
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
grpc_service:
envoy_grpc:
cluster_name: ext-authz
timeout: 0.25s
transport_api_version: V3
这个配置片段表示去调用一个gRPC
服务, 该服务运行在名为extauth
的集群上 (与上面的 backend 定义相同)。我对 2 个近期的发展感到非常高兴, 这让 Go 应用非常容易构建——Go modules 和 Docker 多阶段构建。使用 Alpine 和 Go 应用程序的二进制文件构建一个瘦容器, 只需要 Dockerfile 的这一小片段。是的, 非常好用。
FROM golang:latest as builder
COPY . /ext-auth-poc
WORKDIR /ext-auth-poc
ENV GO111MODULE=on
RUN CGO_ENABLED=0 GOOOS=linux go build -o ext-auth-poc
FROM alpine:latest
WORKDIR /root/
COPY --from=builder /ext-auth-poc .
CMD ["./ext-auth-poc"]
好的, 我们如何构建该应用程序?对于 Go 服务, Envoy
使事情变得非常简单明了。简单地自定义授权服务代码
译者注:原仓库不支持 v3,会报错,建议使用译者 fork 的仓库测试
其中的定义可从 Envoy 存储库中获取, 例如
auth "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3"
定义诸如CheckRequest
和CheckResponse
之类的东西。这允许根据我们需要做的事情构造并返回正确的响应。举个例子, 下面是成功的路径:
// inject a header that can be used for future rate limiting
func (a *AuthorizationServer) Check(ctx context.Context, req *auth.CheckRequest) (*auth.CheckResponse, error) {
...
// valid tokens have exactly 3 characters. #secure.
// Normally this is where you'd go check with the system that knows if it's a valid token.
if len(token) == 3 {
return &auth.CheckResponse{
Status: &status.Status{
Code: int32(rpc.OK),
},
HttpResponse: &auth.CheckResponse_OkResponse{
OkResponse: &auth.OkHttpResponse{
Headers: []*core.HeaderValueOption{
{
Header: &core.HeaderValue{
Key: "x-ext-auth-ratelimit",
Value: tokenSha,
},
},
},
},
},
}, nil
}
}
在请求周期的时间点上编写任意代码的能力非常强大, 因为在此处添加请求头可用于各种决策, 包括路由和限流。
限流
限流可以通过任何实现限流器接口的服务来完成。值得庆幸的是, envoy 官方提供了一个非常不错的工具, 它具有简单但功能强大的配置–对于许多用例来说, 使用起来可能绰绰有余。(envoyproxy/ratelimit) 就像使用外部授权器一样, 有一些Envoy
配置可以启用外部限流服务。先定义集群, 然后启用envoy.filters.http.ratelimit filter
。
- name: envoy.filters.http.ratelimit
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimit
domain: backend
request_type: external
stage: 0
rate_limited_as_resource_exhausted: true
failure_mode_deny: false
rate_limit_service:
grpc_service:
envoy_grpc:
cluster_name: ratelimit
timeout: 0.25s
transport_api_version: V3
为了让Envoy
能够做到限流, 你必须告诉它要限制什么。超级有用的一点是可以根据不同的路由设置不同的限流。Envoy
还可以通过将键/值对发送到ratelimiter
服务来使限流策略配置化。以下定义了两个路由:
/slowpath
, 通过generic_key:slowpath
发送/(其余路由)
, 通过ratelimitkey:$x-ext-auth-ratelimit
和path:$path
—其中带有$
的值是这些请求头具有的任何值
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/slowpath" }
route:
cluster: backend
rate_limits:
- stage: 0
actions:
- {generic_key: {"descriptor_value": "slowpath"}}
- match: { prefix: "/" }
route:
cluster: backend
rate_limits:
- stage: 0
actions:
- {request_headers: {header_name: "x-ext-auth-ratelimit", descriptor_key: "ratelimitkey"}}
- {request_headers: {header_name: ":path", descriptor_key: "path"}}
当你使用 envoyproxy 的ratelimiter
时, 实际配置非常优雅。
---
domain: backend
descriptors:
- key: generic_key
value: slowpath
rate_limit:
requests_per_unit: 1
unit: second
- key: ratelimitkey
descriptors:
- key: path
rate_limit:
requests_per_unit: 2
unit: second
一切都在backdend
域内发生 (任意字符串, 在启用速率限制过滤器时定义)。寻找 2 个描述符, generic_key
和ratelimitkey
。当有 value 提供时, 最终将是一个静态路径 (如slowpath
),该条目适用于所有请求。如果没有 value 提供, 它将使用密钥和提供的值来构建复合密钥。这里也可以定义嵌套结构,二级层次结构为ratelimitkey/path
。
因此, 此配置应做两件事:
全局限制slowpath为每秒 1 个请求 使所有用户每秒每路径有 2 个请求
就是这样!
结合在自定义授权服务代码中设置所需的任何请求头值的能力, 这最终成为对几乎所有所需内容进行限流的绝佳方法。一些有趣的选项包括:
用户或帐户信息 特定的身份验证信息 (使用了哪个 API 密钥/令牌) 源 IP 随用户变化的信息, 使诸如让客户为不同的速率限制付费或发出临时优先权之类的事情 计算的欺诈/风险分类
测试与验证
将所有内容组合在一起很有趣, 但是我想确保它能够正常工作。我最终产出了一个奇怪的go test files
, 但是我会尝试它。简而言之, 这是一个 Go 的表驱动测试, 加上vegeta
作为基础库, 以验证所有授权和限流是否按预期工作。首先, 建立一些具有各种特征的vegeta目标。下面这个是使用有效的 API 密钥进行调用/test
, 另外还有 5 种用于调用路径, 要使用的密钥以及密钥是否有效的各种组合。
// An authenticated path
authedTargetA := vegeta.Target{
Method: "GET",
URL: "http://localhost:8010/test",
Header: http.Header{
"Authorization": []string{"Bearer foo"},
},
}
鉴于此, 然后可以运行一些测试, 如下所示:
testCases := []struct {
desc string
okPct float64
targets []vegeta.Target
}{
{"single authed path, target 2qps", 0.20, []vegeta.Target{authedTargetA}},
{"2 authed paths, single user, target 4qps", 0.40, []vegeta.Target{authedTargetA, authedTargetB}},
{"1 authed paths, dual user, target 4qps", 0.40, []vegeta.Target{authedTargetA, otherAuthTarget}},
{"slow path, target 1qps", 0.1, []vegeta.Target{slowTarget}},
{"unauthed, target 0qps", 0.0, []vegeta.Target{unauthedTarget}},
}
每个测试都有一个描述, 一个预期的 “成功百分比” 以及一个或多个要运行的目标的混合。所有测试均以每秒 10 个查询的速度运行, 因此, 如果速率限制应为每秒 2 个查询, 则预期成功率为 20%。(0.20)。因此, 要使用vegeta
实际运行测试:
func runTest(okPct float64, tgts ...vegeta.Target) (ok bool, text string) {
rate := vegeta.Rate{Freq: 10, Per: time.Second}
duration := 10 * time.Second
targeter := vegeta.NewStaticTargeter(tgts...)
attacker := vegeta.NewAttacker()
var metrics vegeta.Metrics
for res := range attacker.Attack(targeter, rate, duration, "test") {
metrics.Add(res)
}
metrics.Close()
if closeEnough(metrics.Success, okPct) {
return true, fmt.Sprintf("Got %0.2f which was close enough to %0.2f\n", metrics.Success, okPct)
}
return false, fmt.Sprintf("Error: Got %0.2f which was too far from %0.2f\n", metrics.Success, okPct)
}
最终, 这真是令人兴奋。一个简单的测试定义实际上可以测试各种速率限制方案实际上限制了速率。测试确实会运行 10 秒钟。我使用不同的速率进行了测试, 并且由于开始跟踪速率需要花费一些时间, 因此在进行较短的测试时, 数据就变得更加模糊。整个测试套件运行起来非常容易:
# 使用原作者仓库此处可能报错,建议使用译者fork的仓库
$ cd vegeta && make test
cd loadtest && go test -v
=== RUN TestEnvoyStack
--- PASS: TestEnvoyStack (50.10s)
=== RUN TestEnvoyStack/single_authed_path,_target_2qps
--- PASS: TestEnvoyStack/single_authed_path,_target_2qps (10.02s)
=== RUN TestEnvoyStack/2_authed_paths,_single_user,_target_4qps
--- PASS: TestEnvoyStack/2_authed_paths,_single_user,_target_4qps (10.03s)
=== RUN TestEnvoyStack/1_authed_paths,_dual_user,_target_4qps
--- PASS: TestEnvoyStack/1_authed_paths,_dual_user,_target_4qps (10.02s)
=== RUN TestEnvoyStack/slow_path,_target_1qps
--- PASS: TestEnvoyStack/slow_path,_target_1qps (10.02s)
=== RUN TestEnvoyStack/unauthed,_target_0qps
--- PASS: TestEnvoyStack/unauthed,_target_0qps (10.01s)
PASS
测试结果非常令人满意。在短短的 50 秒内, 所有 5 种情况均以相当可靠的方式进行了测试。
最后的想法
Envoy
显然是经过精心设计和真正出色的软件, 我真的很高兴能够在尝试隐藏堆栈的同时尝试按想要的方式进行构建并以自己想要的方式运行。对于需要自定义请求处理的任何用例 (尤其是批量较大的用例), 都值得您考虑。
本文提到的链接
容器化的 playground: https://github.com/jbarratt/envoy_ratelimit_example 译者 fork 仓库: https://github.com/Cluas/envoy_ratelimit_example ratelimit: https://github.com/envoyproxy/ratelimit vegeta: https://github.com/tsenart/vegeta