04 mai 2018

gVisor, WTF ?

Google vient d'annoncer un nouveau runtime pour les containers OCI (les images Docker quoi) : gVisor.

Décryptage. (je pique les illustrations de la page gVisor parce que j'ai la flemme de les refaire)

Linux n'a pas de concept natif de "container", contrairement à BSD avec ses Jails ou à Solaris avec ses Zones. Ce qu'on appelle un "container" n'est donc que l'émergence d'en empilement de divers mécanismes de sécurisation et d'isolation. En un sens c'est pratique parce qu'on peut tuner tout ça et désactiver deux ou trois trucs quand on en a besoin, mais quid de la sécurité du machin en termes de "sandbox" qui permet d'exécuter du code arbitraire sans risquer d'exposer la machine ?

Le runtime à la Docker (containerd) met en place l'arsenal mis à disposition par les noyeau Linux : isolation de visibilité des ressources (namespaces), restrictions (cgroup) et filtres sur les appels noyeau (capabilities, seccomp, apparmor, SELinux). En gros, on sait imposer que telle application a droit d'accéder à tels fichiers, uniquement en lecture, et avec un débit I/O de xx maximum, ce qui permet aux autres application d'avoir aussi des ressources I/O et de placer leur propres données sur le même disque sans risque.

Configuré correctement - la conf par défaut étant déjà pensée pour permettre tout ce qui est raisonnable à une application standard, et bloquer tout ce qui clairement n'est pas justifié - on est en principe à l'abris de tout débordement de la part d'une application malveillante / malcodée / lesdeuxmoncapitaine.


Notons au passage que le support du user-namespace dans Docker est encore minimal (et désactivé par défaut). C'est lui qui permet d'être "root" dans le container mais en fait non, utilisateur non-privilégié sur le système hôte. Idéalement chaque container devrait tourner avec un user ID distinct, comme on fait tourner les divers services d'un système Unix avec des uid/gid différents pour bien isoler qui a droit de faire quoi. Fin de la parenthèse

Bon ça c'est en théorie, parce qu'évidemment tout ça repose sur la solidité de ces mécanismes, et donc sur l'absence de bug dans le noyau. On a pu voir passer quelques articles détracteurs, à charge contre Docker, qui mettent en lumière un problème de partage de ressources au niveau du noyau lorsqu'un container a un comportement particulier, et qu'il finit par pénaliser les autres. On ne parle pas d'escalade de permission ou de fuite d'information, mais c'est déjà un soucis. Dans les articles que j'ai lu le problème était résolut en faisant une mise à jour du noyau (!). Bref, tout repose sur les épaules du noyau et de ses mécanismes de partage équitable et de protection des resources. Et même avec toute la bonne volonté du monde on se doute qu'il y a toujours un risque.

D'où l'idée de ne pas partager le noyau entre containers, et d'avoir un noyau par container. C'est le principe de KataContainers, la fusion des projets ClearContainer (intel) et runV (hyper). Dans les deux cas, on utilise un hyperviseur KVM et un noyau minimal dédié à chaque container. Ce n'est pas magique, ici aussi la machine hôte doit assurer un partage équitable et sécurisé des ressources entre les différentes machines virtuelles + container. Mais en ajoutant cette indirection on limite le risque qu'un bug donné dans le noyau expose significativement la machine hôte.


La partie "virtual hardware" est là où le bâs blesse : Si vraiment on émule le CPU, les disques, la mémoire ... on va se retrouver avec les performances d'un MO5 (c'est d'ailleurs comme ça qu'on fait un émulateur MO5 sur PC, mais c'est une toute autre histoire).

Vous vous doutez bien vu l'utilisation massive de VMs dans le Cloud que ça ne fonctionne pas comme ça : le "hardware" exposé à la machine virtuelle est une version pré-machée, super facile à utiliser, et qui fait passe plat avec les drivers du système hôte. Le noyau de la VM hébergée dispose lui aussi du driver qui va bien pour utiliser ce "hardware" tellement simple que le code fait quelques lignes (bon, quelques centaines, ça reste du code C :P).
On gagne en simplicité - donc on limite les risques de bugs => faille de sécurité - et en performances.

Cette technique de "para-virtualisation" (virt-io) permet de conserver une certaine isolation sans renoncer à la performance. Elle est parfois implémentée niveau hardware, en particulier par le CPU - sans quoi les machines virtuelles râmeraient dur dur, mais aussi sur certaines architectures par le contrôleur mémoire ou encore l'interface réseau...

Bref, les VMs ça marche (non, sans blague ?) et ça permet d'avoir ceinture + bretelle, ce qui est la tenue préférée de l'expert sécurité (sous son gilet pare-balle).

Ok donc qu'est-ce que gVisor vient faire là dedans ?

gVisor veut lui aussi séparer le noyau utilisé par un container du noyau hôte. Pour cela il utilise une technique qui n'est pas nouvelle : User Mode Linux avait déjà exploré la même approche, et le fait que le site soit sur sourceforge vous donne une idée de son ancienneté.

L'idée est de démarrer un process classique en user-space (pas root quoi) qui va servir de noyau à un sous-système hébergé, mais sans couche de virtualisation comme le propose un hyperviseur. Les appels systèmes que l'application fait au noyau hébergé sont alors implémentés sur la base des resources légitimes allouées au process.

Soucis principal : comment faire pour qu'un process standard intercepte les appels systèmes (qui par définition sont ... au coeur du systèmes donc dans le noyau). L'approche utilisée par UML et gVisor est de détourner ptrace, outil de debug Posix, qui permet en gros d'avoir un point d'arrêt sur tout appel système et de répondre à la place du noyau. Un bon gros hack quoi. Ca marche, mais c'est pas gratuit en termes de performances. Sans surprise d'ailleurs, c'est conçu pour du debug !

gVisor par ailleurs utilise un noyau développé en Go par Google. Alors je veux bien croire qu'ils sont très, très bon et que gVisor ne sort pas d'un chapeau, mais celle là je l'avais pas vue venir. Quand je parlais de re-coder Jenkins en Rust c'était pour la blague, coder un noyau OS en Go c'est, heu ... disruptif ?

Mais bon, admettons. Après tout ce "noyau" n'est là que pour filtrer ce qu'on veut bien exposer au container et produire une implémentation simple puisqu'il se base non pas sur du hardware mais sur un système Linux avec tout ce qu'il faut.

Malgré tout je reste perplexe : Certes ce pseudo-noyau permet d'expose quelque chose de plus propre au conteneurs que le fait un runtime Docker :

D'un côté de "vrai" mounts (bien camouflés quoi), de l'autre des montages qui montrent bien le runtime sous-jacent.

Certes ce noyau écrit exprès pour ça peut mettre en oeuvre des choses qui n'auraient aucun sens dans le noyau Linux upstream. Certes Google a une expérience sur le sujet qui lui permet d'être crédible. Il n'en reste pas moins que ça me semble étrange de développer une pile réseau dans ce Go-noyau là où Linux propose des pile réseau virtuelles (veth) depuis des lustres, et qui a priori donne satisfaction à tout le monde. Il y a donc une volonté de prendre le contrôle sur certains détails très fins de ces aspects, qui ne sont pas (encore) documentés

We plan to release a full paper with technical details and will include it here when available.

Ce qui m'amène au point principal, plutôt que de débattre des choix techniques : quel est le cas d'utilisation qui justifie tout ça ? Clairement ce n'est pas prévu pour un usage mainstream par Mme Sysadmin-Michu. Et ça, je l'ai pas trouvé dans la doc.

Mon hypothèse, indéniablement fausse, mais je la propose quand même :

Un noyau linux complet, même utilisant virt-io, est bien trop complexe et "intelligent" pour un contexte aussi trivial qu'une mono-application dans son conteneur, ce qui augmente les risques de bugs. L'idée de Google est donc d'avoir un noyau débile épuré qui n'expose que le strict nécessaire, avec une logique interne ultra-simple pour limiter les failles, puis de passer la main à un "adulte" pour ce qui est de la gestion plus délicate du vrai hardware.

NB: Ma compréhension du noyau est très insuffisante pour dire si cette vision des choses est réaliste / pertinente, donc prenez là pour ce qu'elle est.

Ce qui m'embête c'est que ce cahier des charges c'est ... celui de seccomp :-/ Ce serait donc une approche alternative pour ne pas se baser sur seccomp qui

  1. tourne en espace noyau
  2. ne permet de filtrer que partiellement les paramètres des appels (valeurs directes). Limitation que n'a pas Landlock qui lui succédera peut-être ?



Je ne pense pas qu'on ai du gVisor en production tout de suite (!). Il n'en reste pas moins que l'approche pose des questions, et que je n'arrive pas à trouver des réponse satisfaisantes. Autant dire que je ne vous ai sans doute pas donné la réponse que vous attendiez peut être dans cet article. Poussez-moi des commentaires si vous avez lu des infos intéressantes dessus ou un avis sur la question.

Autre point noir : gVisor n'a pas de logo, et ça c'est nul quand on cherche une illustration pour un billet de blog. Du coup tant pis pour vous, je vous met un David Gageot à la place.







3 commentaires:

Antoine Boegli a dit…

Ouais, vu de la Lune je dirais que c'est une sorte d'unikernel, façon OSv (http://osv.io/) ou quelque chose comme ça, mais avec pour fonctions de faire proxy sur un hôte.

Mathieu Lecarme a dit…

"un process classique en user-space (pas root quoi)"

Un process user space, n'est pas kernel space (comme un driver ou du pseudo code ebpf), root n'y est pour rien dans cette histoire.

Nicolas De Loof a dit…

le "pas root quoi" répondait à "classique" pas à user-space, mais la précision mérite d'être notée en effet