Kitabı oku: «Guía práctica de Kubernetes», sayfa 3
Gestión de autenticación con Secrets
Hasta ahora no hemos entrado en detalle en el servicio Redis al que se conecta nuestro frontend. Pero en cualquier aplicación real necesitamos que las conexiones entre nuestros servicios sean seguras. En parte, se trata de garantizar la seguridad de la comunicación de los usuarios y de sus datos. Además, es esencial evitar errores como, por ejemplo, conectar un frontend de desarrollo con una base de datos de producción.
La autenticación en la base de datos de Redis se realiza mediante una simple contraseña. Parecería conveniente pensar en almacenar esta contraseña en el código fuente de la aplicación, o en un archivo en la imagen, pero ninguna de estas opciones es buena idea por múltiples razones. La primera es que hemos filtrado nuestro secreto (la contraseña) en un entorno en el que no estamos necesariamente pensando en el control de acceso. Si ponemos una contraseña en el control del código fuente, estamos alineando el acceso al código fuente con el acceso a todos los datos secretos. Esto seguramente no sea correcto. Es probable que tengamos un conjunto más amplio de usuarios que puedan acceder a nuestro código fuente del que realmente debería tener acceso a nuestra instancia de Redis. De la misma manera, alguien que tiene acceso a la imagen de nuestro contenedor no necesariamente debe tener acceso a nuestra base de datos de producción.
Además de las preocupaciones sobre el control de acceso, otra razón para evitar la vinculación de los datos secretos al control del código fuente y/o a las imágenes es la parametrización. Deseamos poder utilizar el mismo código fuente e imágenes en una gran variedad de entornos (por ejemplo, desarrollo, canario y producción). Si los datos secretos están estrechamente ligados con el código fuente o con la imagen, se necesita un archivo imagen diferente (o un código diferente) para cada entorno.
Como acabamos de ver ConfigMaps en la sección anterior, el primer pensamiento podría ser el de almacenar la contraseña como si se tratara de una configuración y, a continuación, introducirla en la aplicación como una configuración específica de la misma. Estamos en lo cierto al creer que la separación de la configuración de la aplicación es la misma que la separación de los datos secretos de la aplicación. Pero la verdad es que los datos secretos son un concepto importante en sí mismo. Es probable que deseemos gestionar el control de acceso, la gestión y las actualizaciones de los datos secretos de una manera diferente a la de una configuración. Y lo que es más importante aún, queremos que nuestros desarrolladores piensen de forma diferente cuando accedan a los datos secretos y cuando accedan a la configuración. Por estas razones Kubernetes tiene incorporado el recurso Secret (secreto) para gestionar datos secretos.
Podemos crear una contraseña secreta para nuestra base de datos Redis de la siguiente manera:
kubectl create secret generic redis-passwd --from-literal=passwd=${RANDOM}
Obviamente, es posible que deseemos utilizar algo más que un número aleatorio para la contraseña. Además, es probable que tengamos interés en utilizar un servicio de gestión de secret/key (secreto/clave), ya sea a través del proveedor de cloud computing (computación en la nube) —como Microsoft Azure Key Vault— o mediante un proyecto de código abierto —como HashiCorp’s Vault—. Cuando se utilizan servicios de gestión de claves, estos generalmente tienen una integración más estrecha con los datos secretos de Kubernetes.
Por defecto, los datos secretos en Kubernetes se almacenan sin cifrar. Si deseamos almacenar datos secretos cifrados, podemos hacerlo a través de un proveedor de claves, que nos proporcione una clave que Kubernetes utilizará para cifrar todos los datos secretos en el clúster. Hay que tener en cuenta que, aunque esta acción protege las claves contra ataques directos a la base de datos etcd, necesitamos también tener la seguridad de que el acceso a través del servidor de la API de Kubernetes está debidamente protegido. |
Después de haber almacenado la contraseña de Redis como un dato secreto en Kubernetes, necesitamos enlazar ese dato secreto a la aplicación en ejecución cuando se implemente en Kubernetes. Para hacer esto, podemos usar un Volume (volumen) de Kubernetes. Un Volume es un archivo o directorio que puede montarse en un contenedor en ejecución en un lugar especificado por el usuario. En el caso de datos secretos, Volume se crea como un sistema de archivos con respaldo de RAM tmpfs y, luego, se monta en el contenedor. Esto asegura que, incluso si la máquina está físicamente comprometida (bastante improbable en la nube, pero posible en el centro de datos), sea mucho más difícil que el atacante consiga los datos secretos.
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.
La gestión del estado, en general, es complicada, y Kubernetes no es una excepción. Si ejecutamos la aplicación en un entorno que soporta servicios con estado (por ejemplo, MySQL como servicio, Redis como servicio), generalmente es una buena idea usar esos servicios con estado. Inicialmente, el coste suplementario de un software como servicio (SaaS) con estado puede parecer caro. Pero cuando se tienen en cuenta todos los requisitos operativos de estado (copia de seguridad, localización de datos, redundancia, etc.) y el hecho de que la presencia de estado en un clúster de Kubernetes dificulta mover la aplicación entre clústeres, queda claro que en la mayoría de los casos vale la pena el precio adicional de las aplicaciones SaaS de almacenamiento. En entornos locales en los que no se dispone de SaaS de almacenamiento y que cuentan con un equipo de personas dedicado a proporcionar almacenamiento como servicio a toda la organización es, definitivamente, mejor práctica que permitir que cada equipo de trabajo haga lo suyo. |
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.