Network Simulation and Virtual Labs
"A flight simulator lets a pilot practice an engine failure at ten thousand feet without ever risking a real aircraft or a real passenger. A network simulator offers the same deal to network engineers and protocol developers: try the failure, the misconfiguration, the hostile burst of traffic, in an environment where the worst outcome is a restarted process, not an outage affecting real users."
Every example in this book so far has run on a single machine, usually against localhost, which hides an entire category of problems: real networks have latency that varies, links that drop packets, topologies with more than two nodes, and failures that happen mid-conversation rather than conveniently at the start. Network simulation and virtual labs exist to reintroduce those realities in a controlled, repeatable, disposable way -- letting you test how your Go networking code behaves under conditions your development laptop's loopback interface will never produce on its own.
The Landscape of Tools
This is a space where dedicated, mature tools already exist, and it is worth being honest that none of the big names -- GNS3, Mininet, Containerlab, ns-3 -- are themselves Go projects. GNS3 orchestrates real router and switch images (including actual vendor firmware) in virtual machines; Mininet uses Linux network namespaces to build large virtual topologies on a single kernel; Containerlab does something similar but centered on containerized network operating systems; ns-3 is a discrete-event network simulator used heavily in protocol research. Go's role here is not to replace these tools, but to drive them, and, for smaller-scale protocol-level testing, to build lightweight simulations directly.
Go Implementation: A Pure-Go Protocol-Level Simulator
For testing how your own protocol or application behaves under latency and packet loss -- rather than simulating an entire network of routers -- you do not need external tooling at all. Goroutines and channels can model a lossy, delayed link directly, which is both simpler and safer than depending on an external simulator for this specific question:
package main
import (
"fmt"
"math/rand"
"time"
)
// link simulates a network path with configurable latency and loss,
// sitting between a sender and a receiver channel.
type link struct {
latency time.Duration
lossPct float64
}
func (l link) carry(in <-chan string, out chan<- string) {
for msg := range in {
if rand.Float64() < l.lossPct {
continue // simulated packet loss: silently drop
}
time.Sleep(l.latency) // simulated propagation + queuing delay
out <- msg
}
}
func main() {
sender := make(chan string)
receiver := make(chan string)
lossyLink := link{latency: 50 * time.Millisecond, lossPct: 0.1}
go lossyLink.carry(sender, receiver)
go func() {
for i := 0; i < 10; i++ {
sender <- fmt.Sprintf("message %d", i)
}
close(sender)
}()
for msg := range receiver {
fmt.Println("received:", msg)
}
}
This is a genuine, useful technique, not a toy: it is the same mental model used by libraries like toxiproxy (a real TCP proxy that injects latency, bandwidth limits, and failures between a client and server), just implemented directly at the message level with nothing more than the standard library. It is the right tool when the question is "does my retry logic handle 10% packet loss correctly," and the wrong tool when the question is "how does my BGP configuration behave across five routers" -- for that, you need the network-level fidelity that dedicated topology simulators provide.
Go Implementation: Orchestrating a Container-Based Lab
For topology-level testing -- multiple hosts, real routing between them -- the practical approach in Go is to drive Docker (or a purpose-built tool like Containerlab, which itself orchestrates containers) rather than reimplement network namespace management yourself. Shelling out to the Docker CLI via os/exec keeps this honest: it is exactly what a human operator would type, just automated and made repeatable.
package main
import (
"fmt"
"log"
"os/exec"
)
type virtualLab struct {
networkName string
}
func (lab *virtualLab) up() error {
createCmd := exec.Command("docker", "network", "create", lab.networkName)
if out, err := createCmd.CombinedOutput(); err != nil {
return fmt.Errorf("create network: %w (%s)", err, out)
}
nodes := []string{"host-a", "host-b", "router"}
for _, name := range nodes {
cmd := exec.Command("docker", "run", "-d",
"--name", name,
"--network", lab.networkName,
"--cap-add", "NET_ADMIN", // for routing/iptables
"alpine", "sleep", "infinity",
)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("start node %s: %w (%s)", name, err, out)
}
log.Println("started node:", name)
}
return nil
}
func (lab *virtualLab) exec(node string, args ...string) ([]byte, error) {
cmdArgs := append([]string{"exec", node}, args...)
return exec.Command("docker", cmdArgs...).CombinedOutput()
}
func (lab *virtualLab) down() error {
nodes := []string{"host-a", "host-b", "router"}
for _, name := range nodes {
exec.Command("docker", "rm", "-f", name).Run()
}
rmCmd := exec.Command("docker", "network", "rm", lab.networkName)
_, err := rmCmd.CombinedOutput()
return err
}
func main() {
lab := &virtualLab{networkName: "vlab-net"}
if err := lab.up(); err != nil {
log.Fatal(err)
}
defer lab.down()
// Run a command inside a node exactly as you would over SSH,
// except there is no SSH daemon or network hop involved at all.
out, err := lab.exec("host-a", "ping", "-c", "3", "host-b")
if err != nil {
log.Println("ping failed:", err)
}
fmt.Println(string(out))
}
exec() here is doing the same job docker exec always does -- running a command inside an already-running container's namespace -- which means this small orchestrator can drive arbitrarily complex setup inside each node (configuring routes, installing tc traffic-control rules for latency injection, running your own Go binary as the workload under test) without this book needing to reimplement any of that plumbing.
Choosing the Right Level of Fidelity
The right simulation tool depends entirely on the question you are asking. Testing your own protocol's resilience to loss and jitter: a pure-Go channel-based simulator, as shown above, is faster to write and faster to run than spinning up containers. Testing multi-host topology, real routing protocols, or how your service behaves alongside actual network operating systems: reach for Containerlab or GNS3, with Go (or a shell script) as the orchestration layer starting and tearing down the lab. Testing at genuinely large scale, hundreds of simulated nodes, with statistical fidelity to real-world network behavior: that is squarely ns-3's territory, and no amount of Go tooling substitutes for a purpose-built discrete-event simulator there.
A lab you can tear down and rebuild in seconds is a lab you will actually use before every change, not just after something breaks.
Frequently Asked Questions
My retry logic works fine on localhost -- why bother simulating latency and loss at all?
Because localhost hides exactly the conditions that break retry logic in production: variable latency, dropped packets, and failures that happen mid-conversation instead of conveniently at the start. The link type in this chapter's channel-based simulator exists precisely to answer "does my retry logic handle 10% packet loss correctly" in a repeatable way your loopback interface simply cannot produce on its own.
Should I reach for the pure-Go channel simulator or spin up a Containerlab topology?
It depends entirely on what question you are asking. If you are testing your own protocol's resilience to jitter and loss, the goroutine-and-channel link simulator is faster to write and faster to run than any container setup. If the question involves multiple hosts and real routing between them, you need the network-level fidelity that Containerlab, GNS3, or Mininet actually provide -- no amount of in-process channel simulation substitutes for a real topology once routing itself is what you are testing.
Why does the container-lab example shell out to the docker CLI with os/exec instead of using Docker's official Go SDK?
The DeepDive addresses this directly: github.com/docker/docker/client is a legitimate, officially maintained choice that real tools like Containerlab do use, but its API shifts across Docker Engine versions. The CLI's behavior is stable and easy to verify by hand, which for a teaching example matters more than saving a few lines of code that might drift out of sync with a specific SDK release.
Is this chapter suggesting Go can replace tools like ns-3 or GNS3? No -- quite the opposite. None of the major simulation tools (GNS3, Mininet, Containerlab, ns-3) are Go projects, and Go's role here is to drive them, not replace them: shelling out to Docker or a purpose-built orchestrator for topology-level tests, while reserving hand-written Go simulation for narrower, protocol-level questions the heavier tools would be overkill for.
What is lab.exec actually doing when it runs ping inside host-a?
It is doing exactly what typing docker exec host-a ping -c 3 host-b at a terminal would do -- running a command inside an already-running container's namespace, with no SSH daemon or extra network hop involved. That is what lets this small orchestrator drive arbitrarily complex setup inside each node, from configuring routes to installing tc traffic-control rules, without the book needing to reimplement any of that container plumbing itself.
Key Takeaways
- Network simulation reintroduces the latency, loss, and multi-node topology that a development laptop's loopback interface hides entirely.
- None of the major topology tools -- GNS3, Mininet, Containerlab, ns-3 -- are Go projects; Go's role is to drive them, not replace them.
- Goroutines and channels can model a lossy, delayed link directly, which is the right tool for testing your own protocol's resilience, not for testing real routing.
- Shelling out to the Docker CLI to orchestrate a container-based lab is a legitimate, verifiable approach, even though a Go SDK also exists.
- Match the simulation's fidelity to the question being asked: protocol-level questions want a pure-Go simulator, topology-level questions want Containerlab or GNS3, and internet-scale questions belong to ns-3.