AWS finally supports nested VMs on EC2. Here's why Go devs should care and how to use it.
· 4 min read

AWS nested virtualization is here - what it means for Go developers


AWS has finally shipped something a lot of us had written off as never happening: nested virtualization on EC2. It didn’t get much fanfare, but if you build infrastructure tooling, CI/CD systems, or testing frameworks in Go, it deserves ten minutes of your attention.

The Hacker News discussion was full of people describing workarounds they’d been maintaining for years. Let’s look at what this actually unlocks for Go developers.

What is nested virtualization?

Exactly what it sounds like: a VM inside a VM. Your EC2 instance becomes a hypervisor that can spawn its own virtual machines.

Until now, AWS blocked this outright. You could not run KVM, QEMU, or any hypervisor inside EC2, full stop. Your options were elaborate workarounds or paying for bare metal. Now it works on certain instance types.

A few places this bites in practice:

  • Running Kubernetes clusters with minikube using a VM driver. kind is still useful for local Kubernetes, but it runs nodes as containers rather than nested VMs
  • Testing infrastructure automation tools
  • Building multi-tenant platforms
  • CI/CD pipelines that need full VM isolation

Running VMs from Go

Go’s story here is better than you might expect. Here’s how you might use the libvirt bindings to talk to a hypervisor programmatically:

package main

import (
    "fmt"
    "net"
    "time"

    "github.com/digitalocean/go-libvirt"
)

func main() {
    // Connect to libvirt socket
    c, err := net.DialTimeout("unix", "/var/run/libvirt/libvirt-sock", 2*time.Second)
    if err != nil {
        panic(err)
    }

    l := libvirt.New(c)
    if err := l.Connect(); err != nil {
        panic(err)
    }
    defer l.Disconnect()

    // List running domains
    domains, _, err := l.ConnectListAllDomains(1, libvirt.ConnectListDomainsActive)
    if err != nil {
        panic(err)
    }

    for _, d := range domains {
        fmt.Printf("Running VM: %s\n", d.Name)
    }
}

Not much code for what it does. This is the foundation you’d build an orchestration tool on top of.

Building a VM health checker

Here’s something closer to what you’d actually ship. Say you need to keep an eye on the nested VMs your system spawns:

package main

import (
    "context"
    "fmt"
    "net"
    "time"

    "github.com/digitalocean/go-libvirt"
)

type VMStatus struct {
    Name   string
    State  string
    Memory uint64
    CPUs   uint32
}

func checkVMHealth(ctx context.Context, l *libvirt.Libvirt) ([]VMStatus, error) {
    domains, _, err := l.ConnectListAllDomains(1, libvirt.ConnectListDomainsActive)
    if err != nil {
        return nil, fmt.Errorf("listing domains: %w", err)
    }

    var statuses []VMStatus
    for _, d := range domains {
        state, _, err := l.DomainGetState(d, 0)
        if err != nil {
            continue
        }

        info, err := l.DomainGetInfo(d)
        if err != nil {
            continue
        }

        statuses = append(statuses, VMStatus{
            Name:   d.Name,
            State:  stateToString(libvirt.DomainState(state)),
            Memory: info.Memory,
            CPUs:   uint32(info.NrVirtCpu),
        })
    }
    return statuses, nil
}

func stateToString(state libvirt.DomainState) string {
    switch state {
    case libvirt.DomainRunning:
        return "running"
    case libvirt.DomainPaused:
        return "paused"
    case libvirt.DomainShutdown:
        return "shutting down"
    default:
        return "unknown"
    }
}

Note the context parameter for cancellation. If context patterns are new to you, read what is context in Go before writing this sort of code.

Testing with nested VMs

Testing is where this gets properly interesting for me. You can spin up isolated VMs for integration tests:

func TestDatabaseMigration(t *testing.T) {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
    defer cancel()

    vm, err := createTestVM(ctx, "postgres-test")
    if err != nil {
        t.Fatalf("creating test VM: %v", err)
    }
    defer vm.Destroy()

    // Wait for VM to boot
    if err := vm.WaitForSSH(ctx); err != nil {
        t.Fatalf("waiting for SSH: %v", err)
    }

    // Run your actual tests against the VM
    conn, err := sql.Open("postgres", vm.ConnectionString())
    if err != nil {
        t.Fatalf("connecting to postgres: %v", err)
    }
    defer conn.Close()

    // Your migration tests here
}

True isolation. Each test gets a fresh VM, so there are no container escapes to worry about and no shared state leaking between runs.

When to use this

Nested virtualization isn’t free. Every layer adds overhead, and debugging a problem through two hypervisors is nobody’s idea of a good afternoon. I’d reach for it when you genuinely need full hardware isolation (containers share a kernel; VMs don’t), when you’re building tools that themselves manage VMs and want to test them honestly, when a workload simply demands a real Windows VM, or when you’re running multi-tenant systems where a VM-level security boundary is the whole point.

For most Go applications, containers are still the right choice. They start faster, use less memory, and the tooling around them is far more mature.

The Go advantage

This is where Go’s deployment story earns its keep. Cross-compilation and static binaries mean you can build an orchestration tool on your Mac, copy it to EC2, and it just runs. No runtime dependencies. No version conflicts.

Tools like Firecracker (the microVM technology behind AWS Lambda) have Go SDKs too. Nested virtualization means you can now run Firecracker inside an ordinary EC2 instance for testing, which used to mean renting bare metal.

Wrapping up

If you build CI/CD systems, multi-tenant platforms, or anything that manages VMs, spin up a supported instance type and try running a KVM guest inside it. Worst case, you learn something about hypervisors. For everyone else, keep using containers. They remain the simpler choice for most workloads, and no AWS announcement changes that.