本文基于以下版本:
github.com/golang/protobuf: v1.3.2
google.golang.org/grpc: v1.25.1
nginx: openresty v1.15.8.2
1. 非加密非流式
本节主要进行非加密非流式 GRPC 的通信在字节层面的讨论,假设读者对 GRPC、HTTP/2 等已有基本的了解。
本节使用一个简单的 proto:
syntax = "proto3";
package pb;
service Hot {
rpc Inc (IntReq) returns (IntResp);
}
message IntReq {
int32 i = 1;
}
message IntResp {
int32 i = 1;
}
以及如下的 golang 服务端代码:
package main
import (
"context"
"net"
"os"
"grpc_hot/pb"
"google.golang.org/grpc"
)
type HotService struct{}
func (svc *HotService) Inc(_ context.Context, req *pb.IntReq) (*pb.IntResp, error) {
return &pb.IntResp{I: req.GetI() + 1}, nil
}
func main() {
port := "30080"
if len(os.Args) >= 2 {
port = os.Args[1]
}
srv := grpc.NewServer()
pb.RegisterHotServer(srv, &HotService{})
l, err := net.Listen("tcp", ":"+port)
if nil != err {
println(err.Error())
return
}
srv.Serve(l)
}
和客户端代码:
package main
import (
"context"
"os"
"grpc_hot/pb"
"google.golang.org/grpc"
)
func main() {
port := "30080"
if len(os.Args) >= 2 {
port = os.Args[1]
}
conn, err := grpc.Dial("127.0.0.1:"+port, grpc.WithInsecure())
if nil != err {
println(err.Error())
return
}
defer conn.Close()
cli := pb.NewHotClient(conn)
resp, err := cli.Inc(context.Background(), &pb.IntReq{I: 6})
if nil != err {
println(err.Error())
return
}
println("resp:", resp.GetI())
}
1.1. HTTP/2
启动上述 golang 的服务端,调用一次客户端,均使用默认端口。使用 wireshark 抓包,总共抓到 19 帧。除去那些不包含 TCP 荷载的帧,我们首先逐帧来看看它们在 HTTP/2 这一层长什么亚子。
frame | side | TCP payload |
---|---|---|
04 | client | 50 52 49 20 2a 20 48 54 54 50 2f 32 2e 30 0d 0a 0d 0a 53 4d 0d 0a 0d 0a |
06 | client | 00 00 00 04 00 00 00 00 00 |
07 | server | 00 00 06 04 00 00 00 00 00 00 05 00 00 40 00 |
09 | server | 00 00 00 04 01 00 00 00 00 |
11 | client | 00 00 00 04 01 00 00 00 00 |
12 | client | 00 00 38 01 04 00 00 00 01 83 86 45 89 62 b8 d7 c6 74 b1 92 a2 7f 41 85 b8 c8 00 f0 7f 5f 8b 1d 75 d0 62 0d 26 3d 4c 4d 65 64 7a 8a 9a ca c8 b4 c7 60 2b 89 b5 c3 40 02 74 65 86 4d 83 35 05 b1 1f 00 00 07 00 01 00 00 00 01 00 00 00 00 02 08 06 |
14 | server | 00 00 04 08 00 00 00 00 00 00 00 00 07 00 00 08 06 00 00 00 00 00 02 04 10 10 09 0e 07 07 |
15 | client | 00 00 08 06 01 00 00 00 00 02 04 10 10 09 0e 07 07 |
16 | server | 00 00 0e 01 04 00 00 00 01 88 5f 8b 1d 75 d0 62 0d 26 3d 4c 4d 65 64 00 00 07 00 00 00 00 00 01 00 00 00 00 02 08 07 00 00 18 01 05 00 00 00 01 40 88 9a ca c8 b2 12 34 da 8f 01 30 40 89 9a ca c8 b5 25 42 07 31 7f 00 |
17 | client | 00 00 04 08 00 00 00 00 00 00 00 00 07 00 00 08 06 00 00 00 00 00 02 04 10 10 09 0e 07 07 |
18 | server | 00 00 08 06 01 00 00 00 00 02 04 10 10 09 0e 07 07 |
除第 4 帧外,HTTP 层的结构均如下:
+-----------------------------------------------+
| Length (24) |
+---------------+---------------+---------------+
| Type (8) | Flags (8) |
+-+-------------+---------------+-------------------------------+
|R| Stream Identifier (31) |
+=+=============================================================+
| Frame Payload (0...) ...
+---------------------------------------------------------------+
-
Length
:荷载的字节数,注意是 HTTP 的荷载,不是 TCP 的荷载 -
Type
:HTTP 帧的类型
frame type | code |
---|---|
DATA |
0x0 |
HEADERS |
0x1 |
PRIORITY |
0x2 |
RST_STREAM |
0x3 |
SETTINGS |
0x4 |
PUSH_PROMISE |
0x5 |
PING |
0x6 |
GOAWAY |
0x7 |
WINDOW_UPDATE |
0x8 |
CONTINUATION |
0x9 |
-
Flags
:不同类型的帧具有不同的 flag 定义
1.1.1. 连接
第 4 帧用许多语言都表示为这样:
"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
客户端通过这样一帧去试探服务端是否支持 HTTP/2。
接下来第 6、7、9、11 帧,两端相互请求 SETTINGS
。
SETTINGS
帧的荷载为零到多组键值对,每组键值对的结构为 2 字节的 id 和 4 字节的值。如第 7 帧包含一组键值对,id 为 00 05
,值为 00 00 40 00
。
id 和值的定义见 RFC-7540, section 6.5.2.
1.1.2. 首部
第 12 帧,客户端向服务端发送 HTTP 请求的首部。
HEADERS
帧的荷载结构如下:
+---------------+
|Pad Length? (8)|
+-+-------------+-----------------------------------------------+
|E| Stream Dependency? (31) |
+-+-------------+-----------------------------------------------+
| Weight? (8) |
+-+-------------+-----------------------------------------------+
| Header Block Fragment (*) ...
+---------------------------------------------------------------+
| Padding (*) ...
+---------------------------------------------------------------+
本文的情况中,HEADERS
的帧荷载只有 Header Block Fragment
字段存在。其他字段的定义见 RFC-7540, section 6.2.
Fragment 的编解码使用 HPACK 算法(RFC-7541),包括霍夫曼编码。我们可以使用 Golang 的副标准库当中的封装来解码第 12 帧的 fragment。
import "golang.org/x/net/http2/hpack"
func decodeHeaders(bs []byte) {
d := hpack.NewDecoder(128, nil)
hdrs, _ := d.DecodeFull(bs)
for _, hdr := range hdrs {
println(hdr.Name, hdr.Value)
}
}
其中传入的字节序列长度为帧的 Length
字段指示的 0x38
,但可以看到帧荷载的实际长度不止 0x38
,后面剩余的 16 个字节应该是在 HTTP 层的一个后续帧在粘包,先不管。这里打印出的 header 如下:
:method POST
:scheme http
:path /pb.Hot/Inc
:authority :30081
content-type application/grpc
user-agent grpc-go/1.25.1
te trailers
可以看到这里的首部还包含 HTTP/1.x 中的 method 和 path,由于使用了静态索引表和霍夫曼编码,实际传输的首部只有 56 字节,通信精简的效果很明显。
同样,第 16 帧服务端发送的 HEADERS
帧,从长度上看也包含后续帧,首部解码出来如下:
:status 200
content-type application/grpc
神奇的是整个过程中没有一个 DATA
帧,那么 GRPC 使用的 HTTP body 在哪里呢,我猜你也猜到了。
1.2. GRPC
1.2.1. 请求
在第 12 帧的 HTTP 首部里可以看到,对于 GRPC 调用的请求,method 始终是 POST
,路径是 /{包名}.{服务名}/{方法名}
。
而请求的数据放在这一帧的后续帧中:00 00 07 00 01 00 00 00 01 00 00 00 00 02 08 06
,从第 4 个字节来看,它正是一个 DATA
帧。
DATA
帧的荷载结构对 HTTP 是透明的,真正的定义在于 GRPC 这一层。GRPC 中 DATA
帧的荷载结构如下:
+---------------+
| Compressed(8) |
+---------------+-----------------------------------------------+
| Length (32) |
+---------------------------------------------------------------+
| Data (*) ...
+---------------------------------------------------------------+
-
Compressed
:Data
字段是否被压缩,0
为未压缩,1
为压缩
,此时压缩的算法会标记在首部的Message-Encoding
字段 -
Length
:Data
的字节数 -
Data
:实际的数据,默认为 ProtoBuf 编码,编码算法见 这里
这里 DATA
帧的荷载是 00 00 00 00 02 08 06
,表明 Data
未段未压缩,长度为 2
,内容为 08 06
。
1.2.2. 响应
和请求的帧相同的套路,我们可以看清第 16 帧中的响应数据。不过在这个逻辑上的 DATA
帧后面还有一个 HAEDERS
帧,解码出来是这样:
grpc-status 0
grpc-message
至此,我们已基本看清一个非加密非流式的最简单情况下的 GRPC 请求在字节层面的样子。
1.3. Nginx 代理
下一节我们将会通过使用带 TLS 的 Nginx 代理非加密 GRPC 节点,来讨论带 TLS 的 GRPC 协议。所以这里先给出一个简单的非加密 Nginx 代理非加密 GRPC 节点的 Nginx 配置,包括负载均衡。
我们启动两个 golang 的服务端节点,端口分别为 30081
和 30082
。在 Nginx 配置文件的 http
段中加入:
upstream grpc_hot {
server 127.0.0.1:30081;
server 127.0.0.1:30082;
}
server {
listen 30080 http2;
location / {
grpc_pass grpc://grpc_hot;
}
}
2. 加密非流式
本节主要进行加密非流式 GRPC 的通信在字节层面的讨论,使用带 TLSv1.2 的 nginx 节点代理非加密的 golang 服务端节点,密钥交换使用椭圆曲线,在服务端使用自签名证书,不使用客户端证书,假设读者对 TLS 等已有基本的了解。
使用以下命令生成椭圆曲线密钥和服务端自签名证书:
openssl ecparam -genkey -name secp256r1 | openssl ec -out hot.key -aes128
openssl req -new -x509 -days 365 -key hot.key -out hot.crt
上一节的 proto 和 golang 服务端代码不变,golang 客户端代码变为:
package main
import (
"context"
"crypto/tls"
"os"
"grpc_hot/pb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
func main() {
port := "30080"
if len(os.Args) >= 2 {
port = os.Args[1]
}
creds := credentials.NewTLS(&tls.Config{
InsecureSkipVerify: true,
})
conn, err := grpc.Dial("127.0.0.1:"+port, grpc.WithTransportCredentials(creds))
if nil != err {
println(err.Error())
return
}
defer conn.Close()
cli := pb.NewHotClient(conn)
resp, err := cli.Inc(context.Background(), &pb.IntReq{I: 6})
if nil != err {
println(err.Error())
return
}
println("resp:", resp.GetI())
}
nginx 配置文件变为:
upstream grpc_hot {
server 127.0.0.1:30081;
server 127.0.0.1:30082;
}
server {
listen 30080 ssl http2;
ssl_protocols TLSv1.2;
ssl_certificate hot.crt;
ssl_certificate_key hot.key;
ssl_password_file hot.pass;
ssl_prefer_server_ciphers on;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256;
ssl_session_cache shared:grpc_hot_sess:32m;
ssl_session_timeout 10m;
keepalive_timeout 60;
location / {
grpc_pass grpc://grpc_hot;
}
}
2.1. TLS
启动上述 golang 的服务端和 nginx,调用一次客户端,在客户端连接 30080
端口。使用 wireshark 抓包,总共抓到 40 帧,基本比上节中的情况多了一倍。
在 OSI 七层结构中,TCP、TLS、HTTP 分别位居第 4、6、7 层。本节中我们当然只关心 TCP 的荷载为 TLS 层的帧。TLS 层的结构如下:
+---------------+-------------------------------+------------------------------+
| Cont Type (8) | Version (16) | Length (16) |
+---------------+-------------------------------+------------------------------+
| Data (*) ...
+------------------------------------------------------------------------------+
在第 4、6、8、9 帧,两端完成了 10 步的 TLS 握手:
-
Client Hello
/Server Hello
:两端各生成一个随机串告知对方,并由服务端决定使用套件ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
-
Certificate
:服务端下发证书,包括公钥。客户端验证证书,这里选择不验证 -
Server Key Exchange
/Server Hello Done
:服务端随机生成一个服务端临时私钥,根据该私钥在椭圆曲线上计算出一个服务端临时公钥,下发给客户端 -
Client Key Exchange
/Client Change Cipher Spec
/Client Finished
:同样,客户端随机生成一个客户端临时私钥,根据该私钥在椭圆曲线上计算出一个客户端临时公钥,上传给服务端。同时,客户端根据 hello 步的两个随机串、客户端临时私钥和服务端临时公钥,计算出两端分别使用的对称密钥 -
Server Change Cipher Spec
/Server Finished
:同样,服务端根据 hello 步的两个随机串、服务端临时私钥和客户端临时公钥,计算出两端分别使用的对称密钥。数学的魔力保证了两端分别计算出的对称密钥必然相同,感觉这很浪漫啊。
2.2 HTTP/2
接下来抓到 9 个 TLS 层的帧,它们的 Content type
均为 Application Data (23)
,显然,其中的 Data
字段均为已被对称密钥加密的内容,解密之后即是 HTTP 层的内容。
这里我们打印出解密后的数据:
frame | source | TLS payload(decrypted) |
---|---|---|
10 | server | 00 00 12 04 00 00 00 00 00 00 03 00 00 00 80 00 04 00 01 00 00 00 05 00 FF FF FF 00 00 04 08 00 00 00 00 00 7F FF 00 00 |
11 | client | 50 52 49 20 2A 20 48 54 54 50 2F 32 2E 30 0D 0A 0D 0A 53 4D 0D 0A 0D 0A |
12 | client | 00 00 00 04 00 00 00 00 00 |
14 | server | 00 00 00 04 01 00 00 00 00 |
15 | client | 00 00 00 04 01 00 00 00 00 |
16 | client | 00 00 3E 01 04 00 00 00 01 83 87 45 89 62 B8 D7 C6 74 B1 92 A2 7F 41 8B 08 9D 5C 0B 81 70 DC 64 00 78 1F 5F 8B 1D 75 D0 62 0D 26 3D 4C 4D 65 64 7A 8A 9A CA C8 B4 C7 60 2B 89 B5 C3 40 02 74 65 86 4D 83 35 05 B1 1F 00 00 07 00 01 00 00 00 01 00 00 00 00 02 08 06 |
33 | server | 00 00 35 01 04 00 00 00 01 88 76 8D 3D 65 AA C2 A1 3E 98 0A E1 6D 77 97 17 61 96 DC 34 FD 28 07 54 BE 52 28 20 05 F5 00 ED C6 9B B8 07 54 C5 A3 7F 5F 8B 1D 75 D0 62 0D 26 3D 4C 4D 65 64 00 00 07 00 00 00 00 00 01 00 00 00 00 02 08 07 |
35 | server | 00 00 18 01 05 00 00 00 01 00 88 9A CA C8 B2 12 34 DA 8F 01 30 00 89 9A CA C8 B5 25 42 07 31 7F 00 |
39 | client | 00 00 04 08 00 00 00 00 00 00 00 00 07 00 00 08 06 00 00 00 00 00 02 04 10 10 09 0E 07 07 |
拨云见日,熟悉的亚子又回来了。可以看到,服务端的 SETTINGS
帧早于客户端的试探帧,其他差不都不大。
其中,第 16、33、35 帧的首部解码出来分别如下:
:method POST
:scheme https
:path /pb.Hot/Inc
:authority 127.0.0.1:30080
content-type application/grpc
user-agent grpc-go/1.25.1
te trailers
:status 200
server openresty/1.15.8.2
date Sat, 07 Dec 2019 07:45:07 GMT
content-type application/grpc
grpc-status 0
grpc-message
请求首部的 :scheme
字段变为了 https
,其它都没有什么变化。而两个 DATA
帧也还是我们熟悉的样子。
References
RFC-7540: Hypertext Transfer Protocol Version 2 (HTTP/2)
RFC-7541: HPACK: Header Compression for HTTP/2
Protocol Buffers: Encoding
Introducing gRPC Support with NGINX 1.13.10
Elliptic Curve Cryptography: a gentle introduction
RFC-5246: The Transport Layer Security (TLS) Protocol Version 1.2
Licensed under CC BY-SA 4.0