Gestionar recursos de Kubernetes de manera programática

Normalmente, cuando quieres interactuar con los recursos de un clúster de Kubernetes haces uso de kubectl, e incluso lo integras como parte de tus pipelines de tu herramienta de CI y CD. Sin embargo, existen escenarios donde es necesario gestionar de forma programática recursos de Kubernetes, como pueden ser Jobs, Deployments, etc. Para ello, existen librerías mantenidas tanto oficialmente como por la comunidad que te permiten comunicarte con el API Server de Kubernetes de manera sencilla. En este artículo te muestro un ejemplo de cómo hacerlo con Node.js. El código del ejemplo lo tienes en mi GitHub.

El código

En esta demostración voy a llevar a cabo cuatro acciones: crear un recurso del tipo Job, listar los pods asociados a este, eliminar el Job, y sus pods, y por último intentar listar los secretos del namespace. El objetivo es que para todas ellas tendré permisos excepto para la última, que deberá de ser denegada a través de los mecanismos que me ofrece Kubernetes. Con ello puedes tener un ejemplo claro de qué es lo que se puede hacer.

const k8s = require('@kubernetes/client-node');
const kc = new k8s.KubeConfig();

///Just for painting titles
function title(text) {
    var str = new Array(text.length + 1).join('*');
    console.log(`${str}\n${text}\n${str}`);
}

function errorMsg(error) {
    console.log(`Status code: ${error.statusCode}`);
    console.log(`Message: ${error.response.body.message}`);
}

async function main() {

    const NAMESPACE = 'default';
    const LABEL_SELECTOR = 'owner=0gis0';

    if (process.env.NODE_ENV === 'production') {
        //It uses the config from $HOME/.kube/config
        title('K8S');
        console.log(`Load from cluster`);
        kc.loadFromCluster();
    }
    else {
        // It uses the Service Account permissions (If you are using loadFromCluster (e.g. from inside a pod) then you need to set the serviceAccount on the pod)
        title('LOCAL');
        console.log(`Load from kube config`);
        kc.loadFromDefault();
    }

    const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
    const batchV1Api = kc.makeApiClient(k8s.BatchV1Api);

    // Pod definition
    var pod = {
        metadata: {
            name: 'task',
            labels: {
                'owner': '0gis0'
            }
        },
        spec: {
            containers: [
                {
                    name: 'task',
                    image: 'busybox',
                    command: ['sleep', '10000']


                }
            ],
            restartPolicy: 'OnFailure'
        }
    };

    // Job definition
    var job = {
        metadata: {
            name: 'taskjob',
        },
        spec: {
            template: pod
        }
    };


    title('1. CREATE A JOB');
    try {
        let jobResult = await batchV1Api.createNamespacedJob(NAMESPACE, job);
        console.log(`Job '${jobResult.body.metadata.name}' created`);

    } catch (error) {
        title('ERROR CREATING JOB');
        errorMsg(error);
    }

    title(`2. LIST PODS with label ${LABEL_SELECTOR}`);
    let pods = null;
    try {
        pods = await k8sApi.listNamespacedPod(NAMESPACE, undefined, undefined, undefined, undefined, LABEL_SELECTOR);

        for (let index = 0; index < pods.body.items.length; index++) {
            let pod = pods.body.items[index];
            console.log(pod.metadata.name);
        }

    } catch (error) {
        title('ERROR LISTING PODS');
        errorMsg(error);
    }


    title('3. DELETE JOB');
    try {
        await batchV1Api.deleteNamespacedJob(job.metadata.name, NAMESPACE);
        console.log(`${job.metadata.name} deleted.`)
    } catch (error) {
        title('ERROR DELETING JOB');
        errorMsg(error);
    }


    title('4. DELETE PODS');
    for (let index = 0; index < pods.body.items.length; index++) {
        try {
            let pod = pods.body.items[index];
            await k8sApi.deleteNamespacedPod(pod.metadata.name, NAMESPACE);
            console.log(`${pod.metadata.name} deleted.`);
        } catch (error) {
            title('ERROR DELETING PODS');
            errorMsg(error);
        }
    }

    title('5. LIST SECRETS');
    try {
        let secrets = await k8sApi.listNamespacedSecret(NAMESPACE);
        for (let index = 0; index < secrets.body.items.length; index++) {
            let secret = secrets.body.items[index];
            console.log(secret.metadata.name);
        }
    } catch (error) {
        title('ERROR LISTING SECRETS');
        errorMsg(error);
    }
}

main();

Para poder ejecutar este código tengo dos alternativas: si estoy en local, puedo hacer uso del archivo kubeconfig alojado en $HOME/.kube/config. Para este ejemplo, tengo asociado un clúster en AKS, con un usuario que tiene todos los permisos, incluso para los secretos. Si lo ejecuto debería de tener una salida como la siguiente:

Ejecución de mi cliente de Kubernetes en local

Si por el contrario quiero ejecutar este código dentro de un clúster de Kubernetes, lo ideal es hacer uso de una Service Account que a su vez tenga los permisos necesarios para poder llevar a cabo, o no, estas operaciones. Para este segundo caso tendremos que crear recursos adicionales en el clúster antes de ejecutar este código.

Crear una Service Account con los permisos adecuados

En el escenario que a mi me ocupa, la idea es que este código se ejecute como una aplicación que forma parte del clúster y es por ello que quiero que el Pod encargado de hacer este trabajo tenga los permisos mínimos para ejecutar lo estrictamente necesario. Para ello, lo primero que debo hacer es crear una Service Account, un Role con los permisos que quiero otorgarle y un RoleBinding que me asocie la cuenta con dichos permisos:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: kube-client

---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: default
  name: pod-reader
rules:
  - apiGroups: [""] # "" indicates the core API group
    resources: ["pods"]
    verbs: ["list", "delete"]
  - apiGroups: ["batch"] # "" indicates the core API group
    resources: ["jobs"]
    verbs: ["create", "delete"]

---
apiVersion: rbac.authorization.k8s.io/v1
# This role binding allows "jane" to read pods in the "default" namespace.
# You need to already have a Role named "pod-reader" in that namespace.
kind: RoleBinding
metadata:
  name: read-pods
  namespace: default
subjects:
  # You can specify more than one "subject"
  - kind: ServiceAccount
    name: kube-client # "name" is case sensitive
    apiGroup: ""
roleRef:
  # "roleRef" specifies the binding to a Role / ClusterRole
  kind: Role #this must be Role or ClusterRole
  name: pod-reader # this must match the name of the Role or ClusterRole you wish to bind to
  apiGroup: rbac.authorization.k8s.io

Como ves, en teoría puedo listar y eliminar pods y crear y eliminar jobs. Todo lo que no sea esto debería de ser denegado para la Service Account kube-client. Si te descargas mi código, puedes crear estos tres recursos con el siguiente comando:

kubectl apply -f k8s/service-account.yaml

Una vez hecho esto he creado un Dockerfile con el código anterior con la ayuda de Visual Studio Code, he generado la imagen y la he subido a Docker Hub en este caso.

#Build the image
docker build -t 0gis0/kubenodejs .
#Push the image to Docker Hub
docker push 0gis0/kubenodejs

Por último, he creado un recurso de tipo Pod para probar tanto el código en el clúster como los permisos adquiridos a través de la Service Account asociada:

apiVersion: v1
kind: Pod
metadata:
  name: jobgenerator
  labels:
    name: jobgenerator
spec:
  serviceAccountName: kube-client
  restartPolicy: Never
  containers:
    - name: generator
      image: 0gis0/kubenodejs
      imagePullPolicy: Always
      resources:
        limits:
          memory: "128Mi"
          cpu: "500m"

Como ves, es importante que hagas uso de la propiedad serviceAccountName para decirle a nuestro pod que debe de usar la cuenta de servicio que acabamos de crear, que es la que tiene los permisos que necesitamos para ejecutar nuestro cliente. Si ahora creamos este pod en nuestro clúster:

kubectl apply -f k8s/pod.yaml

Verás que el mismo se ejecuta correctamente. Si recuperamos los logs de este veremos que todas las tareas han terminado correctamente, igual que en local, excepto la última para la que dentro del clúster no tenemos permisos para llevarla a cabo:

La ejecución en k8s de mi cliente falla al listar secrets por los permisos de la Service Account

¡Saludos!