← How Your App Gets to the Internet
Lesson 3 of 6

Virtual Machines, Containers, and Serverless

SoftwareIntermediate

Three Answers to One Question

You have written something and it needs to run somewhere other than your laptop. There are three common answers to that, and they are usually presented as a progression, as though containers replaced virtual machines and serverless is replacing containers. That framing is wrong and it leads people to pick badly. All three are in heavy use, all three are being actively built on, and they are answers to a question that has no single correct value.

The question they answer differently is this: how much of the machinery underneath your code do you want to be responsible for? Every layer you hand over is a layer you no longer maintain, and also a layer you can no longer touch when you need to. That is the trade, in one sentence, and everything else in this lesson is detail on it.

Three stacks side by side with the AWS icons for EC2, Fargate and Lambda above them. The virtual machine stack, from top to bottom: your application, runtime and libraries, a whole guest operating system highlighted in red as booting, patching and eating memory, hypervisor and host OS, and physical hardware. The container stack: your application, runtime and libraries baked into the image, container runtime, one host OS shared by every container on the box, and physical hardware. The serverless stack: your function, and one large dashed box labelled everything else, which still exists but which you cannot see, log into, tune or install anything on. Below, startup times of 30 to 60 seconds, 1 to 3 seconds, and 0.1 to 1 second respectively.
Every layer still exists in all three. The only thing that changes is who is responsible for it, and whether you are allowed to reach it.

The layer highlighted in the first column is where most of the difference comes from. A virtual machine gets a complete operating system of its own - a full Linux installation that boots, keeps its own processes running, needs security updates, and consumes several hundred megabytes of memory before your application has started. If you run ten virtual machines on one physical server, you are running ten complete operating systems, and paying for all of them in memory and maintenance.

A Container Is Not a Small Virtual Machine

This is the single most useful thing to get straight, because almost every diagram in circulation implies otherwise. A container does not contain an operating system. It is an ordinary process, running directly on the host's kernel, that has been given a deliberately restricted view of the machine.

Two Linux features do the work. Namespaces control what the process can see: its own process list, its own network interfaces, its own view of the filesystem. Inside the container it looks like process number 1, as though it were the first thing to start on a fresh machine, because its process namespace has been arranged to show it nothing else. Control groups, usually shortened to cgroups, control what it can use: this much memory, this much processor time, this much disk throughput. Namespaces are the walls, cgroups are the meter.

You can see straight through the illusion from the host side, and doing this once makes the concept stick permanently.

bash
$ docker run -d --name web nginx

# From inside, the container looks like a whole machine it owns.
# nginx is PID 1 - the first process, as if the box just booted.
$ docker exec web ps aux
USER      PID  COMMAND
root        1  nginx: master process nginx -g daemon off;
nginx      29  nginx: worker process

# From the host, it is simply a process among all the others.
# Same program, same memory, different number.
$ ps aux | grep nginx
root    48213  nginx: master process nginx -g daemon off;
nginx   48267  nginx: worker process

# One kernel. No second operating system anywhere in this picture.
$ uname -r && docker exec web uname -r
6.8.0-51-generic
6.8.0-51-generic          # identical - the container is using the host's kernel

That last command is the whole lesson in two lines. The container reports the host's kernel version because it is the host's kernel. Nothing was virtualised.

Three consequences follow directly. Containers start in about a second, because there is no operating system to boot - you are just starting a process. They are far lighter, so a machine that fits five virtual machines will comfortably fit fifty containers. And the isolation, while good, is meaningfully weaker than a hypervisor's: everyone shares one kernel, so a vulnerability in that kernel is a shared problem in a way it would not be across virtual machines. You also cannot run a Windows container on a Linux host, or the reverse, for the same reason.

This is why cloud providers do not simply put different customers' containers next to each other on the same kernel. Underneath a managed container service like Fargate, each customer's workload still gets hardware-level isolation - a lightweight virtual machine per task. You get the container experience, and they take the shared-kernel risk off the table on your behalf.

What Serverless Actually Is

Serverless is a bad name - there are obviously servers - but the thing it describes is real. You hand over a function rather than an application. There is no process of yours that stays running, no port being listened on, no main loop. When a request arrives, the provider finds or starts a container holding your code, calls your function with the request, takes the return value, and sends it back. Between requests, nothing of yours is running and nothing of yours is being billed.

python
import json
import os

import psycopg

# Anything at module level runs ONCE when the container starts, not once
# per request. This is where the connection pool belongs - and it is also
# where a cold start's extra time actually gets spent.
conn = psycopg.connect(os.environ["DATABASE_URL"])


def handler(event, context):
    """The provider calls this. Nothing here calls itself.

    There is no server loop because there is no server that is yours,
    and no state survives between calls except by luck.
    """
    body = json.loads(event.get("body") or "{}")
    name = body.get("name", "world")

    with conn.cursor() as cur:
        cur.execute("SELECT count(*) FROM visits")
        (total,) = cur.fetchone()

    return {
        "statusCode": 200,
        "headers": {"Content-Type": "application/json"},
        "body": json.dumps({"greeting": f"hello {name}", "visits": total}),
    }

The comment about module level is the part worth remembering. That code runs when the container is created, and the container is then kept around for a while and reused for subsequent requests. So a database connection opened there is reused across many requests, which is what makes this viable at all. It also means anything you leave in a global variable might still be there on the next request, or might not, depending on whether you got the same container - which is a genuinely nasty source of bugs for anyone who assumes either behaviour.

Cold Starts, Fairly Described

A cold start is what happens when a request arrives and there is no warm container waiting: the provider has to create one, load your code, run everything at module level, and only then call your function. For an interpreted runtime like Python or Node this typically adds somewhere between 100 and 800 milliseconds. For a JVM or .NET application, which have substantially more startup work to do, it can be several seconds.

Both sides overstate this. It is not true that every request pays it - only the first request to each new container does, and a steadily busy function may almost never go cold. It is also not nothing: a service that gets a request every few minutes will go cold constantly, and the user who triggers the wake-up gets a visibly slower page. Providers sell provisioned concurrency, which keeps containers warm for you, and it works - but you are then paying for idle capacity, which was precisely the thing serverless was supposed to stop you doing.

What Each One Costs You

The pricing models are genuinely different in kind, not just in amount. A virtual machine is rented by the hour whether or not anyone uses it. Containers on a managed service are billed for the time tasks are running, which autoscaling can reduce but rarely to zero, because you keep at least one running to answer the next request. Serverless is billed per request and per millisecond of execution, and drops to actually nothing when idle.

Four stacked rows over one 24-hour day. The first row shows actual traffic as bars, near zero overnight and peaking around midday and early evening. The second row shows a virtual machine billing a solid full-height block across all 24 hours. The third shows autoscaled containers billing a stepped block that never drops below a floor. The fourth shows serverless billing bars that follow the traffic exactly. A summary gives the day's cost as roughly $0.50 for the virtual machine, $0.31 for containers and $0.13 for serverless.
The shaded area is the bill. For this shape of day the virtual machine spends most of its money on hours when nobody visited.

The useful question that chart poses is one you can answer about your own system: what fraction of the time is the thing actually doing work? An internal tool used by twelve colleagues during office hours is idle around eighty percent of the week, and paying for all of it. A public API serving steady traffic around the clock is idle almost never, and the always-on model costs it nothing extra.

Where the Lines Cross

That per-request pricing has an obvious consequence that people consistently fail to extrapolate: it never stops climbing. An always-on machine costs the same at ten requests a month as at ten million. A serverless function costs nearly nothing at ten and a great deal at ten billion. Somewhere between those, the lines cross.

A line chart of monthly cost against monthly request volume on a logarithmic horizontal axis from ten thousand to a hundred million requests. The virtual machine line is flat at about $15 and rises to $30 at the top end. The Fargate line is flat at about $9 rising to $18. The Lambda line starts near zero and climbs steeply to about $63 at a hundred million requests, crossing the others at around 14 million requests a month, which is marked with a dashed line separating a region where serverless is cheaper from one where always-on is cheaper.
Neither model is cheap. They are cheap at different sizes, and the crossover for a small service sits in the low tens of millions of requests a month.

The exact crossing point depends entirely on how long your function runs and how much memory it uses, so treat the number on that chart as an illustration rather than a threshold to memorise. What is worth carrying away is the shape: serverless is dramatically cheaper when volume is low or spiky, meaningfully more expensive when volume is high and steady, and the transition is not gentle.

Cost comparisons like this leave out the largest expense in most real projects, which is people. A setup that costs $40 a month more but takes half a day less to maintain every month has already paid for itself many times over. Compare engineering time alongside the invoice, or the comparison is not honest.

What Actually Decides It

In practice the choice is rarely settled on price. It is usually settled by a handful of hard constraints, and it is much faster to check those first than to model costs for an option that was never viable.

How long does one unit of work take? Serverless functions have a hard ceiling - fifteen minutes on Lambda - and a video transcode or a large report will simply not fit. Do you need connections that stay open, like WebSockets for a chat or a live feed? That fits badly with a model built around short isolated invocations, and needs extra services to work around. Do you need something specific from the machine, a particular kernel module, a GPU, unusual hardware? That points at a virtual machine, because it is the only option that lets you reach that far down. How spiky is the traffic - is it near zero most of the time, or steady? And what does whoever maintains this already know how to operate at three in the morning?

There is one more consideration that does not show up on any pricing page, which is how hard it would be to leave. A container is a portable artifact: the same image runs on your laptop, on a rented server, on any of the three big clouds. A serverless function is written against one provider's specific shape - their event format, their permissions model, their surrounding services - and moving it is a rewrite rather than a redeployment. That may well be an acceptable price for how much less there is to run. It is only a bad deal if nobody realised they were paying it.

A Reasonable Default

If you want a starting position rather than a framework: for a normal web application with a database, containers are the sensible default in 2026. They start fast, they run identically everywhere, the tooling is mature, they impose no ceiling on request duration, and they do not tie you to one provider. Reach for serverless when the work is genuinely event-shaped and bursty - a scheduled job, a webhook handler, an image resizer, something that runs a thousand times a day for two seconds. Reach for a plain virtual machine when you need real control over the machine, when the load is steady enough that always-on is simply cheaper, or when the thing you are running does not fit the other two models.

The most common expensive mistake in this area is not picking the wrong one. It is picking serverless for a conventional web application because it sounded modern, then spending months building back the things a normal server gave away for free: persistent connections, long-running jobs, local caching, and a straightforward way to reproduce the whole thing locally.

Further reading

  • Linux namespaces, manual pageThe kernel feature behind the restricted view demonstrated above. Terse, authoritative, and shorter than most blog posts on the subject.
  • Control groups v2, kernel documentationThe other half of what makes a container a container - the resource limits rather than the visibility limits.
  • FirecrackerThe lightweight virtual machine technology underneath Lambda and Fargate, and the reason the note about hardware isolation in this lesson is true.
  • AWS Lambda: Execution Environment LifecycleWhat actually happens on a cold start, and why module-level code behaves the way the example above describes.
  • AWS Lambda PricingThe per-request and per-gigabyte-second figures used to build the crossover chart, so you can redo it with your own numbers.
  • Open Container Initiative SpecificationsThe standards that make a container image portable across providers - the practical basis for the lock-in point above.