我们团队的 Kong 集群在承载核心业务流量时,CPU毛刺问题越来越频繁。经过几轮火焰图分析和压力测试,矛头最终指向了我们启用的 WAF 插件。该插件在 access
阶段同步执行规则匹配,当请求负载增大,特别是遇到构造复杂的恶意请求时,其正则表达式计算会消耗大量 CPU 时间,直接拖慢了整个请求链路的响应时间。更严重的是,它在网关层面形成了一个潜在的性能瓶颈和故障单点。
将 WAF 逻辑移出 Kong 的核心处理路径,变成一个独立、可扩展的服务,这个想法应运而生。我们需要一个极其轻量的 Kong 插件,它的唯一职责就是将请求的关键信息“中继”(Relay)到一个外部 WAF 服务集群。这个中继过程必须是高效的、低延迟的,并且对 Kong 的影响降到最低。
初步构想与技术选型
架构的核心是将“检测”与“转发”分离。Kong 内部的插件只负责数据采集和发送,真正的 WAF 引擎运行在独立的进程中。
sequenceDiagram participant Client participant Kong participant Kong WAF Relay Plugin participant gRPC WAF Service Client->>+Kong: 发起业务请求 (e.g., POST /api/data) Kong->>+Kong WAF Relay Plugin: access phase hook Note right of Kong WAF Relay Plugin: 采集请求头、URI、部分Body Kong WAF Relay Plugin->>+gRPC WAF Service: CheckRequest(RequestMetadata) Note right of gRPC WAF Service: 运行Coraza WAF引擎
进行规则匹配 gRPC WAF Service-->>-Kong WAF Relay Plugin: CheckResponse{is_threat: true, rule_id: "942100"} alt 发现威胁 Kong WAF Relay Plugin->>Kong: kong.response.exit(403) Kong-->>-Client: 403 Forbidden else 未发现威胁 Kong WAF Relay Plugin-->>-Kong: return Kong->>Upstream Service: 代理请求 Upstream Service->>Kong: 响应 Kong-->>Client: 正常响应 end
这个架构有几个关键的技术选型决策点:
通信协议:gRPC vs. RESTful API
- REST (HTTP/JSON) 实现简单,但 JSON 序列化/反序列化的开销在高性能场景下不可忽视。
- gRPC 基于 HTTP/2,使用 Protobuf 进行二进制序列化,性能更高,延迟更低。同时,它能提供强类型的服务定义,对于维护 Kong 插件(Lua)和后端服务(我们选择 Go)之间的契约非常有帮助。在真实项目中,这种强约束能避免很多运行时错误。我们选择了 gRPC。
WAF 中继服务(Relay Service)语言:Go
- Go 拥有出色的并发性能、成熟的 gRPC 生态和低内存占用,非常适合构建这种网络中间件。
- 社区有成熟的 Go 原生 WAF 引擎库,如 Coraza(OWASP ModSecurity Core Rule Set 的一个端口),可以直接集成,避免了 CGO 的复杂性和性能开销。
Kong 插件与 gRPC 服务交互:
lua-resty-grpc
- 在 OpenResty/Kong 的环境中,我们需要一个 Lua 库来发起 gRPC 调用。
lua-resty-grpc
是一个成熟的选择,它允许我们像调用本地函数一样调用远程 gRPC 服务。
- 在 OpenResty/Kong 的环境中,我们需要一个 Lua 库来发起 gRPC 调用。
步骤一:定义 gRPC 服务契约
一切从 Protobuf 定义开始。我们需要定义一个服务,它接收请求的元数据,并返回一个WAF检查结果。
这里的坑在于,我们不能简单地将整个 HTTP 请求体都序列化发送过去。对于大的请求体,这会产生巨大的网络开销和序列化成本,违背了我们构建轻量级中继的初衷。因此,我们只发送WAF决策所必需的关键信息。
api/v1/waf.proto
syntax = "proto3";
package api.v1;
option go_package = "github.com/your-org/kong-waf-relay/api/v1;v1";
// WAF中继服务定义
service WafService {
// 检查单个请求
rpc CheckRequest(CheckRequestRequest) returns (CheckRequestResponse);
}
// 请求检查的输入数据
message CheckRequestRequest {
// 请求的唯一ID,用于日志追踪
string request_id = 1;
// 客户端IP地址
string client_ip = 2;
// 请求方法,如 GET, POST
string method = 3;
// 请求的URI,包含查询参数
string uri = 4;
// HTTP协议版本,如 "HTTP/1.1"
string http_version = 5;
// 请求头
map<string, string> headers = 6;
// 请求体的一个片段。
// 注意:为了性能,我们不应该发送完整的请求体。
// 这里可以只发送前N个字节,或者对于特定Content-Type(如application/json)解析后的关键字段。
// 此处为简化示例,发送部分原始字节。
bytes body_sample = 7;
}
// 请求检查的输出结果
message CheckRequestResponse {
// 是否检测到威胁
bool is_threat = 1;
// 如果是威胁,触发的规则ID
string rule_id = 2;
// 如果是威胁,触发的规则消息
string rule_message = 3;
// 处理耗时(微秒)
int64 process_time_micros = 4;
}
这份 .proto
文件是整个系统的骨架。它明确了 Kong 插件和服务端之间的通信数据结构。body_sample
的设计是一个重要的权衡,它避免了在每个请求上传输兆字节级别的数据。
步骤二:实现 Go WAF Relay gRPC 服务
服务端的实现是核心。我们将使用 Coraza WAF 库来执行实际的检查。
internal/waf/server.go
package waf
import (
"context"
"fmt"
"log"
"net"
"strings"
"time"
"github.com/corazawaf/coraza/v3"
"github.com/corazawaf/coraza/v3/seclang"
pb "github.com/your-org/kong-waf-relay/api/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Server 实现了 WafService gRPC 服务
type Server struct {
pb.UnimplementedWafServiceServer
waf coraza.WAF
}
// NewServer 创建一个新的WAF服务实例
// directivesPath 是 OWASP CRS 规则文件的路径
func NewServer(directivesPath string, crsPath string) (*Server, error) {
conf := coraza.NewWAFConfig().
WithDirectivesFromFile(directivesPath).
WithDirectives(fmt.Sprintf("Include %s/*.conf", crsPath))
waf, err := coraza.NewWAF(conf)
if err != nil {
return nil, fmt.Errorf("failed to create WAF instance: %w", err)
}
log.Println("Coraza WAF engine initialized successfully.")
return &Server{waf: waf}, nil
}
// CheckRequest 是 gRPC 接口的实现
func (s *Server) CheckRequest(ctx context.Context, req *pb.CheckRequestRequest) (*pb.CheckRequestResponse, error) {
if req.GetRequestId() == "" {
return nil, status.Error(codes.InvalidArgument, "request_id is required")
}
startTime := time.Now()
tx := s.waf.NewTransaction()
defer func() {
if err := tx.Close(); err != nil {
log.Printf("RequestID: %s, failed to close transaction: %v", req.GetRequestId(), err)
}
}()
// 1. 处理请求连接信息
clientIP := net.ParseIP(req.GetClientIp())
if clientIP == nil {
clientIP = net.ParseIP("127.0.0.1") // Fallback
}
tx.ProcessConnection(clientIP, 0, net.ParseIP("127.0.0.1"), 0)
// 2. 处理请求行
tx.ProcessURI(req.GetUri(), req.GetMethod(), req.GetHttpVersion())
// 3. 处理请求头
for key, value := range req.GetHeaders() {
// gRPC headers are typically lowercase. Coraza might expect canonical format.
// For CRS, this is usually fine.
tx.AddRequestHeader(key, value)
}
// 4. 处理请求体
// 这是一个关键的性能点。我们只处理了body_sample。
// 如果需要处理完整body,需要调整插件和这里的逻辑。
if len(req.GetBodySample()) > 0 {
// 注意:如果body被截断,某些基于完整body的规则可能无法触发。
// 这是一个设计上的妥协。
if _, err := tx.RequestBodyWriter().Write(req.GetBodySample()); err != nil {
log.Printf("RequestID: %s, failed to write request body to WAF: %v", req.GetRequestId(), err)
return nil, status.Error(codes.Internal, "WAF body processing error")
}
if err := tx.ProcessRequestBody(); err != nil {
log.Printf("RequestID: %s, failed to process request body: %v", req.GetRequestId(), err)
return nil, status.Error(codes.Internal, "WAF body processing error")
}
}
// 5. 触发WAF处理流程
// ProcessRequestHeaders 会返回一个 Interruption,如果触发了 blocking rule
interruption := tx.ProcessRequestHeaders()
if interruption != nil {
return s.buildResponse(tx, startTime, interruption), nil
}
// 如果没有body,我们也需要一个阶段来让规则运行
if len(req.GetBodySample()) == 0 {
interruption = tx.ProcessRequestBody()
if interruption != nil {
return s.buildResponse(tx, startTime, interruption), nil
}
}
return s.buildResponse(tx, startTime, nil), nil
}
func (s *Server) buildResponse(tx coraza.Transaction, startTime time.Time, interruption *coraza.Interruption) *pb.CheckRequestResponse {
isThreat := interruption != nil && interruption.RuleID > 0
resp := &pb.CheckRequestResponse{
IsThreat: isThreat,
ProcessTimeMicros: time.Since(startTime).Microseconds(),
}
if isThreat {
resp.RuleId = fmt.Sprintf("%d", interruption.RuleID)
// 查找匹配的规则消息
for _, r := range tx.MatchedRules() {
if r.Rule().ID() == interruption.RuleID {
msg, _ := r.Rule().Msg().Get()
resp.RuleMessage = msg
break
}
}
}
return resp
}
这个Go服务的核心是CheckRequest
方法。它模拟了Web服务器处理请求的生命周期,依次调用Coraza事务的ProcessConnection
, ProcessURI
, ProcessRequestHeaders
等方法。一个常见的错误是忘记处理所有阶段,导致某些规则集(例如依赖请求头和请求体的CRS规则)无法正确触发。
cmd/server/main.go
package main
import (
"log"
"net"
"os"
"github.com/your-org/kong-waf-relay/internal/waf"
"google.golang.org/grpc"
pb "github.com/your-org/kong-waf-relay/api/v1"
)
func main() {
listenAddr := getEnv("LISTEN_ADDR", ":50051")
directivesPath := getEnv("WAF_DIRECTIVES_PATH", "configs/coraza.conf")
crsPath := getEnv("WAF_CRS_PATH", "coreruleset")
lis, err := net.Listen("tcp", listenAddr)
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
wafServer, err := waf.NewServer(directivesPath, crsPath)
if err != nil {
log.Fatalf("failed to create WAF server: %v", err)
}
grpcServer := grpc.NewServer()
pb.RegisterWafServiceServer(grpcServer, wafServer)
log.Printf("gRPC WAF Relay server listening on %s", listenAddr)
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}
步骤三:实现 Kong Lua 插件
现在是客户端部分,即在 Kong 内部运行的 Lua 插件。它需要在 access
阶段被触发,收集信息,调用gRPC服务,并根据响应决定是放行还是拦截请求。
kong/plugins/grpc-waf-relay/schema.lua
return {
name = "grpc-waf-relay",
fields = {
{ consumer = { type = "foreign", reference = "consumers" } },
{ service = { type = "foreign", reference = "services" } },
{ route = { type = "foreign", reference = "routes" } },
{
config = {
type = "record",
fields = {
{ grpc_server_address = { type = "string", required = true, example = "127.0.0.1:50051" } },
{ timeout = { type = "number", default = 1000, gte = 0 } },
{ keepalive_pool_size = { type = "number", default = 100 } },
{ keepalive_timeout = { type = "number", default = 60000 } },
-- 为了性能,我们限制读取的body大小
{ max_body_sample_size = { type = "number", default = 4096 } },
},
},
},
},
}
插件的 schema.lua
定义了配置项。这里的 max_body_sample_size
是一个非常重要的生产实践,它直接关系到插件的性能和对gRPC服务的压力,必须设置一个合理的上限。
kong/plugins/grpc-waf-relay/handler.lua
local grpc = require("resty.grpc")
local protoc = require("protoc")
local cjson = require("cjson")
local Handler = {}
Handler.PRIORITY = 1000 -- WAF应该在其他插件之前运行
Handler.VERSION = "0.1.0"
local function log(message)
kong.log.err("[grpc-waf-relay] ", message)
end
function Handler:init_worker()
-- 在worker启动时加载proto文件
-- 这里的路径需要根据你的Kong插件目录结构进行调整
local ok, _ = protoc:load_file("/usr/local/share/lua/5.1/api/v1/waf.proto")
if not ok then
log("Failed to load waf.proto")
end
end
function Handler:access(conf)
local request_id = kong.request.get_header("x-request-id") or ngx.req.get_id()
-- 1. 创建 gRPC 客户端
local client, err = grpc.new()
if not client then
log("Failed to create grpc client: " .. tostring(err))
return kong.response.exit(500, { message = "Internal WAF Error" })
end
client:set_timeout(conf.timeout)
-- 2. 连接到 gRPC 服务
-- 在真实项目中,应该使用 kong.singletons 和连接池来管理连接
local ok, err = client:connect(conf.grpc_server_address)
if not ok then
log("Failed to connect to gRPC server: " .. tostring(err))
return kong.response.exit(503, { message = "WAF service unavailable" })
end
-- 3. 准备请求数据
-- 这里的坑在于如何高效地获取请求体而不阻塞事件循环。
-- kong.request.get_body() 内部处理了这个问题。
kong.request.get_body(conf.max_body_sample_size)
local raw_body, err, truncated = ngx.req.get_body_data()
if not raw_body and err then
log("failed to get request body: " .. tostring(err))
end
if truncated then
log("request body was truncated to " .. tostring(conf.max_body_sample_size) .. " bytes")
end
local req_data = {
request_id = request_id,
client_ip = kong.client.get_ip(),
method = kong.request.get_method(),
uri = kong.request.get_raw_uri(),
http_version = kong.request.get_http_version(),
headers = kong.request.get_headers(),
body_sample = raw_body or ""
}
-- 4. 发起 gRPC 调用
local res, err = client:call(
"api.v1.WafService",
"CheckRequest",
req_data
)
-- 及时关闭连接,释放资源。更好的方式是使用连接池。
client:close()
if not res then
log("gRPC call failed: " .. tostring(err))
-- 在生产中,gRPC调用失败时应该采用“失败放开”(fail-open)策略,
-- 避免WAF服务故障影响业务。除非安全策略要求“失败关闭”(fail-close)。
-- 这里我们选择 fail-open,只记录日志。
return
end
-- 5. 处理响应
if res.is_threat then
log(string.format("Threat detected. RequestID: %s, RuleID: %s, Message: %s",
request_id, res.rule_id, res.rule_message))
return kong.response.exit(403, {
message = "Request blocked by security policy.",
request_id = request_id,
threat_details = {
rule_id = res.rule_id,
}
})
end
end
return Handler
这个插件的实现有几个关键点:
- Protobuf 加载:
protoc:load_file
在init_worker
阶段执行,每个 worker 进程只需加载一次,避免了在每个请求中重复解析.proto
文件。 - 连接管理: 示例代码中每次请求都
connect
和close
,这在生产中是不可接受的。一个常见的优化是使用lua-resty-core
的cosocket
API 结合kong.singletons
实现一个全局的、worker-local 的 gRPC 连接池,复用连接。 - 错误处理: 当gRPC服务不可用时,插件应该选择 fail-open(放行请求)还是 fail-close(阻断请求)。对于大多数业务,fail-open是更合理的选择,以保证业务可用性。
- Body读取:
kong.request.get_body()
是一个异步操作,但它被封装得很好。我们通过max_body_sample_size
来控制内存消耗,防止恶意的大请求耗尽 Kong 的 worker 内存。
部署与验证
假设所有代码和文件都已就位,部署流程如下:
构建并运行 gRPC 服务:
# 编译proto protoc --go_out=. --go_opt=paths=source_relative \ --go-grpc_out=. --go-grpc_opt=paths=source_relative \ api/v1/waf.proto # 下载OWASP CRS git clone https://github.com/coreruleset/coreruleset.git # 运行服务 WAF_CRS_PATH=./coreruleset/rules go run cmd/server/main.go
配置 Kong:
使用 declarative a.k.a DB-less 模式来配置 Kong 是最佳实践。kong.yaml
_format_version: "3.0" services: - name: mock-service url: http://httpbin.org routes: - name: mock-route paths: - / plugins: - name: grpc-waf-relay service: mock-service config: grpc_server_address: "host.docker.internal:50051" # 如果Kong在Docker中运行 timeout: 500 max_body_sample_size: 8192 # 8KB
启动 Kong:
确保将自定义插件的路径添加到KONG_PLUGINS
和KONG_LUA_PACKAGE_PATH
环境变量中。验证:
发送一个正常的请求:curl -i http://localhost:8000/get # 应该返回 200 OK
发送一个典型的 SQL 注入攻击 payload:
curl -i -X POST http://localhost:8000/post -d "id=1' or '1'='1"
如果一切正常,应该会收到由插件返回的 403 Forbidden 响应,并且在 WAF Relay 服务的日志中可以看到类似
Threat detected
的记录。
局限性与未来迭代
这个方案有效地将 WAF 的计算密集型任务从 Kong 网关中剥离出来,使其成为一个可独立扩展、维护和监控的组件。但这并非没有代价。
网络延迟: 引入了一次额外的网络往返(Kong -> WAF Service)。虽然 gRPC 很快,但在延迟敏感的应用中,这仍然是一个需要评估的成本。在我们的场景中,增加的 P99 延迟(约1-2ms)远小于 WAF 插件在高峰期引入的几十毫秒甚至上百毫秒的CPU抖动,因此是值得的。
数据不完整性: 由于只中继了部分请求体(
body_sample
),某些依赖于完整请求体分析的复杂WAF规则可能无法被触发。一个改进方向是,插件可以根据Content-Type
和Content-Length
决定是否需要流式传输整个body,但这会显著增加实现的复杂性。响应体检查: 当前架构只检查了请求。如果需要检查上游服务的响应体,就需要实现
body_filter
阶段的逻辑,将响应体片段也发送到WAF服务进行分析。这对性能的挑战更大。可观测性: 需要为 gRPC WAF 服务建立完善的监控。暴露 Prometheus 指标,如请求处理速率、延迟分布、错误率、各规则触发次数等,是必不可少的。同时,需要将 Kong 的
request_id
与 WAF 服务的日志关联起来,以便于进行全链路问题排查。
这个架构的本质是一种责任分离。Kong 专注于其最擅长的路由、认证、限流等流量管理任务,而复杂的、CPU密集型的安全分析任务则交给了一个为此优化的专用服务。这种解耦为系统的水平扩展和弹性提供了更大的空间。