Kubernetes (II): DaemonSets, StatefulSets, Ingress y almacenamiento

Tras unos pocos meses de atraso, continuamos con los posts de Kubernetes :)

En el post anterior aprendimos unos cuantos conceptos básicos (pods, namespaces, deployments, replicasets y services) de Kubernetes. Son lo mínimo que debería saber un usuario (que no un administrador) para empezar a utilizar nuestro timonel favorito con un mínimo de cabeza, pero no son los únicos y existen otros objetos importantes.

Hoy vamos a explicar algunos conceptos extra que mejorarán nuestra comprensión de la herramienta y ayudará a que desatemos todo su potencial.

De momento vamos a seguir utilizando MicroK8s para los ejemplos que hagamos, pero si algún lector tiene otro clúster instalado podrá utilizarlo también. Leña al mono.

Conceptos básicos II

Kubernetes es una solución muy completa para el despliegue y manejo de pods: podemos usarlo para casi cualquier cosa que se nos ocurra. Con los ReplicaSet y Services del anterior post podemos generar una gran cantidad de servicios web pero… ¿Y si tenemos otras necesidades?

DaemonSet

En la infraestructura tradicional, cuando añadidos un nuevo servidor a veces tenemos que desplegar ciertos servicios. Algunos ejemplos son un demonio de monitorización, un colector de logs o el cliente de una base de datos de conocimiento (CMDB).

En Kubernetes podemos generar un servicio, indicarle el número de copias y escalarlo. Pero de momento, no tenemos forma de garantizar que la distribución de pods a lo largo de los nodos sea la correcta. Si necesitamos como mínimo un pod corriendo en cada nodo de nuestro clúster, podemos utilizar los DaemonSet.

Un DaemonSet es un tipo de objeto que nos asegura que todos los nodos de nuestro clúster (o algunos, en función de lo que necesitemos), tengan corriendo una copia de un pod.

La creación de este tipo de objetos es muy parecida a las de un despliegue, cambiando su kind a DaemonSet y algunos pequeños cambios. A modo de ejemplo, vamos a desplegar un DaemonSet de Fluentd, un recolector de Logs desarrollado por Elastic.

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: fluentd
spec:
  selector:
    matchLabels:
      name: fluentd
  template:
    metadata:
      labels:
        name: fluentd
    spec:
      containers:
        - name: fluentd
          image: quay.io/fluentd_elasticsearch/fluentd:v4.3
      terminationGracePeriodSeconds: 30

Los DaemonSet necesitan algunos campos extra en su especificación como selector y template. No vamos a entrar aun a fondo en ellos, pero si alguien quiere adelantar información puede consultar la documentación.

Si aplicamos el código YAML anterior, veremos lo siguiente:

# Creamos el DaemonSet
kubetl apply -f daemonset.yml

daemonset.apps/fluentd created

# Vemos el estado de nuestro DaemonSet
kubectl get daemonset

NAME      DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR   AGE
fluentd   1         1         1       1            1           <none>          255s

Para limpiar lo creado anteriormente tan sólo tendríamos que ejecutar el comando bash kubectl delete daemonset fluentd

StatefulSet

Kubernetes gestiona eficazmente aplicaciones sin estado: los pods de un Deployment pueden ser sustituidos por otra copia idéntica o generar más copias si la carga de trabajo aumenta. Este comportamiento es muy útil para aplicaciones que procesan datos de una cola, pero no para otros como bases de datos, servidores de correo, etc.

Para que un pod pueda tener este comportamiento, debe ser stateless y no stateful. Si una aplicación es dependiente del estado de ésta para responder a los clientes, se dice que tiene estado. Es un concepto de arquitectura de software que impacta de lleno en la forma de gestionar los pods en Kubernetes. Todos estos ejemplos tendrían problemas con su estado:

  • Nuestra aplicación necesita los datos o el orden de peticiones anteriores para poder continuar trabajando y/o no está preparada para trabajar dichas peticiones en paralelo.

  • Nuestra aplicación utiliza sesiones: cuando nuestro pod se muera, la sesión lo hará con él, impactando a quien use la aplicación en el proceso.

  • Las bases de datos relacionales usan sistemas transaccionales para garantizar la coherencia y consistencia de los datos. Si un pod de este tipo fuese reemplazado en medio de una transacción, podría provocar inconsistencias en los datos almacenados.

Para ejecutar este tipo de cargas en Kubernetes podemos utilizar los llamados StatefulSet. A diferencia de los Deployment, los StatefulSet mantienen una identidad fija para cada uno de los pods y no son intercambiables entre sí, puesto que se encuentran asociados a un almacenamiento que se mantiene cada vez que el pod es reemplazado.

Con el siguiente código vamos a generar un servidor Nginx que mantiene su estado:

---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: nginx-with-state
spec:
  serviceName: "nginx-with-state"
  replicas: 1
  selector:
    matchLabels:
      app: nginx-with-state
  template:
    metadata:
      labels:
        app: nginx-with-state
    spec:
      containers:
      - name: nginx
        image: nginx:stable-alpine
        ports:
        - containerPort: 80
          name: web
        volumeMounts:
        - name: www
          mountPath: /usr/share/nginx/html
  volumeClaimTemplates:
  - metadata:
      name: www
    spec:
      accessModes: [ "ReadWriteOnce" ]
      resources:
        requests:
          storage: 1Gi

En las plantillas de un StatefulSet, definimos un volumen para almacenar los datos. En este caso, creamos un volumen de nombre www para almacenar el estado del pod. Si utilizamos MicroK8s, es posible que falle debido a que nuestro clúster no pueda asignarle ningún tipo de volumen (salvo que habilitemos el addon de Storage), pero si utilizamos el siguiente YAML no tendremos ningún problema:

Debemos cambiar $NOMBREDEMIHOST por el nombre del host donde tengamos instalado microk8s.

---
kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
  name: local-storage
  annotations:
    "storageclass.kubernetes.io/is-default-class": "true"
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: www
  labels:
    type: local
spec:
  storageClassName: local-storage
  capacity:
    storage: 10Gi
  accessModes:
    - ReadWriteOnce
  local:
    path: "/tmp/data"
  nodeAffinity:
    required:
      nodeSelectorTerms:
        - matchExpressions:
          - key: kubernetes.io/hostname
            operator: In
            values:
              - $NOMBREDEMIHOST
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: nginx-with-state
spec:
  serviceName: "nginx-with-state"
  replicas: 1
  selector:
    matchLabels:
      app: nginx-with-state
  template:
    metadata:
      labels:
        app: nginx-with-state
    spec:
      containers:
      - name: nginx
        image: nginx:stable-alpine
        ports:
        - containerPort: 80
          name: web
        volumeMounts:
        - name: www
          mountPath: /usr/share/nginx/html
  volumeClaimTemplates:
  - metadata:
      name: www
    spec:
      accessModes: [ "ReadWriteOnce" ]
      resources:
        requests:
          storage: 1Gi

Al aplicar la plantilla veremos lo siguiente:

# Primero creamos todas las dependencias necesarias para que se ejecute en microk8s
kubectl apply -f full.yml 

storageclass.storage.k8s.io/local-storage created
persistentvolume/www created
statefulset.apps/nginx-with-state created

# Vemos que el pod ha levantado dentro del StatefulSet
kubectl get pods

NAME                 READY   STATUS    RESTARTS   AGE
nginx-with-state-0   1/1     Running   0          10s

Para limpiar lo creado anteriormente, ejecutamos el siguiente comando:

# Borrado de los elementos ya creados
kubectl delete statefulset nginx-with-state
kubectl delete pvc www-nginx-with-state-0
kubectl delete pv www
kubectl delete StorageClass local-storage

Ingress

Los Ingress son objetos que controlan el acceso desde fuera del clúster a servicios dentro del mismo, generalmente a través de endpoints HTTP. Podemos obtener más funcionalidades que con un Service “pelao”: generar un único punto de entrada para diferentes Deployments, balanceo de carga, SSL Offloading y/o controlar varios virtualhosts con diferentes nombres (name-based virtual hosting). Todo el tráfico es enrrutado hacia el clúster a través de las reglas que definimos en el recurso Ingress.

Antes de utilizar un ingress, necesitamos habilitar previamente un Ingress Controller, que es una especie de enrrutador. Aunque el más conocido (y usado por defecto) es el NGINX ingress controller, no es el único y existen una gran variedad de ellos.

Para utilizarlo en Microk8s, tan sólo tenemos que habilitar el addon ingress con sudo microk8s.enable ingress.

Tras habilitarlo, ahora necesitamos algún servicio que enrrutar. Para ello, vamos a volver a utilizar las plantillas de Nginx del post anterior (Deployment y Service ).

# Creamos el Deployment y el Service genéricos
kubectl apply -f https://gitlab.com/tangelov/proyectos/raw/master/templates/kubernetes/basic-nginx-deployment.yml

kubectl apply -f https://gitlab.com/tangelov/proyectos/raw/master/templates/kubernetes/basic-nginx-service-nodeport.yml

Si ahora miramos los elementos que hemo desplegados, veremos nuestro Ingress Controller y el Nginx:

# Listamos todos los Deployment y los Services
kubectl get deployment,svc,ds --all-namespaces
NAMESPACE     NAME                                      READY   UP-TO-DATE   AVAILABLE   AGE
kube-system   deployment.apps/calico-kube-controllers   1/1     1            1           11m
default       deployment.apps/nginx                     2/2     2            2           6m50s

NAMESPACE   NAME                 TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
default     service/kubernetes   ClusterIP   10.152.183.1     <none>        443/TCP          11m
default     service/nginx        NodePort    10.152.183.159   <none>        8000:32471/TCP   6m42s

NAMESPACE     NAME                                               DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR            AGE
ingress       daemonset.apps/nginx-ingress-microk8s-controller   1         1         1       1            1           <none>                   10m
kube-system   daemonset.apps/calico-node                         1         1         1       1            1           kubernetes.io/os=linux   11m

Si accedemos a la IP del Nodeport podremos acceder a nuestro Nginx, pero en lugar de utilizar dicho endpoint, vamos a crear un Ingress para acceder al mismo:

---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nginx
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  rules:
    - http:
        paths:
          - path: /nginx
            pathType: Prefix
            backend:
              service:
                name: nginx
                port:
                  number: 80

En esta plantilla le estamos indicando al Ingress Controller que acceda a nuestro service Nginx a través de la ruta /nginx y que envíe las peticiones al service Nginx al puerto 80.

Ahora nuestro servicio es accesible a través del Ingress Controller. Al utilizar MicroK8s, éste se genera por defecto en https://127.0.0.1 (El Ingress Controller también genera un certificado autofirmado). Debido a la regla que hemos creado, si accedemos a https://127.0.0.1/nginx podremos acceder al Nginx que acabamos de desplegar.

nginx-with-ingress

Si queremos borrar lo antes creado, ejecutamos los siguientes comandos:

# Borramos los elementos antes creados
kubectl delete ingress ingress
kubectl delete svc nginx
kubectl delete deployment nginx

Conceptos sobre almacenamiento

Una gestión correcta del estado de las aplicaciones es algo vital para que su funcionamiento sea óptimo en Kubernetes. Siempre podemos coger y almacenar los datos que necesite la aplicación en un volumen (que montaríamos en el arranque), pero los volúmenes pueden tener muchas limitaciones si pensamos en esta solución (especialmente en clústers multizonales).

Aunque una aplicación no tenga estado, siempre suele necesitar ciertos datos de configuración para funcionar: valores en un servidor, cadenas de conexión a bases de datos o endpoints, certificados o credenciales para funcionar, etc. De esta forma podríamos dividir los datos de una aplicación en tres partes:

  • Datos de estado: si la aplicación necesita los datos de ejecuciones anteriores para continuar, podemos crear un volumen y asociarlo a un pod a través de un StatefulSet como hemos hecho antes. Si utilizamos un proveedor de nube pública, podemos almacenar el estado de una aplicación (si ésta lo soporta), en uno de los servicios gestionados del mismo.

  • Datos de configuración: si almacenamos todos los datos en un sitio externo (como una base de datos) o si necesitamos algunos certificados para verificar quien se conecta, tan sólo necesitamos esos datos en los pods para que la aplicación acceda a ellos.

  • Otros: son datos que mejoran la experiencia de uso de la aplicación pero que no impiden su buen funcionamiento (como los logs que generan, etc).

La API de Kubernetes es muy completa, pudiendo gestionar todas estas necesidades. Para gestionar volúmenes de datos, necesitamos usar StorageClass, PersistentVolume y PersistentVolumeClaims. En cambio, si queremos gestionar ficheros sueltos (de configuración o no) o credenciales, tenemos que utilizar configmaps y secrets.

Configmaps y secrets

Para configurar una aplicación desplegada en K8s podemos utilizar dos tipos de objetos: configmaps y secrets. Aunque funcionan de manera parecida, el contenido de los secretos está ofuscado y permite almacenar datos sensibles a los que no puede acceder cualquiera en el clúster. Si asignáramos permisos de sólo lectura a un usuario en K8s, éste no podría ver el contenido de los secretos pero si el de los configmaps.

Para que un pod pueda utilizar un configmap o un secreto, éste debe ser asociado al mismo, ya sea como variable de entorno o como volumen, según las necesidades que tengamos. Si el contenido de nuestro objeto contiene más de un elemento clave-valor, al montarlo en un directorio, aparecerán tantos ficheros como elementos tengamos.

En el siguiente ejemplo vamos a montar un fichero index.html personalizado a través de un ConfigMap y vamos a cargar como variables de entorno un usuario y una contraseña procedentes de un secreto, en los dos de un deployment de Nginx.

Primero creamos un fichero index.html con el siguiente contenido…

<!DOCTYPE html>
<html>
<head>
<title>¡Bienvenido a nuestro Nginx en K8s!</title>
<style>
    body {
        width: 35em;
        margin: 0 auto;
        font-family: Tahoma, Verdana, Arial, sans-serif;
    }
</style>
</head>
<body>
<h1>¡Bienvenido a nuestro Nginx en K8s!</h1>
<p>Si ves esta página en castellano es porque el laboratorio ha funcionado correctamente.</p>

</body>
</html>

utilizando el comando kubectl create configmap nginx-custom-index --from-file=index.html.

Tras crear el configmap, ahora vamos a crear un secreto con dos claves valor (usuario y contraseña), con el siguiente comando: kubectl create secret generic nginx-secret --from-literal=username=usuariobbdd --from-literal=password=contraseñamolona

Podemos ver que ambos se han creado correctamente:

NAME                                                       TYPE                                  DATA   AGE
secret/default-token-qnhrx                                 kubernetes.io/service-account-token   3      7d22h
secret/nginx-secret                                        Opaque                                2      70s

NAME                                          DATA   AGE
configmap/nginx-custom-index                  1      54s

Como ya hemos comentado la diferencia fundamental entre un secret y un configmap es que uno está almacenado cifrado y el otro no, si cambiamos el output a YAML podemos verlo mejor:

configmap-vs-secret

Si utilizamos ficheros YAML para generar secretos, tendremos que almacenar el valor de cada clave-valor en base64. Aquí podemos ver algún ejemplo y más explicaciones.

Ahora vamos a crear un Deployment que haga uso de dichos elementos con el siguiente código.

Si analizamos el YAML podemos ver cómo vamos a montar los configmaps como si fuesen un volumen y los secretos como si fuesen variables de entorno.

configmaps-y-secrets

Esto no es una limitación del objeto: los secretos también pueden ser montados como volúmenes y los configmaps como variables de entorno. No vamos a tratar todas las posibilidades en este post y nos remitimos a la documentación oficial.

Ahora accedemos al pod para ver cómo nuestras variables de entorno y nuestro fichero son accesibles:

# Vemos los pods que hemos desplegado de nginx
kubectl get pods

NAME                                      READY   STATUS    RESTARTS   AGE
nginx-84cdbdf6dd-8m885                    1/1     Running   0          132m
nginx-84cdbdf6dd-n5skc                    1/1     Running   0          132m

# Nos introducimos en uno de ellos
kubectl exec -ti nginx-84cdbdf6dd-8m885 -- sh

# Comprobamos que las variables están en su sitio:
root@nginx-84cdbdf6dd-8m885:~# echo $BBDD_USERNAME $BBDD_PASSWORD
usuariobbdd contraseñamolona

# Comprobamos el contenido del index.html de Nginx
root@nginx-84cdbdf6dd-8m885:~# cat /usr/share/nginx/html/index.html | grep castellano
<p>Si ves esta página en castellano es porque el laboratorio ha funcionado correctamente.</p>

Para limpiar nuestro clúster, ejecutamos lo siguiente:

# Borramos los elementos antes creados
kubectl delete deployment nginx
kubectl delete configmap nginx-custom-index
kubectl delete secret nginx-secret

Persistent Volumes

Si no vamos a gestionar credenciales y/o configuración y necesitamos modificar durante el proceso los datos de los volúmenes, podemos crear discos de datos y asociarlos a nuestros pods, tal y como hemos hecho en el caso del StatefulSet. Este tipo de volúmenes se llaman en Kubernetes Persistent Volumes.

Para crear, gestionar y asociar un disco como un volumen tenemos que tener en cuenta tres objetos de Kubernetes:

  • StorageClass: Es el tipo de almacenamiento que va a tener el volumen que vayamos a crear. Impacta directamente con las acciones que soporta el volumen: si creamos un volumen cuya clase es nfs, podremos asignarlo a diferentes pods en modo de lectura y escritura, pero no podremos hacerlo con otros tipos, sin que pueda haber corrupción de datos.

  • PersistentVolume: Es una pieza de almacenamiento del clúster que ha sido aprovisionado de una StorageClass y que podemos asociar a un pod. En función de la clase, pueden ser aprovisionados de forma dinámica (automática) o manual (debe ser creado previamente por un administrador).

  • PersistentVolumeClaim: Un PVC es la petición que un pod realiza para asignarse un volumen y en función de su configuración, puede crear un volumen de una clase, de un tamaño determinado, con diferentes modos de acceso (solo lectura, asociación en RW a más de un pod, etc).

Primero vamos a crear una StorageClass. Como estamos utilizando MicroK8s, vamos a apostar por utilizar una clase local, para poder aprovisionar discos locales.

---
kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
  name: local-storage
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer

En este caso, estamos creando una StorageClass que utiliza discos locales, que es la clase por defecto y que espera a que el disco sea aprovisionado de forma manual para asociarlo a un pod (no soporta en la versión 1.14 la creación automática).

En segundo lugar, vamos a crear un volumen que utilice la clase que acabamos de crear:

---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: local-volume
  labels:
    type: local
spec:
  storageClassName: local-storage
  capacity:
    storage: 10Gi
  accessModes:
    - ReadWriteOnce
  local:
    path: "/tmp/data"
  nodeAffinity:
    required:
      nodeSelectorTerms:
        - matchExpressions:
          - key: kubernetes.io/hostname
            operator: In
            values:
              - $NOMBREDEMIHOST

Este YAML generará en el hostname $NOMBREDEMIHOST (aquí también debemos cambiar el nombre), un volumen de hasta 10 GB que se encuentra localizado en el nodo en /tmp/data. También podemos crear algunos ficheros en /tmp/data/.

Ahora vamos a generar un PVC y asociar una parte del almacenamiento del volumen a un pod.

---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: nginx-pvc-storage
spec:
  storageClassName: local-storage
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
  volumeName: "local-volume"
---
apiVersion: v1
kind: Pod
metadata:
  name: nginx-pv-pod
spec:
  volumes:
    - name: nginx-storage
      persistentVolumeClaim:
        claimName: nginx-pvc-storage
  containers:
    - name: task-pv-container
      image: nginx
      ports:
        - containerPort: 80
          name: "nginx"
      volumeMounts:
        - mountPath: "/usr/share/nginx/html"
          name: nginx-storage

Podemos comprobar que hay ficheros en la ruta y que los volúmenes se han asociado con los siguientes comandos:

# Comprobamos que existe un PV y un PVC
kubectl get pv,pvc

NAME                            CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                       STORAGECLASS    REASON   AGE
persistentvolume/local-volume   10Gi       RWO            Retain           Bound    default/nginx-pvc-storage   local-storage            81s

NAME                                      STATUS   VOLUME         CAPACITY   ACCESS MODES   STORAGECLASS    AGE
persistentvolumeclaim/nginx-pvc-storage   Bound    local-volume   10Gi       RWO            local-storage   76s

# Nos metemos dentro del pod y vemos el contenido del volumen
kubectl exec -ti nginx-pv-pod -- bash

root@nginx-pv-pod:/# ls /usr/share/nginx/html/
adios.html  hola.html

Si queremos borrar todos los recursos del laboratorio anterior, podemos ejecutar los siguientes comandos:

kubectl delete pod nginx-pv-pod
kubectl delete pvc nginx-pvc-storage
kubectl delete sc local-volume

Y hasta aquí ha llegado el post número dos sobre elementos de Kubernetes. Con esto ya hemos terminado la primera tanda de conceptos y en el siguiente post veremos como ejecutar una aplicación tradicional dentro de un clúster de Kubernetes. ¡Un saludo a todos!

PD: Si deseamos todos los ficheros YAML utilizados en este post, podemos descargarlos desde aquí.

Documentación

Revisado a 01-05-2023