PHP et Unikernel. Et si Docker était de l’histoire ancienne ?

Guillaume Loulier
12 min readDec 15, 2021

Bien le bonjour à tous, soyez les bienvenus 👋

Dans cet article, nous allons discuter d’Unikernel, de Docker et de comment le premier pourrait bien (merci de noter l’usage du conditionnel) mettre le second à la retraite, oui, j’ose le postulat.

Petit rappel historique

Avant d’aller plus loin, revenons un instant sur l’état de l’art, de nos jours, nous utilisons Docker pour lancer, configurer et -souvent- déployer nos projets.

Je ne reviendrais pas sur ce qu’est Docker ni sur sa structure interne car ce n’est pas l’objectif premier ici mais pour vulgariser le sujet, voyez Docker comme un processus sur votre machine qui vous permet de lancer / gérer des processus tels que Redis, PostgreSQL, Php, Nginx, etc.

Pour introduire le reste de cet article, il faut cependant noter quelques points importants:

  • Docker utilise Linux (oui, j’en suis désolé) tout en étant plus lent que ce dernier.
  • Docker agit comme un wrapper autour d’un système d’exploitation.
  • Docker empaquète votre application mais crée un “vendor lock-in” de par son format de fichier, son architecture, etc.

Soyons clairs, Docker n’est pas une mauvaise solution, son approche centrée autour d’un format unique est suffisante et largement adoptée par nos applications, que les choses soient claires, l’idée ici n’est pas de “cracher dans la soupe” mais de noter les points positifs comme les points négatifs.

A mes yeux, le hic, c’est principalement le fait que nos applications se sont adaptées à Docker (on parle d’ailleurs de “dockerization”) et non l’inverse.

Qui n’a jamais rencontré de problèmes de performances ou de volumes non montés ou de ressources mal consommées ? Et sans parler du fait qu’un
environnement Docker a son propre contexte, ses propres scripts et ses conventions. Toutes ces spécificités ne nous viennent pas en aide quand il s’agit de migrer de Docker vers une installation plus “conventionnelle”.

Bref, Docker a rempli sa tâche première:

Proposer une architecture standardisée afin de déployer simplement nos applications quelque soit la plateforme.

Schématisons le problème

Mettons de côté temporairement Docker et pensons simplement à ce qu’une application représente au fond :

Un processus

Oui, une application aussi complexe soit-elle ne représente rien de plus qu’un processus sur notre machine, même si nous y branchons souvent une base de données ou autres services externes.

Nos applications ne sont, au bout du compte, que des processus.

Nous utilisons donc un gestionnaire de processus pour déployer un processus, ce qui peut sembler logique de premier abord, mais en prenant du recul, est-ce réellement le cas ?

https://nanos.org/thebook#architecture

En fait, Docker n’est pas juste un gestionnaire de processus, du moins, il n’est pas seulement un gestionnaire de processus.

En effet, Docker se positionne au-dessus de votre OS et en-dessous de votre application. En somme, sans Docker, pas d’application. Quand vous déployez votre application avec Docker, vous devez déployer ET Docker ET votre application.

Par rapport à une VM telle que nous la schématisons, l’élément en trop est Docker, et pour cause, il doit gérer le processus qu’est votre application.

En théorie, votre OS peut gérer votre application, en pratique, c’est ce que nous faisons depuis des décennies, quand on décide d’installer PHP et PostgreSQL sur la machine de production, on délègue la gestion du processus applicatif à l‘OS.

Et si ?

Si vous avez observé attentivement le schéma précédent, vous avez probablement remarqué le terme unikernel, le coeur même de cet article.

Cette approche n’est pas récente, en vérité, le sujet est discuté depuis des années et revient souvent dans les discussions comme étant la solution de demain, celle qui remplacera les containers et peut-être, enverra Docker à la retraite.

Quel est le concept derrière ce terme barbare ?

Unikernel est une architecture et non un software tout prêt, centrée autour du kernel, le coeur même de nos OS. L’idée est d’empiler nos applications de la manière suivante:

  • Le hardware qui représente votre machine locale ou distante (Cloud par exemple)
  • Un superviseur qui va gérer votre processus
  • Une application qui sert d’OS (oui oui)

Avant de crier à l’infamie, reprenez votre souffle. Hé oui, votre application est un OS. Et oui, c’est ainsi que vous devriez la concevoir, désolé de vous le dire aussi soudainement :)

J’invite ceux qui souhaitent découvrir plus finement les concepts et techniques derrière Unikernel à lire le whitepaper d’A. Madhavapeddy et D. Scott de 2014, qui présente de manière plus théorique et détaillée cette architecture de virtualisation.

Unikernel a engendré de nombreuses implémentations, et nous allons parler de l’une d’entre elles seulement, nanos, que nous connaissons le mieux.

Selon ses créateurs, nanos embarque environ 27 000 lignes de code là où le noyau linux à lui seul en embarque plus de 19 millions (chaque distribution étant différente, vous pouvez prendre ce chiffre comme une moyenne, qui est d’ailleurs en croissance régulière):

https://nanos.org/thebook#security

Avec 700x moins de code, la surface d’attaque est minimalisée et les performances optimisées :

https://nanos.org/thebook#performance

Toujours selon l’équipe derrière Nanos, un kernel Nanos pourrait démarrer en moins de 50ms, certaines expérimentations avancées auraient réussies à descendre autour des 2–3ms.

Tout ceci est bien sûr à relativiser.

En revanche, en comparaison avec Docker, qui est souvent critiqué pour sa lenteur et sa consommation de ressources, les Unikernel présentent des perspectives bien plus intéressantes d’optimisation du temps de démarrage et de limitation de la surface d’attaque.

Sur le long terme, on peut donc espérer pouvoir optimiser les ressources, c’est-à-dire de diminuer les coûts engagés, matériels mais aussi humains.

En pratique, c’est bien

Bien, maintenant que les grandes lignes sont établies, concentrons-nous sur le coeur du sujet : PHP et Unikernel.

Vous le savez certainement, PHP est un langage pré-compilé, de ce fait, nous ne pouvons pas modifier les extensions embarquées avec PHP lors de son exécution, ou en tout cas, pas de sorte à agir sur la taille de l’installation déjà effectuée.

Utiliser PHP avec Unikernel n’est pas complexe en soi. Avec Nanos, nous pouvons utiliser des packages déjà préparés pour nos applications.

Ici, j’utiliserai Ops, un orchestrateur écrit en Go qui permet de lancer et de configurer nos kernels et instances.

Avant d’aller plus loin, faisons un point sur la terminologie utilisée.

Package, Instance et Image

Si vous êtes familiers avec Docker, certains termes ne devraient pas vous surprendre, commençons donc par un terme connu de tous:

  • Image

Une image est le résultat de la transformation de notre application “en OS” pour nanos (appelé plus communément image disque en informatique) ce dernier est la “copie quasi parfaite” d’une image Docker, elle est basée sur un système de fichiers contenant votre code source (compilé ou non selon le langage) ainsi que les exécutables / extensions requis(es).

  • Package

Un package est l’équivalent direct d’une image mais pour un langage pré-compilé (PHP dans notre cas), en utilisant un langage compilé tel que Go ou Rust, Ops peut utiliser une image, en utilisant un langage pré-compilé, Ops doit utiliser un package, le but final est de construire une image sans avoir à compiler le langage soi-même (cela reste possible si besoin), dans les deux cas, vous obtiendrez au final, une image.

Note importante: Ops propose des packages officiels afin de lancer rapidement une application sous PHP, NodeJS et autres.

  • Instance

Une instance est une machine virtuelle qui tourne en utilisant une image, une instance peut être lancée en local ou sur un provider distant (GCP, Azure, AWS, etc), l’une des restrictions fortes des instances est que si vous lancez une instance localement, cette dernière ne peut pas utiliser une image déjà utilisée par une autre instance locale.

Maintenant que nous avons vu les termes importants, rentrons dans le vif du sujet et lançons une application PHP.

Docker, un compagnon de route

Pour simplifier cet article, j’ai pris la décision de ne pas partir de zéro quand à la création d’une image, en effet, Ops embarque nativement un support pour PHP 7.2 et 7.3 mais ici, je souhaite utiliser PHP 7.4 afin de tester une application Symfony, l’idée est de pouvoir lancer la console via un unikernel (pour peut-être s’en servir dans un cron ?) et limiter les couches lancées à chaque usage de l’application.

Comme précisé plus haut, pour pouvoir utiliser PHP 7.4, je pourrais créer ma propre image mais cela nécessiterait de compiler PHP, installer les extensions et préparer ladite image, soyons clairs, cette approche est tout à fait possible mais nécessiterait d’y consacrer un article complet (à venir ?).

Pour simplifier tout cela, Ops permet de récupérer une image Docker et d’en extraire les composants requis pour construire un package compatible avec un unikernel, pour ce faire, une sous-commande permet de spécifier une image Docker comme source:

ops pkg from-docker php:7.4-cli -f php -n php_74-cli

Avant d’aller plus loin, détaillons ce que fait cette commande:

  • ops pkg

Ce bloc va se concentrer sur la gestion des packages, si votre mémoire vous joue des tours, je vous invite à vous référer à la description plus haut.

  • from-docker php:7.4-cli

Ici, nous indiquons à Ops que la source du package se situe dans Docker, nous indiquons donc l’image ainsi que son tag à utiliser, note importante, je n’utilise pas la version Alpine car sa structure implique des choix techniques pouvant mener à des comportement inattendus plus tard.

  • -f php -n php_74-cli

Ce dernier bloc indique à Ops quel exécutable (ici php qui est aussi l’exécutable par défaut de l’image) doit cibler lors du lancement du package, la dernière option indique à Ops quel nom (ou tag si vous venez du monde Docker) doit recevoir le package une fois créé.

Une fois lancée, Ops va récupérer les fichiers / extensions requis(es) et préparer le package:

ops pkg from-docker php:7.4-cli -f php -n php_74-cli

Note: Ayant déjà téléchargé l’image localement, Docker n’a pas besoin de relancer l’intégralité du processus, selon votre installation locale, cela peut changer.

Le package final est disponible dans la liste des packages en utilisant:

ops pkg list — local

ops pkg list — local

Vous remarquerez que j’ai déjà pris la peine de créer plusieurs packages afin de tester la structure finale obtenue.

L’option local permet de lister les packages créés localement, en effet, le package actuel n’étant pas officiel, il n’apparaît pas dans le registry officiel.

Si vous souhaitez en savoir plus sur ce qui est récupéré depuis l’image Docker, je vous invite à utiliser l’option verbose, pour les plus impatients, un gist est disponible ici.

Et Symfony fit son entrée

Maintenant que le package a été créé, nous allons y stocker l’application Symfony afin de pouvoir utiliser la console, pour ce faire, je prépare une application Symfony via la CLI locale:

symfony new — full sf_ops

Si vous n’êtes pas à l’aise avec cette commande, sachez qu’elle va créer une application Symfony en local avec les bundles / bridges requis pour lancer le tout dans les meilleures conditions.

Une fois le projet créé, il est temps de configurer ce dernier afin qu’Ops puisse le stocker dans notre package, pour cela, créons un fichier config.json (simple convention ici, le nom du fichier importe peu):

config.json

Entrons dans les détails du fichier de configuration:

  • Dirs: Cette clé permet d’indiquer quel dossiers seront inclus dans le package.
  • Args: Cette clé permet d’indiquer au programme (PHP dans notre cas) quel fichier / commande doit être lancé(e) lors de l’exécution du package.
  • Env: Ici, nous pouvons spécifier une liste de variable d’environnement, pour éviter que des informations de debug soient affichées, je décide de passer l’environnement à prod.
  • BaseVolumeSz: Cette clé est liée au volume créé pour le package, chaque package / image peut obtenir un volume spécifique et par conséquent, une taille allouée, ici (et après de nombreux tests), 150 megabytes semblent suffirent pour une application Symfony, selon vos besoins, n’hésitez pas à allouer quelques megabytes de plus.
  • RunConfig: Cette clé permet d’indiquer à Ops la configuration utilisée durant l’exécution du package, plusieurs clés existent notamment pour la gestion des instances, ici, je me contente d’indiquer le nombre de CPU alloués.
  • RebootOnExit: Cette clé est plus spécifique à une application telle que Symfony, en effet, avec l’arrivée récente du composant Runtime, Symfony a découplé son état d’exécution de l’état global d’exécution, ce composant permet d’exécuter plus facilement nos applications selon différents environnements (containers, VM, unikernel ?).
    Dans le cas d’Ops, cela mène à une erreur 1 en sortie de la console, ce qui bloque l’usage de cette dernière, pour éviter ce problème, cette clé permet de redémarrer correctement l’application si une erreur survient.

Bien, maintenant que notre application est configurée, voyons comment lancer notre application dans le package précédemment construit, pour ce faire, voyons la commande suivante:

ops pkg load — local php_74-cli — config config.json

Rentrons dans le détail de cette commande:

  • ops pkg load

Cette commande va permettre de charger un package, dans notre cas, nous utiliserons le package défini plus tôt.

  • — local php_74-cli

Ici, nous précisons via l’option que le package à utiliser est un package local, nous utilisons donc le package défini plus tôt.

  • — config config.json

Cette dernière option permet de définir le fichier de configuration qui sera utilisé, par convention, j’utilise config.json mais comme expliqué plus haut, le nom importe peu.

Voici la sortie que vous devriez obtenir:

ops pkg load — local php_74-cli — config config.json

Bien, la console semble fonctionner correctement, pour aller plus loin, essayons de lancer une commande spécifique, pour ce faire, mettons à jour le fichier config.json:

config.json (lint:container)

Ici, je souhaite vérifier que mon container est capable d’injecter les services / paramètres avec les services qui y font appel, relançons la commande citée plus haut et observons le résultat:

ops pkg load — local php_74-cli — config config.json

Bien, la commande a été correctement lancée et tout semble se passer au mieux, revenons un instant sur ce qui s’est passé et ce que cela nous apprends sur unikernel.

En créant un package plus haut, nous avons défini un environnement de travail dédié, grâce au fichier config.json, Ops a chargé le package et y a injecté notre application, toujours grâce à ce fichier, le package a été lancé en utilisant les arguments comme point d’entrée dudit package.

Si cette approche vous semble familière, c’est tout à fait normal, votre OS fait de même à chaque démarrage, vos containers fonctionnent aussi de cette manière.

Imager et déployer

Maintenant que vous savez construire un package, voyons comment transformer ce dernier en image afin que l’on puisse le déployer plus tard.

De l’art de créer

La transformation d’un package en image est une étape indispensable et simple permettant d’utiliser cette image au sein d’une instance (déployée en local ou à distance comme expliqué plus tôt), pour ce faire, voyons la commande liée:

ops image create — local — package php_74-cli — imagename sf_console

Rentrons dans le détail:

  • ops image create

Cette commande permet de créer une image (merci Captain Obvious), jusque-là, rien de bien compliqué.

  • — local — package php_74-cli

Ici, nous spécifions à Ops que le package utilisé pour créer l’image provient d’un package local, sans cela, il faudrait construire l’image via une structure dédiée.

  • — imagename sf_console

Rien de très spécifique ici, nous définissons un nom pour l’image afin de pouvoir plus facilement la retrouver lors de la création d’une instance.

Rendre disponible sans craintes

Maintenant que notre image est créée, il est temps de créer une instance afin de pouvoir lancer tout cela au-delà d’une commande.

Pour ce faire, Ops embarque la création d’instance (si besoin, se référer plus haut) afin de déployer une image, voyons donc la commande dédiée:

ops instance create — instance-name sf sf_console

Rentrons dans le détail des options:

  • ops instance create

Il me semble que les explications ne sont pas nécessaires à ce stade.

  • — instance-name sf

Ici, nous définissons le nom de l’instance une fois lancée.

  • sf_console

Ce dernier argument permet de définir le nom de l’image utilisée (que nous avons créée plus tôt) par l’instance.

Une fois lancée, l’instance devrait furtivement s’afficher via la commande suivante:

ops instance list

ops instance list

Comme indiqué plus haut, l’instance devrait s’afficher furtivement, en effet, la commande que nous avons configurés plus tôt (lint:container) est relativement rapide à s’exécuter et la sortie est donc limitée dans le temps.

Pour obtenir la sortie console liée à cette commande, nous allons récupérer les logs de l’instance:

ops instance logs sf

ops instance logs sf

Bien, comme vous l’avez sûrement constaté, créer une instance et récupérer la sortie est relativement aisé, en prenant le parti de nous concentrer sur la console, notre instance ne reste pas longtemps disponible, l’usage d’un serveur HTTP aurait été une meilleure idée et bien plus représentatif des usages quotidiens que nous rencontrons.

Voici donc qui conclut cet article (le premier d’une série ?), nous avons vu ce qu’étaient les unikernel et comment les utiliser. Nous avons aussi aperçu les contraintes liées ainsi que leur usage dans un cas plus concret, bien sûr, il existe une multitude de cas d’usages à explorer et je vous invite grandement à explorer cette technologie ainsi que ses implémentations (Unik et bien d’autres).

Dans un futur article, nous verrons comment lancer une application en utilisant un serveur HTTP puis comment déployer le tout via un hébergeur distant, n’hésitez pas à commentez et à poser toute question qui vous viendrez à l’esprit, merci encore pour votre lecture et passez une très bonne journée 👋

--

--

Guillaume Loulier

PHP developer @SensioLabs, Symfony addict (3, 4, 5 & 6 certified!)