Probando Pub/Sub con CloudBuild y CloudFunctions


En el siguiente post vamos a ver como gestionar y configurar varias de las piezas más utilizadas en la nube de Google, como son las subscripciones a las colas (topics de Pub/Sub), la configuración y ejecución de una de las fuciones serverless de GCP, las CloudFunctions, junto con los bucket y una base de datos noSQL “Firestore”.

Además realizaremos una integración y despliegue continuo (CI/CD) para el desarrollo de las CloudFunctions mediante un flujo descrito en CloudBuild y utilizando un repositorio de código como GitHub y un repositorios para almacenar los diferentes artefactos generados en el artifact Registry.

🏗️ Arquitectura

Para llevar a cabo el entramado de todo el ejemplo vamos a utilizar la siguiente arquitectura que se detalla a continuación:

Introducción

Como se observa en el diagrama, en un primer bloque tendremos los dos repositorios de códigos en GitHub, uno para el código de la CloudFunction del publicador (publisher) de mensajes en la cola (topic) de Pub/Sub y otro para el suscriptor (Subscriber) que se conectará a la suscripción de dicha cola para ir consumiendo dichos mensajes. El flujo de trabajo será el siguiente:

1) El desarrollador hace un commit a la rama “dev” de alguno de los repos.

2) Habrá una rama “main” que estará protegida y que solo se podrá actualizar mediante Pull Request (PR) de develop a main.

3) Luego se podrán ir generando las diferentes releases a la rama main y hara que se dispara un triger de CloudBuild.

Este será el flujo de trabajo por parte de los desarrolladores de la CloudFunction.

Una vez se dispare el pipeline de Cloudbuild este proceso de CI/CD tendrá dos partes diferenciadas:

  • Una generar un archivo artefacto con el código de la cloudfunction que será almacenado en el artifact registry de GCP y que será “tageado” con el mismo TAG de GitHub.
  • Otra con el despliegue/actualización de la Cloudfunction en GCP utilizando el comando de gcloud para tal fín.

Una vez desplegadas las cloudfunctions el funcionamiento sería el siguiente:

La CloudFunction del publicador (Publisher) recogerá la información de bucket en formato JSON y los enviará a la cola, mientras el el suscriptor, recogerá dichos mensajes los procesará y los almacenará en una base de datos noSQL de Firestore.

🚀 Despliegue de la infra

Para el despliegue de la infraestructura, como es costumbre utilizaremos terraform. En este repositorio están todos los archivos para lanzar el deploy.

El archivo principal con toda “la chicha” está en el main.tf donde se despliegan cada uno de los componetes de la infra y luego mediante los recursos de terraform de iam_member se otorgan los permisos necesarios a cada una de las service accounts.

Este sería el archivo main.tf:

## Bucket input_data
resource "google_storage_bucket" "input_data" {
  name     = "${var.project_name}-input-data"
  location = "EU"
}
##

## Topic and suscription topic_cf
resource "google_pubsub_topic" "topic_cf" {
  name = "topic-cf"
}

resource "google_service_account" "sa_publisher" {
  account_id   = "sa-publisher"
  display_name = "sa-publisher"
}

resource "google_pubsub_topic_iam_member" "role_sa_publisher" {
  project    = var.project_name
  topic      = google_pubsub_topic.topic_cf.name
  role       = "roles/pubsub.publisher"
  member     = "serviceAccount:${google_service_account.sa_publisher.email}"
  depends_on = [google_pubsub_topic.topic_cf]
}

resource "google_storage_bucket_iam_member" "role_sa_publisher_read_bucket" {
  bucket     = google_storage_bucket.input_data.name
  role       = "roles/storage.objectViewer"
  member     = "serviceAccount:${google_service_account.sa_publisher.email}"
  depends_on = [google_storage_bucket.input_data]
}

resource "google_pubsub_subscription" "topic_cf-subscription" {
  name    = "topic_cf-subscription"
  topic   = google_pubsub_topic.topic_cf.id
  project = var.project_name

  ack_deadline_seconds = 100

  retry_policy {
    minimum_backoff = "10s"
    maximum_backoff = "600s"
  }

  depends_on = [google_pubsub_topic.topic_cf]
}

resource "google_service_account" "sa_subscription" {
  account_id   = "sa-subscription"
  display_name = "sa-subscription"
}

resource "google_pubsub_subscription_iam_member" "role_sa_subscription_v" {
  project      = var.project_name
  subscription = google_pubsub_subscription.topic_cf-subscription.name
  role         = "roles/pubsub.viewer"
  member       = "serviceAccount:${google_service_account.sa_subscription.email}"
  depends_on   = [google_pubsub_subscription.topic_cf-subscription]
}

resource "google_pubsub_subscription_iam_member" "role_sa_subscription_s" {
  subscription = google_pubsub_subscription.topic_cf-subscription.name
  role         = "roles/pubsub.subscriber"
  member       = "serviceAccount:${google_service_account.sa_subscription.email}"
  depends_on   = [google_pubsub_subscription.topic_cf-subscription]
}
##

## Firestore
resource "google_app_engine_application" "firestore" {
  project       = var.project_name
  location_id   = var.region
  database_type = "CLOUD_FIRESTORE"
}

resource "google_service_account_iam_member" "role_sa_firestore" {
  service_account_id = google_service_account.sa_subscription.name
  role               = "roles/datastore.user"
  member             = "serviceAccount:${google_service_account.sa_subscription.email}"
}

resource "google_service_account_iam_member" "role_sa_firestore_develop" {
  service_account_id = google_service_account.sa_subscription.name
  role               = "roles/firebase.developAdmin"
  member             = "serviceAccount:${google_service_account.sa_subscription.email}"
}

resource "google_service_account_iam_member" "role_sa_firestore_serviceStorageAgent" {
  service_account_id = google_service_account.sa_subscription.name
  role               = "roles/firebasestorage.serviceAgent"
  member             = "serviceAccount:${google_service_account.sa_subscription.email}"
}

resource "google_service_account_iam_member" "role_sa_firestore_serviceAgent" {
  service_account_id = google_service_account.sa_subscription.name
  role               = "roles/firestore.serviceAgent"
  member             = "serviceAccount:${google_service_account.sa_subscription.email}"
}
##

Los pasos a seguir para el deploy de la infra serían estos:

1) Clonar el repositorio con los archivos de terraform.

2) Modificar el archivo vars.tf con el project_name y la region por defecto:

variable "project_name" {
  type = string
  default = "NOMBRE_PROYECTO_GCP"
}

variable "region" {
  type = string
  default = "europe-west3"
}

3) Descargar e inicializar los recursos de terraform con init y luego lanzar el apply:

terraform init
terraform apply

Con esto ya tendríamos toda la infra necesaria en GCP.

Configuración de los repositorios de GitHub con cloudbuild

El siguiente paso sería preparar los repositorios de las cloudfunctions en GitHub para el publisher del topic de PubSub y el subscriber de la subscripción de dicho topic.

Configurar trigger / Activador de CloudBuild

Si ya habilitamos la app cloudbuild en GitHub tal y como explicamos en el punto 3.5 de este post, al crear el repo ya nos debería de salir la opción de asociarlos con CloudBuild tal y como se muestra en la captura:

Luego, en la sección de CloudBuild de la consola de GCP, pulsamos sobre “Añadir nuevo activador”.

NOTA: Esta parte también se podría “terraformar” pero en nuestro caso lo haremos manualmente de forma más visual.

Le damos un nombre al nuevo activador, indicando como Evento de su activación “Enviar etiqueta nueva”. Tal y como se muestra en las capturas de pantalla:

Luego en la sección Fuente en el campo etiqueta ponemos la expresión regular de cualquiera ( .* )

NOTA: en la captura veréis que he seleccionado la Región europe-west3, comentaros que hice algunas pruebas y cuando se seleccionan regiones concretas el limite de ejecuciones de CloudBuild es más limitado que cuando se selecciona “global (no regional)”. Si alcanzáis ese límite veréis un aviso el ejecutar cloubbuild como este: “no concurrent builds quota available to create builds“. Hay más información en la doc oficial de GCP.

En el campo repositorio pulsamos sobre “Conectar con un repositorio nuevo”:

Luego seleccionamos el repositorio de pubsub-publisher recién creado:

El resto de opciones la podemos dejar por defecto.

Repetimos los pasos de la configuración del trigger de cloudbuild para el repositorio de pubsub-subscriber.

Proteger la rama main del repositorio

Lo primero que haremos será crear la nueva rama dev en el repo de pubsub-publisher y asignarla por defecto al repo desde el apartado de “settings” -> Branches en GitHub:

Luego en “Branch protection rules” marcamos la siguiente opción para que todos los commits a la rama main se hagan mediante Pull Request (PR) desde dev:

y repetimos estos pasos para el repositorio de pubsub-subscriber.

Dar permisos para crear/actualizar cloudfunction a cloudbuild

Otorga la función de Desarrollador de CloudFunctions a la cuenta de servicio de CloudBuild mediante los siguientes pasos:

  1. Abre la página Configuración de CloudBuild.
  2. Establece el estado de la función de Desarrollador de Cloud Functions en Habilitada tal y como se muestra en la captura:

Workflow CI/CD CloudFunctions Publisher

Una vez que ya tenemos el repo configurado para las cloudfunctions, vamos a ver los pasos de como sería un flujo de trabajo de desarrollo con el CI/CD incluido. En este caso empezaremos por la cloudfunction de publisher.

Commit a DEV

Lo primero será clonarnos el repositorio que hemos creado de publisher en local. Luego podemos “copiar” los archivos al repositorio local desde este repo.

Existen varios archivos:

  • cloudbuild.yaml: Archivo con el proceso de CI/CD de la cloudfunction que será el que ejecute el trigger que configuramos anteriormente. Básicamente ejecutará el comando gcloud para el despliegue de la cloudfunction publisher.
steps:

- id: 'tag name'
  name: 'alpine'
  entrypoint: 'sh'
  args:
  - '-c'
  - |
      echo "***********************"
      echo "$TAG_NAME"
      echo "***********************"

- id: 'Clone repo with TAG'
  name: 'gcr.io/cloud-builders/git'
  args:
  - clone
  - --branch
  - $TAG_NAME
  - 'https://github.com/emoronayuso/pubsub-publisher.git'
  dir: '.'

- id: 'gcloud functions deploy'
  name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
  args:
  - gcloud
  - functions
  - deploy
  - publisher_cf
  - --region=europe-west3
  - --source=.
  - --trigger-bucket=pruebas-pubsub-systerminal-input-data
  - --runtime=python39
  - --service-account=sa-publisher@pruebas-pubsub-systerminal.iam.gserviceaccount.com

Hay que tener en cuenta que hay que modificar el repo del step de “‘Clone repo with TAG’” con el vuestro, y los parámetros de –trigger-bucket y –service-account con el de vuestro proyecto de GCP.

  • .gcloudignore: Este archivo es necesario ya que el proceso de CI/CD de cloudbuild clona el repositorio de código para descargar la versión del código correspondiente que con TAG o release generada, esto incluye todos los archivos de la cloudfunctions del repo, y cuando se vaya al lanzar el comando gcloud functions deploy, esto evitará que se desplieguen ciertos archivos del repo en GCP.
  • main.py: Archivo principal con la cloudfunction. Hay que cambiar las variables de TOPIC_PATH y de BUCKET por el nuestro.
  • requirements.txt: Archivo con la librería de gcloud requeridas de Google storage y pubsub para poder utilizarlas en la cloudfunction.

Una vez tenemos nuestro repo en local configurado con los archivos, hacemos un commit a dev (rama por defecto).

Pull Requests de DEV a MAIN

El siguiente paso será realizar un Pull Requests (PR) de dev a la rama main. Desde GitHub -> Pull requests -> “New pull requests” -> Create Pull Requests

Generar TAG RELEASE

Cuando ya tengamos la rama main con los últimos cambios el siguiente paso será generar tag release con la version de la rama main y esto hará que dispare el trigger de cloudbuild. Para ello desde el apartado de “Code” -> “Releases” -> “Draft a New Release” en GitHub:

rellenamos los siguientes campos:

  • Choose a tag: 1.0.0 (Veremos que nos sale el desplegable “Create a new tag on publish”)
  • Target: main
  • Release title: 1.0.0

y luego pulsamos sobre la opción de “Generate release notes” para que nos genere las notas de los cambios de la nueva release de forma automática:

NOTA: en la imagen he utilizado la versión 2.0.0 por que ya había hecho pruebas anteriores con la 1.0.0.

CI/CD con cloudbuild

Una vez que pulsemos sobre “Publish Release” ya saltará el proceso de CI/CD de cloudbuild.

Podemos verlos desde el apartado de “code” en Github y seleccionando la nuevo “tag” release que acabamos de crear, veremos que aparece un enlace con un punto en “amarillo” justo al lado del commit:

con el enlace al trigger de cloudbuild que se está ejecutando. Si pulsamos sobre “Details” podemos ver el procesos y un enlace directo a la ejecución de cloudbuild en Google Cloud.

Si todo va bien el proceso de build deberá haberse ejecutado correctamente:

y si nos vamos al apartado de cloudfunctions en GCP debería aparecer la cloudfunction de publisher ya desplegada solo con los archivos de main.py y de requirements.txt:

Probando la ejecución de la cloudfunction

Para hacer las pruebas, la cloudfunction recogerá un archivo JSON del bucket con el siguiente formato:

{
  "sensorName": "sensor_1",
  "temperature": 24,
  "humidity": 13
}

Estos archivos de test se pueden descargar la carpeta test del repo.

Desde GCP -> Cloud Storage -> accedemos a nuestro bucket, en mi caso “pruebas-pubsub-systerminal-input-data” y subimos uno de los archivos de prueba json.

Luego volvemos al apartado de cloud functions en GCP y accedemos a “publisher_cf” -> Registros/logs y podemos ver como la cloudfunctión se ha disparado al subirse el archivo json al bucket y se ha ejecutado:

EL siguiente pasó será comprobar que el mensaje que ha parseado la cloudfunction desde el json se ha enviado a al topic correctamente. Para esto desde GCP -> topics (Pub/Sub) -> Subscripciones, podemos hacer un pull de los mensajes de la cola desde la Opción Extraer (Pull):

y podemos ver que el mensaje ha llegado a la cola en formato JSON y se ha consumido por el susbcriptor.

Workflow CI/CD CloudFunctions Subscriber

En este apartado veremos como sería un flujo de trabajo de desarrollo con el CI/CD incluido de la cloudfunction que accede a la suscripción del pubsub para descargar los mensajes de la cola y consumirlos.

Commit a DEV

Al igual que en apartado 3.1, nos clonamos el repositorio que hemos creado de subscriber en local. Luego podemos “copiar” los archivos al repositorio local desde este repo.

Existen varios archivos como en el repo de publisher, pero nos centraremos en el archivo de cloudbuild.yaml que incluye el deploy de la cloudfunction con los parámetros concretos.

Pull Requests de DEV a MAIN

Este paso sería exactamente el mismo que se realizó para la cloudfunctions de publisher en el apartado 3.2

Generar TAG RELEASE

Este paso sería exactamente el mismo que se realizó para la cloudfunctions de publisher en el apartado 3.3

CI/CD con cloudbuild

Este paso sería exactamente el mismo que se realizó para la cloudfunctions de publisher en el apartado 3.4

Probando la ejecución de la cloudfunction

Vamos a crear primero desde GCP la colección y el documento con parámetros en la base de datos de firestore para que se almacenen los datos desde la cloudfunction. Lo creamos de la siguiente manera:

Una vez que hemos subido el archivo de pruebas json al bucket, este se leerá por la cloudfunction de publisher y lo publicará en el topic, luego lo leerá el subscriber y almacenará la información en la base de datos de firestore.

Para ver los datos de firestore podemos crear un par de indices compuestos para poder hacer consultas. Por ejemplo un índice compuesto por “sensorId – temperature” y otro por sensorId – humidity”. Podemos crearlos de esta forma:

Para permitir la escritura de datos en firestore debemos cambiar las reglas de seguridad por defecto, para ello podemos instalar la CLI de firestore.

Luego desde GCP -> Firestore -> Reglas de seguridad activamos firebase.

Luego con el siguiente comando podemos autenticarnos mediante gcloud a la base de datos de firebase:

firebase login

y podemos comprobar que los proyecto se listan con:

firebase projects:list

Luego en una carpeta local de nuestro equipo lanzamos el siguiente comenado para que genere el esqueleto para poder modificar las reglas de seguridad de firestore:

firebase init firestore

Seleccionamos “Use an existing project” y luego editamos el archivo generado por defecto de “firestore.rules”, y lo modificamos de la siguiente manera:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if true;
    }
    match /data/{sensors} {
      allow read, write: if true;
    }
  }
}

y por últimos hacemos un deploy con las nuevas reglas:

firebase deploy

Ahora si subimos una archivos de la carpeta de test/ del repositorio de publisher al bucket de input-data deberíamos de ver en los logistros de logs de la cloudfunction de subscriber_cf que se ha disparado al ver el mensaje en el topic, ha descargado la info y la ha almacenado en la base de datos:

Desde la consola de GCP en Firestore debería de aparecer los datos del “sensor”:

o incluso también podemos consultarlos desde la consola de firestore.

Imágenes de cloudfunctions y Artifact registry

Cuando implementas el código fuente de la función en Cloud Functions, esa fuente se almacena en un bucket de Cloud Storage que es generado de la siguiente manera con este formato: gcf-sources-<PROJECT_NUMBER>-<REGION>.

A continuación, Cloud Build compila el código de forma automática en una imagen de contenedor y envía esa imagen a un registro de imágenes (ya sea Container Registry o Artifact Registry). Cloud Functions accede a esta imagen cuando necesita ejecutar el contenedor para ejecutar la función.

Todo el proceso de compilación de la imagen es automático y no requiere ninguna entrada de tu parte. Todos los recursos usados en el proceso de compilación se ejecutan en tu propio proyecto de usuario.

Tienes más información en la doc oficial de Google Cloud.

Podemos utilizar un repo de Artifact Registry personalizado para almacenar la imágenes de las cloudfunction que se generan en el proceso de build. Para ello desde GCP vamos a la sección de Artifact Registry y creamos un nuevo repositorio:

Luego creamos el repositorio de tipo Docker con el siguiente nombre, en la misma region donde estará desplegada la cloudfunction, en este caso para publisher:

Luego añadimos los siguientes parámentro en el deploy de la cloudfunctions del archivo cloudbuild.yaml:

  • –allow-unauthenticated
  • –docker-repository=projects/pruebas-pubsub-systerminal/locations/europe-west3/repositories/publisher-cf-rep

cuando generemos un nuevo TAG con un nuevo cambio en la cloudfunctions, el proceso de cloudbuild hará el deploy y generara un artefacto que almacenará en este repositorio.

y añadir un paso más para que añada el tag a la imagen con la version de la release de Github. El cloudbuild.yaml completo quedaría de la siguiente manera, en este caso para la cloudfunction de publisher:

steps:

- id: 'tag name'
  name: 'alpine'
  entrypoint: 'sh'
  args:
  - '-c'
  - |
      echo "***********************"
      echo "$TAG_NAME"
      echo "***********************"

- id: 'Clone repo with TAG'
  name: 'gcr.io/cloud-builders/git'
  args:
  - clone
  - --branch
  - $TAG_NAME
  - 'https://github.com/emoronayuso/pubsub-publisher.git'
  dir: '.'

- id: 'gcloud functions deploy'
  name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
  args:
  - gcloud
  - functions
  - deploy
  - publisher_cf
  - --region=europe-west3
  - --source=.
  - --trigger-bucket=pruebas-pubsub-systerminal-input-data
  - --runtime=python39
  - --service-account=sa-publisher@pruebas-pubsub-systerminal.iam.gserviceaccount.com
  - --allow-unauthenticated
  - --docker-repository=projects/pruebas-pubsub-systerminal/locations/europe-west3/repositories/publisher-cf-rep

- id: 'gcloud functions set label artifact'
  name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
  args:
  - gcloud
  - artifacts
  - docker
  - tags
  - add
  - europe-west3-docker.pkg.dev/pruebas-pubsub-systerminal/publisher-cf-rep/publisher__cf:latest
  - europe-west3-docker.pkg.dev/pruebas-pubsub-systerminal/publisher-cf-rep/publisher__cf:$TAG_NAME

si revisamos las imágenes del Artifact Registry podemos comprobar que se almacena la imagen “tageada” con la versión de la release:

Con esto repetimos los mismos pasos para la cloudfunction de subscriber y ya tendríamos al circuito completo. 🚀

Ya solo nos faltaría lanzar un terraform destroy desde la carpeta raíz donde se encuentran los archivos de terraform:

terraform destroy

y luego eliminar el proyecto de GCP para evitar costes tras la pruebas realizadas.

🍺 🍺 🍺