Sommaire
Un peu d’OCaml
Avant de parler de MirageOS, nous avons besoin d’une petite introduction à OCaml. OCaml est un langage né en 1996 et maintenu par l’Inria venant de la famille des langages ML (contraction de Meta Language : un langage de programmation généraliste fonctionnel). Il propose un système de types ainsi qu’une gestion automatique de la mémoire. Nous allons ici nous intéresser aux points qui ont dirigé MirageOS vers ce langage.
Un système de modules
OCaml a un système de modules, qui est régi par une règle : un fichier OCaml, un module ou unité de compilation. Un module est une implémentation d’un certain type de données ainsi que des fonctions permettant de traiter/manipuler ce type de données. On peut par exemple parler du module String
qui implémente les chaînes de caractères ainsi que les fonctions comme find_character
/trim
/ etc.
La particularité d’OCaml réside dans l’idée qu’une implémentation peut être décrite par une signature/interface, le fichier *.mli
. Ce dernier décrit ce qui doit être montré aux autres modules de son implémentation associée. Il est courant en OCaml d’abstraire/cacher le type de données et de décrire uniquement les fonctions associées au type. Il est même commun d’utiliser une signature/interface à plusieurs implémentations tel que :
module type ORDERED = sig
type t
val compare : t -> t -> int
end
Ici, on décrit une interface qui expose compare
. Cette dernière peut être utilisée avec type t = string
(et utiliser memcmp
) ou un entier type t = int
(et utiliser la soustraction).
Foncteur
Grâce à ce mécanisme de modules, on a la possibilité de produire un module/une implémentation à partir d’un autre module décrit par une interface tel que Ordered
. Ainsi, un simple dictionnaire associant une clé avec une valeur peut se spécialiser selon la clé tel que :
module Make (S : ORDERED) : sig
type 'a t
val empty : 'a t
val add : S.t -> 'a -> 'a t -> 'a t
end
Ce principe d’abstraction en OCaml est largement utilisé par la communauté, mais on le retrouve à une autre échelle en ce qui concerne MirageOS.
Fonctoriser les syscalls
Le principe de MirageOS et de son écosystème est de ne pas dépendre d’un appel système tel que ceux offerts par le noyau Linux. L’objectif est de s’abstraire des syscalls et d’injecter leurs implémentations à la production du système d’exploitation. De ce fait, on est en capacité autant d’introduire les syscalls usuels proposés par le noyau Linux comme ceux d’un micro-noyau pouvant être virtualisé avec Xen ou KVM.
L’idée est d’être en capacité de produire la logique applicative (votre service comme un site-web) qui est complétement abstrait de la logique du système - la stack TCP/IP, la couche de cryptographie, le système de fichiers. De cette manière, il devient possible de compiler votre application comme un exécutable ou comme un système entier qui peut être virtualisé via Xen ou KVM ou encore un système pouvant fonctionner sur des puces ESP32.
L’objectif est que le côté applicatif ne devrait pas changer.
Gestion de la mémoire
Depuis la version 3.9 (octobre 2020), seul le mode PVH(VM) est pris en charge, la partie bas niveau permettant le boot est assurée par Solo5 et la gestion de la mémoire est passée à la version de Doug Lea pour malloc.
L’éco-système de MirageOS
Ainsi, tout l’éco-système de MirageOS se fonde sur un principe d’abstraction de ce qui peut être considéré comme le système d’exploitation. Au-delà de ça, les composants du système sont eux-mêmes abstraits à l’aide d’interfaces comme ORDERED
. Ainsi, MirageOS se définit plus comme un outil permettant de faire la glue entre plusieurs composants à l’aide d’interfaces.
Par ce biais, il devient simple par exemple d’interchanger l’implémentation d’un type de données de l’un à l’autre sans changer le reste de la logique du système. C’est, concrètement, ce qui se passe lorsqu’on injecte la stack TCP/IP du système hôte lorsqu’on veut produire un simple exécutable avec MirageOS ou qu’on utilise une implémentation de la stack TCP/IP en OCaml: mirage-tcpip
.
Bien entendu, cette approche requiert que ces implémentations existent ! Et c’est le principal travail de l’équipe de MirageOS : implémenter des formats/protocoles/logiques qui peuvent être utilisés avec MirageOS. Au travers de ce travail d’abstraction, ces différentes implémentations peuvent être utilisées en dehors de MirageOS. Ainsi, la plupart des projets de MirageOS sont utilisés par la communauté dans d’autres contextes que celui de MirageOS comme :
- Windows
- Mac OSX
- votre navigateur web grâce à
js_of_ocaml
Quelques super-stars
Dans ces projets qui sont utilisés par d’autres personnes en dehors de MirageOS, nous avons :
irmin
mirage-tcpip
cohttp
ocaml-tls
ocaml-dns
Irmin
L’idée d’un système de fichiers n’est pas garantie par MirageOS et, même si nous avons essayé d’implémenter certains formats, l’équipe MirageOS a décidé de concentrer ses efforts dans l’implémentation d’un Key-Value store. Irmin est une abstraction de ce que devrait être une telle base de données. Mais, à la différence des systèmes tels que LMDB ou encore Git, Irmin n’offre qu’une abstraction commune. Ensuite, c’est à l’utilisateur de choisir son implémentation.
MirageOS utilise aujourd’hui Irmin avec une implémentation de Git en OCaml. Par ce dernier, un unikernel peut obtenir une base de données clé-valeur interne qui peut se synchroniser au boot avec un dépôt Git. Sur ce dernier, il peut se synchroniser ou il peut le mettre à jour. On parle alors de système de base de données persistant – même si le système s’éteint, il peut reprendre l’état dans lequel la base de données était juste avant de s’éteindre.
Irmin est actuellement utilisé par la crypto-monnaie Tezos afin de manipuler la block-chain.
Une autre utilisation concrète d’Irmin avec MirageOS est un système d’exploitation qui fait office de serveur DNS primaire dont le fichier zone
est stocké dans un dépôt Git. Ainsi, l’unikernel se synchronise avec ce dépôt (avec le protocol Git - comme un git clone
), il peut le modifier (comme git push
) et l’utilisateur peut tout autant modifier aussi et demander ensuite à l’unikernel de se resynchroniser (comme git pull
).
Dans le dernier cas, l’utilisateur peut décrire sa politique de merge (comment résoudre un conflit s’il y en a un) avec Irmin. Cela permet d’assurer la persistance des données en dehors de l’unikernel lui-même.
mirage-tcpip
Du fait que MirageOS est un système d’exploitation complet, l’équipe de MirageOS a finalement implémenté la stack TCP/IP au travers du projet mirage-tcpip. Au-delà de l’intérêt technique de ré-implementer une stack TCP/IP, cette dernière est industriellement utilisée par Docker.
Ce projet permet d’introduire un concept fondateur de MirageOS. L’objectif de l’outil mirage
est de produire, au mieux, un système complet capable d’être virtualisé sur KVM ou Xen, mais il permet aussi de produire un simple exécutable UNIX comme nous aurions l’habitude d’avoir. Le point crucial ici est la capacité de mirage
à orchestrer (indépendamment de l’application) les stacks selon la cible.
Pour ce qui est de la production d’un simple exécutable, mirage
va injecter la stack TCP/IP du système hôte. Pour ce qui est de KVM ou Xen, il va tout simplement injecter mirage-tcpip
(qui n’est fait qu’en OCaml). L’idée est de n’utiliser, du point de vue de l’application, qu’une interface (en l’occurrence mirage-stack) nous permettant de séparer la logique de l’application des autres fondements de notre système.
cohttp
Bien entendu, en tant que premier exemple réel d’un unikernel, il nous faut aussi une implémentation du protocole HTTP 1.1 : cohttp. Pour l’exemple, le site officiel de MirageOS est un unikernel utilisant cohttp
. Mais ce projet, comme la plupart des projets MirageOS, dépasse l’écosystème et fait partie intégrante du plus large écosystème d’OCaml.
Puisque le développement d’une bibliothèque OCaml pour MirageOS se fait toujours en abstraction du système, spécialiser le cœur pour un système comme Linux ou Windows devient plus facile. La qualité la plus reconnue des projets Mirage est leur capacité à pouvoir être utilisés sur pratiquement tous les systèmes. Bien entendu, cette qualité est fortement liée à OCaml aussi qui propose un runtime s’exécutant nativement sur une multitude de plateformes.
ocaml-tls
La conception d’un système entier nécessite aussi de disposer de primitives de cryptographie, qui sont usuellement disponibles avec OpenSSL (ou l’un de ses forks). Il n’est, pour autant, pas aussi simple de ré-intégrer un code C existant (dépendant généralement des syscalls POSIX) dans MirageOS qui n’a, pour le coup, rien de toutes ces primitives.
Un effort colossal a donc été fait pour ré-implémenter les primitives de cryptographie essentielles afin de pouvoir ré-implémenter le protocole TLS, nécessaire pour servir un site internet accessible en HTTPS.
Là aussi, ocaml-tls
est un projet phare limitant le prérequis d’instructions assembleur tout en proposant une bibliothèque avec des performances raisonnables. Encore une fois, il n’a absolument aucune notion de ce que peut être un socket ou de tout ce qui peut être POSIX-compliant.
ocaml-dns
Enfin, le domaine mirage.io est géré par un serveur primaire DNS qui est aussi un unikernel. Plusieurs services DNS tel qu’un résolveur et un service s’occupant du challenge DNS de let's encrypt sont disponibles grâce à ocaml-dns
. Ce projet s’inscrit dans l’ambition de proposer des micro-services : un système d’exploitation pour un service spécifique.
Vous lancer dans l’écriture d’un unikernel
Des exemples prêts à compiler sont disponibles sur le github dont le classique hello world entièrement reproduit ici :
open Lwt.Infix
module Hello (Time : Mirage_time.S) = struct
let start _time =
let rec loop = function
| 0 -> Lwt.return_unit
| n ->
Logs.info (fun f -> f "hello");
Time.sleep_ns (Duration.of_sec 1) >>= fun () ->
loop (n-1)
in
loop 4
end
Ce « noyau » se contente d’écrire quatre fois hello en 4 secondes et se termine. Si vous voulez expérimenter chez vous, vous devez mettre en place un environnement propice à la compilation et à l’exécution, par exemple avec les commandes suivantes (à adapter en fonction de votre distribution) :
sudo dnf install opam && \
opam init && opam update -yu && \
opam install mirage && eval $(opam env) && \
git clone https://github.com/mirage/mirage-skeleton && \
cd mirage-skeleton/tutorial/hello && \
mirage configure -t unix && make depend && make && \
./hello
(Dans cet exemple on produit un binaire exécutable mais en utilisant l’option de configuration mirage configure -t xen
on peut par exemple produire un noyau utilisable avec l’hyperviseur Xen.)
Abstraction avec les functors
Comme vous pouvez le constater, un functor Time
est utilisé pour générer la fonction start (qui est comme le main
en C). C’est un module qui respect la signature Mirage_time.S
. Grâce à ce module, nous pouvons utiliser la fonction sleep_ns
. Son implémentation dépend bien entendu de la cible de votre MirageOS:
- pour UNIX, comme dans cette exemple, nous allons utiliser Unix.sleep
- pour Solo5 (KVM ou Xen), nous allons utiliser une fonction spécifique disponible dans mirage-solo5
Ce choix d’implémentation est fait par l’outil mirage
, lorsque vous lancez: mirage configure
. Dans ce cas, on prend l’implémentation par défaut proposée par mirage
mais l’utilisateur peut très bien choisir son implémentation (tant qu'elle respecte la signature Mirage_time.S
).
Autres aspects
Il y aurait encore beaucoup à dire, car cet article ne peut être exhaustif !
Vous êtes invités à le compléter dans vos commentaires.