
Linux commando: docker
Iemand geeft je een project en zegt "draai het gewoon in Docker". Tien minuten later heb je een container die start en meteen weer stopt, een poort die wel antwoordt op je laptop maar niet vanaf het netwerk, en een map vol bestanden van root die je niet kunt verwijderen. Dat is niet Docker die moeilijk doet. Dat is Docker die precies is wat hij is: een heel dunne schil om een handvol Linux-kernelfuncties heen, met een vriendelijke opdrachtregel eromheen. Zodra je ziet wat eronder zit, houden de verrassingen op.
1. De basis
Docker verpakt een applicatie samen met alles wat die nodig heeft om te draaien, en start het daarna als een gewoon proces op je machine dat denkt dat het de machine voor zichzelf heeft. Dat is het hele idee. De rest is detail.
1.1 De eenvoudigst mogelijke toepassing
Een commando bewijst dat de hele installatie werkt:
$ docker run hello-world
Hello from Docker!
This message shows that your installation appears to be working correctly.
To generate this message, Docker took the following steps:
1. The Docker client contacted the Docker daemon.
2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
(amd64)
3. The Docker daemon created a new container from that image which runs the
executable that produces the output you are currently reading.
4. The Docker daemon streamed that output to the Docker client, which sent it
to your terminal.
Die boodschap is het lezen waard, want hij noemt de vier dingen die elke keer gebeuren als je docker run typt, en drie daarvan zijn niet vanzelfsprekend.
1.2 Image, container, registry
Drie woorden dragen het grootste deel van de betekenis, en ze door elkaar halen is de bron van de helft van alle Docker-verwarring.
| Woord | Wat het is | Alledaagse vergelijking |
|---|---|---|
| Image | Een alleen-lezen bestandssysteem in lagen, plus wat configuratie (welk commando, welke poorten, welke omgevingsvariabelen). Het verandert nooit. | Het installatiebestand, of een class in programmeren |
| Container | Een draaiende (of gestopte) instantie van een image, met een dunne schrijfbare laag erbovenop. | Het geïnstalleerde, draaiende programma, of een object |
| Registry | Een server die images opslaat zodat je ze kunt ophalen en publiceren. Docker Hub is de standaard. | De pakketrepository |
Je kunt veel containers maken van een image, en ze beïnvloeden elkaar niet. Hier twee containers uit dezelfde alpine-image, waarvan er een een bestand aanpast:
$ docker run -d --name c1 alpine:3.20 sleep 300
$ docker run -d --name c2 alpine:3.20 sleep 300
$ docker exec c1 sh -c 'echo "changed by c1" > /etc/motd'
$ docker exec c1 cat /etc/motd
changed by c1
$ docker exec c2 cat /etc/motd
Welcome to Alpine! # untouched
$ docker run --rm alpine:3.20 cat /etc/motd
Welcome to Alpine! # the image itself never changed
Docker noemt dit copy on write. De imagelagen blijven gedeeld en alleen-lezen, en elke container krijgt zijn eigen privé-laag die alleen vastlegt wat hij veranderd heeft. Je kunt precies zien wat een container gewijzigd heeft:
$ docker diff c1
C /etc
C /etc/motd # C changed, A added, D deleted
De kernelfunctie die dit doet is een union filesystem. Docker's standaard opslagdriver heet overlay2, en die bouwt het bestandssysteem van elke container op uit OverlayFS-mappen die je direct kunt bekijken:
$ docker inspect -f '{{json .GraphDriver}}' c1
Name: overlay2
LowerDir = /var/lib/docker/overlay2/df4d83b6.../diff:... # the image layers
UpperDir = /var/lib/docker/overlay2/df4d83b6.../diff # this container
MergedDir = /var/lib/docker/overlay2/df4d83b6.../merged # what it sees
WorkDir = /var/lib/docker/overlay2/df4d83b6.../work
De alleen-lezen imagelagen zijn de lower-mappen, de privé-laag van de container is de upper, en de container ziet de merged weergave van die twee. Een container starten kopieert dus geen rootbestandssysteem, en dat is waarom het ongeveer een vijfde seconde kost in plaats van de minuut die een virtuele machine nodig heeft:
$ time docker run --rm alpine:latest true
0.17 s
In dat ontwerp zit een kostenpost verstopt, en die is het waard om te kennen voordat je hem tegenkomt. Als een container schrijft naar een bestand dat in een lagere laag staat, moet OverlayFS eerst het hele bestand omhoog kopieren naar de schrijfbare laag. Niet het gewijzigde deel: het hele bestand.
# an image carrying one 200 MB file
$ printf 'FROM alpine:latest\nRUN dd if=/dev/zero of=/data.bin bs=1M count=200\n' > Dockerfile
$ docker build -t cowdemo:1 .
$ docker run -d --name cow1 cowdemo:1 sleep 300
$ docker ps -s --format '{{.Names}} {{.Size}}'
cow1 0B (virtual 218MB) # the writable layer is empty
$ docker exec cow1 sh -c 'echo x >> /data.bin' # append two bytes
$ docker ps -s --format '{{.Names}} {{.Size}}'
cow1 210MB (virtual 427MB) # the whole file was copied up
Twee bytes kosten 210 MB. Voor broncode en configuratie maakt dat nooit uit. Voor een databasebestand, een logbestand of een grote upload maakt het enorm uit, en het is een van de concrete redenen dat data thuishoort in een volume (paragraaf 5.1) en niet in het bestandssysteem van de container zelf. Een volume is een gewone map op de host, er rechtstreeks in gemount, zonder lagen en zonder copy-up.
1.3 Een container is een proces, geen machine
Dit is het nuttigste dat je kunt begrijpen, en bijna elke beginner heeft het mis. Een container is geen kleine virtuele machine. Er is geen tweede kernel, geen bootproces, geen BIOS. Een container is een gewoon Linux-proces waartegen de kernel gevraagd is te liegen.
Start een container en kijk erin:
$ docker run -d --name demo alpine:3.20 sleep 600
$ docker exec demo ps -ef
PID USER TIME COMMAND
1 root 0:00 sleep 600
De container is ervan overtuigd dat hij proces nummer 1 is op een verder lege machine. Kijk nu naar hetzelfde proces vanaf de host:
$ docker top demo
UID PID PPID C STIME TTY TIME CMD
root 438607 438586 0 19:42 ? 00:00:00 sleep 600
$ ps -ef | grep "sleep 600"
root 438607 438586 0 19:42 ? 00:00:00 sleep 600
Het is gewoon een proces, met een volstrekt normaal proces-ID, zichtbaar in ps net als al het andere. En het draait op jouw kernel, niet op een eigen kernel:
$ uname -r
6.11.0-29-generic
$ docker exec demo uname -r
6.11.0-29-generic # the same kernel, because there is only one
Twee kernelfuncties doen al het werk. Namespaces geven het proces een privé-beeld van een deel van het systeem: een eigen processenlijst, eigen netwerkinterfaces, een eigen hostnaam, een eigen mounttabel. Control groups (cgroups) leggen een plafond op hoeveel CPU, geheugen en I/O het mag gebruiken. Je kunt de namespaces bekijken waar een container in gezet is:
$ docker run --rm alpine:3.20 ls -l /proc/self/ns/
pid -> pid:[4026533329] # private
net -> net:[4026533332] # private
mnt -> mnt:[4026532489] # private
uts -> uts:[4026532490] # private (this is the hostname)
user -> user:[4026531837] # NOT private: same as the host
$ ls -l /proc/self/ns/user
user -> user:[4026531837] # identical number
Let op de laatste. Standaard geeft Docker een container geen eigen user namespace, wat betekent dat root in de container dezelfde root is die de host kent. Paragraaf 7.4 laat zien wat je dat kost, en hoe userns-remap dat gat dicht.
Het juiste mentale model: een container is een proces, afgeschermd door namespaces en begrensd door een cgroup. Docker heeft dat niet uitgevonden. Docker maakte er een commando van een regel van.
1.4 De client en de daemon
De docker die je typt is alleen een client. Hij doet zelf niets. Hij stuurt HTTP-verzoeken over een Unix-socket naar een achtergronddienst, dockerd, die elke image, container, volume en netwerk op de machine bezit.
$ ls -l /var/run/docker.sock
srw-rw---- 1 root docker 0 Aug 21 09:48 /var/run/docker.sock
Je kunt er ook zelf mee praten, helemaal zonder de Docker-CLI:
$ curl -s --unix-socket /var/run/docker.sock http://localhost/version
{"Platform":{"Name":"Docker Engine - Community"},"Components":[{"Name":"Engine",
"Version":"28.1.1","Details":{"ApiVersion":"1.49","Arch":"amd64", ...
Uit die ene socket volgen twee gevolgen, en beide zijn belangrijk. Ten eerste: "Docker start traag" betekent meestal dat de daemon iets doet, niet de CLI. Ten tweede, en veel belangrijker: die socket is eigendom van root en de groep docker. Iedereen in die groep kan de daemon vragen alles te doen wat root kan. Paragraaf 7.4 laat het zien.
De daemon is ook niet het einde van de keten. Modern Docker bestaat uit vier programma's, en je kunt de overdracht zien door de bovenliggende processen van een container te volgen:
$ docker inspect -f '{{.State.Pid}}' demo
455660
$ ps -o pid,ppid,comm -p 455660
PID PPID COMMAND
455660 455639 sleep
$ ps -o pid,comm,args -p 455639
455639 containerd-shim /usr/bin/containerd-shim-runc-v2 -namespace moby -id e18f2f...
Elk onderdeel heeft een taak:
| Onderdeel | Taak |
|---|---|
docker |
De CLI. Zet je commando om in een HTTP-verzoek en drukt het antwoord af. |
dockerd |
Images, volumes, netwerken, builds, de API. Het Docker-specifieke deel. |
containerd |
De levenscyclus van containers en de imageopslag. Een algemene runtime, niet alleen voor Docker. |
containerd-shim-runc-v2 |
Een per container. Houdt de uitvoer en de afsluitcode vast. |
runc |
Maakt de namespaces en de cgroup aan, en voert daarna jouw programma uit en stopt zelf. |
Het detail dat opvalt is wat er niet in die processenboom staat. Er is geen runc en geen dockerd boven je container, want runc maakt zijn werk af en vertrekt, en de shim is een kind van init in plaats van van de daemon:
$ ps -o pid,ppid,comm -C dockerd -C containerd
PID PPID COMMAND
3249 1 containerd # both are ordinary services,
3680 1 dockerd # started by systemd, side by side
Die opzet maakt het leven van een container onafhankelijk van dat van de daemon: omdat de shim het proces bezit en onder init valt, kan dockerd weggaan en terugkomen zonder dat de container het hoeft te merken. Het is ook waarom Kubernetes dockershim kon laten vallen en rechtstreeks met containerd kon praten (paragraaf 3) zonder een enkele image te wijzigen. Docker is de bovenste twee blokken van een stapel van vijf, en de onderste drie deelt hij met iedereen.
Of containers een herstart van de daemon echt overleven is een aparte instelling, en de standaard verrast mensen:
$ docker info | grep "Live Restore"
Live Restore Enabled: false # containers stop when dockerd stops
Zet "live-restore": true in /etc/docker/daemon.json op een server, en een daemonupdate is geen storing meer.
2. Waar komt de naam vandaan?
Een docker is een havenarbeider: degene op de kade die schepen laadt en lost. De naam is gekozen om de metafoor waar de hele industrie nu op draait.
Voor de jaren vijftig werd lading stuk voor stuk geladen, en elk schip, elke trein en elke vrachtwagen had zijn eigen afhandeling nodig. De intermodale zeecontainer loste dat op door de doos te standaardiseren in plaats van de inhoud. Niemand in de haven hoeft te weten of er bananen of fietsen in een container zitten. De kraan, het schip en de vrachtwagen behandelen allemaal dezelfde standaarddoos op dezelfde standaardmanier.
Software had hetzelfde probleem. Een Python-applicatie, een PHP-applicatie en een Go-binary hadden elk hun eigen uitrolprocedure nodig, hun eigen afhankelijkheden, hun eigen "het werkt op mijn machine"-discussie. Een container-image standaardiseert de doos: wat er ook in zit, de runtime start het op dezelfde manier.
De walvis in het logo heet Moby Dock, een woordgrap op Moby Dick van Herman Melville, en de community koos die naam in een stemming in 2013. In 2017 verplaatste Docker de opensource-engine naar een project met de naam Moby, en hield "Docker" voor de producten die het bedrijf verkoopt. Daarom staat de broncode op GitHub onder moby/moby, en heet de build-engine moby/buildkit.
De rest van het vocabulaire kun je het beste in een keer leren, want elke foutmelding gebruikt het:
image a read-only stack of filesystem layers plus configuration
layer one filesystem change set; images are made of these, stacked
container a process started from an image, with a writable layer on top
registry a server that stores images (Docker Hub is the default)
repository one named collection of images in a registry, e.g. "nginx"
tag a label on one image inside a repository, e.g. "1.27-alpine"
digest the sha256 hash of an image; unlike a tag, it never moves
volume Docker-managed storage that outlives the container
bind mount a host directory pushed into the container at a chosen path
Dockerfile the recipe used to build an image
context the directory sent to the builder when you run docker build
ENTRYPOINT the program a container runs
CMD the default arguments (or the default program, if no ENTRYPOINT)
Twee daarvan zijn nu al het benoemen waard. Een tag is geen versie, het is een verschuifbaar label, en paragraaf 7 laat zien hoe dat bijt. Een volume is geen bind mount, en paragraaf 5.1 laat zien waarom ze zich anders gedragen op een manier die iedereen precies een keer overkomt.
Naar boven3. Een korte geschiedenis
Docker heeft containers niet uitgevonden. Unix had chroot in 1979, FreeBSD had jails in 2000, Solaris had Zones in 2005, en Linux had LXC vanaf 2008, gebouwd op de namespaces en cgroups die al jaren in de kernel landden. Alle onderdelen bestonden. Wat niet bestond, was een manier voor een gewone ontwikkelaar om ze te gebruiken zonder kernelexpert te worden.
Docker begon als intern gereedschap bij dotCloud, een platform-as-a-service-bedrijf. In maart 2013 werd het voor het eerst publiek getoond op PyCon in Santa Clara en als opensource uitgebracht. Het was geen nieuwe mogelijkheid. Het was een pakketformaat, een registry en een opdrachtregel om mogelijkheden heen die Linux al had, en dat bleek het ontbrekende stuk.
| Tijdvak | Mijlpaal |
|---|---|
| 1979 | chroot verschijnt in Unix: verander het idee dat een proces heeft van /. De eerste voorvader. |
| 2000 tot 2008 | FreeBSD jails, Solaris Zones, daarna Linux namespaces en cgroups, en LXC daarbovenop. |
| Maart 2013 | Docker wordt getoond op PyCon en opensource gemaakt. dotCloud hernoemt zichzelf later naar Docker. |
| Maart 2014 | Docker 0.9 laat LXC als standaard los en levert een eigen libcontainer, geschreven in Go. |
| Juni 2014 | Docker 1.0. Container-images duiken overal in productie op. |
| Juni 2015 | Het Open Container Initiative ontstaat bij de Linux Foundation. Docker doneert de runtimecode die runc wordt, en het imageformaat wordt een openbare standaard. |
| 2016 tot 2017 | containerd wordt afgesplitst en aan de CNCF gedoneerd. De opensource-engine wordt hernoemd tot het Moby-project. |
| 2019 | Mirantis koopt Docker Enterprise. Docker Inc. houdt Docker Desktop en Docker Hub. |
| Mei 2022 | Kubernetes 1.24 verwijdert dockershim. Kubernetes praat nu rechtstreeks met containerd. Je images werken gewoon door; het waren altijd al OCI-images. |
| Februari 2023 | Docker Engine 23.0 maakt BuildKit de standaardbuilder op Linux, waardoor docker build stilletjes een veel beter programma werd. |
| Juli 2023 | Compose V1 (het Python-programma docker-compose) bereikt het einde van zijn leven. docker compose, een Go-plugin, vervangt het. |
Twee regels in die tabel verklaren verwarring die je in 2026 nog steeds tegenkomt. Die over Kubernetes leverde een golf koppen op in de trant van "Kubernetes laat Docker vallen", wat mensen onnodig bang maakte: wat losgelaten werd was een vertaallaag, niet het imageformaat, en elke met Docker gebouwde image draait nog steeds. Die over Compose is waarom de helft van de tutorials op internet docker-compose up met een streepje schrijft en de andere helft docker compose up met een spatie. De spatie is de huidige.
Naar bovenDe echte prestatie van Docker was niet technisch, maar standaardisatie. Dankzij de OCI draait een image die je vandaag bouwt onder Docker, Podman, containerd, CRI-O en Kubernetes zonder aanpassingen. De doos is standaard, wat je er ook in stopt.
4. Eenvoudige toepassingen
4.1 Iets draaien en weggooien
De meest onderbenutte Docker-functie is een programma draaien dat je niet geïnstalleerd hebt. Twee opties maken dat prettig:
| Optie | Afkorting van | Wat het doet |
|---|---|---|
--rm |
remove | Verwijder de container zodra hij stopt |
-i |
interactive | Houd standaardinvoer open, zodat je erin kunt typen |
-t |
tty | Geef hem een terminal, zodat prompts en kleuren werken |
$ docker run --rm -it alpine:3.20 sh
/ # cat /etc/alpine-release
3.20.10
/ # exit # container is gone, nothing left behind
Je wilt bijna altijd -it samen, en daarom typen mensen -it zonder erover na te denken. Gebruik ze als je verwacht te communiceren; laat ze weg in scripts en cronjobs, waar geen terminal is om aan te koppelen.
Dit is een echt nuttige gewoonte. Wil je een DNS-vraag testen met een programma dat je niet geïnstalleerd hebt? Heb je tien seconden PHP 7.4 nodig om iets te controleren? Draai het, gebruik het, en het verdwijnt:
$ docker run --rm alpine:3.20 nslookup petermartin.nl
$ docker run --rm -v "$PWD:/app" -w /app php:7.4-cli php -l index.php
4.2 Iets op de achtergrond draaien
Voor alles wat moet blijven draaien gebruik je -d (afkorting van detached) en geef je het een naam:
$ docker run -d --name web -p 8080:80 nginx:latest
c4f1d0a9b0e2f3a7c1d8e9b2a4f6c8d0e1a3b5c7d9e1f3a5b7c9d1e3f5a7b9c1
Die lange tekenreeks is de container-ID. Je hebt hem bijna nooit nodig, omdat je de container een naam gaf. Geef je containers altijd een naam. Docker verzint er anders zelf een, en hoewel elegant_hopper de eerste keer charmant is, heb je er in een script niets aan.
4.3 Zien wat er draait
$ docker ps # running containers only
CONTAINER ID IMAGE STATUS PORTS NAMES
c4f1d0a9b0e2 nginx:latest Up 2 minutes 0.0.0.0:8080->80/tcp web
$ docker ps -a # -a for all: includes stopped ones
$ docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
Draai docker ps -a op een machine die al een tijdje in gebruik is en het resultaat is vaak een schok:
$ docker ps -a --format '{{.Status}}' | grep -c Exited
121
Die 121 gestopte containers draaien niet, maar elk bezit nog steeds zijn schrijfbare laag op schijf. Paragraaf 6.9 gaat daarover.
4.4 De logs lezen
docker logs laat zien wat het hoofdproces van de container naar standaarduitvoer en standaardfout schreef:
$ docker logs web # everything so far
$ docker logs -f web # -f for follow, like tail -f
$ docker logs --tail 50 web # the last 50 lines
$ docker logs --since 10m web # only the last ten minutes
$ docker logs -t web # -t adds timestamps
De belangrijke beperking is makkelijk te missen. Docker vangt alleen de standaarduitvoer en standaardfout van PID 1 op. Alles wat de applicatie naar een logbestand in de container schrijft, is onzichtbaar voor docker logs:
$ docker run -d --name l1 alpine:3.20 sh -c \
'echo to-stdout; echo to-stderr >&2; echo to-file > /tmp/f.log; sleep 60'
$ docker logs l1
to-stdout
to-stderr # both streams appear
$ docker exec l1 cat /tmp/f.log
to-file # this never reaches docker logs
Daarom loggen nette container-images naar standaarduitvoer in plaats van naar een bestand. Als je een applicatie in een container zet die per se /var/log/app.log wil schrijven, is de gebruikelijke truc om dat bestand te symlinken naar /dev/stdout. De officiele nginx-image doet precies dat.
4.5 Een shell in een draaiende container
docker exec start een extra proces in een container die al draait:
$ docker exec -it web bash # a shell, for poking around
$ docker exec web nginx -t # one command, no terminal needed
$ docker exec -u root -it web sh # as root, if the image runs as someone else
Twee dingen gaan hier regelmatig mis. Als de image op Alpine gebaseerd is, is er geen bash, alleen sh. En exec werkt alleen op een draaiende container: als de container steeds stopt, is er niets om in te exec-en, en heb je docker logs nodig. Om een kapotte image te bekijken zonder het normale commando te starten, overschrijf je dat:
$ docker run --rm -it --entrypoint sh myimage:latest
4.6 Stoppen, starten, verwijderen
$ docker stop web # polite: SIGTERM, then SIGKILL after 10s
$ docker start web # start it again, writable layer intact
$ docker restart web
$ docker kill web # immediate SIGKILL, no grace period
$ docker rm web # delete the container (must be stopped)
$ docker rm -f web # stop and delete in one step
Houd stop en rm uit elkaar in je hoofd. stop pauzeert het verhaal: de schrijfbare laag van de container is er nog en docker start pakt de draad op. rm beeindigt het: de schrijfbare laag wordt verwijderd en alles wat de container buiten een volume schreef is voorgoed weg.
De tien seconden in die eerste regel zijn niet willekeurig, en paragraaf 7.2 legt uit waarom bijna elke container die je stopt de volle tien nodig heeft.
4.7 Een poort publiceren
Het netwerk van een container is privé. Een webserver die binnen een container op poort 80 luistert, is niet bereikbaar vanuit je browser tot je de poort publiceert met -p:
$ docker run -d --name web -p 8080:80 nginx:latest
^^^^ ^^
host container
De volgorde is eerst de host, dan de container, en die omdraaien is een inwijdingsrite. Controleer wat er echt gebeurd is:
$ docker ps --format 'table {{.Names}}\t{{.Ports}}'
NAMES PORTS
web 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/
200
Kijk goed naar 0.0.0.0. Dat betekent elke netwerkinterface, dus iedereen die je machine kan bereiken, kan die container bereiken. Op een laptop op een gedeeld netwerk, of op een server met een publiek IP-adres, is dat meestal niet wat je bedoelde. Je kunt in plaats daarvan aan een adres binden:
$ docker run -d --name web -p 127.0.0.1:8080:80 nginx:latest
$ docker ps --format 'table {{.Names}}\t{{.Ports}}'
NAMES PORTS
web 127.0.0.1:8080->80/tcp # localhost only
$ ss -tln | grep 8080
LISTEN 0 4096 127.0.0.1:8080 0.0.0.0:*
Maak 127.0.0.1: je standaard voor alles waar een reverse proxy voor komt te staan. Paragraaf 7.7 legt uit waarom dit meer uitmaakt dan je zou verwachten.
5. Gemiddelde toepassingen
5.1 Data bewaren: volumes en bind mounts
Alles wat een container naar zijn eigen bestandssysteem schrijft, verdwijnt zodra je docker rm doet. Voor een database is dat een ramp die op een dinsdag ligt te wachten. Docker biedt twee manieren om data te bewaren, en die zijn niet uitwisselbaar.
| Named volume | Bind mount | |
|---|---|---|
| Syntaxis | -v mydata:/var/lib/mysql |
-v /home/peter/site:/var/www |
| Staat | In /var/lib/docker/volumes/, beheerd door Docker |
Waar jij het aanwijst, beheerd door jou |
| Overdraagbaar | Ja, hetzelfde op elke host | Nee, het hostpad moet bestaan |
| Het best voor | Databases, uploads, alles wat de applicatie bezit | Broncode tijdens ontwikkeling, configuratiebestanden |
Er is een verschil in gedrag dat iedereen precies een keer overkomt. Een leeg named volume wordt vooraf gevuld vanuit de image. Een bind mount verbergt wat de image op dat pad had staan:
$ docker volume create demo-vol
$ docker run --rm -v demo-vol:/etc alpine:3.20 sh -c 'ls /etc | wc -l'
36 # the image's /etc was copied in
$ mkdir /tmp/empty
$ docker run --rm -v /tmp/empty:/etc alpine:3.20 sh -c 'ls /etc | wc -l'
3 # the image's /etc is hidden
Dat is het mechanisme achter "ik heb mijn code gemount en nu kan de container zijn afhankelijkheden niet vinden". Je hebt je project over een map heen gemount die de image tijdens de build al gevuld had, en het werk van de build is er nog steeds, alleen afgedekt.
De tweede verrassing is eigendom. Een container draait standaard als root, dus de bestanden die hij op een bind mount aanmaakt zijn van root op jouw host:
$ docker run --rm -v /tmp/uid:/data alpine:3.20 touch /data/from-container
$ ls -ln /tmp/uid/
-rw-r--r-- 1 0 0 0 Aug 23 19:43 from-container # owned by UID 0
$ id -u
1000 # but you are UID 1000
De oplossing is Docker vertellen welke gebruiker hij moet zijn:
$ docker run --rm --user "$(id -u):$(id -g)" -v /tmp/uid:/data \
alpine:3.20 touch /data/from-me
$ ls -ln /tmp/uid/
-rw-r--r-- 1 1000 1000 0 Aug 23 19:48 from-me # yours
Nuttige volumecommando's:
$ docker volume ls
$ docker volume inspect mydata # where it lives on disk
$ docker volume rm mydata
$ docker run --rm -v mydata:/data -v "$PWD:/backup" alpine:3.20 \
tar czf /backup/mydata.tar.gz -C /data . # back a volume up
5.2 Netwerken en naamsherleiding
Containers op hetzelfde Docker-netwerk kunnen elkaar op naam bereiken, en zo vindt een webcontainer zijn database zonder dat iemand een IP-adres kent. Maar dit werkt alleen op een zelfgemaakt netwerk, niet op de standaardbridge die een kale docker run gebruikt:
$ docker run -d --name n1 alpine:3.20 sleep 300
$ docker run --rm alpine:3.20 ping -c1 n1
ping: bad address 'n1' # default bridge: no DNS
$ docker network create mynet
$ docker run -d --name n2 --network mynet alpine:3.20 sleep 300
$ docker run --rm --network mynet alpine:3.20 ping -c1 n2
64 bytes from 172.19.0.2: seq=0 ttl=64 time=0.054 ms # works
De herleiding wordt gedaan door een kleine DNS-server die Docker in elke container op een zelfgemaakt netwerk draait:
$ docker run --rm --network mynet alpine:3.20 cat /etc/resolv.conf
nameserver 127.0.0.11 # Docker's embedded resolver
search .
options edns0 trust-ad ndots:0
Dit is een van de sterkste redenen om Compose te gebruiken (paragraaf 5.7): het maakt een netwerk voor je, zodat servicenamen gewoon werken.
Nog twee dingen om te weten over containernetwerken:
localhostin een container is de container zelf. Een applicatie die is ingesteld om een database op127.0.0.1:3306te bereiken, faalt in een container, omdat de database een andere container is. Gebruik de servicenaam.- Om de host vanuit een container te bereiken op Linux voeg je
--add-host=host.docker.internal:host-gatewaytoe, en verbind je daarna methost.docker.internal. Op Docker Desktop bestaat die naam al.
5.3 Omgevingsvariabelen
Configuratie gaat naar binnen via de omgeving, en zo kan dezelfde image zowel ontwikkeling als productie bedienen:
$ docker run -d -e MYSQL_ROOT_PASSWORD=secret mariadb:10.6
$ docker run -d --env-file ./.env myapp:1.0 # read many at once
$ docker exec web printenv | sort # see what a container got
Twee waarschuwingen. Alles wat je met -e meegeeft is zichtbaar in docker inspect voor iedereen die met de daemon kan praten, dus het is geen kluis voor geheimen. En alles wat je met ENV in een Dockerfile inbakt, zit permanent in de image, voor iedereen die hem ophaalt. Paragraaf 7.5 laat zien hoe zichtbaar dat werkelijk is.
5.4 Een Dockerfile schrijven
Een Dockerfile is een lijst instructies. Elke instructie levert een laag op, en het resultaat is een image.
FROM php:8.3-fpm-alpine # the base image to start from
WORKDIR /var/www/html # default directory for later steps
RUN apk add --no-cache icu-dev \
&& docker-php-ext-install intl pdo_mysql
COPY composer.json composer.lock ./ # dependencies first (see 6.1)
RUN composer install --no-dev --optimize-autoloader
COPY . . # then the application code
USER www-data # stop being root (see 6.4)
EXPOSE 9000 # documentation, not a firewall rule
CMD ["php-fpm"]
De instructies die je bijna elke keer gebruikt:
| Instructie | Wat het doet |
|---|---|
FROM |
De basisimage. Elke Dockerfile begint hier. |
RUN |
Voer een commando uit tijdens het bouwen, en bewaar het resultaat als laag. |
COPY |
Kopieer bestanden uit de buildcontext naar de image. |
ADD |
Als COPY, maar pakt ook archieven uit en haalt URL's op. Gebruik liever COPY. |
WORKDIR |
Stel de werkmap in voor de instructies die volgen. |
ENV |
Zet een omgevingsvariabele, permanent, in de image. |
ARG |
Een variabele die alleen tijdens de build beschikbaar is. |
USER |
Schakel over naar een andere gebruiker voor de rest van de build en voor runtime. |
EXPOSE |
Verklaar een poort. Alleen documentatie; het publiceert niets. |
ENTRYPOINT |
Het programma dat de container draait. |
CMD |
Standaardargumenten voor ENTRYPOINT, of het standaardcommando als die er niet is. |
EXPOSE verdient een aparte vermelding omdat het mensen teleurstelt. Het opent geen poort. Het legt in de imagemetadata vast op welke poort de applicatie luistert, zodat mensen en gereedschappen dat kunnen lezen. Alleen -p tijdens het draaien publiceert daadwerkelijk iets.
ENTRYPOINT en CMD samen zijn de andere veelvoorkomende verwarring. De regel: ENTRYPOINT is het programma, CMD zijn de standaardargumenten, en alles wat je na de imagenaam op de opdrachtregel typt vervangt CMD.
ENTRYPOINT ["ping"]
CMD ["-c", "3", "localhost"]
$ docker run myping # runs: ping -c 3 localhost
$ docker run myping -c 1 8.8.8.8 # runs: ping -c 1 8.8.8.8
Beide accepteren twee schrijfwijzen, en het verschil is echt. De exec-vorm CMD ["ping", "localhost"] draait het programma rechtstreeks. De shell-vorm CMD ping localhost verpakt het in /bin/sh -c, waardoor shellfuncties werken maar de shell onderdeel van het plaatje wordt:
# two images whose CMD differs only in its form
$ cat Dockerfile.shell $ cat Dockerfile.exec
FROM alpine:3.20 FROM alpine:3.20
CMD echo "hello $NAME" CMD ["echo","hello $NAME"]
$ docker run --rm -e NAME=peter demo:shell
hello peter # a shell ran it, so $NAME expanded
$ docker run --rm -e NAME=peter demo:exec
hello $NAME # no shell involved, nothing expanded it
Gebruik standaard de exec-vorm. Gebruik de shell-vorm alleen als je echt een pipe, een omleiding of een variabele nodig hebt, en lees paragraaf 7.2 voordat je dat doet.
5.5 Bouwen en taggen
$ docker build -t myapp:1.0 . # -t for tag; the . is the build context
$ docker build -t myapp:1.0 -t myapp:latest .
$ docker build -f docker/Dockerfile.prod -t myapp:prod .
$ docker build --no-cache -t myapp:1.0 . # ignore the cache entirely
Die laatste . betekent niet "hier", het is de buildcontext: de map die wordt ingepakt en naar de build-engine gestuurd. Paragraaf 6.2 legt uit waarom dat veel uitmaakt.
Tag met echte versienummers, niet alleen met latest. Een tag is een verschuifbaar label, dus myapp:latest betekent "wat ik het laatst gepubliceerd heb", en dat is precies de informatie die je nodig hebt als een uitrol misgaat en precies de informatie die een verschuivende tag niet kan geven.
5.6 Herstartbeleid
Containers stoppen. Applicaties crashen, de daemon herstart, de server gaat opnieuw op. Een herstartbeleid vertelt Docker wat hij daarmee moet:
$ docker run -d --restart unless-stopped --name web nginx:latest
| Beleid | Gedrag |
|---|---|
no |
De standaard. Nooit herstarten. |
on-failure[:n] |
Alleen herstarten bij een afsluitcode die niet nul is, hoogstens n keer. |
always |
Altijd herstarten, ook na een herstart van de daemon, zelfs als je hem met de hand gestopt had. |
unless-stopped |
Als always, maar als je hem bewust gestopt hebt, blijft hij gestopt. |
unless-stopped is de juiste standaard voor een dienst. Het overleeft herstarts, en het respecteert je beslissing als je iets met opzet stopt. Docker gebruikt een oplopende wachttijd tussen pogingen, zodat een container die bij het starten crasht de CPU niet laat rondtollen:
$ docker inspect -f 'restarts={{.RestartCount}} status={{.State.Status}}' r1
restarts=3 status=running
5.7 docker compose
Zodra je drie containers hebt, wordt docker run met vijftien opties per stuk typen een geheugentest. Compose zet de hele set in een bestand:
services:
web:
image: nginx:1.27-alpine
ports:
- "127.0.0.1:8080:80"
volumes:
- ./public_html:/usr/share/nginx/html:ro
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: mariadb:10.6
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: joomla
volumes:
- dbdata:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
dbdata:
$ docker compose up -d # build if needed, create, start
$ docker compose ps
$ docker compose logs -f web # logs of one service
$ docker compose exec web sh # a shell in one service
$ docker compose down # stop and remove containers and network
$ docker compose down -v # ... and delete the named volumes too
Compose noemt alles naar het project, en dat is de mapnaam tenzij je het overschrijft met -p:
$ docker compose up -d
$ docker compose ps
NAME IMAGE SERVICE STATUS
comp-cache-1 alpine:3.20 cache Up 8 seconds
comp-web-1 nginx:latest web Up 8 seconds (healthy)
$ docker network ls | grep comp
6d7ac0548fc6 comp_default bridge local
Let op de twee voordelen. De containers delen een netwerk dat comp_default heet, dus web en cache kunnen elkaar op servicenaam bereiken zonder enige configuratie. En docker compose ps rapporteert gezondheid, wat een kale docker ps ook doet, maar wat vrijwel niemand buiten Compose instelt.
Het ene detail om te onthouden: docker compose down verwijdert containers en netwerken maar houdt named volumes. Alleen down -v verwijdert ze:
$ docker compose down
$ docker volume ls | grep comp
local comp_dbdata # your database survived
$ docker compose down -v
Volume comp_dbdata Removed # your database did not
Die standaard is bewust gekozen en het is de juiste. Het betekent ook dat down -v een commando is om langzaam te typen.
6. Gevorderde toepassingen
6.1 Lagen en de buildcache
Elke RUN, COPY en ADD in een Dockerfile maakt een laag, en docker history laat je de stapel zien:
$ docker history myapp:1
IMAGE CREATED BY SIZE
a07370c4d5e3 CMD ["cat" "/app/app.txt"] 0B
<missing> COPY app.txt /app/app.txt # buildkit 10B
<missing> RUN /bin/sh -c apk add --no-cache curl 5.41MB
<missing> CMD ["/bin/sh"] 0B
<missing> ADD alpine-minirootfs-3.20.10-x86_64.tar.gz / 7.81MB
Docker hergebruikt een laag als de instructie en de invoer daarvan niet veranderd zijn. Zodra een laag verandert, moet elke laag daarna opnieuw gebouwd worden. Die ene regel bepaalt de volgorde van je Dockerfile:
$ echo "version 2" > app.txt # change one file
$ docker build -t myapp:2 .
=> [1/3] FROM docker.io/library/alpine:3.20 DONE
=> [2/3] RUN apk add --no-cache curl CACHED
=> [3/3] COPY app.txt /app/app.txt DONE
De dure apk add kwam uit de cache, omdat er niets boven veranderd was. Stel je nu voor dat de COPY eerst kwam: de pakketinstallatie zou bij elke codewijziging opnieuw draaien. Daarom kopieert de Dockerfile in paragraaf 5.4 eerst composer.json en composer.lock apart, installeert de afhankelijkheden, en kopieert pas daarna de applicatie. Afhankelijkheden veranderen zelden, code voortdurend, dus de trage stap hoort boven de snelle.
De tweede laagregel is degene die raadselachtig enorme images oplevert. Een latere laag kan een eerdere laag niet kleiner maken. Een bestand verwijderen legt alleen een verwijdering vast in de nieuwe laag; de bytes staan nog in de laag eronder:
# Dockerfile.bad: two instructions, two layers
FROM alpine:3.20
RUN dd if=/dev/zero of=/big.bin bs=1M count=100
RUN rm /big.bin
# Dockerfile.good: one instruction, one layer
FROM alpine:3.20
RUN dd if=/dev/zero of=/big.bin bs=1M count=100 && rm /big.bin
$ docker images
layerdemo:bad 113MB
layerdemo:good 7.81MB # same result, no /big.bin in either
$ docker history layerdemo:bad --format 'table {{.CreatedBy}}\t{{.Size}}'
RUN /bin/sh -c rm /big.bin 0B <- the deletion
RUN /bin/sh -c dd if=/dev/zero of=/big.bin ... 105MB <- still there
Beide images hebben geen /big.bin. Een ervan is vijftien keer groter. Keten het opruimen in dezelfde RUN met &&, en gebruik apt-get clean && rm -rf /var/lib/apt/lists/* of apk add --no-cache in dezelfde instructie die de pakketten installeerde.
6.2 De buildcontext en .dockerignore
Als je docker build . draait, is die punt een map die wordt ingepakt en aan de build-engine gegeven voordat de Dockerfile ook maar gelezen is. Als je project node_modules, een .git-geschiedenis en een map met klantfoto's bevat, wordt dat allemaal meegestuurd, elke keer.
Een .dockerignore-bestand, met dezelfde syntaxis als .gitignore, houdt het buiten:
.git
node_modules
vendor
*.log
*.sql
.env
tmp/
cache/
Het effect is direct, en het geldt ook voor de image, want COPY . /app kan alleen kopieren wat verstuurd is:
$ docker images
ctxdemo:a 91.7MB # no .dockerignore
ctxdemo:b 7.81MB # with node_modules excluded
Zet .env op de eerste dag in dat bestand. Een hele projectmap in een image kopieren is de meest voorkomende manier waarop inloggegevens in een registry gepubliceerd worden.
6.3 Multi-stage builds
De meeste applicaties hebben de ene set gereedschappen nodig om te bouwen en een compleet andere, veel kleinere set om te draaien. Een compiler, een pakketbeheerder en een testsuite horen niet thuis in een productie-image. Met multi-stage builds kun je ze gebruiken en daarna achterlaten:
FROM alpine:3.20 AS build # stage 1: everything you need to build
RUN apk add --no-cache build-base
COPY hello.c /src/hello.c
RUN gcc -static -o /hello /src/hello.c
FROM alpine:3.20 # stage 2: a clean image
COPY --from=build /hello /usr/local/bin/hello
CMD ["hello"]
Alleen de laatste stage wordt de image. Alles in build wordt weggegooid zodra de uitvoer eruit gekopieerd is:
$ docker images
multidemo:single 230MB # compiler included
multidemo:multi 7.88MB # just the binary
$ docker run --rm multidemo:multi
hello
Hetzelfde patroon werkt voor elke stack: node om de bundler te draaien en nginx om het resultaat te serveren, composer om afhankelijkheden op te lossen en php-fpm om ze te draaien, golang om te compileren en scratch om de binary te bewaren. Kleinere images halen sneller binnen, starten sneller, en bevatten veel minder waarin een scanner een CVE kan vinden.
6.4 Draaien als niet-rootgebruiker
Containers draaien als root tenzij je iets anders zegt. Omdat er standaard geen user namespace is, is die root de root van de host met een hoedje op.
Los het op in de image, zodat niemand een optie hoeft te onthouden:
FROM node:22-alpine
WORKDIR /app
COPY --chown=node:node . .
RUN npm ci --omit=dev
USER node # everything after this runs as node
CMD ["node", "server.js"]
Of tijdens het draaien, wat de juiste aanpak is voor een ontwikkelopstelling met een bind mount:
$ docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/app" -w /app \
node:22-alpine npm install
Voeg de andere standaard hardeningsopties toe zodra je je er prettig bij voelt:
$ docker run -d \
--user 1000:1000 \
--read-only \ # the container filesystem is immutable
--tmpfs /tmp \ # ... except for a RAM-backed /tmp
--cap-drop ALL \ # remove every Linux capability
--security-opt no-new-privileges \
myapp:1.0
Voor een echt rootloze opstelling kijk je naar Docker's rootless mode, waarbij de daemon zelf als een gewone gebruiker draait, of naar Podman, dat van nature zonder daemon en zonder root werkt.
6.5 Capabilities, seccomp en de valdeur --privileged
Paragraaf 6.4 gebruikte --cap-drop ALL en --security-opt zonder te zeggen waarop die inwerken. Er zijn drie aparte kernelmechanismen bij betrokken, en Docker past er al twee voor je toe.
Capabilities splitsen het oude alles-of-niets-rootrecht op in losse bevoegdheden: een lage poort openen, bestandseigendom wijzigen, een kernelmodule laden, enzovoort. Root in een container is geen volledige root, want Docker houdt er maar veertien van de eenenveertig over:
$ docker run --rm alpine:latest grep CapEff /proc/self/status
CapEff: 00000000a80425fb # 14 capabilities
$ docker run --rm alpine:latest sh -c 'apk add -q libcap; capsh --print | head -1'
Current: cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,
cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,
cap_mknod,cap_audit_write,cap_setfcap=ep
$ docker run --rm --cap-drop ALL alpine:latest grep CapEff /proc/self/status
CapEff: 0000000000000000 # none at all
Seccomp filtert welke systeemaanroepen het proces uberhaupt mag doen. Docker past een standaardprofiel toe dat de gevaarlijke blokkeert, en je kunt zien dat het filter actief is:
$ docker run --rm alpine:latest grep Seccomp: /proc/self/status
Seccomp: 2 # 2 = a filter is loaded
$ docker run --rm alpine:latest mount -t tmpfs none /mnt
mount: permission denied (are you root?) # it IS root; the filter said no
AppArmor (of SELinux op Red Hat-systemen) legt daar bovenop een verplicht toegangsprofiel, dat beperkt welke bestanden en interfaces het proces mag aanraken, ongeacht zijn gebruikers-ID:
$ docker run --rm alpine:latest cat /proc/self/attr/current
docker-default (enforce)
Alle drie staan standaard aan, en dat is voor een groot deel waarom een container veiliger is dan dezelfde binary als root op de host draaien. Kijk nu wat een enkele optie ermee doet:
$ docker run --rm --privileged alpine:latest grep CapEff /proc/self/status
CapEff: 000001ffffffffff # all 41 capabilities
$ docker run --rm --privileged alpine:latest grep Seccomp: /proc/self/status
Seccomp: 0 # the filter is GONE
$ docker run --rm --privileged alpine:latest sh -c 'mount -t tmpfs none /mnt && echo OK'
OK # the call that was refused now works
--privileged geeft niet simpelweg "wat extra rechten". Het geeft elke capability terug en zet seccomp stilletjes uit, en het stelt de apparaten van de host bloot:
$ docker run --rm alpine:latest sh -c 'ls /dev | wc -l'
15 # a safe, minimal set
$ docker run --rm --privileged alpine:latest sh -c 'ls /dev | wc -l'
293
$ docker run --rm --privileged alpine:latest ls /dev/ | grep nvme
nvme0
nvme0n1
nvme0n1p1 # the host's raw disk partitions
Een privileged container kan de schijven van de host rechtstreeks lezen en schrijven, wat de bestandsrechten daarop ook zeggen. Het is in geen enkele betekenisvolle zin een beveiligde container; het is een rootshell met extra stappen. Mensen grijpen ernaar omdat iets faalde met "operation not permitted" en --privileged die fout laat verdwijnen. Bijna altijd is de juiste oplossing een enkele capability:
$ docker run --cap-add NET_ADMIN ... # manage interfaces and routes
$ docker run --cap-add SYS_PTRACE ... # run a debugger inside
$ docker run --device /dev/ttyUSB0 ... # one device, not all of them
Zoek uit welke capability je werkelijk nodig hebt, voeg die ene toe, en laat de andere veertig met rust. Bewaar --privileged voor de echte gevallen, zoals Docker-in-Docker in een CI-runner, en behandel het zoals je het uitdelen van het rootwachtwoord zou behandelen.
6.6 Healthchecks
"Draaien" en "werken" zijn verschillende dingen. Een container waarvan de applicatie vastgelopen is, draait nog prima. Een healthcheck vraagt het aan de applicatie zelf:
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS http://localhost/health || exit 1
$ docker ps --format 'table {{.Names}}\t{{.Status}}'
NAMES STATUS
comp-web-1 Up 8 seconds (healthy)
$ docker inspect -f '{{.State.Health.Status}}' comp-web-1
healthy
--start-period is de optie die mensen vergeten. Zonder die optie wordt een database die veertig seconden nodig heeft om op te komen als ongezond gemarkeerd, lang voordat hij een kans gehad heeft. Fouten tijdens de startperiode tellen niet mee voor het aantal pogingen.
Healthchecks verdienen zichzelf terug in Compose, waar een andere service kan wachten op een echte gereedstatus in plaats van op een gok:
depends_on:
db:
condition: service_healthy # not just "started"
Een kale depends_on: [db] wacht alleen tot de container gestart is, wat voor een database ongeveer het moment is waarop hij aan zijn eigen opstartwerk begint. Dat is de oorzaak van de meeste meldingen van "connection refused bij de eerste start, daarna prima".
6.7 Resourcelimieten
Standaard mag een container elke kern en al het geheugen op de machine gebruiken, wat betekent dat een op hol geslagen proces al het andere op de server kan platleggen:
$ docker run -d --memory=512m --cpus=1.5 --pids-limit=200 myapp:1.0
Dit zijn cgroup-instellingen, en je kunt ze vanuit de container lezen:
$ docker run --rm --memory=64m alpine:3.20 cat /sys/fs/cgroup/memory.max
67108864 # 64 MB, in bytes
$ docker run --rm alpine:3.20 cat /sys/fs/cgroup/memory.max
max # no limit at all
$ docker run --rm --cpus=1.5 alpine:3.20 cat /sys/fs/cgroup/cpu.max
150000 100000 # 150ms of CPU per 100ms period
Deze bestanden zijn cgroup v2, de verenigde hiërarchie die elke huidige distributie gebruikt. Op een oudere host zou je in plaats daarvan een boom van mappen per controller vinden, zoals /sys/fs/cgroup/memory/. Controleer welke je hebt met docker info | grep "Cgroup Version".
Volg het verbruik live met docker stats. Als een container zijn geheugenlimiet wel raakt, maakt de OOM-killer van de kernel er een einde aan, en het resultaat ziet er zo uit:
$ docker run -d --name oomtest --memory=32m --memory-swap=32m alpine:latest \
sh -c 'tail /dev/zero' # allocate until it cannot
$ docker inspect -f 'exit={{.State.ExitCode}} oomkilled={{.State.OOMKilled}}' oomtest
exit=137 oomkilled=true
Vergelijk dat nu met de afsluitcode uit paragraaf 7.2, waar een container die simpelweg SIGTERM negeerde na de respijtperiode van tien seconden gedood werd:
exit=137 oomkilled=false # SIGKILLed by docker stop
exit=137 oomkilled=true # SIGKILLed by the kernel, out of memory
Afsluitcode 137 alleen kan je niet vertellen welk van beide gebeurde. Het betekent enkel "gedood door signaal 9", en beide oorzaken eindigen zo. Het veld OOMKilled is het onderscheid, en het is het eerste om te controleren als een container verdwijnt zonder applicatiefout in zijn logs. De kant van de host staat in dmesg of journalctl -k, waar de kernel vastlegt welk proces hij koos en waarom. Een container die onder belasting stilletjes verdwijnt is meestal helemaal niet gecrasht; hij is neergeschoten.
6.8 Waar de logs echt terechtkomen
De standaard logdriver schrijft JSON naar een bestand op de host, een bestand per container:
$ docker inspect -f '{{.LogPath}}' web
/var/lib/docker/containers/ca3b0f8b.../ca3b0f8b...-json.log
$ docker inspect -f '{{json .HostConfig.LogConfig}}' web
{"Type":"json-file","Config":{}}
Kijk naar die lege Config. Er is geen groottelimiet en geen rotatie. Een spraakzame applicatie op een langdraaiende container vult de schijf, en omdat het bestand onder /var/lib/docker staat in plaats van onder /var/log, raakt normale logrotatie het nooit aan. Begrens het:
$ docker run -d --log-opt max-size=10m --log-opt max-file=3 myapp:1.0
Beter nog: stel het een keer in voor de hele machine in /etc/docker/daemon.json en herstart de daemon:
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
Er zijn andere drivers om logs elders heen te sturen: journald zet containeruitvoer in het systemd-journaal zodat journalctl het kan lezen, syslog stuurt door naar een logserver, en none gooit het weg voor containers waarvan je de uitvoer nooit wilt.
6.9 Opruimen
Docker verwijdert nooit uit zichzelf iets. Gestopte containers, losse images, ongebruikte volumes en buildcache stapelen zich stilletjes op tot een uitrol om twee uur 's nachts faalt met "no space left on device". Vraag wat hij vasthoudt:
$ docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 276 76 130.1GB 76.81GB (59%)
Containers 124 2 1.925GB 1.925GB (99%)
Local Volumes 60 7 73.07GB 72.25GB (98%)
Build Cache 1452 0 14.08GB 14.08GB
Dat is een echte ontwikkelmachine, en die houdt ruim 160 GB vast die hij niet nodig heeft. De commando's, van veiligst naar meest destructief:
$ docker container prune # remove all stopped containers
$ docker image prune # remove dangling (untagged) images
$ docker builder prune # remove build cache
$ docker system prune # all of the above, in one go
$ docker system prune -a # ... plus every image not used by a
# running container. Re-pull time.
$ docker volume prune # DANGEROUS: this is your data
$ docker system df -v # itemised, before you decide
Leer het verschil tussen prune en prune -a voordat je een van beide op een server typt. Een kale prune verwijdert ongetagde restanten. Met -a gaat elke image zonder draaiende container weg, inclusief die welke je gestopte diensten nodig hebben om weer te starten.
En behandel docker volume prune als een aparte categorie. Al het andere kost je downloadtijd. Die ene kost je data. Het is ook waarom docker system prune volumes bewust met rust laat tenzij je --volumes toevoegt.
7. Iets wat de meeste gebruikers niet weten
7.1 Je container staat in de processenlijst van de host
Paragraaf 1.3 liet het zien, maar de gevolgen zijn het uitschrijven waard. Een container is een hostproces, dus elk gewoon Linux-gereedschap werkt erop. Je hebt docker exec niet nodig om een container te onderzoeken die niet meer reageert:
$ docker inspect -f '{{.State.Pid}}' web
438607
$ sudo ls -l /proc/438607/root/ # the container's filesystem, from the host
$ sudo cat /proc/438607/environ | tr '\0' '\n'
$ sudo strace -p 438607 # trace its system calls
$ sudo nsenter -t 438607 -a sh # enter its namespaces without Docker
Die laatste is de nooduitgang. Als de Docker-daemon zelf niet lekker is, zet nsenter je nog steeds in de container, want de namespaces zijn van de kernel en niet van Docker.
7.2 PID 1 negeert SIGTERM, en daarom duurt stoppen tien seconden
Klok een stop:
$ docker run -d --name demo alpine:3.20 sleep 600
$ time docker stop demo
real 0m10.093s
$ docker inspect -f 'exit={{.State.ExitCode}}' demo
exit=137 # 128 + 9: it was SIGKILLed
docker stop stuurt SIGTERM, wacht tien seconden, en stuurt dan SIGKILL. Bijna elke container heeft de volle tien nodig, en de reden is een Linux-regel die niets met Docker te maken heeft: de kernel past standaard signaalgedrag niet toe op PID 1. Een gewoon proces dat SIGTERM negeert gaat toch dood, omdat de standaardactie beeindigen is. PID 1 heeft geen standaardactie, dus een signaal dat het niet expliciet afhandelt wordt simpelweg weggegooid.
$ docker run -d --name s1 alpine:3.20 sleep 300
$ docker kill -s TERM s1
$ docker inspect -f 'running={{.State.Running}}' s1
running=true # it shrugged off the signal
Geef hetzelfde proces een handler en de tien seconden verdwijnen:
$ docker run -d --name g1 alpine:3.20 sh -c \
'trap "echo caught SIGTERM; exit 0" TERM; while true; do sleep 1; done'
$ time docker stop g1
real 0m1.055s
$ docker logs g1
caught SIGTERM
$ docker inspect -f 'exit={{.State.ExitCode}}' g1
exit=0 # a clean shutdown
Dit is geen cosmetisch probleem. Tien seconden vertraging bij elke container is vervelend; een database die met SIGKILL wordt neergelegd in plaats van netjes afgesloten is een beschadigde database. Als jij de applicatie schrijft, handel SIGTERM af. Zo niet, dan heeft paragraaf 7.3 de algemene oplossing. Ondertussen kun je de respijtperiode korter of langer maken met docker stop -t 30, of hem per image instellen met STOPSIGNAL.
7.3 Zombies, en waar --init echt voor is
PID 1 heeft naast het afhandelen van signalen nog een taak: verweesde processen adopteren en opruimen als ze stoppen. Een echte init doet dat. sleep, node en python niet. Kijk hoe een zombie ontstaat:
$ docker run -d --name s2 alpine:3.20 sh -c 'sleep 300 & sleep 300'
$ docker exec s2 ps -ef
PID USER TIME COMMAND
1 root 0:00 sleep 300
7 root 0:00 sleep 300
$ docker exec s2 sh -c 'kill -TERM 7'
$ docker exec s2 ps -ef
PID USER TIME COMMAND
1 root 0:00 sleep 300
7 root 0:00 [sleep] # defunct, never reaped
Proces 7 is dood maar zijn regel kan niet weg, omdat PID 1 nooit wait() voor hem aanroept. In een kortlevende container is dat onschuldig. In een langdraaiende container die kindprocessen start, loopt de tabel vol. Merk ook op dat de SIGTERM die PID 1 negeerde proces 7 meteen doodde: hetzelfde signaal, dezelfde binary, een andere uitkomst puur vanwege het proces-ID.
De oplossing is een optie:
$ docker run --rm --init alpine:3.20 ps -ef
PID USER TIME COMMAND
1 root 0:00 /sbin/docker-init -- ps -ef
7 root 0:00 ps -ef
--init zet een piepkleine echte init (tini) op PID 1. Die ruimt zombies op en stuurt signalen door naar jouw proces, zodat het proces SIGTERM krijgt op een PID waar de standaardactie nog geldt. In Compose is het init: true. Gebruik het voor alles wat kindprocessen start, en voor elke image die je niet zelf geschreven hebt.
7.4 Lidmaatschap van de groep docker is roottoegang
Jezelf aan de groep docker toevoegen zodat je sudo kunt overslaan is het eerste wat de meeste mensen doen. Het is goed om te begrijpen wat je jezelf daarmee gaf.
$ id -nG
pe7er adm cdrom sudo dip plugdev users lpadmin docker
$ ls /root
ls: cannot open directory '/root': Permission denied
$ docker run --rm -v /:/host:ro alpine:3.20 ls /host/root
Desktop
snap
Dezelfde gebruiker, een seconde later, een commando. De daemon draait als root, hij mount vrolijk elk hostpad dat je noemt, en de container is root, dus hij leest alles. Vervang :ro door schrijfrechten en je kunt /etc/sudoers bewerken, een SSH-sleutel aan /root/.ssh toevoegen, of een systemd-unit installeren.
Docker documenteert dit eerlijk: de groep docker geeft rechten die gelijkstaan aan root. Op je eigen laptop is dat een redelijke afweging. Op een gedeelde server is het een bewuste beslissing, waar "geef de deploy-gebruiker Docker-toegang" en "geef de deploy-gebruiker root" dezelfde zin zijn. De maatregelen zijn rootless mode, of Podman, of sudo docker verplichten zodat het recht in elk geval zichtbaar is in het auditlog.
Er is een vierde maatregel, en dat is degene die precies het gat dicht uit paragraaf 1.3, waar de user namespace van de container die van de host bleek te zijn. Je kunt de daemon vertellen containers een eigen namespace te geven:
$ cat /etc/subuid
pe7er:100000:65536 # 65536 IDs, starting at 100000
# /etc/docker/daemon.json
{ "userns-remap": "pe7er" }
Met die instelling wordt root in een container (UID 0) toegewezen aan UID 100000 op de host. Een container die uitbreekt vindt zichzelf terug als een gewoon account dat niets bezit, en bestanden die hij op een bind mount schrijft zijn van 100000 in plaats van van root. De prijs is echt, en daarom is het niet de standaard: bind mounts moeten van eigenaar veranderen om bij het toegewezen bereik te passen, sommige images die een specifieke UID verwachten breken, en containers kunnen geen namespaces met de host delen. Zet het aan op een server die containers van anderen draait. Laat het uit op een ontwikkellaptop, en weet gewoon dat root in de container root is.
7.5 Buildargumenten en ENV blijven voor altijd in de image
Een veelgebruikt patroon voor een privé-afhankelijkheid is een token meegeven tijdens de build. Dat doet niet wat mensen denken:
FROM alpine:3.20
ARG API_TOKEN
RUN echo "using token $API_TOKEN" > /tmp/build.log
ENV DB_PASSWORD=hunter2
$ docker build --build-arg API_TOKEN=s3cr3t-value -t leakdemo:1 .
$ docker history --no-trunc leakdemo:1 --format '{{.CreatedBy}}'
ENV DB_PASSWORD=hunter2
RUN |1 API_TOKEN=s3cr3t-value /bin/sh -c echo "using token $API_TOKEN" ...
ARG API_TOKEN=s3cr3t-value
$ docker image inspect leakdemo:1 -f '{{json .Config.Env}}'
["PATH=/usr/local/sbin:...","DB_PASSWORD=hunter2"]
Beide geheimen staan in de imagemetadata, leesbaar voor iedereen die hem ophaalt, en docker history vereist niet eens dat je de container draait. Het bestand in een latere laag verwijderen verandert niets, omdat de waarde in de buildinstructie staat, niet in het bestandssysteem.
BuildKit heeft een fatsoenlijk antwoord. Een secret mount maakt de waarde beschikbaar tijdens een RUN en laat geen spoor achter:
FROM alpine:3.20
RUN --mount=type=secret,id=api_token \
echo "token length: $(wc -c < /run/secrets/api_token)" > /tmp/build.log
$ docker build --secret id=api_token,src=token.txt -t leakdemo:2 .
$ docker history --no-trunc leakdemo:2 --format '{{.CreatedBy}}'
RUN /bin/sh -c echo "token length: $(wc -c < /run/secrets/api_token)" ...
# no value anywhere
$ docker run --rm leakdemo:2 sh -c 'cat /tmp/build.log; ls /run/secrets'
token length: 13 # it was readable during the build
ls: /run/secrets: No such file or directory # and is gone now
Geheimen tijdens het draaien zijn een apart probleem met een apart antwoord: geef ze mee met --env-file of Compose secrets, houd het bestand met .dockerignore uit de image, en nooit met ENV.
7.6 De standaardbridge heeft met opzet geen DNS
Paragraaf 5.2 liet zien dat ping n1 faalt op de standaardbridge en werkt op een zelfgemaakt netwerk. Dat verrast mensen omdat oude tutorials --link gebruiken, een verouderde optie die regels in /etc/hosts schreef.
De moderne vervanger is geen optie, het is een netwerk. Docker draait een ingebouwde DNS-server op 127.0.0.11 voor elke container die aan een zelfgemaakt netwerk hangt, en die herleidt containernamen en Compose-servicenamen automatisch. De standaardbridge blijft zonder, voor achterwaartse compatibiliteit met opstellingen van voor het bestaan van zelfgemaakte netwerken. De praktische regel is kort: maak een netwerk, of gebruik Compose, en gebruik de standaardbridge nooit voor iets met meer dan een container.
7.7 Een gepubliceerde poort kan zo langs je firewall lopen
Je hebt ufw zo ingesteld dat alleen poort 22, 80 en 443 open zijn. Je start een databasecontainer met -p 3306:3306 voor een snelle test. Je database staat nu op het internet.
Docker past iptables rechtstreeks aan om gepubliceerde poorten naar containers te routeren, en die regels worden geevalueerd voor die welke ufw beheert. Dit is gedocumenteerd gedrag, geen fout, en het verrast ervaren beheerders omdat alles eraan er correct uitziet: ufw status meldt precies het beleid dat je schreef, en de poort staat toch open.
Drie verdedigingen, op volgorde van hoe sterk je ze zou moeten verkiezen:
- Bind aan localhost.
-p 127.0.0.1:3306:3306luistert alleen op de loopback-interface, dus er is niets voor een firewall om te blokkeren. Dit is het juiste antwoord in vrijwel elk geval, zeker voor alles achter een reverse proxy. - Publiceer helemaal niet. Containers op hetzelfde zelfgemaakte netwerk bereiken elkaar rechtstreeks op de containerpoort. Een database waar alleen een webcontainer mee praat heeft helemaal geen
-p-regel nodig. - Verander het gedrag van de daemon met
"iptables": falsein/etc/docker/daemon.json, en neem de routeringsregels zelf over. Krachtig, en makkelijk verkeerd te doen. Lees eerst Docker's documentatie over pakketfiltering.
De gewoonte die het waard is om aan te leren: draai na het starten van iets met -p het commando ss -tlnp | grep LISTEN en kijk naar de adressen. 0.0.0.0 betekent iedereen.
7.8 Een image is gewoon een tar-bestand vol tar-bestanden
Er zit geen magie in een imageformaat. Sla er een op en kijk:
$ docker save alpine:3.20 -o alpine.tar
$ tar -tf alpine.tar
blobs/
blobs/sha256/08bc4e534116aa76b16015484b82eac51f9a593416feae9296c8a2d4bb7aa4a2
blobs/sha256/27698c48e88c1988da5594022f53451c70e28738541b64a76b994adf0f357d57
index.json
manifest.json
oci-layout
Elke blob is of een gecomprimeerde tar van een bestandssysteemlaag, of een klein JSON-document dat de image beschrijft. Dat is in de kern de hele OCI-imagespecificatie: een manifest, een config, en wat tarballs, allemaal geadresseerd via hun SHA-256-hash. Daarom kunnen docker save en docker load een image tussen machines verplaatsen zonder enige registry, wat handig is op een server zonder netwerkverbinding:
$ docker save myapp:1.0 | gzip > myapp-1.0.tar.gz
$ scp myapp-1.0.tar.gz server:/tmp/
$ ssh server 'gunzip -c /tmp/myapp-1.0.tar.gz | docker load'
Het is ook waarom vastzetten op inhoud werkt. Elke image heeft een digest, en anders dan een tag kan een digest niet verschuiven:
$ docker image inspect alpine:3.20 -f '{{json .RepoDigests}}'
["alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc"]
$ docker run alpine@sha256:d9e853e87e55... # exactly this image, always
7.9 latest betekent niet de nieuwste
latest is een tag als elke andere. Het heeft voor Docker geen bijzondere betekenis, het is simpelweg de tag die gebruikt wordt als je er geen noemt, en hij wijst naar wat er het laatst met dat label gepubliceerd is. Zodra hij op je schijf staat, blijft hij precies zoals hij was:
$ docker image inspect alpine:latest -f '{{.RepoTags}} {{.Created}}'
[alpine:latest] 2025-02-14T03:28:36Z # pulled once, long ago
$ docker pull alpine:3.20
3.20: Pulling from library/alpine # today's 3.20.10
Op die machine is alpine:latest ruim een jaar ouder dan alpine:3.20, wat geen tegenspraak is zodra je accepteert dat latest niets betekent. Daar volgen twee dingen uit. Vertrouw latest nooit als actueel; draai bewust docker pull of docker compose pull. En rol latest nooit uit, want twee servers die op verschillende momenten opgehaald hebben draaien verschillende software terwijl ze dezelfde tag melden.
7.10 Weten waar Docker ophoudt
Docker is goed gereedschap met duidelijke randen, en een deel van het goed gebruiken is het punt herkennen waar iets anders het overneemt.
| Als je nodig hebt | Kijk naar |
|---|---|
| Containers over veel machines, zelfherstel, geleidelijke updates | Kubernetes, of Docker Swarm voor iets veel eenvoudigers |
| Geen daemon, geen root, systemd-eigen units | Podman, met podman generate systemd |
| Images bouwen zonder daemon, binnen CI | Buildah, Kaniko, of docker buildx |
| Sterkere isolatie dan namespaces bieden | gVisor, Kata Containers, of een echte virtuele machine |
| Reproduceerbare ontwikkelomgevingen, geen uitrol | Nix, of een devcontainer-specificatie |
| Een volledige systeemimage draaien, geen enkel proces | systemd-nspawn, of LXC/LXD |
| Een image scannen op bekende kwetsbaarheden | Trivy, Grype, of de docker scout CLI-plugin |
En een eerlijke grens: Docker lost "het werkt op mijn machine" op voor de software, niet voor de data of de configuratie. Een container die perfect draait en naar de verkeerde database wijst is nog steeds een storing.
Naar boven8. Best practices
- Zet je basisimages vast.
FROM php:8.3-fpm-alpine, nooitFROM php. Voor alles wat je uitrolt zet je ook de digest vast, zodat de image niet onder je vandaan kan veranderen tussen twee servers. - Rol nooit
latestuit. Tag met een versie, een datum of een commit-hash. Als er om twee uur 's nachts iets stukgaat moet je weten wat er draait, enlatestkan je dat niet vertellen. - Orden de Dockerfile van minst naar meest waarschijnlijk om te veranderen. Basisimage, systeempakketten, afhankelijkheidsbestanden, installatie van afhankelijkheden, en dan pas de applicatiecode. Elke minuut buildtijd die je bespaart, bespaar je bij elke commit.
- Keten het opruimen in dezelfde
RUN.apk add --no-cache, ofapt-get install && apt-get clean && rm -rf /var/lib/apt/lists/*. Een aparteRUN rmverwijdert niets uit de image. - Schrijf een
.dockerignorevoor je eerste build. Begin met.git,node_modules,vendor,*.log,*.sqlen.env. - Gebruik multi-stage builds. Compilers, testsuites en pakketbeheerders horen in de buildstage, niet in de image die je uitlevert.
- Zet
USERin elke Dockerfile. Root is de standaard, en zonder user namespace is die root de root van de host. - Een zorg per container. Niet "een proces", dat is te streng, maar stop nginx, PHP-FPM en MariaDB niet in een image. Aparte containers schalen, herstarten en worden onafhankelijk vervangen.
- Log naar standaarduitvoer. Dat is wat
docker logs, Compose en elke logverzamelaar lezen. Begrens het log metmax-sizeenmax-file, bij voorkeur een keer in/etc/docker/daemon.json. - Zet blijvende data in een named volume, en maak een backup van dat volume. Een container is per ontwerp wegwerpbaar; de data erin mag dat niet zijn.
- Bind gepubliceerde poorten aan
127.0.0.1tenzij de wereld ze echt nodig heeft. Docker's eigen firewallregels gaan voor die vanufw, dus het adres waaraan je bindt is de echte knop. - Handel SIGTERM af, of voeg
--inittoe. Anders duurt elke stop tien seconden en eindigt hij in SIGKILL, wat voor een database een vuile afsluiting betekent. - Stel een healthcheck in met een
start_period, en laat services afhangen vanservice_healthyin plaats van van een container die enkel gestart is. - Stel geheugen- en CPU-limieten in op een gedeelde server. Zonder die limieten kan een container al het andere op de machine uithongeren.
- Grijp nooit naar
--privilegedom een fout te laten verdwijnen. Het geeft elke capability terug en zet seccomp uit. Zoek de ene capability die je nodig hebt en voeg die toe, of geef het enkele--devicemee dat je nodig hebt. - Houd geheimen uit images. Niet met
ENV, niet met--build-arg. Gebruik BuildKit secret mounts tijdens de build en een omgevingsbestand of Compose secrets tijdens het draaien. - Gebruik Compose zodra je twee containers hebt. Het bestand is documentatie, versiebeheer en een herhaalbare opstelling tegelijk.
- Ruim op volgens een schema, niet in paniek. Een wekelijkse
docker system prune -fendocker builder prune -fis prima. Bewaar-aen--volumesvoor beslissingen die je bewust neemt. - Lees de documentatie van het ding dat je gaat typen.
$ docker --help # top-level command list
$ docker run --help # the long one; worth reading once
$ docker compose --help
$ man docker-run # if the man pages package is installed
$ docker system df -v # what is actually on this disk
$ docker inspect <name> # everything Docker knows about it
De online documentatie op docs.docker.com voor de Dockerfile-instructies en het Compose-bestandsformaat zijn de twee pagina's om te bookmarken. Beide veranderen vaker dan een manpagina.
9. Veelgemaakte fouten
9.1 Mythe versus werkelijkheid
| Mythe | Werkelijkheid |
|---|---|
| "Een container is een lichtgewicht virtuele machine." | Het is een proces op jouw kernel, afgeschermd door namespaces. Er is geen tweede kernel en geen boot. |
| "Containers zijn geïsoleerd, dus root erin is ongevaarlijk." | Zonder user namespace is root binnen gelijk aan UID 0 buiten. Het schrijft bestanden van root op je bind mounts en het is een verkeerde instelling verwijderd van de host. |
"Ik zit in de groep docker, niet in sudo, dus ik heb geen rechten." |
De groep docker is root. Een -v /:/host en het hele bestandssysteem is van jou (paragraaf 7.4). |
"EXPOSE 80 publiceert poort 80." |
Het documenteert de poort. Alleen -p tijdens het draaien publiceert iets. |
"-p 8080:80 is veilig omdat ufw alleen 22, 80 en 443 toelaat." |
Docker schrijft iptables-regels die voor die van ufw geevalueerd worden. De poort staat open. Bind in plaats daarvan aan 127.0.0.1. |
"latest is de nieuwste versie." |
Het is een label dat wijst naar wat er het laatst gepubliceerd is, en jouw lokale kopie kan een jaar oud zijn. |
"RUN rm -rf /iets-groots heeft de image kleiner gemaakt." |
Een latere laag kan een eerdere niet verkleinen. De bytes staan er nog; je hebt een verwijderingsregel toegevoegd (paragraaf 6.1). |
"--build-arg TOKEN=... houdt het token uit de image." |
docker history drukt het af. Gebruik een BuildKit secret mount (paragraaf 7.5). |
"docker stop sluit de applicatie netjes af." |
Alleen als de applicatie SIGTERM afhandelt. Anders krijgt hij na tien seconden SIGKILL, afsluitcode 137. |
"depends_on wacht tot de database klaar is." |
Het wacht tot de container gestart is. Gebruik condition: service_healthy met een echte healthcheck. |
| "Mijn data staat in de container, dus die is bewaard." | Hij overleeft stop en start. Hij overleeft rm niet, en docker compose up maakt containers routinematig opnieuw aan. |
| "Containers op een host kunnen elkaar op naam vinden." | Alleen op een zelfgemaakt netwerk. De standaardbridge heeft helemaal geen DNS (paragraaf 5.2). |
"localhost in mijn configuratie wijst naar de andere container." |
localhost in een container is die container zelf. Gebruik de servicenaam. |
| "Kubernetes heeft Docker laten vallen, dus mijn images zijn verouderd." | Kubernetes liet dockershim vallen, een vertaallaag. Jouw images zijn OCI-images en draaien overal. |
"docker system prune is een veilige opruimactie." |
Een kale prune is redelijk veilig. -a verwijdert elke image zonder draaiende container, en --volumes verwijdert je data. |
"--privileged geeft gewoon wat extra rechten." |
Het geeft alle 41 capabilities terug, zet seccomp uit, en stelt de apparaten van de host bloot, inclusief de rauwe schijven (paragraaf 6.5). |
| "Afsluitcode 137 betekent dat de geheugenlimiet geraakt is." | 137 betekent gedood door signaal 9. Dat is ook wat docker stop na tien seconden doet. Alleen OOMKilled onderscheidt ze. |
| "Een paar bytes naar een bestand schrijven kost maar een paar bytes." | Als het bestand uit een imagelaag komt, kopieert OverlayFS eerst het hele bestand omhoog. Twee bytes in een bestand van 200 MB kostten 210 MB (paragraaf 1.2). |
"Het commando docker maakt mijn containers aan." |
Het stuurt een HTTP-verzoek. dockerd, containerd, een shim en runc doen het werk, en runc is al gestopt tegen de tijd dat jouw programma draait. |
| "Docker garandeert dat mijn applicatie zich overal hetzelfde gedraagt." | Het garandeert hetzelfde bestandssysteem en hetzelfde proces. De kernel, de processorarchitectuur, de gekoppelde data en de omgevingsvariabelen moet je nog steeds zelf goed krijgen. |
9.2 Andere valkuilen om te vermijden
- De poortkoppeling omdraaien.
-p 80:8080terwijl je-p 8080:80bedoelde. Eerst de host, dan de container, elke keer. - Een bind mount over een map die de build gevuld heeft. Je project op
/appmounten verbergt denode_modulesofvendordienpm ciofcomposer installtijdens de build aanmaakte. Mount ook een volume over de map met afhankelijkheden, of installeer tijdens het draaien. - Bestanden bewerken in een draaiende container. Die verdwijnen zodra de container opnieuw aangemaakt wordt, en
docker compose upmaakt containers opnieuw aan zodra de configuratie verandert. Verander de image of de gemounte broncode. docker commitgebruiken om je werk te bewaren. Het levert een image op die niemand kan herbouwen of nakijken. Zet de wijziging in de Dockerfile.- Een database op een bind mount draaien op macOS of Windows. De vertaallaag van het bestandssysteem maakt het traag en af en toe onbetrouwbaar. Gebruik een named volume.
- Vergeten dat
COPY . .je.envmeekopieert. Als die niet in.dockerignorestaat, zit hij in de image, en als je de image publiceert is hij openbaar. - Containers laten die Docker zelf benoemd heeft.
--namekost drie seconden en maakt elk later commando, elke logregel en elk script leesbaar. - Een image vertrouwen omdat hij vaak opgehaald is. Kies liever officiele images en geverifieerde uitgevers, lees de Dockerfile als het project die publiceert, en scan wat je uitrolt.
- Aannemen dat een image op elke machine draait. Een
amd64-image draait niet op een ARM-server zonder emulatie. Controleer metdocker image inspect -f '{{.Architecture}}', en bouw images voor meerdere platformen metdocker buildx build --platform linux/amd64,linux/arm64. - Het JSON-logbestand eindeloos laten groeien. Het staat onder
/var/lib/docker, duslogrotateziet het nooit, en het vult de schijf op een langdraaiende container. apt-get upgradein een Dockerfile draaien. Het maakt de build onreproduceerbaar en vecht met de updatecyclus van de basisimage zelf. Zet in plaats daarvan een nieuwere basisimage vast./var/run/docker.sockin een container mounten. Die container heeft nu root op de host. Soms is het echt nodig, voor een CI-runner of een reverse proxy die naar nieuwe containers kijkt. Behandel het als de beveiligingsbeslissing die het is, en mount het alleen-lezen waar het gereedschap dat toelaat.- De buildcache negeren bij het zoeken naar een fout. Als een build verouderde resultaten oplevert, vertelt
docker build --no-cacheje in een keer of de cache tegen je loog. - Status in het bestandssysteem van de container bewaren "even voor nu". Uploads, sessies en SQLite-bestanden die buiten een volume geschreven zijn verdwijnen bij de volgende uitrol, en de volgende uitrol is altijd eerder dan je denkt.
10. Samenvatting
Docker is een dunne, goed ontworpen laag over kernelfuncties die al bestonden. Alles wat er vreemd aan voelt wordt logisch zodra je accepteert dat een container een proces is en geen machine.
- Een container is een Linux-proces, dat via namespaces een privé-beeld van het systeem krijgt en via cgroups een budget. Het deelt jouw kernel;
psop de host ziet het. - Een image is een alleen-lezen stapel lagen plus configuratie. Een container is een instantie daarvan met een dunne schrijfbare laag. Een registry bewaart images.
- Het commando
dockeris alleen een client. De daemon doet het werk, via/var/run/docker.sock, als root. Lidmaatschap van de groepdockeris daarom roottoegang. - De naam komt van havenarbeiders, en de metafoor is de zeecontainer: standaardiseer de doos, niet de inhoud. De walvis heet Moby Dock.
- Docker heeft containers niet uitgevonden. Het maakte kernelfuncties uit 2008 bruikbaar in een commando, en de OCI-standaard die het startte zorgt dat je image ook onder Podman, containerd en Kubernetes draait.
- Leer zes commando's en je kunt werken:
run,ps,logs,exec,stop,rm. Gebruik--rm -itvoor wegwerpwerk en-d --namevoor alles wat blijft. docker logslaat alleen zien wat PID 1 naar stdout en stderr schreef. Een logbestand in de container is er onzichtbaar voor.- Bewaar data in een named volume. Een bind mount is voor broncode en configuratie, hij verbergt wat de image op dat pad had, en de bestanden die hij aanmaakt zijn van root tenzij je
--usermeegeeft. - Naamsherleiding tussen containers werkt op een zelfgemaakt netwerk, nooit op de standaardbridge. Compose maakt er een voor je, en dat is de halve reden om het te gebruiken.
- Laagvolgorde is buildsnelheid. Afhankelijkheden voor code, en opruimen in dezelfde
RUN, want een latere laag kan een eerdere nooit verkleinen: 113 MB tegenover 7,81 MB voor hetzelfde resultaat. - Een
.dockerignore-bestand en een multi-stage build zijn de twee wijzigingen met het grootste effect op de imagegrootte. 230 MB werd 7,88 MB in paragraaf 6.3. - PID 1 negeert signalen die het niet afhandelt, en daarom duurt
docker stoptien seconden en eindigt het in afsluitcode 137. Handel SIGTERM af, of draai met--init, dat ook de zombies opruimt die PID 1 anders laat liggen. - Geheimen die je met
--build-argmeegeeft of metENVzet, worden doordocker historyafgedrukt. Gebruik in plaats daarvan een BuildKit--mount=type=secret. -p 8080:80bindt aan0.0.0.0en Docker's firewallregels gaan voor die vanufw. Schrijf-p 127.0.0.1:8080:80, of publiceer helemaal niet.latestis een label, geen versie. Zet tags vast, en zet digests vast voor alles wat je uitrolt.- Onder de CLI zitten
dockerd,containerd, een shim per container enrunc.runcstopt zodra de namespaces bestaan, en de shim valt onderinit, en daarom overleeft de stapel de daemon en kon Kubernetesdockershimlaten vallen zonder een enkele image te breken. - Het bestandssysteem is OverlayFS: alleen-lezen lagen onder, een schrijfbare laag erboven, een samengevoegd beeld. Schrijven naar een bestand uit een imagelaag kopieert eerst het hele bestand omhoog, wat op grote bestanden een echte kostenpost is en nog een reden dat data in een volume hoort.
- Docker past al drie beschermingen toe waar je niet om vroeg: 14 van de 41 capabilities, een seccomp-filter, en een AppArmor-profiel.
--privilegedhaalt alle drie tegelijk weg en geeft de schijven van de host uit handen. Voeg liever een capability toe. - Afsluitcode 137 betekent "gedood door signaal 9" en niets meer.
OOMKilledis wat een geheugenlimiet onderscheidt van een niet-afgehandeldedocker stop. - Docker ruimt nooit uit zichzelf op. Draai regelmatig
docker system df, ruim bewust op, en onthoud dat--volumesdata verwijdert terwijl al het andere alleen een download kost.
Dit is het overzicht dat het bewaren waard is:
RUN AND INSPECT
docker run --rm -it IMAGE sh throwaway container with a shell
docker run -d --name web -p 127.0.0.1:8080:80 IMAGE
docker ps / docker ps -a running / all containers
docker logs -f --tail 50 NAME follow the last 50 lines
docker exec -it NAME sh shell inside a RUNNING container
docker inspect NAME everything Docker knows
docker stats live CPU and memory per container
docker top NAME its processes, as the host sees them
LIFECYCLE
docker stop NAME SIGTERM, then SIGKILL after 10s
docker stop -t 30 NAME give it 30 seconds instead
docker start / restart / kill NAME
docker rm -f NAME stop and delete (writable layer lost)
IMAGES
docker pull IMAGE:TAG tags move; pull deliberately
docker build -t app:1.0 . the "." is the build CONTEXT
docker build --no-cache -t app:1.0 .
docker history --no-trunc IMAGE every layer, and every secret in one
docker image inspect IMAGE -f '{{json .RepoDigests}}'
docker save IMAGE | gzip > img.tar.gz move an image without a registry
gunzip -c img.tar.gz | docker load
DATA AND NETWORK
docker volume create|ls|inspect|rm NAME
-v myvol:/var/lib/mysql named volume: prefilled from the image
-v "$PWD:/app" bind mount: HIDES the image's content
--user "$(id -u):$(id -g)" stop creating root-owned files
docker network create mynet name resolution needs a user network
--add-host=host.docker.internal:host-gateway reach the host from inside
COMPOSE
docker compose up -d / down down keeps named volumes
docker compose down -v ... and this deletes them
docker compose ps / logs -f SERVICE / exec SERVICE sh
docker compose pull actually fetch newer images
HARDENING
--user 1000:1000 --read-only --tmpfs /tmp
--cap-drop ALL --security-opt no-new-privileges
--cap-add NET_ADMIN add ONE capability, never --privileged
--device /dev/ttyUSB0 one device, never --privileged
--memory=512m --cpus=1.5 --pids-limit=200
--init real PID 1: reaps zombies, forwards
signals, ends the 10-second stop
--log-opt max-size=10m --log-opt max-file=3
CLEANUP (increasing danger)
docker system df what is on this disk
docker container prune stopped containers
docker image prune dangling images only
docker builder prune build cache
docker system prune the three above
docker system prune -a every image without a running container
docker volume prune YOUR DATA
DIGGING UNDERNEATH
docker inspect -f '{{.State.Pid}}' NAME its PID on the host
docker inspect -f '{{json .GraphDriver}}' NAME its overlay2 directories
docker ps -s writable-layer size (copy-up shows here)
docker run --rm IMAGE grep CapEff /proc/self/status
docker info | grep -E "Cgroup Version|Live Restore"
sudo nsenter -t PID -a sh enter it without the Docker daemon
lsns / systemd-cgls / dmesg namespaces, cgroups, OOM kills
exit 137 = killed by signal 9. Check OOMKilled to learn WHICH:
oomkilled=false => the 10-second docker stop
oomkilled=true => the kernel, out of memory
exit 143 = SIGTERM | 0.0.0.0 in docker ps = open to the network
Docker beloont een beetje nieuwsgierigheid heel snel. De commando's hierboven dekken bijna alles wat de meeste mensen ooit nodig hebben, en wat er in productie misgaat is bijna altijd een van dezelfde handvol dingen: een poort gebonden aan 0.0.0.0, data die buiten een volume geschreven wordt, een logbestand zonder groottelimiet, of een container die nooit geleerd heeft af te sluiten. Als een site in containers zich gedraagt op een manier die niemand kan verklaren, zijn dat de vier plekken om als eerste te kijken.


Peter is Joomla specialist en Linux admin voor snelle, veilige en schaalbare websites.












