如何使用gRPC和Mutual TLS連接Python和Go應(yīng)用程序?
譯文【51CTO.com快譯】本教程通過(guò)使用Mutual TLS 身份驗(yàn)證的 gRPC 框架,向你介紹用 Python 和 Go 編寫(xiě)的服務(wù)連接的過(guò)程。因多數(shù)開(kāi)發(fā)者對(duì) Python/Django 和 Go 開(kāi)發(fā)比較熟悉,本文將省略大多數(shù)無(wú)聊的東西,比如使用 Django 應(yīng)用程序引導(dǎo) virtualenv 或者如何“ manage.py runserver”它。
???
引言
有一個(gè)Python中的舊系統(tǒng)正在進(jìn)行大的修改。它是由兩部分組成的系統(tǒng):
- Webapp是一個(gè)使用Django框架構(gòu)建的面向用戶(hù)的web應(yīng)用程序。它充當(dāng)API客戶(hù)機(jī),連接到幾個(gè)節(jié)點(diǎn)執(zhí)行一些操作。
- 每個(gè)節(jié)點(diǎn)(服務(wù)器)都是一個(gè)用Python編寫(xiě)的簡(jiǎn)單服務(wù)器,它駐留在Nginx后面。一些節(jié)點(diǎn)位于私有網(wǎng)絡(luò)之外,通信通過(guò)公共網(wǎng)絡(luò)進(jìn)行。
在進(jìn)行一些清理、重構(gòu)和測(cè)試工作之后,客戶(hù)機(jī)基本上滿(mǎn)足了它的需求。另一方面,服務(wù)器有穩(wěn)定性和性能問(wèn)題,所以在Go (Golang)重寫(xiě)服務(wù)器是對(duì)性能提升很有幫助的解決方案。
而Python和Go之間的通信成了唯一的障礙。
用于客戶(hù)機(jī)和服務(wù)器之間通信的現(xiàn)有JSON API是舊的,沒(méi)有文檔記錄。正是因?yàn)閺牧汩_(kāi)始重建它比試圖復(fù)興它更容易。將此API重寫(xiě)為REST/JSON相對(duì)容易,但JSON作為交換格式將不能提供Python和Go之間的互換性和類(lèi)型兼容性。在這兩種語(yǔ)言中,類(lèi)型系統(tǒng)是不同的,要讓它工作是很繁瑣且容易出錯(cuò)的。
一個(gè)更好的解決方案是使用跨平臺(tái)的序列化格式,比如協(xié)議緩沖區(qū)(protobuf)。它的構(gòu)建是為了提供跨平臺(tái)兼容性,在Python和Go中得到很好的支持,而且它比JSON更小、更快。Protobuf可以與REST API一起使用,以確保編程語(yǔ)言之間的數(shù)據(jù)互操作性。但是一個(gè)更好的解決方案是使用gRPC框架來(lái)完全替換舊的API。
gRPC是一個(gè)遠(yuǎn)程過(guò)程調(diào)用(RPC)框架,它在跨服務(wù)通信場(chǎng)景中工作得非常好。它使用協(xié)議緩沖區(qū)作為接口定義語(yǔ)言(Interface Definition Language, IDL)和消息交換格式。gRPC使用HTTP/2作為傳輸,并支持傳輸層安全(TLS)協(xié)議,它可以在沒(méi)有TLS的情況下工作——基本上,這是大多數(shù)教程告訴我們的。這樣,通信是通過(guò)h2c協(xié)議完成的,本質(zhì)上是純文本HTTP/2,沒(méi)有TLS加密。然而,當(dāng)通過(guò)公共網(wǎng)絡(luò)進(jìn)行通信時(shí),TLS是必需的。考慮到現(xiàn)代安全威脅,TLS甚至應(yīng)該被考慮用于私有網(wǎng)絡(luò)連接[1]。
在本系統(tǒng)中,服務(wù)到服務(wù)的通信不需要區(qū)分客戶(hù)機(jī),也不需要向它們授予不同的權(quán)限。盡管如此,確保只有授權(quán)的客戶(hù)機(jī)才能與服務(wù)器通信是很重要的。使用互TLS (mTLS)作為身份驗(yàn)證機(jī)制很容易實(shí)現(xiàn)。
通常在TLS中,服務(wù)器有證書(shū)和公鑰/私鑰對(duì),而客戶(hù)端沒(méi)有。然后,服務(wù)器將其證書(shū)發(fā)送給客戶(hù)機(jī)進(jìn)行驗(yàn)證。在mTLS中,服務(wù)器和客戶(hù)機(jī)都有證書(shū),服務(wù)器也驗(yàn)證客戶(hù)機(jī)的證書(shū)。只有在這之后,服務(wù)器才會(huì)授予對(duì)客戶(hù)端[2]的訪問(wèn)權(quán)。
讓我們創(chuàng)建一個(gè)類(lèi)似的東西——一個(gè)簡(jiǎn)單的Python/Django web服務(wù),它將通過(guò)gRPC/mTLS調(diào)用Go服務(wù)器,并在瀏覽器中顯示結(jié)果,并從存儲(chǔ)庫(kù)的結(jié)構(gòu)開(kāi)始。
代碼布局
對(duì)于這樣的項(xiàng)目,使用單個(gè)存儲(chǔ)庫(kù)(monorepo)就不需要共享API模式。對(duì)于如何組織代碼庫(kù),每個(gè)人都有自己的偏好,只要記住protobuf編譯器,protoc,對(duì)如何組織代碼有自己的想法。
原型文件的位置會(huì)影響編譯后的代碼。它可能需要對(duì)編譯器標(biāo)志進(jìn)行一些試驗(yàn)來(lái)生成工作代碼。將原型文件放在帶代碼的主文件夾之外,這樣重新組織代碼就不會(huì)破壞原型編譯。
我建議這樣的目錄結(jié)構(gòu):
tree -L 1 -d . . ├── certs ├── client ├── proto └── server
- certs -我們用于自簽名證書(shū)的公鑰基礎(chǔ)設(shè)施。
- client - Python/Django web應(yīng)用程序和gRPC客戶(hù)端API。它基本上是一個(gè)' django-admin startproject client . '的結(jié)果,通過(guò)剝離配置,因?yàn)椴恍枰獢?shù)據(jù)庫(kù)。
- proto -是放置gRPC的protobuf源文件的地方。
- server - Go中的gRPC服務(wù)器。
公鑰基礎(chǔ)設(shè)施
要開(kāi)始使用TLS,您需要客戶(hù)端和服務(wù)器的證書(shū)。要?jiǎng)?chuàng)建自簽名證書(shū),我建議使用CloudFlare的PKI工具包CFSSL。
首先,您需要?jiǎng)?chuàng)建一個(gè)證書(shū)頒發(fā)機(jī)構(gòu)(CA),該機(jī)構(gòu)將用于為服務(wù)器和客戶(hù)端生成TLS證書(shū)。此CA證書(shū)還用于在建立TLS連接時(shí)驗(yàn)證另一方證書(shū)的真實(shí)性。
通過(guò)JSON文件配置CFSSL,并提供命令來(lái)生成默認(rèn)的配置模板開(kāi)始:
cd certs cfssl print-defaults config > ca-config.json
默認(rèn)ca-config。Json提供了足夠滿(mǎn)足我們需求的配置文件。讓我們生成一個(gè)CA證書(shū)簽名請(qǐng)求配置,證書(shū)和私鑰:
cat > ca-csr.json <<EOF { "CN": "CA", "key": { "algo": "ecdsa", "size": 256 }, "names": [ { "C": "US", "ST": "CA", "L": "San Francisco" } ] } EOF cfssl gencert -initca ca-csr.json | cfssljson -bare ca -
客戶(hù)端證書(shū)、公鑰和私鑰:
cat > client-csr.json <<EOF { "CN": "client", "key": { "algo": "ecdsa", "size": 256 }, "names": [ { "C": "US", "ST": "CA", "L": "San Francisco" } ] } EOF cfssl gencert \ -ca=ca.pem \ -ca-key=ca-key.pem \ -config=ca-config.json \ -profile=client client-csr.json | cfssljson -bare client
服務(wù)器的IP地址必須包含在API服務(wù)器證書(shū)的主題替代名稱(chēng)列表中。這將確保遠(yuǎn)程客戶(hù)端可以驗(yàn)證證書(shū)。
cat > server-csr.json <<EOF { "CN": "server", "key": { "algo": "ecdsa", "size": 256 }, "names": [ { "C": "US", "ST": "CA", "L": "San Francisco" } ] } EOF cfssl gencert \ -ca=ca.pem \ -ca-key=ca-key.pem \ -config=ca-config.json \ -hostname=127.0.0.1 \ -profile=server server-csr.json | cfssljson -bare server
Protobuf和gRPC
現(xiàn)在證書(shū)已經(jīng)準(zhǔn)備好了,下一步是為gRPC所需的API創(chuàng)建一個(gè)模式定義。它將是一個(gè)名為DiceService的簡(jiǎn)單服務(wù),以演示gRPC和mTLS是如何工作的。
下面是proto/api.proto文件。它定義了一個(gè)RPC端點(diǎn)RollDie,該端點(diǎn)接受RollDieRequest并在rolldierresponse的值字段中返回滾模的值。
syntax = "proto3"; option go_package = "server/api"; package api; message RollDieRequest {} message RollDieResponse { int32 value = 1; } service DiceService { rpc RollDie (RollDieRequest) returns (RollDieResponse) {} }
下一步是使用protobuf編譯器- protoc從原型定義中為每種語(yǔ)言生成代碼。此外,每種語(yǔ)言都需要自己的一組依賴(lài)項(xiàng)。
安裝所需的包,并構(gòu)建Python的原型文件:
pip install grpcio grpcio-tools python -m grpc_tools.protoc -I proto --proto_path=proto \ --python_out=client/api --grpc_python_out=client/api proto/api.proto
編譯后的文件位于client/api目錄中。由于某些原因,protocol的Python編譯器在生成的代碼中使用了絕對(duì)導(dǎo)入[3],它應(yīng)該是固定的:
cd client/api && cat api_pb2_grpc.py | \ sed -E 's/^(import api_pb2.*)/from client.api \1/g' > api_pb2_grpc.tmp && \ mv -f api_pb2_grpc.tmp api_pb2_grpc.py
安裝所需的模塊和構(gòu)建原型文件。protoc-gen-go和protoc-gen-go-grpc默認(rèn)安裝在GOBIN目錄下。你可以覆蓋GOBIN并將其指向virtualenv的bin目錄——這使得之后更容易清理。
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest protoc -I. --go_out=. --go-grpc_out=. proto/api.proto
編譯后的文件位于server/api目錄中。
Python客戶(hù)端
為了將其余的代碼庫(kù)與Protobuf/gRPC代碼隔離開(kāi)來(lái),創(chuàng)建一個(gè)簡(jiǎn)單的包裝器:api/client.py。該包裝器需要CA證書(shū)、客戶(hù)端證書(shū)和密鑰來(lái)建立到提供地址的TLS連接。
import grpc from . import api_pb2, api_pb2_grpc class Certs: root = None cert = None key = None def __init__(self, root, cert, key): self.root = open(root, 'rb').read() self.cert = open(cert, 'rb').read() self.key = open(key, 'rb').read() class Client: rpc = None def __init__(self, addr: str, crt: Certs): creds = grpc.ssl_channel_credentials(crt.root, crt.key, crt.cert) channel = grpc.secure_channel(addr, creds) self.rpc = api_pb2_grpc.DiceServiceStub(channel) def roll_die(self) -> int: return self.rpc.RollDie(api_pb2.RollDieRequest()).value
這是如何在web應(yīng)用程序的視圖中使用這個(gè)客戶(hù)端。這里的變量值包含RPC調(diào)用的結(jié)果。
ICONS = ["?", "?", "?", "?", "?", "?", "?"] def grpc(request): grpc_addr = "127.0.0.1:8443" crt = api.Certs('certs/ca.pem', 'certs/client.pem', 'certs/client-key.pem') try: value = api.Client(grpc_addr, crt).roll_die() except Exception as e: logger.exception(e) return HttpResponse('Value: ' + ICONS[value])
現(xiàn)在,如果你試圖通過(guò)啟動(dòng)web應(yīng)用程序并點(diǎn)擊相應(yīng)的視圖來(lái)執(zhí)行這段代碼,你會(huì)得到一個(gè)錯(cuò)誤。這是預(yù)期的-服務(wù)器還沒(méi)有創(chuàng)建。這里有趣的部分是錯(cuò)誤——它會(huì)說(shuō)一些關(guān)于“連接到所有地址失敗”的東西,這并不多。但是設(shè)置環(huán)境變量GRPC_VERBOSITY=debug會(huì)使gRPC輸出更加詳細(xì),并有助于進(jìn)行故障排除。它可以在client/settings.py文件中完成,例如:
if DEBUG: os.environ['GRPC_VERBOSITY'] = 'debug'
服務(wù)器端
在server/api/server.go中實(shí)現(xiàn)DiceService邏輯。它初始化偽隨機(jī)數(shù)生成器,并根據(jù)請(qǐng)求返回范圍從1到6的隨機(jī)值。
// Number of dots on a die const Dots = 6 type Server struct { UnimplementedDiceServiceServer rnd *rand.Rand } func NewServer() *Server { return &Server{ rnd: rand.New(rand.NewSource(time.Now().UnixNano())), } } func (s *Server) RollDie(ctx context.Context, req *RollDieRequest) (*RollDieResponse, error) { // rand.Intn returns a value in [0, Dots) interval value := s.rnd.Intn(Dots) + 1 return &RollDieResponse{Value: int32(value)}, nil }
服務(wù)實(shí)現(xiàn)已經(jīng)準(zhǔn)備就緒。下一步是為gRPC服務(wù)器提供證書(shū)并啟動(dòng)它。你可以把它放在這里服務(wù)器/服務(wù)器。啟用mTLS的一個(gè)重要時(shí)刻是設(shè)置tls。配置{ClientAuth: tls。RequireAndVerifyClientCert},它指示服務(wù)器請(qǐng)求并驗(yàn)證客戶(hù)端的證書(shū)。
secureAddress := "127.0.0.1:8443" serverCert, err := tls.LoadX509KeyPair("certs/server.pem", "certs/server-key.pem") if err != nil { log.Printf("failed to load server cert/key: %s", err) os.Exit(1) } caCert, err := ioutil.ReadFile("certs/ca.pem") if err != nil { log.Printf("failed to load CA cert: %s", err) os.Exit(1) } caCertPool := x509.NewCertPool() caCertPool.AppendCertsFromPEM(caCert) creds := credentials.NewTLS(&tls.Config{ Certificates: []tls.Certificate{serverCert}, ClientCAs: caCertPool, ClientAuth: tls.RequireAndVerifyClientCert, }) secureSrv := grpc.NewServer(grpc.Creds(creds)) log.Printf("Starting gRPC server, address=%q", secureAddress) lis, err := net.Listen("tcp", secureAddress) if err != nil { log.Printf("failed to listen: %s", err) os.Exit(1) } api.RegisterDiceServiceServer(secureSrv, api.NewServer()) if err := secureSrv.Serve(lis); err != nil { log.Printf("failed to serve: %s", err) os.Exit(1) }
現(xiàn)在使用運(yùn)行server/server運(yùn)行服務(wù)器。去,確保web應(yīng)用程序運(yùn)行和訪問(wèn)它的url -你應(yīng)該看到RPC請(qǐng)求的結(jié)果。gRPC服務(wù)器不會(huì)記錄任何關(guān)于傳入請(qǐng)求的信息,而且很難通過(guò)查看服務(wù)器的輸出來(lái)判斷發(fā)生了什么。
幸運(yùn)的是,有一個(gè)API可以攔截RPC請(qǐng)求的執(zhí)行,您可以使用它添加類(lèi)似于任何HTTP服務(wù)器的日志記錄。它接近于Django中間件的工作方式。下面是一個(gè)簡(jiǎn)單的日志攔截器。要使用它,您需要將它傳遞給grpc.NewServer。
func loggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { ts := time.Now() peer, ok := peer.FromContext(ctx) if !ok { return nil, status.Errorf(codes.InvalidArgument, "missing peer") } md, ok := metadata.FromIncomingContext(ctx) if !ok { return nil, status.Errorf(codes.InvalidArgument, "missing metadata") } res, err := handler(ctx, req) log.Printf("server=%q ip=%q method=%q status=%s duration=%s user-agent=%q", md[":authority"][0], peer.Addr.String(), info.FullMethod, status.FromContextError(err).Code(), time.Since(ts), md["user-agent"][0], ) return res, err } ... secureSrv := grpc.NewServer(grpc.Creds(creds), grpc.UnaryInterceptor(loggingInterceptor))
看來(lái)目標(biāo)已經(jīng)達(dá)到了??蛻?hù)端通過(guò)gRPC與服務(wù)器通信,由于mTLS,連接是安全的,并且是相互驗(yàn)證的。但請(qǐng)記住,該服務(wù)器非?;A(chǔ),需要進(jìn)行一些工作和加固,才能在公共網(wǎng)絡(luò)上的生產(chǎn)環(huán)境中使用它。
讓我們把服務(wù)器放在Nginx后面
另一種方法是把Nginx放在服務(wù)器之前,我更喜歡它,而不是把Go服務(wù)暴露在互聯(lián)網(wǎng)上。開(kāi)箱即用,你將獲得所有經(jīng)過(guò)戰(zhàn)斗測(cè)試的功能,如負(fù)載平衡和速率限制,它還將減少你需要編寫(xiě)和支持的代碼數(shù)量。
Nginx從1.13.10版本開(kāi)始就支持gPRC,并且可以終止、檢查和路由gRPC方法調(diào)用。所以讓我們?cè)诜?wù)器之前添加Nginx,讓它處理mTLS和通過(guò)未加密的HTTP/2的代理請(qǐng)求。這個(gè)設(shè)置有點(diǎn)復(fù)雜,所以這里是圖表:
???
讓我們從客戶(hù)機(jī)中的另一個(gè)視圖開(kāi)始。它將使用不同的端口號(hào)。
def nginx(request): nginx_addr = "127.0.0.1:9443" crt = api.Certs('certs/ca.pem', 'certs/client.pem', 'certs/client-key.pem') try: value = api.Client(nginx_addr, crt).roll_die() except Exception as e: logger.exception(e) return HttpResponse('Value: ' + ICONS[value])
因?yàn)镹ginx將完成所有圍繞TLS的工作,所以沒(méi)有必要在服務(wù)器代碼中為gRPC服務(wù)器提供證書(shū):
insecureAddress := "127.0.0.1:50051" insecureSrv := grpc.NewServer(grpc.UnaryInterceptor(loggingInterceptor)) log.Printf("Starting gRPC server (h2c), address=%q", insecureAddress) lis, err := net.Listen("tcp", insecureAddress) if err != nil { log.Printf("failed to listen: %s", err) os.Exit(1) } api.RegisterDiceServiceServer(insecureSrv, api.NewServer()) if err := insecureSrv.Serve(lis); err != nil { log.Printf("failed to serve: %s", err) os.Exit(1) }
Nginx的配置文件:Nginx .conf這個(gè)配置禁用了妖魔化,并啟動(dòng)了一個(gè)記錄到stdout的進(jìn)程。這對(duì)于演示目的來(lái)說(shuō)更方便。
NoneBashCSSCC#GoHTMLJavaJavaScriptJSONPHPPowershellPythonRubySQLTypeScriptYAMLCopy
events { worker_connections 1024; } # Do not use it in production! daemon off; master_process off; http { upstream grpcservers { server 127.0.0.1:50051; } server { listen 9443 ssl http2; error_log /dev/stdout; access_log /dev/stdout; # Server's tls config ssl_certificate certs/server.pem; ssl_certificate_key certs/server-key.pem; # mTLS part ssl_client_certificate certs/ca.pem; ssl_verify_client on; location / { grpc_pass grpc://grpcservers; } } }
開(kāi)啟Nginx。
nginx -p $(pwd) -c nginx.conf
確保所有的服務(wù)都啟動(dòng)了,并訪問(wèn)之前創(chuàng)建的視圖的URL——您應(yīng)該看到RPC請(qǐng)求的結(jié)果。如果有些東西不能工作-檢查這篇文章在GitHub上的代碼。
【51CTO譯稿,合作站點(diǎn)轉(zhuǎn)載請(qǐng)注明原文譯者和出處為51CTO.com】