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 appgroupUSER 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.
- 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.
- 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.
- 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.
- 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.
- 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 .
- 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.
- 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.
- 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.
- 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.
- Répertoire de travail :
On redéfinit le répertoire de travail dans le conteneur à /app
, comme dans l’étape précédente.
- 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
.
- 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.
- 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.
- 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.
-
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.
- 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.
- Copie du code source :
La commande COPY . .
copie le reste des fichiers du projet dans le conteneur.
- 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/
.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
.
- 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.
- 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.