Build Microservice Architecture with Azure

Problem

You use microservice architecture techniques to design a microservice application and have determined that you want to use Azure container apps and Dapr to create these applications. In this article, we will create a microservices architecture example application based on what we learned in Part 1 and Part 2 of this tutorial.

Solution

To complete this task, you will use Bicep to provision resources for your Azure infrastructure and enable the container apps with Dapr as the main resource, depending on running other resources. You will also use Node.js to create the applications to communicate with resources on the data plane.

Prerequisites

Application Layout

The application will be divided into Bicep infrastructure-related code, Bicep application-related code, Yaml Dapr components definitions, and Node.js code for the container apps-injected applications.

The layout will look like this:

  • Core (infrastructure Bicep)
    • Host (environment)
    • Analytics (host analysis and logging)
    • Security (infrastructure access control)
  • App (application Bicep)
  • Main File
  • Node.js applications (checkout and order processor)

We will use Bicep to develop the core infrastructure (resources for creating the environment, analyzing the environment, and role management or access control). Then we will create a folder to develop the container apps and the Dapr pub/sub components.

In this tutorial, we will deploy an Azure Container Registry to store different Container apps with Dapr sidecars as images, keeping track of our system using Azure analysis:

ACR architecture with microservices

Image source: https://learn.microsoft.com/zh-tw/azure/architecture/example-scenario/serverless/microservices-with-container-apps

This tutorial will focus on deploying resources required using Infrastructure as Code (IaC).

Core Infrastructure

In this section, we will identify and learn about the core resources needed to develop our infrastructure. Let’s start with resources that are built independently from other resources and serve as a building block for other resources – this will be our Analytical resources.

Start by creating the main directory for your application, naming it microservice_containerApps_Dapr. Then create a folder named core with three separate folders under it named host, analytics, and security.

Analytics Resources

The analytics folder will house the Bicep code for monitoring your infrastructure and application’s health and performance as part of Azure Monitor to help collect, analyze, and respond to monitoring data from your environments.

Log Analytics Workspace

First, let’s create a new log analytics workspace, i.e., a platform to help us collect, analyze, and debug data from our applications, which is essential to maintaining a good-performing application.

To create a new log analytics workspace, create a Bicep file named log-analytics.bicep and add the following code:

metadata description = 'Creates a Log Analytics workspace.'
param name string = 'log-analytics'
param location string = resourceGroup().location
param tags object = {}
 
resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
  name: name
  location: location
  tags: tags
  properties: any({
    retentionInDays: 30
    features: {
      searchVersion: 1
    }
  })
}
 
output id string = logAnalytics.id
output name string = logAnalytics.name

This file creates a new log analytics workspace and outputs the name and id of the resource.

Application Insight Instance

Now let’s create an application insight instance to enhance your logging capabilities by providing telemetry data such as requests, exceptions, traces, dependencies, custom metrics, and log data. Application Insights is a feature of Azure Monitor that helps with monitoring performance for live web applications, also supports distributed tracing, which allows you to trace requests across services and components. To create an Application Insight resource linked to the log analytics we created above, create a Bicep file named application-insights.bicep and add the following code:

metadata description = 'Creates an Application Insights instance based on an existing Log Analytics workspace.'
param name string
 
param location string = resourceGroup().location
param tags object = {}
param logAnalyticsWorkspaceId string
 
resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = {
  name: name
  location: location
  tags: tags
  kind: 'web'
  properties: {
    Application_Type: 'web'
    WorkspaceResourceId: logAnalyticsWorkspaceId
  }
}
 
output connectionString string = applicationInsights.properties.ConnectionString
output id string = applicationInsights.id
output instrumentationKey string = applicationInsights.properties.InstrumentationKey
output name string = applicationInsights.name

Single File – Application Insights Instance/Log Analytics Workspace

Now let’s create a single file that will reference both resources created above and help chain the resources together so that the Application Insight can get the linked workspace via its id output. To do this, let’s create a bicep file named monitoring.bicep and add the following code:

metadata description = 'Creates an Application Insights instance and a Log Analytics workspace.'
param logAnalyticsName string
param applicationInsightsName string
 
param location string = resourceGroup().location
param tags object = {}
 
module logAnalytics 'log-analytics.bicep' = {
  name: 'loganalytics'
  params: {
    name: logAnalyticsName
    location: location
    tags: tags
  }
}
 
module applicationInsights 'application-insights.bicep' = {
  name: 'applicationinsights'
  params: {
    name: applicationInsightsName
    location: location
    tags: tags
   
    logAnalyticsWorkspaceId: logAnalytics.outputs.id
  }
}
 
output applicationInsightsConnectionString string = applicationInsights.outputs.connectionString
output applicationInsightsId string = applicationInsights.outputs.id
output applicationInsightsInstrumentationKey string = applicationInsights.outputs.instrumentationKey
output applicationInsightsName string = applicationInsights.outputs.name
output logAnalyticsWorkspaceId string = logAnalytics.outputs.id
output logAnalyticsWorkspaceName string = logAnalytics.outputs.name

Host

The host folder will house the Bicep code for deploying a managed environment, the container apps, updating the container apps, and registry for the container apps.

Azure Container Apps Environment

For the managed environment, create a Bicep file under the host folder named container-apps-env.bicep and add the following code:

metadata description = 'Creates an Azure Container Apps environment.'
param name string
param location string = resourceGroup().location
param tags object = {}
 
@description('Name of the Application Insights resource')
param applicationInsightsName string = ''
 
@description('Specifies if Dapr is enabled')
param daprEnabled bool = false
 
@description('Name of the Log Analytics workspace')
param logAnalyticsWorkspaceName string
 
resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2024-03-01' = {
  name: name
  location: location
  tags: tags
  properties: {
    appLogsConfiguration: {
      destination: 'log-analytics'
      logAnalyticsConfiguration: {
        customerId: logAnalyticsWorkspace.properties.customerId
        sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey
      }
    }
    daprAIInstrumentationKey: daprEnabled && !empty(applicationInsightsName) ? applicationInsights.properties.InstrumentationKey : ''
  }
}
 
resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' existing = {
  name: logAnalyticsWorkspaceName
}
 
resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (daprEnabled && !empty(applicationInsightsName)) {
  name: applicationInsightsName
}
 
output defaultDomain string = containerAppsEnvironment.properties.defaultDomain
output id string = containerAppsEnvironment.id
output name string = containerAppsEnvironment.name

The code above creates a managed environment to host your container apps which we will define shortly. This resource also enables Dapr and the instrumentation key, which gives access to telemetry data that will be logged by Application Insights.

Azure Container Registry

Our container app will be deployed as docker images and stored on the Azure container registry (ACR). To deploy the ARC, create a Bicep file named container-registry.bicep and add the following code:

metadata description = 'Creates an Azure Container Registry.'
param name string
param location string = resourceGroup().location
param tags object = {}
 
@description('Indicates whether admin user is enabled')
param adminUserEnabled bool = false
 
@description('Indicates whether anonymous pull is enabled')
param anonymousPullEnabled bool = false
 
@description('Azure ad authentication as arm policy settings')
param azureADAuthenticationAsArmPolicy object = {
  status: 'enabled'
}
 
@description('Indicates whether data endpoint is enabled')
param dataEndpointEnabled bool = false
 
@description('Encryption settings')
param encryption object = {
  status: 'disabled'
}
 
@description('Export policy settings')
param exportPolicy object = {
  status: 'enabled'
}
 
@description('Metadata search settings')
param metadataSearch string = 'Disabled'
 
@description('Options for bypassing network rules')
param networkRuleBypassOptions string = 'AzureServices'
 
@description('Public network access setting')
param publicNetworkAccess string = 'Enabled'
 
@description('Quarantine policy settings')
param quarantinePolicy object = {
  status: 'disabled'
}
 
@description('Retention policy settings')
param retentionPolicy object = {
  days: 7
  status: 'disabled'
}
 
@description('Scope maps setting')
param scopeMaps array = []
 
@description('SKU settings')
param sku object = {
  name: 'Basic'
}
 
@description('Soft delete policy settings')
param softDeletePolicy object = {
  retentionDays: 7
  status: 'disabled'
}
 
@description('Trust policy settings')
param trustPolicy object = {
  type: 'Notary'
  status: 'disabled'
}
 
@description('Zone redundancy setting')
param zoneRedundancy string = 'Disabled'
 
@description('The log analytics workspace ID used for logging and monitoring')
param workspaceId string = ''
 
// 2023-11-01-preview needed for metadataSearch
resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' = {
  name: name
  location: location
  tags: tags
  sku: sku
  properties: {
    adminUserEnabled: adminUserEnabled
    anonymousPullEnabled: anonymousPullEnabled
    dataEndpointEnabled: dataEndpointEnabled
    encryption: encryption
    metadataSearch: metadataSearch
    networkRuleBypassOptions: networkRuleBypassOptions
    policies:{
      quarantinePolicy: quarantinePolicy
      trustPolicy: trustPolicy
      retentionPolicy: retentionPolicy
      exportPolicy: exportPolicy
      azureADAuthenticationAsArmPolicy: azureADAuthenticationAsArmPolicy
      softDeletePolicy: softDeletePolicy
    }
    publicNetworkAccess: publicNetworkAccess
    zoneRedundancy: zoneRedundancy
  }
 
  resource scopeMap 'scopeMaps' = [for scopeMap in scopeMaps: {
    name: scopeMap.name
    properties: scopeMap.properties
  }]
}
 
resource diagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (!empty(workspaceId)) {
  name: 'registry-diagnostics'
  scope: containerRegistry
  properties: {
    workspaceId: workspaceId
    logs: [
      {
        category: 'ContainerRegistryRepositoryEvents'
        enabled: true
      }
      {
        category: 'ContainerRegistryLoginEvents'
        enabled: true
      }
    ]
    metrics: [
      {
        category: 'AllMetrics'
        enabled: true
        timeGrain: 'PT1M'
      }
    ]
  }
}
 
output id string = containerRegistry.id
output loginServer string = containerRegistry.properties.loginServer
output name string = containerRegistry.name

Container Apps

Now to provision your container apps. Create a file named container-app.bicep and add the following code:

metadata description = 'Creates a container app in an Azure Container App environment.'
param name string
param location string = resourceGroup().location
param tags object = {}
 
@description('Allowed origins')
param allowedOrigins array = []
 
@description('Name of the environment for container apps')
param containerAppsEnvironmentName string
 
@description('CPU cores allocated to a single container instance, e.g., 0.5')
param containerCpuCoreCount string = '0.5'
 
@description('The maximum number of replicas to run. Must be at least 1.')
@minValue(1)
param containerMaxReplicas int = 10
 
@description('Memory allocated to a single container instance, e.g., 1Gi')
param containerMemory string = '1.0Gi'
 
@description('The minimum number of replicas to run. Must be at least 1.')
param containerMinReplicas int = 1
 
@description('The name of the container')
param containerName string = 'main'
 
@description('The name of the container registry')
param containerRegistryName string = ''
 
@description('Hostname suffix for container registry. Set when deploying to sovereign clouds')
param containerRegistryHostSuffix string = 'azurecr.io'
 
@description('The protocol used by Dapr to connect to the app, e.g., http or grpc')
@allowed([ 'http', 'grpc' ])
param daprAppProtocol string = 'http'
 
@description('The Dapr app ID')
param daprAppId string = containerName
 
@description('Enable Dapr')
param daprEnabled bool = false
 
@description('The environment variables for the container')
param env array = []
 
@description('Specifies if the resource ingress is exposed externally')
param external bool = true
 
@description('The name of the user-assigned identity')
param identityName string = ''
 
@description('The type of identity for the resource')
@allowed([ 'None', 'SystemAssigned', 'UserAssigned' ])
param identityType string = 'None'
 
@description('The name of the container image')
param imageName string = ''
 
@description('Specifies if Ingress is enabled for the container app')
param ingressEnabled bool = true
 
param revisionMode string = 'Single'
 
@description('The secrets required for the container')
@secure()
param secrets object = {}
 
@description('The service binds associated with the container')
param serviceBinds array = []
 
@description('The name of the container apps add-on to use. e.g. redis')
param serviceType string = ''
 
@description('The target port for the container')
param targetPort int = 80
 
resource userIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = if (!empty(identityName)) {
  name: identityName
}
 
// Private registry support requires both an ACR name and a User Assigned managed identity
var usePrivateRegistry = !empty(identityName) && !empty(containerRegistryName)
 
// Automatically set to `UserAssigned` when an `identityName` has been set
var normalizedIdentityType = !empty(identityName) ? 'UserAssigned' : identityType
 
module containerRegistryAccess '../security/container-registry-access.bicep' = if (usePrivateRegistry) {
  name: '${deployment().name}-registry-access'
  params: {
    containerRegistryName: containerRegistryName
    principalId: usePrivateRegistry ? userIdentity.properties.principalId : ''
  }
}
 
resource app 'Microsoft.App/containerApps@2023-05-02-preview' = {
  name: name
  location: location
  tags: tags
  // It is critical that the identity is granted ACR pull access before the app is created
  // otherwise the container app will throw a provision error
  // This also forces us to use an user assigned managed identity since there would no way to 
  // provide the system assigned identity with the ACR pull access before the app is created
  dependsOn: usePrivateRegistry ? [ containerRegistryAccess ] : []
  identity: {
    type: normalizedIdentityType
    userAssignedIdentities: !empty(identityName) && normalizedIdentityType == 'UserAssigned' ? { '${userIdentity.id}': {} } : null
  }
  properties: {
    managedEnvironmentId: containerAppsEnvironment.id
    configuration: {
      activeRevisionsMode: revisionMode
      ingress: ingressEnabled ? {
        external: external
        targetPort: targetPort
        transport: 'auto'
        corsPolicy: {
          allowedOrigins: union([ 'https://portal.azure.com', 'https://ms.portal.azure.com' ], allowedOrigins)
        }
      } : null
      dapr: daprEnabled ? {
        enabled: true
        appId: daprAppId
        appProtocol: daprAppProtocol
        appPort: ingressEnabled ? targetPort : 0
      } : { enabled: false }
      secrets: [for secret in items(secrets): {
        name: secret.key
        value: secret.value
      }]
      service: !empty(serviceType) ? { type: serviceType } : null
      registries: usePrivateRegistry ? [
        {
          server: '${containerRegistryName}.${containerRegistryHostSuffix}'
          identity: userIdentity.id
        }
      ] : []
    }
    template: {
      serviceBinds: !empty(serviceBinds) ? serviceBinds : null
      containers: [
        {
          image: !empty(imageName) ? imageName : 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest'
          name: containerName
          env: env
          resources: {
            cpu: json(containerCpuCoreCount)
            memory: containerMemory
          }
        }
      ]
      scale: {
        minReplicas: containerMinReplicas
        maxReplicas: containerMaxReplicas
      }
    }
  }
}
 
resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2023-05-01' existing = {
  name: containerAppsEnvironmentName
}
 
output defaultDomain string = containerAppsEnvironment.properties.defaultDomain
output identityPrincipalId string = normalizedIdentityType == 'None' ? '' : (empty(identityName) ? app.identity.principalId : userIdentity.properties.principalId)
output imageName string = imageName
output name string = app.name
output serviceBind object = !empty(serviceType) ? { serviceId: app.id, name: name } : {}
output uri string = ingressEnabled ? 'https://${app.properties.configuration.ingress.fqdn}' : ''

The code above creates a container app with Dapr enabled, along with its container registry access to control user access for the container apps.

You can deploy your resources as much as you can, and if you deploy a duplicate, Azure will not deploy the duplicate deployment, but won’t give you an error. It is ideal to create a file that enables you to update your container apps after they are deployed. Create a bicep file named container-app-upsert.bicep and add the following code:

metadata description = 'Creates or updates an existing Azure Container App.'
param name string
param location string = resourceGroup().location
param tags object = {}
 
@description('The environment name for the container apps')
param containerAppsEnvironmentName string
 
@description('The number of CPU cores allocated to a single container instance, e.g., 0.5')
param containerCpuCoreCount string = '0.5'
 
@description('The maximum number of replicas to run. Must be at least 1.')
@minValue(1)
param containerMaxReplicas int = 10
 
@description('The amount of memory allocated to a single container instance, e.g., 1Gi')
param containerMemory string = '1.0Gi'
 
@description('The minimum number of replicas to run. Must be at least 1.')
@minValue(1)
param containerMinReplicas int = 1
 
@description('The name of the container')
param containerName string = 'main'
 
@description('The name of the container registry')
param containerRegistryName string = ''
 
@description('Hostname suffix for container registry. Set when deploying to sovereign clouds')
param containerRegistryHostSuffix string = 'azurecr.io'
 
@allowed([ 'http', 'grpc' ])
@description('The protocol used by Dapr to connect to the app, e.g., HTTP or gRPC')
param daprAppProtocol string = 'http'
 
@description('Enable or disable Dapr for the container app')
param daprEnabled bool = false
 
@description('The Dapr app ID')
param daprAppId string = containerName
 
@description('Specifies if the resource already exists')
param exists bool = false
 
@description('Specifies if Ingress is enabled for the container app')
param ingressEnabled bool = true
 
@description('The type of identity for the resource')
@allowed([ 'None', 'SystemAssigned', 'UserAssigned' ])
param identityType string = 'None'
 
@description('The name of the user-assigned identity')
param identityName string = ''
 
@description('The name of the container image')
param imageName string = ''
 
@description('The secrets required for the container')
@secure()
param secrets object = {}
 
@description('The environment variables for the container')
param env array = []
 
@description('Specifies if the resource ingress is exposed externally')
param external bool = true
 
@description('The service binds associated with the container')
param serviceBinds array = []
 
@description('The target port for the container')
param targetPort int = 80
 
resource existingApp 'Microsoft.App/containerApps@2023-05-02-preview' existing = if (exists) {
  name: name
}
 
module app 'container-app.bicep' = {
  name: '${deployment().name}-update'
  params: {
    name: name
    location: location
    tags: tags
    identityType: identityType
    identityName: identityName
    ingressEnabled: ingressEnabled
    containerName: containerName
    containerAppsEnvironmentName: containerAppsEnvironmentName
    containerRegistryName: containerRegistryName
    containerRegistryHostSuffix: containerRegistryHostSuffix
    containerCpuCoreCount: containerCpuCoreCount
    containerMemory: containerMemory
    containerMinReplicas: containerMinReplicas
    containerMaxReplicas: containerMaxReplicas
    daprEnabled: daprEnabled
    daprAppId: daprAppId
    daprAppProtocol: daprAppProtocol
    secrets: secrets
    external: external
    env: env
    imageName: !empty(imageName) ? imageName : exists ? existingApp.properties.template.containers[0].image : ''
    targetPort: targetPort
    serviceBinds: serviceBinds
  }
}
 
output defaultDomain string = app.outputs.defaultDomain
output imageName string = app.outputs.imageName
output name string = app.outputs.name
output uri string = app.outputs.uri

ACR/Azure Container Apps Environment

Now onto bundling the container apps with their respective container app registry. Create a Bicep file named container-apps.bicep and add the following code:

metadata description = 'Creates an Azure Container Registry and an Azure Container Apps environment.'
param name string
param location string = resourceGroup().location
param tags object = {}
 
param containerAppsEnvironmentName string
param containerRegistryName string
param containerRegistryResourceGroupName string = ''
param containerRegistryAdminUserEnabled bool = false
param logAnalyticsWorkspaceName string
param applicationInsightsName string = ''
param daprEnabled bool = false
 
module containerAppsEnvironment 'container-apps-env.bicep' = {
  name: '${name}-container-apps-environment'
  params: {
    name: containerAppsEnvironmentName
    location: location
    tags: tags
    logAnalyticsWorkspaceName: logAnalyticsWorkspaceName
    applicationInsightsName: applicationInsightsName
    daprEnabled: daprEnabled
  }
}
 
module containerRegistry 'container-registry.bicep' = {
  name: '${name}-container-registry'
  scope: !empty(containerRegistryResourceGroupName) ? resourceGroup(containerRegistryResourceGroupName) : resourceGroup()
  params: {
    name: containerRegistryName
    location: location
    adminUserEnabled: containerRegistryAdminUserEnabled
    tags: tags
  }
}
 
output defaultDomain string = containerAppsEnvironment.outputs.defaultDomain
output environmentName string = containerAppsEnvironment.outputs.name
output environmentId string = containerAppsEnvironment.outputs.id
output registryLoginServer string = containerRegistry.outputs.loginServer
output registryName string = containerRegistry.outputs.name

Security

On the security folder, you will assign a role for a user to access the ACR by creating a file named container-registry-access.bicep and adding the following code:

targetScope = 'resourceGroup'
 
metadata description = 'Assigns ACR Pull permissions to access an Azure Container Registry.'
param containerRegistryName string
param principalId string
 
var acrPullRole = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')
 
resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-01-01-preview' existing = {
  name: containerRegistryName
}
 
resource aksAcrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  scope: containerRegistry // Use when specifying a scope that is different than the deployment scope
  name: guid(resourceGroup().id,  containerRegistryName, principalId, acrPullRole)
  properties: {
    roleDefinitionId: acrPullRole
    principalType: 'ServicePrincipal'
    principalId: principalId
  }
}

That should cover the core folder. Below is what the folder structure should look like after adding the files:

  • Core (infrastructure Bicep)
    • Host (environment)
      • Container-apps-env.bicep
      • Container-app.bicep
      • Container-registry.bicep
      • Container-apps.bicep
      • Container-apps-upsert.bicep
    • Analytics (host analysis and logging)
      • Log-analytics.bicep
      • Application-insights.bicep
      • Monitoring.bicep
    • Security (infrastructure access control)
      • container-registry-access.bicep

Next, we move on to the files for the App folder.

App Folder

Let’s start with a file that will inherit the container apps resources created in the previous section as a module and chain it to a Dapr sup/pub component. Create a Bicep file named app-env.bicep and add the following code:

param containerAppsEnvName string
param containerRegistryName string
param location string
param logAnalyticsWorkspaceName string
param serviceBusName string
param applicationInsightsName string = ''
param daprEnabled bool = false
param managedIdentityClientId string
 
// Container apps host (including container registry)
module containerApps '../core/host/container-apps.bicep' = {
  name: 'container-apps'
  params: {
    name: 'apps'
    containerAppsEnvironmentName: containerAppsEnvName
    containerRegistryName: containerRegistryName
    location: location
    logAnalyticsWorkspaceName: logAnalyticsWorkspaceName
    applicationInsightsName: applicationInsightsName
    daprEnabled: daprEnabled
  }
}
 
// Get cApps Env resource instance to parent Dapr component config under it
resource caEnvironment  'Microsoft.App/managedEnvironments@2022-06-01-preview' existing = {
  name: containerAppsEnvName
}
 
resource daprComponentPubsub 'Microsoft.App/managedEnvironments/daprComponents@2022-06-01-preview' = {
  parent: caEnvironment
  name: 'orderpubsub'
  properties: {
    componentType: 'pubsub.azure.servicebus'
    version: 'v1'
    metadata: [
      {
        name: 'azureClientId'
        value: managedIdentityClientId  // See DefaultAzureCredentialOptions.ManagedIdentityClientId Property (Azure.Identity) - Azure for .NET Developers | Microsoft Learn for MSI
      }
      {
        name: 'namespaceName' // See Azure Service Bus Topics | Dapr Docs
        value: '${serviceBusName}.servicebus.windows.net' // the .servicebus.windows.net suffix is required as per dapr docs
      }
      {
        name: 'consumerID'
        value: 'orders' // Set to the same value of the subscription seen in ./servicebus.bicep
      }
    ]
    scopes: []
  }
  dependsOn: [
    containerApps
  ]
}
 
output environmentName string = containerApps.outputs.environmentName
output registryLoginServer string = containerApps.outputs.registryLoginServer
output registryName string = containerApps.outputs.registryName

The code above refers to the Bicep created earlier to provision a container app with a managed environment and ACR. The code then adds a Dapr sub/pub component resource with a property value componentType: ‘pubsub.azure.servicebus’

Then, to create a service bus resource, create a Bicep file named servicebus.bicep and add the following code:

param resourceToken string
param location string
param skuName string = 'Standard'
param topicName string = 'orders'
param tags object
 
 
resource serviceBusNamespace 'Microsoft.ServiceBus/namespaces@2022-10-01-preview' = {
  name: 'sb-${resourceToken}'
  location: location
  tags: tags
  sku: {
    name: skuName
    tier: skuName
  }
  properties: {
    minimumTlsVersion: '1.2'
  }
  resource topic 'topics' = {
    name: topicName
    properties: {
      supportOrdering: true
    }
 
    resource subscription 'subscriptions' = {
      name: topicName
      properties: {
        deadLetteringOnFilterEvaluationExceptions: true
        deadLetteringOnMessageExpiration: true
        maxDeliveryCount: 10
      }
    }
  }
}
 
output SERVICEBUS_ENDPOINT string = serviceBusNamespace.properties.serviceBusEndpoint
output serviceBusName string = serviceBusNamespace.name

To control the service bus access, create a Bicep file named access-control.bicep and add the following code:

param managedIdentityName string
param serviceBusName string
param location string
 
 
// See https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#azure-service-bus-data-sender
var roleIdS = '69a216fc-b8fb-44d8-bc22-1f3c2cd27a39' // Azure Service Bus Data Sender
// See https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#azure-service-bus-data-receiver
var roleIdR = '4f6d3b9b-027b-4f4c-9142-0e5a2a2247e0' // Azure Service Bus Data Receiver
 
// user assigned managed identity to use throughout
resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
  name: managedIdentityName
  location: location
}
 
resource serviceBus 'Microsoft.ServiceBus/namespaces@2021-11-01' existing = {
  name: serviceBusName
}
 
// Grant permissions to the managedIdentity to specific role to servicebus
resource roleAssignmentReceiver 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = {
  name: guid(serviceBus.id, roleIdR, managedIdentityName)
  scope: serviceBus
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleIdR)
    principalId: managedIdentity.properties.principalId
    principalType: 'ServicePrincipal' // managed identity is a form of service principal
  }
  dependsOn: [
    serviceBus
  ]
}
 
// Grant permissions to the managedIdentity to specific role to servicebus
resource roleAssignmentSender 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = {
  name: guid(serviceBus.id, roleIdS, managedIdentityName)
  scope: serviceBus
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleIdS)
    principalId: managedIdentity.properties.principalId
    principalType: 'ServicePrincipal' // managed identity is a form of service principal
  }
  dependsOn: [
    serviceBus
  ]
}
 
output managedIdentityPrincipalId string = managedIdentity.properties.principalId
output managedIdentityClientlId string = managedIdentity.properties.clientId
output managedIdentityId string = managedIdentity.id
output managedIdentityName string = managedIdentity.name

Then, let’s create a Bicep file to provision your publisher container apps. These will be container apps to host the publisher microservices. Create a file named publisher.bicep and add the following code:

param location string = resourceGroup().location
param tags object = {}
 
param containerAppsEnvironmentName string
param containerRegistryName string
param name string = ''
param serviceName string = 'checkout'
param managedIdentityName string = ''
param exists bool = false
 
 
module publisher '../core/host/container-app-upsert.bicep' = {
  name: '${serviceName}-container-app-module'
  params: {
    name: name
    location: location
    tags: union(tags, { 'azd-service-name': 'checkout' })
    containerAppsEnvironmentName: containerAppsEnvironmentName
    containerRegistryName: containerRegistryName
    containerCpuCoreCount: '1.0'
    containerMemory: '2.0Gi'
    daprEnabled: true
    containerName: serviceName
    daprAppId: serviceName
    ingressEnabled: false
    identityType: 'UserAssigned'
    identityName: managedIdentityName
    exists: exists
  }
}
 
 
output SERVICE_PUBLISHER_IMAGE_NAME string = publisher.outputs.imageName
output SERVICE_PUBLISHER_NAME string = publisher.outputs.name

For subscriber microservices, create a Bicep file named subscriber.bicep and add the following code:

param location string = resourceGroup().location
param tags object = {}
 
param containerAppsEnvironmentName string
param containerRegistryName string
param name string = ''
param serviceName string = 'orders'
param managedIdentityName string = ''
param exists bool = false
module subscriber '../core/host/container-app-upsert.bicep' = {
  name: '${serviceName}-container-app-module'
  params: {
    name: name
    location: location
    tags: union(tags, { 'azd-service-name': 'orders' })
    containerAppsEnvironmentName: containerAppsEnvironmentName
    containerRegistryName: containerRegistryName
    containerCpuCoreCount: '1.0'
    containerMemory: '2.0Gi'
    exists: exists
    daprEnabled: true
    containerName: serviceName
    daprAppId: serviceName
    targetPort: 5001
    identityType: 'UserAssigned'
    identityName: managedIdentityName
  }
}
 
 
output SUBSCRIBER_URI string = subscriber.outputs.uri
output SERVICE_SUBSCRIBER_IMAGE_NAME string = subscriber.outputs.imageName
output SERVICE_SUBSCRIBER_NAME string = subscriber.outputs.name

The App folder should look like this after adding bicep files:

  • App
    • App-env.bicep
    • Access.bicep
    • Servicebus.bicep
    • Publisher.bicep
    • Subscriber.bicep

The Main File

Now, it is time to bring the entire project together. In the main directory, create a Bicep file named main.bicep and add the following code:

targetScope = 'subscription'
 
 
@minLength(1)
@maxLength(64)
@description('Name of the environment which is used to generate a short unique hash used in all resources.')
param environmentName string
 
@minLength(1)
@description('Primary location for all resources')
param location string
 
// App based params
// Publisher
param publisherContainerAppName string = ''
param publisherServiceName string = 'checkout'
param publisherAppExists bool = false
 
//Subscriber
param subscriberContainerAppName string = ''
param subscriberServiceName string = 'orders'
param subscriberAppExists bool = false
 
 
param applicationInsightsName string = ''
param logAnalyticsName string = ''
 
 
param containerAppsEnvironmentName string = ''
param containerRegistryName string = ''
 
param resourceGroupName string = ''
// Optional parameters to override the default azd resource naming conventions. Update the main.parameters.json file to provide values. e.g.,:
// "resourceGroupName": {
//      "value": "myGroupName"
// }
 
var abbrs = loadJsonContent('./abbreviations.json')
var resourceToken = toLower(uniqueString(subscription().id, environmentName, location))
var tags = { 'azd-env-name': environmentName }
 
// Organize resources in a resource group
resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = {
  name: !empty(resourceGroupName) ? resourceGroupName : '${abbrs.resourcesResourceGroups}${environmentName}'
  location: location
  tags: tags
}
 
 
// Monitor application with Azure Monitor
module monitoring './core/analytics/monitoring.bicep' = {
  name: 'monitoring'
  scope: rg
  params: {
    location: location
    tags: tags
    logAnalyticsName: !empty(logAnalyticsName) ? logAnalyticsName : '${abbrs.operationalInsightsWorkspaces}${resourceToken}'
    applicationInsightsName: !empty(applicationInsightsName) ? applicationInsightsName : '${abbrs.insightsComponents}${resourceToken}'
   
  }
}
 
module serviceBusResources './app/servicebus.bicep' = {
  name: 'sb-resources'
  scope: rg
  params: {
    location: location
    tags: tags
    resourceToken: resourceToken
    skuName: 'standard'
  }
}
 
module serviceBusAccess './app/access.bicep' = {
  name: 'sb-access'
  scope: rg
  params: {
    location: location
    serviceBusName: serviceBusResources.outputs.serviceBusName
    managedIdentityName: '${abbrs.managedIdentityUserAssignedIdentities}${resourceToken}'
  }
}
 
 
// Shared App Env with Dapr configuration for db
module appEnv './app/app-env.bicep' = {
  name: 'app-env'
  scope: rg
  params: {
    containerAppsEnvName: !empty(containerAppsEnvironmentName) ? containerAppsEnvironmentName : '${abbrs.appManagedEnvironments}${resourceToken}'
    containerRegistryName: !empty(containerRegistryName) ? containerRegistryName : '${abbrs.containerRegistryRegistries}${resourceToken}'
    location: location
    logAnalyticsWorkspaceName: monitoring.outputs.logAnalyticsWorkspaceName
    serviceBusName: serviceBusResources.outputs.serviceBusName
    applicationInsightsName: monitoring.outputs.applicationInsightsName
    daprEnabled: true
    managedIdentityClientId: serviceBusAccess.outputs.managedIdentityClientlId
  }
}
 
module publisherApp './app/publisher.bicep' = {
  name: 'api-resources'
  scope: rg
  params: {
    name: !empty(publisherContainerAppName) ? publisherContainerAppName : '${abbrs.appContainerApps}${publisherServiceName}-${resourceToken}'
    serviceName: publisherServiceName
    containerRegistryName: appEnv.outputs.registryName
    location: location
    containerAppsEnvironmentName: appEnv.outputs.environmentName
    managedIdentityName: serviceBusAccess.outputs.managedIdentityName
    exists: publisherAppExists
  }
  dependsOn: [
    subscriberApp  // Deploy the subscriber first and then deploy the publisher
  ]
}
 
module subscriberApp './app/subscriber.bicep' = {
  name: 'web-resources'
  scope: rg
  params: {
    name: !empty(subscriberContainerAppName) ? subscriberContainerAppName : '${abbrs.appContainerApps}${subscriberServiceName}-${resourceToken}'
    location: location
    containerRegistryName: appEnv.outputs.registryName
    containerAppsEnvironmentName: appEnv.outputs.environmentName
    serviceName: subscriberServiceName
    managedIdentityName: serviceBusAccess.outputs.managedIdentityName
    exists: subscriberAppExists
  }
}
 
 
output SERVICE_PUBLISHER_NAME string = publisherApp.outputs.SERVICE_PUBLISHER_NAME
output SERVICE_PUBLISHER_IMAGE_NAME string = publisherApp.outputs.SERVICE_PUBLISHER_IMAGE_NAME
output SERVICE_SUBSCRIBER_NAME string = subscriberApp.outputs.SERVICE_SUBSCRIBER_NAME
output SERVICE_SUBSCRIBER_IMAGE_NAME string = subscriberApp.outputs.SERVICE_SUBSCRIBER_IMAGE_NAME
output SUBSCRIBER_APP_URI string = subscriberApp.outputs.SUBSCRIBER_URI
output SERVICEBUS_ENDPOINT string = serviceBusResources.outputs.SERVICEBUS_ENDPOINT
output SERVICEBUS_NAME string = serviceBusResources.outputs.serviceBusName
output APPINSIGHTS_INSTRUMENTATIONKEY string = monitoring.outputs.applicationInsightsInstrumentationKey
output APPLICATIONINSIGHTS_NAME string = monitoring.outputs.applicationInsightsName
output AZURE_CONTAINER_ENVIRONMENT_NAME string = appEnv.outputs.environmentName
output AZURE_CONTAINER_REGISTRY_ENDPOINT string = appEnv.outputs.registryLoginServer
output AZURE_CONTAINER_REGISTRY_NAME string = appEnv.outputs.registryName
output AZURE_MANAGED_IDENTITY_NAME string = serviceBusAccess.outputs.managedIdentityName

Before deploying your resources, you must create a JSON file with your parameters to avoid providing the parameters on your Azure CLI deployment command. Create a new JSON file named parameters.JSON and add the following code:

{
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
      "environmentName": {
        "value": "AzureDaprEnv"
      },
      "location": {
        "value": "eastus"
      },
      "publisherAppExists": {
        "value": false
      },
      "subscriberAppExists": {
        "value": false
      }
    }
  }

Deploy the Infrastructure

To deploy your application, use this Azure command:

az deployment group create --name myDeployment --resource-group myResourceGroup --template-file main.bicep --parameters main.parameters.json

After running the command, you will see a success message on your terminal. However, you can get a clearer picture of the deployed resources by logging into the Azure Portal and opening the resource group you use:

Azure Portal deployed resources

Node.js Projects

Now, create a folder for your Node.js projects. These projects communicate and interact with your deployed resources on a data plane. Before continuing, make sure to install the Dapr client for node.js:

npm i dapr-client

Then, create a folder for the publisher application named checkout, add a file named index.js, and add the following code:

import { DaprClient } from 'dapr-client'; 
 
  
 
const DAPR_HOST = process.env.DAPR_HOST || "http://localhost"; 
 
const DAPR_HTTP_PORT = process.env.DAPR_HTTP_PORT || "3500"; 
 
const PUBSUB_NAME = "orderpubsub"; 
 
const PUBSUB_TOPIC = "orders"; 
 
  
 
async function main() { 
 
  const client = new DaprClient(DAPR_HOST, DAPR_HTTP_PORT); 
 
  
 
  while (true) { 
 
    for (var i = 1; i <= 20; i++) { 
 
      const order = { 
 
        orderId: i 
 
      }; 
      // Publish an event using Dapr pub/sub 
      await client.pubsub.publish(PUBSUB_NAME, PUBSUB_TOPIC, order);
      console.log("Published data: " + JSON.stringify(order));
      await sleep(1000); 
 
    } 
 
    await sleep(20000); 
 
  } 
 
} 
async function sleep(ms) { 
 
  return new Promise(resolve => setTimeout(resolve, ms)); 
 
} 
main().catch(e => console.error(e))

Create a JSON file named package.json and add the following code:

{
    "name": "checkout",
    "version": "1.0.0",
    "description": "Checkout service for pub/sub with Dapr on Azure",
    "main": "index.js",
    "type": "module",
    "scripts": {
        "start": "node index.js"
    },
    "keywords": [],
    "author": "",
    "license": "ISC",
    "dependencies": {
        "dapr-client": "^2.3.0"
    }
}

Create a new Docker file to connect the Node.js project to the container apps image in the ACR:

# Use the Node.js base image from Alpine Linux
FROM node:lts-alpine
 
# Set the environment to production
ENV NODE_ENV=production
 
# Define the working directory inside the container
# You should point this to the relevant path where your application code will reside inside the container.
WORKDIR /usr/src/app
 
# Copy your package.json and lock files first to install dependencies
COPY ["package.json", "./"]
 
# Install only production dependencies
RUN npm install --production --silent && mv node_modules ../
 
# Copy the rest of your application code into the container
# This will copy everything from the directory where Dockerfile is located
COPY . .
 
# Expose the application on port 5001
EXPOSE 5001
EXPOSE 3500  
 
# Change ownership to node user
RUN chown -R node /usr/src/app
 
# Switch to node user
USER node
 
# Command to run your application
CMD ["npm", "start"]

Create another Node.js project named order-processor, add a file named index.js, and add the following code:

import { DaprServer } from 'dapr-client'; 
 
const DAPR_HOST = process.env.DAPR_HOST || "http://localhost:9411"; 
 
const DAPR_HTTP_PORT = process.env.DAPR_HTTP_PORT || "3500"; 
 
const SERVER_HOST = process.env.SERVER_HOST || "127.0.0.1"; 
 
const SERVER_PORT = process.env.APP_PORT || 3000; 
 
async function main() { 
 
  const server = new DaprServer(SERVER_HOST, SERVER_PORT, DAPR_HOST, DAPR_HTTP_PORT); 
  // Dapr subscription routes orders topic to this route 
 
  server.pubsub.subscribe("orderpubsub", "orders", (data) => console.log("Subscriber received: " + JSON.stringify(data))); 
 
  
 
  await server.start(); 
 
} 
 
main().catch(e => console.error(e));

Create a JSON file named package.json and add the following code:

{
    "name": "checkout",
    "version": "1.0.0",
    "description": "order processor service for pub/sub with Dapr on Azure",
    "main": "index.js",
    "type": "module",
    "scripts": {
        "start": "node index.js"
    },
    "keywords": [],
    "author": "",
    "license": "ISC",
    "dependencies": {
        "dapr-client": "^2.3.0"
    }
}

Create another docker file for the subscription application:

# Use the Node.js base image from Alpine Linux
FROM node:lts-alpine
 
# Set the environment to production
ENV NODE_ENV=production
 
# Define the working directory inside the container
# You should point this to the relevant path where your application code will reside inside the container.
WORKDIR /usr/src/app
 
# Copy your package.json and lock files first to install dependencies
COPY ["package.json", "./"]
 
# Install only production dependencies
RUN npm install --production --silent && mv node_modules ../
 
# Copy the rest of your application code into the container
# This will copy everything from the directory where Dockerfile is located
COPY . .
 
# Expose the application on port 5001
EXPOSE 5001
EXPOSE 3500  
# Change ownership to node user
RUN chown -R node /usr/src/app
 
# Switch to node user
USER node
 
# Command to run your application
CMD ["npm", "start"]

Run the Node.js Projects

To begin this section, you must ensure that the Node.js Dapr client is installed:

npm install dapr-client

After successfully deploying your infrastructure, you can run the Node.js applications on the infrastructure by injecting the Node.js projects into the container apps created earlier. You must create an authorization rule and connection string to the topic created on Dapr. We will use the resource name, namespace, and topics to create the authorization rule for the topics and the connection string. These values are given when you deploy your infrastructure as an output on your terminal, or you can get these values via the Azure Portal:

Values for resource name, namespace, and topics provided in terminal

To get the values for the connection string using the following commands, first you must create an authorization rule for the “orders” service bus topic created in the previous tutorials:

az servicebus topic authorization-rule create --resource-group <resource-group-name> --namespace-name <namespace-name> --topic-name <topic name> --name <auth rule name> --rights Manage Send Listen

This will give you an output on your terminal:

values for the connection string

Then, create the connection string using the following command:

az webapp config connection-string set -g MyResourceGroup -n MyUniqueApp -t ServiceBus –s MySlotName –settings servicebus=’<MyServiceBusIdEndpoint>’

Next, create a Yaml file to establish the connection for the Dapr commands against Azure by creating a file named servicebus.yalm and adding the following code:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: orderpubsub  # This should match the pubsub name in your app (e.g., 'orderpubsub')
  namespace: default  # Replace with your namespace (or leave blank for default)
spec:
  type: pubsub.azure.servicebus
  version: v1
  metadata:
  - name: connectionString
    value: "<YourConnectionString>"

Now, to create the component that establishes communication between the checkout sidecars and the Azure service bus, you can use this az command:

az containerapp update --name ca-checkout-fdrtujfacex4o --resource-group rg-AzureDaprEnv --set  dapr.components='[{"name": "orderpubsub", "type": "pubsub.azure.servicebus", "metadata": [{"name": "connectionString", "value": "<YourConnectionString"}]}]'

For the orders component, use the following az command:

az containerapp update --name ca-orders-fdrtujfacex4o --resource-group rg-AzureDaprEnv --set  dapr.components='[{"name": "orderpubsub", "type": "pubsub.azure.servicebus", "metadata": [{"name": "connectionString", "value": "<YourConnectionString>"}]}]'

Then navigate to your Node.js publisher project and use this Dapr command to run the application:

dapr run --app-port 5001 --app-id <publisher_app_id> --app-protocol http --dapr-http-port 3501 --components-path ../components -- npm run start

Last, navigate to your Node.js subscriber project and use this Dapr command to run the application:

dapr run --app-port 5001 --app-id <subscriber_app_id> --app-protocol http --dapr-http-port 3501 --components-path ../components -- npm run start

Conclusion

Great job for going all the way! This tutorial aims to take you through the process of deploying Azure infrastructure resources, integrating your Azure resources with Dapr, and creating Node.js applications to be injected into Azure container apps, which uses the pub/sub technique to distribute communication between your applications. This tutorial also demonstrates how to manually deploy Bicep files. You can deploy and update the Bicep templates using Azure Pipelines, which will be covered in Part 4 of this series.

Next Steps

One comment

Leave a Reply

Your email address will not be published. Required fields are marked *