Provando Kaniko in Kubernetes come alternativa a Docker per la creazione di immagini

di Andrea Zani, in .NET,

Dalla versione 1.24 Kubernetes darà l'addio ufficiale a Docker - o per meglio dire, a Dockershim. Alla fine non è un problema grave perché le immagini create con Docker continueranno ad essere compatibili grazie all'adozione dello standard CRI. Il vero problema sarà solo per chi amministra un cluster o se si utilizza Docker direttamente da Kubernetes per alcune operazioni particolari, come la creazione di immagini. Se, come molti, si utilizzano servizi già belli pronti come quelli nei vari cloud - AWS, Azure, Linode, etc... - e si evita l'utilizzo diretto di Docker, non ci dovrebbero essere problemi... spero...

In quest'ultimo post dell'anno, per mia curiosità e proprio per mie necessità particolari, scriverò qualche nota sull'utilizzo di mezzi alternativi per la creazione di immagini Docker all'interno di un cluster Kubernetes. In questo post tratterò Kaniko, uno dei progetti più maturi per l'utilizzo con Kubernetes di un'alternativa a Docker - può darsi che in futuro metterò mano ad altre alternative, ma tutto dipenderà dal tempo e dalla voglia.

Per mettere alla prova Kaniko realizzerò un semplice CI/CD in cui l'obbiettivo finale è avviare un'istanza del buon vecchio e affidabile Jenkins e utilizzando la sua pipeline:

  • Scaricare da github un progetto web in Net 6.
  • Compilarlo.
  • Creare un'immagine Docker.
  • Fare l'upload dell'immagine sul Docker hub repository.
  • Avviare un Deploy all'interno del cluster per rendere disponibile l'applicazione web da browser.

Tutto questo senza usare Docker, naturalmente.

Inizio installando/avviando un'istanza di Jenkins all'interno del cluster. Ecco il file yaml che crea il Deployment e il Load Balancer:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: jenkins
spec:
  replicas: 1
  selector:
    matchLabels:
      app: jenkins
  template:
    metadata:
      labels:
        app: jenkins
    spec:
      securityContext:
        fsGroup: 1000 
        runAsUser: 1000
        fsGroupChangePolicy: OnRootMismatch
      serviceAccountName: sa-jenkins-cluster
      containers:
      - name: jenkins
        image: jenkins/jenkins:lts-jdk11
        ports:
        - containerPort: 8080
          name: httpx
        - containerPort: 50000
          name: tcpx
        volumeMounts:
        - name: my-pv-storage
          mountPath: /var/jenkins_home
      volumes:
      - name: my-pv-storage
        persistentVolumeClaim:
          claimName: my-pv-claim
---
apiVersion: v1
kind: Service
metadata:
  name: my-service
spec:
  selector:
    app: jenkins
  ports:
    - protocol: TCP
      name: html
      port: 8080
      targetPort: 8080
    - protocol: TCP
      name: tcp
      port: 50000
      targetPort: 50000
  type: LoadBalancer
  #externalIPs: # <- per minikube
  #- 192.168.49.2 # <- per minikune. Ip = minikube ip dal terminale

In Jenkins utilizzerò un plugin apposito per accedere a Kubernetes. Se questo viene fatto con Windows Docker il tutto funzionerà senza problemi ma in altri cluster, come già scritto, porterà a strani errori sui permessi etc... Per fare le cose per bene, come scritto in questo post, mi creo un Service Account (sa-jenkins-cluster come definito nel Deployment precedente) con le Role apposite, in modo che dal Pod dove girerà Jenkins, sarà possibile estrarre le informazioni volute. Tra le Role ho aggiunto la possibilità di gestire completamente i Pod ("*" nei verbs) la possibilità di accedere ai namespace e pod/exec per l'esecuzione del codice all'interno dei Pod:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: sa-jenkins-cluster
  namespace: default

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: sa-pods-role
rules:
- apiGroups: [""]
  resources: ["namespaces"]
  verbs: ["get", "list"]
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["*"]
- apiGroups: [""]
  resources: ["pods/exec"]
  verbs: ["create","get"]

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: allow-cluster-info
subjects:
- kind: ServiceAccount
  name: sa-jenkins-cluster
  namespace: default
roleRef:
  kind: ClusterRole
  name: sa-pods-role
  apiGroup: rbac.authorization.k8s.io

Fatto questo, e atteso l'avvio di Jenkins, vado all'url:

http://localhost:8080

Dove viene chiesta la key per sbloccare Jenkins che recupero guardando nel log con il comando:

$ kubectl get po
NAME                      READY   STATUS    RESTARTS   AGE
jenkins-97456b8c4-x4nwm   1/1     Running   0          5s
$ kubectl logs jenkins-97456b8c4-x4nwm
...
*************************************************************
*************************************************************
*************************************************************

Jenkins initial setup is required. An admin user has been created and a password generated.
Please use the following password to proceed to installation:

f63c0c61b1bf4941b5cfba55d11ef27f

This may also be found at: /var/jenkins_home/secrets/initialAdminPassword

*************************************************************
*************************************************************
*************************************************************

Oppure:

kubectl exec jenkins-97456b8c4-x4nwm -- cat /var/jenkins_home/secrets/initialAdminPassword

Quindi lascio le impostazioni di base fino alla home page. Se è utilizzato un cluster Kubernetes in qualche Cloud, a questo punto solitamente appare un errore quando si crea l'utente principale o più avanti quando si installano i plugin, come il seguente:

WARNING  hudson.security.csrf.CrumbFilter#doFilter: Found invalid crumb c6d93c8e8c56a10606697b90f1147af3b8e50cfbb064b4b4711aa15d28292283. If you are calling this URL with a script, please use the API Token instead. More information: https://www.jenkins.io/redirect/crumb-cannot-be-used-for-script
WARNING  hudson.security.csrf.CrumbFilter#doFilter: No valid crumb was included in request for /setupWizard/createAdminUser by admin. Returning 403. 

Il modo più rapido per risolvere e saltare la fase di creazione dell'utente e andare in Manage Jenkins -> Configure Global Security. Quindi cercare la sezione CSRF Protection, e mettere il Checkbox alla voce Enable proxy compatibility. Ora è possibile creare l'utente con la password - Home -> People -> admin -> configure -> Inserire la password - e riavviare Jenkins aggiungendo /restart all'url.

Passo successivo è installare il plugin Kubernetes. In Manage Jenkins c'è l'opzione apposita per la gestione dei Plugin, quindi cercare Kubernetes e installarlo (sarà necessario un altro riavvio di Jenkins).

Ora è il momento della configurazione dei plugin di Jenkins. Selezionando: Manage Jenkins -> Manage nodes and cloud -> Configure Clouds add new cloud -> e in Detail selezionare Kubernetes. Nalla pagina alla textbox per Kubernetes URL si può lasciare vuoto visto che Jenkins gira all'interno di Kubernetes; Namespace: default (perché userò questo namespace). In Jenkins url si deve inserire il DNS esterno del Load Balancer, ma se si usa un cluster in locale si deve inserire il nome del service, nel mio caso: http://my-service:8080. In Jenkins Tunnel, ancora il nome del servizio ma con la porta specifica: my-service:50000. In questa pagina è inoltre possibile configurare i Pod che dovranno essere utilizzati, ma essendo possibile farlo direttamente dal file di configurazione che daremo in pasto alla Pipeline, non tocco nulla.

Alla fine posso cliccare su Test per controllare che Jenkins si connetta correttamente con il cluster di Kubernetes. Per avere la prova definitiva che tutto funzioni è sufficiente creare una nuova Pipeline dalla home page di Jenkins e inserire un esempio base che Jenkins mostra tra le opzioni nella creazione della Pipeline:

Se tutto funziona si dovrebbe vedere l'esito positivo dell'elaborazione di Jenkins e nel log il nome dell'host che ha elaborato la richiesta, inoltre controllando i Pod nel cluster di Kubernetes si dovrebbe trovarne uno nuovo con due container avviati al suo interno che sono stati utilizzati per l'elaborazione. In caso di errore verificare se il plugin può connettersi a Kubernetes e, sempre nel pannello di controllo del plugin, se l'url di Jenkins è corretto (solitamente è uno di questi casi porta al fallimento dell'esecuzione). Solo quando tutto funziona si può passare ai passi successivi.

E' ora di parlare del progetto d'esempio asp.net. Grazie alle minimal API il codice di esempio si riduce a questo:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello, World! v1.0");
app.Run();

Nei file della soluzione inserisco il Jenkinsfile con la pipeline che Jenkins dovrà eseguire una volta che si sarà scaricato da GitHub questo codice:

pipeline {
  agent {
    kubernetes {
      label 'az-app2'  // all your pods will be named with this prefix, followed by a unique id
      idleMinutes 5  // how long the pod will live after no jobs have run on it
      yamlFile 'build-pod.yaml'  // path to the pod definition relative to the root of our project 
      defaultContainer 'kaniko'  // define a default container if more than a few stages use it, will default to jnlp container
    }
  }
  stages {
    stage('Build dotnet solution') {
      ...
    }

    stage('Kaniko create and push image') {
      ...
    }

    stage('Test Kubectl') {
      ...
    }
  }
}

In agent ho specificato i dettagli per la creazione dei Pod che eseguiranno la Build e il Deploy del mio codice - maggiori info qui. Oltre all'immagine predefinita da eseguire nei vari Stage, utile è idleMinutes, che imposta quanti minuti dovrà rimanere avviato il Pod e i suoi container prima che vengano cancellati in automatico (avere già attivo questo Pod velocizza tutta la Pipeline). Importante è yamFile che rimanda ad un file con la configurazione di un Pod e dei container che userò:

apiVersion: v1
kind: Pod
metadata:
  name: kaniko
spec:
  serviceAccountName: crd-jenkins-account
  containers:
  - name: dotnet6
    image: mcr.microsoft.com/dotnet/sdk:6.0-alpine
    command: ["sleep", "infinity"]
    tty: true    
  - name: kubectl
    image: atlassian/pipelines-kubectl:1.22.3
    command: ["sleep", "infinity"]
    tty: true    
  - name: kaniko
    image: gcr.io/kaniko-project/executor:debug
    command: ["sleep", "infinity"]
    tty: true
    volumeMounts:
      - name: kaniko-secret
        mountPath: /kaniko/.docker
  volumes:
    - name: kaniko-secret
      secret:
        secretName: regcred
        items:
          - key: .dockerconfigjson
            path: config.json

Ho inserito tre container:

  • dotnet6: immagine docker con l'SDK di Net6 (la userò per la compilazione del mio codice).
  • kubectl: immagine con che dà la possibilità di usare, tra gli altri, il comando kubectl; io la userò per il Deploy della mia web application all'interno del mio cluster.
  • kaniko: immagine che permette la creazione dell'immagine Docker alternativa all'uso diretto di Docker.

Nel Jenkinsfile ho definito tre Stage che utilizzeranno queste immagini. Il primo è per la compilazione del codice:

      steps {
        container('dotnet6') {
          script {
            sh '''
            dotnet publish MinimalApi/MinimalApi.csproj -p:PublishSingleFile=true -r \
            linux-musl-x64 -c Release --self-contained true -p:EnableCompressionInSingleFile=true \
            -p:PublishTrimmed=true  -o out
            '''
          }
        }
      }

Qui in container specifico quale container usare - l'immagine con Net 6 SDK - quindi con il publish compilo e creo un unico file di output nella directory out.

      steps {
        container('kaniko') {
          script {
            sh '''
            /kaniko/executor --dockerfile `pwd`/Dockerfile3 \
                             --context `pwd` \
                             --destination=sbraer/minimalapi:${BUILD_NUMBER} \
                             --cache=true \
                             --cache-copy-layers=true \
                             --cache-repo=sbraer/kaniko-cache
            '''
          }
        }

In questo secondo step utilizzo l'immagine di Kaniko per la creazione dell'immagine di Docker. Avendo già fatto la compilazione nello stage precedente qui devo solo preparare l'immagine finale:

FROM mcr.microsoft.com/dotnet/runtime-deps:6.0-alpine3.14-amd64
WORKDIR /app
LABEL maintainer="az"
ENV ASPNETCORE_Environment=Production \
  ASPNETCORE_URLS="http://+:8080" \
  DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 \
  DOTNET_RUNNING_IN_CONTAINER=true
EXPOSE 8080/tcp

COPY /out ./
RUN addgroup -g 34000 az
RUN adduser -D -G az --uid 34000 az
RUN chown -R az:az /app
USER az
ENTRYPOINT ["./MinimalApi"]

Durante la creazione è necessario specificare il repository di destinazione dove sarà fatto l'upload del risultato finale. Maggiori info qui.

Ultimo passo:

      steps {
        container('kubectl') {
          script {
            sh 'sed -i "s/sed -i "s/<TAG>/${BUILD_NUMBER}/" minimalapi.yaml'
            sh "kubectl apply -f minimalapi.yaml"
          }
        }
      }

Con il container apposito viene eseguito il comando kubectl per il Deploy della mia soluzione. Sarà inoltre preso il file minimalapi.yaml e sostituito il tag con quello utilizzato per la creazione dell'immagine permettendo l'aggiornamento dell'applicazione eventualmente già avviata (trucco imparato qui).

apiVersion: apps/v1
kind: Deployment
metadata:
  name: minimalapi
spec:
  selector:
    matchLabels:
      app: minimalapi
  replicas: 1
  template:
    metadata:
      labels:
        app: minimalapi
    spec:
      containers:
      - name: minimalapi
        image: sbraer/minimalapi:<TAG>
        ports:
        - containerPort: 80
        livenessProbe:
          httpGet:
            path: /hello
            port: 80
          initialDelaySeconds: 5
          periodSeconds: 3
        readinessProbe:
          httpGet:
            path: /hello
            port: 80
          periodSeconds: 10
          initialDelaySeconds: 5
---
apiVersion: v1
kind: Service
metadata:
  name: my-lb-service-minimalapi
spec:
  type: LoadBalancer
  selector:
    app: minimalapi
  ports:
  - protocol: TCP
    port: 8082
    targetPort: 80

E' presente anche il service LoadBalancer per renderlo disponibile alla porta 8082 (solo per finezza ho inserito anche il livenessProbe e readinessProbe per verificare il corretto avvio della web application e per verificare il suo corretto funzionamento). Anche qui si può notare l'uso di un Service Account custom: sa-jenkins-account che sarà utilizzato per la creazione e aggiornamento dei Deployment:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: sa-jenkins-account
  namespace: default

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: sa-deployment-role
rules:
- apiGroups: ["", "apps"]
  resources: ["deployments"]
  verbs: ["get", "patch", "create", "update"]
- apiGroups: [""]
  resources: ["services"]
  verbs: ["get", "patch", "create", "update"]

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: allow-deployment
subjects:
- kind: ServiceAccount
  name: sa-jenkins-account
  namespace: default
roleRef:
  kind: ClusterRole
  name: sa-deployment-role
  apiGroup: rbac.authorization.k8s.io

E dopo aver scritto fin troppo è ora di trattare l'argomento Kaniko. Quest'immagine viene utilizzata per la creazione di nuove immagini Docker senza le dipendenze che necessita lo strumento di base. Come da documentazione e da esempi che si possono trovare in rete, per compilare un'immagine è sufficiente usare questo comando:

/kaniko/executor --dockerfile `pwd`/Dockerfile \
                 --context `pwd` \
                 --destination=sbraer/minimalapi:${BUILD_NUMBER} \
                 --cache=true \
                 --cache-copy-layers=true \
                 --cache-repo=sbraer/kaniko-cache

Gli ultimi tre parametri sono opzionali. Il primo specifica il nome del Dockerfile da utilizzare per la creazione dell'immagine; context è il path dove salvare le informazioni durante la creazione e in destination è il nome dell'immagine finale (oppure si può creare un file tar, oppure inviare l'immagine sul cloud...). Su quest'ultimo parametro è meglio subito specificare che l'immagine non viene creata e salvata il locale così come fa il comando Docker build, ma invia all'hub specifico l'immagine creata (nel mio caso l'hub ufficiale di Docker). Leggendo la documentazione e da prove personali non ho compreso completamente il funzionamento della cache di Kaniko. Come da comando di esempio sopra, ho abilitato la cache ed ho definito un Repository apposito dove salvare la cache. Qui saranno salvati da Kaniko i vari layer intermedi che saranno utilizzati per la creazione dell'immagine finale. Una volta richiesta una nuova compilazione dell'immagine, Kaniko andrà a riprendere questi layer da quel repository senza doverli ricreare. In ogni caso è presente anche una cache locale utile perché velocizza notevolmente la creazione finale, ma il parametro in cui si definisce il path di questa cache sembra non venga riconosciuto (almeno io non sono stato in grado di trovare i layer creati dalla mia precedente build nel Path da me definita). Se si fanno prove locali è meglio avere una buona connessione per non attendere molti minuti il download continuo dei layer utilizzati. Questo è anche uno dei motivi per cui ho preferito creare uno Stage apposito in Jenkins per la Build e il Publish della mia applicazione in Net 6 evitando la build multi stage nel Dockerfile.

Come appena scritto, Kaniko invia l'immagine creata nel repository finale (così come i layer intermedi se abilitati). Ovviamente si devono inserire le credenziali per potere dagli i permessi di scrittura nel proprio account. Per fare questo, come da documentazione, è necessario creare un secret in Kubernetes con il comando:

kubectl create secret docker-registry regcred \
        --docker-server=https://index.docker.io/v1/ \
        --docker-username=USERNAME \
        --docker-password=PASSWORD_OR_TOKEN \
        --docker-email=EMAIL_ACCOUNT

Quindi il Pod addetto all'elaborazione delle pipeline dovrà essere configurato con il volume aggiuntivo con questo Secret (come visto nella configurazione precedente riguardante la configurazione).

Nel repository con il codice sorgente ho incluso i file di configurazione per Kurbenetes per la creazione delle varie risorse, nella directory K8s. Se si vuole avviare il tutto fare le dovute modifiche al repository Docker visto che nel codice ho inserito direttamente il mio su cui non sarà possibile fare l'upload delle immagini create.

Avviato Kubernetes lanciare il seguente comando per la creazione del primo Service Account utilizzato da Jenkins:

kubectl create -f sa-jenkins-cluster.yaml

Questo per il Service Account dedicato ai Pod della pipeline:

kubectl create -f sa-jenkins-account.yaml

Se non è già stato creato, ecco il PersistentVolumeClaim per la richiesta di un disco che sarà utilizzato da Jenkins:

kubectl create -f pvc.yaml

Ora avvio Jenkins:

kubectl create -f jenkins1.yaml

Ritornando a Jenkins è il momento di provare se tutto funziona. E' sufficiente creare una nuova Pipeline e dagli come input l'url di Git con il codice sorgente:

Specificare anche in Script Path il nome corretto del file, nel mio caso Jenkinsfile.txt.

Tornando nella home page posso lanciare questa Pipeline e verificare che tutto funzioni. Il primo passo da verificare è che sia creato il Pod aggiuntivo per l'elaborazione:

>kubectl get po
NAME                        READY   STATUS    RESTARTS   AGE
jenkins-7757669cc7-2h7n2    1/1     Running   0          13m
test1-1-1l3kr-2rft7-0crhm   4/4     Running   0          4s

Dopo alcuni minuti:

Ultimo test:

>kubectl get po
NAME                         READY   STATUS    RESTARTS   AGE
jenkins-7757669cc7-2h7n2     1/1     Running   0          19m
minimalapi-5ddc9c8db-pcqkj   1/1     Running   0          2m40s
test1-1-1l3kr-2rft7-0crhm    4/4     Running   0          6m34s

>kubectl get svc
NAME                       TYPE           CLUSTER-IP       EXTERNAL-IP   PORT(S)                          AGE
my-lb-service-minimalapi   LoadBalancer   10.111.170.255   localhost     8082:31896/TCP                   2m47s
my-service                 LoadBalancer   10.98.226.143    localhost     8080:32734/TCP,50000:30392/TCP   19m

>curl localhost:8082
Hello, World! v1.0

Ovviamente anche nel registry pubblico di Docker sarà presente l'immagine creata nelle sue versioni con il tag contenente il numero progressivo di deployment di Jenkins.

Ma è valida questa prova in un cluster Kubernetes locale creato dallo stesso Docker? Ovviamente quanto sopra scritto è stato provato anche con Minikube in quanto è possibile selezionare un runtime differente per i container:

minikube start --container-runtime=cri-o --kubernetes-version=v1.22.3

Infine, Kaniko promosso? In tutti i miei test ha fatto sempre il suo lavoro anche se personalmente trovo il suo comportamento nella gestione della cache non del tutto chiaro (avrei preferito una gestione locale della cache nel modo tradizionale di Docker). Inoltre ho notato problemi del download dell'immagine da alcuni servizi di cloud, in cui Jenkins mi mostrava errori 403 per autorizzazioni mancanti al momento del download dell'immagine di Kaniko (in alcuni test fatti dai cloud in Germania). E' un caso? Non so, in ogni caso ci sono modi per scavalcare questo problema ma la cosa, al momento, non mi interessa. In futuro si potrebbe anche optare per l'utilizzo di una macchina virtuale dedicata a questo scopo (anche con basse prestazioni visto che non è necessaria potenza di calcolo per questa operazione).

Ecco il link con il codice.

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