Table des matières

Environement Docker au CREMI

L’utilisation de Docker n’est pas possible au CREMI car le démon docker nécessite des privilèges élevés, ce qui pose des problèmes de sécurité dans un environnement mutualisé. Nous allons utiliser à la place podman une alternative qui n’a pas cette contrainte. Pour simplifier son utilisation, définissez les alias suivant :

alias podman="podman --root=$TMPDIR/containers --storage-driver=overlay "
alias podman-compose="podman-compose --podman-args=\"--root=$TMPDIR/containers --storage-driver=overlay\" "
alias docker=podman
alias docker-compose=podman-compose

Création d’une application minimaliste

Dans un répertoire vierge, créez un répertoire api et à l’intérieur les fichiers suivants

Fichier package.json

{
  "type": "module",
  "scripts": {
    "dev": "nodemon src/index.js",
    "start": "node src/index.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "morgan": "^1.10.0",
    "nodemon": "^3.0.1"
  }
}

Fichier rest.http

###
GET http://localhost:3000/contacts

###
POST http://localhost:3000/contact
Content-Type: application/json

{
    "name" : "Alice",
    "email" : "alice@foo.com"
}

Fichier src/index.js

import express from 'express';
import morgan from 'morgan'
import router from './routes.js';

const app = express();

app.use(morgan('dev'));
app.use(express.json());

app.use(router);

const PORT = 3000;

app.listen(PORT, async () => {
    console.log(`Server is running ...`);
});

Fichier src/routes.js

import express from 'express';
import users from './users.js';

const router = express.Router();

router.get('/', (req, res) => {
    res.send('Hello World!');
});

router.get('/contacts', users.getContacts);
router.post('/contact', users.addContact);

export default router;

Fichier src/users.js

let contacts = []

async function getContacts(req, res) {
    res.status(200).send(contacts);
}

async function addContact(req, res) {
    const { name, email } = req.body;
    if (!name || !email) {
        return res.status(400).send({ message: 'Name and email are required' });
    }

    const contact = { name, email };
    contacts.push(contact);

    res.status(201).send(contact);
}

export default { getContacts, addContact }

Ajouter le fichier Dockerfile

FROM node:21

WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .

EXPOSE 80

CMD ["npm", "run", "dev"]

Testez votre application.

Ajoutons une base de données

Modifiez le fichier users.js comme suit

import { MongoClient } from 'mongodb';

const mongoURI = process.env.MONGO_URI;
const mongoDB = process.env.MONGO_DB;

if (!mongoURI || !mongoDB) {
    throw new Error("Please set the MONGO_URI and MONGO_DB environment variables.");
}

const client = new MongoClient(mongoURI);
let db;

async function connectToDb() {
    try {
        if (!db) {
            await client.connect();
            db = client.db(mongoDB);
            console.log("Connected to MongoDB");
        }
    } catch (error) {
        console.error("Failed to connect to MongoDB:", error);
        throw error;
    }
}

async function getContacts(req, res) {
    try {
        await connectToDb();
        const collection = db.collection('contacts');
        const contacts = await collection.find({}).toArray();
        res.status(200).send(contacts);
    } catch (error) {
        res.status(500).send("Internal Server Error");
    }
}

async function addContact(req, res) {
    const { name, email } = req.body;
    if (!name || !email) {
        return res.status(400).send({ message: 'Name and email are required' });
    }
  
    const contact = { name, email };
    try {
        await connectToDb();
        const collection = db.collection('contacts');
        await collection.insertOne(contact);
        res.status(201).send(contact);
    } catch (error) {
        res.status(500).send("Internal Server Error");
    }
}

export default { getContacts, addContact }

Pour lancer notre application, cela va être un peu plus compliqué. Il va falloir créer un réseau docker et démarrer deux conteneurs sur ce même réseau pour qu’ils puissent communiquer entre eux.

docker network create contacts-net

Il faut ensuite démarrer un serveur mongo

docker run --name mongodb --network contacts-net -d mongo

Puis enfin, démarrer notre serveur

docker run --name contacts -v $(pwd)/src:/app/src --network contacts-net -e MONGO_URI=mongodb://mongodb:27017/ -e MONGO_DB=td7 -p 3000:80 -d contacts

On peut ensuite regarder les logs du serveur

docker logs -f contacts

Utilisons docker-compose

Lorsque nous avons plusieurs conteneurs, cela devient très compliqué de lancer correctement tous les conteneurs, dans le bon ordre, avec les bons paramètres. Pour remédier à cela, nous allons utiliser. docker-compose

Créez le fichier docker-compose.yml à la racine de votre projet.

version: '3.8'

services:
  api:
    container_name: td7-api
    build: 
      context: api
      dockerfile: Dockerfile
    ports:
      - "3000:80"
    environment:
      - MONGO_URI=mongodb://mongodb:27017/
      - MONGO_DB=td7
    volumes:
      - ./api/src:/app/src
    depends_on:
      - mongodb

  mongodb:
    container_name: td7-mongo
    image: mongo
    environment:
      - MONGO_INITDB_DATABASE=td7
    volumes:
      - mongodb_data:/data/db

volumes:
  mongodb_data:

Allons plus loin

Nous allons définir deux environnements différents, un de production et un de développement avec deux fichiers de configuration : docker-compose.dev.yml et docker-compose.prod.yml. Dans la version de production, nous allons transpiler le code JavaScript pour le minifier et produire un seul et unique fichier afin que le chargement soit plus rapide. Pour ce faire, nous allons utiliser l’outil esbuild.

Créez un fichier Dockerfile.prod comme suit

FROM node:21 as builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN mkdir -p ./dist && npm run build

FROM node:21
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist

EXPOSE 80

CMD ["node", "dist/app.bundle.min.cjs"]

et modifier le fichier package.json

{
  "type": "module",
  "scripts": {
    "build": "esbuild ./src/index.js --bundle --minify --platform=node --outfile=./dist/app.bundle.min.cjs",
    "start": "node ./dist/app.bundle.min.cjs",
    "dev": "nodemon ./src/index.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "mongodb": "^6.1.0",
    "morgan": "^1.10.0",
    "nodemon": "^3.0.1"
  },
  "devDependencies": {
    "esbuild": "^0.19.5"
  }
}

Nous pouvons maintenant créer un nouveau fichier docker-compose à la racine de notre projet

version: '3.8'

services:
  api:
    container_name: td7-api
    build: 
      context: api
      dockerfile: Dockerfile.prod
    ports:
      - "3000:80"
    environment:
      - MONGO_URI=mongodb://mongodb:27017/
      - MONGO_DB=td7
    depends_on:
      - mongodb

  mongodb:
    container_name: td7-mongo
    image: mongo
    environment:
      - MONGO_INITDB_DATABASE=td7
    volumes:
      - mongodb_data:/data/db

volumes:
  mongodb_data:

Encore plus loin ?

Ajoutez un cache redis pour ne pas faire des appels inutiles à votre base de données. Pour s’assurer que tout fonctionne bien, ajouter une latence sur les requêtes à la base de données.

Ajoutez ensuite une petite interface web pour afficher proprement les contacts en utilisant le framework react.

npm create vite@latest

Configuration Docker

Une fois votre projet React créé, vous devrez modifier la configuration de votre application pour spécifiier le port à utiliser. Dans le fichier vite.config.js ou vite.config.ts suivant votre configuration, ajoutez les lignes suivantes :

server: {
    watch: {
      usePolling: true,
    },
    host: true,
    port: 80,
  },

Docker en mode production

En mode production, vous pouvez générer des fichiers html,css,js qui seront servis statiquement par un serveur Web. Vous pouvez utiliser la configuration Docker suivante :

FROM node AS builder

WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build


FROM nginx:stable-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Il faut maintenant définir le fichier de configuration pour le serveur nginx.

server {
    listen 80;
    server_name localhost;
    location / {
        root /usr/share/nginx/html;
        index index.html;
        try_files $uri $uri/ /index.html;
    }
}

Reverse proxy

Imaginons que dans notre configuration docker-compose, nous disposons d’un service pour l’interface utilisateur, nommé front, et d’un service pour l’API, nommé api. Pour contourner les restrictions imposées par la same origin policy, il est possible d’intégrer un conteneur Nginx qui aura pour rôle de rediriger les requêtes des clients vers le bon service backend.

Modifier le docker-compose

  reverseproxy:
      restart: always
    build:
      context: reverse-proxy/
      dockerfile: ./Dockerfile.dev
    depends_on:
      - api
      - front
    volumes:
      - ./reverse-proxy/log:/var/log/nginx
    ports:
      - 8080:80

Définir le Dockerfile pour le service reverse-proxy

FROM nginx

COPY nginx.conf.dev /etc/nginx/nginx.conf

Configuration nginx en mode dev

events { worker_connections 1024; }

http {
    sendfile on;

    server {
        listen 80;
        server_name localhost;

        error_log  /var/log/nginx/error.log;
        access_log /var/log/nginx/access.log;

        location / {
            proxy_pass http://front;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
            proxy_cache_bypass $http_upgrade;
            proxy_redirect off; 
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ^~ /api/ {
            proxy_pass http://api/;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
            proxy_cache_bypass $http_upgrade;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
}

Appel de l’API depuis le Front-end

Dans votre code front-end, pour appeler l’API, utilisez la fonction fetch avec le chemin relatif de l’API. Par exemple, si votre API a une route ‘/foo/toto’, vous pouvez l’appeler avec fetch('/api/foo/toto').

Configuration de redis

Fichier de configuration redis.conf

maxmemory 500mb
maxmemory-policy allkeys-lru
save 60 1000

Configuration docker pour redis

  redis:
    image: redis:latest
    volumes:
      - redis-data:/data
      - ./redis.conf:/usr/local/etc/redis/redis.conf
    command: ["redis-server", "/usr/local/etc/redis/redis.conf"]