Aller au contenu
English
Compte : non connecté, synchronisation possible Mon apprentissage
Selectionner le thème
English
Read in English

Docker USER : exécuter une image en non-root sans la casser

Par mis à jour le

Améliorer cette page

Logo Docker

Un audit demande que les conteneurs ne tournent plus sous root. Le geste paraît tenir en une ligne : ajouter un USER au Dockerfile, reconstruire, livrer. On le fait, et l'application cesse d'écrire, sur un répertoire que le Dockerfile vient de créer deux lignes plus haut. La tentation est alors de revenir en arrière, ou d'ouvrir le répertoire à tout le monde ; les deux font repartir l'application, et les deux ratent l'audit.

Cette leçon montre pourquoi, en séparant trois décisions que USER fait croire confondues, puis elle règle la façon dont l'image démarre. Elle ne traite que l'identité, les propriétaires et le démarrage. La leçon Organiser les couches et exploiter le cache vient après : elle traite de l'ordre des instructions du Dockerfile et du cache de construction, et n'est pas un prérequis. Les mesures qui suivent portent sur Docker 29.9.2, avec un socle alpine épinglé par digest, l'empreinte qui désigne son contenu exact.

  • Trois décisions distinctes : l'identité d'exécution, les droits des fichiers, la déclaration que l'image transmet
  • Le piège mesuré : USER change l'uid du processus et aucun droit sur ce qui est déjà copié
  • La bonne réponse : aligner le propriétaire au build, pas élargir le mode
  • L'ordre des instructions : pourquoi USER se déclare en dernier
  • Le démarrage : ENTRYPOINT, CMD et la forme exec, qui décident du processus qui reçoit les signaux
  • COPY --chown : ce qu'il règle, ce qu'il laisse intact, et comment il échoue en silence
  • L'uid numérique : ce que docker image inspect transmet, et pourquoi un nom ne suffit pas
  • Ce qui n'est pas garanti : --user au lancement écrase le USER de l'image
  • Les ports sous 1024 : la vraie raison pour laquelle un non-root y écoute dans un conteneur

La leçon Écrire un Dockerfile suffit comme socle : FROM, RUN, COPY et CMD. Il faut aussi un moteur Docker en service, et un répertoire de travail jetable.

L'application d'exemple fait deux choses, et c'est volontaire : elle dit son identité et elle écrit un relevé de charge machine. Une image qui se contente de lire passerait en non-root sans que rien ne casse, et le piège resterait invisible. Le script est exécutable sur l'hôte (chmod +x collecte.sh) avant chaque construction.

collecte.sh
#!/bin/sh
set -e
echo "uid=$(id -u) gid=$(id -g)"
cut -d' ' -f1 /proc/loadavg >> /srv/collecte/charge.log
echo "releves=$(wc -l < /srv/collecte/charge.log | tr -d ' ')"

Sortir une image de root fait intervenir trois choses qui se décident séparément, à des moments différents, et qui ne se remplacent pas.

L'identité d'exécution est l'uid que prend le processus, c'est-à-dire le numéro qui désigne un utilisateur pour le noyau (le gid, son équivalent pour un groupe). Elle se pose avec USER, et c'est tout ce que USER fait.

Les droits des fichiers sont le propriétaire (un uid et un gid) et le mode (qui peut lire, écrire ou exécuter) de ce que l'image contient. La leçon Droits sur un volume les lit en détail. Ils sont gravés dans les couches au moment où chaque instruction s'exécute, et aucune déclaration d'identité ne les modifie après coup.

La déclaration de l'image est ce que l'image transmet à qui la lance : le champ Config.User, que docker image inspect rend. Ce n'est pas l'identité effective du conteneur, c'est une valeur par défaut, et le schéma ci-dessous montre à quel endroit la ligne de commande peut la contredire.

Deux chaînes indépendantes : USER alimente la déclaration de l'image, le chown du build fixe le propriétaire des fichiers. Au lancement, docker run --user peut imposer un autre uid, et un propriétaire resté à root fait échouer l'écriture.ExécutionImage livréeConstruction de l'imagenonouipropriétaire resté rootUSER 20500:20500chown au buildou COPY --chownDéclarationConfig.UserPropriétaire des fichiersgravé dans les couchesdocker runpose --user ?uid de la lignede commandeuid déclarépar l'imageL'application écritPermission denied
L'écriture ne réussit que si les deux chaînes se rejoignent : l'uid effectif et le propriétaire du répertoire. Source · SVG
Échap pour fermer, + et − pour zoomer, défilement pour se déplacer. 100 %

Voici l'image de départ. Elle fonctionne, et elle tourne sous root. CMD fixe la commande que l'image lance par défaut, et la section sur le démarrage y revient.

Dockerfile de départ
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
RUN mkdir -p /srv/collecte
COPY collecte.sh /usr/local/bin/collecte
CMD ["/usr/local/bin/collecte"]

Ajoutons un utilisateur et la ligne qui change son identité, sans rien toucher d'autre :

Dockerfile avec USER seul
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
RUN mkdir -p /srv/collecte
COPY collecte.sh /usr/local/bin/collecte
RUN adduser -D -u 20500 collecte
USER 20500:20500
CMD ["/usr/local/bin/collecte"]

Chaque variante du Dockerfile est construite sous sa propre étiquette, collecte:root, collecte:user, collecte:sans-root et ainsi de suite, avec docker build -t collecte:root . pour la première. Ce suffixe dit laquelle est en jeu. La même application, sous les deux écritures :

Fenêtre de terminal
docker run --rm collecte:root
uid=0 gid=0
releves=1
Fenêtre de terminal
docker run --rm collecte:user
uid=20500 gid=20500
/usr/local/bin/collecte: line 4: can't create /srv/collecte/charge.log: Permission denied

L'identité a bien changé : l'uid passe de 0 à 20500. L'écriture, elle, est refusée. Il suffit de regarder le répertoire dans l'image pour voir pourquoi. L'option --entrypoint stat remplace la commande de l'image par stat, qui affiche propriétaire et mode sans lancer l'application :

Fenêtre de terminal
docker run --rm --entrypoint stat collecte:user -c '%u:%g:%a %U' /srv/collecte
0:0:755 root

Le RUN mkdir -p /srv/collecte s'est exécuté avant la ligne USER, donc sous root, et le répertoire appartient à root avec un mode 0755. Un processus d'uid 20500 n'y écrit pas, et c'est la règle ordinaire du système de fichiers : USER ne touche aucun fichier, aucun répertoire, aucun droit.

Deux corrections font repartir l'application. La première consiste à donner le répertoire à l'utilisateur ; la seconde, à l'ouvrir à tout le monde avec un chmod 777. Les deux marchent, et la seconde défait exactement ce que le passage en non-root cherchait : n'importe quel processus du conteneur, quelle que soit son identité, peut alors y écrire.

La commande install -d crée un répertoire et fixe d'un coup son propriétaire (-o), son groupe (-g) et son mode (-m) : le répertoire n'existe jamais dans un état intermédiaire, propriété de root.

Dockerfile corrigé
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
RUN adduser -D -u 20500 collecte
RUN install -d -o 20500 -g 20500 -m 0700 /srv/collecte
COPY collecte.sh /usr/local/bin/collecte
USER 20500:20500
CMD ["/usr/local/bin/collecte"]
Fenêtre de terminal
docker run --rm collecte:sans-root
docker run --rm --entrypoint stat collecte:sans-root -c '%u:%g:%a %U' /srv/collecte
uid=20500 gid=20500
releves=1
20500:20500:700 collecte

Le mode 0700 n'est pas décoratif : il dit que le droit vient du propriétaire et d'aucun autre. C'est ce qui distingue une correction d'un contournement, et un contrôle automatisé sait faire la différence en lisant le dernier chiffre du mode, celui des autres, qui doit rester sans écriture.

Deux détails de forme comptent dans ce Dockerfile. La création et l'attribution tiennent dans la même instruction, parce que le répertoire n'existe qu'à partir de là. Et l'uid est fixé à 20500 plutôt que laissé à l'image de base, pour que la valeur ne change pas quand l'image de base change de version.

Les autres étiquettes de la leçon sont des variantes de ces deux Dockerfile, dont on ne change que la ligne que chaque mesure décrit. Le tableau donne, pour chacune, le Dockerfile de base et la différence exacte :

ÉtiquetteDockerfile de baseCe qui change
collecte:chownde départla ligne COPY devient COPY --chown=20500:20500 collecte.sh /usr/local/bin/collecte
collecte:chownnomde départla ligne COPY devient COPY --chown=collecte:collecte collecte.sh /usr/local/bin/collecte, sans adduser avant
collecte:nomcorrigéUSER 20500:20500 devient USER collecte
collecte:nominconnucorrigéUSER collecte à la place de USER 20500:20500, et la ligne adduser supprimée
collecte:uidseulcorrigéla ligne adduser supprimée, USER 20500:20500 conservé

Toute instruction placée après USER s'exécute sous cette identité, sans pouvoir ni créer ni donner. Déplacer la ligne de trois rangs suffit à casser le build :

USER placé trop haut
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
RUN adduser -D -u 20500 collecte
USER 20500:20500
RUN mkdir -p /srv/collecte && chown collecte:collecte /srv/collecte
Fenêtre de terminal
docker build -t collecte:ordre .
[...]
> [3/3] RUN mkdir -p /srv/collecte && chown collecte:collecte /srv/collecte:
0.130 mkdir: can't create directory '/srv/collecte': Permission denied
------
Dockerfile:4
--------------------
2 | RUN adduser -D -u 20500 collecte
3 | USER 20500:20500
4 | >>> RUN mkdir -p /srv/collecte && chown collecte:collecte /srv/collecte
5 |
--------------------
ERROR: failed to build: failed to solve: process "/bin/sh -c mkdir -p /srv/collecte && chown collecte:collecte /srv/collecte" did not complete successfully: exit code: 1

La règle tient en une phrase : USER se déclare en dernier, juste avant la commande de démarrage, une fois que tout ce qui demandait des privilèges est fait.

Comment l'image démarre-t-elle : ENTRYPOINT, CMD et forme exec ?

Section intitulée « Comment l'image démarre-t-elle : ENTRYPOINT, CMD et forme exec ? »

L'image démarre en lançant un seul processus, le PID 1 du conteneur (le processus numéro 1, celui auquel docker stop s'adresse), et l'utilisateur déclaré par USER est celui sous lequel il tourne. CMD donne la commande par défaut, que l'on remplace en la tapant après le nom de l'image dans docker run. ENTRYPOINT fixe au contraire le programme lancé : ce que l'on tape après le nom de l'image devient ses arguments, et CMD ne fournit plus que les arguments par défaut. Seule l'option --entrypoint, vue plus haut avec stat, le remplace.

Les deux s'écrivent sous deux formes. La forme exec est une liste JSON, comme dans les Dockerfile de cette page : CMD ["/usr/local/bin/collecte"] lance le programme directement, et c'est lui qui devient PID 1. La forme shell, CMD /usr/local/bin/collecte, confie la commande à /bin/sh -c, et ce qui devient PID 1 dépend alors du shell de l'image : le shell busybox d'Alpine remplace lui-même une commande unique par l'application, mais dès que la ligne enchaîne plusieurs commandes, le shell reste PID 1 et l'application n'est que son enfant. Or c'est à PID 1 que docker stop envoie le signal d'arrêt, et un shell ne le retransmet pas à l'application. Celle-ci ne s'arrête alors pas proprement, et le moteur finit par la tuer d'autorité.

La leçon Cycle de vie d'un conteneur et signaux mesure ces cas ; retenez ici deux règles : écrire ENTRYPOINT et CMD en forme exec, qui ne laisse rien au hasard du shell, et vérifier que le fichier lancé est exécutable et lisible par l'uid déclaré, puisque c'est ce compte qui le démarre.

COPY --chown : ce qu'il fait, et ce qu'il ne fait pas

Section intitulée « COPY --chown : ce qu'il fait, et ce qu'il ne fait pas »

COPY --chown pose le propriétaire sur ce qu'il copie, au moment où il le copie. Il dispense d'un RUN chown séparé, et il économise une couche.

COPY --chown=20500:20500 collecte.sh /usr/local/bin/collecte
Fenêtre de terminal
docker run --rm --entrypoint stat collecte:chown -c '%n %u:%g %a' /usr/local/bin/collecte /srv/collecte
/usr/local/bin/collecte 20500:20500 755
/srv/collecte 0:0 755

Le fichier copié appartient bien à 20500, et son mode reste celui qu'il avait dans le contexte de build : --chown change le propriétaire, pas les droits. Le répertoire /srv/collecte, créé par un RUN mkdir plus haut, appartient toujours à root : l'application échoue encore. Ce que COPY --chown ne fait pas est donc aussi important que ce qu'il fait. Il ne touche que ses propres destinations, il ne crée aucun utilisateur, et il ne corrige rien de ce qu'une autre instruction a déposé.

Il a un troisième comportement qui surprend. Un nom d'utilisateur se résout dans le /etc/passwd de l'image telle qu'elle est à cette étape. Si le nom n'existe pas, le build ne s'arrête pas : le fichier est écrit en 0:0, en silence. Avec COPY --chown=collecte:collecte et aucun adduser avant :

Fenêtre de terminal
docker build -t collecte:chownnom .
docker run --rm --entrypoint stat collecte:chownnom -c '%u:%g' /usr/local/bin/collecte
[...]
#8 naming to docker.io/library/collecte:chownnom done
#8 unpacking to docker.io/library/collecte:chownnom 0.0s done
#8 DONE 0.2s
0:0

Avec un RUN adduser -D -u 20500 collecte placé avant, le même COPY --chown=collecte:collecte rend bien 20500:20500. Le nom fonctionne donc, à condition d'exister au bon moment ; l'uid numérique, lui, n'a rien à résoudre.

La référence Dockerfile annonce pourtant que le build échoue si /etc/passwd ou /etc/group manquent ou ne contiennent pas les noms donnés. Le code de BuildKit (solver/llbsolver/ops/user_linux.go) fait autre chose : il laisse l'uid et le gid à zéro quand le nom est introuvable. C'est le comportement mesuré ci-dessus qui fait foi, pas la phrase de la documentation.

USER accepte un nom comme des chiffres, et le conteneur tourne sous le bon uid dans les deux cas. La différence est dans ce que l'image transmet. Trois images, déclarées avec USER collecte, avec USER 20500:20500, et sans USER, donnent :

Fenêtre de terminal
docker image inspect collecte:nom --format '{{.Config.User}}'
docker image inspect collecte:sans-root --format '{{.Config.User}}'
docker image inspect collecte:root --format '[{{.Config.User}}]'
collecte
20500:20500
[]

Un nom se résout dans le /etc/passwd de l'image. Un outil qui refuse les conteneurs root ne lit pas ce fichier : il lit la déclaration, et il ne peut pas prouver qu'un nom n'est pas root. Un uid numérique, lui, se vérifie sans rien ouvrir. La forme 20500:20500 déclare l'uid et le gid ; 20500 seul est également numérique et accepté.

Le nom a un second défaut, celui-là brutal : il se résout au lancement, et une image qui déclare un nom absent de son /etc/passwd ne démarre pas du tout. Le build, lui, est passé sans un mot.

Fenêtre de terminal
docker run --rm collecte:nominconnu
docker: Error response from daemon: unable to find user collecte: no matching entries in passwd file
Run 'docker run --help' for more information

Un uid numérique n'a pas ce point de rupture : il démarre même si aucune entrée ne lui correspond, et id rend alors l'uid sans nom.

Fenêtre de terminal
docker run --rm --entrypoint id collecte:uidseul
uid=20500 gid=20500 groups=20500

Un chown 20500:20500 accepte les mêmes chiffres, si bien qu'une image non-root tient sans aucun adduser : elle tourne, elle écrit, et stat rend 20500:20500:700 UNKNOWN. Ce que l'entrée dans /etc/passwd apporte en plus est modeste mais réel : un $HOME (sinon /) et un uid que les outils savent nommer, là où whoami répond unknown uid 20500. C'est pour cela que le adduser reste dans le Dockerfile corrigé.

Le troisième cas mérite un regard : une image sans USER ne déclare rien du tout, pas même root. Elle tourne en root parce que c'est le défaut du moteur, et une chaîne vide ne se distingue pas d'une image dont on ignore l'intention.

La déclaration est une valeur par défaut, pas une garantie. Qui lance décide :

Fenêtre de terminal
docker run --rm --user 0:0 collecte:sans-root
uid=0 gid=0
releves=1

L'image non-root, lancée avec --user 0:0, tourne sous uid 0 et écrit sans difficulté. C'est pour cette raison qu'un contrôle qui veut mesurer ce qu'une image fait la lance sans aucune option d'identité : avec l'option, il mesurerait la commande. Et c'est aussi pourquoi le USER du Dockerfile ne remplace pas une politique côté plateforme : il dit l'intention de l'image, pas ce qu'une plateforme en fera.

Les ports sous 1024, et la croyance qu'ils entretiennent

Section intitulée « Les ports sous 1024, et la croyance qu'ils entretiennent »

On lit souvent qu'un processus non root ne peut pas écouter sous le port 1024. Dans un conteneur Docker, il y arrive :

Fenêtre de terminal
docker run --rm --user 20500:20500 alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a sh -c 'nc -l -p 80 & sleep 1; netstat -ltn | grep ":80 "'
tcp 0 0 :::80 :::* LISTEN

La raison n'est pas une capacité accordée au processus. Une capacité (capability) est un fragment des pouvoirs de root que le noyau accorde séparément, comme NET_BIND_SERVICE pour ouvrir un port bas ; la page Sécuriser Docker en production les présente. Un non-root dans un conteneur n'en a aucune :

Fenêtre de terminal
docker run --rm --user 20500:20500 alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a grep -E '^Cap(Prm|Eff|Bnd)' /proc/self/status
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 00000000a80425fb

L'ensemble effectif (CapEff, ce que le processus peut réellement exercer) est vide : seul l'ensemble englobant (CapBnd, le plafond) garde les capacités par défaut, et il ne sert qu'à borner ce qu'un processus pourrait acquérir. Ce qui rend le port 80 accessible est ailleurs, dans un réglage du noyau, ip_unprivileged_port_start (le premier numéro de port ouvert à tout processus), que le moteur pose dans l'espace de noms réseau du conteneur, c'est-à-dire dans la pile réseau propre à ce conteneur. Dans le conteneur, puis sur l'hôte :

Fenêtre de terminal
docker run --rm alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a cat /proc/sys/net/ipv4/ip_unprivileged_port_start
cat /proc/sys/net/ipv4/ip_unprivileged_port_start
0
1024

Deux mesures achèvent de le montrer. Retirer toutes les capacités du conteneur ne retire rien à l'écoute, et remettre le réglage à sa valeur habituelle fait revenir le refus, capacités intactes :

Fenêtre de terminal
docker run --rm --cap-drop ALL --user 20500:20500 alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a sh -c 'nc -l -p 80 & sleep 1; netstat -ltn | grep ":80 "'
docker run --rm --sysctl net.ipv4.ip_unprivileged_port_start=1024 --user 20500:20500 alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a nc -l -p 80
tcp 0 0 :::80 :::* LISTEN
nc: bind: Permission denied

Le réglage appartient à l'espace de noms réseau du conteneur, et un conteneur lancé avec --network host partage celui de l'hôte. Il n'a alors plus son réglage à lui : le non-root retombe sur la valeur de l'hôte, ici 1024, et le refus revient. Le moteur interdit en plus de le corriger depuis la ligne de commande.

Fenêtre de terminal
docker run --rm --network host --user 20500:20500 alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a nc -l -p 80 -w 1
docker run --rm --network host --sysctl net.ipv4.ip_unprivileged_port_start=0 alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a true
nc: bind: Permission denied
docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: sysctl "net.ipv4.ip_unprivileged_port_start" not allowed in host network namespace

La conclusion pratique est courte : dans un conteneur Docker tel qu'il est livré et hors --network host, le port d'écoute n'est pas un argument pour rester root. Le choix du port se décide sur la lisibilité et sur la publication, pas sur les privilèges. Le reste du durcissement d'un conteneur non privilégié, dont les capacités à retirer, est traité dans Sécuriser Docker en production.

Chaque variante construite reste dans le magasin d'images local. Cette commande retire celles de la leçon ; si vous n'avez pas construit toutes les variantes, elle signale celles qui manquent et retire les autres.

Fenêtre de terminal
docker rmi collecte:root collecte:user collecte:sans-root collecte:chown collecte:chownnom collecte:nom collecte:nominconnu collecte:uidseul

Le lab vous remet dans la situation du départ avec votre propre application : une image qui tourne, qui écrit, et qui est root. Vous ajoutez le USER, vous lisez le Permission denied, puis vous corrigez. La validation porte sur l'état du système et sur le moyen autant que sur l'effet : quatre contrôles de poids égal vérifient que l'image tourne sous un uid non nul sans qu'aucune option ne le lui impose, qu'elle écrit toujours son journal, que le répertoire de données lui appartient sans être inscriptible par tous, et qu'elle déclare son utilisateur par un uid numérique. Un chmod 777 fait passer le deuxième contrôle et échoue au troisième ; un USER par nom passe les trois premiers et échoue au quatrième.

  1. USER change l'identité d'exécution et aucun droit. Les répertoires créés avant lui appartiennent à root, et l'application non-root n'y écrit plus.

  2. Aligner le propriétaire, ne pas élargir le mode. Donner le répertoire à l'utilisateur au build, avec un mode qui n'ouvre rien aux autres, fait écrire l'application ; un chmod 777 aussi, et il défait le bénéfice du passage en non-root.

  3. USER se déclare en dernier, juste avant la commande de démarrage : toute instruction qui le suit s'exécute sans privilège et échoue à créer ou à donner.

  4. Le démarrage s'écrit en forme exec. ENTRYPOINT et CMD en liste JSON font de l'application le PID 1, celui qui reçoit le signal d'arrêt, alors que la forme shell laisse au shell de l'image le soin de décider, et le garde en PID 1 dès que la ligne enchaîne plusieurs commandes.

  5. COPY --chown ne vaut que pour ses propres destinations. Il ne crée aucun utilisateur, ne corrige rien de ce qu'une autre instruction a déposé, et retombe silencieusement sur 0:0 si le nom donné n'existe pas encore dans l'image.

  6. Déclarer l'uid en chiffres. Un nom se résout dans le /etc/passwd de l'image ; un uid numérique se lit dans Config.User sans rien ouvrir. Une image sans USER ne déclare pas root, elle ne déclare rien.

  7. La déclaration est un défaut, pas une garantie. docker run --user 0:0 ramène l'image non-root sous uid 0 : mesurer une image se fait sans option d'identité.

  8. Le port 80 n'oblige pas à rester root. Un non-root n'a aucune capacité effective dans un conteneur, et il y écoute quand même parce que le moteur abaisse ip_unprivileged_port_start à 0.

Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

6 questions
6 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

Ce projet vous est utile ?

Les guides, formations, quiz et labs restent gratuits. Si vous les utilisez régulièrement, vous pouvez contribuer à leur maintenance et à leur développement.

Devenir membre de soutien (nouvel onglet) Soutenir une fois (nouvel onglet) En savoir plus

Suivez aussi le projet sur LinkedIn