DEV Community

Cover image for Azure Functions to Container Apps, One Endpoint at a Time
Martin Oehlert
Martin Oehlert

Posted on AI-assisted

Azure Functions to Container Apps, One Endpoint at a Time

You have /api/orders/* running on a Dapr-enabled Container App in staging and /api/reports/* nowhere near ready, and Azure Front Door will move production traffic for you either way. The two ways are different objects and you cannot get one from the other: a route maps a set of path patterns to exactly one origin group, while a weight splits traffic between origins inside a single origin group. Take the route and the cutover happens one path at a time, reverting by deleting a pattern. Take the weight and both runtimes have to serve every path on that route, and the ratio you typed does nothing at all until you change a load balancing setting almost nobody mentions. The four phases below are the order those two levers go in, and what each one costs you when it goes wrong.

The four phases and what moves in each

Phase 1: deploy alongside and route nothing

The running scenario for the rest of this article: an order API on api.contoso.com, fronted by a Front Door Standard profile, with a single route whose patterns are ['/*'] pointing at a Function App origin. Everything is on that one route today. /api/orders/* moves to the Container App first, /api/reports/* moves last.

Phase 1 adds two things and changes nothing else. The Container App gets deployed and registered in the Front Door profile as an origin in its own origin group. No route points at that group yet, so it serves zero production requests, and the only claim phase 1 makes is that Front Door can reach it and gets a 200 back from a probe.

The /* route stays exactly where it is, for the whole migration. It is the catch-all: the fallback for every endpoint you have not moved yet, and the thing that catches a path back when you revert one. Front Door has no implicit default, and a request matching no route errors out with one of two status codes depending on which doc you read, so do not build anything that inspects the code.

Everything here is Front Door Standard/Premium, resource type Microsoft.Cdn/profiles. Front Door classic (Microsoft.Network/frontDoors) retires on 2027-03-31: no new profiles, no new domain onboarding, no new managed certificates. If your existing profile is classic, the migration in front of you is a different one from this article's.

The ordering decision worth making now is which path goes last, and /api/reports/* is last here for a reason that is not "reports matter less". Front Door's origin response timeout is 16 to 240 seconds, and "this timeout value is applied to all endpoints in the Azure Front Door profile". A long-running report path cannot be given a longer timeout than the order path next to it. Whichever endpoint has the widest latency spread sets the number for the whole profile, so you move it when you have the most information.

The probe is the first thing that breaks

Front Door's health probe has a rule that reads as a detail and behaves as an outage: only 200 OK counts as healthy. Not 204, not 301. Put a Function App whose /healthz returns 204, which plenty do on purpose, in an origin group next to a container that returns 200, and you have manufactured an outage in the component you added to prevent one.

The second rule contradicts the first place you would look: the probe path is case sensitive, while route path patterns are case insensitive, to the point that Front Door rejects /FOO and /foo as duplicates in the same field.

The Container Apps side is quieter and worse. With ingress enabled and no probes defined, Container Apps adds defaults that are TCP checks against the ingress target port, and a TCP probe passes as long as something is bound to it. A process that is listening and broken, whose database connection died on startup or whose configuration failed to bind, passes every default probe it gets: false green, at the exact moment you are looking for evidence that the new runtime works.

On top of startup, readiness and liveness, a Dapr-enabled app adds a fourth opinion: the sidecar's own app health probe (dapr.appHealth, with its own path, interval, timeout and threshold), which decides whether the sidecar considers your app up. That is a separate question from whether the platform considers the replica up, and from whether Front Door considers the origin healthy.

Collapse it onto one answer: one HTTP health endpoint returning exactly 200, the same path with the same casing on both origins, and explicit probes replacing the TCP defaults so the container's readiness reflects the check Front Door is making:

probes:
  - type: Readiness
    httpGet:
      path: /healthz      # same string, same casing, as the Front Door probePath
      port: 8080
Enter fullscreen mode Exit fullscreen mode

Then have the Function App serve /healthz at 200 too, before you point a single probe at it, and check which verb you are probing with while you are there. New profiles default to HEAD, which is the right choice for load, and a MapGet answers it with 405 while an [HttpTrigger(..., "get")] answers it with 404. Either add the verb on both origins or set probeRequestType: 'GET', because the rule at the top of this aside does not care why the response was not a 200.

Phase 2: send the new endpoints and nothing else

Phase 2 is the first config that carries traffic, and it is two resources: an origin group for the orders path, and a route pointing at it. Everything else in the profile is untouched.

resource ordersOriginGroup 'Microsoft.Cdn/profiles/originGroups@2021-06-01' = {
  name: 'orders-origin-group'
  parent: frontDoorProfile
  properties: {
    loadBalancingSettings: {
      sampleSize: 4
      successfulSamplesRequired: 3
      // Load-bearing, and it does nothing in phase 2. Leave it at the default 0 and the
      // weights you set in phase 3 are ignored entirely. See "Your 90/10 split is a
      // 100/0 split" below.
      additionalLatencyInMilliseconds: 500
    }
    healthProbeSettings: {
      probePath: '/healthz'          // case sensitive, unlike patternsToMatch
      probeRequestType: 'HEAD'
      probeProtocol: 'Https'
      probeIntervalInSeconds: 30     // set it; the documented default varies by source
    }
  }
}

resource ordersRoute 'Microsoft.Cdn/profiles/afdEndpoints/routes@2021-06-01' = {
  name: 'orders-route'
  parent: frontDoorEndpoint
  dependsOn: [ ordersOrigin ]   // the origin group must not be empty when the route is created
  properties: {
    originGroup: { id: ordersOriginGroup.id }
    supportedProtocols: [ 'Https' ]
    // Both patterns, deliberately. The wildcard rules do not settle whether
    // '/api/orders/*' also matches a bare '/api/orders', and POST /api/orders is the
    // create-order call. Listing both is correct either way and costs nothing.
    patternsToMatch: [ '/api/orders', '/api/orders/*' ]
    forwardingProtocol: 'HttpsOnly'
    linkToDefaultDomain: 'Enabled'
    httpsRedirect: 'Enabled'
  }
}
Enter fullscreen mode Exit fullscreen mode

The /* route from phase 1 is still deployed and still points at the Function App, and nothing about this new route mentions it. That omission is why the strangler fig shape works in Front Door: there is no priority field on a route and declaration order is irrelevant. Front Door "always matches to the most-specific request by evaluating the left-hand side properties: protocol, domain, and path, in that order", with the frontend host matched exactly and the path matched against exact patterns first, wildcard patterns second. /api/orders/12345 hits orders-route and /api/reports/monthly falls through to the catch-all, and you did not order anything to make that happen.

The wildcard syntax has three rules and they are all about the end of the string: * is valid only as the last character, with nothing after it, preceded by /. A pattern with no wildcard is an exact match, and so is a pattern ending in /, which is the one that catches people:

How Front Door matches a request against route patterns, most specific first

The CLI equivalent, if your Front Door is not in Bicep yet:

az afd route create \
  --resource-group rg-orders --profile-name afd-orders \
  --endpoint-name orders-endpoint --route-name orders-route \
  --origin-group orders-origin-group \
  --patterns-to-match "[/api/orders,/api/orders/*]" \
  --supported-protocols "[Https]" \
  --forwarding-protocol HttpsOnly \
  --https-redirect Enabled --link-to-default-domain Enabled
Enter fullscreen mode Exit fullscreen mode

There is no --cache-configuration flag on that command, and its absence is the setting: omitting it leaves caching off for the route. A migrating order API wants it off badly enough that phase 3 spends an aside on what happens if you forget.

Doing this one path at a time has a ceiling, and it is far away: a Standard profile allows 100 routes and 100 origin groups (Premium doubles both), 100 path patterns per route, and a composite limit of 5000 routes per profile counted as domains multiplied by paths. An API with thirty endpoints is not close to any of those.

A route is a switch, not a dial

Read the route above as a percentage control and you will misread the whole migration. A route maps its patterns to exactly one origin group and 100% of matching requests go there. There is no weight, ratio or percentage anywhere in a route's properties, and no partial state between "the Function App serves /api/orders" and "the Container App serves it".

The percentage lever lives one level down: origin weights, integers from 1 to 1000, on the origins inside a single origin group. That is a different object with different behaviour, and the two do not compose the way most migration write-ups imply. You cannot put 10% of /api/orders/* on the Container App by editing a route, because what the route selects is the group, not the origin. Which leaves a design fork that decides the rest of your migration:

  • Two origin groups plus two routes. Per-path cutover, no percentage available. You revert by removing a pattern from patternsToMatch, and the catch-all route re-catches the path. Blast radius is one path pattern.
  • One origin group plus two origins. Percentage rollout by weight, reverted by disabling the new origin. The cost is that both origins are eligible for every path on that route, so the Function App and the Container App each have to serve the whole pattern set.

Phase 2 is the first shape, deliberately: a path you have never sent a request to is the cheapest thing in the world to move, and the revert is one line of config. Phase 3 is the second shape, where the weights come out and turn out not to work at their defaults.

The host header decides which app answers

Both origins need one property that has no obvious symptom when it is missing, because Front Door is documented to fill it in for you: "If you use Azure Resource Manager templates or another method without explicitly setting this field, Front Door sends the incoming host name as the value for the host header." Omit originHostHeader in Bicep and the Container App receives Host: api.contoso.com.

That breaks on both sides, and harder on the new one. App Service and Functions want the host header to match the backend domain unless the custom domain is bound to the app. Container Apps goes further: the environment's ingress proxy routes traffic to the correct app, revision and replica based on host headers, so a wrong header is not a mismatched certificate, it is the environment being unable to work out which of your apps you meant. Set it explicitly on each origin, and keep both lines next to each other so the asymmetry is obvious in review:

originHostHeader: 'orders-api.orangeplant-77e5875b.westeurope.azurecontainerapps.io'  // Container App
originHostHeader: 'orders-func.azurewebsites.net'                                     // Function App
Enter fullscreen mode Exit fullscreen mode

The same property decides which app answers a probe, so an unset host header can turn phase 1's green probe into a phase 2 outage without either config changing in between.

Both runtimes on one Service Bus topic

Phase 2 moved an HTTP path. The order topic is the first Azure resource both runtimes hold at once, and it is where a migration stops being a routing exercise. Three files decide whether that goes well, and the same string appears in all three.

The Dapr component on the Container App:

# components/orders-pubsub.yaml
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: orders-pubsub
spec:
  type: pubsub.azure.servicebus.topics
  version: v1
  metadata:
    - name: namespaceName
      value: "sb-orders.servicebus.windows.net"
    - name: azureClientId
      value: "<user-assigned-identity-client-id>"
    # The line that decides whether this is a cutover or a duplicate.
    # consumerID is the Service Bus SUBSCRIPTION NAME, not a label.
    - name: consumerID
      value: "orders-fn"
    # The Function App's bicep already created the topic and the subscription.
    - name: disableEntityManagement
      value: "true"
Enter fullscreen mode Exit fullscreen mode

The Function App, unchanged, still deployed, still consuming:

[Function(nameof(ProcessOrder))]
public Task ProcessOrder(
    [ServiceBusTrigger("orders", "orders-fn", Connection = "ServiceBus")] Order order)
Enter fullscreen mode Exit fullscreen mode

And the scale rule on the Container App, which is the file people forget is part of the same decision:

scale: {
  minReplicas: 0
  maxReplicas: 10
  rules: [ { name: 'orders-topic', custom: {
    type: 'azure-servicebus'
    identity: 'system'
    metadata: {
      topicName: 'orders'
      subscriptionName: 'orders-fn'   // the same literal as consumerID above
      messageCount: '30'
    }
  } } ]
}
Enter fullscreen mode Exit fullscreen mode

orders-fn three times, in three different formats, validated by nothing.

That shared string buys you the second row of a two-row decision, and that decision is the whole coexistence story. A subscription on a Service Bus topic "resembles a virtual queue that receives copies of the messages that are sent to the topic", and every subscription gets its own copy of every published message. The number of subscriptions is the number of times each order gets processed.

Two Service Bus topic topologies: separate subscriptions process every order twice, one shared subscription makes the runtimes competing consumers

The second row is a supported configuration rather than a hack. Subscriptions "support the same patterns described earlier in this section regarding queues: competing consumer, temporal decoupling, load leveling, and load balancing." Dapr already does it to itself: replicas sharing an app-id get each message delivered to "only one instance of that application", so adding the Function widens an existing race rather than starting a new one. Which runtime wins any given race is not something you get to know, so do not write a handler whose correctness depends on which side processed order 41.

One default changes load rather than behaviour, so nothing will page you about it. Dapr's maxConcurrentHandlers defaults to 0, meaning unlimited; the Functions host's maxConcurrentCalls defaults to 16. Point both runtimes at the same downstream database and the new side applies materially more concurrent pressure than the side it replaces, on a number nobody typed.

Sessions are the one mismatch here that fails loudly, at component init. Session properties belong to the subscription metadata rather than the component metadata (requireSessions, default false; maxConcurrentSessions, default 8), and the Functions equivalent is IsSessionsEnabled. Point a default Dapr subscriber at a session-enabled subscription the Function has been draining for two years and the component source returns subscription %s already exists but session requirement doesn't match, with nothing in the portal mentioning sessions. If you enable sessions on the Dapr side, check the concurrency number too: 8 against the Functions host's 2000 is a drop of two and a half orders of magnitude.

consumerID is the subscription name, and it defaults to your app ID

The Dapr reference page describes consumerID as a grouping label. Verbatim: "Consumer ID (consumer tag) organizes one or more consumers into a group. Consumers with the same consumer ID work as one virtual consumer ... If the consumerID is not provided, the Dapr runtime set it to the Dapr application ID (appID) value."

Read that as a label and you will leave it unset. The component source says what the page does not, which is that the value is a name of a real Azure entity:

// pubsub/azure/servicebus/topics/servicebus.go
r, rErr := a.client.GetClient().NewReceiverForSubscription(req.Topic, a.metadata.ConsumerID, nil)
...
err := a.client.EnsureSubscription(subscribeCtx, a.metadata.ConsumerID, req.Topic, opts)
Enter fullscreen mode Exit fullscreen mode

ConsumerID goes straight into NewReceiverForSubscription and EnsureSubscription as the subscription argument. The consumer ID is the Service Bus subscription name. That mapping comes from components-contrib, not from the reference page, so cite the source if you have to defend it in review.

Follow the default through the migration and you get a specific outcome. Deploy the Container App with app ID orders-api and no consumerID, and Dapr creates a Service Bus subscription literally named orders-api and consumes from it. Nothing about orders-fn changes. The Function keeps working perfectly. You are now in the first row of that table: every order processed twice, by two runtimes, with no error raised anywhere, and the only visible symptom is downstream. Duplicate confirmation emails, doubled inventory decrements, two rows where you expected one.

The second half of the pairing is the scale rule. subscriptionName in the KEDA azure-servicebus rule is the entity KEDA polls to decide how many replicas you need. consumerID is the entity Dapr drains. Both are naming the same kind of thing, so if the two strings differ, KEDA is measuring a backlog that the app it is scaling will never touch. The failure is quiet in the direction that matters: with minReplicas: 0, KEDA watches a subscription that stays empty, keeps the app at zero replicas, and the subscription that is actually filling up has no consumer at all. Nothing validates the pair, in either resource.

Entity management is the third thing the component does that a migration does not want it doing. disableEntityManagement defaults to "false", meaning create-if-missing. The source shows what "automatically" costs: the component builds a second, administrative client, and EnsureSubscription calls EnsureTopic and then GetSubscription before deciding whether to create anything. A failed GetSubscription is an error rather than a shrug:

res, err := c.adminClient.GetSubscription(ctx, topic, subscription, nil)
if err != nil {
    return false, fmt.Errorf("could not get subscription %s: %w", subscription, err)
}
Enter fullscreen mode Exit fullscreen mode

GetSubscription is a management-plane call. A Container App whose managed identity holds only Azure Service Bus Data Receiver has no path to the management plane, so on that reading subscription setup fails at init while the identity holds every right it needs to actually receive messages. The permission that looks correct on a least-privilege review is the one that stops the sidecar from starting. That chain is read off the source rather than watched, so treat the exact failure text as unconfirmed until you have the sidecar log in front of you.

One related waste of time. Four fields in the component reference (lockDurationInSec, maxDeliveryCount, defaultMessageTimeToLiveInSec, autoDeleteOnIdleInSec) each carry the qualifier "Used during subscription creation only", and the source confirms there is no update path. In a migration you are always pointing at a subscription that already exists, so all four are inert: setting maxDeliveryCount in your component YAML changes nothing about how many times a message is delivered. Set it on the Service Bus entity.

What to do instead, and it is the file at the top of this section. Set consumerID explicitly to the Function's existing subscription name. Set disableEntityManagement: "true". Pre-create the topic and the subscription in the Function App's existing bicep, which already owns them. Make the KEDA subscriptionName the same literal, and if your infrastructure is in Bicep, make it the same variable so the compiler enforces what neither service does. Then grep for that string before you deploy: it should appear in the component, the trigger attribute, and the scale rule, and nowhere else.

The envelope goes one way cleanly and one way silently

Dapr "uses the CloudEvents 1.0 specification as its message format" and wraps outgoing messages automatically. Publish an order from the Container App and this is what lands on the topic:

{
  "topic": "orders",
  "pubsubname": "orders-pubsub",
  "data": { "orderId": "order-123", "customerId": "cust-42", "total": 89.50 },
  "id": "5929aaac-a5e2-4ca1-859c-edfe73f11565",
  "specversion": "1.0",
  "datacontenttype": "application/json; charset=utf-8",
  "source": "orders-api",
  "type": "com.dapr.event.sent",
  "time": "2026-09-11T06:23:21Z",
  "traceid": "00-113ad9c4e42b27583ae98ba698d54255-e3743e35ff56f219-01",
  "traceparent": "00-113ad9c4e42b27583ae98ba698d54255-e3743e35ff56f219-01",
  "tracestate": ""
}
Enter fullscreen mode Exit fullscreen mode

Your order is in there, one level down, under data. Going the other way costs nothing: the Function publishes a bare order body with the SDK, and "the subscribing Dapr process still wraps these raw messages in a CloudEvent before delivering them to the subscribing application", so the handler receives an envelope whose data is the body the Function sent. The direction that breaks is the one you will deploy second, where the Function's existing trigger binds to Order:

[Function(nameof(ProcessOrder))]
public Task ProcessOrder(
    [ServiceBusTrigger("orders", "orders-fn", Connection = "ServiceBus")] Order order)
{
    // order.OrderId    is null
    // order.CustomerId is null
    // order.Total      is 0
    logger.LogInformation("Processing order {OrderId}", order.OrderId);
    return Task.CompletedTask;
}
Enter fullscreen mode Exit fullscreen mode

The binding deserializes the message body, and the body is now the envelope. topic and specversion are not properties of Order, and OrderId is not a property of a CloudEvent, so every field lands on its default, with no exception thrown, because the JSON is valid and the type is populated with nothing. The message completes, the delivery count never increments, nothing dead-letters, and DeadletteredMessages stays flat while every order silently evaporates.

There is a documented switch that makes the problem go away, and it costs more than it looks. Setting rawPayload: "true" on publish stops Dapr wrapping the message, but the same page states that disabling CloudEvents "disables support for tracing, event deduplication per messageId, content-type metadata, and any other features built using the CloudEvent schema", and the subscribe side is then "always base64 encoded with content type application/octet-stream". Trading tracing away is a bad deal in a migration whose whole argument is that a single trace spans both sides of it.

So teach the Function about the envelope rather than teaching Dapr to stop producing it. The shim is a record and one property access:

public sealed record CloudEvent<T>(
    [property: JsonPropertyName("data")] T Data,
    [property: JsonPropertyName("id")] string Id,
    [property: JsonPropertyName("type")] string Type,
    [property: JsonPropertyName("traceparent")] string? TraceParent);

[Function(nameof(ProcessOrder))]
public Task ProcessOrder(
    [ServiceBusTrigger("orders", "orders-fn", Connection = "ServiceBus")] CloudEvent<Order> message)
{
    var order = message.Data;
    logger.LogInformation("Processing order {OrderId} from event {EventId}", order.OrderId, message.Id);
    return Task.CompletedTask;
}
Enter fullscreen mode Exit fullscreen mode

That keeps tracing, keeps messageId deduplication, and hands the Function the traceparent it needs to join the Container App's trace. It also survives the Function App being turned off in phase 4. Reach for rawPayload only when the Function App is genuinely untouchable, and write down what you gave up.

How many requests one call actually makes

Phase 3 is about to put production traffic on the new runtime, so this is the last comfortable moment to count what one request there actually costs. The same orders-api that now shares that subscription also asks inventory-api whether an order can be filled, and the call site is one call:

using var response = await inventory.PostAsJsonAsync(
    "/stock/check", new StockCheckRequest(order.OrderId, order.Lines), cancellationToken);
Enter fullscreen mode Exit fullscreen mode

inventory is an HttpClient built by DaprClient.CreateInvokeHttpClient("inventory-api") with AddStandardResilienceHandler() on it, which is what the templates put there. That client, the /stock/check endpoint it calls and the service defaults wrapping both are in DaprAspireDemo in azure-functions-samples, the same projects Part 4 built. Count the HTTP requests inventory-api receives for that one call while it is having a bad minute:

Layer 1  AddStandardResilienceHandler   1 initial + 3 retries = 4 attempts
Layer 2  Dapr service invocation        1 initial + 3 retries = 4 attempts, per layer-1 attempt
                                                                ------------
         delivered to inventory-api                 4 x 4    =   16

Layer 0  Service Bus redelivery, MaxDeliveryCount 10
         before one message dead-letters           10 x 16   =  160
Enter fullscreen mode Exit fullscreen mode

Layer 1 is Microsoft.Extensions.Http.Resilience. AddStandardResilienceHandler() chains five strategies from outermost to innermost: a rate limiter, a 30 second total timeout, a retry strategy with max retries 3 and exponential backoff with jitter on a 2 second base delay, a circuit breaker, and a 10 second per-attempt timeout. It retries on HTTP 500 and above, 408 and 429, and by default it retries every HTTP method including POST. Three retries means four attempts leave your process.

Layer 2 is the sidecar those four attempts pass through. Dapr's built-in service-invocation retries use a 1 second backoff interval with a threshold of 3, so each attempt becomes four sidecar-to-sidecar requests, on a number nobody wrote down in your repository. The version repeated in write-ups goes one further and is wrong: attach your own Dapr retry policy and you would expect 4 x 4 x 4 = 64, but "a user defined retry policy replaces default retries. Targets rely solely on the applied policy." The number stays 16.

What makes sixteen a migration number rather than a Dapr number is the message underneath it. orders-api is woken up by a message on the orders-fn subscription the Function App still reads, and throwing out of the handler abandons that message for redelivery up to MaxDeliveryCount, which defaults to 10 and replays the whole fan-out each time. That last multiplication is arithmetic on documented defaults rather than a number anyone publishes, so read it as the shape of the problem. MaxDeliveryCount belongs to the subscription rather than the consumer. That is why the component field of the same name was inert back in the topic section, and why the count only bites during coexistence: every delivery the Container App burns on a failure is one the Function App does not get. Two runtimes, one budget, sized when only one existed.

The timing interaction is worse than the multiplication, and it is what turns a slow dependency into an outage. Take inventory-api degraded to 3 seconds per request, not down, just slow:

sidecar budget for ONE layer-1 attempt   4 x 3 s + 3 backoffs x 1 s  = 15 s
layer-1 per-attempt timeout                                          = 10 s
Enter fullscreen mode Exit fullscreen mode

The outer timeout is shorter than the inner retry budget. Polly cancels every attempt at 10 seconds, waits its backoff, and starts a fresh one, so the sidecar's sequence never runs to the end, and the 30 second total timeout leaves room for roughly three of those attempts. The caller pushes seven or eight real requests into an already-degraded dependency and returns a timeout, having succeeded zero times. The defaults in that chain are documented; the interaction between them is reasoning, not a measured run. The rule: the outermost timeout must exceed the innermost retry budget, or the inner layer never finishes and the outer layer only adds load. Either raise the attempt timeout above (maxRetries + 1) * expected_latency + total_backoff, or take a layer out.

The instinct at this point is to port the Function App's retry configuration across. Functions runtime retry policies ([FixedDelayRetry], [ExponentialBackoffRetry]) support four trigger types: Cosmos DB, Event Hubs, Kafka and Timer. Service Bus is not one of them, so a Service Bus trigger's retry behaviour has always come from the broker's delivery count, and clientRetryOptions in host.json looks like the answer and is not: those settings "only apply to interactions with the Service Bus service. They don't affect retries of function executions." Where a policy does carry across, on a Timer or Event Hubs trigger you move later, the retry count "is stored in the memory of the instance", so "the maximum retry count is a best effort". Either way a like-for-like port is a category error: "we retry ten times" meant one thing when a broker counted the deliveries, and on a chained in-process pipeline sitting on a sidecar it means 10 x 16.

The one lever you own everywhere, including on Container Apps, is layer 1:

httpClientBuilder.AddStandardResilienceHandler(options =>
{
    options.Retry.DisableForUnsafeHttpMethods();   // POST, PATCH, PUT, DELETE, CONNECT
});
Enter fullscreen mode Exit fullscreen mode

DisableFor(params HttpMethod[]) is the targeted form if you want to keep retrying one unsafe method you know is idempotent. The Dapr layer below retries a POST regardless of what you think about idempotency, and the app layer is the only place the multiplication can be stopped.

resiliency.yaml is the right answer and it does not reach your deployment

Dapr has a proper answer, and it is one resource. policies names the strategies, targets binds them to an app, a component or an actor type, and both blocks are required:

apiVersion: dapr.io/v1alpha1
kind: Resiliency
metadata:
  name: orders-resiliency
version: v1alpha1
scopes:
  - orders-api
spec:
  policies:
    timeouts:
      inventoryTimeout: 5s
    retries:
      inventoryRetry:
        policy: exponential
        maxInterval: 10s
        maxRetries: 2
        matching:
          httpStatusCodes: "429,500-599"
    circuitBreakers:
      inventoryBreaker:
        maxRequests: 1
        timeout: 30s
        trip: consecutiveFailures > 5
  targets:
    apps:
      inventory-api:
        timeout: inventoryTimeout
        retry: inventoryRetry
        circuitBreaker: inventoryBreaker
Enter fullscreen mode Exit fullscreen mode

Two caveats before you copy it. The apiVersion is dapr.io/v1alpha1 and the resource is not on Dapr's alpha/beta API maturity page, so do not plan around it as a stable surface. And "currently, resiliency policies are not supported for service invocation via gRPC", which, since sidecar-to-sidecar traffic is gRPC whatever the caller speaks, means your app must call its sidecar over HTTP rather than that the wire must be HTTP end to end.

The larger caveat is that on Azure Container Apps the block above does not apply: the Dapr Resiliency resource is not exposed there. ACA has two unrelated preview features instead, Dapr component resiliency, which covers outbound and inbound hops and has no targets.apps concept, and service discovery resiliency, whose documentation states that "you can't apply resiliency policies to requests made by using the Dapr Service Invocation API".

That closure makes the much-repeated claim "Dapr's built-in retries cannot be disabled" true in a narrower way than it is usually written. The global override is genuinely capped: overrides "are not applied to specific targets", and "you cannot override with lesser values than the provided default value, or completely remove default retries", so maxRetries: 0 under DaprBuiltInServiceRetries does nothing. A per-target policy replaces the built-in rather than lowering it, and maxRetries: 0 there does take, which a maintainer thread (dapr/dapr#9625) documents as the supported workaround. On ACA that per-target route is the one thing you cannot reach: resiliency "is not enabled for Dapr service invocation. And for components it is only enabled if you explicitly create a component resiliency policy" (microsoft/azure-container-apps#585, February 2024).

So pick one layer to own retries and neuter the others. On Container Apps the choice is made for you: layer 2 is not yours, so layer 1 is where the policy lives.

  1. Own layer 1 deliberately. AddStandardResilienceHandler is per-HttpClient, so leave it off the client that goes through the sidecar and keep it on the clients that call external HTTP APIs directly. If you keep it on the sidecar client, apply DisableForUnsafeHttpMethods() and raise AttemptTimeout above the sidecar's budget so an attempt can complete.
  2. Configure the layer ACA does give you. Component resiliency covers the sidecar-to-component and sidecar-to-app hops that pub/sub and state run over:
   az containerapp env dapr-component resiliency create \
     --name orders-pubsub-resiliency \
     --dapr-component-name orders-pubsub \
     --environment aca-env --resource-group rg-orders \
     --out-http-retries 2 --out-http-delay 500 --out-http-interval 5000
Enter fullscreen mode Exit fullscreen mode

The CLI defaults if you leave those out are 3 retries, a 1000 ms delay and a 10000 ms interval, and responseTimeoutInSeconds "includes all retries". Applying a policy requires restarting your Dapr applications, so it is a deployment step, not a hot config change.

  1. Turn the message-level multiplier down while both runtimes share a subscription. Judgement rather than documentation: MaxDeliveryCount at 10 was sized for a single consumer whose only retry mechanism was redelivery, and with a pipeline and a sidecar underneath it, a lower count dead-letters faster and costs the shared dependency less.

Write the number down for one endpoint before you cut it over. Sixteen is fine if you decided on sixteen.

Phase 3: one endpoint at a time

Everything still sitting on the /* route has live clients on it, so phase 3 is a loop rather than a deploy. One path leaves the catch-all per iteration, and every point inside the iteration has to be somewhere you would be willing to stop.

The two levers stack rather than blend: the route decides which origin group serves a path, and the weights inside that group decide which runtime serves a request. So one path moves in two motions, carved out of the catch-all onto its own route pointing at a group that holds both origins, then reweighted. /api/customers/* is the one moving here; /api/reports/* is still last.

// Step 1. One origin group for the path, holding both runtimes.
// customersOriginGroup is name: 'customers-origin-group', and its
// loadBalancingSettings and healthProbeSettings are the same block as
// orders-origin-group in phase 2, additionalLatencyInMilliseconds: 500 included.
// That line is the whole reason the weights below do anything: see the aside.

resource customersFunctionOrigin 'Microsoft.Cdn/profiles/originGroups/origins@2021-06-01' = {
  name: 'customers-functions'
  parent: customersOriginGroup
  properties: {
    hostName: 'orders-func.azurewebsites.net'
    originHostHeader: 'orders-func.azurewebsites.net'
    httpsPort: 443
    priority: 1          // both origins share a priority, deliberately
    weight: 75
    enabledState: 'Enabled'
  }
}

resource customersContainerOrigin 'Microsoft.Cdn/profiles/originGroups/origins@2021-06-01' = {
  name: 'customers-containerapp'
  parent: customersOriginGroup
  properties: {
    hostName: 'orders-api.orangeplant-77e5875b.westeurope.azurecontainerapps.io'
    originHostHeader: 'orders-api.orangeplant-77e5875b.westeurope.azurecontainerapps.io'
    httpsPort: 443
    priority: 1
    weight: 25
    enabledState: 'Disabled'   // deploy dark; step 3 is the only step that moves traffic
  }
}
Enter fullscreen mode Exit fullscreen mode

The route is the phase 2 shape with different values: patternsToMatch: [ '/api/customers', '/api/customers/*' ], pointing at customersOriginGroup. That is the whole configuration, and the order you apply it in is what makes it reversible:

  1. Deploy the group with the new origin disabled. The Function App is the only enabled member, so the group behaves exactly like the catch-all did.
  2. Deploy the route. The path is carved out of /* and 100% of it still lands on the Function App. Nothing a client can observe changes, which is why it is worth deploying on its own and leaving alone for a day.
  3. Enable the Container App origin, at the low ratio you already deployed. First contact between new-runtime code and a production request on this path.
  4. Shift weight and watch. Raise the Container App, lower the Function App, repeat.
  5. Revert by disabling the origin, not by undoing the deployment. "If any issues arise with the new origin, disable it to route all traffic back to the old origin", and "when an origin is disabled, both routing and health probes to the origin are also disabled".

That is Microsoft's own blue/green procedure with a migration in the middle of it, and Microsoft lists the use case by name under weighted routing: "Application migration to Azure: ... Adjust weights to prefer new origins ... then disable and remove less preferred origins."

Reversibility is worth costing before you need it, because the five levers are not equally cheap:

Five reversibility levers, what each one reverts by, and the blast radius of pulling it

Every row is config only: no redeploy, no pipeline, and any of them can be done by whoever is holding the pager. What none of them is, is a keystroke with an immediate effect. The first four are control-plane changes to Front Door, and a control-plane change reaches the points of presence on Front Door's schedule rather than yours. The last row is the exception, and it exists only if API Management is already in the path. Measure the delay in your own profile, and write the runbook with a window in it rather than a promise.

Which path goes next is judgement rather than documentation. Move the paths that are neither cacheable nor session-affine first, because both change behaviour when the origin behind a path changes, and neither is something you want to debug on the same afternoon as a runtime change. That is a second reason /api/reports/* goes last, on top of the profile-wide timeout argument from phase 1.

One more weighting mechanism shows up in this phase, and it is not on this axis at all. Container Apps splits traffic between revisions:

az containerapp ingress traffic set -n orders-api -g rg-orders \
  --revision-weight orders-api--knowngood=80 orders-api--newrevision=20
Enter fullscreen mode Exit fullscreen mode

Weights there must total 100, it needs multiple revision mode, and every name in that command is a revision of orders-api: no argument accepts a Function App. Front Door weights answer "old runtime or new runtime"; revision weights answer "old version or new version of the service that already moved". The trick worth stealing early is a label on a revision carrying weight 0, a stable URL that takes no production traffic.

Your 90/10 split is a 100/0 split

Set 75 and 25 on the two origins above, deploy, and watch every single request land on one of them. Nothing fails. The portal shows the weights you typed, on the origins you set them on, in the group the route points at, and the traffic ignores all of it.

Weights are the last of four filters, and the third one has already discarded an origin by the time weighting runs. Front Door picks an origin in this order:

  1. Available. Enabled origins that are passing their health probe.
  2. Priority. Only origins at the best priority value survive.
  3. Latency. Only origins within the latency sensitivity range of the fastest one survive.
  4. Weight. Round robin across whatever is left, in your ratio.

Stage 3 reads additionalLatencyInMilliseconds on the origin group, and it defaults to 0. Verbatim, from the routing methods page: "By default, the latency sensitivity property is set to 0 ms. With this setting, requests are always forwarded to the fastest available origins. Weights on the origins only take effect if two origins have the same network latency." Two origins having the same measured network latency is the condition, and a Function App and a Container App do not meet it, ever. Whichever one measures faster from a given point of presence is the only origin still standing after stage 3, and stage 4 round-robins across a set of one. Your 90/10 deploys as 100/0.

The same pipeline has a second way to do this to you, one stage earlier. If the Function App origin is priority 1 and the Container App origin is priority 2, the Container App gets nothing at all while the Function App is healthy, whatever the weights say. Both origins have to carry the same priority value, which is why the Bicep above sets priority: 1 on both and says so in a comment.

And the honest caveat for anyone running an internal API at a handful of requests per second: "For customers with very low RPS, due to the distributed nature of Azure Front Door points of presence (POPs) and machines, Azure Front Door can't guarantee that the weights you configure are strictly followed and the load balancing might appear skewed." A clean 90/10 is not something to promise a change board.

The fix is one property on the origin group, and it is the blue/green guide's value:

// before: the default. 90/10 deploys as 100/0.
loadBalancingSettings: {
  sampleSize: 4
  successfulSamplesRequired: 3
  additionalLatencyInMilliseconds: 0
}

// after: both origins stay eligible at stage 3, and 90/10 means 90/10.
loadBalancingSettings: {
  sampleSize: 4
  successfulSamplesRequired: 3
  additionalLatencyInMilliseconds: 500
}
Enter fullscreen mode Exit fullscreen mode

Confirming it took effect is two checks, not one, because the property being deployed and the split being live are different claims. Read the value back off the deployed resource:

az afd origin-group show -g rg-orders --profile-name afd-orders \
  --origin-group-name customers-origin-group \
  --query loadBalancingSettings.additionalLatencyInMilliseconds
Enter fullscreen mode Exit fullscreen mode

Then count requests at the two origins over a window and compare the ratio to the one you configured, rather than assuming it. Both apps log their own hostname, and Front Door's probes announce themselves with the Edge Health Probe user agent, so exclude those before you count or a low-traffic path will look busier on both sides than it is. Expect the ratio to be roughly right rather than exactly right, and treat "roughly 75/25" as the property working and "everything on one origin" as it not working.

Caching and affinity change what a cutover means

A path you cut over on Tuesday is still answering from the Function App on Friday, and every piece of routing config is correct.

Only GET requests are cacheable, so POST /api/orders was never at risk and GET /api/customers/{id} is, from the moment anyone enables caching on the route. The part that outlives the cutover is what Front Door does when your origin is quiet about it: if the response carries no Cache-Control, Front Door invents a TTL "between one and three days" at random. A migration-era endpoint that forgot its cache headers can serve the old runtime's response for days after the origin behind it changed, and none of the five reversibility levers touches it: they all move traffic, not what is already cached.

Caching also rewrites the request the origin sees: Content-Length, Transfer-Encoding, Accept, Accept-Charset, Accept-Language and Vary are not forwarded, so an API that content-negotiates behaves differently on the same code. And Set-Cookie is stripped from cacheable responses, which is how caching breaks session affinity without either feature being touched: Front Door's own affinity is not established at all if the origin sends a cacheable response.

Affinity has a harder constraint underneath it, a dead end rather than a trap. Container Apps sticky sessions (ingress.stickySessions.affinity: "sticky") are supported in single revision mode only, which is exactly what revision traffic splitting cannot run in, so the smoke-test lever from earlier in this phase is not available on an affine path.

So: leave caching off for /api/* for the whole migration, which is what omitting --cache-configuration already did in phase 2, and prove it rather than believe it. Curl a migrated GET and read the X-Cache header: CONFIG_NOCACHE is caching being off, TCP_HIT is a response you are no longer choosing the origin for. Move session-affine paths by path rather than by weight, because a weighted origin group is free to send the second request of a session to the other runtime, and if a path needs sticky sessions, pick between affinity and revision splitting before the cutover window starts.

Going private takes the whole environment

The two origin types stop being interchangeable at network lockdown:

Private Link and origin lockdown compared across a Function App origin and a Container App origin, where the private endpoint scopes to the whole environment

The load-bearing row is the target sub-resource. The Container Apps private endpoint attaches to managedEnvironments, so enabling it takes the entire environment private, every app in it, not the one path you migrated last week: you plan the environment, not the app, and you plan it before the first cutover. The row underneath costs you a pattern you probably already have on the Function App, since the AzureFrontDoor.Backend service tag paired with an x-azure-fdid header match has no Container Apps ingress equivalent. Origin lockdown on the new runtime is either Private Link or an X-Azure-FDID check in your own middleware.

What you do when it goes wrong

Two hours after /api/orders/* starts serving from the Container App, the 5xx rate on it triples. You have two questions and only one of them has a good answer: how fast you can put the traffic back, and what putting it back leaves behind. The second one is the expensive one.

The fast answer depends on which surface owns the routing. With only Front Door in front of the two runtimes, the levers are the ones phase 3 costed. If API Management sits between Front Door and your backends, which is the steady-state shape Part 6 builds, the fastest lever in the migration is a policy edit, because it takes effect at the gateway rather than at the edge:

<policies>
  <inbound>
    <base />
    <choose>
      <when condition="@(context.Request.Url.Path.StartsWith("/api/orders"))">
        <choose>
          <!-- {{orders-backend}} is a named value: "containerapp" or "functions".
               One edit in the portal or one az apim nv update, no redeploy. -->
          <when condition="@("{{orders-backend}}" == "containerapp")">
            <set-backend-service backend-id="orders-containerapp" />
          </when>
          <otherwise>
            <set-backend-service backend-id="orders-functions" />
          </otherwise>
        </choose>
      </when>
      <otherwise>
        <set-backend-service backend-id="orders-functions" />
      </otherwise>
    </choose>
    <!-- Type is Single or Pool. This is how you prove from logs which runtime
         served request X, rather than inferring it from response latency. -->
    <set-header name="X-Backend-Type" exists-action="override">
      <value>@(context.Backend?.Type ?? "n/a")</value>
    </set-header>
  </inbound>
  <backend><base /></backend>
  <outbound><base /></outbound>
</policies>
Enter fullscreen mode Exit fullscreen mode

context.Backend exposes Id, Type and AzureRegion, so the X-Backend-Type header turns "which runtime served this request" from a guess into a field you can group by. During a mixed week that header is the difference between an incident review and an argument.

There is one way to get that wrong that you will not enjoy at 3am. set-backend-service takes either base-url or backend-id, and the two do not mix across scopes: if a base policy sets the backend with backend-id, "it can only be overridden with a policy using the backend-id attribute, not the base-url attribute." Pick backend-id everywhere, including the rollback branch.

And the backend pool is the one place in this migration where a Function App and a Container App can be given real percentages against each other: up to 30 backends with round-robin, weighted or priority-based balancing and per-backend circuit breakers, with no latency-sensitivity setting standing between the weights you typed and the traffic you get. The docs are honest that "because of the distributed nature of the API Management architecture, backend load balancing is approximate", which is a much smaller caveat than the one phase 3 ran into.

The runbook is mostly about the writes

Write this before the cutover, one per migrated endpoint, and keep it to six lines:

  1. Route revert. The named value edit above, or az afd route update --route-name orders-route --enabled-state Disabled, with the resource group already filled in.
  2. Consumer re-enable. The app setting that disabled the Function's orders-fn trigger (phase 4 names it), and the value to put it back to.
  3. Stores the new path wrote to, by name: the Dapr state store component, its container, and anything the handler touched directly.
  4. The reconciliation query for each. Counts and checksums on both sides rather than a row-by-row diff, written out, tested, and dated.
  5. Dead-letter disposition. Drain, dead-letter or accept, decided now rather than during the incident.
  6. The delivery-count assumption. What you believe MaxDeliveryCount is on orders-fn, so the person reverting knows what budget they are inheriting.

Lines 1 and 2 take minutes to write. Lines 3 to 5 are why the document exists, because reverting a route is a control-plane change and a control-plane change has no opinion about data. Three things the new path did while it was live survive the revert intact.

State it committed. The write through the sidecar succeeded, and flipping the named value does not un-write it. Dapr does not store your object as your object: it stores an envelope, your document nested under a value property and the application ID prefixed onto the key. So the old Function path, still reading that container with the Cosmos SDK, finds documents under IDs it never wrote, in a shape it does not deserialize. Do not point both runtimes at one state store during the migration; give each its own and keep them in step through the topic.

Messages it published. An order event the Container App put on the orders topic is in the broker. The HTTP revert does not recall it, and the subscriber will process it minutes after you believe the new path is off. This is the line teams skip, and the one that produces the second incident an hour after the first closed.

Delivery attempts it burned. If the Container App consumed a message from orders-fn and failed, the count is already incremented broker-side, and "the delivery count is increased when a message is received in PeekLock mode and didn't complete the message before the message lock expired." Re-enable the Function consumer and it inherits a partially spent budget on messages it has never seen. The counter does not reset because you changed your mind.

Re-enabling is not the mirror image of disabling either. It puts a cold app back on a subscription that accumulated backlog for the duration of the failed experiment, with no way to ask whether it came back healthy, which is the next problem.

The health signal is not symmetric

You will want a dashboard with the old runtime on the left and the new one on the right. The left column does not exist.

App Service Health check is the feature you would use, and for the app most likely to be strangled it is not available: "Health check isn't an option for the Flex Consumption and Consumption plans." Where it does exist, it needs an anonymous HTTP trigger answering on the configured path with a 200, which a queue-triggered Function App does not have, and even then a failure only surfaces once ten consecutive one-minute pings cross the load-balancing threshold.

The Container App side is the opposite problem: startup, readiness and liveness probes plus the sidecar's own dapr.appHealth, the four-opinion pile-up from phase 1, with readiness at its default failure threshold of 48 on a 5-second period giving a signal roughly four minutes wide. Four health opinions and a four-minute signal on one side, against nothing and a ten-minute signal on the other.

Front Door does not close that gap, and for the shape phase 2 built it is documented not to: "If you have only a single origin, Azure Front Door always routes traffic to that origin even if its health probe reports an unhealthy status." Microsoft's guidance is to disable probes when a group has one origin, so every probe in orders-origin-group is telemetry rather than protection. Probes are not free either: volume is per point of presence, so a freshly cut-over Container App running minReplicas: 0 is kept warm, and billed, by probe traffic alone.

So build for one side. The rollback trigger is the new runtime's error rate and readiness state, grouped by the X-Backend-Type header, plus the reconciliation queries from line 4 of the runbook. Do not wait for a comparison that the old runtime cannot participate in.

There is no feature flag in API Management

The policy above says {{orders-backend}} rather than anything that looks like a feature flag, and that is not a stylistic choice. API Management has no feature-flag primitive. What it has is a named value (plain, secret, or a Key Vault reference) read as {{name}} inside a choose, and a backend pool with per-backend weights. Everything written as "check the feature flag in your APIM policy" is one of those two with a nicer noun on it.

If the flag genuinely has to live in Azure App Configuration, price the gateway version before you build it. A bare send-request per inbound request is a full HTTP round trip in the inbound pipeline before the backend has been chosen, with a timeout that defaults to 60 seconds. Caching the lookup is the only workable shape, and the cache is its own project: cache-store-value is asynchronous, so every expiry lets a burst through to App Configuration at once; the built-in cache is volatile and per-region, so a flag flip lands region by region; it does not exist at all in the Consumption tier; and cache-lookup-value "is not supported inside a policy fragment", which closes the obvious refactor.

Then the number that decides the design. In-app, Microsoft.FeatureManagement with UseFeatureFlags() refreshes App Configuration feature flags on a 30-second default interval, with no cache to design, no tier requirement and no regional skew.

What to do instead: use the named value from the first policy for the routing switch, because it is one edit that applies at the gateway, with no cache in front of it. Reach for the App Configuration lookup only when the rollback genuinely has to beat 30 seconds, and be able to say why it does. One security sentence, because migrations hand out policy-edit rights broadly: anyone holding Microsoft.ApiManagement/service/apis/policies/write can use authentication-managed-identity to authenticate as the service identity and take the token with them.

The cross-runtime trace is two settings away from existing

The part everyone worries about works. Dapr uses W3C trace context on service invocation and pub/sub: "When a request arrives without a trace ID, Dapr creates a new one. Otherwise, it passes the trace ID along the call chain", and "Dapr always propagates trace spans to an application." The traceparent in the CloudEvent envelope earlier is that contract, in a message. Dapr's docs then warn that you must carry the context from your app's inbound request onto its outbound calls yourself, with no SDK helpers for it. Do not repeat that warning unchanged to a .NET audience: System.Diagnostics.Activity plus the HttpClient diagnostics handler already injects traceparent from the ambient activity, and W3C TraceContext has been the default ID format since .NET 5.

What actually breaks, in both directions, breaks by omission. Open Application Insights during a mixed week with nothing configured and the Function App contributes no host telemetry, because the isolated worker emits OpenTelemetry only when host.json says so, while the sidecar hop between the runtimes is missing because Dapr's documented default sample rate is 0.0001, one span in ten thousand. Two islands of application-level logging, with a hole where the interesting hop was.

The obvious fix is closed on Container Apps. Sampling lives at spec.tracing.samplingRate in a Dapr Configuration resource, and the first entry in ACA's list of unsupported Dapr capabilities is "Dapr Configuration spec: Any capabilities that require use of the Dapr configuration spec." Neither doc set says whether ACA's managed sidecar keeps the 0.0001 default or sets its own rate once export is enabled, so turn export on and count spans rather than trusting a number from anywhere, including this article.

Two settings, one per runtime. On the Function App:

{
  "version": "2.0",
  "telemetryMode": "OpenTelemetry"
}
Enter fullscreen mode Exit fullscreen mode

with APPLICATIONINSIGHTS_CONNECTION_STRING set alongside it. On the Container Apps environment, the property that replaces the sampling knob you cannot reach:

properties: {
  daprAIConnectionString: appInsights.properties.ConnectionString
  openTelemetryConfiguration: {
    tracesConfiguration: {
      includeDapr: true                 // exports the sidecar's own spans
      destinations: [ 'appInsights' ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

After both, one request that enters through the Function App and continues into orders-api is one operation_Id, because the W3C to Application Insights mapping is fixed: trace-id becomes operation_Id and the span's parent-id becomes the id of the request or dependency. The Application Map draws both runtimes and the sidecar hop between them.

Set telemetryMode on the Function App, set includeDapr on the environment, and point both runtimes at the same Application Insights resource. That last one is not a detail. Two workspaces give you a trace that is technically continuous and practically invisible, because Transaction Diagnostics and the Application Map join on data they can both see. A migration whose main argument is that one trace spans both runtimes should be able to show the trace.

Phase 4: turning the Function App off

Phase 4 has no new code in it, and the only question it asks is how you know it is safe to stop. One endpoint is done when nothing routes to its Function implementation and nothing triggers it, which is what the end of each pass through phase 3 looks like. The app is done when the last endpoint is done, and that is not the same as the app being gone. Disable, do not delete.

The route goes first, because it is the only thing here that reverses without touching state. For every path you already moved, the Front Door work finished in phase 3; what is left is the /* catch-all, still pointing at the Function App origin and still catching everything nobody has looked at in six weeks. Do it in two moves. Set enabledState: 'Disabled' on the Function App origin, which stops routing and probes together and leaves the route there to re-enable. Then, only after a full business cycle passes without anyone noticing, remove the /* route. That second move changes what an unrecognised path does, from quietly served by the old runtime to erroring at the edge, which is both the point of it and the reason it goes last.

The consumer goes next, one function at a time. The message-triggered half of the app has nothing to do with Front Door and has its own switch, an app setting named after the function: "You can disable a function in place by creating an app setting in the format AzureWebJobs.<FUNCTION_NAME>.Disabled set to true." One function stops and the rest of the app keeps serving.

az functionapp config appsettings set \
  --name orders-func --resource-group rg-orders \
  --settings AzureWebJobs.ProcessOrder.Disabled=true
Enter fullscreen mode Exit fullscreen mode

Two caveats travel with that command. The setting is not honoured for Functions running on Container Apps, so if you already moved the Function App into a container this lever does not exist for you. And "changing application settings causes your function app to restart by default across all hosting plans", so disabling one function restarts every other function in the app. Schedule it as a restart, not as a config tweak. Then wait longer than feels reasonable: the grace period for draining in-flight invocations "can extend up to 10 minutes for Consumption plan apps and up to 60 minutes for Flex Consumption and Premium plan apps". Budget an hour on Premium, and check that the Service Bus extension is version 4.2.0 or later before you rely on the drain rather than after.

The app itself you stop and keep. Stop it if you want the compute line gone, and leave the code deployed, because reverting this step is AzureWebJobs.ProcessOrder.Disabled back to false: the shortest rollback anywhere in this migration, though not a free one, since it costs the same restart and puts a cold Function App back onto whatever backlog built up. Delete the app and that rollback becomes a redeploy from a repository whose pipeline you disabled two weeks ago, at the exact moment you are least interested in fixing a build. Keep it deployed and disabled for one full retention window, long enough that a bug report about an order processed on the old path can still be answered by the app that processed it. The CloudEvent<T> shim from the topic section stays in that code for the same reason: the Container App goes on publishing envelopes whether or not anything is subscribed.

One deletion needs planning, and it does not look like it belongs to the Function App at all. The topic and the orders-fn subscription were pre-created in the Function App's bicep, and the Container App now consumes from them, so deleting that resource group takes away the subscription the migrated app is reading from. Move those two resources into the template that owns the new runtime before you delete anything on the old side, and treat the move as its own change with its own rollback, because a Service Bus subscription that gets deleted and recreated does not bring its messages with it.

ActiveMessageCount zero does not mean the old consumer is gone

Disabling the consumer ends in "wait, then confirm", and "confirm" is the step people replace with a metric. The metric they reach for cannot answer the question, twice over.

The first reason is shape. The Azure Monitor ActiveMessages metric carries the dimension EntityName and nothing else, and on a topic the topic-level figure is not a backlog at all: "the active message count on the topic itself is 0, as those messages have been successfully forwarded to the subscription." Per-subscription backlog is not available from Azure Monitor metrics.

The second survives getting the number right. An active message count counts messages nobody is currently holding, and a message received in PeekLock and not yet completed is not active, so orders-fn can read zero while a Function instance is halfway through the last three orders it will ever process. Zero proves the subscription is empty right now, and says nothing about whether anything is still attached to it.

Take three signals instead, in increasing order of how much they prove:

  • Function invocation count in Application Insights. Necessary, not sufficient: zero invocations is also what a broken trigger looks like, so it tells you when to keep waiting, not when to proceed.
  • Per-subscription CountDetails, from the administration API rather than from metrics. Get-AzServiceBusSubscription ... | Select CountDetails returns ActiveMessageCount, DeadLetterMessageCount, ScheduledMessageCount and the two transfer counts; in .NET, SubscriptionRuntimeProperties. Poll it gently, because "the acquisition of the message counters is an expensive operation inside the message broker, and executing it frequently directly and adversely impacts the entity performance".
  • ActiveConnections on the namespace or entity. The receiver's AMQP link disappearing is the closest thing to proof that the old consumer let go. It will not drop to zero, because the Container App holds links of its own, so write down the count while both consumers are attached, or the drop you are waiting for has nothing to be a drop from.

Conclusion

By path. The route is the lever you reach for first, and the reason is what the revert costs: taking /api/orders/* back off the Container App means deleting one string from patternsToMatch and letting the catch-all catch it again, with a blast radius of exactly one path pattern and no arithmetic to get wrong at three in the morning. Weights are the second lever rather than the competing one. They belong inside a single origin group, and they mean nothing until additionalLatencyInMilliseconds stops handing every request to whichever origin measured fastest. Use them when one path is too large to move in a single piece, and not to avoid making the per-path decision.

None of that is the hard part. The hard part is that almost nothing in this migration fails loudly: a weight that is ignored, a second subscription that appears under your app ID because you left a field blank, a message that deserializes into an object with every field at its default and nothing thrown. Nothing raises and nothing pages, and all of it looks like working software right up until a customer counts their confirmation emails. Most of the work in the four phases is not moving traffic. It is making those failures audible while you are still watching for them.

Part 6 takes the same three components (Front Door, API Management, Container Apps) and asks what they look like when they are not a transition: the steady-state architecture, sized and secured for the shape you land on rather than the one you pass through.

Top comments (0)