Dapr and Azure Functions: Part 3 – Containerizing with Dapr
In Part 2, we created the Dockerfile and deployed the Functions app to a container.
Now we want to put it all together with Dapr. We’re going to start with a bit of housekeeping and reorganizing our project.
Step 1: Refactor the Solution
Our first step is to refactor the solution.
If we simply add the Dapr container, our solution will look like this:
It is important to note that in this case, the Functions Job Host (FJH) is exposed directly via port 8181 on the host which is mapped to port 80 on the container where the FJH is listening; we do not have access to the daprd
container directly unless we do some networking ninjutsu to expose it via a port on the host.
For this example to make any sense, we need to create another service which the FJH will interact with so our goal is something like this:
We’re going to add another “backend” service which simply provides the date to the existing “frontend” HelloWorld
service.
In the current project, create a new folder structure like this:
services
hello
(move all current files here)date
(a new folder for the second service)
From the terminal, switch into the date directory and we’ll create another Function app.
func init --name DateFunc --worker-runtime dotnet
Add another Function:
func function new --name CurrentDate --authlevel anonymous
We’ll update this Function to just return the current date:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.WebJobs; using Microsoft.Azure.WebJobs.Extensions.Http; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; namespace date { public static class CurrentDate { [FunctionName("CurrentDate")] public static async Task<IActionResult> Run( [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req, ILogger log) { log.LogInformation("C# HTTP trigger function processed a request."); return new OkObjectResult(DateTime.Now.ToString()); } } } |
We’ll also go ahead and add the Dockerfile
(could have been done at func init
, too):
func init --docker-only
Step 2: Invoke the Service via Dapr
What we want to do now is to invoke this service not directly through its HTTP endpoint, but through Dapr service invocation.
From HelloWorld.cs
, we’re going to get the current date and add it to our output message.
First, we switch into the /services/hello
directory in the terminal and execute:
dotnet add package dapr.client
NOTE: After adding the package in VS Code, intellisense is broken until you reload the project or CTRL+SHIFT+P
and run Developer: Reload Window
Now we are going to get the current date from the service:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
using System.IO; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.WebJobs; using Microsoft.Azure.WebJobs.Extensions.Http; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Dapr.Client; using System.Net.Http; namespace dapr_func { public static class HelloWorld { [FunctionName("HelloWorld")] public static async Task<IActionResult> Run( [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req, ILogger log) { log.LogInformation("C# HTTP trigger function processed a request."); string name = req.Query["name"]; string requestBody = await new StreamReader(req.Body).ReadToEndAsync(); dynamic data = JsonConvert.DeserializeObject(requestBody); name = name ?? data?.name; // Invoke the CurrentDate service to get the current date. HttpClient httpClient = DaprClient.CreateInvokeHttpClient(); string currentDateString = await httpClient.GetStringAsync("http://dateapp/api/CurrentDate"); string responseMessage = string.IsNullOrEmpty(name) ? $"This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response ({currentDateString})." : $"Hello, {name}. This HTTP triggered function executed successfully ({currentDateString})."; return new OkObjectResult(responseMessage); } } } |
You can find more information about the service invocation in this documentation, but the gist of it is that the HttpClient
that has been constructed will translate the outbound URL from:
http://dateapp/api/CurrentDate
to:
http://127.0.0.1:3500/v1/invoke/dateapp/method/api/CurrentDate
Pay special attention to the dateapp
string.
Note that in production, it is recommended to keep the HttpClient
long-lived and inject it into the Function (for an example, check my AzFunctionsGraphQL GitHub repo or the Microsoft documentation on Functions Dependency Injection).
At this point, we can test this by running the two apps separately using dapr
without deploying everything to Docker:
- From
/services/date
rundapr run --app-id dateapp --app-port 7081 -- func start -p 7081
- From
/services/hello
rundapr run --app-id helloapp --app-port 7071 --dapr-http-port 8181 -- func start -p 7071
Take special note of the --
in the commands before the func
since we need this to tell it to use a different port; if you don’t include this, it will ignore the -p
parameter value and you will have a port conflict since dapr run
starts the functions runtime in the host context (so both instances of func.exe
will try to bind to a single port).
In the browser, we can access it like this: http://localhost:7071/api/HelloWorld
We get the default message with the current date time 🎉
This is perhaps the best way to work during development as it eases debugging and you can cycle individual services.
Now that we’ve confirmed that everything is working, let’s pull this all together in a Docker Compose deployment.
Step 3: Add Docker Compose File
The Docker Compose File (DCF) is used to bring up/down the full set of artifacts in the Docker runtime.
Once we have this set up, we can execute docker-compose up
to bring up the full set of containers.
Start by creating a file at the root called docker-compose.yml
.
(NOTE: It is extremely helpful to have the Docker Compose reference handy; this documentation is really, really lacking for the volume of knowledge that you need to understand the complexity, but it’s better than nothing!)
Your file tree should look like this:
I think that this was really the first wall of complexity for me so we’re going to spend some time here breaking down what we need to do in this file.
The structure of the file at a high level will look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
version: '3.4' services: # The HelloWorld Functions app in /services/hello helloapp: # The Dapr Sidecar for HelloWorld helloapp-dapr: # The CurrentDate Functions app in /services/date dateapp: # The Dapr sidecar for CurrentDate dateapp-dapr: # This is purely optional; a default network is created regardless networks: # The name just makes it easier to identify when you run docker network ls hello-dapr: |
It is important that you start from this baseline as it will be foundational for understanding each piece that gets added.
Step 3.1: Set Up helloapp
First we set up helloapp
. This is the Functions app that will respond to the browser invocation:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
version: '3.4' services: helloapp: image: ${REGISTRY:-helloworldfuncdapr}/helloworld.api:${PLATFORM:-linux}-${TAG:-latest} build: # Switch into the working directory or build will fail context: ./services/hello # The name of the Dockerfile dockerfile: Dockerfile ports: # helloapp exposes port 8181 on the host and connects this to port 80 on the container - "8181:80" networks: # The network that the app will belong to - hello-dapr # TODO helloapp-dapr: # TODO dateapp: # TODO dateapp-dapr: networks: hello-dapr: |
The image name will look like this once published at the end:
Let’s break down each line:
context
is the working directory to use when executing the build command. If you use only.
, the build will fail so use the full path OR modify theDockerfile
dockerfile
is the name of theDockerfile
8181:80
8181 is an arbitrary port on the host machine to bind to port 80 on the container which is where the Functions Host Runtime is listening by default. So for us to access this, we need a mapping.hello-dapr
identifies the network that this app belongs to. This is optional; if we do not specify a network at all, then a default one will be assigned but that can make it difficult to troubleshoot issues with networking if needed!
Step 3.2: Set Up helloapp-dapr
Now we connect the side car to the app:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
version: '3.4' services: helloapp: image: ${REGISTRY:-helloworldfuncdapr}/helloworld.api:${PLATFORM:-linux}-${TAG:-latest} build: context: ./services/hello dockerfile: Dockerfile ports: - "8181:80" networks: - hello-dapr helloapp-dapr: # In this case, we want the Dapr image since we are not building our own image: "daprio/daprd:1.0.0" command: [ "./daprd", # The name of the ID; this is IMPORTANT since the URLs need to match the -app-id! "-app-id", "helloapp", # The port that the app (helloapp) is listening on "-app-port", "80" ] depends_on: # Creates the dependency to the app. - helloapp # As far as I understand it, this connects the sidecar to the helloapp network network_mode: "service:helloapp" # TODO dateapp: # TODO dateapp-dapr: networks: hello-dapr: |
command
is the command that will be executed in the container once it starts uphelloapp
is the name that is assigned to the Dapr service endpoint and it must match the URLs used in your service invocations.80
identifies the port that the app (Functions Host Runtime) will be listening on (we’ll look at this in a moment)
helloapp
identifies the dependencyservice:helloapp
connects the network to the same network as the app
When everything is hooked up correctly, you should see the following message in the console output for the sidecar when we start it up:
msg="application protocol: http. waiting on port 80. This will block until the app is listening on that port."
Then when it detects the Functions Host Runtime on port 80, you’ll see:
msg="application discovered on port 80"
If you are blocked at the first message, then you will need to review the configuration to get all of the parts connected.
Step 3.3 Set Up dateapp
Now we set up the container for the date service app that will provide the current date/time:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
version: '3.4' services: helloapp: image: ${REGISTRY:-helloworldfuncdapr}/helloworld.api:${PLATFORM:-linux}-${TAG:-latest} build: context: ./services/hello dockerfile: Dockerfile ports: - "8181:80" networks: - hello-dapr helloapp-dapr: image: "daprio/daprd:1.0.0" command: [ "./daprd", "-app-id", "helloapp", "-app-port", "80" ] depends_on: - helloapp network_mode: "service:helloapp" dateapp: # Note the different name for this image image: ${REGISTRY:-helloworldfuncdapr}/date.api:${PLATFORM:-linux}-${TAG:-latest} build: # Note the chnage in context to the correct working directory context: ./services/date dockerfile: Dockerfile # Instead of ports, we are using expose here. Per the documentation: # Expose ports without publishing them to the host machine - they’ll only be accessible to linked services. Only the internal port can be specified. expose: - "80" networks: - hello-dapr # TODO dateapp-dapr: networks: hello-dapr: |
We don’t need to cover each line here, but let’s take a look at the key differences:
expose
instead ofports
. Thedateapp
is a “backend” service and isn’t exposed to the host. In this case, we only need to expose it to the internal network of the cluster. You can read more about theexpose
option in the Docker Compose documentation.80
like before is the port that the Functions Runtime Host is listening on.
Step 3.4 Set Up dateapp-dapr
And the final piece is almost identical to the previous helloapp-dapr
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
version: '3.4' services: helloapp: image: ${REGISTRY:-helloworldfuncdapr}/helloworld.api:${PLATFORM:-linux}-${TAG:-latest} build: context: ./services/hello dockerfile: Dockerfile ports: - "8181:80" networks: - hello-dapr helloapp-dapr: image: "daprio/daprd:1.0.0" command: [ "./daprd", "-app-id", "helloapp", "-app-port", "80" ] depends_on: - helloapp network_mode: "service:helloapp" dateapp: image: ${REGISTRY:-helloworldfuncdapr}/date.api:${PLATFORM:-linux}-${TAG:-latest} build: context: ./services/date dockerfile: Dockerfile expose: - "80" networks: - hello-dapr dateapp-dapr: image: "daprio/daprd:1.0.0" command: [ "./daprd", # dateapp instead of helloapp "-app-id", "dateapp", "-app-port", "80" ] depends_on: # dateapp instead of helloapp - dateapp # dateapp instead of helloapp network_mode: "service:dateapp" networks: hello-dapr: |
Step 4: Running it All
Now we’re ready to run this bad boy.
We can do this using CTRL+SHIFT+P
and Docker: Compose Up
or from the terminal at the root directory, use docker-compose up
This should start building the images and bring everything online:
We can see that there is one port exposed at 8181for dapr-func_helloapp_1
. If we load the browser and hit the URL: http://localhost:8181/api/HelloWorld
, we should see the following:
If we take a look at the diagram we started with, it all comes together now:
From our entry point on localhost:8181
, we can interact with an HttpTrigger
Function that will invoke the dateapp
service via a Dapr Sidecar! We haven’t added any components yet; at this stage, I hope you have a solid understanding of the fundamentals of how to put the pieces together!
Next Part
Next, we want to do the same with Kubernetes!
Addendum: Notes
NOTE: When modifying code, use docker compose build
to repackage the images.
3 Responses
[…] Now to add Dapr to our container! […]
[…] Part 3 – Containerizing with Dapr […]
[…] In Part 3, we deployed the services to Docker using Docker Compose. […]