构建基于gRPC的Kong外部WAF中继服务以分离安全负载


我们团队的 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

这个架构有几个关键的技术选型决策点:

  1. 通信协议:gRPC vs. RESTful API

    • REST (HTTP/JSON) 实现简单,但 JSON 序列化/反序列化的开销在高性能场景下不可忽视。
    • gRPC 基于 HTTP/2,使用 Protobuf 进行二进制序列化,性能更高,延迟更低。同时,它能提供强类型的服务定义,对于维护 Kong 插件(Lua)和后端服务(我们选择 Go)之间的契约非常有帮助。在真实项目中,这种强约束能避免很多运行时错误。我们选择了 gRPC。
  2. WAF 中继服务(Relay Service)语言:Go

    • Go 拥有出色的并发性能、成熟的 gRPC 生态和低内存占用,非常适合构建这种网络中间件。
    • 社区有成熟的 Go 原生 WAF 引擎库,如 Coraza(OWASP ModSecurity Core Rule Set 的一个端口),可以直接集成,避免了 CGO 的复杂性和性能开销。
  3. Kong 插件与 gRPC 服务交互:lua-resty-grpc

    • 在 OpenResty/Kong 的环境中,我们需要一个 Lua 库来发起 gRPC 调用。lua-resty-grpc 是一个成熟的选择,它允许我们像调用本地函数一样调用远程 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_fileinit_worker 阶段执行,每个 worker 进程只需加载一次,避免了在每个请求中重复解析 .proto 文件。
  • 连接管理: 示例代码中每次请求都 connectclose,这在生产中是不可接受的。一个常见的优化是使用 lua-resty-corecosocket API 结合 kong.singletons 实现一个全局的、worker-local 的 gRPC 连接池,复用连接。
  • 错误处理: 当gRPC服务不可用时,插件应该选择 fail-open(放行请求)还是 fail-close(阻断请求)。对于大多数业务,fail-open是更合理的选择,以保证业务可用性。
  • Body读取: kong.request.get_body() 是一个异步操作,但它被封装得很好。我们通过 max_body_sample_size 来控制内存消耗,防止恶意的大请求耗尽 Kong 的 worker 内存。

部署与验证

假设所有代码和文件都已就位,部署流程如下:

  1. 构建并运行 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
  2. 配置 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
  3. 启动 Kong:
    确保将自定义插件的路径添加到 KONG_PLUGINSKONG_LUA_PACKAGE_PATH 环境变量中。

  4. 验证:
    发送一个正常的请求:

    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 网关中剥离出来,使其成为一个可独立扩展、维护和监控的组件。但这并非没有代价。

  1. 网络延迟: 引入了一次额外的网络往返(Kong -> WAF Service)。虽然 gRPC 很快,但在延迟敏感的应用中,这仍然是一个需要评估的成本。在我们的场景中,增加的 P99 延迟(约1-2ms)远小于 WAF 插件在高峰期引入的几十毫秒甚至上百毫秒的CPU抖动,因此是值得的。

  2. 数据不完整性: 由于只中继了部分请求体(body_sample),某些依赖于完整请求体分析的复杂WAF规则可能无法被触发。一个改进方向是,插件可以根据 Content-TypeContent-Length 决定是否需要流式传输整个body,但这会显著增加实现的复杂性。

  3. 响应体检查: 当前架构只检查了请求。如果需要检查上游服务的响应体,就需要实现 body_filter 阶段的逻辑,将响应体片段也发送到WAF服务进行分析。这对性能的挑战更大。

  4. 可观测性: 需要为 gRPC WAF 服务建立完善的监控。暴露 Prometheus 指标,如请求处理速率、延迟分布、错误率、各规则触发次数等,是必不可少的。同时,需要将 Kong 的 request_id 与 WAF 服务的日志关联起来,以便于进行全链路问题排查。

这个架构的本质是一种责任分离。Kong 专注于其最擅长的路由、认证、限流等流量管理任务,而复杂的、CPU密集型的安全分析任务则交给了一个为此优化的专用服务。这种解耦为系统的水平扩展和弹性提供了更大的空间。


  目录