Idealmente, el análisis de los ficheros de definición de objetos (YAML) en Kubernetes debería realizarse antes de crear los objetos en el clúster. Para ello, uno de los stages del proceso de CI/CD debería incorporar KubeLinter (por ejemplo).

De forma paralela, también deberíamos tener un proceso periódico que revise los ficheros de definición de los objetos que tenemos almacenados en el repositorio para identificar, por ejemplo, el uso de versiones de la API desaconsejadas (deprecated) en proceso de eliminación de la API.

En este artículo vemos cómo configurar un Cronjob que ejecute KubeLinter para obtener los ficheros de un repositorio remoto y analizarlos.

Crear una imagen base con KubeLinter y Git

Usamos como referencia el Dockerfile del usuario cwadley en DockerHub para crear una imagen con Git y KubeLinter basada en Alpine:

# Thanks to https://hub.docker.com/r/cwadley/kube-linter/dockerfile
FROM alpine:3.13
RUN apk add git
RUN wget https://github.com/stackrox/kube-linter/releases/download/0.1.6/kube-linter-linux.tar.gz && \
    tar -xzf kube-linter-linux.tar.gz && \
    mv kube-linter /usr/local/bin && \
    rm kube-linter-linux.tar.gz
ENTRYPOINT ["kube-linter"]

Subimos la imagen al repositorio en DockerHub: xaviaznar/kubelinter:v0.1.6-git (la imagen con etiqueta v0.1.6 no incorpora Git).

Como sempre, creamos un namespace para aislar las pruebas que realizamos; en este caso, llamo al namespace cronjobs:

---
kind: Namespace
apiVersion: v1
metadata:
  name: cronjobs

Creamos un PersistentVolumeClaim en el que clonar el repositorio remoto para que KubeLinter lo analice. Esto es necesario porque el volumen raíz del contenedor se configura como read only.

Debes ajustar el tamaño del volumen en función del tamaño en disco del repositorio a analizar, aunque puedes usar opciones como --depth=1 para clonar únicamente el último commit y no toda la historia del repositorio.

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: volume-repo
  namespace: cronjobs
spec:
  resources:
    requests:
      storage: 50Mi
  accessModes:
    - ReadWriteOnce

El siguiente paso es crear un ConfigMap para el script que se encarga de clonar el repositorio remoto y lanzar kube-linter:

---
kind: ConfigMap
apiVersion: v1
metadata:
  name: script
  namespace: cronjobs
data:
  linter.sh: |
    #!/bin/sh
    localPath="/repo"

    if [ -d $localpPath ] && [ ! "$(ls -A $localPath)" ]
    then
      git clone --depth=1 --verbose $1 $localPath
    else
        cd $localPath
        echo "[INFO] Fetching $1"
        git fetch --verbose origin $2
        
        localHEAD=$(git rev-parse --short HEAD)
        remoteHEAD=$(git rev-parse --short origin/$2)

        echo "[INFO] localHEAD=$localHEAD remoteHEAD=$remoteHEAD"

        if [ "$localHEAD" != "$remoteHEAD" ]
        then
            echo "Merging FETCH_HEAD"
            git merge --verbose FETCH_HEAD
        else
            echo "[INFO] Local copy in sync with remote. Not updated"
        fi
    fi

    kube-linter lint $localPath    

El script clona el repositorio remoto en el volumen reclamado anteriormente con el PersistentVolumeClaim si no existe o lo actualiza si existe (y si es necesario).

Una mejora para el script sería clonar una rama específica (diferente a master/main), por ejemplo, mediante git clone --branch ${nombre-rama} ${repo-url}.

Tras realizar la actualización, el script lanza kube-linter lint para analizar el contenido del repositorio en la ubicación definida en $localPath.

Puedes ajustar la ruta a donde apunta kube-linter para realizar el análisis si los ficheros YAML no se encuentran en la raíz del repositorio.

Cronjob

Ya tenemos todas las piezas necesarias para crear el Cronjob:

---
kind: CronJob
apiVersion: batch/v1beta1
metadata:
  name: kubelinter-repo-testkubelinter
  namespace: cronjobs
spec:
  #suspend: true
  schedule: '*/1 * * * *'
  jobTemplate:
    spec:
      backoffLimit: 0 # Do not retry when it fails
      template:
        metadata:
          creationTimestamp: null
        spec:
          containers:
          - name: kubelinter
            image: xaviaznar/kubelinter:v0.1.6-git
            imagePullPolicy: IfNotPresent
            command:
              - /script/linter.sh
            args:
              - "https://github.com/onthedock/testkubelinter.git"
              - "main"
            securityContext:
              runAsUser: 1001
              runAsGroup: 1001
              readOnlyRootFilesystem: true
            resources:
              requests:
                memory: "128Mi"
                cpu: "0.1"
              limits:
                memory: "512Mi"
                cpu: "0.5"
            volumeMounts:
              - name: repo
                mountPath: /repo
              - name: linter
                mountPath: /script
          restartPolicy: Never
          volumes:
            - name: repo
              persistentVolumeClaim:
                claimName: volume-repo
            - name: linter
              configMap:
                name: script
                defaultMode: 0777

Especificamos una periodicidad mediante schedule: '*/1 * * * *' (en este caso, cada minuto, para hacer pruebas) y en la plantilla del job, usamos la imagen con KubeLinter.

Especificamos el comando a ejecutar, pasando como argumentos la URL del repositorio (público) y el nombre de la rama principal. Montamos el script como un volumen de tipo ConfigMap y el PersistentVolumeClaim como almacenamiento de los pods creados en las diferentes ejecuciones del CronJob.

Problemas de permisos para ejecutar el script

Durante las pruebas obtenía errores de acceso denegado al ejecutar el script /script/linter.sh. Por ello fue necesario especificar defaultMode: 0777 para poder proporcionar acceso al usuario con el que se ejecuta el Pod sobre el script montado a partir del ConfigMap.

Evitar el reintento de ejecución del Job

KubeLinter finaliza con exit code 1 (error) si detecta fallos en alguno de los ficheros YAML analizados. Por defecto, Kubernetes está configurado para reinitentar un Job fallido (hasta 6 veces), lo que generaba múltiples ejecuciones del job.

El fallo del Job refleja la detección de errores por parte de KubeLinter al analizar los ficheros YAML del repositorio. No significa que la ejecución del Job ha fallado, por lo que es el comportamiento deseado/esperado.

Para evitar que Kubernetes reintente la ejecución del Job, especificamos spec.jobTemplate.spec.backoffLimit: 0 (para la definición del CronJob) y spec.jobTemplate.spec.template.spec.restartPolicy: Never.

En los logs del Pod creado durante la ejecución del CronJob podemos observar las acciones que realiza el script.

En caso de error, por ejemplo:

$ kubectl get pods -n cronjobs
NAME                                              READY   STATUS      RESTARTS   AGE
kubelinter-repo-testkubelinter-1613909820-r9jrq   0/1     Error       0          47m

Resultado del análisis con KubeLinter

Revisando logs logs del contenedor creado por la ejecución del CronJob:

$ kubectl -n cronjobs logs pod kubelinter-repo-testkubelinter-1613909820-r9jrq

[INFO] Fetching https://github.com/onthedock/testkubelinter.git
POST git-upload-pack (294 bytes)
POST git-upload-pack (260 bytes)
From https://github.com/onthedock/testkubelinter
* branch main -> FETCH_HEAD
ce96888..6382485 main -> origin/main
[INFO] localHEAD=ce96888 remoteHEAD=6382485
Merging FETCH_HEAD
Updating ce96888..6382485
Fast-forward
cronojb-kubnelinter.yaml | 7 +++++++
1 file changed, 7 insertions(+)
/repo/cronojb-kubnelinter.yaml: (object: cronjobs/kubelinter-repo-testkubelinter batch/v1beta1, Kind=CronJob) container "kubelinter" does not have a read-only root file system (check: no-read-only-root-fs, remediation: Set readOnlyRootFilesystem to true in your container's securityContext.)

/repo/cronojb-kubnelinter.yaml: (object: cronjobs/kubelinter-repo-testkubelinter batch/v1beta1, Kind=CronJob) container "kubelinter" is not set to runAsNonRoot (check: run-as-non-root, remediation: Set runAsUser to a non-zero number, and runAsNonRoot to true, in your pod or container securityContext. See https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ for more details.)

Error: found 2 lint errors

Y en caso de que los ficheros de definición de los objetos no contengan errores, el Job acaba como “Completed”:

$ kubectl get pods -n cronjobs
NAME                                              READY   STATUS      RESTARTS   AGE
kubelinter-repo-testkubelinter-1613909820-r9jrq   0/1     Error       0          47m
kubelinter-repo-testkubelinter-1613909880-2fkbg   0/1     Completed   0          46m

Y en los logs observamos cómo KubeLinter indica que no se han encontrado problemas en los ficheros analizados:

$ kubectl -n cronjobs logs pod/kubelinter-repo-testkubelinter-1613909880-2fkbg
[INFO] Fetching https://github.com/onthedock/testkubelinter.git
POST git-upload-pack (294 bytes)
POST git-upload-pack (310 bytes)
From https://github.com/onthedock/testkubelinter
 * branch            main       -> FETCH_HEAD
   6382485..e23b2bf  main       -> origin/main
[INFO] localHEAD=6382485 remoteHEAD=e23b2bf
Merging FETCH_HEAD
Updating 6382485..e23b2bf
Fast-forward
 cronojb-kubnelinter.yaml | 4 ++++
 1 file changed, 4 insertions(+)
No lint errors found!

Suspender temporalmente el CronJob

Si queremos pausar temporalmente la ejecución del CronJob, actualizaremos el fichero de definición incluyendo la opción .spec.suspend: true.

Tras actualizar el CronJob, el campo SUSPEND se marca como True y no se lanzan nuevas ejecuciones del Job (aunque aquellos en ejecución finalizarán con normalidad):

$ kubectl get cj -n cronjobs
NAME                             SCHEDULE      SUSPEND   ACTIVE   LAST SCHEDULE   AGE
kubelinter-repo-testkubelinter   * */1 * * *   True      0        34s             33m

Conclusiones y mejoras

Hemos visto cómo podemos automatizar el análisis de los ficheros de definición de objetos de Kubernetes usando KubeLinter y CronJobs.

En la entrada anterior KubeLinter: identifica malas configuraciones en los objetos de Kubernetes usaba KubeLinter como herramienta para mejorar la seguridad de los objetos desplegados en el clúster. Aunque se realizaba el análisis manualmente, lo ideal sería integrar KubeLinter como parte de un proceso de CI/CD.

Quizás las primeras aplicaciones se desplegaran en Kubernetes sin haber incorporado las buenas prácticas en materia de seguridad y ahora sea necesario examinar una gran número de ficheros YAML.

A medida que la API de Kubernetes avanza, también crecen las posibilidades de que los ficheros de definiciones en nuestro repositorio contengan referencias a APIs desaconsejadas y que dejarán de existir en versiones posteriores de Kubernetes. Esto hace necesario configurar algún proceso que revise las definiciones de los ficheros y Helm Charts con las que se desplegaron recursos en el clúster.

Para este caso concreto, tenemos herramientas especializadas como pluto, de Fairwinds.

KubeLinter también analiza las configuraciones de los recursos con un énfasis especial en la seguridad, lo que lo hace una herramienta más completa.

El análisis periódico mediante un CronJob se podría mejorar incluyendo alertas a los responsables del repositorio en el momento que se identifiquen errores, incluyendo este tipo de análisis junto otras revisiones de seguridad aplicables al clúster.