🪴 GoDeep
Search
← Deep dives

Deep dive · Shipping Software · Azure & deployment

Rented Promises: What 'It's Deployed' Actually Means

The cloud is not a computer. It is a set of promises you are renting, and reading them tells you how your app will fail.

7 min read

There is a moment, familiar to anyone who ships agent-written software, when the pipeline goes green, a URL starts answering, and the work appears to be finished. It is a genuinely good moment. It is also the point at which most people stop asking questions, and it is the wrong place to stop, because “deployed” is not a description of a state. It is a description of an agreement.

The most useful mental correction I know for cloud hosting is this: you have not been given a computer. You have been sold a set of promises, each with conditions attached. Some process, somewhere, will be started with your code in it. It will be reachable at a name. It will be restarted, moved, duplicated or shut down according to rules you did not write. And crucially, the promise is specific — different Azure services make different promises, and the differences are precisely the things that will surprise you at eleven o’clock on a Tuesday night.

Read the promise and you can predict your failures. That is the whole skill.

What the promise contains

Four clauses matter more than the rest.

Availability. A service level agreement is a number with money attached, and the number is never 100 per cent. A typical Azure compute SLA sits around 99.95 per cent monthly, which sounds like nothing and is roughly twenty minutes of allowed unavailability a month. Your platform is permitted to have a bad afternoon. If your scheduled job runs once a day with no retry, you have quietly built a system that fails a few times a year by design.

The scaling model. How many copies of your code run, and who decides? This is the clause people skip, and it is the one that breaks things, because almost every assumption a single-process program makes about memory becomes false the moment there are two processes.

Statefulness. Whether anything your code writes to disk or holds in memory survives. On managed platforms the answer is generally no, and “generally” is doing a lot of work in that sentence — it may survive for hours, then vanish, which is far more confusing than never surviving at all.

Identity. What your code is allowed to talk to, and how it proves who it is. Either your app carries copied-and-pasted secrets, or it has an identity the platform vouches for. These are very different security positions.

The same app, three behaviours

Take one FastAPI application. Deploy it three ways on Azure and you get three genuinely different programs.

On App Service, you are renting a long-lived web server. Your process stays up, holds connections, and can keep things in memory for as long as the instance lives. Scale out to two instances and you have two of everything, chosen per request by a load balancer. There is a setting, session affinity, that pins a browser to one instance with a cookie — enabled by default, which papers over in-memory state for interactive users and does nothing at all for your API clients or your background work.

On Container Apps, you are renting elasticity. It will run your container, add replicas under load, and — if you let it — scale to zero when nothing is happening. Scale-to-zero is excellent for cost and brutal for anything that assumed it was always running: your in-process scheduler, your warm model cache, your “load the lookup table once at startup” optimisation. The first request after a quiet period pays for the start-up.

On Functions, you are renting individual executions. The unit is the invocation, not the server. Startup cost is per-cold-instance, execution time is bounded (on the consumption plan, minutes rather than hours by default), and anything long-running has to be restructured into steps rather than made faster. An agent asked to “move this script to Azure Functions” will often produce something that works on small inputs and times out on real ones.

None of these is the right answer in general. But notice that the code can be byte-identical while the promise underneath it changes completely — and that no build step, test suite or green tick will tell you which promise you are standing on. You have to look.

# What am I actually running? Three questions worth asking of any App Service
az webapp show -g rg-iff -n iff-api \
  --query "{state:state, https:httpsOnly, kind:kind}" -o table

# How many copies of my code exist right now?
az webapp show -g rg-iff -n iff-api --query "siteConfig.numberOfWorkers"

# Does this app have an identity, or is it carrying secrets?
az webapp identity show -g rg-iff -n iff-api

An empty response from that last command is informative. It means every credential your app uses was pasted in by someone.

State: the mistake that hides for months

Here is the failure I would bet on if I knew nothing else about a deployed application than that it had been scaled out.

# Works perfectly on one instance. Silently wrong on two.
_jobs: dict[str, str] = {}   # in-memory job status

@app.post("/jobs")
async def create_job() -> dict[str, str]:
    job_id = str(uuid4())
    _jobs[job_id] = "running"
    asyncio.create_task(run_job(job_id))   # dies if this instance restarts
    return {"job_id": job_id}

@app.get("/jobs/{job_id}")
async def get_job(job_id: str) -> dict[str, str]:
    return {"status": _jobs.get(job_id, "unknown")}   # "unknown" on the other instance

This is not a subtle bug, but it is a well-hidden one. With one instance it is correct. With two, roughly half of all status checks land on the instance that has never heard of the job, and the dashboard shows a job flickering between “running” and “unknown” — a symptom so strange that people tend to blame the front end. And because the background task lives in the process, a routine platform restart loses work with no error anywhere, because nothing failed. Something stopped existing.

The fix is not cleverness, it is moving the state somewhere that is shared and durable:

# Status lives in the database; any instance can answer, restarts survive
@app.post("/jobs")
async def create_job(db: Annotated[AsyncSession, Depends(get_db)]) -> dict[str, str]:
    job = Job(id=uuid4(), status="queued")
    db.add(job)
    await db.commit()
    await queue.send(str(job.id))   # a real queue, so a worker can pick it up again
    return {"job_id": str(job.id)}

The general rule is worth stating plainly: on a platform that can run more than one copy of your code, anything held in a module-level variable, a local file, or an in-process cache is a bug waiting for traffic. The same logic applies to rate limiters counting in a dictionary, in-process schedulers firing twice because two replicas are running, and uploaded files written to local disk and then not found by the next request.

Identity: stop carrying secrets

The second recurring mistake is authentication by copy-and-paste. An agent needs to reach Blob Storage, so it puts a connection string in app settings, and it works. Now that string is in an environment variable, probably in a .env file, plausibly in git history, readable by anyone with access to the portal, and valid until someone rotates it — which nobody will, because nobody knows what would break.

Azure’s alternative is managed identity: the platform gives your app an identity, you grant that identity a role on the resource, and tokens are fetched at runtime and expire on their own. In code it is usually fewer lines, not more:

from azure.identity.aio import DefaultAzureCredential
from azure.storage.blob.aio import BlobServiceClient

# No secret anywhere: managed identity in Azure, your az login locally
credential = DefaultAzureCredential()
client = BlobServiceClient(
    account_url="https://iffexports.blob.core.windows.net",
    credential=credential,
)

Where a genuine secret is unavoidable — a third-party API key, say — it belongs in Key Vault, referenced from app settings rather than pasted into them, so that rotation happens in one place. Plain values in app settings are the “works now, unauditable forever” option.

An hour in Application Insights beats a week of guessing

Which brings me to the clause nobody reads at all: the promise that the platform will tell you what happened. Azure collects request traces, dependency calls, exceptions and performance counters, and almost everyone who has this switched on has never opened it.

The cost of not looking is not abstract. It is optimising the wrong endpoint for a fortnight. It is an exception that has been thrown four thousand times since March and been noticed by nobody because the front end swallows errors into an empty table. It is a “slow API” that turns out to be one unindexed query.

Before changing any code for performance reasons, spend an hour with the query language. Two queries will tell you most of what you need:

// Where does the time actually go? Slowest endpoints by 95th percentile
requests
| where timestamp > ago(24h)
| summarize count(), p95 = percentile(duration, 95) by name
| order by p95 desc
// What is already broken and unreported?
exceptions
| where timestamp > ago(7d)
| summarize count() by type, outerMessage
| order by count_ desc

The second query is the one that changes people’s minds. Running it against a system you believed to be healthy is an unsettling experience, and a useful one. The p95 rather than the average matters too: averages hide the tail, and the tail is what users notice and remember.

Measuring first also protects you from a specific agent failure mode. Ask an agent to make an endpoint faster and it will make something faster — add caching, add concurrency, restructure a loop — because that is what it was asked to do. Whether that something was the bottleneck is a question only your telemetry can answer, and an optimisation aimed at the wrong place is not neutral: it is new complexity, new cache-invalidation bugs, and no improvement.

The habit worth keeping

Being able to deploy is a real skill and you have it. The next one is smaller and pays better: after the URL answers, ask four questions. How many copies of this are running? What happens to what it remembers when one of them restarts? How does it prove who it is? And where do I look when it goes wrong?

If you can answer those, you know what you have rented. If you cannot, you have a URL that works, which is not the same thing at all — and the difference will be explained to you eventually, at an hour of the platform’s choosing.