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.
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:
gitadd
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:
kubectlcreate
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 v2
y 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:
kubectlcreate
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:
kubectlcreate
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:
helminstall
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.