Terug naar hoofdinhoud
Linux commando: docker
Op deze pagina

Linux commando: docker

01 september 2026

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.

WoordWat het isAlledaagse 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:

OnderdeelTaak
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.

Naar boven

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 boven

3. 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.

TijdvakMijlpaal
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.

De 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.

Naar boven

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:

OptieAfkorting vanWat 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.

Naar boven

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 volumeBind 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:

  • localhost in een container is de container zelf. Een applicatie die is ingesteld om een database op 127.0.0.1:3306 te 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-gateway toe, en verbind je daarna met host.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:

InstructieWat 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
BeleidGedrag
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.

Naar boven

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.

Naar boven

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:3306 luistert 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": false in /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 hebtKijk 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 boven

8. Best practices

  • Zet je basisimages vast. FROM php:8.3-fpm-alpine, nooit FROM 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 latest uit. 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, en latest kan 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, of apt-get install && apt-get clean && rm -rf /var/lib/apt/lists/*. Een aparte RUN rm verwijdert niets uit de image.
  • Schrijf een .dockerignore voor je eerste build. Begin met .git, node_modules, vendor, *.log, *.sql en .env.
  • Gebruik multi-stage builds. Compilers, testsuites en pakketbeheerders horen in de buildstage, niet in de image die je uitlevert.
  • Zet USER in 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 met max-size en max-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.1 tenzij de wereld ze echt nodig heeft. Docker's eigen firewallregels gaan voor die van ufw, dus het adres waaraan je bindt is de echte knop.
  • Handel SIGTERM af, of voeg --init toe. 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 van service_healthy in 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 --privileged om 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 --device mee 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 -f en docker builder prune -f is prima. Bewaar -a en --volumes voor 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.

Naar boven

9. Veelgemaakte fouten

9.1 Mythe versus werkelijkheid

MytheWerkelijkheid
"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:8080 terwijl je -p 8080:80 bedoelde. Eerst de host, dan de container, elke keer.
  • Een bind mount over een map die de build gevuld heeft. Je project op /app mounten verbergt de node_modules of vendor die npm ci of composer install tijdens 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 up maakt containers opnieuw aan zodra de configuratie verandert. Verander de image of de gemounte broncode.
  • docker commit gebruiken 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 .env meekopieert. Als die niet in .dockerignore staat, zit hij in de image, en als je de image publiceert is hij openbaar.
  • Containers laten die Docker zelf benoemd heeft. --name kost 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 met docker image inspect -f '{{.Architecture}}', en bouw images voor meerdere platformen met docker buildx build --platform linux/amd64,linux/arm64.
  • Het JSON-logbestand eindeloos laten groeien. Het staat onder /var/lib/docker, dus logrotate ziet het nooit, en het vult de schijf op een langdraaiende container.
  • apt-get upgrade in 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.sock in 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-cache je 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.
Naar boven

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; ps op 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 docker is alleen een client. De daemon doet het werk, via /var/run/docker.sock, als root. Lidmaatschap van de groep docker is 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 -it voor wegwerpwerk en -d --name voor alles wat blijft.
  • docker logs laat 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 --user meegeeft.
  • 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 stop tien 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-arg meegeeft of met ENV zet, worden door docker history afgedrukt. Gebruik in plaats daarvan een BuildKit --mount=type=secret.
  • -p 8080:80 bindt aan 0.0.0.0 en Docker's firewallregels gaan voor die van ufw. Schrijf -p 127.0.0.1:8080:80, of publiceer helemaal niet.
  • latest is 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 en runc. runc stopt zodra de namespaces bestaan, en de shim valt onder init, en daarom overleeft de stapel de daemon en kon Kubernetes dockershim laten 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. --privileged haalt 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. OOMKilled is wat een geheugenlimiet onderscheidt van een niet-afgehandelde docker stop.
  • Docker ruimt nooit uit zichzelf op. Draai regelmatig docker system df, ruim bewust op, en onthoud dat --volumes data 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.

Naar boven
Linux commando: docker
Peter Martin
Peter Martin
Joomla Specialist

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

Gerelateerde artikelen