Azure Functions running in Azure Container Apps with Dapr

2023, Jun 13

In case your organization has embraced containers in its strategy, it's worth noting that Azure Container Apps now can host and enable the execution of Azure Functions. Embracing this approach brings several benefits, as Azure Functions can leverage the features offered by Azure Container Apps. Let's delve into Dapr as an illustration of this scenario.

NOTE: This is currently in Public Preview.

The Azure Function

To obtain a containerized instance of Azure Functions, we can utilize the func command to automatically generate the necessary Dockerfile for us:

func init --worker-runtime dotnet-isolated --docker --target-framework "net7.0"

The generated Dockerfile appears outdated, as it utilizes an older version of the dotnet SDK that is not aligned with the latest version:

FROM mcr.microsoft.com/dotnet/sdk:5.0 AS installer-env

# Build requires 3.1 SDK
COPY --from=mcr.microsoft.com/dotnet/core/sdk:3.1 /usr/share/dotnet /usr/share/dotnet

COPY . /src/dotnet-function-app
RUN cd /src/dotnet-function-app && \
    mkdir -p /home/site/wwwroot && \
    dotnet publish *.csproj --output /home/site/wwwroot

# To enable ssh & remote debugging on app service change the base image to the one below
# FROM mcr.microsoft.com/azure-functions/dotnet-isolated:3.0-dotnet-isolated5.0-appservice
FROM mcr.microsoft.com/azure-functions/dotnet-isolated:3.0-dotnet-isolated5.0
ENV AzureWebJobsScriptRoot=/home/site/wwwroot \
    AzureFunctionsJobHost__Logging__Console__IsEnabled=true

COPY --from=installer-env ["/home/site/wwwroot", "/home/site/wwwroot"]

To align with the latest net7.0 (as of today), the updated Dockerfile will become this:

FROM mcr.microsoft.com/dotnet/sdk:7.0 AS installer-env

# Build requires 7.0 SDK
COPY --from=mcr.microsoft.com/dotnet/sdk:7.0 /usr/share/dotnet /usr/share/dotnet

COPY . /src/dotnet-function-app
RUN cd /src/dotnet-function-app && \
    mkdir -p /home/site/wwwroot && \
    dotnet publish *.csproj --output /home/site/wwwroot

# To enable ssh & remote debugging on app service change the base image to the one below
FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated7.0
ENV AzureWebJobsScriptRoot=/home/site/wwwroot \
    AzureFunctionsJobHost__Logging__Console__IsEnabled=true

COPY --from=installer-env ["/home/site/wwwroot", "/home/site/wwwroot"]

In addition to that, we also need to incorporate certain Functions, specifically HttpTriggers for HttpExample and DaprExample. To achieve this, run the following commands:

func new --name HttpExample --template "HTTP trigger" --authlevel anonymous
func new --name DaprExample --template "HTTP trigger" --authlevel anonymous

You should be able to start the function by running func start.

NuGet packages and code changes

Update the NuGet packages of the project to the latest, make sure to also include Dapr.Client.

The DaprExample.cs file will be replaced with this:

public class DaprExample
{
    private readonly ILogger _logger;
    private readonly string _daprStoreName = "function-statestore";

    public DaprExample(ILoggerFactory loggerFactory)
    {
        _logger = loggerFactory.CreateLogger<DaprExample>();
    }

    [Function(nameof(SaveState))]
    public async Task<HttpResponseData> SaveState([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req)
    {
        _logger.LogInformation("C# HTTP trigger function [SaveState] processed a request.");

        var client = new DaprClientBuilder().Build();
        for (var i = 1; i <= 100; i++)
        {
            var order = new Order(i);

            // Save state into the state store
            await client.SaveStateAsync(_daprStoreName, i.ToString(), order.ToString());
            Console.WriteLine("Saving Order: " + order);
        }

        var response = req.CreateResponse(HttpStatusCode.OK);
        response.Headers.Add("Content-Type", "text/plain; charset=utf-8");

        await response.WriteStringAsync("State saved with Dapr on Azure Functions!");

        return response;
    }

    [Function(nameof(GetState))]
    public async Task<HttpResponseData> GetState([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req)
    {
        _logger.LogInformation("C# HTTP trigger function [GetState] processed a request.");

        var client = new DaprClientBuilder().Build();
        for (var i = 1; i <= 100; i++)
        {
            // Get state from the state store
            var result = await client.GetStateAsync<string>(_daprStoreName, i.ToString());
            Console.WriteLine("Getting Order: " + result);
        }

        var response = req.CreateResponse(HttpStatusCode.OK);
        response.Headers.Add("Content-Type", "text/plain; charset=utf-8");

        await response.WriteStringAsync("State retrieved with Dapr on Azure Functions!");

        return response;
    }

    [Function(nameof(DeleteState))]
    public async Task<HttpResponseData> DeleteState([HttpTrigger(AuthorizationLevel.Anonymous, "delete")] HttpRequestData req)
    {
        _logger.LogInformation("C# HTTP trigger function [DeleteState] processed a request.");

        var client = new DaprClientBuilder().Build();
        for (var i = 1; i <= 100; i++)
        {
            var order = new Order(i);

            // Delete state from the state store
            await client.DeleteStateAsync(_daprStoreName, i.ToString());
            Console.WriteLine("Deleting Order: " + order);
        }

        var response = req.CreateResponse(HttpStatusCode.OK);
        response.Headers.Add("Content-Type", "text/plain; charset=utf-8");

        await response.WriteStringAsync("State deleted with Dapr on Azure Functions!");

        return response;
    }
}

public record Order([property: JsonPropertyName("orderId")] int OrderId);

How to run (and test) it locally? The Developer experience

Getting started can pose challenges, as it requires a certain level of prior knowledge about Dapr and Docker Compose. This understanding is crucial for troubleshooting any unexpected issues that may arise.

First of all, to be able to run the function locally with full Dapr support, make sure you have Dapr installed1 and initialised2.

Assuming that you have your Dapr environment sorted, copy the dapr\components\function-statestore.yaml file and save it to your C:\Users\youruser\.dapr\components folder. It should look like this:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: function-statestore
  namespace: function
spec:
  type: state.redis
  version: v1
  metadata:
  - name: redisHost
    value: redis:6379
  - name: redisPassword
    value: ""
  - name: actorStateStore
    value: "true"
scopes:
- function

NOTE: The variable value of _daprStoreName matches the metadata name on function-statestore.yaml.

Now you can run it locally with the following command:

docker compose up

NOTE: You should not modify the docker-compose.yml, it should work as is. One thing to notice is that we are using redis locally.

The docker-compose.yml file is accessible in the repository and is presented here solely for educational purposes. For more comprehensive information, kindly refer to the Dapr documentation3:

version: '3.8'

services:

  function:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - 8080:80
    depends_on:
      - placement
    networks:
      - dummy-dapr

  function-dapr:
    image: "daprio/daprd:edge"
    command: [
      "./daprd",
     "-app-id", "function",
     "-placement-host-address", "placement:50000",
     "-resources-path", "./components",
     "-log-level", "debug"
     ]
    volumes:
      - ~/.dapr/components:/components
    depends_on:
      - function
    network_mode: "service:function"

  placement:
    image: "daprio/dapr"
    command: ["./placement", "--port", "50000"]
    ports:
      - "50000:50000"
    volumes:
      - ~/.dapr:/root/.dapr
    networks:
      - dummy-dapr
    environment:
      - DAPR_LOG_LEVEL=debug
      - DAPR_METRICS_PORT=9090

  redis:                                                                          
    image: "redis"
    hostname: "redis"
    ports:
      - "6379:6379"
    networks:
      - dummy-dapr

networks:
  dummy-dapr: null

WHERE:

  • function - the main service
  • function-dapr - the dapr sidecar
  • placement - the dapr self-hosted service
  • redis - the state storage service
  • dummy-dapr - a necessary docker network to connect the services

Troubleshooting

Possible errors you might face when running the docker-compose:

  • Bind for 0.0.0.0:6379 failed: port is already allocated

Make sure to stop all Redis containers running on your machine.

  • redis store: error connecting to redis at : dial tcp 127.0.0.1:6379: connect: connection refused
  • Grpc.Core.RpcException: Status(StatusCode="Unavailable", Detail="Error connecting to subchannel.", DebugException="System.Net.Sockets.SocketException (111): Connection refused

For both errors you probably have a local statestore.yml (or another file) that is conflicting, available on your C:\Users\youruser\.dapr\components folder. Try moving the files to another directory, then re-run docker-compose.

Create the container locally and push it to the Container Registry

First, we need to create the container, then we can push it to the Azure Container Registry.

Run the following to create and run the container locally:

docker build --tag demo-az-function:v1 .
docker run -p 8080:80 -it demo-az-function:v1

Test it with the HTTPExample endpoint:

curl --location 'http://localhost:8080/api/HttpExample'

Then get it ready to push to Azure Container Registry (ACR), by running the following:

// Login to the registry
az acr login --name <REGISTRY_NAME>

// Create another tag that includes the ACR FQDN (Fully Qualified Domain Name)
docker tag demo-az-function:v1 <LOGIN_SERVER>/azurefunctionsimage:v1.0.0

// Push the image to the registry
docker push <LOGIN_SERVER>/azurefunctionsimage:v1.0.0

// Enable admin account
az acr update -n <REGISTRY_NAME> --admin-enabled true

// Retrieve admin account details
az acr credential show -n <REGISTRY_NAME> --query "[username, passwords[0].value]" -o tsv

WHERE:

  • REGISTRY_NAME is the ACR name. i.e.: yourcontainerregistry.azurecr.io
  • LOGIN_SERVER is the ACR FQDN. i.e.: yourcontainerregistry.azurecr.io

NOTE: For detailed explanations of the commands, check the thorough explanation from this Microsoft Docs4.

Provisioning the Container App

To provision a new instance of container apps, run the following commands:

// The AZ CLI needs upgrading, an extension added and providers registered
az upgrade
az extension add --name containerapp --upgrade -y
az provider register --namespace Microsoft.Web 
az provider register --namespace Microsoft.App 
az provider register --namespace Microsoft.OperationalInsights

// Provision resource group
az group create --name AzureFunctionsContainers-rg --location australiaeast

// Provision container app environment
az containerapp env create --name MyContainerappEnvironment --resource-group AzureFunctionsContainers-rg --location australiaeast

// Provision storage account
az storage account create --name <STORAGE_NAME> --location australiaeast --resource-group AzureFunctionsContainers-rg --sku Standard_LRS

WHERE:

  • STORAGE_NAME is a unique storage name in Azure, that must contain 3 to 24 characters numbers and lowercase letters only.

NOTE: For detailed explanations of the commands, check the thorough explanation from this Microsoft Docs5.

Create the Azure Function in Container Apps

With the Container Apps environment running, run the following command to create the Azure Function in it:

az functionapp create --name <APP_NAME> --storage-account <STORAGE_NAME> --environment MyContainerappEnvironment --resource-group AzureFunctionsContainers-rg --functions-version 4 --runtime dotnet-isolated --image <LOGIN_SERVER>/azurefunctionsimage:v1.0.0  --registry-username <ADMIN_USER> --registry-password <ADMIN_PASSWORD>

WHERE:

  • APP_NAME is the function name.
  • STORAGE_NAME is the storage created in the previous step.
  • LOGIN_SERVER is the ACR FQDN. i.e.: yourcontainerregistry.azurecr.io
  • ADMIN_USER is the ACR registry admin.
  • ADMIN_PASSWORD is the ACR registry admin password.

Configure a user-assigned identity for the Dapr-enabled function

This step enables us to grant permissions to a user-assigned identity for accessing a blob storage account.

One of the advantages of Dapr's technology abstraction is that we can effortlessly assign a supported Azure service and expect it to work seamlessly.

Given our usage of state management, opting for Blob Storage offers a simpler and more cost-effective solution. The best part is that we don't need to make any changes. Everything will work smoothly without requiring any modifications!

To create the user-assigned identity, run the following PowerShell commands:

// Set variables
$ResourceGroupName = "AzureFunctionsContainers-rg"
$Location = "australiaeast"
$StorageAcctName = "<STORAGE_NAME>"

// Load module and create the user-assigned identity
if (Get-Module -ListAvailable -Name AZ.ManagedServiceIdentity) {
    Write-Output "AZ.ManagedServiceIdentity module is already installed."
} else {
    Find-Module AZ.ManagedServiceIdentity | Install-Module -Force
    Write-Output "AZ.ManagedServiceIdentity module is not installed, installing now."
}
New-AzUserAssignedIdentity -ResourceGroupName $ResourceGroupName -Name 'functionAppIdentity' -Location $Location

// Get ids
$PrincipalId = (Get-AzUserAssignedIdentity -ResourceGroupName $ResourceGroupName -Name 'functionAppIdentity').PrincipalId
$IdentityId = (Get-AzUserAssignedIdentity -ResourceGroupName $ResourceGroupName -Name 'functionAppIdentity').Id
$ClientId = (Get-AzUserAssignedIdentity -ResourceGroupName $ResourceGroupName -Name 'functionAppIdentity').ClientId
$SubscriptionId=$(Get-AzContext).Subscription.id

// Load module and create assignment
if (Get-Module -ListAvailable -Name Az.Resources) {
    Write-Output "Az.Resources module is already installed."
} else {
    Find-Module Az.Resources | Install-Module -Force
    Write-Output "Az.Resources module is not installed, installing now."
}
New-AzRoleAssignment -ObjectId $PrincipalId -RoleDefinitionName 'Storage Blob Data Contributor' -Scope "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.Storage/storageAccounts/$StorageAcctName"

WHERE:

  • STORAGE_NAME is the storage created in the previous steps.

NOTE: For detailed explanations of the commands, check the thorough explanation from this Microsoft Docs6.

Now you need to associate this user-assigned identity to the Container App Managed Identity:

Managed Identity

NOTE: My container app was named containerappfncapp. Feel free to pick any name.

Configuring the state store component

This particular step is well-documented on Microsoft Docs7, however, I encountered an issue with it that remains unresolved, as indicated by an existing Github issue. You are welcome to give it a try, and kindly share your feedback by leaving a comment on this post if the approach proves successful.

As a workaround, I used the user interface (UI) and manually added the Dapr component.

Go to the Container Apps Environment, and add a new Dapr Generic Component.

Dapr Generic Component

The metadata values will come from the intended statestore.yaml file (from Microsoft Docs):

# statestore.yaml for Azure Blob storage component
componentType: state.azure.blobstorage
version: v1
metadata:
  - name: accountName
    value: "<STORAGE_NAME>"
  - name: containerName
    value: mycontainer
  - name: azureClientId
    value: "<MANAGED_IDENTITY_CLIENT_ID>"
scopes:
  - <APP_NAME>

WHERE:

  • STORAGE_NAME is the storage created in the previous steps.
  • MANAGED_IDENTITY_CLIENT_ID is the ClientId obtained in the previous step.
  • APP_NAME is the function name.

On UI it should look like this:

Dapr Generic Component 1 Dapr Generic Component 2

Time to test it

Here's the thrilling part about these configuration steps: the exhilarating moment when everything comes together and you witness it running flawlessly!

Let's query some APIs, with the following CURL requests:

curl --location --request POST 'https://youfunctionapp.region.azurecontainerapps.io/api/SaveState'

You get State saved with Dapr on Azure Functions! as a response.

curl --location 'https://youfunctionapp.region.azurecontainerapps.io/api/GetState'

You get State retrieved with Dapr on Azure Functions! as a response.

curl --location --request DELETE 'https://youfunctionapp.region.azurecontainerapps.io/api/DeleteState'

You get State deleted with Dapr on Azure Functions! as a response.

As you revel in the satisfaction of witnessing the seamless functionality achieved thus far, it's time to engage your sharp intellect and raise a critical question:

What about the storage?

Run the SaveState query and look at the blob storage:

Storage Account Populated

Click in a blob to see the details:

Storage Account Blob

Run the DeleteState query and look at the blob storage:

Storage Account Empty

That seems to be working!

The examples are available on PlayGoKids repository.


  1. Dapr CLI: Installation
  2. Dapr Self-hosted: Installation
  3. Docker Compose: Self-hosted
  4. Publish image: Microsoft Docs
  5. Deploy Azure Resources: Microsoft Docs
  6. User-assigned identity: Microsoft Docs
  7. State store: Microsoft Docs