Desplegar una aplicación con Dapr en Azure Container Apps

Después de toda esta serie escrita con una mano 🥰, el último sitio donde quiero mostrarte que puedes desplegar tus aplicaciones con Dapr es en Azure Container Apps, un servicio relativamente nuevo que viene con Dapr «incorporado». En este artículo te cuento cómo he desplegado mi ejemplo Tour of heroes en este entorno.

Prepara tu suscripción

Antes de comenzar con el despliegue de recursos es necesario que prepares tu suscripción, y Azure CLI, con los siguientes comandos:

# Install the Azure Container Apps extension for the CLI
az extension add --name containerapp --upgrade
# Register the Microsoft.App namespace.
az provider register --namespace Microsoft.App
# Register the Microsoft.OperationalInsights provider for the Azure Monitor Log Analytics workspace if you have not used it before.
az provider register --namespace Microsoft.OperationalInsights

Despliegue de servicios adicionales

En este ejemplo, además de las APIs con Dapr necesitamos de servicios adicionales como una cache, en este caso Redis, una base de datos SQL Server, un sitio donde almacenar nuestros secretos y otros donde enviar las trazas. Todo ello puedes desplegarlo con lo siguiente:

# Variables
RESOURCE_GROUP="tour-of-heroes-capps"
LOCATION="northeurope"
# Create a resource group
az group create --name $RESOURCE_GROUP --location $LOCATION
#######################
### Deploy services ###
#######################
# Redis
REDIS_NAME="redis-cache-for-capps"
# Dapr can use any Redis instance - containerized, running on your local dev machine, or a managed cloud service, provided the version of Redis is 5.0.0 or later.
az redis create --location $LOCATION --name $REDIS_NAME --resource-group $RESOURCE_GROUP --sku Basic --vm-size c0 --redis-version 6 --enable-non-ssl-port 
REDIS_HOSTNAME=$(az redis show --name $REDIS_NAME --resource-group $RESOURCE_GROUP --query "hostName" -o tsv)
REDIS_ACCESS_KEY=$(az redis list-keys --name $REDIS_NAME --resource-group $RESOURCE_GROUP --query "primaryKey" -o tsv)
# az redis delete --name $REDIS_NAME --resource-group $RESOURCE_GROUP --yes
# SQL Server
SQL_SERVER_NAME="sqlserver-for-capps"
SQL_SERVER_USER="usersql"
SQL_SERVER_PASSWORD="p@ssw0rd"
startIp='0.0.0.0'
endIp='0.0.0.0'
az sql server create --name $SQL_SERVER_NAME --resource-group $RESOURCE_GROUP --location "$LOCATION" --admin-user $SQL_SERVER_USER --admin-password $SQL_SERVER_PASSWORD
az sql server firewall-rule create --resource-group $RESOURCE_GROUP --server $SQL_SERVER_NAME -n AllowYourIp --start-ip-address $startIp --end-ip-address $endIp
SQL_FQDN=$(az sql server show --name $SQL_SERVER_NAME --resource-group $RESOURCE_GROUP --query "fullyQualifiedDomainName" -o tsv)
# Generating a new Azure AD application and Service Principal 
# https://docs.dapr.io/developing-applications/integrations/azure/authenticating-azure/
# Create the app
APP_NAME="app-for-capps"
APP_ID=$(az ad app create --display-name "${APP_NAME}"  | jq -r .appId)
# To create a client secret
CLIENT_SECRET=$(az ad app credential reset --id "${APP_ID}" --years 2 | jq -r .password)
# Create a service principal
SERVICE_PRINCIPAL_ID=$(az ad sp create --id "${APP_ID}" | jq -r .id)
# Create keyvault 
KEY_VAULT_NAME="key-vault-for-capps"
az keyvault create \
--name $KEY_VAULT_NAME \
--resource-group $RESOURCE_GROUP \
--location $LOCATION \
--enable-rbac-authorization true
# Assign reader role
RESOURCE_GROUP_ID=$(az group show -n $RESOURCE_GROUP --query id -o tsv)
az role assignment create \
  --assignee "${SERVICE_PRINCIPAL_ID}" \
  --role "Key Vault Secrets User" \
  --scope "${RESOURCE_GROUP_ID}/providers/Microsoft.KeyVault/vaults/${KEY_VAULT_NAME}"
# Role assigment for Azure CLI
az role assignment create \
  --assignee $(az ad signed-in-user show --query id -o tsv) \
  --role "Key Vault Secrets Officer" \
  --scope "${RESOURCE_GROUP_ID}/providers/Microsoft.KeyVault/vaults/${KEY_VAULT_NAME}"
  
# Add new secret to the az key vault
az keyvault secret set -n sql-connection-string \
--value "Server=${SQL_FQDN},1433;Initial Catalog=heroes;Persist Security Info=False;User ID=${SQL_SERVER_USER};Password=${SQL_SERVER_PASSWORD};" \
--vault-name $KEY_VAULT_NAME
# Application Insights
APP_INSIGHTS_NAME="insights-from-capps"
az monitor app-insights component create --app $APP_INSIGHTS_NAME --location $LOCATION  --resource-group $RESOURCE_GROUP
APP_INSIGHRS_INSTRUMENTATION_KEY=$(az monitor app-insights component show --app $APP_INSIGHTS_NAME --resource-group $RESOURCE_GROUP --query instrumentationKey)

Desplegar entorno de Azure Container Apps

Ahora que ya tenemos todos los servicios colindantes desplegados, ha llegado la hora de desplegar el entorno donde vivirán nuestros contenedores:

# Create a Container App Environment
CONTAINERAPPS_ENVIRONMENT="heroes-and-villains-env"
az containerapp env create \
  --name $CONTAINERAPPS_ENVIRONMENT \
  --resource-group $RESOURCE_GROUP \
  --location "$LOCATION" \
  --dapr-instrumentation-key $APP_INSIGHRS_INSTRUMENTATION_KEY

Este solo tiene asociado durante su creación el recurso de Application Insights. El resto irá configurado a través de los componentes de Dapr:

# Configure the Dapr components in the Container Apps environment.
# https://learn.microsoft.com/en-us/azure/container-apps/dapr-overview?tabs=bicep1%2Cyaml#component-schema
### state store ###
cat > az-container-apps-dapr-components/statestore.yaml <<EOF
version: v1
componentType: state.redis
metadata:
  - name: redisHost
    value: ${REDIS_HOSTNAME}:6379
  - name: redisPassword
    value: "${REDIS_ACCESS_KEY}"
  - name: actorStateStore
    value: "true"
scopes:
- tour-of-heroes-api
EOF
az containerapp env dapr-component set \
    --name $CONTAINERAPPS_ENVIRONMENT --resource-group $RESOURCE_GROUP \
    --dapr-component-name statestore \
    --yaml az-container-apps-dapr-components/statestore.yaml
### secret store ###
### kubernetes secret store not supported
TENANT_ID=$(az account show --query tenantId -o tsv)
cat > az-container-apps-dapr-components/secret-store.yaml <<EOF
version: v1
componentType: secretstores.azure.keyvault
metadata:
  - name: vaultName
    value: "${KEY_VAULT_NAME}"
  - name: azureTenantId
    value: "${TENANT_ID}"
  - name: azureClientId
    value: "${APP_ID}"
  - name: azureClientSecret
    value: "$CLIENT_SECRET"
scopes:
- tour-of-heroes-api
EOF
az containerapp env dapr-component set \
    --name $CONTAINERAPPS_ENVIRONMENT --resource-group $RESOURCE_GROUP \
    --dapr-component-name heroessecretstore \
    --yaml az-container-apps-dapr-components/secret-store.yaml
### pubsub ###
cat > az-container-apps-dapr-components/publication.yaml <<EOF
version: v1
componentType: pubsub.redis
metadata:
  - name: redisHost
    value: ${REDIS_NAME}.redis.cache.windows.net:6379
  - name: redisPassword
    value: "${REDIS_ACCESS_KEY}"
EOF
az containerapp env dapr-component set \
    --name $CONTAINERAPPS_ENVIRONMENT --resource-group $RESOURCE_GROUP \
    --dapr-component-name villain-pub-sub \
    --yaml az-container-apps-dapr-components/publication.yaml
### subcription ### NOT SUPPORTED
# az containerapp env dapr-component set \
#     --name $CONTAINERAPPS_ENVIRONMENT --resource-group $RESOURCE_GROUP \
#     --dapr-component-name pubsub \
#     --yaml az-container-apps-dapr-components/subscription.yaml

Como ves, la definición de los componentes es ligeramente diferente a la original. También es importante que sepas que hay algunas cosas que a día de hoy no están soportadas. Por ejemplo, el secret store de tipo Kubernetes, la definición de la suscripción a un topic o desplegar el objeto de tipo Configuration son algunas de ellas. Para el primer punto lo que he hecho ha sido reemplazar el componente de tipo secret store para que usé Azure Key Vault, como ya te conté cuando te hablé de secretos. En el caso de la suscripción la he definido de manera declarativa:

//Subscribe to a topic        
[Topic("villain-pub-sub", "villains")]
[HttpPost("/newvillain")]
 public ActionResult NewVillain([FromBody] object villain)
 {
    _logger.LogInformation($"A new villain is in the city!");
    _logger.LogInformation(villain.ToString());
    return Ok();
 }

y en Program.cs he añadido la línea app.MapSubscribeHandler(); para que busque todas aquellas suscripciones definidas a través del atributo Topic.

Por otro lado, en un articulo anterior sobre las trazas en Dapr te mostré como cambiar la configuración para configurar la ingesta. En este caso, al no estar soportado, he aprovechado la integración con Application Insights para este cometido.

Dicho todo esto, para listar los componentes que acabas de dar de alta en tu entorno de Azure Container Apps puedes usar este comando:

az containerapp env dapr-component list --name $CONTAINERAPPS_ENVIRONMENT --resource-group $RESOURCE_GROUP -o table

Por otro lado, si quieres ver el resultado de algunas de las configuraciones puedes hacerlo con este otro:

az containerapp env dapr-component show  --dapr-component-name statestore --name $CONTAINERAPPS_ENVIRONMENT --resource-group $RESOURCE_GROUP -o yaml

Ahora sólo nos queda desplegar nuestras APIs con Dapr.

Desplegar APIs en Azure Container Apps

Vamos a empezar con Tour of villains:

### tour-of-villains-api ###
az containerapp create \
  --name tour-of-villains-api \
  --resource-group $RESOURCE_GROUP \
  --environment $CONTAINERAPPS_ENVIRONMENT \
  --image ghcr.io/0gis0/tour-of-villains-api/tour-of-villains-api-dapr:ab34791 \
  --env-vars ConnectionStrings__DefaultConnection="Server=${SQL_FQDN},1433;Initial Catalog=heroes;Persist Security Info=False;User ID=${SQL_SERVER_USER};Password=${SQL_SERVER_PASSWORD};" \
  --target-port 5111 \
  --ingress 'external' \
  --enable-dapr true \
  --dapr-app-id tour-of-villains-api \
  --dapr-app-port  5111 \
  --dapr-log-level debug \
  --dapr-enable-api-logging \
  --cpu 0.25 --memory 0.5Gi \
  --query configuration.ingress.fqdn
az containerapp browse -n tour-of-villains-api -g $RESOURCE_GROUP

Además de los parámetros básicos el resto son bastante auto descriptivos. En lo que se refiere a Dapr tenemos un conjunto de ellos que se corresponden con los mismos que configuramos en Visual Studio Code o cuando ejecutábamos la API por linea de comandos. Una vez que termine de crearse el contenedor nos devolverá la URL para acceder a él, o también podemos usar el siguiente comando, az container browse, que abrirá el navegador directamente.

Para ver los diferentes logs que se registran por cada contenedor puedes usar los siguientes comandos:

# See logs
az containerapp logs show --resource-group $RESOURCE_GROUP -n tour-of-villains-api --follow
az containerapp logs show --resource-group $RESOURCE_GROUP -n tour-of-villains-api --container daprd --follow
az containerapp logs show --resource-group $RESOURCE_GROUP -n tour-of-villains-api --container tour-of-villains-api --follow
az containerapp logs show --resource-group $RESOURCE_GROUP -n tour-of-villains-api --type system --follow

El caso de tour of heroes es bastante similar:

### tour-of-heroes-api ###
az containerapp create \
  --name tour-of-heroes-api \
  --resource-group $RESOURCE_GROUP \
  --environment $CONTAINERAPPS_ENVIRONMENT \
  --image ghcr.io/0gis0/tour-of-heroes-dotnet-api/tour-of-heroes-api-dapr:3312b3b \
  --target-port 5222 \
  --exposed-port 80 \
  --ingress 'external' \
  --enable-dapr true \
  --dapr-app-id tour-of-heroes-api \
  --dapr-app-port  5222 \
  --dapr-log-level debug \
  --dapr-enable-api-logging \
  --cpu 0.25 --memory 0.5Gi 
az containerapp browse -n tour-of-heroes-api -g $RESOURCE_GROUP
az containerapp logs show --resource-group $RESOURCE_GROUP -n tour-of-heroes-api --container daprd --follow
az containerapp logs show --resource-group $RESOURCE_GROUP -n tour-of-heroes-api --container tour-of-heroes-api --follow

Los logs también pueden ser consumidos a través de Log Analytics:

# Using YAML file: https://learn.microsoft.com/en-us/azure/container-apps/azure-resource-manager-api-spec?tabs=yaml#container-app-examples
LOG_ANALYTICS_WORKSPACE_CLIENT_ID=`az containerapp env show --name $CONTAINERAPPS_ENVIRONMENT --resource-group $RESOURCE_GROUP --query properties.appLogsConfiguration.logAnalyticsConfiguration.customerId --out tsv`
az monitor log-analytics query \
--workspace $LOG_ANALYTICS_WORKSPACE_CLIENT_ID \
--analytics-query "ContainerAppConsoleLogs_CL | where ContainerAppName_s == 'tour-of-heroes-api' | project Log_s | take 5" \
--out table
az monitor log-analytics query \
--workspace $LOG_ANALYTICS_WORKSPACE_CLIENT_ID \
--analytics-query "ContainerAppSystemLogs_CL | where Type_s != 'Normal'| project Log_s | take 10" \
--out table

Probar las APIs

Ahora que ya tenemos todo configurado y desplegado solo nos queda, como siempre, probarlo:

# Test API
CAPPS_HERO_API=$(az containerapp show --name tour-of-heroes-api --resource-group $RESOURCE_GROUP --query "properties.configuration.ingress.fqdn" -o tsv)
CAPPS_VILLAINS_API=$(az containerapp show --name tour-of-villains-api --resource-group $RESOURCE_GROUP --query "properties.configuration.ingress.fqdn" -o tsv)
# Variables for cURL commands
HERO_API_URL=http://$CAPPS_HERO_API/api/hero
VILLAIN_API_URL=http://$CAPPS_VILLAINS_API/villain
# Check if hero api is working
curl -L $HERO_API_URL | jq
curl -L --post301 --header "Content-Type: application/json" \
  --request POST \
  --data '{
    "name": "Batman",
    "description": "Un multimillonario magnate empresarial y filántropo dueño de Empresas Wayne en Gotham City. Después de presenciar el asesinato de sus padres, el Dr. Thomas Wayne y Martha Wayne en un violento y fallido asalto cuando era niño, juró venganza contra los criminales, un juramento moderado por el sentido de la justicia.",
    "alterEgo": "Bruce Wayne" 
   
}' \
  $HERO_API_URL | jq
# Get heroes again
curl -L $HERO_API_URL | jq
curl -L $VILLAIN_API_URL | jq
# Check if villain api is working
curl -L --post301 --header "Content-Type: application/json" \
  --request POST \
  --data '{
    "name": "Octopus",
    "hero":{
        "name": "Spiderman",
        "description": "un joven huérfano neoyorquino que adquiere superpoderes después de ser mordido por una araña radiactiva, y cuya ideología como héroe se ve reflejada primordialmente en la expresión «un gran poder conlleva una gran responsabilidad».20​21​ Suele ser asociado con una personalidad bromista, amable, inventiva y optimista, lo que le ha llevado a ser catalogado como el «vecino amigable» de cualquiera lo cual, aunado a sus vivencias caracterizadas por los problemas cotidianos.",
        "alterEgo": "Peter Parker"     
    },
    "description": "Es un científico loco muy inteligente y algo fornido que tiene cuatro apéndices fuertes que se asemejan a los tentáculos de un pulpo, que se extienden desde la parte posterior de su cuerpo y pueden usarse para varios propósitos."
}' \
  $VILLAIN_API_URL | jq

# Check if pub sub is working viewing logs
# kubectl logs -l app=tour-of-heroes-api -c tour-of-heroes-api
az containerapp logs show --resource-group $RESOURCE_GROUP -n tour-of-heroes-api --container tour-of-heroes-api
#Check keys in the redis cache
docker run --name redis-client --rm redis redis-cli -h $REDIS_HOSTNAME -a $REDIS_ACCESS_KEY KEYS '*'
# Check if service to service invocation is working
curl -L $HERO_API_URL/villain/spiderman | jq

El comando cURL tiene la opción -L, y en los POST además –post301, porque Azure Container Apps tiene habilitada la redirección por defecto. Si vas revisando los logs comprobarás que accede a Redis, la base de datos, se generan mensajes en el topic y se recepcionan correctamente, etcétera.

Si todo ha ido bien, en Application Insights, sección Application Map, ya podrás ver las interacciones entre los servicios.

Azure Container Apps – App Insights

En Transaction search puedes encontrar más información.

El código del ejemplo lo tienes en mi GitHub.

¡Saludos!