• Docker
  • Bonnes pratiques pour un hôte Docker en "prod"

Si je ne me trompe pas, l'ARG n'est valide que pour la construction de l'image. Quand elle tourne c'est seulement l'ENV qui est pris en compte.

Merci déjà pour cette réponse.
Je ne sais pas si j'ai bien compris mais le user-namespace et l'arg "user: PID:GID" semblent correspondre à la même chose ? Sauf qu'avec le user-namespace tu ne déclares pas à chaque fois un user ? Et ce serait juste l'utilisateur qui exécute l'image ?
Par contre pour l'env ce serait l'utilisateur qui est utilisé dans l'image ?

Je plussoie le message de xataz, et je rajouterais pour le socket, dans le cas où tu as besoin de l'utiliser, d'avoir recours à un proxy granulaire comme celui-là : https://github.com/Tecnativa/docker-socket-proxy

Pour gérer l'isolation réseau des conteneurs utiliser des networks bridge que tu définis toi-même (plutôt que d'utiliser le bridge par défaut et d'utiliser des links, dépréciés d'ailleurs). Utilise le flag --internal si tu veux isoler un réseau de l'Internet par exemple (doit y avoir quelques précisions en plus).

Pour iptables, depuis les versions récentes de Docker, utilise la chaîne DOCKER-USER, puis dedans ajoute une chaîne de filtre qui accepte le traffic prévu vers l'extérieur, puis rejette le reste.

Enfin pour les runtimes je trouve que c'est un peu compliqué encore de conseiller ça, même si le niveau d'isolation est step up encore davantage. Je joue d'ailleurs avec actuellement pour voir comment bien le gérer (a priori c'est surtout niveau réseau que ça se corse car la résolution DNS par Docker ne fonctionne pas sans la stack réseau de l'hôte - mais tu peux y parer en définissant des adresses statiques).

Je ne sais pas si j'ai bien compris mais le user-namespace et l'arg "user: PID:GID" semblent correspondre à la même chose ? Sauf qu'avec le user-namespace tu ne déclares pas à chaque fois un user ? Et ce serait juste l'utilisateur qui exécute l'image ?

Alors y a plusieurs différences.

  • Le paramètre --user définit le user (UID/GID) par défaut du conteneur. Par défaut, sauf si USER est déclaré dans un Dockerfile, ça sera root.
  • Le user namespace est une feature du kernel qui permet de mapper différents user pour faire en sorte, par exemple, que root dans le conteneur =/= root sur l'hôte (ce qui empêche des catastrophes en cas de compromission d'un conteneur).
  • Enfin, certaines images proposent des variables d'environnement (ENV dans le Dockerfile) pour effectivement configurer un utilisateur. Ca ne garantit pas qu'il n'y a pas de root process dans le conteneur, par contre, mais c'est flexible et ça utilise la stratégie du "degrading privileges" (qui a ses défauts aussi).

En gros il faut utiliser un peu de tout ça selon ton besoin, tes images. Idéalement le namespace pour pas qu'un root escape soit dangereux pour la sécurité de l'hôte (d'ailleurs considère que n'importe quel user non-root qui a accès à Docker sans namespace sur l'hôte a tous les pouvoirs....), le paramètre user pour correspondre à tes besoins, et certaines images avec les variables qui "font tout".

Ce ne sont pas vraiment les mêmes choses mais ce sont des moyens mis en oeuvre pour plus ou moins le même but. Et c'est assez compliqué, j'en conviens...

Merci beaucoup @Wonderfall, c'est déjà un peu plus clair pour la partie userns, l'argument user et les variables user. Après comme tu dis reste à réussir à les mettre en pratique vu que c'est un peu compliqué surtout pour un débutant, j'ai ma VM de test je vais pouvoir faire des essais 😉

Je vais regarder aussi pour le docker-socket-proxy qui avait déjà été abordé un peu plus haut par @xataz ainsi que pour la config UDP de Traefik proposée par @Banip (je me fais un mémo en même temps avant de noter tout ça proprement)

Pour gérer l'isolation des containers tu ne conseilles pas de passer l'ICC à false donc ? Si l'option est dépréciée, je n'aurais pas la possibilité de faire communiquer les containers entre eux... Par contre dans le cas d'un Traefik v2 en front de plusieurs containers, je suis obligé de tous les mettre dans le même réseau bridge (que j'ai créé avant, par exemple nommé frontend) ou il y a une autre solution ?

Pour iptables, la chaine DOCKER-USER est à configurer soi-même ou par défaut elle laisse passer le trafic de/vers l'extérieur ?

Le runtime et le rootless pour le moment je n'y touche pas, on verra un jour 😃

Tu utilises un daemon.json pour configurer le daemon Docker ? Si oui, tu pourrais le partager pour avoir une idée ?

Merci !

C'est une excellente question quant à l'ICC et l'usage de --link.
Le link est effectivement déprécié : https://docs.docker.com/network/links/

Du coup pour ICC, je ne sais pas trop si c'est encore d'actualité, j'attendrais le retour de quelqu'un de plus expérimenté comme xataz. Je ferai des recherches approfondies dessus, mais il me semble que l'ICC aujourd'hui ne s'applique qu'au bridge par défaut.

L'idée c'est effectivement de gérer tes propres réseaux désormais. Par exemple imaginons tu un conteneur Postgres, il est inutile qu'il soit dans le réseau "frontend", et aussi inutile qu'il soit connecté à l'extérieur (d'où --internal). Tu pourras créer un réseau isolé dédié à l'app en question (app <-> db <-> redis par exemple).

Effectivement pour Traefik le plus commun est de créer un gros réseau dédié, c'est le plus simple. Par contre, toutes les apps de ce réseau peuvent communiquer entre "elles" donc il faut effectivement faire attention si par exemple la sécurité d'une app repose sur le proxy.

Alors il me semble (à vérifier) que Traefik doit simplement pouvoir router vers ces différents conteneurs, donc tu peux voir le sujet à l'envers et faire des réseaux uniques pour chaque lien Traefik <-> conteneur. Mais ça demande un peu plus de configuration, par contre c'est nécessaire pour vraiment bien isoler entre des conteneurs qui n'ont pas besoin de communiquer entre eux (et c'est la bonne pratique).

Pour iptables, la chaine DOCKER-USER est à configurer soi-même ou par défaut elle laisse passer le trafic de/vers l'extérieur ?

Elle est là par défaut, et oui elle laisse passer le traffic. Après pour bloquer spécifiquement l'accès d'un réseau Docker à l'extérieur, je n'ai pas utilisé iptables personnellement.

Tu utilises un daemon.json pour configurer le daemon Docker ? Si oui, tu pourrais le partager pour avoir une idée ?

Oui j'utilise daemon.json, le mien est très simple (enfin c'est relatif) et sur mon serveur perso je n'utilise pas encore le rootless. J'ai juste mis btrfs, le live restore activé, les runtimes alternatifs (gvisor, kata), etc. Rien de spécial ! 😄

{
    "data-root": "/docker",
    "debug": false,
    "live-restore": true,
    "userland-proxy": false,
    "iptables": true,
    "icc": false,
    "runtimes": {
        "runsc": {
            "path": "/usr/bin/runsc"
        },
        "kata": {
            "path": "/snap/bin/kata-containers.runtime"
        }
    },
    "storage-driver": "btrfs"
}

(Pourquoi je n'utilise pas les user namespaces : pas compatibles avec kata et gvisor, et c'est redondant avec eux. gvisor par exemple fonctionne avec son namespace séparé de l'hôte. Sinon, je conseille de l'utiliser, c'est une isolation bonne à prendre avec runc.)

hydrog3n mouaif bouffer xGB d'espace libre pour ça je trouve ça un peu con ^^ Une machine ça se monitore et comme une des règles de base c'est 50% d'espace libre minimum c'est assez tôt que t'as une alerte qui te permet d'éviter un disque plein !
Je préfère de la surveillance (de toute façon il y a d'autres choses à surveiller donc il en faut) à une bidouille.

    Merci encore @Wonderfall pour tous ces détails. J'allais commencer à tester l'usage de --link, je vais finalement m'en passer vu ton lien 😉

    Je vais aussi chercher de mon côté si je trouve des éléments sur ICC, dans tous les cas j'ai déjà un montage de réseau comme tu le décris en première partie, un frontend pour tout ce qui sort vers l'extérieur et un backend pour les DB, Redis ou autres qui n'ont pas besoin de sortir.

    Je vais revoir ce point, passer le backend en internal (le paramètre est aussi valable pour un bridge ? Je n'ai trouvé des utilisations qu'avec overlay jusqu'à présent mais je n'ai qu'un hôte Docker).
    Et pour le reste ça voudrait dire de créer un réseau (ou 2 si utilisation d'un backend) par app et tous les déclarer (pas les backend) dans le docker-compose de traefik ? Je n'en ai pas 100 non plus, peut-être une dizaine, c'est jouable. Les réseaux configurés au sein d'un même docker-compose n'ont pas de visibilité entre eux ?

    Pour iptables, je suis loin d'être un expert Linux mais tu utilises quoi du coup ?

    Merci pour le daemon.json, il y a déjà l'option data-root à côté de laquelle j'étais passé et qui peut être pratique. Les runtimes ce sera une étape future avant le rootless, je pense que mon niveau débutant ne me permet pas encore de m'aventurer là dedans 😁
    Le BtrFS il faudrait que je regarde aussi mais ça rajoute de la complexité en gestion si j'ai bien compris. Pour le moment je n'ai pas l'impression d'avoir rencontré de problème en ext4.

    En priorité ça va être user-namespace et réseaux, la suite reste à déterminer 🙂

    Dans les sources du script de bench sécurité du CIS, j'ai trouvé ceci :

    Alternatively, you can follow the Docker documentation and create a custom network and only
    join containers that need to communicate to that custom network. The --icc parameter only applies
    to the default docker bridge, if custom networks are used then the approach of segmenting networks
    should be adopted instead.

    Donc à priori effectivement ça ne s'applique qu'au default.

    J'ai trouvé ceci aussi pour vérifier si l'ICC est actif ou non :

    Get ICC setting for a specific network
    docker inspect -f '{{index .Options "com.docker.network.bridge.enable_icc"}}' [network]

    Et ceci pour créer le réseau en désactivant l'ICC (ne peut être fait qu'à la création du réseau) :

    Create a network and explicitly enable ICC
    docker network create -o com.docker.network.bridge.enable_icc=true [network]

    Il faut que je teste si la désactivation de l'ICC dans le daemon.json avant la création de nouveaux réseaux applique automatiquement la désactivation de l'ICC

    Je vais revoir ce point, passer le backend en internal (le paramètre est aussi valable pour un bridge ? Je n'ai trouvé des utilisations qu'avec overlay jusqu'à présent mais je n'ai qu'un hôte Docker).

    Visiblement oui. Dans mon docker-compose quand je spécifie un réseau qui doit être isolé de l'extérieur, j'utilise internal: true.

    Les réseaux configurés au sein d'un même docker-compose n'ont pas de visibilité entre eux ?

    Tu peux avoir plein de réseaux dans un même compose, ça ne pose pas de soucis. La visibilité c'est dès lors que 2 ou plus de conteneurs sont dans un même réseau. (Ou si tu utilises l'option host, mais déconseillé sauf cas particulier.)

    Pour iptables, je suis loin d'être un expert Linux mais tu utilises quoi du coup ?

    J'ai un iptables.conf basique comme ça :

    *filter
    :INPUT ACCEPT [0:0]
    :FORWARD DROP [0:0]
    :OUTPUT ACCEPT [0:0]
    :FILTERS - [0:0]
    :DOCKER-USER - [0:0]
    
    -F INPUT
    -F DOCKER-USER
    -F FILTERS
    
    -A INPUT -i lo -j ACCEPT
    -A INPUT -j FILTERS
    
    -A DOCKER-USER -i eno1 -j FILTERS
    
    # Allow existing connections
    -A FILTERS -m state --state ESTABLISHED,RELATED -j ACCEPT
    
    # Allow ping
    -A FILTERS -p icmp --icmp-type echo-request -j ACCEPT
    
    # Allow HTTP
    -A FILTERS -p tcp --dport 8080 -m conntrack --ctstate NEW --ctorigdstport 80 -j ACCEPT
    -A FILTERS -p tcp --dport 4430 -m conntrack --ctstate NEW --ctorigdstport 443 -j ACCEPT
    
    # Allow containers to interact with full addresses
    -A FILTERS -m state --state NEW -p tcp --dport 80 -j ACCEPT
    -A FILTERS -m state --state NEW -p tcp --dport 443 -j ACCEPT
    
    # Allow SSH
    -A FILTERS -m state --state NEW -p tcp --dport 22 -j ACCEPT
    
    # Reject everything else
    -A FILTERS -j REJECT --reject-with icmp-host-prohibited
    
    COMMIT

    C'est à titre d'exemple, j'ai viré des besoins perso, mais tu vois l'idée. (Et mes règles HTTP/HTTPS sont bizarres car mon conteneur Traefik est non-root.)

    Il faut que je teste si la désactivation de l'ICC dans le daemon.json avant la création de nouveaux réseaux applique automatiquement la désactivation de l'ICC

    De ce que j'avais compris l'ICC en tant qu'option n'a plus de sens dès lors que tu gères tes user-defined bridges. C'était surtout valable à l'époque quand il n'y avait qu'un seul bridge, etc.

    J'attendrais xataz pour t'en dire plus dessus. 🙂

    Merci pour les réponses @wonderyan, on va attendre le passage de @xataz pour avoir plus d'infos 🙂

    Pour l'ICC s'il est actif sur tous les réseaux et si le --link est déprécié je ne vois pas comment tu peux faire communiquer les containers entre eux. Vu que chez toi l'option est active dans le daemon.json tu n'as pas de problème de communication inter container (si tu utilises un traefik par exemple) ?

    Comme dit, ce paramètre ne s'applique a priori qu'au bridge par défaut. C'était une option plus ou moins indispensable avant que Docker ne se développe, car par défaut, tous les conteneurs étaient sur le même réseau. Pas terrible niveau isolation, donc c'était mieux de tout restreindre et d'utiliser des --link pour décider qui communique avec qui.

    Docker a évolué depuis et comme dit, tu peux créer tes propres réseaux bridge (user-defined bridge). C'est la façon "moderne" de faire communiquer des conteneurs entre eux. C'est aussi simple que ça :

    docker network create mon_reseau
    docker run -d --network mon_reseau --name conteneur_1 image_1
    docker run -d --network mon_reseau --name conteneur_2 image_2

    Et l'équivalent docker-compose est possible aussi.
    Et pouf les 2 conteneurs vont pouvoir communiquer ensemble. Les user-defined bridge ont la particularité de permettre une résolution DNS via Docker (contrairement au bridge par défaut qui utilisait /etc/hosts). Par exemple dans conteneur_1, tu peux tout à fait ping/curl conteneur_2 par exemple. A noter qu'avec des runtimes isolés, cette résolution DNS ne fonctionnera pas, donc il faut passer par des IP statiques, c'est pour ça je ne conseille pas pour l'instant de changer de runtime.

    Par exemple pour Traefik, tu peux créer un réseau app_frontend et ajouter Traefik et ton app à ce réseau, que tu précises à Traefik via le label sur l'app. Et imaginons que tu veuilles un réseau interne à ton app pour qu'elle communique avec un conteneur SQL, tu ajouteras les deux à un réseau commun (avec le flag --internal pour l'isoler de l'extérieur).

    En résumé, te casse pas la tête avec l'ICC, les --link et tout, fais des réseaux par groupe de conteneurs qui doivent communiquer entre eux !

    Merci encore @Wonderfall
    Je vais regarder tout ça et avancer sur le sujet, une dernière question sur le daemon.json. Est-ce que tu as testé les paramètres suivants, recommandés par le CIS d'après ce que j'ai pu lire :

    {
    "icc": false, # point déjà abordé, à gérer par la création de réseaux
    "userns-remap": "default", # à mettre en place (si non utilisation de runtime)
    "userland-proxy": false, # ? option assez floue pour moi
    "iptables" : true, # à activer pour que docker puisse manipuler iptables ?!
    "no-new-privileges": true, # ? un peu abstrait pour moi en utilisation réelle
    "log-driver" : "syslog", # pas d'importance sur le fonctionnement de docker
    "live-restore": true # à activer pour que les containers puissent continuer à fonctionner si problème de daemon
    }

    Merci !

    Pas de soucis !

    Comme tu peux le voir dans mon daemon.json, j'ai activé :

    • live-restore : cette fonction permet effectivement aux conteneurs de continuer à fonctionner sans que le daemon Docker soit actif. En effet Docker est une "coquille" qui orchestre le tout à l'aide des OCI runtimes (runc par défaut donc), donc c'est pertinent de pouvoir le mettre à jour sans tout devoir redémarrer.
    • userland-proxy : historiquement Docker utilisait une application dans l'espace utilisateur pour gérer les connexions des conteneurs, mais c'est une surface d'attaque supplémentaire. Docker moderne prend en charge iptables à la place (pas activé par défaut car iptables dépend des kernel, et quand c'est pas à jour...).
    • no-new-privileges : c'est une protection supplémentaire (utile avec runc) pour éviter des escalations de privilèges dans le conteneur. Par exemple, pour éviter qu'un utilisateur devienne root. Je conseille d'activer cette option aussi, je ne l'ai pas dans mon daemon.json car je le précise dans mes docker-compose (security-opt, je te laisse Google ça).

    Le reste on l'a déjà abordé. 🙂

    Et encore merci @Wonderfall il ne me reste plus qu'à mettre tout ça en application 😉
    Donc le userland-proxy est à mettre à false et iptables à true, les 2 vont de paire si je comprends bien.

    Je vais m'attaquer à ça cette semaine, si tu as d'autres astuces ou d'autres options (sécurité ou autre) à activer dans les docker-compose (comme no-new-privileges) je suis preneur aussi 🙂 merci !

      julienth37 Chacun fait ce qu'il veut je passe pas m'a nuit a regarder mes alertes. Si le serveur s'emballe sur l'espace disque c'est une simple solution pour débloquer rapidement l'espace disque et travailler correctement. Je ne dis en rien qu'un système d'alerte n'est pas utile.

      NicCo Notre discussion m'a inspiré à faire un article pour détailler mon aventure avec les runtimes si ça t'intéresse : https://wonderfall.space/gvisor-kata-containers/

      Après m'être renseigné longuement et avoir discuté avec quelques chercheurs sur des chans Matrix obscurs, je conseille vivement de s'intéresser à gVisor, c'est vraiment le futur ce truc. 🙂

        Génial cet article @Wonderfall
        Est-ce que tu penses que c'est jouable pour un débutant comme moi de se lancer sur gVisor ? Dans ce cas tu n'utilises pas les userns dans le daemon.json ?

          NicCo Je pense que c'est jouable, c'est pas dur à installer, ça demande en soit pas de configuration par défaut. Le but de gVisor c'est vraiment d'apporter des conteneurs sandboxés à tout le monde sans les défauts des VM.

          Alors le problème, c'est que peut-être tes applications ne tourneront pas bien avec gVisor. Faut tester au cas par cas, si tout va bien pour toi, alors franchement je ne vois pas le défaut, t'as une sécurité digne de production.

          Du coup non, pas besoin de cette option comme expliqué ici.

          Merci @Wonderfall
          Du coup tu peux choisir d'utiliser le runtime avec certaines applis et de rester avec le runtime par défaut avec d'autres ?

            NicCo C'est expliqué dans l'article, mais en gros, avec Docker en CLI tu précises avec --runtime, et dans un docker-compose c'est tout simplement runtime:. Si tu précises rien, ça sera runc, celui de base.