A pocos sorprenderá saber que dentro del equipo de Goodly, Cloud Run es uno de nuestros productos favoritos presentes en GCP. Para Google también parece ser muy importante porque las novedades se suceden continuamente.

Solo en 2023 se publicaron más de 70 novedades. Una de estas últimas novedades fue presentada en preview el pasado mes de mayo y liberada para uso general a mediados de noviembre del mismo.

¿Qué es Cloud Run?

Pese a que deben quedar pocas personas que no hayan oído hablar de Cloud Run, Cloud Run es una plataforma de computación totalmente gestionada basada en Knative que nos permite desplegar cargas de una manera sencilla, delegando las tareas relacionadas con infraestructura como pueden ser el aprovisionamiento, el escalado y la configuración.

¿Cuál es la novedad?

Google anunció el pasado mayo que tenemos en preview una nueva funcionalidad por la que podremos correr un contenedor sidecar junto al contenedor principal. Hasta ahora un Cloud Run solo podía correr un único contenedor.

Esto nos abre un abanico importante de posibilidades para extender las capacidades de nuestros Runs de una manera simple y modular, como por ejemplo:

Caso de ejemplo

Uno de los casos de uso que a mí particularmente me parece más interesante de esta nueva funcionalidad presentada, es usar los sidecars para añadir un filtro de autorización a nuestros Cloud Runs.

Además, en nuestro caso en particular nos vino como anillo al dedo para abordar un proyectillo en el que andábamos trabajando. Partíamos de un Cloud Run muy sencillo que tenía un check para comprobar en cada llamada si esta era legítima o no. En caso de no serlo, se devolvía un error; y en caso de pasar la validación, la petición era procesada.

Este caso en particular es algo que la tecnología ya permitía abordar de diferentes maneras, pero además es uno de los ejemplos en los que el uso de Cloud Run con sidecars nos puede ayudar y es con el que vamos a trabajar durante este post.

Punto de partida

Vamos a suponer un ejemplo muy sencillo, un API que además incluye la validación de la request para saber si tiene que servir la respuesta o no. Para simplificar el ejemplo vamos a suponer que la única comprobación que se va a hacer es comprobar la presencia de una cabecera en la request. Esta cabecera es “token” y el valor debería ser “RXN0b05vRXNOYWRhSW1wb3J0YW50ZQ==”.

El código sería algo así:

from fastapi import Request, FastAPI, status
from fastapi.responses import JSONResponse
import uvicorn
import os

SUCCESS_TOKEN = "RXN0b05vRXNOYWRhSW1wb3J0YW50ZQ=="

class NoAuthException(Exception):
   pass

app = FastAPI()

def checkRequest(req):
   print("Checking request...")
   token = req.headers.get('token')
   if (token!=SUCCESS_TOKEN):
       print("Checking KO")
       raise NoAuthException("Authorize error")
   print("Checking OK")

@app.get("/")
def proxy(req: Request):
   try:
       checkRequest(req)
       data = {
           "foo": "bar"
       }
       return JSONResponse(content=data)
   except Exception:
       data = {}
       return JSONResponse(content=data, status_code=status.HTTP_403_FORBIDDEN)

if __name__ == "__main__":
   uvicorn.run(app, host='0.0.0.0', port=int(os.environ.get('PORT', 8080)))

Partiendo de esto vamos a ver cómo separarlo en dos contenedores para lanzarlos en un Cloud Run con sidecar.

En primer lugar, vamos a eliminar la comprobación del API, dejando lo que vendría a ser puramente el API:

from fastapi import Request, FastAPI
from fastapi.responses import JSONResponse
import uvicorn
import os

app = FastAPI()

@app.get("/")
def proxy(req: Request):
   print("API Request")
   data = {
       "foo": "bar"
   }
   return JSONResponse(content=data)

if __name__ == "__main__":
   uvicorn.run(app, host='0.0.0.0', port=int(os.environ.get('API_PORT', 8888)))

Por otro lado, vamos a crear un nuevo contenedor. Este se va a encargar de ejecutar las validaciones que anteriormente hemos comentado para autorizar o no las llamadas finales al API. En caso de que estas validaciones se cumplan reenviará la petición a nuestro API:

from fastapi import Request, Body, FastAPI, status
from fastapi.responses import HTMLResponse
import os
import json
from urllib import request, parse
import uvicorn

SUCCESS_TOKEN = "RXN0b05vRXNOYWRhSW1wb3J0YW50ZQ=="

class NoAuthException(Exception):
   pass

BASE_URL = os.environ.get("BASE_URL", "http://127.0.0.1:8888")

app = FastAPI()

def checkRequest(req):
   print("Checking request...")
   token = req.headers.get('token')
   if (token!=SUCCESS_TOKEN):
       print("Checking KO")
       raise NoAuthException("Authorize error")
   print("Checking OK")


@app.get("/{path:path}")
async def proxy(req: Request, path: str):

   try:
       checkRequest(req)
       print("Processing request...")
       url = '{}/{}'.format(BASE_URL, path)
       print(f"url: {url}")

       dest_req =  request.Request(url)
       dest_req.add_header('Content-Type', 'application/json')
      
       print(f"Trying to connect to {url}")
       with request.urlopen(dest_req) as dest_rsp:
           print(f"Connection established successfully")
           return HTMLResponse(content=dest_rsp.read(), status_code=dest_rsp.status)
   except NoAuthException as error:
       print("Authorize error")
       return HTMLResponse(content=None, status_code=status.HTTP_403_FORBIDDEN)
   except Exception as error:
       print(f"Error when try to connect to {url}: {error}")
       return HTMLResponse(content=None, status_code=status.HTTP_503_SERVICE_UNAVAILABLE)
        
if __name__ == "__main__":
   uvicorn.run(app, host='0.0.0.0', port=int(os.environ.get('PORT', 8080)))

Una vez tenemos separado el código, necesitamos subir al Artifact Registry las imágenes de ambos. Para ello añadiendo el siguiente Dockerfile las creamos y las subimos:

# Use the official lightweight Python image.
# https://hub.docker.com/_/python
FROM python:3.11-slim

# Allow statements and log messages to immediately appear in the logs
ENV PYTHONUNBUFFERED True
ENV HNSWLIB_NO_NATIVE=1 
# Copy local code to the container image.
ENV APP_HOME /app
WORKDIR $APP_HOME
COPY . ./

# Install production dependencies.
RUN pip install --no-cache-dir -r requirements.txt

CMD exec uvicorn main:app --host 0.0.0.0 --port $PORT --reload

Nuestro ArtifactRegistry lo hemos creado en europe-west3. Por lo que tras crearlo nos autenticamos, creamos las imágenes y las subimos desde la consola:

> docker buildx build --platform linux/amd64 -t api-mock .
> docker tag api-mock europe-west3-docker.pkg.dev/sandbox-jrberenguer/artifact-registry/api-mock
> docker push europe-west3-docker.pkg.dev/sandbox-jrberenguer/artifact-registry/api-mock

> docker buildx build --platform linux/amd64 -t proxy .
> docker tag proxy europe-west3-docker.pkg.dev/sandbox-jrberenguer/artifact-registry/proxy
> docker push europe-west3-docker.pkg.dev/sandbox-jrberenguer/artifact-registry/proxy

¿Y cómo juntamos todo esto para desplegar la solución completa? Simplemente, tenemos que definir un service en un fichero yaml:

— — —
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
 name: cloudrun-sidecar-test
 labels:
   cloud.googleapis.com/location: europe-west3
 annotations:
   run.googleapis.com/launch-stage: BETA
   run.googleapis.com/description: sample tutorial service
   run.googleapis.com/ingress: all
spec:
 template:
   metadata:
     annotations:
       run.googleapis.com/container-dependencies: "{proxy: [api]}"
   spec:
     containers:
       - image: europe-west3-docker.pkg.dev/sandbox-jrberenguer/artifact-registry/proxy:latest
         name: proxy
         env:
           - name: BASE_URL
             value: "http://127.0.0.1:8888"
         ports:
           - name: http1
             containerPort: 80
         resources:
           limits:
             cpu: 500m
             memory: 256Mi
       - image: europe-west3-docker.pkg.dev/sandbox-jrberenguer/artifact-registry/api-mock:latest
         name: api
         env:
           - name: API_PORT
             value: '8888'
           - name: PROJECT_ID
             value: 'sandbox-jrberenguer'           
         resources:
           limits:
             cpu: 500m
             memory: 256Mi

En este fichero vamos a definir los contenedores (en nuestro caso dos) y las dependencias entre ellos. Analizado por partes sería:

Creamos el servicio que en este caso llamaremos cloudrun-sidecar-test:

apiVersion: serving.knative.dev/v1
kind: Service
metadata:
 name: cloudrun-sidecar-test
 labels:
   cloud.googleapis.com/location: europe-west3
 annotations:
   run.googleapis.com/launch-stage: BETA
   run.googleapis.com/description: sample tutorial service
   run.googleapis.com/ingress: all

Definimos las dependencias entre los contenedores, en este caso solo hay una…

metadata:
     annotations:
       run.googleapis.com/container-dependencies: "{proxy: [api]}"

Por otro lado, tenemos los dos contenedores que hemos definido más arriba.
Proxy:

- image: europe-west3-docker.pkg.dev/sandbox-jrberenguer/artifact-registry/proxy:latest
         name: proxy
         env:
           - name: BASE_URL
             value: "http://127.0.0.1:8888"
         ports:
           - name: http1
             containerPort: 80
         resources:
           limits:
             cpu: 500m
             memory: 256Mi

API:

- image: europe-west3-docker.pkg.dev/sandbox-jrberenguer/artifact-registry/api-mock:latest
         name: api
         env:
           - name: PORT
             value: '8888'
           - name: PROJECT_ID
             value: 'sandbox-jrberenguer'           
         resources:
           limits:
             cpu: 500m
             memory: 256Mi

Una vez definido el fichero service, solo nos queda desplegarlo:

Fichero service despleagado.

Una vez desplegado el servicio, ahora toca probarlo. Para esto simplemente vamos a lanzar algunas peticiones al servicio para ver si realmente está aplicando el filtro o no.

En el proxy el check que vamos a hacer es simplemente que el valor de la cabecera token sea uno en concreto:

SUCCESS_TOKEN = "RXN0b05vRXNOYWRhSW1wb3J0YW50ZQ=="
def checkRequest(req):
   print("Checking request...")
   token = req.headers.get('token')
   if (token!=SUCCESS_TOKEN):
       print("Checking KO")
       raise NoAuthException("Authorize error")
   print("Checking OK")

Si lanzamos un curl con un token incorrecto, obtenemos un 403:

Token incorrecto

Si, por el contrario, añadimos el token válido obtenemos un 200 y la respuesta correcta:

Token válido.

Para cerrar

En este post hemos podido ver cómo resolver uno de los casos de uso que esta nueva actualización de Cloud Run viene a solventar: añadir filtros de autorización a nuestro contenedor principal. Pero como hemos mencionado anteriormente, este no es el único caso de uso que viene a cubrir esta nueva feature. Prometheus o incluso OpenTelemetry con Cloud Run ahora es posible.

Cloud Run sigue siendo para muchos uno de los productos estrella de GCP y Google lo sabe, ya que no deja de evolucionarlo y hacerlo cada vez más potente y más usable para incorporarlo a nuestras soluciones tecnológicas en la nube, posicionándolo en el top de los productos serverless.

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete

Estamos comprometidos.

Tecnología, personas e impacto positivo.