Skip to content

Opdracht 7 - Docker advanced

Doel

Tijdens deze opdracht leer je hoe je een bestaande applicatie containeriseert met een Dockerfile en hoe je meerdere containers laat samenwerken. Daarnaast leer je hoe je gegevens persistent maakt met Docker volumes.

Na deze opdracht kan je:

  • Een Docker image bouwen aan de hand van een Dockerfile.
  • Containers opstarten en beheren met aangepaste configuraties.
  • Data persistent opslaan met bind mounts en named volumes.
  • Meerdere containers laten samenwerken via een Docker netwerk.
  • Een databasecontainer opzetten en verbinden met een applicatiecontainer.

Deze opdracht is geïnspireerd op de Get Started tutorial van Docker: https://docs.docker.com/get-started/. Enkele stappen zijn aangepast om de opdracht te vereenvoudigen of af te stemmen met de leerinhoud van dit OLOD. We gebruiken hier ook nog een oudere versie van de to do applicatie omdat deze eenvoudiger is.

Een bestaande applicatie containeriseren

In deze opdracht krijg je een bestaande webapplicatie. Het is aan jou om deze applicatie in een container te stoppen, oftewel te containeriseren. Je zal de applicatie opstarten in een container en de database in een andere container. In deze eerste stap zal je alleen de applicatie containeriseren.

We gaan ervan uit dat git geïnstalleerd is op jouw Ubuntu VM. Indien dit niet het geval is, installeer dan git met het volgende commando:

Bash
sudo apt install -y git

Clone vervolgens de repository van de webapplicatie in je home directory:

Bash
git clone https://github.com/docker/getting-started.git

Inspecteer de inhoud van de repository. Je zal zien dat er een aantal bestanden en mappen aanwezig zijn. De belangrijkste map is app. Deze map bevat een Node.js (= JavaScript) applicatie die je zal containeriseren. Het is niet belangrijk om te weten hoe de applicatie precies werkt. Het enige wat je moet weten is dat de applicatie gestart wordt met het commando node src/index.js als je je in de map app bevindt. De applicatie zal dan op poort 3000 luisteren voor inkomende verbindingen.

Dit zal uiteraard niet werken op jouw host/VM, omdat daar geen Node.js geïnstalleerd is. Het is ook niet de bedoeling dat je Node.js installeert op jouw host/VM, we gaan de applicatie immers containeriseren.

Navigeer naar de map waarin de applicatie gecloned werd en vervolgens naar de map app. Maak daar een Dockerfile aan met de volgende inhoud:

Docker
1
2
3
4
5
6
7
8
9
FROM node:24-alpine
WORKDIR /app

COPY . .
RUN yarn install --production

EXPOSE 3000

CMD ["node", "src/index.js"]
Wat doet elk van de commando's in de Dockerfile?
  • FROM node:24-alpine: dit is de basisimage die we gebruiken. In dit geval gebruiken we Node.js versie 24 op basis van Alpine Linux. Alpine Linux is een zeer kleine Linux distributie die vaak gebruikt wordt in Docker images.
  • WORKDIR /app: dit is de werkmap in de container. Alle volgende commando's zullen uitgevoerd worden in deze map.
  • COPY . .: kopieer alle bestanden van de huidige map (de map waarin de Dockerfile staat) naar de map /app in de container.
  • RUN yarn install --production: installeer de nodige afhankelijkheden van de applicatie. yarn is een package manager voor Node.js.
  • CMD ["node", "src/index.js"]: start de applicatie met het commando node src/index.js. Dit is het commando dat zal uitgevoerd worden als de container opstart.
  • EXPOSE 3000: documenteer dat de applicatie luistert op poort 3000. Dit is niet strikt nodig voor port binding, maar het maakt duidelijk welke poort de applicatie gebruikt.
Bouw de container image met todo-app als naam. Welk commando gebruik je hiervoor?
Bash
docker build -t todo-app .

Start de container op met het volgende commando:

Bash
docker run -d -p 3000:3000 todo-app

-d zorgt ervoor dat de container op de achtergrond opgestart wordt. Dit is handig als je de container wil laten draaien en je niet wil dat de terminal geblokkeerd wordt door de container.

Je kan nu naar http://localhost:3000 surfen om de applicatie te bekijken.

Een lege to do applicatie

Voeg gerust wat to do items toe, vink bepaalde items aan of verwijder ze. De applicatie zou moeten werken zoals je zou verwachten.

De applicatie updaten

Er zit een typfout in de applicatie. De melding No items yet! Add one above! moet eigenlijk You have no todo items yet! Add one above! zijn.

Pas deze tekst aan in het bestand src/static/js/app.js (hint: deze tekst staat op lijn 56).

Bash
nano src/static/js/app.js

Bouw de container image opnieuw met dezelfde naam. Start de container opnieuw op. Je krijgt waarschijnlijk een foutmelding omdat de vorige container nog gekoppeld is aan poort 3000.

Text Only
docker: Error response from daemon: driver failed programming external connectivity on endpoint pedantic_bouman (920a64e7ee16c889c26897bcaf35ee11667a5e0530708e000c255e510d242665): Bind for 0.0.0.0:3000 failed: port is already allocated.
Stop en verwijder de vorige container. Welk(e) commando('s) heb je hiervoor gebruikt?
Bash
docker stop <CONTAINER ID>
docker rm <CONTAINER ID>

Je kan dit ook met één commando, maar dan moet je de -f optie gebruiken om de container te forceren te stoppen en te verwijderen. Dit is gevaarlijker omdat meteen alle data verloren gaat als je geen volumes gebruikt.

Bash
docker rm -f <container-id>

Probeer de container opnieuw te starten en ververs de pagina in de browser. Je zal zien dat de container opnieuw opgestart wordt en dat de tekst aangepast is.

Aangepaste melding

De data persistent maken

Je hebt waarschijnlijk gemerkt dat de todo items verdwijnen als je de container stopt of verwijdert. Dit komt omdat de data wordt opgeslagen in een SQLite databank in de container. Dit willen we oplossen door de data persistent op te slaan.

Bind mounts

Om de data persistent te maken, kunnen we data opslaan via bind mounts of named volumes. We starten met een bind mount. Dit koppelt een directory op het bestandssysteem van de host met een directory in de container. Je kan bind mounts gebruiken om data te delen tussen containers of om de bestanden in een container direct te bewerken met je eigen tools.

We gaan een bind mount gebruiken om de data van de to do applicatie te delen met de Docker host. We gaan hiervoor een map aanmaken op de Docker host, nl. ~/todo-db.

Verwijder de container en start hem opnieuw met de optie -v om de bind mount te koppelen aan de container:

Bash
docker run -d -p 3000:3000 -v ~/todo-db:/etc/todos todo-app

Merk op dat we deze map niet zelf hoeven aan te maken. Docker zal deze map aanmaken als deze nog niet bestaat.

Neem een kijkje in de map ~/todo-db op de Docker host:

Bash
ls ~/todo-db

Je zal zien dat er een bestand todos.db aanwezig is. Dit is de SQLite databank die we in de container hebben aangemaakt.

Voeg een aantal to do items toe in de applicatie. Stop en verwijder de container, en start de container opnieuw op met dezelfde bind mount. Je zal zien dat de to do items nog steeds aanwezig zijn.

Named volumes

Een bind mount is handig wanneer je bestanden actief wil bewerken, maar voor databases is dit niet nodig. Het is namelijk mogelijk dat je per ongeluk bestanden aanpast of verwijdert. Daarom gebruiken we voor databaseopslag vaak named volumes. Deze kunnen we aanmaken met het commando docker volume create. We geven het volume de naam todo-db:

Bash
docker volume create todo-db

Verwijder opnieuw de vorige container van de applicatie:

Bash
docker rm -f <container-id>

Start de container opnieuw op, maar nu met de optie -v om het volume te koppelen aan de container:

Bash
docker run -d -p 3000:3000 -v todo-db:/etc/todos todo-app

Voeg een aantal to do items toe. Stop en verwijder de container. Start de container opnieuw op. Je zal zien dat de to do items nog steeds aanwezig zijn.

Waar wordt deze data nu opgeslagen? Bekijk de inhoud van het volume todo-db met het commando docker volume inspect:

Bash
docker volume inspect todo-db

Daar zal je een Mountpoint zien. Dit is de locatie op het bestandssysteem van de Docker host waar de data opgeslagen wordt. In principe kan je deze data bekijken, maar je hebt hier root permissies voor nodig. Dit soort volumes worden beheerd door Docker en zijn dus niet bedoeld om door de gebruiker te worden aangepast.

Multi-container applicaties

SQLite is een relatief lichte databank, maar niet geschikt voor grote toepassingen. Daarom gaan we een MySQL databank gebruiken om de to do items op te slaan. We starten hiervoor een tweede container met een MySQL databank en koppelen deze aan de eerste container met een netwerk. Zonder netwerk kunnen de containers niet met elkaar communiceren.

Allereerst moet je een Docker netwerk aanmaken met de naam todo-app. Welk commando gebruik je hiervoor?
Bash
docker network create todo-app

Vervolgens starten we een MySQL container in dit netwerk. We gebruiken een volume om de data persistent te maken. We kiezen ook een wachtwoord voor de root gebruiker van de databank en vragen de container om een databank todos aan te maken. We geven deze container ook een netwerkalias mysql, hier komen we later op terug.

Bash
1
2
3
4
5
6
docker run -d \
    --network todo-app --network-alias mysql \
    -v todo-mysql-data:/var/lib/mysql \
    -e MYSQL_ROOT_PASSWORD=letmein \
    -e MYSQL_DATABASE=todos \
    mysql:8.0
Wat is het nut van MYSQL_ROOT_PASSWORD en MYSQL_DATABASE? De Docker Hub pagina van de image is altijd een goede start om informatie over een image terug te vinden.
  • MYSQL_ROOT_PASSWORD: dit is het wachtwoord voor de root gebruiker van de MySQL databank. Dit wachtwoord is nodig om in te loggen op de databank en om de databank te beheren.
  • MYSQL_DATABASE: dit is de naam van de databank die aangemaakt zal worden bij het opstarten van de container.

Deze omgevingsvariabelen worden gebruikt door de MySQL image om de databank correct te configureren. Normaal geef je deze niet mee op de commandolijn, maar via bv. omgevingsvariabelen in een docker-compose.yml bestand (zie later).

Wacht tot de databank correct is opgestart. Dit kan enkele seconden duren. Je kan de status opvolgen door de logs van de container te inspecteren. De container zal ready for connections uitprinten als de databank klaar is.

Welk commando gebruik je hiervoor?
Bash
docker logs -f <container-id>

Met de -f optie volg je de logs in real-time. Nieuwe logs worden automatisch toegevoegd aan de uitvoer. Je kan dit stoppen met Ctrl+C.

Controleer of de databank correct gestart is en draait. Dit doen we door een commando uit te voeren in de container:

Bash
docker exec -it <mysql-container-id> mysql -u root -p

We openen hier een MySQL console en loggen in als gebruiker root met het wachtwoord letmein (of het wachtwoord dat je zelf hebt gekozen).

We kunnen nu SQL-commando's uitvoeren in de databank. Bijvoorbeeld, om alle aanwezige databanken te bekijken:

SQL
SHOW DATABASES;

Dit overzicht zou o.a. een databank todos moeten bevatten. Verlaat de MySQL console met het commando exit.

Als laatste moeten we de to do applicatie aanpassen zodat deze de MySQL databank gebruikt. Elke container krijgt binnen zijn netwerk een IP-adres. Dit kan wijzigen, dus het is niet handig om dit IP-adres hard te coderen in de applicatie. We kunnen de container echter bereiken via de netwerkalias die we hebben meegegeven. Deze alias wordt door de ingebouwde DNS-server van Docker omgezet naar het juiste IP-adres. Dit werkt vergelijkbaar met hoe jouw computer weet waar bv. https://www.hogent.be zich bevindt, m.a.w. welk IP-adres achter deze domeinnaam schuilgaat.

De to do applicatie is zo opgebouwd dat deze automatisch voor de MySQL databank zal kiezen als de omgevingsvariabele MYSQL_HOST is ingesteld. Er zijn ook nog een paar andere omgevingsvariabelen die we moeten instellen:

  • MYSQL_HOST: locatie van de databank
  • MYSQL_USER: gebruiker om in te loggen
  • MYSQL_PASSWORD: wachtwoord voor deze gebruiker
  • MYSQL_DB: de naam van de databank

Verwijder de vorige container van de applicatie (niet de mysql-container) en start deze opnieuw met de juiste waarden voor de omgevingsvariabelen. Vergeet ook niet om het juiste Docker netwerk op te geven!

Bash
1
2
3
4
5
6
7
8
docker run -d \
    -p 3000:3000 \
    --network todo-app \
    -e MYSQL_HOST=mysql \
    -e MYSQL_USER=root \
    -e MYSQL_PASSWORD=letmein \
    -e MYSQL_DB=todos \
    todo-app

Bekijk de logs van de container om te zien of de applicatie correct is opgestart. Je kan de logs bekijken met het commando docker logs <container-id>. Je zou een gelijkaardige uitvoer moeten zien:

Text Only
1
2
3
4
Waiting for mysql:3306.
Connected!
Connected to mysql db at host mysql
Listening on port 3000

Voeg een paar to do items toe en controleer of deze ook in de MySQL databank staan. Dit doen we opnieuw door een commando uit te voeren in de MySQL container:

Bash
docker exec -it <mysql-container-id> mysql -u root -p todos

We loggen opnieuw in als gebruiker root en kunnen nu alle to do items bekijken:

SQL
1
2
3
4
# Selecteer de databank todos
use todos;
# Selecteer de to do items
SELECT * FROM todo_items;

Dit geeft een gelijkaardig overzicht:

Text Only
1
2
3
4
5
6
7
+--------------------------------------+--------------+-----------+
| id                                   | name         | completed |
+--------------------------------------+--------------+-----------+
| 660fa44d-dbfa-4c22-be15-f317463d0cc7 | Test         |         1 |
| 31898ef0-31e3-449a-96b6-f214509b02a7 | Nog een test |         0 |
+--------------------------------------+--------------+-----------+
2 rows in set (0.00 sec)

Opruimen

Verwijder alle containers, netwerken en volumes die je hebt aangemaakt tijdens deze opdracht.

Welk(e) commando('s) heb je hiervoor gebruikt?

Minimaal heb je volgende commando's nodig:

Bash
docker system prune -a --volumes
docker volume rm todo-db todo-mysql-data

Je mag ook de map ~/todo-db verwijderen, deze zijn niet meer nodig. Laat de map ~/getting-started staan, deze heb je nog nodig in de volgende opdracht.

Uitdaging (optioneel)

Optimaliseer de Dockerfile zodat je de dependencies enkel installeert als er wijzigingen zijn in package.json of yarn.lock. Dit kan de bouwtijd van de image aanzienlijk verkorten als je enkel wijzigingen aanbrengt in de applicatiecode.

Controleer of je optimalisatie werkt door de image eerst opnieuw te bouwen met de aangepaste Dockerfile. Pas vervolgens, net als hierboven, het bestand src/static/js/app.js aan en bouw de image nogmaals. De stap om de dependencies te installeren zou nu uit de cache gehaald moeten worden (herkenbaar aan CACHED in de build output).

Welke aanpassing heb je gemaakt aan de Dockerfile?

Je kan een extra COPY commando toevoegen om enkel package.json en yarn.lock te kopiëren voordat je de dependencies installeert. Hierdoor zal Docker deze laag cachen als deze bestanden niet gewijzigd zijn.

De aangepaste Dockerfile zou er als volgt uit kunnen zien:

Docker
FROM node:24-alpine
WORKDIR /app

# Kopieer enkel package.json en yarn.lock eerst
COPY package.json yarn.lock ./
RUN yarn install --production

# Kopieer daarna de rest van de bestanden
COPY . .

EXPOSE 3000

CMD ["node", "src/index.js"]

Extra informatie