Despliegue de Aplicaciones sobre Kubernetes

0
3646

Indice de contenidos

Introducción

Para completar el tema relacionado con Kubernetes (K8s) de la guía de DevOps, vamos a llevar a cabo un pequeño ejemplo práctico que reúna algunos de los conceptos explicados, para así poder «ponerle cara» al despliegue de aplicaciones con K8s. Aunque en la guía se traten muchos otros conceptos, hemos decidido enfocar el tutorial de manera que sirva de introducción a K8s, sin entrar en mucho detalle.

Autores

Este tutorial es el trabajo de más de un autor, donde todos hemos aportado contenido. Los autores somos:

  • Lucía Rodriguez Pérez.
  • Juan Carlos Moreno García.
  • Nicolae Alexandru Molnar.

Entorno de trabajo

El tutorial se ha creado usando el siguiente entorno:

  • Hardware: Portátil MacBook Pro 2014 (2,5 GHz Intel Core i7, 16GB DDR3).
  • OS: macOS Big Sur, Darwin 11.6.7 x64.
  • Versiones:
    • IDE: Visual Studio Code 1.71.0.
    • Terminal: kitty 0.25.2 (zsh shell).
    • Node version: v18.7.0.
    • Npm version: 8.15.0.
    • Docker: 4.10.1.
    • Minikube: v1.26.1 (Controlador VirtualBox).
    • kubectl: v1.24.1.

Aplicación de registro de libros

La aplicación que vamos a desarrollar en Node es una aplicación sencilla, que consiste en un servicio web que nos permite registrar «libros» y obtener una lista de cuales tenemos registrados. Para el caso que nos concierne (un ejemplo práctico de K8s), la aplicación solo guardará títulos y contenidos de cada libro, y la consulta de libros solo devolverá sus nombres de archivo.

Técnicamente, la aplicación consiste en una API REST con dos endpoints sencillos que nos permiten ver que la configuración de K8s es correcta y funciona. El primer endpoint, /, devuelve un JSON con el nombre del nodo donde se está ejecutando la aplicación, la versión y las credenciales que vamos a configurar como secretos. El otro endpoint, /books, nos permite almacenar y recuperar archivos con información sobre libros en formato JSON, para comprobar que el volumen persistente que vamos a configurar funciona de forma adecuada.

Despliegue de la aplicación

Para comenzar con el tutorial, lo primero que debemos hacer es crear la aplicación que queremos desplegar. Para seguir el ejemplo, puedes hacer uso de la nuestra clonando este repositorio.

Paso 0: Crear y subir nuestra propia imagen de Docker

Una vez tengamos lista la aplicación, en nuestro caso en node.js, necesitamos hacerla portable y usable por los contenedores de K8s. Para ello, crearemos una imagen Docker que contenga nuestra aplicación.

Para crear la imagen Docker, debemos crear un archivo Dockerfile que sea adecuado para nuestra aplicación. En nuestro caso utilizaremos como base una imagen de node, generando así el siguiente archivo:

FROM node:lts-alpine
RUN mkdir -p /usr/src/app/node_modules
WORKDIR /usr/src/app
COPY ["package*.json", "npm-shrinkwrap.json*", "./"]
RUN npm install --production --silent && mv node_modules ../
ENV NODE_ENV=production
COPY --chown=node:node . . 
EXPOSE 3000
RUN chown -R node /usr/src/app
USER node
CMD ["npm", "start"]

Si analizamos el contenido de ese fichero, vemos que crea una imagen basada en node:lts-alpine, crea el directorio /usr/src/app y lo configura como directorio de trabajo. Los comandos copy sirven para copiar archivos desde nuestra máquina a la imagen y mediante comandos run podemos lanzar comandos en la consola del contenedor. De esta forma, instalamos las dependencias de node y configuramos los permisos del usuario node sobre la carpeta de trabajo. Por último, se expone el puerto 3000 a la red local y se inicia el servidor node.

Para facilitar las tareas de construcción de la imagen, vamos a utilizar un archivo tasks.json dentro de la carpeta .vscode (si estás usando este IDE), donde tendremos lo siguiente:

{
   "version": "2.0.0",
   "tasks": [
       {
           "type": "docker-build",
           "label": "docker-build",
           "platform": "node",

           "dockerBuild": {
               "dockerfile": "${workspaceFolder}/Dockerfile",
               "context": "${workspaceFolder}",
               "pull": true,
               "tag":"learningkubernetes.example.io:1.0",
           },
           "node": {
               "package": "${workspaceFolder}/package.json"
           }
       },
   ]
}

Es importante el campo tag, donde debemos escribir el nombre que queremos dar a la imagen. Una vez configurado, ejecutamos la tarea docker-build, construyendo así la imagen.

Si no estás utilizando VS Code, puedes ejecutar el comando docker build para compilar la imagen definida de la siguiente manera:

docker build -t learningkubernetes.example.io:1.0 $(pwd)

docker build output

Con el comando anterior creamos la imagen con una etiqueta y una versión. Para definirlas se usa el parámetro -t. Por último, en lugar de $(pwd) podemos especificar la ruta donde se encuentra nuestro proyecto (y el Dockerfile). Al usar el comando pwd obtenemos la ruta actual (análogo a usar .)

Para comprobar que el proceso tiene el resultado esperado, podemos usar el comando:

docker images

Que nos devolverá una salida parecida a esta:
result of building the image
Donde podremos comprobar el nombre de la imagen, la etiqueta y el tamaño. Si alguno de estos campos no es el que esperabas, revisa la definición del Dockerfile o el comando de compilación y vuelve a intentarlo.

Ya hemos creado una imagen Docker, pero para poder usarla en K8s es conveniente subirla a DockerHub. Para ello, primero necesitamos tener una cuenta de usuario en https://hub.docker.com/ e iniciar sesión desde nuestra consola con el comando:

docker login

Ahora vamos a subir la imagen utilizando los siguientes comandos, donde tendrás que sustituir {username} por tu nombre de usuario de DockerHub:

docker tag learningkubernetes.example.io:1.0 {username}/learningkubernetes.example.io:1.0
docker push {username}/learningkubernetes.example.io:1.0

docker image pushed to hub

Paso 1: Crear un Deployment

Para ejecutar nuestra aplicación en un pod, vamos a utilizar un controlador Deployment que lo haga de forma automática. En este tutorial utilizaremos un clúster de Minikube, así que arrancaremos Minikube y crearemos el siguiente Deployment:

apiVersion: apps/v1
kind: Deployment
metadata: 
  name: kubernetes-example
  labels: 
    app: learning-kubernetes
spec: 
  replicas: 2
  selector: 
    matchLabels: 
      app: learning-kubernetes
  template:
    metadata: 
      labels:
        app: learning-kubernetes
    spec: 
      containers: 
        - name: kubernetes-example
          image: nmolnar/learningkubernetes.example.io:1.0
          env:
            - name: PV
              valueFrom:
                configMapKeyRef:
                  name: kubernetes-example
                  key: pv_path
            - name: NODE_USERNAME
              valueFrom:
                secretKeyRef:
                  name: secret-credentials
                  key: username
            - name: NODE_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: secret-credentials
                  key: password
          imagePullPolicy: IfNotPresent
          volumeMounts:
            - name: kubernetes-example-pv
              mountPath: /usr/src/data
          ports: 
          - containerPort: 3000
      volumes: 
      - name: kubernetes-example-pv
        persistentVolumeClaim: 
          claimName: pv-claim

Nota: El valor del campo `.image` tenéis que cambiarlo para que descargue la imagen que habéis subido a vuestra cuenta de DockerHub.

En este archivo, al cual llamaremos deployment.yaml(lo podéis encontrar en el repositorio, dentro de la carpeta ./kubernetes, al igual que el resto de archivos YAML necesarios), hemos definido la estructura completa del Deployment que necesitamos para poder desplegar nuestra aplicación. Si revisamos el código fuente de nuestra aplicación en Node.js, podemos observar que hacemos uso de diferentes variables de entorno (PV y PWD) y, puesto que queremos ofrecer un servicio de almacenamiento de «libros», nos interesa disponer de un volumen persistente de K8s, para no perder los libros registrados en caso de reinicio de pod.

Por tanto, podemos ver que necesitaremos especificar más elementos a partir de una definición base de Deployment en K8s como esta:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: kubernetes-example
  labels:
    app: learning-kubernetes
spec:
  replicas: 2
  selector:
    matchLabels:
      app: learning-kubernetes
  template:
    metadata:
      labels:
        app: learning-kubernetes
    spec:
      containers:
        – name: kubernetes-example
          image: nmolnar/learningkubernetes.example.io:1.0
      ports:
          – containerPort: 3000

Aquí definimos 2 réplicas de la imagen que hemos generado en el paso 0, que posteriormente expondremos para dar servicio a nuestra aplicación. Sin embargo, tal y como hemos codificado la aplicación, nos encontraríamos con variables de entorno no definidas, que provocan que nuestra aplicación no actúe como debería.

Para solucionar esto, las asignaremos en cada pod por medio de un ConfigMap, que crearemos en un paso posterior. El archivo de definición del quedaría así:

apiVersion: apps/v1
kind: Deployment
…
    spec:
      containers:
        – name: kubernetes-example
          image: nmolnar/learningkubernetes.example.io:1.0
          env:
            – name: PV
              valueFrom:
                configMapKeyRef:
                  name: kubernetes-example
                  key: pv_path
          ports:
          – containerPort: 3000

Donde definimos la variable de entorno `PV`, que se corresponde con la ruta a la carpeta persistida.

Además, para almacenar información de forma persistente haremos uso de un PersistentVolume, que montaremos sobre la carpeta /usr/src/data. Para ello, modificamos el archivo donde definimos el Deployment, quedando así:

apiVersion: apps/v1
kind: Deployment
…
    spec:
      containers:
        – name: kubernetes-example
          image: nmolnar/learningkubernetes.example.io:1.0
          env:
            …
          volumeMounts:
            – name: kubernetes-example-pv
              mountPath: /usr/src/data
          ports:
          – containerPort: 3000
      volumes:
      – name: kubernetes-example-pv
        persistentVolumeClaim:
          claimName: pv-claim

En esta versión del Deployment hemos añadido un nuevo volumen, en este caso a partir de un PersistentVolumeClaim, que montamos dentro de la definición del contenedor como elemento de la lista .template.spec.containers.volumeMounts, en la ruta /usr/src/data.

Por último, definiremos las variables de entorno correspondientes a las credenciales (nombre de usuario y contraseña) que guardaremos en Secrets más adelante:

apiVersion: apps/v1
kind: Deployment
…
    spec:
      containers:
        – name: kubernetes-example
          image: nmolnar/learningkubernetes.example.io:1.0
          env:
            – name: PV
              …
            – name: NODE_USERNAME
              valueFrom:
                secretKeyRef:
                  name: secret-credentials
                  key: username
            – name: NODE_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: secret-credentials
                  key: password
          imagePullPolicy: IfNotPresent # Pendiente de revisión
          volumeMounts:
            …
          ports:
          – containerPort: 3000
      volumes:
      – name: kubernetes-example-pv
        …

Una característica muy interesante de K8s es que podemos crear un objeto que dependa de objetos que aún no están creados/preparados. Este no pasará a estado Ready hasta que esas dependencias se cumplan.

Gracias a esta característica, podemos crear el objeto Deployment con el siguiente comando (previamente debemos arrancar Minikube):

minikube start --driver=virtualbox
kubectl apply -f kubernetes/deployment.yaml

Esperamos unos segundos y comprobamos que se ha creado el Deployment y, por tanto, 2 pods con la imagen de nuestra aplicación:

kubectl get deployments
kubectl get pods

Observa que los objetos aún no están listos, debido a que todavía no hemos definido los recursos que utilizan. Vamos a ello:

Paso 2: Crear un ConfigMap

Para crear el ConfigMap necesario para nuestro ejemplo, basta con definir el siguiente archivo YAML, llamado configmap.yaml:

apiVersion: v1
kind: ConfigMap
metadata:
  name: kubernetes-example
data:
  pv_path: "/usr/src/data"

El punto importante de este objeto es el campo .data, donde podemos definir propiedades y configuraciones tanto en formato clave-valor, como en forma de archivo. Para nuestra aplicación es suficiente el formato clave-valor para definir el campo pv_path, que almacena la ruta del pod sobre la que se monta el volumen persistente.

Para crear el objeto basta con ejecutar el siguiente comando en nuestra terminal:

kubectl apply -f kubernetes/configmap.yaml

Y podemos comprobar el estado del objeto con los comandos:

kubectl get cm
kubectl describe cm kubernetes-example


Paso 3: Crear un Secret

En nuestra aplicación utilizamos Secrets a modo de demostración. Habrás visto que el uso que le damos al secreto es trivial, simplemente lo mostramos en la ruta base. Para incluir el secreto en nuestra aplicación, definiremos primero el objeto Secret. Como no necesitamos demasiada personalización, hemos optado por crearlo de forma imperativa:

kubectl create secret generic secret-credentials --from-file=username=./kubernetes/username.txt --from-file=password=./kubernetes/password.txt

Esta ejecución implica la existencia de dos archivos username.txt y password.txt, que contienen los valores admin y 3xtr3m3l1S3cr3tP4ssw0rd respectivamente (podrás encontrarlos también en nuestro repositorio, pero lo recomendable sería no versionar estos archivos). Para ver cómo ha quedado almacenado el Secret en K8s podemos utilizar:

kubectl get secrets
kubectl describe secret secret-credentials

Que nos dará una salida como esta:

En ella podemos ver que el secreto ha sido creado con éxito y las longitudes de las contraseñas coinciden con los valores de Data.

Paso 4: Crear un PersistentVolume

Vamos a crear el volumen persistente de K8s que nos permitirá almacenar los objetos de la lógica de nuestra aplicación. En primer lugar, creamos el objeto PersistentVolume con un archivo YAML, al que llamaremos pv-volume.yaml:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-volume
  labels:
    type: local
spec:
  storageClassName: manual
  capacity:
    storage: 512Mi
  accessModes:
    – ReadWriteOnce
  hostPath:
    path: /mnt/data

Hemos definido los requisitos del volumen que necesitamos. En .spec.capacity.storage hemos indicado 512 Mebibytes, un valor elevado para nuestra aplicación, pero este volumen podría acabar compartido por varios pods. También hemos definido el modo de acceso para que un nodo tenga acceso de lectura y escritura, mientras que el resto tienen solo acceso de lectura. Por último, hemos definido el path del cluster que corresponde al volumen, /mnt/data.

Ahora necesitamos crear un PersistentVolumeClaim para definir cuánta parte de ese volumen necesitamos para nuestro pod. En este caso, solicitaremos permiso de lectura/escritura a 128Mi. Teniendo esto cuenta, definimos el siguiente archivo, al que llamaremos pv-claim.yaml:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pv-claim
spec:
  storageClassName: manual
  accessModes:
    – ReadWriteOnce
  resources:
    requests:
      storage: 128Mi

Una vez terminado, necesitamos crear el volumen compartido que hemos configurado en hostPath, en nuestro caso /mnt/data. Para ello, nos conectamos al nodo de Minikube con el comando:

minikube ssh

Y una vez dentro creamos la carpeta y otorgamos los permisos necesarios para que el usuario “node” de cada contenedor pueda leer y escribir de ella:

sudo mkdir -p /mnt/data
sudo chown -R 1000:1000 /mnt/data

Nota: Para el comando chown hemos utilizado el valor 1000, ya que es el identificador (UID y GID) por defecto del usuario principal de un sistema UNIX. Si el sistema tiene varios usuarios, debemos utilizar el comando id para averiguar los valores que necesitamos.

Una vez lo tengamos todo listo, salimos de Minikube, con el comando exit, y creamos los objetos con el comando apply:

kubectl apply -f kubernetes/pv-volume.yaml

Y el claim que lo asocia al pod:

kubectl apply -f kubernetes/pv-claim.yaml

Observa que este es un tutorial sencillo que demuestra cómo crear un volumen persistente y montarlo en los diferentes pods de un deployment. Para casos más complejos, en lugar de utilizar el comando chown para definir quién es el propietario del volumen, deberíamos hacer uso de Security Context.

Llegados a este punto, hemos definido y creado todos los objetos necesarios para que se produzca el despliegue. Antes de exponer nuestra aplicación fuera del clúster de Kubernetes, comprobamos que el despliegue de nuestra aplicación se ha realizado con éxito con los siguientes comandos:

kubectl get pods
kubectl get deployments

Si los pods del Deployment están READY, nuestra aplicación se ha desplegado con éxito. En caso de fallo en alguno de ellos, podemos utilizar el comando:

kubectl describe {resource} {objectName}

para comprobar la razón del fallo. Por ejemplo, en la siguiente imagen:

podemos ver que los pods fallan, pero no por qué. Sin embargo, en la salida del comando describe, en la sección de “Events”:

kubectl describe pod {podName}

observamos que no se ha conseguido cargar el ConfigMap kubernetes-example.

Paso 5: Exponer la aplicación al exterior del clúster

Si has llegado a este punto sin errores, acabas de poner en marcha tu primera aplicación con K8s. Para comprobar que la aplicación funciona y poder probarla, debemos crear un servicio y exponerlo al exterior. Para ello, debes ejecutar el siguiente comando:

kubectl expose deployment kubernetes-example --type=NodePort --port=3000

De este modo, se crea el servicio kubernetes-example, que podemos consultar con:

kubectl get svc

Y en nuestro caso obtendremos la siguiente salida:

Donde podemos ver la IP del cluster y el puerto expuesto para el acceso al servicio. Otra manera más cómoda de obtener la dirección de acceso al servicio es:

minikube service kubernetes-example --url

Que nos devuelve un enlace clickable con la dirección url, en formato http://{node-ip}:{node-port}, de nuestro servicio:

Nota: La IP puede variar de una máquina a otra, y el puerto se genera aleatoriamente, por lo que lo más probable es que al ejecutar el comando te dé un resultado distinto.

Si ahora accedemos a esta url, podemos comprobar en qué pod se ha lanzado la aplicación, la versión y el secreto que hemos utilizado:

Si lanzamos repetidamente peticiones a la url, veremos cómo el nombre del pod va alternando entre los 2 pods que se han creado con el Deployment, comprobando así el balanceo de carga que este hace.

Además, en el endpoint http://{node-ip}:{node-port}/books, sustituyendo las variables entre llaves por tus valores, podemos comprobar mediante los verbos GET y POST cómo los datos se almacenan a prueba de reinicios del pod. Añadimos nuevos libros con POST:

curl -X POST "http://{node-ip}:{node-port}/books" -H "Content-Type:application/json" -d '{"title":"titulo","content":"contenido"}' -s | jq

Que nos confirmará el éxito devolviendo el título y la fecha de subida:

Y que podemos comprobar con el verbo GET sobre el mismo endpoint:

Paso 6: Actualizar el Deployment a otra versión de la imagen

Ahora vamos a realizar un pequeño cambio a nuestra aplicación, para después generar una nueva versión de la imagen y así poder actualizar el Deployment.

En este caso, cambiaremos la versión que se muestra al hacer la petición al endpoint /. Para ello, abrimos el archivo server.js, que está dentro de la carpeta src, y cambiamos el valor de la constante version tal y como se indica en la imagen:

A continuación, volvemos a crear la imagen y la subimos a DockerHub, pero esta vez con el tag de la nueva versión (2.0):

docker build -t learningkubernetes.example.io:2.0 $(pwd)
docker tag learningkubernetes.example.io:2.0 {username}/learningkubernetes.example.io:2.0
docker push {username}/learningkubernetes.example.io:2.0

Una vez tengamos la nueva imagen subida a nuestra cuenta de DockerHub, cambiamos la imagen del Deployment con el comando `set image`, indicando la nueva versión. Pero antes de actualizar el Deployment, cambiaremos la descripción de la última versión (sólo tenemos una, por lo que coincide con la creación) que aparece en el historial de versiones para poder identificarlas mejor cuando tengamos varias actualizaciones:

kubectl annotate deployment/kubernetes-example kubernetes.io/change-cause="Deployment created"

Si ahora listamos el historial de versiones del Deployment, veremos lo siguiente:

kubectl rollout history deployment/kubernetes-example

Ahora sí, actualizamos la imagen con el siguiente comando:

kubectl set image deployment/kubernetes-example kubernetes-example={username}/learningkubernetes.example.io:2.0

Y cambiamos la descripción que se mostrará en el historial:

kubectl annotate deployment/kubernetes-example kubernetes.io/change-cause="Image updated to 2.0"

Podemos comprobar que la actualización se ha completado con éxito con el comando:

kubectl rollout status deployment/kubernetes-example
kubectl get pods

Además, como podemos observar en la imagen, al listar los pods vemos que estos han cambiado. Esto se debe a que, cuando un Deployment se actualiza, destruye los pods antiguos uno a uno y crea unos nuevos a partir de los nuevos cambios. Mencionar que esto se hace así para garantizar que la aplicación esté disponible en todo momento.

Si ahora accedemos a la url que obtuvimos al exponer el servicio, veremos que el JSON recibido nos muestra la versión 2.0:

Paso 7: Hacer rollback de la actualización

En caso de que quisiéramos volver a la versión anterior, podemos hacerlo fácilmente con el siguiente comando:

kubectl rollout undo deployment/kubernetes-example

Y al hacer la petición nuevamente a la url, observamos que la versión vuelve a ser la 1.0:

Si ahora listamos el historial de versiones, veremos que la revisión 1 desaparece y ocupa el lugar de la revisión 3:

También podemos volver a una versión anterior cualquiera indicando el número de revisión. Como ejemplo, volveremos a la revisión 2, que actualizará de nuevo la imagen a la versión 2.0:

kubectl rollout undo deployment/kubernetes-example --to-revision=2


Conclusiones

Como has podido observar, poner en práctica los conceptos básicos de K8s no ha sido tan difícil. Hemos aprendido cómo desplegar una aplicación sencilla configurando su infraestructura con Kubernetes y las facilidades de despliegue que este ofrece. Si quieres profundizar en este tema, te recomendamos visitar nuestra Guía de DevOps y leer el capítulo de Kubernetes, relacionado con este tutorial.

Referencias

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad