Running a Fleet of Firecracker microVMs for eve.dev Agents, Part 5: Networking & Routing
At the end of Part 4 the control plane could place an agent onto a host and boot its microVM. GET /agents/{id} would tell you it was running on host i-0abc123. And there was nothing you could do with that — the agent had no address the outside world could reach. This part fixes that: by the end, a request to https://my-agent.fleet.example.com finds the exact microVM the scheduler placed, wherever it landed.
Routing on a fleet is a location problem. The control plane put an agent on some host, chosen at deploy time by bin-packing; a client typing a URL has no idea which. So the routing layer's whole job is to turn a stable name into a live location. We solve it in three layers, from the microVM outward: give each microVM a real address on its host, put a public load balancer out front, and place a thin proxy in between that does the name-to-location lookup.
Layer 1: Giving a microVM an Address
The single-VM guide waved at this — it mentioned a veth0 tap device and said networking was "a few extra ip commands beyond scope." Those commands are the foundation of everything here, so this part is where we actually run them. When the host-agent boots a microVM (the boot command from Part 4), it also sets up that microVM's networking on the host:
# Per microVM, run by the host-agent at boot. $TAP, $GUEST_IP, $HOST_PORT
# are allocated per agent; the guest always listens on :8080 internally.
ip tuntap add dev "$TAP" mode tap
ip addr add "169.254.$N.1/30" dev "$TAP" # host side of a tiny point-to-point link
ip link set "$TAP" up
# Egress: the agent needs to reach model APIs, npm, git — NAT it out the host.
iptables -t nat -A POSTROUTING -s "$GUEST_IP" -o eth0 -j MASQUERADE
iptables -A FORWARD -i "$TAP" -o eth0 -j ACCEPT
iptables -A FORWARD -i eth0 -o "$TAP" -m state --state RELATED,ESTABLISHED -j ACCEPT
# Ingress: expose the guest's :8080 on a host-local proxy port in the 8000-8999
# range Part 1's security group opened.
iptables -t nat -A PREROUTING -p tcp --dport "$HOST_PORT" -j DNAT \
--to-destination "$GUEST_IP:8080"
Two directions, two mechanisms. Egress is a MASQUERADE rule so the agent's traffic leaves the host with the host's source IP and can reach the internet through the NAT gateway from Part 1 — this is how an agent calls a model API. Ingress is a DNAT rule that maps a host-local port (in the 8000–8999 range Part 1 reserved) to the guest's internal :8080, the port the agent's systemd unit listens on from Part 3. After this runs, the agent is reachable at <host-private-ip>:<host-port> — a real address on the VPC. The host-agent records that address in the placement row, so the registry now knows not just which host an agent is on but exactly where.
Layer 2: The Public Front Door
Now the internet-facing piece. An Application Load Balancer with a wildcard TLS certificate is the single public entry point — the only thing the ALB security group from Part 1 lets take traffic from 0.0.0.0/0 on 443. Wildcard DNS (*.fleet.example.com) points every agent subdomain at it, so adding an agent never touches DNS or the load balancer — a brand-new whatever.fleet.example.com already resolves.
// lib/routing-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
import * as autoscaling from "aws-cdk-lib/aws-autoscaling";
import * as acm from "aws-cdk-lib/aws-certificatemanager";
import * as route53 from "aws-cdk-lib/aws-route53";
import * as r53targets from "aws-cdk-lib/aws-route53-targets";
import { config } from "./config";
export interface RoutingStackProps extends cdk.StackProps {
vpc: ec2.IVpc;
albSg: ec2.ISecurityGroup;
fleet: autoscaling.AutoScalingGroup; // exposed from the host-fleet stack (Part 2)
}
export class RoutingStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: RoutingStackProps) {
super(scope, id, props);
const { vpc, albSg, fleet } = props;
const zone = route53.HostedZone.fromLookup(this, "Zone", {
domainName: config.domain, // e.g. "fleet.example.com"
});
const cert = new acm.Certificate(this, "WildcardCert", {
domainName: `*.${config.domain}`,
validation: acm.CertificateValidation.fromDns(zone),
});
const alb = new elbv2.ApplicationLoadBalancer(this, "FleetAlb", {
vpc,
internetFacing: true,
securityGroup: albSg,
});
// The ALB spreads traffic across every host's front proxy. It does NOT
// know about individual agents — the host-local proxy resolves those.
const listener = alb.addListener("Https", {
port: 443,
certificates: [cert],
});
listener.addTargets("HostFrontProxies", {
port: config.hostProxyPort, // e.g. 8080, inside the 8000-8999 range
protocol: elbv2.ApplicationProtocol.HTTP,
targets: [fleet], // the ASG registers every host into this target group
healthCheck: { path: "/_health", healthyHttpCodes: "200" },
});
// Wildcard DNS → the ALB. New agents need no DNS change.
new route53.ARecord(this, "WildcardRecord", {
zone,
recordName: `*.${config.domain}`,
target: route53.RecordTarget.fromAlias(
new r53targets.LoadBalancerTarget(alb)
),
});
new cdk.CfnOutput(this, "AlbDns", { value: alb.loadBalancerDnsName });
}
}
The ALB deliberately knows nothing about agents. It has one target group — the whole host fleet — and it round-robins requests across hosts. That is what keeps it static: no per-agent listener rules (you would hit the limit in the hundreds), no target-group churn as agents come and go. The intelligence lives one layer down.
Layer 3: The Host-Local Front Proxy
Each host runs a small front proxy — part of the host-agent — listening on the config.hostProxyPort the ALB targets. Its job is the name-to-location lookup: read the Host header, find where that agent lives, and forward. Because the ALB may hand any request to any host, the proxy has to handle two cases — the agent is on this host, or it is on another.
// front proxy inside the host-agent (sketch)
async function handle(req, res) {
if (req.url === "/_health") return res.writeHead(200).end("ok");
const agentId = agentFromHost(req.headers.host); // my-agent.fleet.example.com
const placement = await lookupPlacement(agentId); // cached from the registry
if (!placement || placement.status !== "running") {
return res.writeHead(502).end("agent not available");
}
if (placement.hostId === SELF_HOST_ID) {
// Local: proxy straight to this microVM's DNAT port.
return proxyTo(`http://127.0.0.1:${placement.hostPort}`, req, res);
}
// Remote: forward to the owning host's front proxy — one hop, same port.
return proxyTo(`http://${placement.hostPrivateIp}:${SELF_PROXY_PORT}`, req, res);
}
The proxy keeps a cached view of the placements table (refreshed on a short interval, or driven by the agent.placed / agent.running events from Part 4), so the common path is a local map lookup, not a DynamoDB read per request. A request for a local agent is proxied straight to its microVM; a request for a remote one takes a single host-to-host hop to the owning host, which then proxies locally. Location transparency, at the cost of at most one extra hop.
That host-to-host hop needs one small amendment to Part 1's host security group — it originally allowed hosts to talk to each other only on the port-7000 management path. We widen the self-rule to cover the proxy port so the mesh forward is allowed:
// add to the host security group from Part 1
hostSg.addIngressRule(
hostSg,
ec2.Port.tcp(config.hostProxyPort),
"Front-proxy mesh: forward to the host that owns the agent"
);
Because the proxy speaks plain HTTP and simply pipes bytes, it carries streaming responses and WebSocket upgrades unchanged — which matters, because an eve agent's replies stream and its channels hold long-lived connections. And add the two new config values these stacks reference:
// lib/config.ts — additions
domain: "fleet.example.com",
hostProxyPort: 8080,
Wire It Up and Reach an Agent
The routing stack needs the ASG from Part 2 (expose it as public readonly fleet on the host-fleet stack) and the ALB security group from Part 1:
// bin/app.ts — additions
const hosts = new HostFleetStack(app, "EveFleetHosts", {
env, vpc: network.vpc, hostSg: network.hostSg,
});
new RoutingStack(app, "EveFleetRouting", {
env, vpc: network.vpc, albSg: network.albSg, fleet: hosts.fleet,
});
cdk deploy EveFleetRouting
With an agent deployed through Part 4's control plane, it is now reachable by name:
curl -s https://my-agent.fleet.example.com/health
# → {"status":"ok"} ← served by the microVM the scheduler placed, wherever it landed
Trace that request: DNS resolved the wildcard to the ALB; the ALB forwarded to some host's front proxy; the proxy read my-agent from the Host header, looked up its placement, found it on another host, and forwarded one hop; the owning host's proxy piped it to the microVM's DNAT port; the agent answered. The client saw a normal HTTPS endpoint and none of the placement underneath.
Tearing Down
Routing is the outermost layer, so it comes down first:
cdk destroy EveFleetRouting
The ALB and its ENIs live in the VPC from Part 1 — this is exactly the "tear down in reverse order" warning from earlier parts. Remove routing before the control plane, the hosts, and the network, or the VPC delete will hang on the load-balancer ENIs it does not know about.
What's Next
The platform is now end-to-end functional: build an image (Part 3), deploy it through the control plane (Part 4), and reach the running agent by name (this part). Every piece works — but driving it still means running build-rootfs.sh by hand, curl-ing the control-plane API, and polling for status.
In Part 6 we make that a single command. We build the deploy my-agent developer experience — a thin CLI that chains build → upload → deploy → wait-for-URL — lock the control-plane API down with IAM auth before it faces a CI system, and wire the same call into GitHub Actions so a merge ships an agent and a pull request gets its own preview agent on a per-PR subdomain.
If the raw per-microVM networking here felt dense, the single-VM guide sets up one tap device and one microVM slowly enough to see each piece before a fleet multiplies it.