本文基于以下版本:
github.com/golang/protobuf: v1.3.2
google.golang.org/grpc: v1.25.1
nginx: openresty v1.15.8.2
1. 非加密非流式
本節(jié)主要進(jìn)行非加密非流式 GRPC 的通信在字節(jié)層面的討論,假設(shè)讀者對(duì) GRPC、HTTP/2 等已有基本的了解。
本節(jié)使用一個(gè)簡(jiǎn)單的 proto:
syntax = "proto3";
package pb;
service Hot {
rpc Inc (IntReq) returns (IntResp);
}
message IntReq {
int32 i = 1;
}
message IntResp {
int32 i = 1;
}
以及如下的 golang 服務(wù)端代碼:
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)
}
和客戶(hù)端代碼:
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
啟動(dòng)上述 golang 的服務(wù)端,調(diào)用一次客戶(hù)端,均使用默認(rèn)端口。使用 wireshark 抓包,總共抓到 19 幀。除去那些不包含 TCP 荷載的幀,我們首先逐幀來(lái)看看它們?cè)?HTTP/2 這一層長(zhǎng)什么亞子。
| 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 層的結(jié)構(gòu)均如下:
+-----------------------------------------------+
| Length (24) |
+---------------+---------------+---------------+
| Type (8) | Flags (8) |
+-+-------------+---------------+-------------------------------+
|R| Stream Identifier (31) |
+=+=============================================================+
| Frame Payload (0...) ...
+---------------------------------------------------------------+
-
Length:荷載的字節(jié)數(shù),注意是 HTTP 的荷載,不是 TCP 的荷載 -
Type:HTTP 幀的類(lèi)型
| 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:不同類(lèi)型的幀具有不同的 flag 定義
1.1.1. 連接
第 4 幀用許多語(yǔ)言都表示為這樣:
"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
客戶(hù)端通過(guò)這樣一幀去試探服務(wù)端是否支持 HTTP/2。
接下來(lái)第 6、7、9、11 幀,兩端相互請(qǐng)求 SETTINGS。
SETTINGS 幀的荷載為零到多組鍵值對(duì),每組鍵值對(duì)的結(jié)構(gòu)為 2 字節(jié)的 id 和 4 字節(jié)的值。如第 7 幀包含一組鍵值對(duì),id 為 00 05,值為 00 00 40 00。
id 和值的定義見(jiàn) RFC-7540, section 6.5.2.
1.1.2. 首部
第 12 幀,客戶(hù)端向服務(wù)端發(fā)送 HTTP 請(qǐng)求的首部。
HEADERS 幀的荷載結(jié)構(gòu)如下:
+---------------+
|Pad Length? (8)|
+-+-------------+-----------------------------------------------+
|E| Stream Dependency? (31) |
+-+-------------+-----------------------------------------------+
| Weight? (8) |
+-+-------------+-----------------------------------------------+
| Header Block Fragment (*) ...
+---------------------------------------------------------------+
| Padding (*) ...
+---------------------------------------------------------------+
本文的情況中,HEADERS 的幀荷載只有 Header Block Fragment 字段存在。其他字段的定義見(jiàn) RFC-7540, section 6.2.
Fragment 的編解碼使用 HPACK 算法(RFC-7541),包括霍夫曼編碼。我們可以使用 Golang 的副標(biāo)準(zhǔn)庫(kù)當(dāng)中的封裝來(lái)解碼第 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)
}
}
其中傳入的字節(jié)序列長(zhǎng)度為幀的 Length 字段指示的 0x38,但可以看到幀荷載的實(shí)際長(zhǎng)度不止 0x38,后面剩余的 16 個(gè)字節(jié)應(yīng)該是在 HTTP 層的一個(gè)后續(xù)幀在粘包,先不管。這里打印出的 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,由于使用了靜態(tài)索引表和霍夫曼編碼,實(shí)際傳輸?shù)氖撞恐挥?56 字節(jié),通信精簡(jiǎn)的效果很明顯。
同樣,第 16 幀服務(wù)端發(fā)送的 HEADERS 幀,從長(zhǎng)度上看也包含后續(xù)幀,首部解碼出來(lái)如下:
:status 200
content-type application/grpc
神奇的是整個(gè)過(guò)程中沒(méi)有一個(gè) DATA 幀,那么 GRPC 使用的 HTTP body 在哪里呢,我猜你也猜到了。
1.2. GRPC
1.2.1. 請(qǐng)求
在第 12 幀的 HTTP 首部里可以看到,對(duì)于 GRPC 調(diào)用的請(qǐng)求,method 始終是 POST,路徑是 /{包名}.{服務(wù)名}/{方法名}。
而請(qǐng)求的數(shù)據(jù)放在這一幀的后續(xù)幀中:00 00 07 00 01 00 00 00 01 00 00 00 00 02 08 06,從第 4 個(gè)字節(jié)來(lái)看,它正是一個(gè) DATA 幀。
DATA 幀的荷載結(jié)構(gòu)對(duì) HTTP 是透明的,真正的定義在于 GRPC 這一層。GRPC 中 DATA 幀的荷載結(jié)構(gòu)如下:
+---------------+
| Compressed(8) |
+---------------+-----------------------------------------------+
| Length (32) |
+---------------------------------------------------------------+
| Data (*) ...
+---------------------------------------------------------------+
-
Compressed:Data字段是否被壓縮,0為未壓縮,1為壓縮
,此時(shí)壓縮的算法會(huì)標(biāo)記在首部的Message-Encoding字段 -
Length:Data的字節(jié)數(shù) -
Data:實(shí)際的數(shù)據(jù),默認(rèn)為 ProtoBuf 編碼,編碼算法見(jiàn) 這里
這里 DATA 幀的荷載是 00 00 00 00 02 08 06,表明 Data 未段未壓縮,長(zhǎng)度為 2,內(nèi)容為 08 06。
1.2.2. 響應(yīng)
和請(qǐng)求的幀相同的套路,我們可以看清第 16 幀中的響應(yīng)數(shù)據(jù)。不過(guò)在這個(gè)邏輯上的 DATA 幀后面還有一個(gè) HAEDERS 幀,解碼出來(lái)是這樣:
grpc-status 0
grpc-message
至此,我們已基本看清一個(gè)非加密非流式的最簡(jiǎn)單情況下的 GRPC 請(qǐng)求在字節(jié)層面的樣子。
1.3. Nginx 代理
下一節(jié)我們將會(huì)通過(guò)使用帶 TLS 的 Nginx 代理非加密 GRPC 節(jié)點(diǎn),來(lái)討論帶 TLS 的 GRPC 協(xié)議。所以這里先給出一個(gè)簡(jiǎn)單的非加密 Nginx 代理非加密 GRPC 節(jié)點(diǎn)的 Nginx 配置,包括負(fù)載均衡。
我們啟動(dòng)兩個(gè) golang 的服務(wù)端節(jié)點(diǎn),端口分別為 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. 加密非流式
本節(jié)主要進(jìn)行加密非流式 GRPC 的通信在字節(jié)層面的討論,使用帶 TLSv1.2 的 nginx 節(jié)點(diǎn)代理非加密的 golang 服務(wù)端節(jié)點(diǎn),密鑰交換使用橢圓曲線,在服務(wù)端使用自簽名證書(shū),不使用客戶(hù)端證書(shū),假設(shè)讀者對(duì) TLS 等已有基本的了解。
使用以下命令生成橢圓曲線密鑰和服務(wù)端自簽名證書(shū):
openssl ecparam -genkey -name secp256r1 | openssl ec -out hot.key -aes128
openssl req -new -x509 -days 365 -key hot.key -out hot.crt
上一節(jié)的 proto 和 golang 服務(wù)端代碼不變,golang 客戶(hù)端代碼變?yōu)椋?/p>
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 配置文件變?yōu)椋?/p>
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
啟動(dòng)上述 golang 的服務(wù)端和 nginx,調(diào)用一次客戶(hù)端,在客戶(hù)端連接 30080 端口。使用 wireshark 抓包,總共抓到 40 幀,基本比上節(jié)中的情況多了一倍。
在 OSI 七層結(jié)構(gòu)中,TCP、TLS、HTTP 分別位居第 4、6、7 層。本節(jié)中我們當(dāng)然只關(guān)心 TCP 的荷載為 TLS 層的幀。TLS 層的結(jié)構(gòu)如下:
+---------------+-------------------------------+------------------------------+
| Cont Type (8) | Version (16) | Length (16) |
+---------------+-------------------------------+------------------------------+
| Data (*) ...
+------------------------------------------------------------------------------+
在第 4、6、8、9 幀,兩端完成了 10 步的 TLS 握手:
-
Client Hello/Server Hello:兩端各生成一個(gè)隨機(jī)串告知對(duì)方,并由服務(wù)端決定使用套件ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 -
Certificate:服務(wù)端下發(fā)證書(shū),包括公鑰??蛻?hù)端驗(yàn)證證書(shū),這里選擇不驗(yàn)證 -
Server Key Exchange/Server Hello Done:服務(wù)端隨機(jī)生成一個(gè)服務(wù)端臨時(shí)私鑰,根據(jù)該私鑰在橢圓曲線上計(jì)算出一個(gè)服務(wù)端臨時(shí)公鑰,下發(fā)給客戶(hù)端 -
Client Key Exchange/Client Change Cipher Spec/Client Finished:同樣,客戶(hù)端隨機(jī)生成一個(gè)客戶(hù)端臨時(shí)私鑰,根據(jù)該私鑰在橢圓曲線上計(jì)算出一個(gè)客戶(hù)端臨時(shí)公鑰,上傳給服務(wù)端。同時(shí),客戶(hù)端根據(jù) hello 步的兩個(gè)隨機(jī)串、客戶(hù)端臨時(shí)私鑰和服務(wù)端臨時(shí)公鑰,計(jì)算出兩端分別使用的對(duì)稱(chēng)密鑰 -
Server Change Cipher Spec/Server Finished:同樣,服務(wù)端根據(jù) hello 步的兩個(gè)隨機(jī)串、服務(wù)端臨時(shí)私鑰和客戶(hù)端臨時(shí)公鑰,計(jì)算出兩端分別使用的對(duì)稱(chēng)密鑰。數(shù)學(xué)的魔力保證了兩端分別計(jì)算出的對(duì)稱(chēng)密鑰必然相同,感覺(jué)這很浪漫啊。
2.2 HTTP/2
接下來(lái)抓到 9 個(gè) TLS 層的幀,它們的 Content type 均為 Application Data (23),顯然,其中的 Data 字段均為已被對(duì)稱(chēng)密鑰加密的內(nèi)容,解密之后即是 HTTP 層的內(nèi)容。
這里我們打印出解密后的數(shù)據(jù):
| 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
|
撥云見(jiàn)日,熟悉的亞子又回來(lái)了??梢钥吹?,服務(wù)端的 SETTINGS 幀早于客戶(hù)端的試探幀,其他差不都不大。
其中,第 16、33、35 幀的首部解碼出來(lái)分別如下:
: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
請(qǐng)求首部的 :scheme 字段變?yōu)榱?https,其它都沒(méi)有什么變化。而兩個(gè) 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