Indice de contenidos
- 1. Introducción
- 2. Autores
- 3. Entorno de trabajo
- 4. Aplicación de registro de libros
- 5. Manos a la obra
- 6. Conclusiones
- 7. Referencias
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)
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:
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
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
- https://kubernetes.io/docs/home/
- https://www.digitalocean.com/community/tutorials/how-to-build-a-node-js-application-with-docker-quickstart-es
- https://code.visualstudio.com/docs/containers/reference
- https://www.autentia.com/libros-y-guias/?utm_source=banner&utm_medium=Post%20blog%20Autentia&utm_campaign=Gu%C3%ADa%20DevOps%20Completa#DevOps:_La_gu%C3%ADa_completa