Keegan Ott / dreekoSlop

Architecture · · By · 6 min read

Rebuilding an Azure event-driven system for air-gapped Kubernetes

The application already worked in Azure. My job was to make it work without Azure, without flattening its durable workflows into a pile of background jobs.

An abstract cloud and data platform illustration for a disconnected Kubernetes system
The target is not a cloud diagram on different hardware; it is the behaviours the managed platform used to supply.

I worked on this problem while engineering a self-hosted edition of an Azure-first .NET platform. The original system used event-driven Azure Functions and durable orchestrations. The target had no access to Azure and needed to run on Kubernetes with locally operated messaging, persistence and scaling.

At first it sounded like a translation exercise: managed service on the left, self-hosted equivalent on the right. That framing lasted until the first serious inventory. A product name was often covering three or four behaviours, and the replacement matched only the visible one. The work became less about choosing technology and more about finding the promises the old platform had been keeping for us.

I can’t publish the client system or its operational details, so this is the part worth discussing in public: which behaviours had to survive, where the obvious replacements were misleading, and what I would try to break before calling the port finished.

The first spreadsheet was more useful than the first prototype

A dependency inventory that says “replace Azure Functions” is too shallow. Functions may be providing several distinct behaviours:

I wrote those down as requirements. Otherwise it was too easy to point at a running container and declare the Function replaced, while retries or dead-letter handling had quietly vanished.

The target architecture

The self-hosted design split the workload into containerised .NET services deployed to Kubernetes. RabbitMQ supplied asynchronous messaging, SQL Server held application and workflow state, and KEDA connected queue activity to Kubernetes scaling.

The rough mapping ended up like this:

The architectural boundary matters more than the broker. Producers should express intent through contracts; consumers should own their processing and failure policy. That keeps a broker migration from leaking across the whole codebase.

Port the orchestration semantics carefully

Durable workflows are where superficial migrations tend to fail. An orchestration is not merely a sequence of method calls. It has persisted state, deterministic replay, activity retries, timers and rules about what may happen inside the orchestrator.

We preserved that programming model by moving durable state to SQL Server and maintaining the necessary provider integration. That allowed existing workflow intent to survive without pretending that ordinary background jobs were equivalent.

Determinism still applies

Replay-based orchestration code must remain deterministic. Current time, random values and external I/O belong behind activities or framework-provided deterministic APIs. A platform change is a good time to add replay tests around any workflow that has accumulated incidental side effects.

Idempotency belongs in the application

At-least-once delivery means duplicate execution is normal, not exceptional. Consumers need stable operation identifiers, transactional state transitions and safe handling when the same message arrives again. Broker acknowledgements alone cannot provide end-to-end exactly-once behaviour.

RabbitMQ needed decisions, not defaults

A queue per worker was a starting point, not a topology. I still had to decide who owned each queue, what could retry, and which failures needed isolating:

Wolverine provided useful .NET messaging and handler conventions, but framework convenience does not remove the need to decide delivery semantics explicitly.

Scale on work, not CPU

CPU is a poor leading indicator for asynchronous consumers. Queue depth and message age describe demand more directly. KEDA can expose those signals to Kubernetes and adjust replicas accordingly.

Scale-to-zero is valuable in resource-constrained environments, but only for workers whose startup latency and connection behaviour tolerate it. Minimum replicas, polling intervals, activation thresholds and graceful shutdown all need workload-specific values. A pod terminated while holding an unacknowledged message should return that work cleanly to the queue.

Disconnected operations change the definition of done

An application is not air-gap-capable merely because its runtime images are local. Every transitive dependency must be accounted for:

Build and deployment must succeed against an internal registry and package source with external network access removed. Testing that constraint early is much cheaper than discovering a hidden download during installation.

What I would validate before cutover

This is where I stop trusting architecture diagrams. A diagram cannot show whether a half-finished activity is safe to repeat, or whether the installer has one forgotten internet dependency. I want the ugly tests:

What I took away from it

I came away less interested in whether an architecture is called cloud-native. The portable part was much less glamorous: explicit messages, durable state, failures an operator can see, independent scaling and a release process that works without a helpful internet connection.

Azure had been doing more work for us than the architecture diagram admitted. Outside it, every retry, lock, scale rule and recovery path had an owner. I considered the port credible when duplicate messages and dead nodes became routine tests rather than scary edge cases.