Dapr on Container Apps is a managed sidecar: your container talks HTTP to localhost and the sidecar talks to Service Bus. That indirection is worth having in a model pipeline for one specific reason, and it introduces one specific hazard.
What the sidecar gives you
Enabling Dapr on a container app attaches a sidecar that exposes the Dapr APIs. Microsoft documents the sidecar as running on HTTP port 3500 and gRPC port 50001. Your application code publishes by making an HTTP request to that port; it never references a Service Bus SDK, a namespace or a connection string.
The reason that is worth the extra moving part in a model pipeline is that the broker becomes a deployment-time decision rather than a code-time one. The same publish call runs against Service Bus queues, Service Bus topics or Event Hubs depending on which component is installed in the environment. When the volume changes and the broker has to change with it, no service is rebuilt.
Dapr versions in Container Apps are documented as a semantic version plus a Microsoft suffix — for example 1.13.6-msft.1 — where the prefix denotes compatibility with the corresponding open-source runtime APIs. Microsoft, Microservice APIs powered by Dapr.
The pub/sub component
Components are installed on the environment, not on an app, and shared by the apps named in their scopes array. Microsoft classifies supported components into Tier 1 and Tier 2, where Tier 1 receives immediate investigation in critical scenarios. For pub/sub, the Tier 1 component types are pubsub.azure.servicebus.queues, pubsub.azure.servicebus.topics and pubsub.azure.eventhubs. Kafka and Redis Streams are Tier 2.
Define the component in YAML and register it against the environment:
# pubsub.yaml
componentType: pubsub.azure.servicebus.topics
version: v1
metadata:
- name: namespaceName
value: "sb-inference.servicebus.windows.net"
scopes:
- ingest
- inference
az containerapp env dapr-component set \
--name env-inference \
--resource-group rg-model \
--dapr-component-name jobs \
--yaml pubsub.yaml
--dapr-component-name jobs is the name your code will use in the publish URL. The scopes entries are Dapr app IDs, not container app names — they must match the --dapr-app-id values below, and an app not listed here simply never loads the component.
Enabling Dapr on the two apps
az containerapp update \
--name ingest --resource-group rg-model \
--enable-dapr --dapr-app-id ingest --dapr-app-port 8000
az containerapp update \
--name inference --resource-group rg-model \
--enable-dapr --dapr-app-id inference --dapr-app-port 8000
--dapr-app-port is the port the sidecar uses to call into your app — for the subscriber, that is where the delivery POST arrives. Get it wrong and publishes succeed while nothing is ever delivered, which reads like a broker problem and is not one. Dapr settings apply to all revisions of an app when running in multiple revisions mode.
Publishing and subscribing
The publisher POSTs to the sidecar. The path is /v1.0/publish/<pubsubname>/<topic>, and DAPR_HTTP_PORT is injected into your container:
const port = process.env.DAPR_HTTP_PORT || 3500;
await fetch(
`http://localhost:${port}/v1.0/publish/jobs/inference-requested`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jobId, prompt, tenantId }),
}
);
The subscriber declares what it wants by answering a GET /dapr/subscribe call that the sidecar makes at startup:
app.get("/dapr/subscribe", (req, res) => {
res.json([
{
pubsubname: "jobs",
topic: "inference-requested",
route: "/on-inference-requested",
},
]);
});
app.post("/on-inference-requested", async (req, res) => {
const { jobId, prompt } = req.body.data; // Dapr wraps in a CloudEvent
await runInference(jobId, prompt);
res.sendStatus(200);
});
Note req.body.data. Dapr wraps the payload in a CloudEvent envelope by default, so the object you published is nested under data rather than being the body. Reading the body directly gets you envelope fields and a confusing undefined.
Redelivery, and why it costs money
The subscriber’s HTTP status is the acknowledgement. Return 200 and the message is done; return anything else, or fail to return at all, and it is redelivered. That is ordinary at-least-once messaging and it is fine when handling a message is cheap.
Handling a message here is a model call. If runInference takes longer than the broker’s lock duration, or the replica is scaled in mid-call, the message comes back and you pay for the inference twice — and if the first call had already written its result, you may also write it twice. The mitigations are the usual ones and they are worth building on day one rather than after the first duplicated invoice:
- Make the handler idempotent on
jobId. Check for an existing result before calling the model, and write the result under a key derived from the job rather than appending. - Keep the handler’s worst case comfortably inside the lock duration — which means bounding
max_tokensand setting an explicit client timeout, not hoping. - Acknowledge before doing slow work only if you have somewhere durable to record that the work is owed. Otherwise a scale-in silently drops jobs instead of duplicating them, which is the worse failure.
One thing the platform does give you here for free: the Dapr health API is documented as automatically configured when Dapr is enabled on a container app, so the runtime already knows whether the sidecar is ready, and the metadata API will tell you at runtime which components actually loaded — the fastest way to confirm a scopes mistake. What none of that knows is whether your handler is ready, which is why the readiness probe on the deployment page is still yours to write.
Dapr and scale to zero
The awkward interaction in this design is between a sidecar that holds the subscription and a platform that wants to remove replicas. If the subscriber scales to zero, there is no sidecar, so there is no subscription, so nothing drains the topic. Dapr does not scale the app for you.
The fix is that the subscriber needs its own KEDA scale rule against the same broker the component uses — a Service Bus rule on the underlying queue or subscription, exactly as on the KEDA page. You now have two things pointed at one broker: the Dapr component, which reads messages, and the scale rule, which counts them. They are configured separately, they authenticate separately, and nothing warns you when only one of them is right. A subscriber that never wakes up is almost always a missing scale rule rather than a broken component.
Two further documented limitations shape the topology. Dapr is not supported for Container Apps jobs, so a job-and-event-driven design has to choose one or the other. And Dapr actors do not support scaling to zero at all — actor reminders require a minimum of one replica or they will not fire, because Dapr uses virtual actors whose in-memory representation is not tied to their identity or lifetime.
Finally, weigh the component tier before you pick a broker. Microsoft splits supported components into Tier 1, which receive immediate investigation in critical security or regression scenarios, and Tier 2, which are investigated at lower priority because they are not in a stable state or belong to a third party. Kafka and Redis Streams pub/sub are Tier 2; the Service Bus and Event Hubs components are Tier 1. Choosing Kafka here is choosing a support posture as much as a broker. Anything requiring the Dapr configuration spec, or an alpha Dapr API, is outside what Container Apps supports at all.
Component tiers, supported component types and the Dapr version scheme are Microsoft’s documented state at the time of writing and move with each Dapr release into Container Apps.
Top comments (0)