We zijn allemaal gewend aan het werken met containers. We weten allemaal hoe we snel een container op moeten spinnen. Maar wat gebeurt er eigenlijk onder de motorkap? Ik zocht het uit en bouwde een container vanaf scratch zodat jij dat niet hoeft te doen. In dit verhaal kom je er achter dat de magie van Docker slechts een abstractielaag is die een hoop technische zaken voor je afschermt. Er is voor de duidelijkheid niet 1 manier om de dingen die achter de schermen moeten gebeuren, te doen. Ook zou ik een boek nodig hebben inplaats van alleen dit artikel om alles volledig toe te lichten. Het doel is dat je na het lezen een idee hebt van wat er op systeemniveau gebeurt. Hierdoor zul je container runtimes als Docker meer gaan waarderen omdat je het niet allemaal zelf hoeft te doen.
Het netwerk
Containers zijn actief in een container netwerk. Dat kan je op verschillende manieren inrichten. In het schema hieronder zie je hoe ik dat gedaan heb voor dit artikel.

Dit is de schematische weergave van een computer met een standaard netwerk interface die op het lokale netwerk: LAN. Het gele vlak is de standaard aanwezige netwerk interface.
Het blauwe vlak is een bridge interface. Als je Docker installeert, krijg je ook een bridge interface. Zie het als een netwerkswitch waarop in dit geval de containers aangesloten worden. Het container netwerk krijgt ook een eigen IP configuratie. Binnen het gebruikte subnet krijgen zowel de bridge (blauw vlak) en de container (het groene vlak) een IP adres.
Virtual ethernet pair
De paars gekleurde vlakken vormen een virtual ethernet pair. zo’n virtual ethernet pair (of Veth” device kan je zien als een buis. Als je er aan de ene kant iets instopt, komt het er aan de andere kant uit.
Netwerk namespaces
Containers maken gebruik van netwerk namespaces. Namespaces zijn, in deze context methodes om op kernel niveau een proces een soort oogkleppen te geven zodat het alleen kan zien wat jij wil dat het ziet. Het perspectief wat een proces heeft op het systeem, en in dit geval het netwerk, kan je er mee aanpassen. Op deze manier kan je de container een eigen afwijkende netwerk configuratie geven. Als je vanaf een container het commando “ip a” uitvoert, zul je dus ook andere netwerkdevices, routes en ipconfiguratie zien dan wanneer je dat buiten de container, buiten de netwerk namespace doet. Later in dit verhaal zul je zien dat kernel namespaces op veel meer manieren gebruikt worden om containers meer een eigen identiteit/perspecfief en isolatie te geven.
de configuratie zelf
Om het bovenstaande verhaal te realiseren zal de computer waarop we de container van scratch maken, geconfigureerd moeten worden. Zie hieronder het script om dat te doen:
###
#!/bin/bash
# ns-bridge-nat.sh — maakt bridge, veth-paar, namespace en NAT
# variabelen
EXT_IF="enp2s0" # Standaard netwerk interface
BR_IF="br0" # De naam van de bridge interface
NS="ns1" # De naam van de te gebruiken netwerk namespace
VETH_HOST="veth0" # virtual ethernet device. Host kant van de VETH pair.
VETH_NS="veth1" # virtual ethernet device. Namespace/container kant
# van VETH PAIR
BR_NET="192.168.100.0/24" # subnet voor de bridge interface
BR_IP="192.168.100.1/24" # IP adres voor de bridge interface
NS_IP="192.168.100.2/24" # IP adres voor de container
# schakel IP forwarding in
sysctl -w net.ipv4.ip_forward=1
# verwijder oude setup als die bestaat
ip netns del $NS 2>/dev/null
ip link del $BR_IF 2>/dev/null
# maak bridge
ip link add $BR_IF type bridge
ip addr add $BR_IP dev $BR_IF
ip link set $BR_IF up
# maak veth-paar
ip link add $VETH_HOST type veth peer name $VETH_NS
# verbind host-kant met bridge
ip link set $VETH_HOST master $BR_IF
ip link set $VETH_HOST up
# maak namespace en verbind de andere kant
ip netns add $NS
ip link set $VETH_NS netns $NS
# configureer de interface in de namespace
ip netns exec $NS ip addr add $NS_IP dev $VETH_NS
ip netns exec $NS ip link set $VETH_NS up
ip netns exec $NS ip link set lo up
ip netns exec $NS ip route add default via ${BR_IP%/*}
# NAT zodat namespace het internet op kan
iptables -t nat -A POSTROUTING -o $EXT_IF -s $BR_NET -j MASQUERADE
echo "Bridge + namespace '$NS' met NAT is actief."
###
Testen of het werkt
In de onderstaande afbeelding is de ip configuratie van de container te zien. Ook zie je dat het mogelijk is om vanuit “de container” de buitenwereld te bereiken.

Het image
Nu het container netwerk geconfigureerd is kunnen we kijken naar het container image. Een container image is een bestand/mappen structuur waarin de applicatie en al zijn afhankelijkheden zitten. Als geheel is dit te verpakt als een “container image”. Vergelijk het met een tar bal waarmee je vroeger applicaties kon distribueren. Onder de motorkap is het in feite niet veel meer dan een Chroot! Een nieuwe plek dat gezien wordt als het “root filesystem”. In dit voorbeeld gaan we zo’n “changed root” of chroot maken met Alpine linux.
wget https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/x86_64/alpine-minirootfs-3.21.0-x86_64.tar.gz
mkdir rootfs
tar -xzf alpine-minirootfs-3.21.0-x86_64.tar.gz -C rootfs
Open een chroot naar Alpine linux in de rootfs directory en stel de nameserver in. Die is voor Alpine linux in de chroot nog niet ingesteld. Je hebt geen Dockerfile in dit geval! We willen in het “image” voor de demo software installeren. DNS is dus nodig. Daarna installeren we Nginx.
chroot ./rootfs /bin/sh
echo "nameserver 1.1.1.1" >/etc/resolv.conf
apk update
apk add nginx
Bind mounts
Verlaat de chroot en kopieer de inhoud van “chroot/etc/nginx” (dat is dus /etc/nginx vanuit het perspectief van het image/de container/de chroot!). Verwijder vervolgens de inhoud van chroot/etc/nginx.
cp -R rootfs/etc/nginx .
rm -rf rootfs/etc/nginx/*
We zorgen er nu voor dat de inhoud van nginx een bind Mount krijgt op chroot/etc/nginx. Als je dan de chroot opent zie je dat /etc/nginx gevuld is terwijl de map zelf eigenlijk leeg is. De configuratiemap en de configuratiefiles worden via een bind-mount beschikbaar gemaakt in chroot/etc/nginx. Dat lijkt toch verdacht veel op hoe Kubernetes configmaps beschikbaar maakt in pods!
mount --bind nginx rootfs/nginx
Chroot met bind-mount en netwerk namespace
We kunnen nu kijken of de tot nu toe gemaakte configuratie werkt met het volgende commando:
mount --bind nginx rootfs/etc/nginx
ip netns exec ns1 chroot ./rootfs /bin/sh
- “ip a” laat de ip configuratie van de container zien!
- “ls /etc/nginx” laat de inhoud van /etc/nginx zien waarop de nginx config directory gemount is van buiten de “container”.
Namespaces
Eerder in dit verhaal had ik het al over de netwerk namespace. Namespaces zijn super belangrijk bij het draaien van containers. Ze geven het proces in de container een eigen identiteit en kijk op de wereld. Dit is niet beperkt tot enkel de netwerkconfiguratie. Hieronder staan eigenschappen van containers die alleen mogelijk zijn met het gebruik van kernel namespaces.
Geïsoleerde hostname
Als je shell opent in een container zie je dat de hostname afwijkt van de hostname van de host waarop de container draait. Dit is onmogelijk zonder de UTS (Unix Timesharing System) namespace! Met het unshare commando kan een afwijkende hostname ingesteld worden waarin we de chroot starten met de netwerk namespace:
unshare --uts --fork bash -c "hostname nginx-container && ip netns exec ns1 chroot ./rootfs /bin/sh"
Geïsoleerde process ID’s
als we in de container die we nu maken het “top” commando uitvoeren, zien we alle processen op de hele host! Dat is dus niet wat we willen! We willen dat de container enkel de processen ziet in de container zelf! We passen het bovenstaande commando aan en isoleren daarmee de process ID’s:
ip netns exec ns1 unshare --pid --uts --fork bash -c "mount -t proc proc ./rootfs/proc && hostname nginx-container && chroot ./rootfs /bin/sh"
Geïsoleerde Mounts
De container moet natuurlijk niet alle mounts zien die op de host aanwezig zijn. Dit is te isoleren door unshare de –mount optie mee te geven:
ip netns exec ns1 unshare --mount --pid --uts --fork bash -c "mount -t proc proc ./rootfs/proc && hostname nginx-container && chroot ./rootfs /bin/sh"
Geisoleerde IPC namespace
IPC ofwel: Inter Process Communication is een namespace die het mogelijk maakt om geheugen tussen processen te delen. Dat is normaal gesproken een welkome feature maar in de containerwereld willen we daar een strikte isolatie toepassen! Het nieuwe commando wordt nu:
ip netns exec ns1 unshare --ipc --user --map-root-user --mount --pid --uts --fork bash -c 'mkdir -p ./rootfs/proc && mount -t proc proc ./rootfs/proc && hostname nginx-container && chroot ./rootfs /bin/sh'
In het onderstaande screenshot is te zien dat in de normale namespace (zonder unshare) er systeembrede shared memory blocks te zien zijn. Met een geïsoleerde IPC namespace ontbreken die:

CPU en Memory limieten
Het is best practice om containers een eigen resource plafond te geven. Op die manier kan een probleem in de container, zoals een memory leak zich beperken tot de container en loopt het werkgeheugen van de host niet verder vol dan het geconfigureerde plafond. Het zelfde geldt voor CPU. Als een applicatie in een container de CPU volledig overspannen maakt, zal dit vanuit het host perspectief beperkt blijven tot de CPU cycles die aan de container zijn toegekend. Deze limieten kan je configureren met Cgroups.
In het script hieronder gebruik ik cgroups om CPU en memory limieten in te stellen. Vervolgens open ik een shell om met het nieuwe resource plafond aan de slag te gaan:
# Maak cgroup
sudo mkdir /sys/fs/cgroup/nginx-container
# Stel limieten
echo $((500*1024*1024)) | sudo tee /sys/fs/cgroup/nginx-container/memory.max
echo "25000 100000" | sudo tee /sys/fs/cgroup/nginx-container/cpu.max
# Start een nieuwe shell en voeg die toe
/bin/sh -c 'echo $$ | sudo tee /sys/fs/cgroup/nginx-container/cgroup.procs; exec /bin/sh'
Wat meteen opvalt, is dat als je je bewust moet zijn van het perspectief van de tools die je gebruikt. Als ik nu in die shell “free -m” in tik zie ik de volledige hoeveelheid systeemgeheugen:

Als je dan een fork-bomb script gebruikt, en je houdt “/sys/fs/cgroup/nginx-container/memory.current” bijvoorbeeld in de gaten, dan zul je al snel zien waar Kubernetes pod resource CPU/Memory usage vandaan haalt. In dit geval komt de OOM-killer om de hoek kijken:

Nu we weten hoe dit werkt, kunnen we het integreren in onze handgemaakte container:
# maak de nginx configuratie beschikbaar in de chroot:
mount --bind nginx rootfs/etc/nginx
# Maak cgroup
sudo mkdir /sys/fs/cgroup/nginx-container
# Stel limieten
echo $((500*1024*1024)) | sudo tee /sys/fs/cgroup/nginx-container/memory.max
echo "25000 100000" | sudo tee /sys/fs/cgroup/nginx-container/cpu.max
# Start een nieuwe shell en voeg die toe
#/bin/sh -c 'echo $$ | sudo tee /sys/fs/cgroup/nginx-container/cgroup.procs; exec /bin/sh'
/bin/sh -c 'echo $$ | sudo tee /sys/fs/cgroup/nginx-container/cgroup.procs; exec ip netns exec ns1 unshare --ipc --user --map-root-user --mount --pid --uts --fork bash -c "mkdir -p ./rootfs/proc && mount -t proc proc ./rootfs/proc && hostname nginx-container && chroot ./rootfs /bin/sh"'
De user namespace
De user namespace maakt het mogelijk om een user in de container, te “mappen” naar een user op de host. Op die manier kan bijvoorbeeld root in de container een andere user zijn op de host. Dat is wel zo veilig. Zogenaamde Rootless containers zijn een mooi streven maar nog altijd wel een dingetje. Voor veel van de eerder genoemde commando’s in dit artikel is stiekem toch nog “een stukje” root nodig. In het geval van Docker zal de user die met Docker wil werken lid moeten zijn van de docker groep.
Kijken of het werkt
met de bovenstaande stappen hebben we een basis gemaakt. Het doel is om te kijken we van buiten de container Nginx kunnen benaderen. We zorgen er voor dat nginx luistert op poort 8080 en proberen de “applicatie” te benaderen:

Tot slot
Container runtimes als Docker vormen aan de ene kant slechts een abstractielaag voor bestaande technieken. Aan de andere kant zie je in dit verhaal hoe bewerkelijk het is om 1 container op te spinnen. meer containers wordt onoverzichtelijk en op de schaal die Kubernetes kan hebben is het niet te doen.
De nginx-container die ik gemaakt heb vormt slechts de top van de ijsberg. Er is nog veel meer mogelijk en op veel andere manieren. zo zijn Root capabilities of seccomp profiles niet aan bod gekomen. Een completer verhaal zou ook een stuk groter zijn. het is niet mijn doel geweest om hier een volledig boek te schrijven.
Het is mooi om te zien dat sommige technieken al jaren bestaan en dat er “in eens” een toepassing voor gevonden wordt. Sommige mensen zouden bijna vergeten waar we vandaan komen. In the end is het nog steeds gewoon Linux.
0 reacties