- -
- 100%
- +
Para añadir un volumen de datos secretos a Deployment, necesitamos especificar dos nuevas entradas en el YAML de Deployment. La primera es la entrada volume para la cápsula, que añade el volumen a la cápsula:
... volumes: - name: passwd-volume secret: secretName: redis-passwd
Con el volumen en la cápsula, es necesario montarlo en un contenedor específico. Lo hacemos mediante el campo volumeMounts en la descripción del contenedor:
... volumeMounts: - name: passwd-volume readOnly: true mountPath: "/etc/redis-passwd" ...
Esto incorporará el volumen de datos secretos al directorio redis-passwd para el acceso con el código de cliente. Poniendo todo esto junto, tenemos el Deployment completo de la siguiente manera:
apiVersion: extensions/v1beta1 kind: Deployment metadata: labels: app: frontend name: frontend namespace: default spec: replicas: 2 selector: matchLabels: app: frontend template: metadata: labels: app: frontend spec: containers: - image: my-repo/journal-server:v1-abcde imagePullPolicy: IfNotPresent name: frontend volumeMounts: - name: passwd-volume readOnly: true mountPath: "/etc/redis-passwd" resources: request: cpu: "1.0" memory: "1G" limits: cpu: "1.0" memory: "1G" volumes: - name: passwd-volume secret: secretName: redis-passwd
En este momento hemos configurado la aplicación de cliente, con lo que tenemos disponibles los datos secretos para la autenticación en el servicio de Redis. La configuración de Redis para usar esta contraseña es similar; la montamos en la cápsula de Redis y cargamos la contraseña desde el archivo.
Despliegue de una sencilla base de datos con estado
Aunque conceptualmente el despliegue de una aplicación stateful (con estado) es similar al despliegue de un cliente como nuestro frontend, el estado trae consigo más complicaciones. La primera es que en Kubernetes podemos necesitar reprogramar una cápsula por una serie de razones, como pueden ser la comprobación de los nodos, una actualización o un rebalanceo. Cuando esto sucede, la cápsula podría trasladarse a una máquina diferente. Si los datos asociados con la instancia de Redis están localizados en una máquina en particular o dentro del propio contenedor, estos datos se pierden cuando el contenedor migra o se reinicia. Para evitar esto, al ejecutar tareas de estado en Kubernetes es importante usar PersistentVolumes remotos para administrar el estado asociado con la aplicación.
Hay una gran variedad de aplicaciones de PersistentVolumes en Kubernetes, y todas comparten características comunes. Como en los volúmenes de datos secretos descritos anteriormente, se asocian a una cápsula y se montan en un contenedor en un lugar determinado. A diferencia de los datos secretos, PersistentVolumes suelen estar montados en almacenamiento remoto a través de algún tipo de protocolo de red, ya sea basado en archivos —como Network File System (sistema de archivos de red) (NFS) o Server Message Block (bloque de mensajes del servidor) (SMB)— o basado en bloques —iSCSI, discos basados en la nube, etc.—.
Generalmente, para aplicaciones como bases de datos son preferibles los discos basados en bloques porque normalmente ofrecen un mejor rendimiento. Pero si el rendimiento no tiene mucha importancia, a veces los discos basados en archivos pueden ofrecer una mayor flexibilidad.

Para desplegar nuestro servicio Redis, utilizamos el recurso StatefulSet. Añadido después del lanzamiento inicial de Kubernetes como complemento a los recursos de ReplicaSet, StatefulSet ofrece unas garantías un poco más sólidas, como nombres consistentes (¡sin hashes aleatorios!) y un orden definido para la ampliación y la reducción de escala. Cuando implementamos una instancia única, esto es menos importante, pero cuando deseamos desplegar un estado replicado, estos atributos son muy convenientes.
Para obtener un PersistentVolume para nuestro Redis, utilizamos PersistentVolumeClaim. Podemos pensar que se trata de una demanda de «solicitud de recursos». Nuestro Redis declara en abstracto que quiere 50 GB de almacenamiento, y es el clúster de Kubernetes el que determina cómo aprovisionar el PersistentVolume apropiado. Hay dos razones para ello. La primera es que podemos escribir un StatefulSet que sea portátil entre diferentes nubes e instalaciones, donde los detalles de los discos pueden ser diferentes. La otra razón es que, aunque se pueden montar muchos tipos de PersistentVolume en una sola cápsula, podemos usar la demanda de volumen para escribir una plantilla que se pueda replicar y, sin embargo, tener cada cápsula con su propio PersistentVolume específico asignado.
El siguiente ejemplo muestra un Redis StatefulSet con PersistentVolumes:
apiVersion: apps/v1 kind: StatefulSet metadata: name: redis spec: serviceName: "redis" replicas: 1 selector: matchLabels: app: redis template: metadata: labels: app: redis spec: containers: - name: redis image: redis:5-alpine ports: - containerPort: 6379 name: redis volumeMounts: - name: data mountPath: /data volumeClaimTemplates: - metadata: name: data spec: accessModes: [ "ReadWriteOnce" ] resources: requests: storage: 10Gi
Esto implementa una única instancia de nuestro servicio Redis. Pero supongamos que queremos replicar el clúster de Redis para ampliar las lecturas y la resistencia a fallos. Para ello, es necesario aumentar el número de réplicas a tres, pero también necesitamos asegurar que las dos nuevas réplicas se conectan al master (maestro) de Redis para poder escribir en él.
Cuando creamos Service sin encabezamiento para StatefulSet de Redis, se crea una entrada DNS redis-0.redis; esta es la dirección IP de la primera réplica. Podemos utilizarla para crear un sencillo script que se puede lanzar en todos los contenedores:
#!/bin/bash PASSWORD=$(cat /etc/redis-passwd/passwd) if [[ "${HOSTNAME}" == "redis-0" ]]; then redis-server --requirepass ${PASSWORD} else redis-server --slaveof redis-0.redis 6379 --masterauth ${PASSWORD} -- requirepass ${PASSWORD} fi
Podemos crear este script como ConfigMap:
kubectl create configmap redis-config --from-file=launch.sh=launch.sh
A continuación, añadimos este ConfigMap a StatefulSet y lo utilizamos como comando para el contenedor. También agregamos la contraseña para la autenticación que hemos creado anteriormente en este capítulo.
El Redis completo de tres réplicas se ve de la siguiente manera:
apiVersion: apps/v1 kind: StatefulSet metadata: name: redis spec: serviceName: "redis" replicas: 3 selector: matchLabels: app: redis template: metadata: labels: app: redis spec: containers: - name: redis image: redis:5-alpine ports: - containerPort: 6379 name: redis volumeMounts: - name: data mountPath: /data - name: script mountPath: /script/launch.sh subPath: launch.sh - name: passwd-volume mountPath: /etc/redis-passwd command: - sh - -c - /script/launch.sh volumes: - name: script configMap: name: redis-config defaultMode: 0777 - name: passwd-volume secret: secretName: redis-passwd volumeClaimTemplates: - metadata: name: data spec: accessModes: [ "ReadWriteOnce" ] resources: requests: storage: 10Gi
Creación de un equilibrador de carga TCP con Services
Ahora que hemos implementado el servicio con estado de Redis, tenemos que ponerlo a disposición de nuestro frontend. Para ello, creamos dos Services de Kubernetes diferentes. El primero es el Service de lectura de datos de Redis. Debido a que Redis replica los datos a los tres miembros de StatefulSet, no nos importa a quién va dirigida nuestra solicitud. En consecuencia, utilizamos un Service básico para las lecturas:
apiVersion: v1 kind: Service metadata: labels: app: redis name: redis namespace: default spec: ports: - port: 6379 protocol: TCP targetPort: 6379 selector: app: redis sessionAffinity: None type: ClusterIP
Para habilitar la escritura, necesitamos apuntar al master de Redis (replica #0). Para ello, creamos un Service sin encabezamiento. Un Service sin encabezamiento no tiene una dirección IP del clúster, sino que programa una entrada DNS para cada cápsula en el StatefulSet.
Esto significa que podemos acceder al master a través del nombre DNS redis-0.redis:
apiVersion: v1 kind: Service metadata: labels: app: redis-write name: redis-write spec: clusterIP: None ports: - port: 6379 selector: app: redis
Por lo tanto, cuando queramos conectarnos a Redis por escrito o mediante pares de lectura/escritura transaccionales, podemos crear un cliente de escritura separado y conectado al servidor redis-0.redis.
Uso de Ingress para enrutar el tráfico a un servidor de archivos estáticos
El componente final de nuestra aplicación es un servidor de archivos estáticos. El servidor de archivos estáticos es responsable de servir archivos HTML, CSS, JavaScript y archivos de imágenes. Es muy eficaz y, a la vez, está enfocado a permitir que podamos separar el servicio de archivos estáticos de nuestro frontend, descrito anteriormente, que atiende las peticiones API. Podemos utilizar cómodamente un servidor de archivos estáticos de alto rendimiento como NGINX para servir archivos, lo cual permite al mismo tiempo que nuestros equipos de desarrollo se concentren en el código con el que implementar nuestra API.
Afortunadamente, el recurso Ingress hace que este principio de arquitectura de mini-microservicio sea muy fácil. Al igual que en el frontend, podemos usar el recurso Deployment para describir un servidor NGINX replicado. Vamos a crear las imágenes estáticas en el contenedor de NGINX y las desplegaremos en cada réplica. El recurso Desployment tiene el siguiente aspecto:
apiVersion: extensions/v1beta1 kind: Deployment metadata: labels: app: fileserver name: fileserver namespace: default spec: replicas: 2 selector: matchLabels: app: fileserver template: metadata: labels: app: fileserver spec: containers: - image: my-repo/static-files:v1-abcde imagePullPolicy: Always name: fileserver terminationMessagePath: /dev/termination-log terminationMessagePolicy: File resources: request: cpu: "1.0" memory: "1G" limits: cpu: "1.0" memory: "1G" dnsPolicy: ClusterFirst restartPolicy: Always
Ahora que hay un servidor web estático replicado funcionando, también crearemos un recurso Service para que actúe como equilibrador de carga:
apiVersion: v1 kind: Service metadata: labels: app: frontend name: frontend namespace: default spec: ports: - port: 80 protocol: TCP targetPort: 80 selector: app: frontend sessionAffinity: None type: ClusterIP
Ahora que tenemos un Service para el servidor de archivos estáticos, extendemos el recurso Ingress para que contenga la nueva ruta. Es importante tener en cuenta que debemos colocar la ruta / después de la ruta /api; de lo contrario, subsumiría /api y dirigiría las peticiones de la API al servidor de archivos estáticos. El nuevo Ingress tiene el aspecto siguiente:
apiVersion: extensions/v1beta1 kind: Ingress metadata: name: frontend-ingress spec: rules: - http: paths: - path: /api backend: serviceName: frontend servicePort: 8080 - path: / backend: serviceName: nginx servicePort: 80
Parametrización de la aplicación utilizando Helm
Todo lo que hemos discutido hasta ahora se centra en el despliegue de una sola instancia de nuestro servicio en un solo clúster. Sin embargo, en realidad, casi todos los servicios y casi todos los servicios de los equipos de trabajo van a necesitar desplegarse en varios entornos diferentes (aunque compartan un clúster). Incluso si se trata de un único desarrollador que trabaja en una sola aplicación, es probable que desee tener al menos una versión de desarrollo y una versión de producción de la aplicación —para poder hacer iteraciones y desarrollos sin interrumpir a los usuarios en producción—. Después de tener en cuenta las pruebas de integración y CI/CD, es probable que incluso con un solo servicio y un puñado de desarrolladores deseemos desplegar hasta al menos tres entornos diferentes, y posiblemente más si consideramos gestionar los fallos a nivel de centros de datos.
Un tipo de fallo habitual, al principio, en muchos equipos de trabajo se produce al copiar los archivos de un clúster a otro. En lugar de tener un solo directorio /frontend, tenemos un par de directorios frontend-production/ y frontend-development/. La razón por la que esto es tan peligroso es porque ahora tenemos que asegurarnos de que estos archivos permanezcan sincronizados entre ellos. Si estuvieran destinados a ser totalmente idénticos sería fácil, pero se espera que haya alguna diferencia entre el desarrollo y la producción porque desarrollamos nuevas características. Es fundamental que la diferencia sea premeditada y fácil de gestionar.
Otra opción para lograr esto sería usar ramas y control de versiones, donde las ramas de producción y desarrollo parten de un repositorio central, y las diferencias entre las ramas son claramente visibles. Esta puede ser una opción viable para algunos equipos de trabajo, pero la mecánica de moverse entre ramas se convierte en un reto cuando deseamos desplegar software simultáneamente en diferentes entornos (por ejemplo, un CI/CD que se implementa en varias regiones diferentes de la nube).
En consecuencia, la mayoría de los desarrolladores terminan con un sistema de plantillas. Un sistema de plantillas combina plantillas, que forman la columna vertebral centralizada de la configuración de la aplicación, con parámetros que especializan la plantilla para una configuración de entorno específica. De esta manera, podemos tener una configuración compartida en general con una deliberada personalización (y fácil de entender) cuando sea necesario. Hay una amplia variedad de sistemas de plantillas para Kubernetes, pero el más popular con diferencia es un sistema llamado Helm (https://helm.sh).
En Helm, una aplicación es un paquete formado por un conjunto de archivos llamado carta náutica (los chistes de náutica abundan en el mundo de los contenedores y de Kubernetes). La carta náutica empieza con un archivo chart.yaml, que define los metadatos de la propia carta:
apiVersion: v1 appVersion: "1.0" description: A Helm chart for our frontend journal server. name: frontend version: 0.1.0
Este archivo se coloca en la raíz del directorio de la carta náutica (por ejemplo, frontend/). Dentro de este directorio, hay un directorio de plantillas, en el que se colocan las plantillas. Una plantilla es básicamente un archivo YAML como los de los ejemplos anteriores, con algunos de los valores del archivo reemplazados con referencias a parámetros. Por ejemplo, imaginemos que queremos parametrizar el número de réplicas en el frontend. Anteriormente, esto es lo que tenía Deployment:
... spec: replicas: 2 ...
En el archivo de plantillas (frontend-deployment.tmpl), se ve de la siguiente forma:
... spec: replicas: {{ .replicaCount }} ...
Esto significa que cuando despleguemos la carta náutica, sustituiremos el valor por réplicas con el parámetro apropiado. Los propios parámetros están definidos en el archivo values.yaml. Habrá un archivo de valores por cada entorno en el que se debe implementar la aplicación. El archivo de valores para esta sencilla carta náutica se vería así:
replicaCount: 2
Juntando todo esto, podemos desplegar esta carta náutica usando la herramienta helm, como se muestra a continuación:
helm install path/to/chart --values path/to/environment/values.yaml
Esto parametriza la aplicación y la implementa en Kubernetes. Con el tiempo, estas parametrizaciones crecerán para incluir la variedad de diferentes entornos de la aplicación.
Mejores prácticas en el despliegue de servicios
Kubernetes es un sistema potente que puede parecer complicado. Pero poner en marcha una aplicación básica y tener éxito puede resultar fácil si utilizamos las siguientes prácticas:
• La mayoría de los servicios deberían desplegarse como recursos Deployments. Los Deployments crean réplicas idénticas en redundancia y escala.
• Los Deployments se pueden presentar utilizando un Service, que es un equilibrador de carga. Un Service se puede presentar dentro de un clúster (por defecto) o externamente. Si deseamos presentar una aplicación HTTP, podemos utilizar un controlador Ingress para agregar cosas como solicitar enrutamiento y SSL.
• Eventualmente, estaremos interesados en parametrizar la aplicación para hacer su configuración más reutilizable en diferentes entornos. Las herramientas de empaquetado como Helm son la mejor opción para este tipo de parametrización.
Resumen
La aplicación creada en este capítulo es sencilla, pero contiene casi todos los conceptos que necesitaremos para crear aplicaciones más grandes y complejas. Comprender cómo encajan las piezas y cómo utilizar los componentes más importantes de Kubernetes es clave para tener éxito cuando trabajamos con esta herramienta.
Sentar unas buenas bases en el control de versiones, la revisión de código y la ininterrumpida entrega del servicio nos asegurará que lo que creemos se creará de una manera sólida. A medida que progresemos sobre los temas más avanzados en los siguientes capítulos, habrá que tener en cuenta esta información fundamental.
CAPÍTULO 2
Flujos de trabajo para desarrolladores
Kubernetes se creó para operar software de manera confiable. Simplifica el despliegue y la gestión de software con una API orientada a la aplicación, con propiedades de autorregeneración y herramientas útiles como Deployments (implementaciones) para la puesta en marcha de software con tiempo de inactividad cero. Aunque todas estas herramientas tienen su utilidad, no contribuyen de forma importante a facilitar el desarrollo de aplicaciones en Kubernetes. Además, a pesar de que muchos clústeres están diseñados para ejecutar aplicaciones en el entorno de producción —y, por lo tanto, rara vez se accede a ellos mediante los flujos de trabajo de los desarrolladores—, también es fundamental permitir que los flujos de trabajo de desarrollo tengan como objetivo Kubernetes, y esto normalmente significa tener un clúster o al menos una parte de un clúster destinado a desarrollo. La configuración de este tipo de clústeres para facilitar el desarrollo de aplicaciones en Kubernetes es un aspecto necesario para asegurar el éxito. Si no hay ningún código que se cree para nuestro clúster, el clúster por sí mismo no va a conseguir mucho.
Objetivos
Antes de describir las mejores prácticas para la creación de clústeres de desarrollo, merece la pena establecer los objetivos para dichos clústeres. Obviamente, el objetivo final es hacer posible que los desarrolladores puedan crear aplicaciones en Kubernetes fácilmente y de forma rápida. Pero ¿qué significa eso en la práctica y cómo se refleja en las características prácticas del clúster de desarrollo?
Es útil identificar las fases de interacción del desarrollador con el clúster.
La primera fase es la de incorporación. Esto ocurre cuando un nuevo desarrollador se une al equipo de trabajo. Esta fase incluye facilitar al usuario el acceso al clúster, así como proporcionarle orientación en su primer despliegue. El objetivo para esta fase es conseguir que el desarrollador adquiera experiencia en un corto espacio de tiempo. Para este proceso deberíamos establecer el objetivo de Key Performance Indicator (indicador clave de desempeño) (KPI). Un objetivo razonable sería que el usuario pudiera pasar de la nada a poder dirigir la ejecución de la aplicación en curso en menos de media hora. Cada vez que alguien se incorpore al equipo de trabajo, comprobaremos cómo nos va con este objetivo.
La segunda fase es la de desarrollo. Esta es la actividad diaria del desarrollador. El objetivo de esta fase es asegurar una iteración y una depuración rápidas. Los desarrolladores necesitan pasar código al clúster rápidamente y de forma repetitiva. También deben ser capaces de probar su código y depurarlo cuando no funciona correctamente. El KPI para esta fase es más difícil de medir, pero se puede estimar midiendo el tiempo en obtener una pull request (petición de validación) (PR) o el tiempo empleado en el cambio y la ejecución en el clúster, o con encuestas sobre la productividad que percibe el usuario, o ambas. También podremos medirlo en la productividad global de los equipos de trabajo.
La tercera fase es de la de pruebas. Esta fase se intercala con la de desarrollo y se utiliza para validar el código antes de su envío y fusión. El objetivo de esta fase es doble. En primer lugar, el desarrollador debe ser capaz de realizar todas las pruebas de su entorno antes de presentar la PR. En segundo lugar, todas las pruebas deben ejecutarse automáticamente antes de que el código se fusione en el repositorio. Además de estos objetivos, también debemos establecer un KPI para la duración del tiempo que tardan las pruebas en realizarse. A medida que el proyecto se vuelve más complejo, es natural que un mayor número de pruebas necesite más tiempo. Cuando esto sucede, puede ser rentable identificar un conjunto más reducido de pruebas de humo que el desarrollador puede utilizar para la validación inicial antes de enviar la PR. También debemos tener un KPI muy estricto en cuanto a la debilidad de las pruebas. Una prueba problemática es una prueba que ocasionalmente (o no tan ocasionalmente) falla. En cualquier proyecto activo aceptable, un índice de debilidad de más de un fallo por cada mil ejecuciones hará que el desarrollador tenga problemas. Necesitamos asegurar que el entorno del clúster no conduce a pruebas problemáticas. A veces las pruebas problemáticas ocurren debido a problemas en el código, pero también pueden ocurrir debido a interferencias en el entorno de desarrollo (por ejemplo, quedarnos sin recursos o experimentar vecinos ruidosos). Debemos tener la seguridad de que nuestro entorno de desarrollo está libre de tales problemas midiendo la debilidad y actuando rápidamente para arreglarlo.
Creación de un clúster de desarrollo
Cuando alguien empieza a pensar en un desarrollo en Kubernetes, una de las primeras dudas que aparecen es si crear un único gran clúster de desarrollo o tener un clúster de desarrollo por desarrollador. Hay que tener en cuenta que esta opción solo tiene sentido en un entorno en el que la creación de clústeres dinámicos resulta fácil, como es la nube pública. En entornos físicos, es posible que la única opción sea un clúster grande.
Si tenemos opciones, debemos considerar los pros y los contras de cada opción. Si optamos por tener un clúster de desarrollo por usuario, el principal inconveniente de este enfoque es que será más costoso y menos eficiente, y tendremos que gestionar un gran número de clústeres de desarrollo diferentes. Los costes adicionales provienen del hecho de que es probable que cada clúster esté muy infrautilizado. Además, con los desarrolladores trabajando en diferentes clústeres, se hace más difícil rastrear y recolectar recursos que ya no están en uso.




