Nous retrouvons de plus en plus d’architecture Data contenant du dbt. Pour rappel dbt est un outil de transformation de données à utiliser à la sauce ELT et disponible via une offre SaaS (dont les éléments de tarification sont disponibles ici) ou bien en version open-source mais là, c’est à vous de le positionner comme il se doit dans votre architecture.
Chez dcube, nous aimons les architectures simples, efficaces, scalables et peu onéreuses. Nous verrons dans cet article comment déployer dbt et l’exécuter dans une Azure Container App via :
Exemple d’architecture
Dans une architecture Data, dbt se retrouve sur la partie « Transformation » du cycle de la donnée. Voici un exemple d’architecture avec dbt ( le N°4, au cœur de notre archi data)

dbt est ici containerisé dans une Azure Container App qui se déclenche sur réception de message dans une file d’attente. Une règle de scaling permet de créer un container en fonction du nombre de message dans la file d’attente. Notre projet dbt s’exécute donc autant de fois qu’il y a de message dans la file ; ce qui est fort intéressant pour exécuter un même code projet simultanément sur des périmètres de données bien spécifiques (traitement de données parcellaire, approche data mesh, etc.). Voici le paramétrage de la règle de scaling :

Scripts Terraform
Au moment où j’écris cet article, nous en sommes à la version 3.57.0 du provider azurerm de terraform. Sur cette version, il est possible de créer un Container Environment et une Container App mais il n’est pas possible de mettre en place de règle de scaling. Nous utilisons donc le provider AzApi à cet effet.
Commençons par créer le Container Environment :
resource "azurerm_container_app_environment" "container_environment" { name = "cae-data-dev-01" location = data.azurerm_resource_group.this.location resource_group_name = data.azurerm_resource_group.this.name log_analytics_workspace_id = data.azurerm_log_analytics_workspace.core.id tags = data.azurerm_resource_group.this.tags }
Lorsque nous créons la Container App, il faut que celle-ci ait les droits de récupérer l’image sur la Registry. Si l’authentification se fait par login/mot de passe, nous pouvons mettre ces identifiants dans des secrets de la Container App. Mais pour plus de sécurité, nous utilisons les identités Azure. Ceci nous permet de nous passer d’identifiant. Par contre, nous ne pouvons pas utiliser l’identité de la Container App car pour donner les droits, il faut que la ressource existe mais nous ne pouvons pas la créer si elle n’a pas les droits… Dans ce cas, nous créons préalablement une identité managée sur laquelle nous positionnons les droits puis nous assignons cette identité à la Container App.
Voici le script de création de l’identité et de l’assignation des permissions :
resource "azurerm_user_assigned_identity" "aci_identity" { name = "id-data-dev-01" resource_group_name = data.azurerm_resource_group.this.name location = data.azurerm_resource_group.this.location tags = data.azurerm_resource_group.this.tags } resource "azurerm_role_assignment" "container_app_pull_assignment" { scope = data.azurerm_container_registry.this.id role_definition_name = "AcrPull" principal_id = azurerm_user_assigned_identity.aci_identity.principal_id }
Nous avons vu dans le schéma d’architecture que nous avons besoin d’un service bus. Nous le déclarons donc en assignant des permissions à la Container App pour qu’elle puisse dépiler les messages :
resource "azurerm_servicebus_namespace" "this" { name = "sb-data-dev-01" resource_group_name = data.azurerm_resource_group.this.name location = data.azurerm_resource_group.this.location sku = var.service_bus_sku_name tags = data.azurerm_resource_group.this.tags } resource "azurerm_servicebus_queue" "queue" { name = local.resource_names.service_bus_dbt_run_queue_name namespace_id = azurerm_servicebus_namespace.this.id } resource "azurerm_role_assignment" "service_bus_receiver_assignment" { scope = azurerm_servicebus_namespace.this.id role_definition_name = "Azure Service Bus Data Receiver" principal_id = azurerm_user_assigned_identity.aci_identity.principal_id }
Nous pouvons désormais créer la Container App :
resource "azapi_resource" "container_app" { name = "ca-data-dev-01" location = data.azurerm_resource_group.this.location parent_id = data.azurerm_resource_group.this.id identity { type = "UserAssigned" identity_ids = [azurerm_user_assigned_identity.aci_identity.id] } type = "Microsoft.App/containerApps@2022-03-01" body = jsonencode({ properties : { managedEnvironmentId = azurerm_container_app_environment.container_environment.id configuration = { secrets = [ { name = "service-bus-connection-string" value = azurerm_servicebus_namespace.this.default_primary_connection_string } ] ingress = null registries = [ { server = data.azurerm_container_registry.this.login_server identity = azurerm_user_assigned_identity.aci_identity.id }] } template = { containers = [{ image = "${data.azurerm_container_registry.this.login_server}/${var.container_repository}:${var.container_image_tag}" name = "dbt-instance" resources = { cpu = var.container_cpu memory = var.container_memory } env = [ { name = "SB_NAMESPACE" value = azurerm_servicebus_namespace.this.name }, { name = "SB_QUEUE_NAME" value = local.resource_names.service_bus_dbt_run_queue_name }, { name = "AZURE_CLIENT_ID" value = azurerm_user_assigned_identity.aci_identity.client_id } ] }] scale = { minReplicas = 0 maxReplicas = 5 rules = [{ name = "queue-based-autoscaling" custom = { type = "azure-servicebus" metadata = { queueName = local.resource_names.service_bus_dbt_run_queue_name messageCount = "1" namespace = azurerm_servicebus_namespace.this.name } auth = [{ secretRef = "service-bus-connection-string" triggerParameter = "connection" }] } }] } } } }) tags = data.azurerm_resource_group.this.tags depends_on = [ azurerm_role_assignment.container_app_pull_assignment ] }
Quelques explications sur cette ressource :
- Comme indiqué au début, cette ressource est créée avec le provider azapi et non azurerm.
- Nous pouvons voir l’assignation de l’identité managée déclarée précédemment. C’est cette identité qui est utilisée pour récupérer l’image. Mais elle peut aussi être utilisée dans le container pour accéder à d’autres ressources Azure.
- Une chaine de connexion au service bus est passée dans les secrets du container. Cette chaine de connexion permet à KEDA de faire le scale automatique en fonction de la règle indiquée.
- L’accès au registry se fait par l’identité managée et non par login/mot de passe
- Des variables d’environnement sont ajoutées pour l’application qui s’exécute dans la Container App
- La règle de mise à l’échelle, dans cet exemple, crée un container pour chaque message reçu, dans la limite de 5. Le fait d’avoir un container par message permet de dépiler les messages aussitôt qu’ils sont reçus
Code source terraform : https://github.com/dcube/azure-dbt/tree/main/terraform
Déploiement des scripts Terraform
Maintenant que nous avons nos scripts Terraform, nous pouvons la déployer par la CI/CD de manière très classique comme vous avez l’habitude de le faire pour n’importe quel autre projet Terraform.
Voici un exemple de déploiement par Azure Devops
– task: TerraformInstaller@0 displayName: ‘Install Terraform ${{ parameters.TerraformVersion }}’ inputs: terraformVersion: ${{ parameters.TerraformVersion }} – task: TerraformTaskV3@3 displayName: ‘Terraform : azurerm init’ inputs: provider: ‘azurerm’ command: ‘init’ workingDirectory: ‘$(Pipeline.Workspace)/${{ parameters.PipelineResourceName }}/${{ parameters.ArtifactName }}/Terraform’ commandOptions: ‘-no-color’ backendServiceArm: ‘${{ parameters.ServiceConnection }}’ backendAzureRmResourceGroupName: ‘${{ parameters.TerraformResourceGroupName }}’ backendAzureRmStorageAccountName: ‘${{ parameters.TerraformStorageAccountName }}’ backendAzureRmContainerName: ‘${{ parameters.TerraformContainerName }}’ backendAzureRmKey: ‘${{ parameters.TerraformKey }}’ – task: TerraformTaskV3@3 displayName: ‘Terraform : azurerm plan’ inputs: provider: ‘azurerm’ command: plan workingDirectory: ‘$(Pipeline.Workspace)/${{ parameters.PipelineResourceName }}/${{ parameters.ArtifactName }}/Terraform’ commandOptions: ‘-no-color –var-file=variables.auto.tfvars’ environmentServiceNameAzureRM: ‘${{ parameters.ServiceConnection }}’ – task: TerraformTaskV3@3 displayName: ‘Terraform : azurerm validate and apply’ inputs: provider: ‘azurerm’ command: apply workingDirectory: ‘$(Pipeline.Workspace)/${{ parameters.PipelineResourceName }}/${{ parameters.ArtifactName }}/Terraform’ commandOptions: ‘-no-color environmentServiceNameAzureRM: ‘${{ parameters.ServiceConnection }}’
Build de l’image du container
En réalité, ceci ne fonctionne pas tant que vous n’avez pas l’image dans votre registry. Voyons comment préparer cette image et la déployer.
Tout d’abord, nous devons vérifier que le code dbt compile et pour cela, nous installons tous les composants nécessaires.
dbt s’installe à partir de packages Python. Nous installons donc Python et les packages dbt. Voici un exemple de l’installation de dbt-core et dbt-snowflake (retrouvez l’ensemble des data engines supportées ici) :
- task: UsePythonVersion@0 displayName: Install Python inputs: versionSpec: '3.9' - script: pip install --upgrade setuptools pip install dbt-core dbt-snowflake displayName: Install DBT
Nous pouvons alors publier une image docker à partir d’un fichier DockerFile :
- task: Docker@2 displayName: Build and push DBT image inputs: containerRegistry: '${{ parameters.RegistryServiceConnection }}' command: 'buildAndPush' Dockerfile: '$(Pipeline.Workspace)/${{ parameters.PipelineResourceName }}/dbt/Dockerfile' buildContext: '$(Pipeline.Workspace)/${{ parameters.PipelineResourceName }}/dbt' repository: '${{ parameters.ContainerRepositoryDBT }}' tags: | $(resources.pipeline.resourceBuild.runID) $(ContainerImageTag)
Code source YAML : https://github.com/dcube/azure-dbt/tree/main/.azurepipelines
Exécution de dbt sur le container
Une fois que nous avons déployé une image contenant notre projet dbt, nous pouvons mettre une ligne de commande en fin du Dockerfile pour exécuter la commande dbt souhaitée (dbt build/run/test, etc.). Sauf que dans notre architecture, la container App scale à partir d’un message sur la file d’attente, il nous faut donc les dépiler. Pour cela, nous créons une application Python qui se lance au démarrage du container, nous permettant de gérer les messages, les acquitter ou les mettre dans la « dead_letter ».
Nous utilisons l’identité de la Container App pour récupérer les messages car nous avons vu dans le code Terraform que la Container App possède le role « Azure Service Bus Data Receiver » sur la file d’attente :
from azure.identity import DefaultAzureCredential from azure.servicebus import ServiceBusClient credential = DefaultAzureCredential() service_bus_name = os.environ['SB_NAMESPACE'] fully_qualified_namespace= f'{service_bus_name}.servicebus.windows.net' client = ServiceBusClient(fully_qualified_namespace, credential)
Un des problèmes d’utiliser une Container App pour faire du batch, c’est que les messages sur un service bus ne peuvent être verrouillés que pendant 5 minutes maximum mais si notre batch dure plus longtemps, le verrou est libéré et un autre container peut le récupérer et lancer en parallèle le même traitement. Pour résoudre ce problème, il faut renouveler le verrou jusqu’à ce que le traitement soit complètement terminé en utilisant l’objet « AutoLockRenewer ». Voici comment récupérer le message :
a_day = 24*60*60 lock_renewal = AutoLockRenewer(max_lock_renewal_duration=a_day, on_lock_renew_failure=on_renew_error) receiver = client.get_queue_receiver(queue_name, max_wait_time=30, auto_lock_renewer=lock_renewal) received_message = receiver.receive_messages(max_message_count=1, max_wait_time=10)
Et une fois que le traitement est terminé, nous pouvons acquitter le message « receiver.complete_message(message) » ou le mettre dans la dead_letter « receiver.dead_letter_message(message) »
Nous avons ainsi une instance Container App qui se crée pour chaque message reçu dans la file d’attente et la Container App descend le nombre d’instance à 0 quand il n’y a plus de message. Nous avons une utilisation et une consommation optimale des ressources.
A savoir que dans le contenu du message reçu sur la file d’attente il y a la commande dbt à exécuter. Par exemple, « build –profiles-dir . ». Dans notre code python, nous passons en paramètre la commande « dbt build –profiles-dir . ».
Ainsi, nous pouvons lancer différentes commandes sur le même projet dbt : « dbt test –profiles-dir . », « dbt build –select tag:my_tag –profiles-dir . »
Code source python : https://github.com/dcube/azure-dbt/tree/main/container/python_launcher
Déploiement de la documentation dbt
dbt permet de documenter et d’exposer la documentation par un site web static contenant l’intégralité des éléments descriptifs de vos modèles : description des tables, des vues, des champs (data dictionnary), des tests (data quality) visualisation du code source et code SQL compilé, la dépendance entre les différentes modèles (data lineage). Bref des éléments essentiels à cocher dans le cadre de la gouvernance de vos data products analytics. Voyons comment exposer ce site web sur une autre Container App.
Tout d’abord, dans le pipeline Yaml, il faut générer la doc dbt que nous mettons dans un autre répertoire :
– bash: | dbt docs generate –no-compile displayName: DBT Generate Docs workingDirectory: $(Build.artifactstagingdirectory)/dbt/dbt_docs
Ensuite, il faut un fichier dockerfile dédié qui exécute le site static via la commande dbt docs serve :
FROM ghcr.io/dbt-labs/dbt-snowflake:1.3.latest #copy DBT project COPY dbt_docs /dbt WORKDIR /dbt # Run dbt docs ENTRYPOINT [« dbt », « docs », « serve », « –port », « 8080 »]
Et enfin dans le script Terraform, il faut déployer un nouveau container :
resource "azurerm_user_assigned_identity" "aci_identity_doc" { name = "id-data-dev-02" resource_group_name = data.azurerm_resource_group.this.name location = data.azurerm_resource_group.this.location tags = merge(data.azurerm_resource_group.this.tags, { Role = "Identité de la container app" }) } resource "azurerm_role_assignment" "container_app_pull_assignment_doc" { scope = data.azurerm_container_registry.this.id role_definition_name = "AcrPull" principal_id = azurerm_user_assigned_identity.aci_identity_doc.principal_id } resource "azapi_resource" "container_app_docs" { name = local.resource_names.container_app_doc_name location = data.azurerm_resource_group.this.location parent_id = data.azurerm_resource_group.this.id identity { type = "UserAssigned" identity_ids = [azurerm_user_assigned_identity.aci_identity_doc.id] } type = "Microsoft.App/containerApps@2022-03-01" body = jsonencode({ properties : { managedEnvironmentId = azurerm_container_app_environment.container_environment.id configuration = { secrets = [] ingress = { external = true targetPort = 8080 allowInsecure = false } registries = [ { server = data.azurerm_container_registry.this.login_server identity = azurerm_user_assigned_identity.aci_identity_doc.id }] } template = { containers = [{ image = "${data.azurerm_container_registry.this.login_server}/${var.container_doc_repository}:${var.container_image_tag}" name = "dbt-instance" resources = { cpu = var.container_cpu memory = var.container_memory } env = [] }] scale = { minReplicas = 0 maxReplicas = 1 rules = [{ name = "httpscalingrule" custom = { type = "http" metadata = { "concurrentRequests" : "100" } } }] } } } }) tags = merge(data.azurerm_resource_group.this.tags, { Role = "Container app pour la doc DBT" }) depends_on = [ azurerm_role_assignment.container_app_pull_assignment_doc ] }
Contrairement à l’autre Container App, celle-ci a une règle de scale sur le nombre de requête HTTP concurrente et nous avons activé l’ingress pour y accéder.
Attention à bien mettre en place les règles de sécurité nécessaires pour que ce ne soit pas accessible par n’importe qui (VNET, filtrage IP, …)
Code source complet : https://github.com/dcube/azure-dbt
Frédéric BROSSARD & Nicolas BAILLY
Really informative article, I had the opportunity to learn a lot, thank you.