Moving from AWS to Azure without stopping customers
How to plan an AWS to Azure move for functions and queues: a real inventory, a strangler seam, dual-running, cutover checklists and rollback.
5 min readaws · azure · migration · cloud · serverless
Cloud migrations rarely go wrong on the big pieces. They go wrong on small assumptions nobody wrote down: a timeout that only works because Lambda allows long executions, a consumer that quietly relies on SQS redelivering a message after its visibility timeout, a permission that exists only because some role was granted it once.
At Sendler.ai the platform ran on AWS (EC2, RDS, Aurora, S3, Lambda and SQS), and I planned and led its migration to Azure, onto Azure Functions and Service Bus, while keeping customers running. This post covers the compute and messaging side, which is where Functions and Service Bus come in. Moving databases has its own playbook (replication, verification, a short write freeze) and deserves a separate post.
Start with an inventory, not a target diagram
Before drawing the Azure architecture, list what exists, including the implicit parts:
- every function: its trigger, timeout, memory and concurrency settings
- every queue: its producers and consumers, retry and dead-letter behaviour, and whether ordering matters
- every place the code calls a cloud SDK directly
- identities and permissions, environment variables, secrets and scheduled triggers
- the operational knowledge: dashboards, alerts and the runbooks people actually use
Then map each item to its target and write down the semantic differences, because an "equivalent service" is never fully equivalent.
| AWS | Azure | Differences to check |
|---|---|---|
| Lambda | Azure Functions | Maximum execution time depends on the hosting plan; scaling and cold starts behave differently; trigger bindings replace event source mappings |
| SQS visibility timeout | Service Bus peek-lock | Lock duration is capped at five minutes and has to be renewed for longer work |
| SQS redrive policy | Max delivery count | Every Service Bus queue has a built-in dead-letter subqueue |
| SQS FIFO message groups | Service Bus sessions | Ordering is opt-in per queue and changes how consumers receive messages |
| IAM roles | Managed identities and Azure RBAC | Permissions are assigned to identities at resource scopes |
The right-hand column is the real plan. Each row is a place where code that worked on AWS can behave differently on Azure without throwing a single error.
Put a seam in before you move anything
The strangler approach starts with a seam: an interface between business code and the cloud provider. Business code publishes and consumes through it and never imports a provider SDK. The first release with the seam changes nothing except where the SDK calls live. The next one adds the Azure implementation. After that, moving a queue is a configuration change.
import json
from typing import Protocol
from azure.servicebus import ServiceBusClient, ServiceBusMessage
class MessageBus(Protocol):
def publish(self, queue: str, body: dict, *, message_id: str) -> None: ...
class SqsBus:
def __init__(self, sqs, queue_urls: dict[str, str]):
self._sqs, self._urls = sqs, queue_urls
def publish(self, queue, body, *, message_id):
payload = {**body, "message_id": message_id}
self._sqs.send_message(QueueUrl=self._urls[queue], MessageBody=json.dumps(payload))
class ServiceBusBus:
def __init__(self, client: ServiceBusClient):
self._client = client
def publish(self, queue, body, *, message_id):
# Simplified: a real implementation keeps senders open and reuses them.
with self._client.get_queue_sender(queue_name=queue) as sender:
sender.send_messages(ServiceBusMessage(json.dumps(body), message_id=message_id))
class RoutedBus:
"""Picks a backend per queue from a flag, so cutover and rollback are config changes."""
def __init__(self, old: MessageBus, new: MessageBus, flags):
self._old, self._new, self._flags = old, new, flags
def publish(self, queue, body, *, message_id):
target = self._new if self._flags.enabled(f"bus.azure.{queue}") else self._old
target.publish(queue, body, message_id=message_id)A stable message_id pays off twice. Service Bus can use it for duplicate detection, and consumers on both sides can use it as an idempotency key, which you'll need during the overlap.
Dual-run, then drain
Flipping a flag and switching off the old side loses whatever was in flight. The safer sequence, one queue at a time:
- Deploy the Azure consumers and let them sit idle on the new queue.
- Flip the producers for that queue to Service Bus.
- Keep the SQS consumers running until the old queue is empty and nothing is in flight.
- Watch error rates, dead-letter counts and processing latency on both sides.
- Only then stop the old consumers.
For a while both systems process the same kind of work, so every consumer must be idempotent. The overlap is exactly where duplicates are most likely, because a message can be retried on one side after its producer has already switched to the other.
Order matters too. Start with internal, low-risk queues where a delay isn't visible to customers, and move customer-facing work last, once the team has run the sequence a few times. For functions where side effects can be switched off, a short shadow period is worth it: run the new function on the same input and compare outputs before it does anything for real.
A cutover checklist for every step
Each flag flip should go through the same short checklist. Mine looks like this:
- Monitoring and alerts exist on the new side before any traffic reaches it.
- Lock duration, max delivery count and message time-to-live are set on purpose, not left at defaults.
- The dead-letter subqueue is monitored, and someone is named to look at it.
- Function timeouts and scaling limits match what the work actually needs.
- Identities have the permissions they need and nothing more.
- The flip happens outside customers' peak hours, with an owner watching the dashboards.
- The rollback step is written down and, for the first few flips, rehearsed.
Rollback is a flag, until it isn't
While both sides run, rollback is cheap: flip the flag back, and whatever is already on Service Bus drains through the Azure consumers. That's the whole point of the seam.
Rollback stops being cheap at two moments. The first is when you decommission the old resources. The second is when state starts living only on the new side, such as a schedule or configuration that exists only in Azure. Name those points of no return in the plan, keep the old path warm for a fixed period after each cutover, and don't decommission anything in the same week you moved it.
The pattern I'd reuse for any cloud move: inventory semantics, not just services; put a seam between business code and the provider before migrating anything; move one queue at a time with both sides running and idempotent consumers; and write down exactly where rollback stops being a flag. Customers keep running because nothing ever has to switch all at once.