Un mejor enfoque para la implementación continua de Google Cloud

Publicado: 2022-03-11

La implementación continua (CD) es la práctica de implementar automáticamente código nuevo en producción. La mayoría de los sistemas de implementación continua validan que el código que se implementará es viable mediante la ejecución de pruebas unitarias y funcionales, y si todo se ve bien, se implementa la implementación. La implementación en sí generalmente ocurre en etapas para poder revertir si el código no se comporta como se esperaba.

No hay escasez de publicaciones de blog sobre cómo implementar su propia canalización de CD utilizando varias herramientas como la pila de AWS, la pila de Google Cloud, la canalización de Bitbucket, etc. Pero encuentro que la mayoría de ellos no se ajustan a mi idea de lo que es una buena canalización de CD. debería verse como: uno que compila primero, y prueba e implementa solo ese único archivo compilado.

En este artículo, voy a crear una canalización de implementación continua basada en eventos que se compila primero y luego ejecuta pruebas en nuestro artefacto final de implementación. Esto no solo hace que los resultados de nuestras pruebas sean más confiables, sino que también hace que la canalización de CD sea fácilmente ampliable. Se vería algo como esto:

  1. Se realiza una confirmación en nuestro repositorio de origen.
  2. Esto desencadena una compilación de la imagen asociada.
  3. Las pruebas se ejecutan en el artefacto construido.
  4. Si todo se ve bien, la imagen se implementa en producción.

Este artículo asume al menos una familiaridad pasajera con Kubernetes y la tecnología de contenedores, pero si no está familiarizado o le vendría bien un repaso, consulte ¿Qué es Kubernetes? Una guía para la contenedorización y el despliegue.

El problema con la mayoría de las configuraciones de CD

Aquí está mi problema con la mayoría de las canalizaciones de CD: generalmente hacen todo en el archivo de compilación. La mayoría de las publicaciones de blog que he leído sobre esto tendrán alguna variación de la siguiente secuencia en cualquier archivo de compilación que tengan ( cloudbuild.yaml para Google Cloud Build, bitbucket-pipeline.yaml para Bitbucket).

  1. Ejecutar pruebas
  2. Crear imagen
  3. Empujar imagen al repositorio del contenedor
  4. Actualizar entorno con nueva imagen

No estás ejecutando tus pruebas en tu artefacto final.

Al hacer las cosas en este orden, ejecuta sus pruebas. Si tienen éxito, crea la imagen y continúa con el resto de la canalización. ¿Qué sucede si el proceso de construcción cambió su imagen de tal manera que las pruebas ya no pasarían? En mi opinión, debe comenzar produciendo un artefacto (la imagen final del contenedor) y este artefacto no debe cambiar entre la compilación y el momento en que se implementa en producción. Esto asegura que los datos que tiene sobre dicho artefacto (resultados de pruebas, tamaño, etc.) sean siempre válidos.

Su entorno de construcción tiene las "llaves del reino".

Al usar su entorno de compilación para implementar su imagen en su pila de producción, está permitiendo que cambie su entorno de producción. Veo esto como algo muy malo porque cualquier persona con acceso de escritura a su repositorio de origen ahora puede hacer lo que quiera en su entorno de producción.

Debe volver a ejecutar toda la tubería si el último paso falló.

Si el último paso falla (por ejemplo, debido a un problema de credenciales), debe volver a ejecutar toda la canalización, lo que consume tiempo y otros recursos que podrían invertirse mejor en otra cosa.

Esto me lleva a mi punto final:

Tus pasos no son independientes.

En un sentido más general, tener pasos independientes le permite tener más flexibilidad en su canalización. Supongamos que desea agregar pruebas funcionales a su canalización. Al tener sus pasos en un archivo de compilación, debe hacer que su entorno de compilación active un entorno de prueba funcional y ejecute las pruebas en él (lo más probable es que sea secuencial). Si sus pasos fueran independientes, podría tener tanto sus pruebas unitarias como las pruebas funcionales iniciadas por el evento "imagen construida". Luego se ejecutarían en paralelo en su propio entorno.

Mi configuración de CD ideal

En mi opinión, una mejor manera de abordar este problema sería tener una serie de pasos independientes, todos unidos entre sí por un mecanismo de eventos.

Esto tiene varias ventajas en comparación con el método anterior:

Puede realizar varias acciones independientes en diferentes eventos.

Como se indicó anteriormente, la construcción exitosa de una nueva imagen solo publicaría un evento de "construcción exitosa". A su vez, podemos hacer que se ejecuten varias cosas cuando se dispara este evento. En nuestro caso, comenzaríamos con las pruebas unitarias y funcionales. También puede pensar en cosas como alertar al desarrollador cuando se activa un evento de falla de compilación o si las pruebas no pasan.

Cada entorno tiene su propio conjunto de derechos.

Al hacer que cada paso suceda en su propio entorno, eliminamos la necesidad de que un solo entorno tenga todos los derechos. Ahora, el entorno de compilación solo puede compilar, el entorno de prueba solo puede probar y el entorno de implementación solo puede implementar. Esto le permite estar seguro de que, una vez que se haya construido su imagen, no cambiará. El artefacto que se produjo es el que terminará en su pila de producción. También permite una auditoría más sencilla de qué paso de su canalización está haciendo qué, ya que puede vincular un conjunto de credenciales a un paso.

Hay más flexibilidad.

¿Quiere enviar un correo electrónico a alguien en cada compilación exitosa? Simplemente agregue algo que reaccione a ese evento y envíe un correo electrónico. Es fácil: no tiene que cambiar su código de compilación y no tiene que codificar el correo electrónico de alguien en su repositorio de origen.

Los reintentos son más fáciles.

Tener pasos independientes también significa que no tiene que reiniciar toda la canalización si falla un paso. Si la condición de falla es temporal o se solucionó manualmente, puede volver a intentar el paso que falló. Esto permite una canalización más eficiente. Cuando un paso de compilación lleva varios minutos, es bueno no tener que reconstruir la imagen solo porque olvidó otorgarle a su entorno de implementación acceso de escritura a su clúster.

Implementación de la implementación continua de Google Cloud

Google Cloud Platform tiene todas las herramientas necesarias para crear un sistema de este tipo en poco tiempo y con muy poco código.

Nuestra aplicación de prueba es una aplicación Flask simple que solo sirve un fragmento de texto estático. Esta aplicación se implementa en un clúster de Kubernetes que sirve a Internet en general.

Implementaré una versión simplificada de la canalización que presenté anteriormente. Básicamente, eliminé los pasos de prueba, por lo que ahora se ve así:

  • Se realiza una nueva confirmación en el repositorio de origen.
  • Esto desencadena una creación de imagen. Si tiene éxito, se envía al repositorio del contenedor y se publica un evento en un tema de Pub/Sub.
  • Se suscribe un pequeño script a ese sujeto y se verifican los parámetros de la imagen; si coinciden con lo que solicitamos, se implementa en el clúster de Kubernetes.

Aquí hay una representación gráfica de nuestra canalización.

Representación gráfica de la tubería.

El flujo es el siguiente:

  1. Alguien se compromete con nuestro repositorio.
  2. Esto desencadena una compilación en la nube que genera una imagen de Docker basada en el repositorio de origen.
  3. La compilación en la nube envía la imagen al repositorio del contenedor y publica un mensaje en la publicación/suscripción en la nube.
  4. Esto desencadena una función en la nube que verifica los parámetros del mensaje publicado (estado de la compilación, nombre de la imagen creada, etc.).
  5. Si los parámetros son buenos, la función de nube actualiza una implementación de Kubernetes con la nueva imagen.
  6. Kubernetes implementa nuevos contenedores con la nueva imagen.

Código fuente

Nuestro código fuente es una aplicación Flask muy simple que solo sirve texto estático. Esta es la estructura de nuestro proyecto:

 ├── docker │ ├── Dockerfile │ └── uwsgi.ini ├── k8s │ ├── deployment.yaml │ └── service.yaml ├── LICENSE ├── Pipfile ├── Pipfile.lock └── src └── main.py

El directorio de Docker contiene todo lo necesario para construir la imagen de Docker. La imagen se basa en la imagen de uWSGI y Nginx y solo instala las dependencias y copia la aplicación en la ruta correcta.

El directorio k8s contiene la configuración de Kubernetes. Consta de un servicio y una implementación. La implementación inicia un contenedor basado en la imagen creada a partir de Dockerfile . Luego, el servicio inicia un balanceador de carga que tiene una dirección IP pública y redirige a los contenedores de la aplicación.

Construcción en la nube

La configuración de compilación en la nube en sí se puede realizar a través de la consola en la nube o la línea de comando de Google Cloud. Elegí usar la consola en la nube.

Captura de pantalla de la consola en la nube

Aquí, construimos una imagen para cualquier compromiso en cualquier rama, pero podría tener diferentes imágenes para desarrollo y producción, por ejemplo.

Si la compilación es exitosa, la compilación en la nube publicará la imagen en el registro del contenedor por su cuenta. A continuación, publicará un mensaje en el tema pub/sub de compilaciones en la nube.

La compilación en la nube también publica mensajes cuando una compilación está en progreso y cuando falla, por lo que también podría hacer que las cosas reaccionen a esos mensajes.

La documentación para las notificaciones de publicación/suscripción de la compilación en la nube está aquí y el formato del mensaje se puede encontrar aquí

Publicación/suscripción en la nube

Si mira en la pestaña pub/sub de la nube en la consola de la nube, verá que la compilación en la nube ha creado un tema llamado compilaciones en la nube. Aquí es donde la compilación en la nube publica sus actualizaciones de estado.

Captura de pantalla del proyecto Pub/Sub

Función de la nube

Lo que haremos ahora es crear una función en la nube que se active en cualquier mensaje publicado en el tema de compilaciones en la nube. Nuevamente, puede usar la consola en la nube o la utilidad de línea de comandos de Google Cloud. Lo que hice en mi caso es que uso la compilación en la nube para implementar la función de la nube cada vez que hay un cambio en ella.

El código fuente de la función de la nube está aquí.

Primero veamos el código que implementa esta función en la nube:

 steps: - name: 'gcr.io/cloud-builders/gcloud' id: 'test' args: ['functions', 'deploy', 'new-image-trigger', '--runtime=python37', '--trigger-topic=cloud-builds', '--entry-point=onNewImage', '--region=us-east1', '--source=https://source.developers.google.com/projects/$PROJECT_ID/repos/$REPO_NAME']

Aquí, usamos la imagen de Google Cloud Docker. Esto permite ejecutar comandos de GCcloud fácilmente. Lo que estamos ejecutando es el equivalente a ejecutar el siguiente comando directamente desde una terminal:

 gcloud functions deploy new-image-trigger --runtime=python37 --trigger-topic=cloud-builds --entry-point=onNewImage --region=us-east1 --source=https://source.developers.google.com/projects/$PROJECT_ID/repos/$REPO_NAME

Le pedimos a Google Cloud que implemente una nueva función en la nube (o que la reemplace si ya existe una función con ese nombre en esa región) que usará el tiempo de ejecución de Python 3.7 y se activará con nuevos mensajes en el tema de compilaciones en la nube. También le indicamos a Google dónde encontrar el código fuente para esa función (aquí PROJECT_ID y REPO_NAME son variables de entorno que se establecen en el proceso de compilación). También le decimos qué función llamar como punto de entrada.

Como nota al margen, para que esto funcione, debe proporcionar a su cuenta de servicio de cloudbuild tanto el "desarrollador de funciones en la nube" como el "usuario de la cuenta de servicio" para que pueda implementar la función en la nube.

Aquí hay algunos fragmentos comentados del código de función de la nube

Los datos del punto de entrada contendrán el mensaje recibido en el tema de pub/sub.

 def onNewImage(data, context):

El primer paso es obtener las variables para esa implementación específica del entorno (las definimos modificando la función de la nube en la consola de la nube.

 project = os.environ.get('PROJECT') zone = os.environ.get('ZONE') cluster = os.environ.get('CLUSTER') deployment = os.environ.get('DEPLOYMENT') deploy_image = os.environ.get('IMAGE') target_container = os.environ.get('CONTAINER')

Omitiremos la parte en la que verificamos que la estructura del mensaje es la que esperamos y validamos que la compilación fue exitosa y produjo un artefacto de imagen.

El siguiente paso es asegurarnos de que la imagen que se creó es la que queremos implementar.

 image = decoded_data['results']['images'][0]['name'] image_basename = image.split('/')[-1].split(':')[0] if image_basename != deploy_image: logging.error(f'{image_basename} is different from {deploy_image}') return

Ahora, obtenemos un cliente de Kubernetes y recuperamos la implementación que queremos modificar.

 v1 = get_kube_client(project, zone, cluster) dep = v1.read_namespaced_deployment(deployment, 'default') if dep is None: logging.error(f'There was no deployment named {deployment}') return

Finalmente, parcheamos el deployment con la nueva imagen; Kubernetes se encargará de implementarlo.

 for i, container in enumerate(dep.spec.template.spec.containers): if container.name == target_container: dep.spec.template.spec.containers[i].image = image logging.info(f'Updating to {image}') v1.patch_namespaced_deployment(deployment, 'default', dep)

Conclusión

Este es un ejemplo muy básico de cómo me gusta que las cosas se diseñen en una canalización de CD. Podría tener más pasos simplemente cambiando qué evento pub/sub desencadena qué.

Por ejemplo, podría ejecutar un contenedor que ejecute las pruebas dentro de la imagen y publique un evento en caso de éxito y otro en caso de falla y reaccione ante ellos actualizando una implementación o alertando según el resultado.

La canalización que construimos es bastante simple, pero podría escribir otras funciones en la nube para otras partes (por ejemplo, una función en la nube que enviaría un correo electrónico al desarrollador que comprometió el código que rompió las pruebas unitarias).

Como puede ver, nuestro entorno de compilación no puede cambiar nada en nuestro clúster de Kubernetes y nuestro código de implementación (la función de la nube) no puede modificar la imagen que se creó. Nuestra separación de privilegios se ve bien, y podemos dormir tranquilos sabiendo que un desarrollador deshonesto no derribará nuestro clúster de producción. También podemos dar a nuestros desarrolladores más orientados a las operaciones acceso al código de función de la nube para que puedan arreglarlo o mejorarlo.

Si tiene alguna pregunta, comentario o mejora, no dude en comunicarse en los comentarios a continuación.