> Blog Post

Running a Fleet of Firecracker microVMs for eve.dev Agents, Part 7: Fleet Operations

Across Parts 1 through 6 we built a platform that works: a network, a host fleet, an image pipeline, a control plane that places agents, a routing layer that reaches them, and a deploy workflow that ships them on merge. Everything functions — as long as nothing goes wrong and demand never changes. This final part is about the gap between "works in a demo" and "runs unattended": scaling the fleet with load, surviving a host failure, seeing what each agent is doing, and knowing what it all costs.

None of this needs new architecture. It is all reconciliation loops and telemetry hung off the events and registry the earlier parts already emit — which is the payoff of having built it event-driven in the first place.

Scaling the Host Fleet

The host fleet has a fixed desired count from Part 2, and bare metal is the whole cost, so getting this number right is the platform's economics. Two forces move it, in opposite directions.

Scale out is event-driven and precise. When the scheduler cannot find a host with room, it already emits agent.unplaceable (Part 4). That event is the signal — a Lambda subscribed to it bumps the ASG's desired capacity by one and the placement retries once the new host registers:

// lib/operations-stack.ts — scale out on demand
new events.Rule(this, "ScaleOutRule", {
  eventBus: bus,
  eventPattern: { source: ["eve.fleet"], detailType: ["agent.unplaceable"] },
  targets: [new targets.LambdaFunction(scaleOutFn)],
});
// scaleOutFn: increment fleet DesiredCapacity by 1 (up to a ceiling),
// then re-emit agent.deploy.requested so the scheduler tries again.

This beats a CPU-threshold alarm because the trigger is the actual condition you care about — a real agent that could not be placed — not a proxy metric that lags it.

Scale in is the careful direction, and it has a trap: an empty-looking host might have gone idle seconds ago and be about to receive a placement, and — worse — you must never terminate a host with running agents on it. A CloudWatch metric of aggregate free capacity drives the decision to remove a host, but an ASG lifecycle hook makes the removal safe:

const drainHook = new autoscaling.LifecycleHook(this, "DrainHook", {
  autoScalingGroup: fleet,
  lifecycleTransition: autoscaling.LifecycleTransition.INSTANCE_TERMINATING,
  heartbeatTimeout: cdk.Duration.minutes(5),
  defaultResult: autoscaling.DefaultResult.ABANDON,
});
// On the terminating hook: reschedule this host's agents elsewhere (below),
// then CompleteLifecycleAction so the instance is allowed to go.

When the ASG decides to remove a host, the hook pauses the termination and hands a Lambda five minutes to drain it: look up every agent on that host (the byHost GSI from Part 4), re-emit agent.deploy.requested for each so the scheduler places them on hosts that are staying, wait for the new placements to go healthy, then complete the lifecycle action. Draining and rescheduling are the same operation as recovering from a crash — which is the next section.

Surviving a Dead Host

A bare-metal host will eventually fail — hardware, an AZ event, a kernel panic. When it does, its agents die with it, and the platform's job is to notice and rebuild them elsewhere without a human paging through dashboards. The mechanism is the heartbeat from Part 2: a healthy host updates lastHeartbeat every few seconds; a dead one stops. A reconciliation loop turns that silence into recovery.

// A scheduled reconciler — the desired-vs-actual loop.
new events.Rule(this, "ReconcileRule", {
  schedule: events.Schedule.rate(cdk.Duration.minutes(1)),
  targets: [new targets.LambdaFunction(reconcileFn)],
});

Once a minute, the reconciler scans the hosts table for rows whose lastHeartbeat is older than a threshold. For each dead host it reads that host's agents from the byHost placement index, marks their placements lost, and re-emits agent.deploy.requested — sending them straight back through the same bin-packing scheduler that first placed them, which lands them on healthy hosts. Meanwhile the ASG's own EC2 health check replaces the dead instance, and the new one registers fresh capacity. The two loops compose: the ASG heals the hardware, the reconciler heals the placements.

This is why Parts 2 and 4 split desired state (the agents table) from actual state (the placements table). Recovery is nothing more than a loop that notices they disagree and re-drives the events to make actual match desired again. Every failure mode — crash, drain, scale-in, a botched boot — funnels into that one reconciliation path.

Seeing What the Fleet Is Doing

Operating blind is not operating. The single-VM guide's Step 9 already established the per-host telemetry — the CloudWatch agent for host metrics, alarms to SNS, and an ADOT collector exporting the agent's OpenTelemetry traces. On a fleet that pattern runs on every host, and we add the layer above it: platform-level signals that only exist across the whole fleet.

  • Per-agent logs, queryable by name. Each host's ADOT collector tags every span and log line with the agentId the front proxy already knows, and ships them to a shared CloudWatch Logs group. my-agent's logs are one Logs Insights query away no matter which host — or hosts, over its lifetime — it ran on.
  • A fleet dashboard. A CloudWatch dashboard over the registry and events: agents running, placement latency, agent.unplaceable rate, aggregate free capacity, hosts in service. These are the numbers that tell you the platform is healthy, distinct from whether any one agent is.
  • The alarms that page. Two matter most at the fleet level: a sustained agent.unplaceable rate (you are out of capacity and scale-out is not keeping up) and a reconciler that is rescheduling more than a trickle (hosts are dying faster than normal). Both point at the platform, not an agent, and both go to the same SNS topic Step 9 set up.

The through-line: an agent's own behavior is application telemetry (OTel, per Step 9); the fleet's behavior is platform telemetry (the registry and the event bus). Watch both, from the two sources that already produce them.

What It Costs

Now the question finance actually asks. The bare-metal hosts are essentially the entire bill — Lambda, DynamoDB, and the ALB are rounding error next to an m5.metal — so cost per agent is cost per host divided by how densely you pack it. That single fact is why every earlier decision leaned toward density: the bin-packing scheduler in Part 4, the tightest-fit placement, the drain-and-consolidate on scale-in here.

The arithmetic makes the lever obvious. Take an m5.metal at roughly $4.60/hour on-demand and the 46-agent capacity from Part 2's model:

full host, well packed:   $4.60 / 46 agents  ≈  $0.10 / agent-hour
half-empty host:          $4.60 / 23 agents  ≈  $0.20 / agent-hour

Packing density is the unit economics — a fleet running its hosts half-full costs twice as much per agent as one that consolidates. So the FinOps controls are the same two from Step 9, now read at fleet scale: cost-allocation tags on every resource so Cost Explorer shows spend per agent (slice by the agentId tag) and per environment, and an AWS Budgets tripwire so a runaway scale-out — a bug that deploys a thousand preview agents, say — pages you before the month closes. For non-production fleets, the highest-leverage control is the bluntest: scale the ASG to zero out of hours. A dev fleet that runs 10 hours on weekdays costs a quarter of one left on around the clock.

Tearing the Whole Thing Down

You have built five stacks on top of a network. They come down in strict reverse order of creation — each one removes resources the ones below it depend on:

cdk destroy EveFleetRouting        # ALB + wildcard DNS first (frees VPC ENIs)
cdk destroy EveFleetControlPlane   # API, tables, scheduler
cdk destroy EveFleetHosts          # the ASG — drains and terminates the hosts
cdk destroy EveFleetArtifacts      # the image bucket (autoDeleteObjects handles versions)
cdk destroy EveFleetNetwork        # the VPC, last

Drain the agents first (drop the host fleet's desired count to zero, or DELETE each agent through the control plane) so nothing is mid-flight, then work down the list. If a destroy hangs, it is almost always the reverse-order rule being violated — something above is still holding an ENI or a target in the layer you are trying to remove.

The Whole Platform, in One Picture

Seven parts in, here is everything, and how a single fleet-deploy flows through it:

  • A developer runs fleet-deploy (Part 6). It builds a versioned rootfs and pushes it to the image bucket (Part 3), then calls the control plane (Part 4).
  • The control plane records desired state, and its bin-packing scheduler reserves capacity on a host and commands that host's host-agent to boot the microVM — from the fleet of bare-metal hosts (Part 2) sitting in the network (Part 1).
  • The routing layer (Part 5) makes the agent reachable at my-agent.fleet.example.com, and the operations loops (this part) keep it alive, scaled, observed, and costed.

That is a self-service, multi-tenant agent platform — reviewed in pull requests, deployed on infrastructure you own, with hardware-enforced isolation between agents — built from AWS primitives and Firecracker, entirely in CDK.

Where to Go From Here

This series deliberately built a learning platform, not a turnkey production system. The seams where you would harden it for real workloads are worth naming: Firecracker's jailer for defense-in-depth around each microVM, snapshot-backed warm pools so preview agents boot instantly, per-tenant network policy and egress filtering, and a real secrets backend behind the MMDS injection from Part 3. Each is a project in itself.

If a fleet is more than you need, the honest conclusion is the one the single-VM guide opened with: for one agent, or for multi-tenant isolation you would rather not operate, vercel deploy and Vercel Sandbox give you the same microVM boundary without any of the machinery in these seven parts. Build the fleet when you need to own it — the control, the cost model, the data residency — and now you know exactly what owning it entails.

Thanks for following the whole series. The companion eve framework quickstart and single-VM guide are the ground floor beneath everything here; the series hub collects all seven parts in order.