Snaturare Kubernetes evitando i custom container Docker

di Andrea Zani, in .NET,

L'idea di questo post è nata dopo una discussione sui vantaggi e svantaggi nel mantenere un cluster Kubernetes per l'esecuzione di più servizi. In effetti non c'è nessuno che obbliga a questa scelta quando si utilizzano altri strumenti performanti e affidabili.

Forse sono partito male ed è meglio partire dall'inizio. Lo scenario è il seguente: dei servizi girano su alcuni server (alcuni di essi servizi scritti in Python) e vengono gestiti grazie ad Ansible. Semplificando il tutto, ad una macchina in cui gira Ansible sono collegati altri nodi su cui vengono installati ed eseguiti dei servizi proprio grazie ad Ansible. Ah dimenticavo la prima mia premessa: Ansible io lo conosco solo in teoria e non ho mai avuto la fortuna di poterci mettere le mani seriamente. Proseguo: da quel che ho capito, questi script in Ansible scaricano da un repository il codice sorgente sulla macchina interessata e ne lanciano l'esecuzione, fine.

La contestazione nell'uso di Kubernetes in questo scenario è la seguente: ma perché dovrei complicarmi la vita aggiungendo un ulteriore layer di complessità nel Deploy visto che si deve creare un container Docker prima di poterlo eseguire nel cluster?

Da questa semplice domanda sono seguite una lunga lista di problematiche annesse e investigazioni già fatte dall'autore di questa contestazione (al quale non ho potuto rispondere perché non conosco Python in ambito Docker). Uno dei punti fondamentali è la dimensione dell'immagine creata per ogni servizio scritto in Python che soffre dello stesso problema di .Net (Core): doversi trascinare dietro tutte le dipendenze che nel caso di Python è proprio il suo eseguibile e nel caso di .Net è tutto il Framework (tralasciando la possibilità di compilare entrambe in modo che possano essere eseguiti senza alcun supporto esterno). A parte eventuali trucchi che portano ad una dimensione ridotta dell'immagine ridotta, è il concetto di base dell'uso di Docker per ogni build che rendeva il contestatore contrario all'uso di strumenti come Kubernetes.

Seconda premessa: il continuo della discussione e questo post NON sono in alcun modo un motivo per convincere chicchessia della bontà di Kubernetes a confronto di altre soluzione (come Ansible, visto che l'ho tirato in ballo).

Ovviamente c'è un trucco che permette di eseguire qualsiasi codice senza dover creare immagini Docker, ed è molto semplice, e si basa, nel caso di Python, di scaricare da git, per esempio, il proprio codice e farlo girare in un container in cui è presente il comando Python. Fine. Così come per le applicazioni in C# con .Net: è sufficiente utilizzare l'immagine contenente l'SDK, scaricare il codice sorgente, compilarlo, ed eseguirlo. In caso di aggiornamento del codice sorgente, è sufficiente riavviare il Deploy in Kubernetes dell'applicazione e ritrovare in esecuzione l'ultima versione del nostro codice. Ovviamente in tutto questo ci sono sì dei vantaggi, ma pure degli svantaggi che tratterò poi. Parto con l'esempio in Python perché era il principale soggetto della discussione. Io utilizzerò il seguente codice che crea un web server in Python e mostra delle semplici pagine html:

# importazione delle librerie necessarie
import http.server
import socketserver
import os

PORT = 8000
ROOT_FOLDER = 'wwwroot'
web_dir = os.path.join(os.path.dirname(__file__), ROOT_FOLDER)
os.chdir(web_dir)

Handler = http.server.SimpleHTTPRequestHandler
httpd = socketserver.TCPServer(("", PORT), Handler)
print("serving at port", PORT)
httpd.serve_forever()

Maggiori info qui. Ora che ho il web server devo pubblicarlo con Kubernetis. Non creerò l'immagine contenente questo codice ma utilizzerò python:3.8-slim-buster come immagine nel container in cui lo eseguirò. A questo punto il problema è come fare trovare il codice qui sopra all'interno di questo container. In aiuto vengono gli initContainers. Prima dell'avvio del/dei container del Pod, è possibile avviare uno o più container che, con il Pod principale, condivideranno tutto, compreso un eventuale volume collegato e, finito il loro compito, si cancellano liberando risorse del cluster. Per la mia necessità creerò un initContainer che scaricherà il codice sorgente qui sopra e lo preparerà per il container principale nel Pod che penserà alla sua esecuzione. Ecco qui un Deployment di Kubernetes che fa quanto scritto finora:

apiVersion: v1
kind: Service
metadata:
  name: pythonservice2
spec:
  selector:
    app: pythonweb2
  ports:
  - protocol: TCP
    port: 8084
    targetPort: 8000
  type: LoadBalancer
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pythonweb2
  labels:
    app: pythonweb2
spec:
  replicas: 1
  selector:
    matchLabels:
      app: pythonweb2
  template:
    metadata:
      labels:
        app: pythonweb2
    spec:
      volumes:
      - name: data
        emptyDir: {}
      containers:
      - name: run
        image: python:3.8-slim-buster
        imagePullPolicy: IfNotPresent
        tty: true
        command: ["sh", "-c"]
        args: 
        - echo Start1;
          cd /app;
          python server.py
        readinessProbe:
          httpGet:
            path: /
            port: 8000
          periodSeconds: 10
          initialDelaySeconds: 3
        volumeMounts:
        - name: data
          mountPath: /app
      # These containers are run during pod initialization
      initContainers:
      - name: downloadcode
        image: bitnami/git:2.34.1
        imagePullPolicy: IfNotPresent
        command: ["sh", "-c"]
        args: 
        - echo Start;
          mkdir /apptmp;
          cd /apptmp;
          git clone https://github.com/sbraer/k8sdeploy.git;
          cd k8sdeploy/mypython;
          cp -Rf . /app;
        volumeMounts:
        - name: data
          mountPath: /app

In initContainer ho caricato un'immagine con git che mi permettesse di scaricare il codice sorgente, e nel Pod principale l'immagine con Python; entrambi questi container condividevano un Volume di tipo emptyDir (volume temporaneo che viene cancellato alla distruzione del Pod). Il container che scarica il codice sorgente lo inserisce in questo volume che sarà poi visto e avviato dal Container principale.

Avviato con il classico comando:

kubectl apply -f python-webapp.yaml

Atteso il tempo di scaricare le immagini e avviare il tutto sarà possibile vedere il risultato da browser con l'url: http://localhost:8084. L'esempio presente in questo post e tutti i riferimenti qui fatti sono frutto dei test con l'uso di Kubernetes presente nell'installazione standard di Docker in Windows 10. Se si usano altri cluster, verificare che il service utilizzato, LoadBalancer, abbia assegnato correttamente l'external IP:

kubectl get svc

Altrimenti, verificato il nome del Pod in cui gira Python, è possibile fare un port-forward diretto:

kubectl get po
kubectl port-forward nomepo 8080

Se modificassi il file python-example.yaml, per esempio cambiassi la stringa di output echo Start1, e eseguito nuovamente il comando:

kubectl apply -f python-webapp.yaml

Un nuovo Pod sarà creato contenente l'ultima versione del codice, e quando tutto è pronto, il vecchio sarà cancellato. Con questo semplice trucco ho tolto di mezzo un attore nel Deploy di una nuova versione del codice: la build dell'immagine. Ma alla fine è un vantaggio? L'unico inconveniente nella creazione di un'immagine Docker è il suo tempo per la creazione; per il resto del Deploy, è solo un vantaggio. Alla fine solo la prima volta l'immagine creata in Docker potrebbe occupare anche parecchi megabyte, ma ai successi download per aggiornamenti al codice, solo il layer contenente le modifiche (solitamente pochissimi megabyte), saranno scaricati. L'utilizzo di questo trucco, invece, permette sì di evitare questo step del Deployment, ma comporta una certa lentezza della creazione e aggiornamento del Pod con il codice aggiornato (di seguito mostrerò lo stesso esempio con un'applicazione in Net6) che nel caso di altri linguaggi e tecnologie, come il Net Core, necessita di compilazione. Il contestatore non potrebbe essere contento di questa soluzione? Non so... pazienza.

Complichiamo un po' la cosa. Ora utilizzerò una piccola applicazione scritta in Net6 in un repository su Github privato, inoltre il codice dovrà essere compilato, e per rendere il tutto più difficile non userò l'user root in Docker come da default, ma un utente con permessi limitati - come si dovrebbe sempre fare. Primo passaggio: configurare il repository su Github in modo che possa permettere il download solo chi è autorizzato. Tralasciando la scelta delle credenziali pure (username e password), è possibile usare le Access Key con permessi di sola lettura. Prima di tutto sarà necessario create una ssh key. Se si utilizza Windows il modo più semplice è usare Docker con un container Linux:

$ docker run --rm -it bitnami/git:2.34.1 bin/bash
root@2013cebbc2d0:/# ssh-keygen -b 4096
Generating public/private rsa key pair.
Enter file in which to save the key (/root/.ssh/id_rsa): 
Created directory '/root/.ssh'.
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /root/.ssh/id_rsa.
Your public key has been saved in /root/.ssh/id_rsa.pub.
The key fingerprint is:
SHA256:geXu7kM+tCfFK4os65A8jQWsgvDxWIforSguSwkzJXg root@2013cebbc2d0
The key's randomart image is:
+---[RSA 4096]----+
|        .        |
|o  . . +         |
|+oE o o o        |
|+*.* . . .       |
|B +.o   S.       |
|+==.   .o o      |
|=*..   +.o .     |
|=.o. . .B o      |
|o+ooo .oo*       |
+----[SHA256]-----+

Ho lasciato i valori di default e NON ho creato la passphrase. Ho lasciato che i file venissero creati nella directory di default ed ora ho bisogno della chiave pubblica che prendo con:

cat /root/.ssh/id_rsa.pub
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC1mNPK/Zr+dLikf0Lu471ZMzR2IvSisRWzYGtMDEHz0Hy72snvh/7vCFV8SFIE6xBmZmkCAx9aDJx0Eom+kDqYfI5dwDTVxG97Ix8IZ4gPXrILMJnYox4kWy/iBY1WTqU6+Q7wCe8UMavfYVs+yqPefFvW/fWiVRyoTsN3lHELTBsUHCR43HY5OTOu84Q/Fnn+J5QVhkZ/RUUA1R+xv7dIKRTa1wHPiqurPMCqdVR1Wii5LKxK1vO2f/RTydtDwkEWXoR6TXkgAD4iVDIGDES4ftVRkTRfa6fOagpX+e/4avJb9FOrQkntO2Au9bHP0Mn+sy7jW4BDtMzFlfn+y0MSppAg/QrgxJGHWIRwVoGvZvJgfLdQ44pi0xOYizCJe2S7xYQG9Qj1wKybEHbljMVsgWMHKJUu8ayO9cD1Q+YvTGJ9S408+DI3FGmgPh2lVmLtFh63rxV1rqwH7/dj5vH0WC1WQYMTtJPr4buZu/SrwrFfSYRgl4i6dvAPZfyq9WDAtFxCeLMGmtwCSo2LANdlJlAG9TBVQpZyb3Uqt9BLsu/Z+KAacKadVb1bNWL6tb+jnNXFA/ptJ0NFQWImkVwGXhLrnhVWLJPRtvQEn8qhwoUo9AyW5IAJl/qImGnoBp61sNn84BoBe1vUzS0KzM5Z/alkBdRjj03pFN2OYtOr7Q== root@2013cebbc2d0

Lasciando aperto questo container Docker, vado nella pagina in Github con il mio codice, quindi vado in Settings -> Deploy Keys -> Add Deploy Key:

Qui copio la stringa presa dal container Docker. Piccola nota, anche in Bitbucket è presente questa feature sotto Access Keys.

Ora ho bisogno della chiave privata che dovrò inserire in un Secret in Kubernetes in base64:

root@2013cebbc2d0:/# cat /root/.ssh/id_rsa | base64 -w 0
LS0tLS1CRUdJTiBPUEVOU1NIIFBSSVZBVEU...IIFBSSVZBVEUgS0VZLS0tLS0K

Ora definisco il Secret in Kubernetes: 

apiVersion: v1
kind: Secret
metadata:
  name: keyforgit
type: Opaque
data:
  id_rsa: LS0tLS1CRUdJTiBPUEVOU1NIIFBSSVZBVEU...IIFBSSVZBVEUgS0VZLS0tLS0K
  config: SG9zdCAqCiAgIFN0cmljdEhvc3RLZXlDaGVja2luZyBubw==

Oltre al file id_rsa ho inserito anche il file config che ha questo contenuto (il cui contenuto decodificato):

Host *
   StrictHostKeyChecking no

Per evitare che al momento del clone della soluzione venga richiesta la conferma per la chiave a cui non è possibile rispondere.

Prima di mostrare il file yaml completo per il Deploy della applicazione in Net6, un piccolo dettaglio di cui ci si deve occupare. Come scritto sopra ho deciso di eseguire questa applicazione con un utente con permessi limitati per maggiore sicurezza. Di default Docker avvia i suoi container con l'utente root con cui si può fare tutto, pure i danni. Sarebbe buona regola evitare questa cosa. Questo fa nascere un problema nei permessi dei file id_rsa e config visti prima che bloccheranno il loro uso. La soluzione è semplice: sempre da initContainer avvio un container con i permessi maggiori che, montato un volume con il contenuto di quel Secret, copia della giusta folder questi file assegnandone i giusti permessi (i file sono montati in /keys):

cp -Rf /keys/ /root/.ssh/;
chmod 400 /root/.ssh/id_rsa;
chmod 400 /root/.ssh/config;
mkdir /app/apptmp/;
cd /app/apptmp/;
git clone git@github.com:sbraer/k8sdeploy.git;

Risolto questo problema sono pronto al deploy:

apiVersion: v1
kind: Secret
metadata:
  name: keyforgit
type: Opaque
data:
  id_rsa: LS0tLS1CRUdJTiBPUEVOU...U1NIIFBSSVZBVEUgS0VZLS0tLS0K
  config: SG9zdCAqCiAgIFN0cmljdEhvc3RLZXlDaGVja2luZyBubw==
---
apiVersion: v1
kind: Service
metadata:
  name: minimalapiservice
spec:
  selector:
    app: minimalapiweb
  ports:
  - protocol: TCP
    port: 8082
    targetPort: 3000
  type: LoadBalancer
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: minimalapiweb
  labels:
    app: minimalapiweb
spec:
  replicas: 1
  selector:
    matchLabels:
      app: minimalapiweb
  template:
    metadata:
      labels:
        app: minimalapiweb
    spec:
      securityContext:
        fsGroup: 2000
      volumes:
      - name: gitlogin
        secret:
          secretName: keyforgit
          defaultMode: 256
      - name: data
        emptyDir: {}
      containers:
      - name: run
        image: mcr.microsoft.com/dotnet/aspnet:6.0-alpine
        imagePullPolicy: IfNotPresent
        securityContext:
          runAsUser: 1000
          runAsGroup: 3000
        command: ["sh", "-c"]
        args: 
        - echo Start 1;
          ls -R /app;
          cd /app/out;
          ./MinimalApiTestHost;
        readinessProbe:
          httpGet:
            path: /
            port: 3000
          periodSeconds: 10
          initialDelaySeconds: 3
        volumeMounts:
        - name: data
          mountPath: /app
          readOnly: true
      # These containers are run during pod initialization
      initContainers:
      - name: downloadcode
        image: bitnami/git:2.34.1
        imagePullPolicy: IfNotPresent
        command: ["sh", "-c"]
        args: 
        - echo Start1;
          cp -Rf /keys/ /root/.ssh/;
          chmod 400 /root/.ssh/id_rsa;
          chmod 400 /root/.ssh/config;
          mkdir /app/apptmp/;
          cd /app/apptmp/;
          git clone git@github.com:sbraer/k8sdeploy.git;
        volumeMounts:
        - name: gitlogin
          mountPath: /keys
        - name: data
          mountPath: /app
      - name: build
        image: mcr.microsoft.com/dotnet/sdk:6.0-alpine
        imagePullPolicy: IfNotPresent
        securityContext:
          runAsUser: 0 # to use chmod
          runAsGroup: 0 #
        command: ["sh", "-c"]
        args: 
        - echo Start1;
          cd /app/apptmp/;
          cd k8sdeploy/MinimalApiTestHost/;
          dotnet build -c Release -o /app/out;
          cd /app/out;
          rm -Rf /app/apptmp/;
        volumeMounts:
        - name: data
          mountPath: /app

Sono presenti due container nella sezione initContainer, il primo - downloadcode - che con il codice visto prima, esegue il clone del repository da Github. Quindi build che compilerà il mio codice e lo copierà nella directory /app/out:

cd /app/apptmp/;
cd k8sdeploy/MinimalApiTestHost/;
dotnet build -c Release -o /app/out;
cd /app/out;
rm -Rf /app/apptmp/;

Alla fine il container principale che userà come immagine mcr.microsoft.com/dotnet/aspnet:6.0-alpine (non l'SDK), avvierà la web app:

cd /app/out;
./MinimalApiTestHost;

Il codice di questa we bapp? Molto semplice:

using System.Net;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => $"Hello, World! v1.0: {Dns.GetHostName()}");
app.Run("http://*:3000");

Restituisce il classico messaggio Hello, World! e il nome dell'Host, che in questo caso è il nome del container in cui gira l'applicazione.

Eccomi ai commenti finali. Questo trucco funziona ed evita la creazione dell'immagine Docker e può andare bene come il test che ho presentato in questo post. Ho fatto delle prove creando decine di Pod in questo modo, e i tempi di Deploy si sono allungati, ovviamente. Inoltre la creazione dei Container per la compilazione ha fatto salire notevolmente il consumo delle risorse nel cluster, problema risolvibile con il RollingUpdate anche se con tempi ben superiori per l'update.

Alla fine questo post non voleva convincere nessuno. E' solo stato un altro mio divertissement.

Qui il codice. Per la creazione nel cluster Kubernetes l'applicazione in Python:

kubectl create -f python-webapp.yaml

Questa è per l'applicazione in Net6; comprende pure la configurazione per l'access Key in Github come esempio, anche se ora il codice è pubblico.

kubectl create -f minimalapi.yaml
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