Capítulo 1. Configurar un servicio básico

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

Este capítulo describe el procedimiento para configurar una aplicación multicapa sencilla en Kubernetes. El ejemplo que veremos consta de dos niveles: una aplicación web sencilla y una base de datos. Aunque no sea la aplicación más complicada, es un buen punto de partida para aprender a gestionar una aplicación en Kubernetes.

Visión general de la aplicación

La aplicación que utilizaremos para nuestro ejemplo es bastante sencilla. Es un simple servicio de diario con los siguientes detalles:

  • Tiene un servidor de archivos estáticos independiente que utiliza NGINX.

  • Tiene una interfaz de programación de aplicaciones (API) RESTful https://some-host-name.io/api en la ruta /api.

  • Tiene un servidor de archivos en la URL principal, https://some-host-name.io.

  • Utiliza el servicio Let's Encrypt para gestionar la capa de sockets seguros (SSL).

La Figura 1-1 presenta un diagrama de esta aplicación. No te preocupes si no entiendes todas las piezas de inmediato; se explicarán con más detalle a lo largo del capítulo. Recorreremos la construcción de esta aplicación paso a paso, primero utilizando archivos de configuración YAML y luego gráficos de Helm.

Application Diagram
Figura 1-1. Diagrama de nuestro servicio de diario desplegado en Kubernetes

Gestionar archivos de configuración

Antes de que entre en los detalles de cómo construir esta aplicación en Kubernetes, merece la pena hablar de cómo gestionamos las propias configuraciones. Con Kubernetes, todo se representa declarativamente. Esto significa que escribes el estado deseado de la aplicación en el clúster (generalmente en archivos YAML o JSON), y estos estados deseados declarados definen todas las piezas de tu aplicación. Este enfoque declarativo es muy preferible a un enfoque imperativo en el que el estado de tu clúster es la suma de una serie de cambios en el clúster. Si un clúster se configura imperativamente, es difícil comprender y replicar cómo llegó el clúster a ese estado, lo que dificulta la comprensión o la recuperación de los problemas de tu aplicación.

Al declarar el estado de tu aplicación, la gente suele preferir YAML a JSON, aunque Kubernetes admite ambos. Esto se debe a que YAML es algo menos verboso y más editable por humanos que JSON. Sin embargo, hay que tener en cuenta que YAML es sensible a la indentación; a menudo los errores en las configuraciones de Kubernetes pueden deberse a una indentación incorrecta en YAML. Si las cosas no se comportan como se espera, comprobar la indentación es un buen punto de partida para solucionar el problema. La mayoría de los editores admiten resaltado sintáctico tanto para JSON como para YAML. Cuando trabajes con estos archivos, es una buena idea instalar estas herramientas para que te resulte más fácil encontrar errores de autor y archivo en tus configuraciones. También hay una excelente extensión para Visual Studio Code que admite una comprobación de errores más rica para los archivos de Kubernetes.

Dado que el estado declarativo contenido en estos archivos YAML sirve como fuente de verdad para tu aplicación, la administración correcta de este estado es fundamental para el éxito de tu aplicación. Cuando modifiques el estado deseado de tu aplicación, querrás poder gestionar los cambios, validar que son correctos, auditar quién hizo los cambios y, posiblemente, revertirlos si fallan. Afortunadamente, en el contexto de la ingeniería de software, ya hemos desarrollado las herramientas necesarias para gestionar tanto los cambios en el estado declarativo como la auditoría y la reversión. Es decir, las buenas prácticas en torno al control de versiones y la revisión del código se aplican directamente a la tarea de gestionar el estado declarativo de tu aplicación.

Hoy en día, la mayoría de la gente almacena sus configuraciones de Kubernetes en Git. Aunque los detalles específicos del sistema de control de versiones carecen de importancia, muchas herramientas del ecosistema Kubernetes esperan archivos en un repositorio Git. Para la revisión de código hay mucha más heterogeneidad; aunque claramente GitHub es bastante popular, otros utilizan herramientas de revisión de código on premise o servicios. Independientemente de cómo implementes la revisión del código para la configuración de tu aplicación, debes tratarla con la misma diligencia y enfoque que aplicas al control de código fuente.

Cuando llega el momento de diseñar el sistema de archivos de tu aplicación, merece la pena utilizar la organización de directorios que viene con el sistema de archivos para organizar tus componentes. Normalmente, se utiliza un único directorio para englobar un Servicio de Aplicación. La definición de lo que constituye un Servicio de Aplicación puede variar en tamaño de un equipo a otro, pero en general, se trata de un servicio desarrollado por un equipo de 8-12 personas. Dentro de ese directorio, se utilizan subdirectorios para los subcomponentes de la aplicación.

Para nuestra aplicación, dispondremos los archivos como sigue:

journal/
  frontend/
  redis/
  fileserver/

Dentro de cada directorio están los archivos YAML concretos necesarios para definir el servicio. Como verás más adelante, cuando empecemos a desplegar nuestra aplicación en varias regiones o clusters diferentes, esta distribución de archivos se complicará más en .

Creación de un servicio replicado mediante Implementaciones

Para describir nuestra aplicación, empezaremos por el frontend y trabajaremos hacia abajo. La aplicación frontend de la revista es una aplicación Node.js implementada en TypeScript. La aplicación completa es demasiado grande para incluirla en el libro, así que la hemos alojado en nuestro GitHub. Allí también podrás encontrar código para futuros ejemplos, así que merece la pena marcarlo. La aplicación expone un servicio HTTP en el puerto 8080 que sirve solicitudes a la ruta /api/* y utiliza el backend de Redis para añadir, eliminar o devolver las entradas actuales del diario. Si piensas trabajar con los ejemplos YAML que siguen en tu máquina local, querrás crear esta aplicación en una imagen de contenedor utilizando el archivo Dockerfile y enviarla a tu propio repositorio de imágenes. Entonces, en lugar de utilizar nuestro nombre de archivo de ejemplo, deberás incluir el nombre de tu imagen de contenedor en tu código.

Buenas prácticas para la gestión de imágenes

Aunque en general, construir y mantener imágenes de contenedores está fuera del alcance de este libro, merece la pena identificar algunas buenas prácticas generales para construir y nombrar imágenes. En general, el proceso de construcción de imágenes puede ser vulnerable a los "ataques a la cadena de suministro" de. En tales ataques, un usuario malintencionado inyecta código o binarios en alguna dependencia de una fuente de confianza que luego se construye en tu aplicación. Debido al riesgo de tales ataques, es fundamental que cuando construyas tus imágenes te bases sólo en proveedores de imágenes conocidos y de confianza. Alternativamente, puedes crear todas tus imágenes desde cero. Construir desde cero es fácil para algunos lenguajes (por ejemplo, Go) que pueden construir binarios estáticos, pero es significativamente más complicado para lenguajes interpretados como Python, JavaScript o Ruby.

Las otras buenas prácticas para las imágenes se refieren a la nomenclatura. Aunque la versión de una imagen contenedora en un registro de imágenes es teóricamente mutable, debes tratar la etiqueta de versión como inmutable. En concreto, alguna combinación de la versión semántica y el hash SHA de la confirmación en la que se creó la imagen es una buena práctica para nombrar las imágenes (por ejemplo, v1.0.1-bfeda01f). Si no especificas una versión de la imagen, se utiliza por defecto latest. Aunque esto puede ser conveniente en el desarrollo, es una mala idea para el uso en producción porque latest está claramente mutando cada vez que se construye una nueva imagen.

Crear una aplicación replicada

Nuestra aplicación frontend es sin estado; depende totalmente del backend Redis para su estado. Como resultado, podemos replicarla arbitrariamente sin afectar al tráfico. Aunque es poco probable que nuestra aplicación se utilice a gran escala, sigue siendo una buena idea funcionar con al menos dos réplicas para que puedas hacer frente a un fallo inesperado o lanzar una nueva versión de la aplicación sin tiempo de inactividad.

En Kubernetes, el recurso ReplicaSet es el que gestiona directamente la replicación de una versión concreta de tu aplicación en contenedores. Dado que la versión de todas las aplicaciones cambia con el tiempo a medida que modificas el código, no es una buena práctica utilizar directamente un ReplicaSet. En su lugar, utiliza el Recurso de implementación. Una Implementación combina las capacidades de replicación de ReplicaSet con el control de versiones y la posibilidad de realizar una implementación por etapas. Con una Implementación puedes utilizar las herramientas integradas en Kubernetes para pasar de una versión de la aplicación a la siguiente.

El Recurso de implementación de Kubernetes para nuestra aplicación tiene el siguiente aspecto:

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    # All pods in the Deployment will have this label
    app: frontend
  name: frontend
  namespace: default
spec:
  # We should always have at least two replicas for reliability
  replicas: 2
  selector:
    matchLabels:
      app: frontend
  template:
    metadata:
      labels:
        app: frontend
    spec:
      containers:
      - image: my-repo/journal-server:v1-abcde
        imagePullPolicy: IfNotPresent
        name: frontend
        # TODO: Figure out what the actual resource needs are
        resources:
          request:
            cpu: "1.0"
            memory: "1G"
          limits:
            cpu: "1.0"
            memory: "1G"

En esta Implementación hay varias cosas que debes tener en cuenta en . La primera es que estamos utilizando Etiquetas para identificar la Implementación, así como los ReplicaSets y los pods que crea la Implementación. Hemos añadido la etiqueta app: frontend a todos estos recursos para que podamos examinar todos los recursos de una capa concreta en una única solicitud. Verás que, a medida que añadamos otros recursos, seguiremos la misma práctica.

Además, hemos añadido comentarios en varios lugares del YAML. Aunque estos comentarios no llegan al recurso de Kubernetes almacenado en el servidor, al igual que los comentarios en el código, sirven para guiar a las personas que miran esta configuración por primera vez.

Debes observar también que, para los contenedores de la Implementación, hemos especificado las solicitudes de recursos Request y Limit, y hemos establecido Request igual a Limit. Al ejecutar una aplicación, la Solicitud es la reserva que se garantiza en la máquina anfitriona donde se ejecuta. El Límite es el uso máximo de recursos que se permitirá al contenedor. Cuando estés empezando, establecer Solicitud igual a Límite conducirá al comportamiento más predecible de tu aplicación. Esta previsibilidad se consigue a expensas de la utilización de los recursos, ya que establecer Solicitud igual a Límite impide que tus aplicaciones programen en exceso o consuman recursos ociosos en exceso, por lo que no podrás conseguir la máxima utilización a menos que ajustes Solicitud y Límite con mucho, mucho cuidado. A medida que avances en tu comprensión del modelo de recursos de Kubernetes, puedes considerar la posibilidad de modificar Solicitud y Límite para tu aplicación de forma independiente, pero en general la mayoría de los usuarios consideran que la estabilidad de la previsibilidad merece la pena por la reducción de la utilización.

A menudo, como sugiere nuestro comentario, es difícil conocer los valores correctos para estos límites de recursos. Empezar sobrestimando las estimaciones y luego utilizar el monitoreo para afinar los valores correctos es un enfoque bastante bueno. Sin embargo, si estás lanzando un nuevo servicio, recuerda que la primera vez que veas tráfico a gran escala, es probable que tus necesidades de recursos aumenten significativamente. Además, hay algunos lenguajes, especialmente lenguajes con recolección de basura, que consumirán alegremente toda la memoria disponible, lo que puede dificultar la determinación del mínimo correcto para la memoria. En este caso, puede ser necesaria alguna forma de búsqueda binaria, ¡pero recuerda hacerlo en un entorno de pruebas para que no afecte a tu producción en !

Ahora que tenemos definido el Recurso de implementación, lo comprobaremos en el control de versiones y lo desplegaremos en Kubernetes:

git add frontend/deployment.yaml
git commit -m "Added deployment" frontend/deployment.yaml
kubectl apply -f frontend/deployment.yaml

También es una buena práctica asegurarte de que el contenido de tu clúster coincide exactamente con el contenido de tu control de código . El mejor patrón para asegurar esto es adoptar un enfoque GitOps e implementar en producción sólo desde una rama específica de tu control de código fuente, utilizando la automatización de integración continua/entrega continua (CI/CD). De este modo te garantizas que el control de origen y la producción coincidan. Aunque una canalización CI/CD completa pueda parecer excesiva para una aplicación sencilla, la automatización por sí misma, independientemente de la fiabilidad que proporciona, suele merecer el tiempo que se tarda en configurarla. Y el CI/CD es extremadamente difícil de adaptar a una aplicación ya existente e imperativamente desplegada.

Volveremos a este YAML de descripción de la aplicación en secciones posteriores para examinar elementos adicionales como el ConfigMap y los volúmenes secretos, así como como Calidad de servicio del Pod.

Configurar una entrada externa para el tráfico HTTP

Los contenedores de nuestra aplicación ya están desplegados, pero actualmente nadie puede acceder a ella. Por defecto, los recursos del clúster sólo están disponibles dentro del propio clúster. Para exponer nuestra aplicación al mundo, necesitamos crear un servicio y un equilibrador de carga para proporcionar una dirección IP externa y llevar tráfico a nuestros contenedores. Para la exposición externa vamos a utilizar dos recursos de Kubernetes. El primero es un servicio que equilibra la carga del tráfico del Protocolo de Control de Transmisión (TCP) o del Protocolo de Datagramas de Usuario (UDP). En nuestro caso, vamos a utilizar el protocolo TCP. Y el segundo es un recurso Ingress, que proporciona equilibrio de carga HTTP(S) con enrutamiento inteligente de solicitudes basado en rutas y hosts HTTP. Con una aplicación sencilla como ésta, podrías preguntarte por qué elegimos utilizar el Ingress más complejo, pero como verás en secciones posteriores, incluso esta aplicación sencilla atenderá solicitudes HTTP de dos servicios diferentes. Además, tener un Ingress en el perímetro permite flexibilidad para futuras ampliaciones de nuestro servicio.

Nota

El recurso Ingress es uno de los recursos más antiguos de Kubernetes, y a lo largo de los años se han planteado numerosos problemas con la forma en que modela el acceso HTTP a los microservicios, lo que ha llevado al desarrollo de la API Gateway para Kubernetes. La pasarela de API se ha diseñado como una extensión de Kubernetes y requiere la instalación de componentes adicionales en tu clúster. Si descubres que Ingress no satisface tus necesidades, considera pasarte a la API de Pasarela.

Antes de poder definir el recurso Ingress, tiene que haber un Servicio Kubernetes para al que apunte el Ingress. Utilizaremos Etiquetas para dirigir el Servicio a los pods que creamos en la sección anterior. El Servicio es bastante más sencillo de definir que la Implementación y tiene el siguiente aspecto:

apiVersion: v1
kind: Service
metadata:
  labels:
    app: frontend
  name: frontend
  namespace: default
spec:
  ports:
  - port: 8080
    protocol: TCP
    targetPort: 8080
  selector:
    app: frontend
  type: ClusterIP

Después de definir el Servicio, puedes definir un recurso Ingress. A diferencia de los recursos de Servicio, Ingress requiere que se ejecute un contenedor controlador de Ingress en el clúster. Hay varias implementaciones diferentes entre las que puedes elegir, ofrecidas por tu proveedor de la nube o implementadas utilizando servidores de código abierto. Si eliges instalar un proveedor de Ingress de código abierto, es una buena idea utilizar el gestor de paquetes Helm para instalarlo y mantenerlo. Los proveedores de Ingress nginx o haproxy son opciones populares:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: frontend-ingress
spec:
  rules:
  - http:
      paths:
      - path: /testpath
        pathType: Prefix
        backend:
          service:
            name: test
            port:
              number: 8080

Con nuestro recurso Ingress creado, nuestra aplicación está lista para servir tráfico de navegadores web de todo el mundo. A continuación, veremos cómo puedes configurar tu aplicación para que sea fácil de configurar y personalizar .

Configurar una aplicación con ConfigMaps

Toda aplicación necesita cierto grado de configuración. Puede ser el número de entradas del diario que se mostrarán por página, el color de un fondo determinado, la visualización de un día festivo especial, o muchos otros tipos de configuración. Normalmente, separar esa información de configuración de la propia aplicación es una buena práctica a seguir.

Hay varias razones para esta separación. La primera es que puede que quieras configurar el mismo binario de aplicación con configuraciones diferentes según el entorno. En Europa podrías querer iluminar un especial de Pascua, mientras que en China podrías querer mostrar un especial para el Año Nuevo Chino. Además de esta especialización medioambiental, hay razones de Agility para la separación. Normalmente, una versión binaria contiene varias funciones nuevas diferentes; si activas estas funciones mediante código, la única forma de modificar las funciones activas es crear y publicar un nuevo binario, lo que puede ser un proceso caro y lento.

El uso de la configuración para activar un conjunto de funciones significa que puedes activar y desactivar rápidamente (e incluso dinámicamente) funciones en respuesta a las necesidades del usuario o a fallos del código de la aplicación. Las funciones pueden desplegarse y volverse a desplegar en función de cada una de ellas. Esta flexibilidad garantiza que sigas avanzando con la mayoría de las funciones, aunque algunas deban revertirse para solucionar problemas de rendimiento o corrección.

En Kubernetes, este tipo de configuración se representa mediante un recurso llamado ConfigMap. Un ConfigMap contiene varios pares clave/valor que representan información de configuración o un archivo. Esta información de configuración puede presentarse a un contenedor en un pod mediante archivos o variables de entorno. Imagina que quieres configurar tu aplicación de diario online para que muestre un número configurable de entradas de diario por página. Para conseguirlo, puedes definir un ConfigMap de la siguiente forma:

kubectl create configmap frontend-config --from-literal=journalEntries=10

Para configurar tu aplicación, expones la información de configuración como una variable de entorno en la propia aplicación. Para ello, puedes añadir lo siguiente al recurso container en la Implementación que definiste anteriormente:

...
# The containers array in the PodTemplate inside the Deployment
containers:
  - name: frontend
    ...
    env:
    - name: JOURNAL_ENTRIES
      valueFrom:
        configMapKeyRef:
          name: frontend-config
          key: journalEntries
...

Aunque esto demuestra cómo puedes utilizar un ConfigMap para configurar tu aplicación, en el mundo real de las Implementaciones, querrás introducir cambios regulares en esta configuración, al menos semanalmente. Puede resultar tentador hacerlo simplemente cambiando el propio ConfigMap, pero esto no es realmente una buena práctica en, por varias razones: la primera es que cambiar la configuración no provoca realmente una actualización de los pods existentes. La configuración sólo se aplica cuando se reinicia el pod. Como resultado, el despliegue no se basa en la salud y puede ser ad hoc o aleatorio. Otra razón es que el único versionado del ConfigMap está en tu control de versiones, y puede ser muy difícil realizar una reversión.

Un enfoque mejor es poner un número de versión en el nombre del propio ConfigMap. En lugar de llamarlo frontend-config, llámalo frontend-config-v1. Cuando quieras hacer un cambio, en lugar de actualizar el ConfigMap, crea un nuevo ConfigMap v2y actualiza el Recurso de implementación para que utilice esa configuración. Cuando lo hagas, se activará automáticamente un Recurso de implementación, utilizando la comprobación de estado y las pausas adecuadas entre los cambios. Además, si alguna vez necesitas volver atrás, la configuración de v1 se encuentra en el clúster y volver atrás es tan sencillo como actualizar de nuevo la Implementación .

Gestionar la autenticación con secretos

Hasta ahora, en no hemos hablado realmente del servicio Redis al que se conecta nuestro frontend. Pero en cualquier aplicación real necesitamos asegurar las conexiones entre nuestros servicios. En parte, esto es para garantizar la seguridad de los usuarios y sus datos, y además, es esencial para evitar errores como conectar un frontend de desarrollo con una base de datos de producción.

La base de datos Redis se autentica mediante una simple contraseña. Podría ser conveniente pensar que almacenarías esta contraseña en el código fuente de tu aplicación, o en un archivo de tu imagen, pero ambas son malas ideas por varias razones. La primera es que has filtrado tu secreto (la contraseña) en un entorno en el que no estás pensando necesariamente en el control de acceso . Si pones una contraseña en el control de tu fuente, estás alineando el acceso a tu fuente con el acceso a todos los secretos. Esta no es la mejor forma de proceder, porque probablemente tendrás un conjunto más amplio de usuarios que pueden acceder a tu código fuente de los que realmente deberían tener acceso a tu instancia de Redis. Del mismo modo, alguien que tenga acceso a tu imagen de contenedor no debería tener necesariamente acceso a tu base de datos de producción.

Además de las preocupaciones sobre el control de acceso, otra razón para evitar vincular secretos al control de código fuente y/o a las imágenes es la parametrización de. Quieres poder utilizar el mismo código fuente y las mismas imágenes en distintos entornos (por ejemplo, desarrollo, canario y producción). Si los secretos están fuertemente ligados al código fuente o a una imagen, necesitarás una imagen diferente (o un código diferente) para cada entorno.

Habiendo visto ConfigMaps en la sección anterior, podrías pensar inmediatamente que la contraseña podría almacenarse como una configuración y luego rellenarse en la aplicación como una configuración específica de la aplicación. Tienes toda la razón al creer que la separación de la configuración de la aplicación es la misma que la separación de los secretos de la aplicación. Pero lo cierto es que un secreto es un concepto importante por sí mismo. Es probable que quieras gestionar el control de acceso, la manipulación y las actualizaciones de los secretos de una forma distinta a la de una configuración. Y lo que es más importante, quieres que tus desarrolladores piensen de forma diferente cuando acceden a los secretos que cuando acceden a la configuración. Por estas razones, Kubernetes tiene incorporado el recurso Secreto para gestionar los datos secretos.

Puedes crear una contraseña secreta para tu base de datos Redis de la siguiente manera:

kubectl create secret generic redis-passwd --from-literal=passwd=${RANDOM}

Obviamente, es posible que quieras utilizar algo distinto a un número aleatorio para tu contraseña. Además, es probable que quieras utilizar un servicio de gestión de secretos/claves, ya sea a través de tu proveedor en la nube, como Microsoft Azure Key Vault, o de un proyecto de código abierto, como HashiCorp's Vault. Cuando utilizas un servicio de gestión de claves, suelen tener una integración más estrecha con los secretos de Kubernetes.

Una vez que hayas almacenado la contraseña de Redis como secreto en Kubernetes, tendrás que vincular ese secreto de a la aplicación en ejecución cuando se despliegue en Kubernetes. Para ello, puedes utilizar un Volumen de Kubernetes. Un Volumen es un archivo o directorio que puede montarse en un contenedor en ejecución en una ubicación especificada por el usuario. En el caso de los secretos, el Volumen se crea como un sistema de archivos tmpfs respaldado por RAM y luego se monta en el contenedor. Esto garantiza que, aunque la máquina se vea físicamente comprometida (algo bastante improbable en la nube, pero posible en el centro de datos), los secretos sean mucho más difíciles de obtener para un atacante.

Nota

Los secretos en Kubernetes se almacenan sin cifrar por defecto. Si quieres almacenar los secretos encriptados, puedes integrarte con un proveedor de claves que te proporcione una clave que Kubernetes utilizará para encriptar todos los secretos del clúster. Ten en cuenta que, aunque esto asegura las claves contra ataques directos a la base de datos etcd, debes asegurarte de que el acceso a través del servidor API de Kubernetes está debidamente protegido.

Para añadir un Volumen secreto a una Implementación, tienes que especificar dos nuevas entradas en el YAML de la Implementación. La primera es una entrada volume para el pod que añade el Volumen al pod:

...
  volumes:
  - name: passwd-volume
    secret:
    secretName: redis-passwd

Los controladores de la Interfaz de Almacenamiento de Contenedores (CSI) te permiten utilizar sistemas de gestión de claves (KMS) que se encuentran fuera de tu clúster de Kubernetes. Esto suele ser un requisito de cumplimiento y seguridad en organizaciones grandes o reguladas. Si utilizas uno de estos controladores CSI, tu Volumen tendría el siguiente aspecto:

...
  volumes:
  - name: passwd-volume
    csi:
      driver: secrets-store.csi.k8s.io
      readOnly: true
      volumeAttributes:
        secretProviderClass: "azure-sync"
...

Independientemente del método que utilices, con el Volumen definido en el pod, tienes que montarlo en un contenedor concreto. Esto se hace a través del campo volumeMounts de la descripción del contenedor:

...
  volumeMounts:
  - name: passwd-volume
    readOnly: true
    mountPath: "/etc/redis-passwd"
...

Esto monta el Volumen secreto en el directorio redis-passwd para poder acceder a él desde el código cliente. Juntando todo esto, tienes la Implementación completa como sigue:

apiVersion: apps/v1
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:
          requests:
            cpu: "1.0"
            memory: "1G"
          limits:
            cpu: "1.0"
            memory: "1G"
      volumes:
        - name: passwd-volume
          secret:
            secretName: redis-passwd

Llegados a este punto, hemos configurado la aplicación cliente para que disponga de un secreto para autenticarse en el servicio Redis. Configurar Redis para que utilice esta contraseña es similar; lo montamos en el pod de Redis y cargamos la contraseña desde el archivo .

Implementación de una base de datos con estado simple

Aunque conceptualmente desplegar una aplicación con estado es similar a desplegar un cliente como nuestro frontend, el estado conlleva más complicaciones. La primera es que en Kubernetes un pod puede ser reprogramado por varias razones, como la salud del nodo, una actualización o un reequilibrio. Cuando esto ocurre, el pod puede trasladarse a una máquina diferente. Si los datos asociados a la instancia de Redis se encuentran en una máquina concreta o dentro del propio contenedor, esos datos se perderán cuando el contenedor migre o se reinicie. Para evitarlo, al ejecutar cargas de trabajo con estado en Kubernetes es importante utilizar PersistentVolumesremotos para gestionar el estado asociado a la aplicación.

Hay una gran variedad de implementaciones de PersistentVolumes en Kubernetes, pero todas comparten características comunes. Al igual que los Volúmenes secretos descritos anteriormente, están asociados a un pod y montados en un contenedor en una ubicación concreta. A diferencia de los secretos, los VolúmenesPersistentes son generalmente almacenamiento remoto montado a través de algún tipo de protocolo de red, ya sea basado en archivos, como el sistema de archivos de red (NFS) o el Bloque de mensajes de servidor (SMB), o basado en bloques (iSCSI, discos basados en la nube, etc.). Generalmente, para aplicaciones como las bases de datos, los discos basados en bloques son preferibles porque ofrecen un mejor rendimiento, pero si el rendimiento es menos importante, los discos basados en archivos ofrecen a veces una mayor flexibilidad.

Nota

Gestionar el estado en general es complicado, y Kubernetes no es una excepción. Si estás funcionando en un entorno que admite servicios con estado (por ejemplo, MySQL como servicio, Redis como servicio),suele ser una buena idea utilizar esos servicios con estado. Inicialmente, el sobrecoste de un software como servicio (SaaS) con estado puede parecer caro, pero cuando tienes en cuenta todos los requisitos operativos del estado (copia de seguridad, localidad de datos, redundancia, etc.), y el hecho de que la presencia de estado en un clúster de Kubernetes dificulta el traslado de aplicaciones entre clústeres, queda claro que, en la mayoría de los casos, el SaaS de almacenamiento merece el sobrecoste. En entornos locales en los que el almacenamiento SaaS no está disponible, tener un equipo dedicado a proporcionar almacenamiento como servicio a toda la organización es definitivamente una práctica mejor que permitir que cada equipo lo construya por sí mismo.

Para desplegar nuestro servicio Redis, utilizamos un recurso StatefulSet de . Añadido después de la versión inicial de Kubernetes como complemento de los recursos ReplicaSet, un StatefulSet ofrece garantías ligeramente más sólidas, como nombres coherentes (¡sin hashes aleatorios!) y un orden definido para el aumento y la reducción de escala. Cuando despliegas un singleton, esto es algo menos importante, pero cuando quieres desplegar estado replicado, estos atributos son muy convenientes.

Para obtener un PersistentVolume para nuestro Redis, utilizamos un PersistentVolumeClaim. Puedes pensar en una claim como una "solicitud de recursos". Nuestro Redis declara abstractamente que quiere 50 GB de almacenamiento, y el clúster Kubernetes determina cómo aprovisionar un PersistentVolume adecuado. Hay dos razones para ello. La primera es que podemos escribir un StatefulSet que sea portable entre diferentes nubes y en las instalaciones, donde los detalles de los discos pueden ser diferentes. La otra razón es que, aunque muchos tipos de PersistentVolume sólo pueden montarse en un único pod, podemos utilizar las reclamaciones de Volumen para escribir una plantilla que pueda replicarse y seguir teniendo asignado a cada pod su propio PersistentVolume específico.

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 despliega una única instancia de tu servicio Redis, pero supongamos que quieres replicar el clúster Redis para escalar las lecturas y resistir los fallos. Para ello, obviamente tienes que aumentar el número de réplicas a tres, pero también tienes que asegurarte de que las dos nuevas réplicas se conectan al maestro de escritura de Redis. Veremos cómo realizar esta conexión en la siguiente sección.

Cuando creas el Servicio headless para el Redis StatefulSet, se crea una entrada DNSredis-0.redis; ésta es la dirección IP de la primera réplica. Puedes utilizarla para crear un script sencillo que pueda lanzarse en todos los contenedores:

#!/bin/sh

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

Puedes crear este script como un ConfigMap:

kubectl create configmap redis-config --from-file=./launch.sh

A continuación, añade este ConfigMap a tu StatefulSet y utilízalo como comando para el contenedor. Añadamos también la contraseña para la autenticación que creamos anteriormente en el capítulo.

La Redis completa de tres réplicas tiene el siguiente aspecto:

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

Ahora tu Redis está agrupado en clúster para la tolerancia a fallos. Si alguna de las tres réplicas de Redis falla por cualquier motivo, tu aplicación puede seguir funcionando con las dos réplicas restantes hasta que la tercera réplica esté restaurada.

Creación de un equilibrador de carga TCP mediante servicios

Ahora que hemos desplegado el servicio Redis con estado, tenemos que ponerlo a disposición de nuestro frontend. Para ello, crearemos dos Servicios Kubernetes diferentes. El primero es el Servicio para leer datos de Redis. Como Redis está replicando los datos a los tres miembros del StatefulSet, no nos importa a qué lectura va nuestra solicitud. En consecuencia, utilizamos un Servicio 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 las escrituras, tienes que apuntar al maestro de Redis (réplica nº 0). Para ello, crea un Servicio sin cabeza. Un Servicio sin cabeza no tiene una dirección IP de clúster, sino que programa una entrada DNS para cada pod del StatefulSet. Esto significa que podemos acceder a nuestro maestro 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

Así, cuando queramos conectarnos a Redis para escrituras o pares de lectura/escritura transaccionales, podemos crear un cliente de escritura independiente conectado a el servidor redis-0.redis-write.

Utilizar Ingress para dirigir el tráfico a un servidor de archivos estático

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 e imágenes. Para nosotros es más eficiente y más específico separar el servidor de archivos estáticos de nuestro frontend servidor de API descrito anteriormente. Podemos utilizar fácilmente un servidor de archivos estáticos estándar de alto rendimiento, como NGINX, para servir archivos, mientras permitimos que nuestros equipos de desarrollo se centren en el código necesario para implementar nuestra API.

Afortunadamente, el recurso Ingress facilita mucho este tipo de arquitectura de miniservicio. Al igual que en el frontend, podemos utilizar un Recurso de implementación para describir un servidor NGINX replicado. Construyamos las imágenes estáticas en el contenedor NGINX y despleguémoslas en cada réplica. El Recurso de implementación tiene el siguiente aspecto:

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: fileserver
  name: fileserver
  namespace: default
spec:
  replicas: 2
  selector:
    matchLabels:
      app: fileserver
  template:
    metadata:
      labels:
        app: fileserver
    spec:
      containers:
      # This image is intended as an example, replace it with your own
      # static files image.
      - image: my-repo/static-files:v1-abcde
        imagePullPolicy: Always
        name: fileserver
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
        resources:
          requests:
            cpu: "1.0"
            memory: "1G"
          limits:
            cpu: "1.0"
            memory: "1G"
      dnsPolicy: ClusterFirst
      restartPolicy: Always

Ahora que hay un servidor web estático replicado en funcionamiento, crearás igualmente un recurso de servicio para que actúe como equilibrador de carga:

apiVersion: v1
kind: Service
metadata:
  labels:
    app: fileserver
  name: fileserver
  namespace: default
spec:
  ports:
  - port: 80
    protocol: TCP
    targetPort: 80
  selector:
    app: fileserver
  sessionAffinity: None
  type: ClusterIP

Ahora que tienes un Servicio para tu servidor de archivos estáticos, amplía el recurso Ingress para que contenga la nueva ruta. Es importante tener en cuenta que debes colocar la ruta / después de la ruta /api, o de lo contrario subsumiría a /api y dirigiría las peticiones de la API al servidor de archivos estático. El nuevo Ingress tiene este aspecto

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: frontend-ingress
spec:
  rules:
  - http:
      paths:
      - path: /api
        pathType: Prefix
        backend:
          service:
            name: fileserver
            port:
              number: 8080
      # NOTE: this should come after /api or else it will hijack requests
      - path: /
        pathType: Prefix
        backend:
          service:
            name: fileserver
            port:
              number: 80

Ahora que has configurado un recurso Ingress para tu servidor de archivos, además del Ingress para la API que configuraste antes, la interfaz de usuario de la aplicación está lista para ser utilizada. La mayoría de las aplicaciones modernas combinan archivos estáticos, normalmente HTML y JavaScript, con un servidor API dinámico implementado en un lenguaje de programación del lado del servidor como Java, .NET o Go.

Parametrizar tu aplicación utilizando Helm

Todo lo que hemos tratado hasta ahora en se centra en la implementación de una única instancia de nuestro servicio en un único clúster. Sin embargo, en realidad, casi todos los servicios y todos los equipos de servicios van a necesitar implementarse en varios entornos (aunque compartan un clúster). Incluso si eres un único desarrollador que trabaja en una única aplicación, es probable que quieras tener al menos una versión de desarrollo y una versión de producción de tu aplicación para poder iterar y desarrollar sin interrumpir a los usuarios de producción. Después de tener en cuenta las pruebas de integración y el CI/CD, es probable que incluso con un único servicio y un puñado de desarrolladores, quieras desplegar en al menos tres entornos diferentes, y posiblemente más si consideras la gestión de fallos a nivel de centro de datos. Exploremos algunas opciones de implementación.

Un modo de fallo inicial para muchos equipos es simplemente copiar los archivos de un clúster a otro. En lugar de tener un único directorio frontend/, tienes un par de directorios frontend-producción/ y frontend-desarrollo/. Aunque ésta es una opción viable, también es peligrosa porque ahora te encargas de garantizar que estos archivos permanezcan sincronizados entre sí. Si se pretendiera que fueran totalmente idénticos, esto podría ser fácil, pero es de esperar que haya cierta desviación entre desarrollo y producción, porque estarás desarrollando nuevas funciones. Es fundamental que la desviación sea intencionada y fácil de gestionar.

Otra opción para conseguirlo sería utilizar ramas y control de versiones, con las ramas de producción y desarrollo partiendo de un repositorio central y las diferencias entre las ramas claramente visibles. Esta puede ser una opción viable para algunos equipos, pero la mecánica de movimiento entre ramas es un reto cuando quieres implementar software simultáneamente en distintos entornos (por ejemplo, un sistema CI/CD que se despliega en varias regiones diferentes de la nube).

En consecuencia, la mayoría de la gente acaba 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 queespecializan la plantilla a una configuración de entorno específica. De esta forma, puedes tener una configuración compartida en general, con una personalización intencionada (y fácil de entender) según sea necesario. Hay una gran variedad de sistemas de plantillas para Kubernetes, pero el más popular con diferencia es Helm.

En Helm, una aplicación se empaqueta en una colección de archivos llamada carta (las bromas náuticas abundan en el mundo de los contenedores y Kubernetes).

Un gráfico comienza con un archivo chart.yaml, que define los metadatos del propio gráfico:

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 del gráfico (por ejemplo, frontend/). Dentro de este directorio, hay un directorio de plantillas, que es donde se colocan las plantillas. Una plantilla es básicamente un archivo YAML de los ejemplos anteriores, con algunos de los valores del archivo sustituidos por referencias a parámetros. Por ejemplo, imagina que quieres parametrizar el número de réplicas de tu frontend. Anteriormente, esto es lo que tenía la Implementación:

...
spec:
  replicas: 2
...

En el archivo de plantilla(frontend-deployment.tmpl), en cambio, tiene el siguiente aspecto:

...
spec:
  replicas: {{ .replicaCount }}
...

Esto significa que cuando despliegues el gráfico, sustituirás el valor de las réplicas por el parámetro adecuado. Los propios parámetros se definen en un archivo values.yaml. Habrá un archivo values por cada entorno en el que deba desplegarse la aplicación. El archivo values para este gráfico sencillo tendría el siguiente aspecto:

replicaCount: 2

Juntando todo esto, puedes implementar este gráfico utilizando la herramienta helm, como se indica a continuación:

helm install path/to/chart --values path/to/environment/values.yaml

Esto parametriza tu aplicación y la implementa en Kubernetes. Con el tiempo, estas parametrizaciones crecerán para abarcar la variedad de entornos de tu aplicación .

Implementación de Servicios Buenas prácticas

Kubernetes es un potente sistema que puede parecer complejo. Pero configurar una aplicación básica para que tenga éxito puede ser sencillo si utilizas las siguientes buenas prácticas:

  • La mayoría de los servicios deben desplegarse como Recurso de implementación. Las Implementaciones crean réplicas idénticas para aumentar la redundancia y la escala.

  • Las Implementaciones pueden exponerse utilizando un Servicio, que es en realidad un equilibrador de carga. Un Servicio puede exponerse dentro de un clúster (por defecto) o externamente. Si quieres exponer una aplicación HTTP, puedes utilizar un controlador Ingress para añadir cosas como enrutamiento de peticiones y SSL.

  • Con el tiempo, querrás parametrizar tu aplicación para que su configuración sea más reutilizable en distintos entornos. Las herramientas de empaquetado como Helm son la mejor opción para este tipo de parametrización.

Resumen

La aplicación construida en este capítulo es sencilla, pero contiene casi todos los conceptos que necesitarás para construir aplicaciones más grandes y complicadas. Comprender cómo encajan las piezas y cómo utilizar los componentes fundamentales de Kubernetes es clave para trabajar con éxito con Kubernetes.

Sentar las bases correctas mediante el control de versiones, la revisión del código y la entrega continua de tu servicio garantiza que, construyas lo que construyas, esté construido con solidez. A medida que avancemos en los temas más avanzados de los capítulos siguientes, ten presente esta información básica.

Get Las mejores prácticas de Kubernetes, 2ª edición now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.