关于golang:使用nginx作grpc的反向代理踩坑总结

3次阅读

共计 3994 个字符,预计需要花费 10 分钟才能阅读完成。

本文记录应用 nginx 作 gRPC 的反向代理踩的坑和解决办法。

背景

家喻户晓,nginx 是一款高性能的 web 服务器,罕用于负载平衡和反向代理。所谓的反向代理是和正向代理绝对应,正向代理即咱们惯例意义上了解的“代理”:例如失常状况下在国内是无法访问 google 的,如果咱们须要拜访,就须要通过一层代理去转发。这个正向代理代理的是服务端(也就是 google),而反向代理则相同,代理的是客户端(也就是用户),用户的申请达到 nginx 后,nginx 会代理用户的申请向理论的后端服务发动申请,并将后果返回给用户。

(图片来自维基百科)

正向代理和反向代理实际上是站在用户的角度来定义的,正向也就是代理用户所要申请的服务,而反向则是代理用户向服务发动申请。两者一个很重要的区别:

正向代理服务方不感知申请方,反向代理申请方不感知服务方。

思考一下下面的例子,你通过代理拜访 google 时,google 只能感知到申请来自代理服务器,而无奈间接感知到你(当然通过 cookie 等伎俩也能够追踪到);而通过 nginx 反向代理时,你是不感知申请具体被转发到哪个后端服务器上的。

nginx 最常被用于反向代理的场景就是咱们所熟知的 http 协定,通过配置 nginx.conf 文件能够很简略地定义一个反向代理规定:

worker_processes  1;

events {worker_connections  1024;}

http {
    include       mime.types;
    default_type  application/octet-stream;

    server {
        listen       80;
        server_name  localhost;

        
        location / {proxy_pass http://domain;}
    }
}

nginx 从 1.13.10 当前就反对 gRPC 协定的反向代理,配置相似:

worker_processes  1;

events {worker_connections  1024;}

http {
    include       mime.types;
    default_type  application/octet-stream;

    server {
        listen       81 http2;
        server_name  localhost;

        
        location / {grpc_pass http://ip;}
    }
}

然而当需要场景更加简单的时候,就发现 nginx 的 gRPC 模块实际上有很多坑,实现的能力不如 http 残缺,当套用 http 的解决方案时就会呈现问题

场景

最开始咱们的场景很简略,通过 gRPC 协定实现一个简略的 C / S 架构:

但这种单纯的直连有些场景下是不可行的,例如 client 和 server 在两个网络环境下,彼此不相连通,那就无奈通过简略的 gRPC 连贯拜访服务。一种解决办法是通过两头的代理服务器转发,用下面说的 nginx 反向代理 gRPC 办法:

nginx proxy 部署在两个环境都能拜访的集群上,这样就实现了跨网络环境的 gRPC 拜访。随之而来的问题是如何配置这个路由规定?留神咱们最开始的 gRPC 的指标节点都是清晰的,也就是 server1 和 server2 的 ip 地址,当两头加了一层 nginx proxy 后,client 发动的 gRPC 申请的对象都是 nginx proxy 的 ip 地址。那 client 与 nginx 建设连贯后,nginx 如何晓得须要将申请转发给 server1 还是 server2 呢?(这里 server1 和 server2 不是简略的同一个服务的冗备部署,可能须要依据申请的属性决定由谁响应,例如用户 id 等,因而不能应用负载平衡随机筛选一个响应申请)

解决办法

如果是 http 协定,那有很多实现办法:

  • 通过门路辨别

申请将 server 的信息增加在 path 里,例如:/server1/service/method,而后 nginx 转发申请的时候还原为原始的申请:

worker_processes  1;

events {worker_connections  1024;}

http {
    include       mime.types;
    default_type  application/octet-stream;

    server {
        listen       80;
        server_name  localhost;

        location ~ ^/server1/ {proxy_pass http://domain1/;}
        
        location ~ ^/server2/ {proxy_pass http://domain2/;}
    }
}

留神 http://domain/ 最初的斜杠,如果没有这个斜杠申请的门路会是 /server1/service/method,而服务端只能响应/service/method 的申请,这样就会报 404 的谬误。

  • 通过申请参数辨别

也能够将 server1 的信息放在申请参数里:

worker_processes  1;

events {worker_connections  1024;}

http {
    include       mime.types;
    default_type  application/octet-stream;

    server {
        listen       80;
        server_name  localhost;

        location /service/method {if ($query_string ~ x_server=(.*)) {proxy_pass http://$1;}
        }
    }
}

但对于 gRPC 就没这么简略了,首先 gRPC 不反对 URI 的写法,nginx 转发的申请会保留原来的 path,无奈在转发的时候批改 path,这意味着上述的第一种方法不可行。其次 gRPC 是基于 HTTP 2.0 协定的,HTTP2 没有 queryString 这一概念,申请头里有一项 :path 代表申请的门路,例如 /service/method,而这一门路是不能携带申请参数的,也就是:path 不能写为/service/method?server=server1。这意味着上述的第二种办法也不可行。

留神到 HTTP2 中申请头 :path 是指定申请的门路的,那咱们间接批改 :path 不就行了吗:

worker_processes  1;

events {worker_connections  1024;}

http {
    include       mime.types;
    default_type  application/octet-stream;

    server {
        listen       80 http2;
        server_name  localhost;

        location ~ ^/(.*)/service/.* {
            grpc_set_header :path /service/$2;
            grpc_pass http://$1;
        }
    }
}

然而理论验证表明这种办法也不可行,间接批改 :path 的申请头会导致服务端报错,一种可能的谬误如下:

rpc error: code = Unavailable desc = Bad Gateway: HTTP status code 502; transport: received the unexpected content-type "text/html"

抓包后发现,grpc_set_header并没有笼罩 :path 的后果,而是新增了一项申请头,相当于申请 header 里存在两个:path,可能就是因为这个起因导致服务端报了 502 的谬误。

柳暗花明之际想起 gRPC 的 metadata 性能,咱们能够在 client 端将 server 的信息存储在 metadata 中,而后在 nginx 路由时依据 metadata 中 server 的信息转发给对应的后端服务,这样就实现了咱们的需要。对于 go 语言,设置 metadata 须要实现 PerRPCCredentials 接口,而后在发动连贯的时候传入这个实现类的实例:

type extraMetadata struct {Ip string}

func (c extraMetadata) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {return map[string]string{"x-ip": c.Ip,}, nil
}

func (c extraMetadata) RequireTransportSecurity() bool {return false}

func main(){
    ...
    // nginxProxy 是 nginx proxy 的 ip 或域名地址
    var nginxProxy string
    // serverIp 是依据申请属性计算好的后端服务的 ip
    var serverIp string
    con, err := grpc.Dial(nginxProxy, grpc.WithInsecure(),
        grpc.WithPerRPCCredentials(extraMetadata{Ip: serverIp}))
}

而后在 nginx 配置里依据这个 metadata 转发到对应的 server:

worker_processes  1;

events {worker_connections  1024;}

http {
    include       mime.types;
    default_type  application/octet-stream;

    server {
        listen       80 http2;
        server_name  localhost;

        location ~ ^/service/.* {grpc_pass grpc://$http_x_ip:8200;}
    }
}

留神这里应用了 $http_x_ip 这一语法援用了咱们传递的 x-ip 这个 metadata 信息。这一办法验证无效,client 能够通过 nginx proxy 胜利拜访到 server 的 gRPC 服务。

总结

nginx 的 gRPC 模块的文档太少了,官网文档只给出了几个指令的用处,并没有阐明 metadata 这一办法,网上的文档也鲜有波及,导致花了两三天的工夫在排查。将整个过程总结在这里,心愿能帮忙到遇到同一问题的人。

正文完
 0