GRPC 的字节结构观察

本文基于以下版本:

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 (*)                          ...
+---------------------------------------------------------------+
  • CompressedData 字段是否被压缩,0 为未压缩,1 为压缩
    ,此时压缩的算法会标记在首部的 Message-Encoding 字段
  • LengthData 的字节数
  • 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 的服务端节点,端口分别为 3008130082。在 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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,311评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,339评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,671评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,252评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,253评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,031评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,340评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,973评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,466评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,937评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,039评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,701评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,254评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,259评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,497评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,786评论 2 345

推荐阅读更多精彩内容