Bridge Networking: Giving a Container an IP Address¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
A container in a new network namespace has no network. None at all. Even loopback isn't up. We need to create a virtual network and connect the container to the host. Here's how it works in Sheep.
Network architecture¶
graph TB
subgraph "Host"
ETH["eth0<br/>192.168.1.x"]
BR["sheep0 (bridge)<br/>10.20.0.1/16"]
VETH1H["veth_abc1"]
VETH2H["veth_def2"]
IPTABLES["iptables NAT<br/>MASQUERADE"]
end
subgraph "Container 1"
VETH1G["eth0<br/>10.20.0.2/16"]
end
subgraph "Container 2"
VETH2G["eth0<br/>10.20.0.3/16"]
end
BR --- VETH1H
BR --- VETH2H
VETH1H -.- VETH1G
VETH2H -.- VETH2G
BR --- IPTABLES
IPTABLES --- ETH
Three components: a bridge (virtual switch), veth pairs (virtual cables), NAT (internet access).
Constants¶
const (
BridgeName = "sheep0"
BridgeSubnet = "10.20.0.0/16"
BridgeGateway = "10.20.0.1"
)
var ipCounter uint32 = 1
The 10.20.0.0/16 subnet gives 65534 addresses. For a learning project -- more than enough.
Creating the bridge¶
func ensureBridge() error {
// Check if the bridge already exists
if _, err := net.InterfaceByName(BridgeName); err == nil {
return nil
}
// Create the bridge
run("ip", "link", "add", BridgeName, "type", "bridge")
run("ip", "addr", "add", BridgeGateway+"/16", "dev", BridgeName)
run("ip", "link", "set", BridgeName, "up")
// Enable IP forwarding
os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0644)
// NAT for outgoing traffic
run("iptables", "-t", "nat", "-A", "POSTROUTING",
"-s", BridgeSubnet, "-j", "MASQUERADE")
return nil
}
The bridge is created once with the first container. ip_forward lets the kernel forward packets between interfaces.
Setting up container networking¶
Here's the full function:
func setupNetworkForContainer(c *Container, pid int) error {
if err := ensureBridge(); err != nil {
return fmt.Errorf("ensure bridge: %w", err)
}
ip := allocateIP()
vethHost := fmt.Sprintf("veth%s", ShortID(c.ID)[:8])
vethGuest := "eth0"
// Create veth pair
run("ip", "link", "add", vethHost,
"type", "veth", "peer", "name", vethGuest)
// Host end goes into the bridge
run("ip", "link", "set", vethHost, "master", BridgeName)
// Guest end goes into the container's namespace
run("ip", "link", "set", vethGuest,
"netns", strconv.Itoa(pid))
// Bring up the host end
run("ip", "link", "set", vethHost, "up")
// Configure networking inside the container
nsRun(pid, "ip", "addr", "add", ip+"/16", "dev", "eth0")
nsRun(pid, "ip", "link", "set", "eth0", "up")
nsRun(pid, "ip", "link", "set", "lo", "up")
nsRun(pid, "ip", "route", "add", "default", "via", BridgeGateway)
c.Network = &NetworkSettings{
IPAddress: ip,
Gateway: BridgeGateway,
Bridge: BridgeName,
VethHost: vethHost,
VethGuest: vethGuest,
}
return nil
}
Veth pairs -- a virtual cable¶
Veth (virtual ethernet) is a pair of network interfaces connected to each other. Whatever enters one end comes out the other. One end stays on the host (attached to the bridge), the other is moved into the container's network namespace.
// nsRun executes a command inside the network namespace of a process
func nsRun(pid int, name string, args ...string) error {
nsArgs := append([]string{
"-t", strconv.Itoa(pid), "-n", "--", name,
}, args...)
return run("nsenter", nsArgs...)
}
nsenter is a utility that "enters" the namespace of a process with a given PID. The -n flag means the network namespace.
IP allocation¶
func allocateIP() string {
n := atomic.AddUint32(&ipCounter, 1)
return fmt.Sprintf("10.20.%d.%d", (n>>8)&0xFF, n&0xFF)
}
A simple atomic counter. The IP is persisted to disk:
func LoadIPCounter(baseDir string) {
data, _ := os.ReadFile(
filepath.Join(baseDir, "network", "ip_counter"))
if n, err := strconv.ParseUint(
strings.TrimSpace(string(data)), 10, 32); err == nil {
atomic.StoreUint32(&ipCounter, uint32(n))
}
}
Where this breaks¶
Our IP allocation doesn't account for released addresses. The counter only goes up. Docker has a full IPAM (IP Address Management) that tracks the pool and reuses addresses.
The second thing -- we use ip and nsenter as external commands. Docker and containerd use netlink through a Go library, which is faster and doesn't depend on installed utilities. This is on the roadmap to rewrite — the task is already in the Sheep backlog.
Try it yourself¶
sudo ./sheep run --name net-test -d minimal /bin/sleep 3600
# On the host, check the bridge and veth:
ip link show type bridge
ip link show type veth
# Container IP:
sudo ./sheep inspect net-test | grep IP
The container has an IP. Next up -- NAT and iptables: how packets reach the internet and come back.
Resources¶
- veth(4) — virtual ethernet pair kernel docs
- network_namespaces(7) — Linux network namespace overview
- ip-link(8) — managing network interfaces
- nsenter(1) — running commands inside a namespace
- Docker bridge driver — official driver docs
- RFC 1918 — private IPv4 address ranges
Previous: OverlayFS | Next: NAT and iptables
