Introduction
Lorsque l'on développe une application Next.js, il est possible de choisir de la déployer sur des plateformes comme Vercel, qui offrent une intégration transparente et des fonctionnalités avancées dans la mesure où ces applications sont conçues pour être déployées dans de tels environnements. Cependant, il arrive que l'on souhaite exercer un contrôle total sur notre environnement de déploiement, que ce soit pour des raisons de coût, de personnalisation ou de conformité.
Dans ces cas, containériser notre application Next.js avec Docker devient une solution qui comporte de nombreux avantages. Cela nous permet de la déployer sur n'importe quel serveur ou service cloud qui prend en charge Docker, tout en garantissant une portabilité et une cohérence entre les environnements. C'est en effet le cas de ce blog par exemple.
Cependant, en construisant une image de manière classique, on se rend compte que celle-ci est assez volumineuse (plusieurs Go). Heureusement Next.js dispose d'un mode de build standalone, qui une fois couplé avec le principe de multistage build de Docker, permet de réduire drastiquement la tailel de l'image générée. Une image plus légère consomme moins de ressources, se déploie plus rapidement et réduit les coûts d'hébergement. Dans cet article, nous allons explorer comment créer une image Docker optimisée pour une application Next.js en utilisant le mode standalone et le build multistage.
Prérequis
- Avoir une application Next.js prête à être conteneurisée.
- Avoir Docker installé sur votre machine.
Le mode standalone de Next.js
Qu'est-ce que le Mode Standalone de Next.js ?
Le mode standalone de Next.js est une fonctionnalité qui permet de simplifier le déploiement de votre application en production. Lors de la construction de votre application avec Next.js, le mode standalone génère une version optimisée de votre application qui inclut uniquement les fichiers nécessaires pour l'exécution en production. En effet lors du build de l'application, les dépendances requises vont être "tracées" et seules celles-ci seront incluses.
Comment activer le mode standalone
Pour activer le mode standalone, il suffit d'ajouter une ligne dans le fichier next.config.js
module.exports = {
output: 'standalone',
}
Création du Dockerfile
Qu'est-ce que le multi-stage build ?
Le multi-stage build est une fonctionnalité avancée de Docker qui permet de créer des images plus légères et plus efficaces en divisant le processus de construction en plusieurs étapes distinctes. Chaque étape jouera un rôle précis et ne contiendra que les éléments lui permettant de le réaliser. Dans notre cas, on peut identifier 4 étapes :
- Image de base : Sert de fondation pour les étapes suivant en fournissant une image de base avec laquelle seront effectuées les opérations
- Installation des dépendances: Cette étape vise à installer les dépendances nécessaires à la construction de l'application. En y dédiant une étape, on s'assure qu'elle ne se retrouve pas dans l'image finale.
- Construction de l'application: Cette étape sert à construire l'application en se servant des dépendances installées dans l'étape précédente.
- Image de production: Ceci sera l'image finale, n'incluant que les fichiers nécessaires à l'exécution de l'application ainsi qu'un environnement configuré pour l'exécuter.
Étape 1 : Image de base
# Étape 1 : Image de base
# Utiliser Node.js comme image de base
FROM node:20-alpine AS base
Cette étape est très court et consiste simplement à définir l'image de base, on utilise ici une image Node Alpine qui comporte plusieurs avantages, notamment en termes de taille, de sécurité et de performance. On renomme cette étape en tant que base
Étape 2 : Installation des dépendances
# Étape 2 : Dépendances
# Installer les dépendances uniquement lorsque nécessaire
FROM base AS deps
# https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copier package.json et yarn.lock
COPY package.json yarn.lock ./
# Installer les dépendances
RUN yarn install --frozen-lockfile
Dans cette étape nous nous basons sur l'image base définie précédemment, et la renommons deps à présent. Comme expliqué précédemment, cette étape sert à installer les dépendances nécessaires à la construction de l’application. On ajoute d'abord la librairie libc6-compat pour éviter tout problème de librairie manquante (voir ici pour l'explication). On définit ensuite notre répertoire de travail comme étant /app puis on y copie nos fichiers package.json ainsi que yarn.lock dans mon cas car je me sers de yarn en tant que gestionnaire de paquets. Pour terminer, on lance la commande permettant l'installation des dépendances à partir du lockfile, afin de garantir que les versions exactes soient installées.
Étape 3 : Build
# Étape 3 : Build
# Reconstruire le code source uniquement lorsque nécessaire
FROM base AS builder
# Strapi
ARG STRAPI_URL=https://monurl.com
ENV STRAPI_URL=${STRAPI_URL}
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
# Construire l'application
RUN yarn build
Cette étape est celle qui se charge de créer le build de l'application Next.js en compilant le code source.
On reprend tout d'abord notre image de base, qu'on renomme en builder.
Viens ensuite la définition des variables d'environnement. Je spécifie ici l'url de mon backend directement dans le dockerfile mais il est possible de la passer en argument à docker lors de la construction de l'image.
Nous définissons ensuite /app en tant qu'espace de travail et les packages npm depuis l'image deps dans notre image actuelle, puis nous copions également tous les éléments du dossier courant dans notre espace de travail.
Pour terminer il ne reste plus qu'à construire l'application avec yarn build.
Étape 3 : Image de production
# Étape 4 : Production
# Image de production, copier tous les fichiers et exécuter next
FROM base AS runner
WORKDIR /app
# Définir les variables d'environnement pour la production
ENV PORT 3000
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
# Créer un groupe système et un utilisateur pour exécuter l'application
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copier les fichiers nécessaires de l'étape de construction
COPY /app/public ./public
# Définir les permissions correctes pour le cache de pré-rendu
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Utilisation du tracing pour réduire la taille de l'image
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
# Passer à l'utilisateur nextjs
USER nextjs
# Exposer le port de l'application
EXPOSE 3000
# Commande pour exécuter l'application
# server.js est créé par next build à partir de la sortie autonome
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["node", "server.js"]
Cette dernière partie vise à construire l'image finale de production, qui sera utilisée pour être déployée.
À nouveau, on se base sur l'image base renommée en runner et on définit le répertoire de travail.
L'étape suivante consiste à définir les variables d'environnement utiles à l'environnement de production.
Pour des raisons de sécurité, nous créons un nouveau groupe et un nouvel utilisateur, qui serviront à exécuter l'application.
Nous copions ensuite le dossier public comportant les assets publiques depuis l'image builder ainsi que les dossiers .next/standalone et .next/static/ dans notre image actuelle. Ces dossiers contiennent l'applciation compilée. On s'assure également que les droits nécessaires sont appliqués au dossier ./next/.
Pour terminer, on utilise l'utilisateur précédemment crée, on expose le port voulu et on lance le serveur généré par nextjs avec node.
Le fichier Dodckerfile final
# Étape 1 : Image de base
# Utiliser Node.js comme image de base
FROM node:20-alpine AS base
# Étape 2 : Dépendances
# Installer les dépendances uniquement lorsque nécessaire
FROM base AS deps
# Consultez https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine pour comprendre pourquoi libc6-compat pourrait être nécessaire.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copier package.json et yarn.lock
COPY package.json yarn.lock ./
# Installer les dépendances
RUN yarn install --frozen-lockfile
# Étape 3 : Build
# Reconstruire le code source uniquement lorsque nécessaire
FROM base AS builder
# Strapi
ARG STRAPI_URL=https://monurl.com
ENV STRAPI_URL=${STRAPI_URL}
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
# Construire l'application
RUN yarn build
# Étape 4 : Production
# Image de production, copier tous les fichiers et exécuter next
FROM base AS runner
WORKDIR /app
# Définir les variables d'environnement pour la production
ENV PORT 3000
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
# Créer un groupe système et un utilisateur pour exécuter l'application
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copier les fichiers nécessaires de l'étape de construction
COPY /app/public ./public
# Définir les permissions correctes pour le cache de pré-rendu
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Utilisation des traces de next.js pour optimiser la taille de l'image
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
# Passer à l'utilisateur nextjs
USER nextjs
# Exposer le port de l'application
EXPOSE 3000
# Commande pour exécuter l'application
# server.js est créé par next build à partir de la sortie autonome
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["node", "server.js"]
Construction de l'image
Il ne reste plus qu'à lancer la commande suivante :
docker build . -t mon-image:latest
Docker va ensuite construire notre image, on peut suivre l'avancement précis de l'opération :
On vérifie la taille de l'image et on voit qu'elle fait à peine 200Mb, contrairement à environ 2Go sans optimisation, on a donc réduit la taille notre image d'un facteur 10 !