gRPC mit Go-Microservices

17.03.2021 Raffael Schneider
Cloud Tech golang k8s microservices handson

Im Cloud-Umfeld gibt es zahlreiche Möglichkeiten wie man eine Service-orientierte Architektur mithilfe von Microservices implementieren kann. Dabei steht oft die Ressourcen-, und somit die Kosteneffizienz im Vordergrund, wie auch die Skalierbarkeit bei unterschiedlichen Lastfenstern. Heute schauen wir uns an, wie man kosteneffizient Microservices in der Cloud laufen lassen kann.

Grundsätzlich gibt es zwei etablierte Arten wie man Microservices laufen lässt, entweder als eine Serverless Cloud Function oder als einen Container in einem Kubernetes-Cluster. Serverless Functions wie die beliebte AWS Lamdba sind mit niedrigen Tarifen einsetzbar und leicht aufzubauen. Serverless Functions werden aber ab einer gewissen Lastgrenze markant teurer. Die Variante mit Containern auf Kubernetes ist hingegen doch komplexer, lässt sich aber langfristig kosteneffizienter skalieren. Somit ist die Kubernetes-Lösung darauf angewiesen Container auszurollen, die mit einem geringstmöglichen Footprint und verfügbarer Ansprechleistung gebaut sind.

Wir haben uns in der Vergangheit bereits Quarkus als Framework der Wahl angeschaut gehabt, mit welchem man problemlos schnell einen Microservice in Java geschrieben hat. Aber geht da noch mehr? Aus diesem Grund fokussieren wir uns heute auf Microservcies, die in Go geschrieben sind und mit einem anderen Protokoll Endpunkte bereitstellen, die noch mehr Durchsatz erzielen.

Dieser Beitrag ist inspiriert durch https://itnext.io/grpc-go-microservices-on-kubernetes-bcb6267e9f53.

gRPC – Der universelle Remote Procedure Call Framework

Bei dem Begriff Remote Prodecure Call mag man sich noch wage an Java’s Remote Method Invocation (kurz RMI) oder EJBs (Enterprise JavaBeans) erinnern, womit eine Java-Applikation remote über eine Netzwerk-Verbindung direkt eine bestimmte Methode einer zweiten Java-Applikation aufrufen konnte.

gRPC ist von Google entwickeltes Protokoll zum Aufruf von Funktionen in verteilten Systemen. gRPC steht für Google Remote Procedure Call. gRPC wird von der Cloud Native Computing Foundation als ‘Incubating Project’ bewertet. Dabei werde folgende zwei Hauptkomponenten verwendet:

  • Protocol Buffers (kurz Protobuf) als Interface Description Language: gRPC nutzt Protobuf als Serialisierung zur Kommunikation. Dabei wird ein binäres Format genutzt, was die Schnittstelle schneller auf Anfragen reagieren lässt. Die Schnittstelle definiert man semantisch (und nicht etwa binär) in einem .proto-File. Diese Definition generiert das Binär-Format, welches dann als Interface Description Language (wie beispielsweise JSON) fungiert.

  • HTTP/2 als Transport: Seit 2015 gibt es den neuen HTTP-Standard. Diese zweite Iteration erlaubt bidirektionale Kommunikation, Server Pushs, Header Compression, Multiplexing und weitere Dinge. gRPC nutzt diese neuen Features um die Datenübertragung noch schneller zu gestalten.

Für Microservices nutzt man typischerweise eine REST-Schnittstelle um für die Kommunikation unter Applikationen zu schaffen. Mit gRPC hat Google eine Lösung implementiert, womit man noch schneller kommunizieren miteinander kann.

Features und Vorteile

  • Einfache Service-Definition

  • Effizient da binär, skalierbar

  • Sprachunabhängig, polyglot

  • Bidirektionaler Datenaustausch/-streaming

  • Authentifizierung

  • Perfekt für Interne Kommunikation unter Services

gRPC lässt sich in unterschiedlichen Programmiersprachen nutzen. Somit kann relativ einfach auf REST-Endpunkten eine gPRC-Schnittstelle implementieren, die für bessere Performance bei geringerer Leistungsbedarf sorgt. Es sei angemerkt, dass HTTP/2 noch nicht flächendeckend genutzt wird, da noch veraltete Browser noch tägliche Verwendung finden. Mit gRPC Web, einer JavaScript-Implementierung für Browser-Clients, kann man auch mit älteren Browsern gRPC ansprechen. Falls man weiterhin Applikationen im Einsatz hat, die nur REST-Schnittstellen ansprechen können, kann man mit gRPC Gateway einen Parallelbetrieb über die Definitionsfile schaffen, wobei der Service REST, wie auch gRPC exponiert. Auf diese Technologie gehen wir hier jetzt nicht genauer ein, aber es ist sicherlich gut zu wissen, wie flexibel eine Schnittstelle mit gRPC sein kann.

Wichtig zu wissen ist dass man die Schnittstelle deklarativ in einem .proto-File schreibt und mit dieser dann stub-Files (Code-Bausteine) in der gewünschten Sprache kompilieren kann. Unabhängig von der Programmiersprache, können diese Bausteine in dem jeweiligen Projekt implementiert werden und in der laufenden Applikation miteinander sprachunabhängig kommunizieren.

Unterstütze Sprachen für gRPC

C, C++, C#, Dart, Go, Java, Kotlin, Node.js, Objective-C, PHP, Python, Ruby.

Service-Definition mit Google’s Protobuf

Die gRPC-Schnittstelle nutzt Protobuf als IDL. Diese Schnittstelle schreibt man deklarativin einem .proto-File und kompiliert mit dieser dann stub-Files (Code-Bausteine) in die gewünschten Sprache. Unabhängig von der Programmiersprache, können diese Bausteine in dem jeweiligen Projekt implementiert werden und in der laufenden Applikation miteinander sprachunabhängig kommunizieren.

Ein Beispiel einter .proto-File könnte für eine geographisch-bewusste Chat-API wie folgt aussehen:

syntax = "proto3";

message Point {
  int32 latitude = 1;
  int32 longitude = 2;
}

message Feature {
  string name = 1;
  Point location = 2;
}

message RouteNote {
  Point location = 1;
  string message = 2;
}

service RouteGuid {
  rpc GetFeature(Point) returns (Feature);
  rpc RouteChat(stream RouteNote) returns (stream RouteNote);
}

Protobuf installieren und anwenden

Wie können wir jetzt einen Microservice mit gRPC in Go bauen? Das ist recht einfach. Zuerst installieren wir uns Protobuf wie folgt:

❯ brew install protobuf

Dazu muss man noch das Go-Plugin protoc-gen-go von laden:

❯ go get -u github.com/golang/protobuf/{proto,protoc-gen-go}

Stellt des weiteren sicher dass die Bash-Pfade für Go korrekt gesetzt sind :

❯ echo 'export GOPATH=$HOME/Go' >> $HOME/.bashrc
❯ source $HOME/.bashrc

Sowie auch :

❯ echo 'export PATH=$PATH:$GOPATH/bin' >> $HOME/.bashrc
❯ source $HOME/.bashrc

Das folgende Snippet ist eine exemplarische grpcapi.proto. Mithilfe der Interface Description Language von Protobuf beschreiben wir unsere Schnittstelle.

// grpcapi.proto

syntax = "proto3";

package grpcapi;
option go_package = ".;grpcapi";

message GrpcRequest {
    string input = 1;
}

message GrpcResponse {
    string response = 1;
}

service GrpcService {
    rpc grpcService(GrpcRequest) returns (GrpcResponse) {};
}

Mit diesem Befehl kompiliert der Protobuf-Compiler protoc das Schema in ein verwendbares Go-File:

❯ protoc -I=. --go_out=. grpcapi.proto

Der Output von protoc ist ein Go-File protocol.pb.go das in das gleiche Verzeichnis gelegt wird. Das erstelle Go-File hat über 200 Zeichen, aber um sicherzustellen dass wir den gleichen Output haben, sind die ersten Zeilenblöcke hier ersichtlich:

// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
//     protoc-gen-go v1.25.0-devel
//     protoc        v3.14.0
// source: grpcapi.proto

package grpcapi

import (
   protoreflect "google.golang.org/protobuf/reflect/protoreflect"
   protoimpl "google.golang.org/protobuf/runtime/protoimpl"
   reflect "reflect"
   sync "sync"
)

const (
   // Verify that this generated code is sufficiently up-to-date.
   _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
   // Verify that runtime/protoimpl is sufficiently up-to-date.
   _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)

// . . .

Microservice-Architektur mit Go

Der Server

Jetzt schreiben wir ein server.go-Microservice, der eine GrpcService-Invocation von einem Client entgegen nehmen kann. Schaut euch die Funktion func (*grpcServer) GrpcService() dabei genauer an.

// server.go
package main

import (
	"context"
	"fmt"
	"os/signal"

	"log"
	"net"
	"os"

	"github.com/b-nova-techhub/go-grpc-api"
	"google.golang.org/grpc"
	"google.golang.org/grpc/reflection"
)

type grpcServer struct{}

func (*grpcServer) GrpcService(ctx context.Context, req *grpcapi.GrpcRequest) (*grpcapi.GrpcResponse, error) {
	fmt.Printf("grpcServer %v\n", req)
	name, _ := os.Hostname()

	input := req.GetInput()
	result := "Got input " + input + " server host: " + name
	res := &grpcapi.GrpcResponse{
		Response: result,
	}
	return res, nil
}

func main() {
	fmt.Println("Starting Server...")

	log.SetFlags(log.LstdFlags | log.Lshortfile)

	hostname := os.Getenv("SVC_HOST_NAME")

	if len(hostname) <= 0 {
		hostname = "0.0.0.0"
	}

	port := os.Getenv("SVC_PORT")

	if len(port) <= 0 {
		port = "50051"
	}

	lis, err := net.Listen("tcp", hostname+":"+port)
	if err != nil {
		log.Fatalf("Failed to listen: %v", err)
	}

	opts := []grpc.ServerOption{}
	s := grpc.NewServer(opts...)
	grpcapi.RegisterGrpcServiceServer(s, &grpcServer{})

	// reflection service on gRPC server.
	reflection.Register(s)

	go func() {
		fmt.Println("Server running on ", (hostname + ":" + port))
		if err := s.Serve(lis); err != nil {
			log.Fatalf("failed to serve: %v", err)
		}
	}()

	// Wait for Control C to exit
	ch := make(chan os.Signal, 1)
	signal.Notify(ch, os.Interrupt)

	// Block until a signal is received
	<-ch
	fmt.Println("Stopping the server")
	s.Stop()
	fmt.Println("Closing the listener")
	lis.Close()
	fmt.Println("Server Shutdown")

}

Bevor man den server.go kompilieren kann, müssen zuerst noch die externen Dependencies gezogen werden. Einfach folgende go-Befehle in der Konsole ausführen:

❯ go get -u github.com/b-nova-techhub/go-grpc-api
❯ go get -u	google.golang.org/grpc
❯ go get -u google.golang.org/grpc/reflection

Danach kann man den Server kompilieren und starten.

❯ go run server.go
Starting Server...
Server running on  0.0.0.0:50051

Der Client

Jetzt schreiben wir ein client.go-Microservice, der eine GrpcService-Invocation an einem Server vornimmt. Schaut euch die Funktion func callService() dabei genauer an.

// client.go
package main

import (
	"context"
	"fmt"
	"log"
	"os"

	"github.com/b-nova-techhub/go-grpc-api"
	"google.golang.org/grpc"
)

func main() {

	fmt.Println("Starting client...")

	hostname := os.Getenv("SVC_HOST_NAME")

	if len(hostname) <= 0 {
		hostname = "0.0.0.0"
	}

	port := os.Getenv("SVC_PORT")

	if len(port) <= 0 {
		port = "50051"
	}

	cc, err := grpc.Dial(hostname+":"+port, grpc.WithInsecure())
	if err != nil {
		log.Fatalf("could not connect: %v", err)
	}
	defer cc.Close()

	c := grpcapi.NewGrpcServiceClient(cc)
	fmt.Printf("Created client: %f", c)

	callService(c)

}

func callService(c grpcapi.GrpcServiceClient) {
	fmt.Println("callService...")
	req := &grpcapi.GrpcRequest{
		Input: "test",
	}
	res, err := c.GrpcService(context.Background(), req)
	if err != nil {
		log.Fatalf("error while calling gRPC: %v", err)
	}
	log.Printf("Response from Service: %v", res.Response)
}

Jetzt können wir auch den Client kompilieren und laufen lassen:

❯ go run client.go
Starting client...
Created client: &{%!f(*grpc.ClientConn=&{0xc00007eb40 0x1094060 0.0.0.0:50051 {passthrough  0.0.0.0:50051} 0.0.0.0:50051 {<nil> <nil> [] [] <nil> <nil> {{1000000000 1.6 0.2 120000000000}} false false true 0 <nil>  {grpc-go/1.37.0-dev <nil> false [] <nil> <nil> {0 0 false} <nil> 0 0 32768 32768 0 <nil> true} [] <nil> 0 false true false <nil> <nil> <nil> <nil> 0x13fb280 []} 0xc00000ef60 {<nil> <nil> <nil> 0 grpc-go/1.37.0-dev {passthrough  0.0.0.0:50051}} 0xc0001928d0 {{{0 0} 0 0 0 0} 0xc0000102d8} {{0 0} 0 0 0 0} 0xc000032a80 0xc000088690 map[0xc0000d7340:{}] {0 0 false} pick_first 0xc00007ec40 {<nil>} 0xc00000ef40 0 0xc000024860 {0 0} <nil>})}callService...
grpcServer input:"test" 
2021/02/18 16:12:36 Response from Service: Got input test server host: spacegrey.home

:white_check_mark: Super, hat geklappt !

Hier sehen wir die Response vom Server. Dabei printet wir den ganzen binären Output heraus.

Grpc-Server auf Kubernetes

Jetzt können wir das ganze Konstrukt noch in unseren Kubernetes-Cluster deployen.

Requirements

  • Docker-Kenntnisse und Zugang zu einer Docker-Repository

  • Kubernetes-Kenntnisse und Zugang zu einem Kubernetes-Cluster

  • Falls Sie das nicht haben, können Sie gerne schauen was wir hier machen

Das Dockerfile für den grpc_server :

FROM iron/go
WORKDIR /app

ADD grpc_server /app/

CMD [ "./grpc_server" ]

Kubernetes-Manifest :

apiVersion: v1
kind: Service
metadata:
  name: grpcserver
spec:
  ports:
    - port: 50051
      protocol: TCP
      targetPort: 50051
  selector:
    run: grpcserver
status:
  loadBalancer: { }
---
---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    run: grpcserver
  name: grpcserver
spec:
  replicas: 1
  selector:
    matchLabels:
      run: grpcserver
  strategy: { }
  template:
    metadata:
      labels:
        run: grpcserver
    spec:
      containers:
        - image: javierramos1/grpc_server:latest
          name: grpcserver
          ports:
            - containerPort: 50051
          resources: { }
status: { }

Sobald Sie auf dem Kubernetes-Cluster verbunden sind, kann man das Manifest wie folgt applizieren:

❯ kubectl apply -f deployment.yaml 
service/grpcserver configured
deployment.apps/grpcserver configured

Jetzt kann man die externe IP-Adresse herausfinden, um so den Client danach auszurichten:

❯ kubectl get services
NAME         TYPE           CLUSTER-IP      EXTERNAL-IP      PORT(S)          AGE
grpcserver   ClusterIP      10.245.215.85   <none>           50051/TCP        9m41s
kubernetes   ClusterIP      10.245.0.1      <none>           443/TCP          15d
my-nginx     LoadBalancer   10.245.195.94   164.90.243.164   8080:32336/TCP   13d

Jetzt sind Sie dran! Wie viel performanter ist jetzt eine gRPC-Schnittstelle im Vergleich mit einer CRUD-Schnittstelle?

Stay tuned !

Raffael Schneider – Crafter, Disruptor, Freethinker. Als leidenschaftlicher Software-Crafter schreibt Raffael gerne über Programmiersprachen und Resilienz in modernen verteilten Systemen. Ob DevOps, SRE oder Systemarchitektur, Raffael weiss stets wie man diese Dinge auf eine neue Weise betrachten kann.