Por qué usar Azure Container Apps

En los últimos meses muchos de nuestros clientes se están interesando cada vez más por este servicio llamado Azure Container Apps, el cual promete simplificar el uso de una arquitectura basada en contenedores sin la necesidad de tener que desplegar y gestionar todo un clúster de Kubernetes si no es realmente necesario. Muchas de las aplicaciones que nos encontramos encajan perfectamente en este servicio, del cual quiero mostrarte algunas de sus bondades en este artículo con un ejemplo de aplicación típica, que seguro te va a ayudar a entender si este te facilita la vida como promete 😙

Aplicación de ejemplo

Para poder mostrarte cómo sería utilizar Azure Container Apps voy a apoyarme en mi Tour of heroes, como casi siempre 🙃, para que veas cómo podría ser su uso con una aplicación de tres capas típica:

En ella como ves tengo un frontal en Angular, una API REST en .NET y una base de datos SQL Server. Como te puedes imaginar, esta tiene que gestionar variables de entorno, secretos, comunicaciones a través de HTTP y TCP, escalar algunos de sus componentes, dar accesos públicamente a unos y a otros no, en fin todo lo que puedes esperar de una aplicación hoy en día 😃. Para este ejemplo voy a desplegar los tres componentes dentro de Azure Container Apps, usando Azure CLI, aunque lo ideal es que la base de datos estuviera en un servicio gestionado como SQL Database, pero en este ejemplo quiero mostrarte también que es posible exponer puertos TCP como el 1433 que necesita este, por lo que lo mantendremos dentro de ACA junto con el resto.

Importante: La intención de este artículo no es que sea un despliegue production ready sino que puedas ver qué cosas se pueden hacer con Azure Container Apps, por lo que no expongas las base de datos al exterior como hago aquí 😙

Instalar la extensión containerapp

Como voy a utilizar Azure CLI para desplegar el entorno, necesitas además tener instalada la extensión llamada containerapp:

# Add container app extension
az extension add --name containerapp --upgrade

Registro de namespaces

Por otro lado, debes asegurarte de que tienes registrados los siguientes namespaces:

# Register namespaces
az provider register --namespace Microsoft.App --wait
az provider register --namespace Microsoft.OperationalInsights --wait
az provider register --namespace Microsoft.ContainerService --wait

Con esto ya tienes tu local y tu suscripción lista para poder trabajar con este servicio.

Variables de entorno

Para que puedas modificar los valores a tu gusto lo primero que hago es definir un conjunto de variables:

RESOURCE_GROUP="tour-of-heroes-aca"
LOCATION="westeurope"
CONTAINERAPPS_ENVIRONMENT="tour-of-heroes-env"
VNET_NAME="heroes-vnet"
KEYVAULT_NAME="heroes-kv"
SQL_CONTAINER_APP_NAME="sqlserver"
API_CONTAINER_APP_NAME="api"
FRONTEND_CONTAINER_APP_NAME="frontend"
STORAGE_ACCOUNT_NAME="heroesdata"
STORAGE_SHARE_NAME="sqldata"
STORAGE_MOUNT_NAME="sqlserver-data"

Con ellas puedes hacerte una idea de qué tipos de recursos vamos a necesitar en este ejemplo.

Crear un grupo de recursos, una red virtual y un Azure Key Vault

Empecemos por aquellos elementos de los que va a depender nuestra aplicación, que son el grupo de recursos donde va a vivir y una red virtual que asociaremos después:

# Create a resource group
az group create --name $RESOURCE_GROUP --location $LOCATION
# Create a virtual network
az network vnet create \
  --name $VNET_NAME \
  --resource-group $RESOURCE_GROUP \
  --location $LOCATION \
  --address-prefixes 10.0.0.0/16 \
  --subnet-name containers-subnet \
  --subnet-prefixes 10.0.0.0/21
# Get subnet ID
SUBNET_ID=$(az network vnet subnet show \
  --name containers-subnet \
  --resource-group $RESOURCE_GROUP \
  --vnet-name $VNET_NAME \
  --query id \
  --output tsv)
# Delegate the subnet to Azure Container Apps
az network vnet subnet update --ids $SUBNET_ID --delegations Microsoft.App/environments

Es posible crear un entorno para Azure Container Apps sin tener que definir la red virtual pero en este ejemplo es necesario para poder exponer el puerto 1433, y que veas que esto también es posible.

Por otro lado, como en cualquier entorno, lo ideal es que los secretos que nuestros componentes deban conocer se almacenen en un servicio como Azure Key Vault:

# Create Azure Key Vault
az keyvault create \
  --name $KEYVAULT_NAME \
  --resource-group $RESOURCE_GROUP \
  --location $LOCATION
# Create a secret for SQL Server SA password
SA_PWD_ID=$(az keyvault secret set \
  --vault-name $KEYVAULT_NAME \
  --name "sqlserver-sa-password" \
  --value "P@ssword123" \
  --query id \
  --output tsv)
# Load connection string into Azure Key Vault using .env file
SQL_SECRET_ID=$(az keyvault secret set \
  --vault-name heroes-kv \
  --name "sqlserver-connection-string" \
  --file .env \
  --query id \
  --output tsv)
# Create an identity for the SQL Server container
SQL_IDENTITY_ID=$(az identity create \
  --name sqlserver \
  --resource-group $RESOURCE_GROUP \
  --location $LOCATION \
  --query id \
  --output tsv)
# Give permissions to the managed identity to read the secret
az keyvault set-policy \
  --name $KEYVAULT_NAME \
  --object-id $(az identity show --id $SQL_IDENTITY_ID --query principalId --output tsv) \
  --secret-permissions get
# Create user-assigned managed identity 
API_IDENTITY_ID=$(az identity create \
  --name heroes-api \
  --resource-group $RESOURCE_GROUP \
  --location $LOCATION \
  --query id \
  --output tsv)
# Give permissions to the managed identity to read the secret
az keyvault set-policy \
  --name heroes-kv \
  --object-id $(az identity show --id $API_IDENTITY_ID --query principalId --output tsv) \
  --secret-permissions get

Como puedes ver, además de crear el servicio y añadir tanto la contraseña del usuario SA de la base de datos como la cadena de conexión, este no tiene sentido si no hay algo que lo consuma, por lo que he creado dos identidades, que asociaremos después a quien corresponda, que tienen permisos para poder recuperar estos secretos.

Crear un entorno de Azure Container Apps

Con la infraestructura anterior configurada ya estamos casi listos para empezar a desplegar contenedores, pero antes necesitas un sitio común donde alojar los mismos. Este sitio se llama Azure Container Apps Environment y nos va a permitir que los mismos puedan verse entre sí, compartir recursos, etcétera.

# Create a container app environment
az containerapp env create \
  --name $CONTAINERAPPS_ENVIRONMENT \
  --resource-group $RESOURCE_GROUP \
  --location $LOCATION \
  --infrastructure-subnet-resource-id $SUBNET_ID

A este como ves, además de darle un nombre le asociamos la subnet que hemos generado en la red virtual creada anteriormente.

Si bien ahora tenemos el «continente» nos falta el contenido, así que vamos a por ello 😊

Container app para la base de datos

Ahora que ya tenemos un entorno donde desplegar nuestro contenedores, vamos a empezar por la definición para la base de datos. Como la misma debe almacenar información que sea persistente podemos apoyarnos en Azure Storage creando un file share:

# Create an Azure Storage account
az storage account create \
  --name $STORAGE_ACCOUNT_NAME \
  --resource-group $RESOURCE_GROUP \
  --location $LOCATION \
  --sku Standard_LRS \
  --enable-large-file-share \
  --kind StorageV2
# Create an Azure File Share
az storage share-rm create \
  --resource-group $RESOURCE_GROUP \
  --storage-account $STORAGE_ACCOUNT_NAME \
  --name $STORAGE_SHARE_NAME \
  --quota 1024 \
  --enabled-protocols SMB \
  --output table
# Get storage account key
STORAGE_ACCOUNT_KEY=$(az storage account keys list \
  --resource-group $RESOURCE_GROUP \
  --account-name $STORAGE_ACCOUNT_NAME \
  --query "[0].value" \
  --output tsv)

Una vez lo tengamos necesitamos actualizar el entorno que acabamos de crear de Azure Container Apps para registrar el mismo y que este pueda ser usado, en este ejemplo, por la base de datos:

# Create the storage mount
az containerapp env storage set \
  --access-mode ReadWrite \
  --azure-file-account-name $STORAGE_ACCOUNT_NAME \
  --azure-file-account-key $STORAGE_ACCOUNT_KEY \
  --azure-file-share-name $STORAGE_SHARE_NAME \
  --storage-name $STORAGE_MOUNT_NAME \
  --name $CONTAINERAPPS_ENVIRONMENT \
  --resource-group $RESOURCE_GROUP \
  --output table

Con todo ello podemos crear la container app para la base de datos de la siguiente manera:

# Create SQL Server container
az containerapp create \
  --name $SQL_CONTAINER_APP_NAME \
  --resource-group $RESOURCE_GROUP \
  --environment $CONTAINERAPPS_ENVIRONMENT \
  --image mcr.microsoft.com/mssql/server:latest \
  --min-replicas 1 \
  --max-replicas 1 \
  --env-vars 'ACCEPT_EULA=Y' 'SA_PASSWORD=secretref:sa-pwd' \
  --user-assigned $SQL_IDENTITY_ID \
  --secrets "sa-pwd=keyvaultref:$SA_PWD_ID,identityref:$SQL_IDENTITY_ID" \
  --ingress external \
  --transport tcp \
  --target-port 1433 \
  --exposed-port 1433 \
  --cpu 1.0 \
  --memory 2.0Gi \
  --output yaml > sqlserver.yaml

No es apropiado decir que esto define el contenedor porque en realidad lo que define sería lo que conocemos en Kubernetes como el Deployment, ya que aquí como ves estoy definiendo primeramente un nombre para el mismo, el grupo de recursos y el entorno al que corresponde, y al igual que un deployment le indico la imagen, el número de réplicas mínimas, y también las máximas (por si tiene que autoescalar decirle hasta cuándo), e incluso también puedo definir variables de entorno. En estas como ves, la que tiene que ver con aceptar el EULA podemos incluirla como parte de la definición de la variable sin problemas, pero en el caso de la contraseña del administrador he preferido hacer una referencia a un secreto, el cual está definido dos lineas más abajo, y este a su vez hace uso del Azure Key Vault creado anteriormente, además de la identidad que he generado, con permisos de lectura en el key vault, y la referencia del secreto que contiene la contraseña.
Esta definición indica además que necesita tener acceso externo (no recomendado por supuesto, pero para que veas que es posible), que el método de transporte es TCP y que el puerto target, y el expuesto, es el 1433. De esta forma puedes comprobar que la base de datos es accesible a través de herramientas como Azure Data Studio por ejemplo 🙃.
Por último, defino cuánta CPU y memoria necesitan estos contenedores.

Para finalizar con su configuración, a día de hoy, como quiero además asociarle el file share para que los datos queden almacenados en el mismo, he recuperado la definición de esta container app en formato YAML, de tal forma que pueda modificarlo con el montaje a esta cuenta de almacenamiento:

[...]  
provisioningState: Succeeded
  runningStatus: Running
  template:
    containers:
    - env:
      - name: ACCEPT_EULA
        value: Y
      - name: SA_PASSWORD
        secretRef: sa-pwd
      image: mcr.microsoft.com/mssql/server:latest
      name: sqlserver
      resources:
        cpu: 1.0
        ephemeralStorage: 4Gi
        memory: 2Gi
      volumeMounts:
      - volumeName: mssql-data
        mountPath: /var/opt/mssql
    initContainers: null
    revisionSuffix: ''
    scale:
      maxReplicas: 1
      minReplicas: 1
      rules: null
    serviceBinds: null
    terminationGracePeriodSeconds: null
    volumes: 
    - name: mssql-data
      storageName: sqlserver-data
      storageType: AzureFile
[...]

Si te fijas, he añadido el apartado volumeMounts donde especifico el nombre del volumen y la ruta sobre la cual se va a montar y, en la parte inferior de este fragmento, puedes ver que hago uso de la sección volumes donde referencio el nombre que utilicé cuando di de alta este file share como parte de mi entorno. Ahora solo queda actualizar la container app:

az containerapp update \
  --name $SQL_CONTAINER_APP_NAME \
  --resource-group $RESOURCE_GROUP \
  --yaml sqlserver.yaml \
  --output table

Y ya tendremos los datos almacenados en Azure Storage para evitar su pérdida.

Como puedes ver, sin tener que haber creado un clúster completo de Kubernetes puedo definir en este caso un despliegue con un numero de réplicas, un tamaño para las mismas, definir cómo quiero que se comporte el Ingress Controller (sin tener que crearlo, ni configurarlo, ni mantenerlo) y poder generar en una sola linea secretos asociados que pueden alimentarse de Azure Key Vault.

Si quieres comprobar que se ha desplegado todo correctamente puedes usar este comando para ver los logs:

# Check SQL Server logs
az containerapp logs show -n $SQL_CONTAINER_APP_NAME -g $RESOURCE_GROUP

Vamos a ver el siguiente componente 👇🏻

Container App para la API

La base de datos era el ejemplo más simple donde creo una única instancia. Pero en el caso de la API puede ser que se vea afectado el número de réplicas por las peticiones que reciba. Por lo que en este caso he definido la misma de la siguiente forma:

# Deploy the API
API_FQDN=$(az containerapp create \
  --name $API_CONTAINER_APP_NAME \
  --resource-group $RESOURCE_GROUP \
  --environment $CONTAINERAPPS_ENVIRONMENT \
  --max-replicas 10 \
  --image ghcr.io/0gis0/tour-of-heroes-dotnet-api/tour-of-heroes-api:fd0c343 \
  --ingress external \
  --target-port 5000 \
  --scale-rule-name azure-http-rule \
  --scale-rule-type http \
  --scale-rule-http-concurrency 5 \
  --user-assigned $API_IDENTITY_ID \
  --secrets "connection-string=keyvaultref:$SQL_SECRET_ID,identityref:$API_IDENTITY_ID" \
  --env-vars "ConnectionStrings__DefaultConnection=secretref:connection-string" \
  --query "properties.configuration.ingress.fqdn" \
  --output tsv)

En esta como ves también he añadido los parámetros comentados anteriormente para la imagen, el ingress, en este caso enrrutando al puerto 5000, la cadena de conexión en base a un secreto asociado a uno en Azure Key Vault y la identidad. Si quisieras comprobar que la API está conectada correctamente con la base de datos puedes utilizar el archivo client.http y actualizando la variable baseUrl con el devuelto en este echo:

# Use this to call the API with the client.http file
echo "https://$API_FQDN/api/hero"

Puedes rellenar la base de datos con algunos héroes y comprobarlo tando desde el navegador como accediendo a la misma desde Azure Studio.

Por otro lado, le he añadido parámetros relacionados con el escalado, que son aquellos que empiezan con –scale-rule-*. Estos nos van a permitir, a través de KEDA (de nuevo, ¡sin tener que instalar absolutamente nada!) que mi API escale en base a las peticiones que reciba. Para que puedas verlo en funcionamiento, abre otra ventana y ejecuta lo siguiente:

# Split your terminal and execute this command to watch how the replicas are scaled
API_CONTAINER_APP_NAME="api"
RESOURCE_GROUP="tour-of-heroes-aca"
watch -n 5 az containerapp replica count --name $API_CONTAINER_APP_NAME --resource-group $RESOURCE_GROUP

Si lo has lanzado nada más ejecutar la creación de esta Container App, o justo después de invocarla con héroes, es posible que todavía veas que hay una replica funcionando y si esperas un poco más verás que termina teniendo como valor cero. Esto es así porque en este caso no he establecido un número mínimo de réplicas, lo cual me permite que si hay ciertos componentes que no necesito tener una respuesta inmediata simplemente que escalen a cero y no consuman recursos. Lo que sí que he definido es el número máximo que puedo llegar a tener, así que ahora, en la ventana principal podemos lanzar una prueba de carga sencilla para ver si en base a las peticiones que está recibiendo este número se incrementa o no:

# Execute a load test
brew install hey
hey -z 60s -c 5 https://$API_FQDN

Al cabo de unos instantes comprobarás que el número de réplicas efectivamente crece hasta llegar a 10. Una vez que finalice la prueba, comprobarás que después de unos minutos volverá a cero.

Container App para el frontal web

Para terminar, en el caso del frontal web he querido demostrar cómo gracias a las revisiones podemos tener un entorno con más de una versión disponible sobre las que podemos balancear la carga para evaluar tanto su estabilidad como esas nuevas características que queremos introducir pero que queremos hacer de una forma gradual, midiendo tanto la aceptación como el buen funcionamiento del sitio:

# Deploy Angular app
FRONTEND_FQDN=$(az containerapp create \
  --name frontend \
  --resource-group $RESOURCE_GROUP \
  --environment $CONTAINERAPPS_ENVIRONMENT \
  --image ghcr.io/0gis0/tour-of-heroes-angular/tour-of-heroes-angular:d39626e \
  --min-replicas 1 \
  --ingress external \
  --target-port 80 \
  --exposed-port 80 \
  --revisions-mode multiple \
  --revision-suffix basic \
  --env-vars "API_URL=https://$API_FQDN/api/hero" \
  --query "properties.configuration.ingress.fqdn" \
  --output tsv)
# Create a new revision of the frontend
FRONTEND_FQDN=$(az containerapp create \
  --name frontend \
  --resource-group $RESOURCE_GROUP \
  --environment $CONTAINERAPPS_ENVIRONMENT \
  --image ghcr.io/0gis0/tour-of-heroes-angular:heroes-with-pics \
  --min-replicas 1 \  
  --ingress external \
  --target-port 80 \
  --exposed-port 80 \
  --env-vars "API_URL=https://$API_FQDN/api/hero" \
  --revisions-mode multiple \
  --revision-suffix pics \
  --query "properties.configuration.ingress.fqdn" \
  --output tsv)
# Traffic Split
az containerapp ingress traffic set \
  --name $FRONTEND_CONTAINER_APP_NAME \
  --resource-group $RESOURCE_GROUP \
  --revision-weight frontend--basic=20 frontend--pics=80
az containerapp ingress traffic show \
  --name $FRONTEND_CONTAINER_APP_NAME \
  --resource-group $RESOURCE_GROUP
  
echo "https://$FRONTEND_FQDN"

Aquí como ves parece que estoy desplegando dos contenedores más pero en realidad bajo el mismo nombre estoy creando dos revisiones de una misma container app, donde en cada una de ellas estoy usando una versión de la imagen distinta. Por último estoy configurando el split del tráfico dandole un 20% a la versión básica de mi web y un 80% a una versión que incluye imágenes de mis héroes. Si ahora accedo al endpoint generado para este frontal podré ver el contenido cargado a través de la API en la base de datos y las dos versiones de mi aplicación web.

Espero que con estos tres ejemplos, unidos en una misma arquitectura, te den una idea de todo el potencial que Azure Container Apps nos da de forma súper sencilla y súper gestionada.

El script completo lo tienes en este repositorio de mi cuenta de GitHub 🐈‍⬛

¡Saludos!