Kubernetes, gRPC e service mesh

di Andrea Zani, in .NET,

Questo post è nato da uno scambio pacifico di idee. Il soggetto della discussione è l'utilizzo, in una struttura con microservice, del protocollo gRPC. Andando oltre la classica demo del GreeterClient in locale mostrerò due modalità di pubblicazione di un service gRPC e di un client che fa le classiche richieste, naturalmente con l'ultimo Net Core 5. Questo è il mio file proto per la definizione dei metodi e dei parametri utilizzati:

syntax = "proto3";

option csharp_namespace = "AzGrpcService";

package azgrpc;

service MyGrpcService {
    rpc GetMessage (Message) returns (Reply);
}

message Message {
    string Text = 1;
}

message Reply {
    string Id = 1;
    string Text = 2;
    int32 Counter = 3;
}

Il cui codice, in C#:

using Grpc.Core;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace AzGrpcService
{
    public class AzService : MyGrpcService.MyGrpcServiceBase
    {
        private readonly ILogger<AzService> _logger;
        private readonly IHelper _helper;

        public AzService(ILogger<AzService> logger, IHelper helper)
        {
            _logger = logger;
            _helper = helper;
        }

        public override Task<Reply> GetMessage(Message request, ServerCallContext context)
        {
            _helper.Counter++;
            return Task.FromResult(new Reply
            {
                Id = _helper.ProcessId,
                Text=request.Text,
                Counter=_helper.Counter
            });
        }
    }
}

Chi ha provato il classico esempio presente nella documentazione ufficiale di Microsoft e ha sviluppato il tutto con Visual Studio, avrà notato che il tutto è semplice, soprattutto la procedura di creazione automatica del certificato SSL per la comunicazione protetta al service creato. Con VsCode la cosa non è molto più complessa perché alla fine basta scrivere da terminale:

dotnet dev-certs https --trust

E per rimuoverlo:

dotnet dev-certs https --clean

Il problema è che questo certificato è valido solo in locale. Ovviamente per rendere pubblico questo service si deve creare un certificato apposito, come nel primo esempio che mostrerò, oppure c'è una seconda opportunità che esporrò in seguito. Effettivamente c'è una terza scelta: fare in modo che il service non usi il certificato e che le connessioni tra lui e il client siano in chiaro (potrebbe essere accettabile in una intranet, ma non è così che si fa!

Avendo già un cluster Kubernetes avviato come primo passaggio si deve creare il certificato. Di seguito la procedura l'ho eseguita con openssl con una macchina Linux, ma la cosa dovrebbe essere simile anche sotto Windows. Questo certificato NON essendo rilasciato da una CA ufficiale ovviamente avrà dei problemi di riconoscimento da parte del client, ma la soluzione è semplice. Innanzitutto creerò un file che sarà utilizzato da openssl per creare il certificato. Eccolo:

[ req ]
default_bits = 2048
prompt = no
default_md = sha256
req_extensions = req_ext
distinguished_name = dn

[ dn ]
C = IT
ST = IT
L = MILAN
O = LOCAL
OU = LOCAL
CN = webserver.default.svc.cluster.local

[ req_ext ]
subjectAltName = @alt_names

[ alt_names ]
DNS.1 = webserver
DNS.2 = webserver.default
DNS.3 = webserver.default.svc
DNS.4 = webserver.default.svc.cluster
DNS.5 = webserver.default.svc.cluster.local

[ v3_ext ]
authorityKeyIdentifier=keyid,issuer:always
basicConstraints=CA:FALSE
keyUsage=keyEncipherment,dataEncipherment
extendedKeyUsage=serverAuth,clientAuth
subjectAltName=@alt_names

Ho specificato con CN e, soprattutto, come DNS webserver webserver.default.svc.cluster.local perché sarà il domain name del service all'interno di Kubernetes. Le varianti specificate (DNS.1, DNS.2... serve per renderlo disponibile da qualsiasi punto all'interno del cluster). E' arrivato ora il momento di creare i certificati. Da terminale creo il ca.key:

openssl genrsa -out ca.key 2048

Quindi il ca.crt:

openssl req -x509 -new -nodes -key ca.key -subj "/CN=webserver.default.svc.cluster.local" -days 10000 -out ca.crt

Ora creo la server.key sempre a 2048 bit:

openssl genrsa -out server.key 2048

E con quest'ultima key e il file creato prima per la definizione del certificato creo il certificato firmato (server.csr):

openssl req -new -key server.key -out server.csr -config csr.conf

Ultimi due passaggi, creazione del certificato x509:

openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key     -CAcreateserial -out server.crt -days 10000     -extensions v3_ext -extfile csr.conf

Per poter essere utilizzato all'interno della web application ho bisogno di convertirlo in formato pfx:

openssl pkcs12 -export -out domain.name.pfx -inkey server.key -in server.crt

Ora ho tutti i file di cui ho bisogno:

  • domain.name.pfx
  • ca.crt

domain.name.pfx lo userò per la web application visto che non posso usare quello creato con il comando dotnet. Senza questo certificato si ottiene l'errore:

crit: Microsoft.AspNetCore.Server.Kestrel[0]
      Unable to start Kestrel.
      System.InvalidOperationException: Unable to configure HTTPS
endpoint. No server certificate was specified, and the default
developer certificate could not be found or is out of date.
      To generate a developer certificate run 'dotnet dev-certs
https'. To trust the certificate (Windows and macOS only) run 'dotnet
dev-certs https --trust'.
      For more information on configuring HTTPS see
https://go.microsoft.com/fwlink/?linkid=848054.

Per utilizzare questo file pfx è necessario modificare il metodo CreateHostBuilder con questo codice semplificato:

    public static IHostBuilder CreateHostBuilder(string[] args)
  {
    return Host.CreateDefaultBuilder(args)
              .ConfigureWebHostDefaults(webBuilder =>
              {
                  webBuilder.UseKestrel(options =>
                  {
                      options.Listen(IPAddress.Any, 443, listenOptions =>
          {
                          var serverCertificate = LoadCertificate();
                          listenOptions.UseHttps(serverCertificate);
          });
                  });
      
                  webBuilder.UseStartup<Startup>();
              });
  }

  private static X509Certificate2 LoadCertificate()
  {
      string certificatePfx = "domain.name.pfx";
      byte[] certificatePayload = File.ReadAllBytes(certificatePfx);
      return new X509Certificate2(certificatePayload, "123456");
  }

Grazie agli oggetti come i secrets e i configmap posso inserirlo direttamente con Kuberbenetes. Quasi tutto risolto, ma questo certificato ritornerà un errore dal client perché non rilasciato da un CA. Per risolvere velocemente ci sono tre strade semplici: il primo è inserire sulle macchine worker di Kurbenetes questi certificati come se fosse rilasciati da una CA (è sufficiente un comando), oppure, se come nella maggior parte dei casi non è possibile mettere mano direttamente a queste macchine, si possono inserire direttamente in Docker e lasciare che lui esegua tutta la procedura, infine, utilizzando questo esempio Kurbenetes, posso lasciarlo fare a lui nella definizione dei pod (io userò questa strada). Questa strada mi permette di inserire i due file per i certificati negli oggetti Secrets o ConfigMap e collegarli ai pod dove gireranno i miei due progetti. Inizio proprio con questo punto:

kubectl create secret generic certificate-webserver --from-file=./ca.crt --from-file=./domain.name.pfx

E' il momento di avviare il service all'interno di Kubernetes:

---
apiVersion: v1
kind: Service
metadata:
  name: webserver
  annotations:
spec:
  selector:
    app: webserver
  ports:
  - protocol: TCP
    port: 443
    targetPort: 443
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: webserver
  labels:
    app: webserver
spec:
  replicas: 1
  selector:
    matchLabels:
      app: webserver
  template:
    metadata:
      labels:
        app: webserver
    spec:
      containers:
      - name: webserver
        image: sbraer/grpcservice:v1
        imagePullPolicy: IfNotPresent
        env:
        - name: USE_CERTIFICATE
          value: "true"
        - name: CERTIFICATE_PFX_FILE
          value: /usr/local/share/ca-certificates/domain.name.pfx
        - name: PORT
          value: "443"
        command:
        - bash
        - "-c"
        - |
          set ex
          chmod 644 /usr/local/share/ca-certificates/ca.crt
          update-ca-certificates
          dotnet AzGrpcService.dll
        volumeMounts:
        - name: certificates
          mountPath: /usr/local/share/ca-certificates
      volumes:
      - name: certificates
        secret:
          secretName: certificate-webserver

In command ho inserito il codice per memorizzare il certificato e avviare la mia web application (tralascio la descrizione del codice della dichiarazione dei Volume perché già spiegato più volte nei post precedenti. Ora controllo che il client e il service stiano comunicando:

kubectl get po

Per sapere l'id del pod client, quindi controllo il log:

$ kubectl logs webclient-ecf9271041-424c7 -f
From service: a1cfe880-768a-4e7b-b9e2-b1ded76e840e Message 1 1
From service: a1cfe880-768a-4e7b-b9e2-b1ded76e840e Message 2 2
From service: a1cfe880-768a-4e7b-b9e2-b1ded76e840e Message 3 3
From service: a1cfe880-768a-4e7b-b9e2-b1ded76e840e Message 4 4
From service: a1cfe880-768a-4e7b-b9e2-b1ded76e840e Message 5 5

Il parametro -f fa in modo che il comando log non si chiuda dopo aver visualizzato il log ma continui a rimanere collegato in attesa di altri messaggi.

Perfetto, prima soluzione funziona. Critiche a questo metodo: è necessario inserire uno o più certificati a mano in tutti i pod che effettueranno le richieste. Questo potrebbe essere anche una cosa positiva, perché si preclude la possibilità che un pod possa comunicare per errore con uno non autorizzato. Ma il difetto più grande è il rinnovo del certificato. Io solo per test ho creato un certificato sopra dalla durata di 10.000 giorni (27 anni), ma in casi normali un certificato anche rilasciato da una CA ha una scadenza molto più breve, e in quel caso ci si deve ricordare di aggiornare il file in Kubernetes, quindi aggiornare tutti i pod coinvolti. A questo c'è una soluzione alternativa nativa all'interno di Kubernetes che permette di creare certificati con l'aggiornamento automatico, ma non posso dire di più perché non ho mai provato questa funzionalità.

Seconda opzione, non usare i certificati, anzi, per meglio dire, fare in modo che la connessione tra i servizi e tutta la gestione dei certificati venga gestita in automatico da terzi grazie al service mesh. Il service mesh è una servizio che permette di agganciare ai pod interessati un proxy che gestirà lui le connesioni tra pod aggiungendo interessanti funzionalità, tra le quali la connessione con certificato TLS e il controllo preciso delle connessioni.

I più noti service mesh sono:

Istio è sicuramente il più famoso e con più opzioni. AWS App Mesh è utilizzabile nel cloud AWS ma non avendo alcuna esperienza diretta non mi spingerò a dare giudizi e lo tralascerò completamente, così come Consul Connect (Consul lo conosco per altre peculiarità come avevo scritto in un post di qualche anno fa). Nel mio caso userò Linkerd che preferisco per semplicità di utilizzo. Ma come funziona un service mesh, in questo caso Linkerd? Il bello di questi strumenti è che mettono a disposizione il tutto senza alcuna modifica al nostro codice se non una riga aggiuntiva nei file yaml di configurazione: al resto pensa tutto lui. Nel mio caso sono presenti due pod - il service e il client - al quale Linkerd inserirà in modo automatico un proxy alle connessioni. Ma prima di vedere come, è ora di semplificare il codice usato finora. Come visto prima ho inserito nel service il caricamento del certificato pfx. Ora non mi serve più. Anzi, non mi serve utilizzare connessioni cifrate: posso utilizzare le chiamate in chiaro, e per fare questo, seguendo la documentazione, scriverò:

      int port = int.Parse(Environment.GetEnvironmentVariable("PORT") ?? "5001");

    return Host.CreateDefaultBuilder(args)
              .ConfigureWebHostDefaults(webBuilder =>
              {
                  webBuilder.UseKestrel(options =>
                  {
                      options.Listen(IPAddress.Any, port, listenOptions =>
                      {
                        listenOptions.Protocols = HttpProtocols.Http2;
                      });
                  });
      
                  webBuilder.UseStartup<Startup>();
              });

Ora se avvio in locale questa applicazione non avrò bisogno di certificati o altro, e anche il client si potrà connettere con il protocollo http. Il codice del client non subirà alcune modifica visto che passerò il link da chiamare con una environment variable. Fine. Cancellato quanto fatto in precedenza in Kubernetes (con violenza kubectl delete all --all) ricreo il tutto con i file yaml che saranno più semplici visto che non utilizzerò alcun certificato:

$ kubectl apply -f server_without_certificate.yaml
$ kubectl apply -f client_without_certificate.yaml
$ kubectl get po
NAME                         READY   STATUS    RESTARTS   AGE
webclient-79c8465574-78djm   1/1     Running   0          3s
webserver-779f8cccdf-fhchd   1/1     Running   0          64s
$ kubectl logs webclient-79c8465574-78djm
From service: 094a4627-4d53-4492-868b-a1fe0df9773f Message 1 1
From service: 094a4627-4d53-4492-868b-a1fe0df9773f Message 2 2
From service: 094a4627-4d53-4492-868b-a1fe0df9773f Message 3 3
From service: 094a4627-4d53-4492-868b-a1fe0df9773f Message 4 4
From service: 094a4627-4d53-4492-868b-a1fe0df9773f Message 5 5
From service: 094a4627-4d53-4492-868b-a1fe0df9773f Message 6 6
From service: 094a4627-4d53-4492-868b-a1fe0df9773f Message 7 7
From service: 094a4627-4d53-4492-868b-a1fe0df9773f Message 8 8
From service: 094a4627-4d53-4492-868b-a1fe0df9773f Message 9 9
...

Funziona, ma la connessione tra i due pod è in chiaro. Ci si potrebbe accontentare di questo visto che tutto funziona. Ma ora è il momento di Linkerd. Innanzitutto lo si deve installare. Sul sito è presente una guida semplicissima:

https://linkerd.io/2/getting-started/

Una volta seguiti pedissequamente i passaggi si può verificare che tutto funzioni:

linkerd check

Oppure:

$ kubectl get -n linkerd po
NAME                                      READY   STATUS         RESTARTS   AGE
linkerd-controller-59ff7845-g6jj7         0/2     NodeAffinity   0          6d12h
linkerd-controller-59ff7845-phjk2         2/2     Running        9          5d1h
linkerd-destination-764bd6fb75-fmzn6      0/2     NodeAffinity   0          5d1h
linkerd-destination-764bd6fb75-gdc92      2/2     Running        4          3d1h
linkerd-destination-764bd6fb75-lv99p      0/2     NodeAffinity   0          6d12h
linkerd-grafana-8b8cd7f8c-cpq8p           0/2     NodeAffinity   0          6d12h
linkerd-grafana-8b8cd7f8c-g5kpf           2/2     Running        6          5d1h
linkerd-identity-689dc7c694-jnbb6         0/2     NodeAffinity   0          6d12h
linkerd-identity-689dc7c694-vd8qm         2/2     Running        9          5d1h
linkerd-prometheus-c5567d5f-j7c2k         0/2     NodeAffinity   0          6d12h
linkerd-prometheus-c5567d5f-vc7vx         2/2     Running        6          5d1h
linkerd-proxy-injector-67b4c6678b-5kfvf   0/2     NodeAffinity   0          6d12h
linkerd-proxy-injector-67b4c6678b-zdmgz   2/2     Running        9          5d1h
linkerd-sp-validator-c8fd55bd6-8rqbd      2/2     Running        9          5d1h
linkerd-sp-validator-c8fd55bd6-bmtxk      0/2     NodeAffinity   0          6d12h
linkerd-tap-5778ddd878-k7jj5              2/2     Running        9          5d1h
linkerd-tap-5778ddd878-rkfmm              0/2     NodeAffinity   0          6d12h
linkerd-web-645876c58-cm5dg               2/2     Running        9          5d1h
linkerd-web-645876c58-sq9dc               0/2     NodeAffinity   0          6d12h

Il bello è che si può lasciare il tutto a lui per inserire il suo proxy nei pod avviati come da documentazione:

kubectl get -n default deploy -o yaml   | linkerd inject -   | kubectl apply -f -

Esaminando l'output si nota che ha aggiunto, negli oggetti di tipo Deployment, nelle Annotation una nuova voce linkerd.io/inject: enabled:

apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: webserver
    labels:
      app: webserver
  spec:
    replicas: 1
    selector:
      matchLabels:
        app: webserver
    template:
      metadata:
        labels:
          app: webserver
        annotations:
          linkerd.io/inject: enabled
...

La stessa cosa per il service usato dal server:

apiVersion: v1
kind: Service
metadata:
  name: webserver
  annotations:
    linkerd.io/inject: enabled
spec:
  selector:
    app: webserver
  ports:
  - protocol: TCP
    port: 80
    targetPort: 80

Ora provo ad avviare il service con questa modifica:

$ kubectl apply -f server_with_linkerd.yaml 
service/webserver created
deployment.apps/webserver created
$ kubectl get po
NAME                        READY   STATUS    RESTARTS   AGE
webserver-6b48fbfd5-jntzk   2/2     Running   0          26s

Sì può notare ora che i pod avviati ora per il service sono due e non più uno come in precedenza. Questo è merito di Linkerd che ha creato un pod contenente il proxy che svolgerà il lavoro di connessione. Solo per curiosità posso controllare con log cosa sta succedendo:

$ kubectl logs webserver-6b48fbfd5-jntzk
  error: a container name must be specified for pod webserver-6b48fbfd5-jntzk, choose one of: [webserver linkerd-proxy] or one of the init containers: [linkerd-init]

E' stato avviato un pod come init e insieme al mio service c'è il proxy di linkerd. In init:

$ kubectl logs webserver-6b48fbfd5-jntzk linkerd-init
  2020/12/26 20:47:02 Tracing this script execution as [1609015622]
  2020/12/26 20:47:02 current state
  ------------------------------------------------------------
  2020/12/26 20:47:02 :; iptables-save
  2020/12/26 20:47:02 
  
  2020/12/26 20:47:02 configuration
  ------------------------------------------------------------
  2020/12/26 20:47:02 Will ignore port [4190 4191 25 443 587 3306 11211] on chain PROXY_INIT_REDIRECT
  2020/12/26 20:47:02 Will redirect all INPUT ports to proxy
  2020/12/26 20:47:02 Ignoring uid 2102
  2020/12/26 20:47:02 Will ignore port [25 443 587 3306 11211] on chain PROXY_INIT_OUTPUT
  2020/12/26 20:47:02 Redirecting all OUTPUT to 4140
  2020/12/26 20:47:02 
  
  2020/12/26 20:47:02 adding rules
  ------------------------------------------------------------
  2020/12/26 20:47:02 :; iptables -t nat -N PROXY_INIT_REDIRECT -m comment --comment proxy-init/redirect-common-chain/1609015622
  2020/12/26 20:47:02 :; iptables -t nat -A PROXY_INIT_REDIRECT -p tcp --match multiport --dports 4190,4191,25,443,587,3306,11211 -j RETURN -m comment --comment proxy-init/ignore-port-4190,4191,25,443,587,3306,11211/1609015622
  2020/12/26 20:47:02 :; iptables -t nat -A PROXY_INIT_REDIRECT -p tcp -j REDIRECT --to-port 4143 -m comment --comment proxy-init/redirect-all-incoming-to-proxy-port/1609015622
  2020/12/26 20:47:02 :; iptables -t nat -A PREROUTING -j PROXY_INIT_REDIRECT -m comment --comment proxy-init/install-proxy-init-prerouting/1609015622
  2020/12/26 20:47:02 :; iptables -t nat -N PROXY_INIT_OUTPUT -m comment --comment proxy-init/redirect-common-chain/1609015622
  2020/12/26 20:47:02 :; iptables -t nat -A PROXY_INIT_OUTPUT -m owner --uid-owner 2102 -o lo ! -d 127.0.0.1/32 -j PROXY_INIT_REDIRECT -m comment --comment proxy-init/redirect-non-loopback-local-traffic/1609015622
  2020/12/26 20:47:02 :; iptables -t nat -A PROXY_INIT_OUTPUT -m owner --uid-owner 2102 -j RETURN -m comment --comment proxy-init/ignore-proxy-user-id/1609015622
  2020/12/26 20:47:03 :; iptables -t nat -A PROXY_INIT_OUTPUT -o lo -j RETURN -m comment --comment proxy-init/ignore-loopback/1609015622
  2020/12/26 20:47:03 :; iptables -t nat -A PROXY_INIT_OUTPUT -p tcp --match multiport --dports 25,443,587,3306,11211 -j RETURN -m comment --comment proxy-init/ignore-port-25,443,587,3306,11211/1609015622
  2020/12/26 20:47:03 :; iptables -t nat -A PROXY_INIT_OUTPUT -p tcp -j REDIRECT --to-port 4140 -m comment --comment proxy-init/redirect-all-outgoing-to-proxy-port/1609015622
  2020/12/26 20:47:03 :; iptables -t nat -A OUTPUT -j PROXY_INIT_OUTPUT -m comment --comment proxy-init/install-proxy-init-output/1609015622
  2020/12/26 20:47:03 
  
  2020/12/26 20:47:03 end state
  ------------------------------------------------------------
  2020/12/26 20:47:03 :; iptables-save
  2020/12/26 20:47:03 # Generated by iptables-save v1.8.2 on Sat Dec 26 20:47:03 2020
  *nat
  :PREROUTING ACCEPT [0:0]
  :INPUT ACCEPT [0:0]
  :OUTPUT ACCEPT [0:0]
  :POSTROUTING ACCEPT [0:0]
  :PROXY_INIT_OUTPUT - [0:0]
  :PROXY_INIT_REDIRECT - [0:0]
  -A PREROUTING -m comment --comment "proxy-init/install-proxy-init-prerouting/1609015622" -j PROXY_INIT_REDIRECT
  -A OUTPUT -m comment --comment "proxy-init/install-proxy-init-output/1609015622" -j PROXY_INIT_OUTPUT
  -A PROXY_INIT_OUTPUT ! -d 127.0.0.1/32 -o lo -m owner --uid-owner 2102 -m comment --comment "proxy-init/redirect-non-loopback-local-traffic/1609015622" -j PROXY_INIT_REDIRECT
  -A PROXY_INIT_OUTPUT -m owner --uid-owner 2102 -m comment --comment "proxy-init/ignore-proxy-user-id/1609015622" -j RETURN
  -A PROXY_INIT_OUTPUT -o lo -m comment --comment "proxy-init/ignore-loopback/1609015622" -j RETURN
  -A PROXY_INIT_OUTPUT -p tcp -m multiport --dports 25,443,587,3306,11211 -m comment --comment "proxy-init/ignore-port-25,443,587,3306,11211/1609015622" -j RETURN
  -A PROXY_INIT_OUTPUT -p tcp -m comment --comment "proxy-init/redirect-all-outgoing-to-proxy-port/1609015622" -j REDIRECT --to-ports 4140
  -A PROXY_INIT_REDIRECT -p tcp -m multiport --dports 4190,4191,25,443,587,3306,11211 -m comment --comment "proxy-init/ignore-port-4190,4191,25,443,587,3306,11211/1609015622" -j RETURN
  -A PROXY_INIT_REDIRECT -p tcp -m comment --comment "proxy-init/redirect-all-incoming-to-proxy-port/1609015622" -j REDIRECT --to-ports 4143
  COMMIT
  # Completed on Sat Dec 26 20:47:03 2020
  
  2020/12/26 20:47:03

I più grossi cambiamente sono al firewall. Ora controllo il proxy:

$ kubectl logs webserver-6b48fbfd5-jntzk linkerd-proxy
  time="2020-12-26T20:47:04Z" level=info msg="running version stable-2.9.1"
  [     0.001074s]  INFO ThreadId(01) linkerd2_proxy::rt: Using single-threaded proxy runtime
  [     0.001849s]  INFO ThreadId(01) linkerd2_proxy: Admin interface on 0.0.0.0:4191
  [     0.001989s]  INFO ThreadId(01) linkerd2_proxy: Inbound interface on 0.0.0.0:4143
  [     0.002001s]  INFO ThreadId(01) linkerd2_proxy: Outbound interface on 127.0.0.1:4140
  [     0.002006s]  INFO ThreadId(01) linkerd2_proxy: Tap interface on 0.0.0.0:4190
  [     0.002010s]  INFO ThreadId(01) linkerd2_proxy: Local identity is default.default.serviceaccount.identity.linkerd.cluster.local
  [     0.002019s]  INFO ThreadId(01) linkerd2_proxy: Identity verified via linkerd-identity-headless.linkerd.svc.cluster.local:8080 (linkerd-identity.linkerd.serviceaccount.identity.linkerd.cluster.local)
  [     0.002024s]  INFO ThreadId(01) linkerd2_proxy: Destinations resolved via linkerd-dst-headless.linkerd.svc.cluster.local:8086 (linkerd-destination.linkerd.serviceaccount.identity.linkerd.cluster.local)
  [     0.002356s]  INFO ThreadId(01) outbound: linkerd2_app: listen.addr=127.0.0.1:4140 ingress_mode=false
  [     0.002495s]  INFO ThreadId(01) inbound: linkerd2_app: listen.addr=0.0.0.0:4143
  [     0.058478s]  INFO ThreadId(02) daemon:identity: linkerd2_app: Certified identity: default.default.serviceaccount.identity.linkerd.cluster.local

E il mio service?

$ kubectl logs webserver-6b48fbfd5-jntzk webserver
  warn: Microsoft.AspNetCore.Server.Kestrel[0]
        Overriding address(es) 'http://+:5000, https://+:5001'. Binding to endpoints defined in UseKestrel() instead.
  info: Microsoft.Hosting.Lifetime[0]
        Now listening on: http://0.0.0.0:80
  info: Microsoft.Hosting.Lifetime[0]
        Application started. Press Ctrl+C to shut down.
  info: Microsoft.Hosting.Lifetime[0]
        Hosting environment: Production
  info: Microsoft.Hosting.Lifetime[0]
        Content root path: /app

E' il momento del client:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: webclient
  labels:
    app: webclient
spec:
  replicas: 1
  selector:
    matchLabels:
      app: webclient
  template:
    metadata:
      labels:
        app: webclient
      annotations:
        linkerd.io/inject: enabled
    spec:
      containers:
      - name: webclient
        image: sbraer/grpcclient:v1
        imagePullPolicy: IfNotPresent
        env:
        - name: SERVER_NAME
          value: "http://webserver.default.svc.cluster.local"

Anche qui linkerd.io/inject: enabled. Lo avvio:

$ kubectl apply -f client_with_linkerd.yaml 
deployment.apps/webclient created
$ kubectl get po
NAME                         READY   STATUS    RESTARTS   AGE
webclient-6f65f8d9c9-4pdb    2/2     Running   0          21s
webserver-6b48fbfd5-jntzk    2/2     Running   0          14m

Anche in questo caso i pod sono diventati due. Questa volta non andrò nei dettagli. Voglio solo vedere se funziona:

$ kubectl logs webclient-6f65f8d9c9-4pdb9 
error: a container name must be specified for pod webclient-6f65f8d9c9-4pdb9, choose one of: [webclient linkerd-proxy] or one of the init containers: [linkerd-init]
$ kubectl logs webclient-6f65f8d9c9-4pdb9 webclient
From service: 8f8f9962-b246-4ec2-8b90-eb1f4e7d5fa7 Message 1 1
From service: 8f8f9962-b246-4ec2-8b90-eb1f4e7d5fa7 Message 2 2
From service: 8f8f9962-b246-4ec2-8b90-eb1f4e7d5fa7 Message 3 3
From service: 8f8f9962-b246-4ec2-8b90-eb1f4e7d5fa7 Message 4 4
From service: 8f8f9962-b246-4ec2-8b90-eb1f4e7d5fa7 Message 5 5
From service: 8f8f9962-b246-4ec2-8b90-eb1f4e7d5fa7 Message 6 6
...

Funziona. Ma la connessione è protetta?

$ linkerd tap deployment/webclient --namespace default --to deployment/webserver   --to-namespace default --path /azgrpc.MyGrpcService/GetMessage
req id=2:0 proxy=out src=172.17.0.14:50250 dst=172.17.0.13:80 tls=true :method=POST :authority=webserver :path=/azgrpc.MyGrpcService/GetMessage
rsp id=2:0 proxy=out src=172.17.0.14:50250 dst=172.17.0.13:80 tls=true :status=200 latency=2396µs
end id=2:0 proxy=out src=172.17.0.14:50250 dst=172.17.0.13:80 tls=true grpc-status=OK duration=81µs response-length=59B
req id=2:1 proxy=out src=172.17.0.14:50260 dst=172.17.0.13:80 tls=true :method=POST :authority=webserver :path=/azgrpc.MyGrpcService/GetMessage
rsp id=2:1 proxy=out src=172.17.0.14:50260 dst=172.17.0.13:80 tls=true :status=200 latency=1455µs
end id=2:1 proxy=out src=172.17.0.14:50260 dst=172.17.0.13:80 tls=true grpc-status=OK duration=42µs response-length=59B
req id=2:2 proxy=out src=172.17.0.14:50268 dst=172.17.0.13:80 tls=true :method=POST :authority=webserver :path=/azgrpc.MyGrpcService/GetMessage
rsp id=2:2 proxy=out src=172.17.0.14:50268 dst=172.17.0.13:80 tls=true :status=200 latency=3118µs
end id=2:2 proxy=out src=172.17.0.14:50268 dst=172.17.0.13:80 tls=true grpc-status=OK duration=119µs response-length=59B
req id=2:3 proxy=out src=172.17.0.14:50274 dst=172.17.0.13:80 tls=true :method=POST :authority=webserver :path=/azgrpc.MyGrpcService/GetMessage

Il comando linkerd tap mostra le connessioni attive, qui sopra ho aggiunto il filtro per la connessione di mio interesse, altrimenti potevo vedere tutte le connessioni gestite da Linkerd con il comando generico, e più facile da ricordare, linkerd -n default tap deploy. Qui sopra, nelle risposte, è possibile vedere informazioni su tutte le connessioni gRPC del mio service, ed è presente la colonna tls=true che mi informa che la connessione è protetta.

Problema risolto. Codice più semplice sia a livello di software scritto, sia nella configurazione dei certificati all'interno di Kubernetes. Inoltre, in questo caso, i certificati si aggiornano senza nessun intervento umano. Un ultimo dettaglio molto interessante è la console che offre Linkerd. Da terminale, per lanciarla, si usa il comando:

linkerd dashboard

Che aprirà il browser:

Linkerd console home page

Andando nel namespace da me utilizzato - default - avrò i dettagli dei miei pod:

Linkerd console dettagli

E su tutte le loro connessioni:

Linkerd console dettagli connessioni

E' possibile vedere molte altre informazioni, come anche usare i comandi, come il tap usato prima, direttamente da console. Inoltre aggiunge anche Grafana per avere report dettagliati:

Grafana in Linkerd

C'è una feature interessante in Linkerd che però non è utilizzare con connessioni gRPC, il retry. Questa funzionalità permette, in caso di problemi di connessione o di errore del service, di ripetere in automatico la chiamata in modo trasparente dal client. Vediamolo velocemente visto che ho creato anche un service API REST per testare questo.

---
apiVersion: v1
kind: Service
metadata:
  name: httpserver
  annotations:
    linkerd.io/inject: enabled
spec:
  selector:
    app: httpserver
  ports:
  - protocol: TCP
    port: 80
    targetPort: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: httpserver
  labels:
    app: httpserver
spec:
  replicas: 1
  selector:
    matchLabels:
      app: httpserver
  template:
    metadata:
      labels:
        app: httpserver
      annotations:
        linkerd.io/inject: enabled
    spec:
      containers:
      - name: webserver
        image: sbraer/httpservice:v1
        imagePullPolicy: IfNotPresent
        env:
        - name: RANDOM_ERROR
          value: "true"

E' presente la environment variable RANDOM_ERROR per simulare un errore generico: su cinque richieste una restituirà l'errore HTTP 500. Lo avvio nel mio cluster:

$ kubectl apply -f server_http.yaml 
service/httpserver created
deployment.apps/httpserver created
$ kubectl logs httpserver-56b558996c-67s9h
error: a container name must be specified for pod httpserver-56b558996c-67s9h, choose one of: [webserver linkerd-proxy] or one of the init containers: [linkerd-init]
$ kubectl logs httpserver-56b558996c-67s9h webserver
info: Microsoft.Hosting.Lifetime[0]
      Now listening on: http://[::]:80
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /app

Controllando con il log questo http service funziona correttamente. Ora il client:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: httpclient
  labels:
    app: httpclient
spec:
  replicas: 1
  selector:
    matchLabels:
      app: httpclient
  template:
    metadata:
      labels:
        app: httpclient
      annotations:
        linkerd.io/inject: enabled
    spec:
      containers:
      - name: httpclient
        image: sbraer/httpclient:v1
        imagePullPolicy: IfNotPresent
        env:
        - name: SERVER_NAME
          value: "http://httpserver/weatherforecast"

E il log:

$ kubectl apply -f client_http.yaml 
deployment.apps/httpclient created
$ kubectl get po
NAME                          READY   STATUS    RESTARTS   AGE
httpclient-774fdcfbd7-kqcjz   2/2     Running   0          8s
httpserver-56b558996c-67s9h   2/2     Running   0          5m8s
$ kubectl logs httpclient-774fdcfbd7-kqcjz
error: a container name must be specified for pod httpclient-774fdcfbd7-kqcjz, choose one of: [httpclient linkerd-proxy] or one of the init containers: [linkerd-init]
$ kubectl logs httpclient-774fdcfbd7-kqcjz httpclient
serverName = http://httpserver/weatherforecast
0: The remote server returned an error: (500) Internal Server Error. - ERROR
1: 507 - OK
2: 500 - OK
3: 500 - OK
4: 504 - OK
5: The remote server returned an error: (500) Internal Server Error. - ERROR
6: 506 - OK
7: 500 - OK
8: 489 - OK
9: 503 - OK
10: The remote server returned an error: (500) Internal Server Error. - ERROR
11: 513 - OK
12: 508 - OK
13: 501 - OK
14: 502 - OK
15: The remote server returned an error: (500) Internal Server Error. - ERROR
16: 498 - OK
17: 499 - OK
18: 507 - OK
19: 502 - OK

Come previsto, ogni cinque chiamate una ritorna errore. Questa cosa è visibile anche dalla console e questo fa emergere immediatamente il problema:

Console error http server

Per attivare la funzionalità di retry di Linkerd:

apiVersion: linkerd.io/v1alpha2
kind: ServiceProfile
metadata:
  name: httpserver.default.svc.cluster.local
  namespace: default
spec:
  routes:
  - condition:
      method: GET
      pathRegex: /.*
    name: GET ALL
    isRetryable: true

Questa è la versione minimale della configurazione del retry. In name nel metadata ho inserito il dns name completo del service, e in routes si può specificare i singoli path e method per abilitare o meno il retry. Lo avvio:

$ kubectl apply -f server_http_retry.yaml 
serviceprofile.linkerd.io/httpserver.default.svc.cluster.local created
$ kubectl get ServiceProfile
NAME                                   AGE
httpserver.default.svc.cluster.local   18s
$ kubectl apply -f client_http.yaml 
deployment.apps/httpclient created
$ kubectl logs httpclient-774fdcfbd7-h22vx httpclient
serverName = http://httpserver/weatherforecast
0: 492 - OK
1: 502 - OK
2: 503 - OK
3: 493 - OK
4: 503 - OK
5: 496 - OK
6: 500 - OK
7: 506 - OK
8: 501 - OK
9: 497 - OK
10: 498 - OK
11: 510 - OK
12: 494 - OK
13: 498 - OK
14: 504 - OK
15: 504 - OK
16: 500 - OK
17: 498 - OK
18: 502 - OK

Ora al client le connessioni rispondono sempre correttamente grazie al retry autimatico di Linkerd. Con il comando linkerd routes si possono avere altri dettagli:

$ linkerd routes deploy/httpclient --to deploy/httpserver -o wide
ROUTE          SERVICE   EFFECTIVE_SUCCESS   EFFECTIVE_RPS   ACTUAL_SUCCESS   ACTUAL_RPS   LATENCY_P50   LATENCY_P95   LATENCY_P99
GET ALL     httpserver             100.00%          2.0rps           80.00%       2.5rps           1ms           3ms           4ms
[DEFAULT]   httpserver                   -               -                -            -             -             -             -

Le due colonne EFFECTIVE SUCCESS e ACTUAL SUCCES mostrano le informazioni previste. Ma ecco la nota negativa: purtroppo con le connessioni gRPC il retry non funziona (o sono io incapace di configurarlo, accetto consigli). A parte questo, fantastico tutto questo? Sì, ma con molta attenzione, perché solo alcune operazioni possono essere ripetute in moto automatico senza rischiare di corrompere i dati (in inglese API idempotency): un update, un delete o una select, anche se ripetuti, non rischiano di corrompere eventuali dati trattati, mentre un insert, se ripetuto per errore da Linkerd (in caso di timeout della richiesta, per esempio) comporterebbe l'inserimento di un doppio record.

E' ora di chiudere. Non ho aggiunto molte info per Linkerd perché le guide sul sito ufficiale sono molto esaustive. E, ovviamente, non ho voluto sbilanciarmi sulla mia preferenza tra queste due soluzioni. Infine, qui il codice sorgente delle quattro applicazioni, i file dei certificati creati e i file per Kubernetes.

Commenti

Visualizza/aggiungi commenti

| Condividi su: Twitter, Facebook, LinkedIn

Per inserire un commento, devi avere un account.

Fai il login e torna a questa pagina, oppure registrati alla nostra community.

Nella stessa categoria
I più letti del mese