Aller au contenu

Environnement de production

Dans cette section, nous allons voir comment j’ai conçu un environnement de production avec Docker. Pour cela, j’ai créée deux Dockerfile (un pour la partie api, un autre pour la partie client) et un docker-compose.yml pour gérer l’orchestration des conteneurs.


Dockerfile

Back-end

Le Dockerfile pour la partie back-end est le suivant :

FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY gradle/ gradle/
COPY settings.gradle.kts build.gradle.kts gradlew ./
RUN chmod +x gradlew
RUN ./gradlew dependencies --no-daemon
COPY src/ src/
RUN ./gradlew build -x test --no-daemon
_______________________________________________________________________
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-jar", "app.jar"]

Expliquons un peu ce qu’il se passe ici ! Contrairement aux environnement de développement et de tests, nous avons deux étapes dans ce Dockerfile. C’est ce que l’on nomme le multi-stage ! La première étape, nommée builder, est utilisée pour compiler l’application. La seconde étape, la finale, est utilisée pour exécuter l’application.

  1. Image de base :

On utilise l’image eclipse-temurin:21-jdk-alpine, qui contient un JDK (Java Development Kit), nécessaire pour compiler et exécuter notre application Java. L’option alpine est une version allégée de l’image JDK, ce qui réduit la taille finale de l’image, et la version 21 est exploitée ici puisqu’il s’agit de la dernière version stable de Java au moment de l’écriture de ce document.

  1. Répertoire de travail :

La commande WORKDIR définit le répertoire de travail dans le conteneur. Toutes les commandes suivantes y seront exécutées. Ici, nous définissons /app, qui est le répertoire racine de notre application.

  1. Copie des fichiers de configuration Gradle :
    • gradle/ : contient les fichiers de configuration de Gradle.
    • settings.gradle.kts, build.gradle.kts, gradlew : fichiers de configuration et script Gradle pour lancer les tâches.

Ces deux étapes auraient pu aussi bien s’écrire en plusieurs autres, comme ceci :

COPY gradle/ gradle/
COPY settings.gradle.kts .
COPY build.gradle.kts .
COPY gradlew .

Le rendu est plus clair, mais la première méthode est plus optimisée puisqu’à chaque instruction COPY, Docker crée une nouvelle couche dans l’image. Et plus il y a de couches, plus l’image est lourde.

  1. Changement des permissions :

La commande RUN chmod +x gradlew permet de rendre le script Gradle exécutable. Dans un environnement Docker, les fichiers copiés depuis l’hôte vers le conteneur peuvent perdre leurs permissions d’origine, c’est pourquoi il faut parfois les redéfinir comme ici.

  1. Téléchargement des dépendances :

La commande ./gradlew dependencies --no-daemon télécharge les dépendances nécessaires à l’application. Le paramètre --no-daemon empêche l’utilisation du démon Gradle, dont on a pas besoin ici. Cette étape est importante pour éviter de télécharger à nouveau les dépendances à chaque reconstruction de l’image .

  1. Copie du code source :

Ensuite, nous copions le répertoire src/ qui contient le code source de l’application, les fichiers Java, les tests, les ressources, etc.

  1. Compilation de l’application :

La commande ./gradlew build -x test --no-daemon compile l’application. Le paramètre -x test permet d’ignorer les tests lors de la compilation, ce qui est utile pour réduire le temps de construction de l’image (ceci d’autant plus que les tests sont automatisés dans une pipeline). Le paramètre --no-daemon empêche l’utilisation du démon Gradle.

  1. Image finale :

La seconde étape du Dockerfile commence par la création d’une nouvelle image à partir de l’image eclipse-temurin:21-jre-alpine, qui contient un JRE (Java Runtime Environment), nécessaire pour exécuter l’application Java. L’option alpine est une version allégée de l’image JRE, ce qui réduit la taille finale de l’image, et la version 21 est exploitée ici puisqu’il s’agit de la dernière version stable de Java au moment de l’écriture de ce document.

  1. Création d’un utilisateur :

La commande RUN addgroup -S appgroup && adduser -S appuser -G appgroup crée un groupe et un utilisateur pour exécuter l’application. Cela permet d’améliorer la sécurité en évitant d’exécuter l’application en tant qu’utilisateur root. Si ce n’est pas fait, l’application s’exécute avec les permissions root, donc si un attaquant parvient à exploiter une vulnérabilité dans l’application, il pourrait obtenir un accès complet au système d’exploitation hôte. En exécutant l’application avec un utilisateur non privilégié, on limite les dégâts potentiels en cas de compromission.

  1. Répertoire de travail :

On redéfinit le répertoire de travail dans le conteneur à /app, comme dans l’étape précédente.

  1. Copie de l’application :

La commande COPY --from=builder /app/build/libs/*.jar app.jar copie le fichier JAR de l’application depuis l’étape de construction vers l’image finale. Le --from=builder indique que nous voulons copier le fichier depuis l’étape de construction nommée builder.

  1. Point d’entrée :

La commande ENTRYPOINT définit la commande à exécuter lorsque le conteneur démarre. Ici, nous exécutons l’application Java avec les options -XX:+UseContainerSupport, qui permet à la machine virtuelle Java de s’adapter aux ressources du conteneur (comme la mémoire et le CPU), et -jar app.jar, qui exécute le fichier JAR de l’application.

Front-end

Voici le Dockerfile pour le front-end :

FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json angular.json tsconfig.json ./
RUN npm ci
COPY . .
RUN npm run build
_______________________________________________________________________
FROM nginx:alpine
RUN addgroup -S nginxgroup && adduser -S nginxuser -G nginxgroup
COPY nginx.conf /etc/nginx/nginx.conf
USER nginxuser
WORKDIR /usr/share/nginx/html
COPY --from=build /app/dist/* ./
EXPOSE 80 443
CMD ["nginx", "-g", "daemon off;"]

Expliquons un peu ce qu’il se passe ici ! Comme pour le Dockerfile de l’api, nous avons deux étapes dans ce Dockerfile. La première étape, nommée build, est utilisée pour compiler l’application. La seconde étape, la finale, est utilisée pour exécuter l’application.

  1. Image de base :

On utilise l’image node:22-alpine, qui contient un Node.js (JavaScript runtime), nécessaire pour compiler et exécuter notre application Angular. L’option alpine est une version allégée de l’image Node.js, ce qui réduit la taille finale de l’image, et la version 22 est exploitée ici puisqu’il s’agit de la dernière version stable de Node.js au moment de l’écriture de ce document.

  1. Répertoire de travail :

Nous définissons le répertoire de travail dans le conteneur à /app, qui est le répertoire racine de notre application.

  1. Copie des fichiers de configuration :

    • package.json : contient les dépendances de l’application.
    • package-lock.json : verrouille les versions des dépendances.
    • angular.json : fichier de configuration de l’application Angular.
    • tsconfig.json : fichier de configuration de TypeScript.

  1. Installation des dépendances :

La commande RUN npm ci installe les dépendances du projet. Le paramètre ci est utilisé pour installer les dépendances à partir du fichier package-lock.json, ce qui garantit que les versions des dépendances sont exactement celles spécifiées dans le fichier de verrouillage.

  1. Copie du code source :

La commande COPY . . copie le reste des fichiers du projet dans le conteneur.

  1. Compilation de l’application :

La commande RUN npm run build compile l’application Angular en mode production. Cela génère les fichiers statiques de l’application dans le répertoire dist/.

  1. Image finale :

La seconde étape du Dockerfile commence par la création d’une nouvelle image à partir de l’image nginx:alpine, qui contient un serveur web Nginx. L’option alpine est une version allégée de l’image Nginx, ce qui réduit la taille finale de l’image.

  1. Création d’un utilisateur :

La commande RUN addgroup -S nginxgroup && adduser -S nginxuser -G nginxgroup crée un groupe et un utilisateur pour exécuter l’application. Cela permet d’améliorer la sécurité en évitant d’exécuter l’application en tant qu’utilisateur root. Si ce n’est pas fait, l’application s’exécute avec les permissions root, donc si un attaquant parvient à exploiter une vulnérabilité dans l’application, il pourrait obtenir un accès complet au système d’exploitation hôte. En exécutant l’application avec un utilisateur non privilégié, on limite les dégâts potentiels en cas de compromission.

  1. Copie de la configuration Nginx :

La commande COPY nginx.conf /etc/nginx/nginx.conf copie le fichier de configuration Nginx dans le conteneur. Le fichier de configuration Nginx est utilisé pour configurer le serveur web, définir les règles de routage, gérer les en-têtes HTTP, etc. Il est important de personnaliser ce fichier pour répondre aux besoins spécifiques de l’application.

  1. Définition de l’utilisateur :

La commande USER nginxuser définit l’utilisateur qui exécutera le conteneur. Cela permet d’améliorer la sécurité en évitant d’exécuter l’application en tant qu’utilisateur root. Nous faisons cette définition après la copie de la configuration Nginx pour éviter que l’utilisateur nginxuser n’ait pas les permissions nécessaires pour écrire dans le fichier de configuration.

  1. Répertoire de travail :

Nous redéfinissons le répertoire de travail dans le conteneur à /usr/share/nginx/html, qui est le répertoire par défaut pour les fichiers statiques de Nginx.

  1. Copie de l’application :

La commande COPY --from=build /app/dist/* ./ copie les fichiers statiques de l’application depuis l’étape de construction vers l’image finale. Le --from=build indique que nous voulons copier le fichier depuis l’étape de construction nommée build.

  1. Exposition des ports :

Le port 80 est le port par défaut pour le protocole HTTP et le port 443 est le port par défaut pour le protocole HTTPS. Cela permet de rendre ces ports disponible depuis l’extérieur du conteneur. Attention, l’exposition ici n’a qu’une influence sur la documentation de l’image et ne rend pas les ports automatiquement accessibles.

  1. Point d’entrée :

La commande CMD ["nginx", "-g", "daemon off;"] définit la commande à exécuter lorsque le conteneur démarre. Ici, nous exécutons le serveur Nginx avec l’option -g daemon off;, qui permet de garder le processus Nginx au premier plan et d’éviter que le conteneur ne se termine immédiatement. Nous préférons cette méthode à l’utilisation de ENTRYPOINT car elle permet de remplacer la commande par défaut si nécessaire, par exemple pour le débogage ou les tests.


Docker Compose

Pour orchestrer les conteneurs, j’ai créé un fichier docker-compose.yml à la racine du projet :

services:
database:
image: postgres:17
restart: unless-stopped
env_file: .env
networks:
- backend
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
TZ: Europe/Paris
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
retries: 5
interval: 3s
timeout: 30s
api:
build:
context: ./api
dockerfile: Dockerfile
cap_drop:
- ALL
security_opt:
- no-new-privileges
depends_on:
database:
condition: service_healthy
restart: unless-stopped
env_file: .env
networks:
- frontend
- backend
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://database:5432/${POSTGRES_DB}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
SPRING_PROFILES_ACTIVE: prod
TZ: Europe/Paris
client:
build:
context: ./client
dockerfile: Dockerfile
security_opt:
- no-new-privileges
tmpfs:
- /tmp
- /var/cache/nginx
depends_on:
- api
restart: unless-stopped
networks:
- frontend
ports:
- "80:80"
- "443:443"
networks:
frontend:
backend:
volumes:
postgres_data:

Ce fichier définit trois services :

  • database : conteneur PostgreSQL pour la base de données.
  • api : conteneur pour l’application back-end Spring Boot.
  • client : conteneur pour l’application front-end Angular.

Chaque service a ses propres configurations, telles que le nom du conteneur, l’image à utiliser, les dépendances, les volumes, les ports, les variables d’environnement, etc.

Détaillons un peu tout ça !


Base de données

database:
image: postgres:17
restart: unless-stopped
env_file: .env
networks:
- backend
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
TZ: Europe/Paris
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
retries: 5
interval: 3s
timeout: 30s

Le service database utilise l’image postgres:17 pour créer un conteneur PostgreSQL.

Voici les configurations principales :

  • image : image à utiliser pour le conteneur.
  • restart : politique de redémarrage du conteneur.
  • env_file : charge les variables d’environnement à partir d’un fichier .env.
  • networks : connecte le conteneur à un ou plusieurs réseaux.
  • environment : variables d’environnement pour la base de données.
  • volumes : volumes pour stocker les données de la base de données.
  • healthcheck : vérification de l’état de santé du conteneur.

L’image postgres:17 est basée sur l’image officielle PostgreSQL disponible sur le Docker Hub. On n’utilise pas la version alpine ici, car elle ne supporte pas les locales nécessaires pour la configuration de la base de données, ce qui peut poser des problèmes lors de son initialisation ou des incohérences avec des caractères spéciaux ou des langues spécifiques.

J’utilise la version 17 puisque c’est la dernière version stable de PostgreSQL au moment de l’écriture de ce document.

La politique de redémarrage unless-stopped garantit que le conteneur redémarre automatiquement après un crash ou un redémarrage de l’hôte, sauf s’il a été arrêté manuellement.

Le conteneur est connecté au réseau nommé backend, ce qui lui permet de communiquer avec d’autres conteneurs qui se trouvent sur le même réseau.

Les variables d’environnement POSTGRES_DB, POSTGRES_USER et POSTGRES_PASSWORD sont utilisées pour configurer la base de données, l’utilisateur et le mot de passe. Ces variables sont chargées à partir du fichier .env à la racine du projet.

Le volume nommé postgres_data est utilisé pour stocker les données de la base de données de manière persistante. Cela permet de conserver les données même si le conteneur est supprimé ou redémarré. Le volume est monté dans le répertoire /var/lib/postgresql/data du conteneur, comme indiqué dans la documentation de l’image PostgreSQL sur le Docker Hub.

La vérification de l’état de santé du conteneur est effectuée à l’aide de la commande pg_isready -U postgres, qui vérifie si le serveur PostgreSQL est prêt à accepter des connexions. Si le conteneur n’est pas prêt, il sera redémarré jusqu’à ce qu’il soit opérationnel. Les options retries, interval et timeout définissent respectivement le nombre de tentatives, l’intervalle entre les tentatives et le délai d’attente pour la vérification de l’état de santé.


API

api:
build:
context: ./api
dockerfile: Dockerfile
cap_drop:
- ALL
security_opt:
- no-new-privileges
depends_on:
database:
condition: service_healthy
restart: unless-stopped
env_file: .env
networks:
- frontend
- backend
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://database:5432/${POSTGRES_DB}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
SPRING_PROFILES_ACTIVE: prod
TZ: Europe/Paris

Le service api utilise un Dockerfile spécifique pour construire l’image du conteneur. Le fichier Dockerfile-dev se trouve dans le répertoire api/ du projet.

Voici les configurations principales :

  • build : définition du contexte et du fichier Dockerfile pour construire l’image.
  • cap_drop : supprime toutes les capacités du conteneur pour améliorer la sécurité.
  • security_opt : option de sécurité pour le conteneur.
  • depends_on : dépendance du service par rapport à un autre service.
  • restart : politique de redémarrage du conteneur.
  • env_file : charge les variables d’environnement à partir d’un fichier .env.
  • networks : connecte le conteneur à un ou plusieurs réseaux.
  • environment : variables d’environnement pour la base de données.

Comme il s’agit d’une image personnalisée, nous devons définir le contexte et le fichier Dockerfile à utiliser pour construire l’image. Le contexte est le répertoire dans lequel se trouve le Dockerfile que l’on souhaite utiliser. Ici, le contexte est ./api et le Dockerfile est Dockerfile.

Le conteneur est connecté aux réseaux frontend et backend, ce qui lui permet de communiquer avec d’autres conteneurs qui se trouvent sur ces réseaux.

Les options cap_drop et security_opt sont utilisées pour améliorer la sécurité du conteneur. L’option cap_drop supprime toutes les capacités du conteneur, ce qui réduit les privilèges du conteneur. L’option security_opt empêche l’élévation des privilèges dans le conteneur.

Le conteneur dépend du service database, ce qui signifie qu’il ne sera démarré qu’après que le service database soit opérationnel. La condition service_healthy garantit que le conteneur api ne sera démarré que lorsque le conteneur database sera prêt à accepter des connexions.

Les variables d’environnement SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME et SPRING_DATASOURCE_PASSWORD sont utilisées pour configurer la connexion à la base de données PostgreSQL. Ces variables sont chargées à partir du fichier .env à la racine du projet.


Client

client:
build:
context: ./client
dockerfile: Dockerfile
security_opt:
- no-new-privileges
tmpfs:
- /tmp
- /var/cache/nginx
depends_on:
- api
restart: unless-stopped
networks:
- frontend
ports:
- "80:80"
- "443:443"

Le service client utilise également un Dockerfile spécifique pour construire l’image du conteneur. Le fichier Dockerfile se trouve dans le répertoire client/ du projet.

Voici les configurations principales :

  • build : définition du contexte et du fichier Dockerfile pour construire l’image.
  • security_opt : option de sécurité pour le conteneur.
  • tmpfs : monte des répertoires en mémoire vive.
  • depends_on : dépendance du service par rapport à un autre service.
  • networks : connecte le conteneur à un ou plusieurs réseaux.
  • ports : ports à exposer pour la communication avec l’application.

Comme pour le service api, nous devons définir le contexte et le fichier Dockerfile à utiliser pour construire l’image. Le contexte est le répertoire dans lequel se trouve le Dockerfile que l’on souhaite utiliser. Ici, le contexte est ./client et le Dockerfile est Dockerfile.

Le conteneur est connecté au réseau frontend, ce qui lui permet de communiquer avec d’autres conteneurs qui se trouvent sur ce réseau.

Les options security_opt et tmpfs sont utilisées pour améliorer la sécurité du conteneur. L’option security_opt empêche l’élévation des privilèges dans le conteneur. L’option tmpfs monte les répertoires /tmp et /var/cache/nginx en mémoire vive, ce qui permet de stocker temporairement des fichiers sans les écrire sur le disque dur.

Le conteneur dépend du service api, ce qui signifie qu’il ne sera démarré qu’après que le service api soit opérationnel.

La politique de redémarrage unless-stopped garantit que le conteneur redémarre automatiquement après un crash ou un redémarrage de l’hôte, sauf s’il a été arrêté manuellement.

Les ports 80 et 443 sont exposés pour permettre la communication avec l’application. Le port 80 est le port par défaut pour le protocole HTTP et le port 443 est le port par défaut pour le protocole HTTPS. Cela permet d’accéder à l’application via un navigateur web.


Volume & réseaux

networks:
frontend:
backend:

Le fichier docker-compose.yml définit deux réseaux : frontend et backend. Ces réseaux permettent aux conteneurs de communiquer entre eux de manière sécurisée. En revanche, les conteneurs qui se trouvent sur des réseaux différents ne peuvent pas communiquer entre eux.

volumes:
postgres_data:

Le fichier docker-compose.yml définit également un volume nommé postgres_data. Ce volume est utilisé pour stocker les données de la base de données de manière persistante. Cela permet de conserver les données même si le conteneur est supprimé ou redémarré. Le volume est monté dans le répertoire /var/lib/postgresql/data du conteneur database, comme indiqué dans la configuration du service database.