Plongée dans l'OPcache

Magazine
Marque
GNU/Linux Magazine
Numéro
224
Mois de parution
mars 2019
Spécialité(s)


Résumé

Depuis le début de sa carrière comme simple outil de traitement de formulaires HTML, le PHP a considérablement évolué pour devenir un langage mûr et abouti. Mais, contrairement à d'autres, il n'a pas été conçu au départ pour prendre en charge la distribution de code compilé. Étudions ceci de plus près.


Body

Au cours de sa croissance, PHP a emprunté à ses voisins quelques fonctionnalités intéressantes telles que les traits, les types de retour, ou les classes anonymes. D'autres inspirations extérieures sont en cours d'étude par l'équipe de mainteneurs. Mais le langage a également conservé son ADN unique, avec notamment le système d'extensions et le cycle de vie centré sur le protocole HTTP. Un point de repère très répandu concernant la programmation en PHP, est que les fichiers de scripts sont remplaçables à la volée pour la prochaine requête.

Nous brossons dans cet article quelques observations autour de l'extension OPcache afin de proposer une modification simple qui pourrait ouvrir la voie au déploiement de programmes PHP pré-compilés.

1. Cycle d'exécution

À partir du PHP 5.5, le runtime est entré dans une nouvelle ère avec l'utilisation systématique des opcodes. L'exécution d'un script ne se produit plus directement pendant la consommation de ses lignes, mais le fichier est plutôt analysé dans son ensemble, et converti en mémoire en une série d'instructions élémentaires, appelées oplines. À ce stade, le parser — un des composants principaux dans le moteur PHP — a terminé son travail. Mais il reste toujours à dérouler l'exécution du script.

1.1 Machine virtuelle

La phase d'exécution est le travail de la virtual machine : l'autre composant majeur. Ne se reportant plus du tout au script d'origine, la machine virtuelle joue les oplines intermédiaires qui ont été produites par le parser.

Une opline est un peu comme l'atome du programme : une instruction (ou opcode) avec son petit nombre d'arguments (ou opérandes). L'ensemble formé par les différents opcodes est comparable au jeu d'instructions d'un processeur matériel. Simplement, les oplines d'un programme sont exécutées par un autre programme, appelé machine virtuelle. Plusieurs raisons peuvent motiver un langage de programmation à s'appuyer sur une machine virtuelle, comme l'indépendance à l'architecture ou encore le contrôle de l'allocation des ressources.

Les classes, traits, exceptions, lambdas, structures de contrôle, sont autant d'éléments de langage qui permettent au développeur d'exprimer son intention. À l'opposé, les oplines sont la partition à jouer, note après note, par le simple automate à états et à registres qu’est la machine. Le flux d'exécution comporte donc deux phases : compiler d'abord, puis jouer ensuite. Ceci sert deux objectifs :

  1. Créer une séparation hermétique entre la sémantique du langage, et les actions réelles que l'ordinateur doit effectuer. Ceci laisse d'ailleurs la place à une étape d'optimisation après compilation.
  2. Économiser sur l'effort d'analyse du script. La compilation, étape coûteuse, n'est à faire qu'une fois, et le résultat peut être exécuté à souhait — pour peu que les oplines soient conservées quelque part pour réutilisation. Ceci est l'objet de notre article.

1.2 Opcodes

PHP est un langage expressif. Il propose une variété d'instruments (programmation orientée objet, générateurs, etc.) que le développeur peut exploiter au travers d'un ensemble de mots-clés et une grammaire spécifique. En d'autres termes : une langue pour l'humain.

Aussi pratique que soit la possibilité de déclarer la valeur par défaut d’un argument de fonction par exemple, ce qui importe vraiment à la machine (matérielle ou virtuelle), c'est de savoir exactement quoi faire dans l'immédiat avec son petit jeu de registres. Une machine à états ne sait exécuter que de petites étapes, explicitement qualifiées. C'est un peu comme la liste de tous les tournants le long d'un trajet en voiture que le voyageur a tracé macroscopiquement sur la carte.

Voyons un exemple avec l'extrait suivant :

$orderlines []= (new OrderlineBuilder())
 ->withProductId($productId)
 ->withQuantity($qty)
 ->withPrice($price * (1 + $vatRate))
 ->withColorCode(Colors::codeForName($color))
 ->build();

Sémantiquement, ce code est composé d'une seule instruction. Pourtant pour pouvoir l'exécuter, la machine doit effectuer plusieurs étapes intermédiaires, que nous pouvons intuitivement lister :

  1. calculer le prix avec taxe ;
  2. déterminer le code couleur ;
  3. instancier un OrderlineBuilder ;
  4. invoquer successivement les méthodes with pour le configurer ;
  5. invoquer sa méthode build pour obtenir une Orderline ;
  6. ajouter la nouvelle instance à la collection.

Les instructions dont dispose le moteur PHP pour réaliser ces petites étapes, sont de type :

  • lire ou stocker la valeur d'une variable ;
  • faire une opération arithmétique sur deux nombres ;
  • tester une valeur booléenne ;
  • sauter à un autre endroit du programme ;
  • instancier un objet, lever une exception, et encore beaucoup d'autres.

Le Zend Engine 2 possède plus de 200 opcodes [1]. Chacun n'est en fait qu'un simple nombre, mais on lui fait correspondre un mot mnémonique pour la lisibilité : ADD vaut 2, ASSIGN est le 38, NEW correspond à 68. Utilisez l'extension expérimentale VLD [2] pour visionner les oplines de vos scripts. Vous pouvez aussi vous rendre sur 3V4L.org [3] pour un aperçu en ligne.

Prenons ce court exemple :

$price = 42;
$vatRate = 0.17;
$total = $price * (1 + $vatRate);

Une fois compilé, il devient :

ASSIGN !0, 42
ASSIGN !1, 0.17
ADD ~0 1, !1
MUL ~1 !0, ~0

ASSIGN !2, ~1

Voyons ce que ça signifie.

À travers la compilation, les noms des variables locales ne sont pas retenus, car ils ne présentent pas d'intérêt pour la machine. Le script compilé y fait plutôt référence par une sorte d'adresse virtuelle en mémoire : !0 est la première adresse utilisée, !1 la suivante, etc.

Lorsqu'un résultat intermédiaire doit être mémorisé pour une opération ultérieure, il est temporairement stocké dans un registre. Ici, le registre ~0 est utilisé pour capturer le résultat de (1 + $vatRate), sachant que la variable $vatRate se trouve elle-même à l'adresse !1. Ensuite, puisqu'il y a un autre résultat provisoire à calculer (la multiplication du résultat précédent par $price), le programme doit utiliser le registre ~1. Mais, dès que les calculs intermédiaires ont été exploités et ne sont plus nécessaires, leurs registres sont libérés et peuvent être réaffectés à d'autres calculs.

1.3 Retour en arrière ?

Dans sa forme compilée, le programme ne contient plus les noms des variables locales, ni d'instruments sémantiques explicites. Il n'y a donc plus de chemin direct pour reconvertir les oplines en code source d'origine.

Néanmoins, les noms de presque tous les autres symboles sont retenus dans le binaire. Classes, fonctions ou propriétés se doivent d'être accessibles d'un fichier à l'autre. Les noms sont également fondamentaux pour le mécanisme de réflexion. Aussi, avec un effort suffisant, et une connaissance approfondie du format binaire d'OPcache, il est possible de comprendre le sens des oplines, et même de reconstituer un programme PHP entier.

Lycéen, j'étais catastrophique aux sports de ballon, mais j'étais un redoutable craqueur de jeux vidéos : je passais des heures dans l'océan de leur code assembleur. Ce n'était pas du PHP, mais le principe est le même. Je traquais patiemment le petit bit, dans toute la disquette, qui avait le pouvoir de faire basculer un DEC en INC sur ce que je repérais être la variable du compteur de vies. À moi les parties infinies !

2. Persistance sur disque

Nous avons dit que le PHP, de nos jours, convertit systématiquement un script source en opcodes avant de l'exécuter. Et nous nous souvenons du deuxième objectif : réutiliser les oplines. Une fois qu'un script a été digéré, le moteur peut théoriquement en jouer les oplines directement lorsqu'on souhaite l'exécuter.

Cependant, PHP est étroitement lié au cycle requête-réponse du protocole HTTP : au-delà de la réponse, le processus se termine et toutes les ressources en mémoire sont libérées — y compris les oplines des scripts.

L'extension OPcache offre la possibilité de conserver les binaires compilés dans un segment de mémoire partagée — un service fourni par le noyau Linux qui permet à des processus d'échanger des données au fil du temps, et même indépendamment de leur propre durée de vie. Grâce à cette extension, un processus PHP qui exécute un script peut en stocker les oplines en mémoire partagée, et se terminer. Plus tard, un autre processus qui voudra jouer le même script, pourra simplement puiser les oplines du cache, s'évitant ainsi le travail de l'analyse syntaxique.

Ce comportement est activé par les paramètres ini suivants :

zend_extension = opcache.so

opcache.enable = 1

Ce n'est pas tout ; pour faire fonctionner de façon optimale le cache mémoire, il faudrait configurer ces deux autres directives : opcache.max_accelerated_files et opcache.memory_consumption, qui sont expliquées dans le manuel [4]. Pour le moment, il nous suffit de comprendre que les fichiers d'opcodes restent disponibles en mémoire sur l'ordinateur, et que le serveur PHP peut les réutiliser pour d'autres requêtes HTTP.

Or, depuis PHP 7, l'OPcache offre aussi la possibilité de stocker les binaires dans un cache disque en plus de la mémoire — ou en remplacement de celle-ci. Officiellement, le but est de permettre au cache de survivre à un redémarrage du serveur. En pratique ce n'est pas aussi simple, et cela pose des problèmes de sécurité (par exemple un attaquant pourrait injecter un fichier compilé malveillant dans le cache disque). Néanmoins, ceci suggère que l'opfile, en tant que forme compilée d'un script PHP après parsing, peut être sauvegardé sur disque et réutilisé à la place du source.

Regardons comment ça fonctionne.

2.1 Expériences avec le cache disque

Commençons avec deux fichiers dans mon répertoire /home/gabriel :

Script a.php :

<?php
echo "I'm A in ".__FILE__.".\n";
include 'b.php';

Script b.php :

<?php
echo "I'm B in ".__FILE__.".\n";

Et, après avoir pris soin de créer le répertoire /tmp/opcache s'il n'existe pas déjà, nous configurons ces deux directives dans le fichier ini :

opcache.enable_cli = 1

La première ligne active l'OPcache lorsque PHP est utilisé en ligne de commandes. La deuxième directive précise le répertoire où stocker les fichiers binaires d'oplines.

Fichiers ini 

Les réglages PHP peuvent se trouver dans différents endroits selon les distributions. Sur Debian, on placera en général dans le répertoire /usr/local/etc/php/conf.d/ un fichier séparé pour chaque module. La liste des chemins à utiliser et les fichiers chargés s’obtiennent par la commande php --ini.

Pour une utilisation en ligne de commandes, il est également possible de préciser les paramètres au lancement avec l’option -d, exemple : php -dopcache.enable_cli=1 <suite de la commande>.

Ensuite, exécutons le programme a.php. Comme nous pouvions le supposer, la sortie donne :

$ php a.php

I'm A in /home/gabriel/a.php.

I'm B in /home/gabriel/b.php.

Observons maintenant le contenu du répertoire de cache :

$ tree /tmp/opcache

/tmp/opcache/

`-- dc01a59d22c314dc66a5c1930fc88cba

`-- home

`-- gabriel

|-- a.php.bin

`-- b.php.bin

Nous voyons que le chemin complet des scripts sources a été reproduit dans le dossier de cache, sous une racine dont le nom est un hash — nous y reviendrons plus tard.

Nous pouvons également constater que les deux fichiers sont présents dans le cache, bien que nous n'ayons exécuté que le premier, qui inclut le second.

Dès lors, le cas d'utilisation typique pour OPcache est lorsqu’un de ces scripts sera exécuté à nouveau : le moteur cherchera le binaire correspondant dans le cache, et jouera cette forme compilée ainsi que ses dépendances sans avoir à effectuer l'analyse syntaxique des sources. Mais OPcache sait également compiler les dépendances, ou recompiler un source qui aurait été modifié ultérieurement à sa mise en cache.

Ce dernier point est contrôlé par l'option opcache.validate_timestamps. Éteignons-la pour forcer l’utilisation d’un fichier du cache même si le source a changé. En outre, nous activerons opcache.file_cache_only pour indiquer que la zone d'échange interprocessus se limite au disque, à l'exclusion de la mémoire partagée.

opcache.validate_timestamps = 0

opcache.file_cache_only = 1

Enfin, effaçons le contenu des fichiers sources (mais sans les supprimer !) et exécutons à nouveau :

$ > a.php

$ > b.php

$ php a.php

I'm A in /home/gabriel/a.php.

I'm B in /home/gabriel/b.php.

La sortie reste la même !

Voici ce qu'il s'est passé :

  1. PHP essaie de lancer a.php ;
  2. OPcache identifie qu'un binaire correspondant se trouve en cache, et que le contenu du source doit être ignoré en dépit d'une heure de modification plus récente ;
  3. et ainsi le moteur exécute les versions (déjà) compilées des deux scripts.

La raison pour laquelle nous n'avons fait qu'effacer le contenu des scripts, plutôt que supprimer les fichiers, est que le mécanisme OPcache n'intervient que relativement tard dans le cycle d'exécution du moteur PHP. Celui-ci passe la main à OPcache sur la base d'un pointeur de fichier source déjà ouvert avec succès, même si au final OPcache choisira d'ignorer le source. Mais s'il n'existe pas, le moteur échoue avant même d'exploiter toute extension.

2.2 Cible

De cette manipulation, nous pouvons déduire un certain nombre de conclusions :

  • en théorie, nous pouvons feinter PHP et lui faire exécuter un binaire sur disque au lieu d'un source,
  • mais le fichier compilé doit se trouver sous le dossier pointé par opcache.file_cache,
  • et le fichier d'origine doit exister, même si PHP en ignore totalement le contenu.

Nous allons maintenant étudier de quelle façon il faudrait modifier le mécanisme OPcache pour permettre l’exploitation d'un fichier compilé à partir de l'emplacement d'origine de son script source. D'ailleurs, nous voudrons faire en sorte que ce comportement soit activable par une nouvelle option ini, afin de préserver la rétrocompatibilité.

2.3 System ID

En principe, une fois compilé, un fichier binaire devrait pouvoir tourner sur n'importe quel système équipé de PHP. Naturellement, la version du moteur doit correspondre, ainsi que l'architecture de la plateforme. Ceci est le rôle du hash présent à la racine du répertoire de cache, évoqué plus haut. Il s'appelle le System ID, et dérive [5] de la version du PHP ainsi que de la largeur du type entier sur la machine (ce qui est une propriété significative de l'architecture matérielle).

Le hash est inscrit dans l'entête de chaque fichier au moment de la compilation. Lors de l'exécution, s'il y a divergence avec la plateforme, l'opfile est ignoré.

Le System ID est très sensible, jusqu'au petit numéro de patch de la version de PHP. Pourtant, il serait improbable que le jeu d'instructions du moteur ne change entre deux numéros de patch. Ceci introduit une limitation à ce que nous essayons d'accomplir, et nous en reparlerons plus loin.

3. Essai de patch

3.1 Ce que nous souhaitons faire

Nous voudrions compiler un script PHP, renommer la résultante avec une extension en .php, et ensuite lancer cet opfile tout comme s'il s'agissait d'un script source.

Mais d'abord, c'est peut-être le bon moment de se demander « pourquoi ».

Il existe des solutions commerciales qui permettent de compiler, signer, chiffrer des projets PHP, et même d'en conditionner l'exécution à la validité d'une clé de licence. Ceci sort du cadre du présent article, dans lequel nous nous focalisons sur la relation qui existe entre un script source et les oplines que le parser produit.

Citons néanmoins le produit privatif ionCube, payant pour chiffrer, mais assorti d'un runtime gratuit généralement installé chez les hébergeurs mutualistes ; ainsi que le feu logiciel Zend Guard, au fonctionnement similaire, mais dont le développement a été abandonné à la sortie de PHP 7.

On pourrait envisager plusieurs avantages potentiels avec un opfile compilé, par rapport à un script source PHP :

  • Célérité : comme indiqué plus haut, la phase d'analyse est déjà du passé. L'opfile peut être exécuté immédiatement. Cependant, cette étape de parsing n'est à effectuer qu'une seule fois, et en production elle est complètement négligeable devant les milliers d'exécutions du même opfile.
  • Intégrité : un opfile est un bloc binaire dont la cohérence est scellée par une somme de contrôle, et il n'est pas aisé de le bricoler. Cette protection n'est pas meilleure que le mécanisme de signature des archives Phar, qui présente ses propres défauts.
  • Compacité : dans certains langages de programmation, un binaire compilé est plus léger que son code source correspondant. Mais ceci n'est pas le cas avec OPcache, et il s'avère que les opfiles sont même considérablement plus gros que les scripts. Une des raisons en est que ce format binaire embarque une grande quantité d'informations de runtime. Par exemple, lorsqu'une exception est levée, la pile d'appels rapporte les noms des fonctions et numéros de lignes, ce qui prouve que les traces de débogage sont présentes dans les oplines.
  • Obscurcissement : c'est là notre piste d'intérêt principal. Un opfile est difficile à lire et à comprendre ; un projet entier d'opfiles d'autant plus. Si l'application tourne sur votre propre serveur ou dans une infrastructure Cloud, les scripts sources ne sont jamais accessibles au public. Mais si vous distribuez un projet hébergé sur site, et que vous souhaitez protéger sa propriété intellectuelle ou ses données confidentielles, alors l'obscurcissement résultant de la transformation OPcache peut présenter une valeur ajoutée relative.

Nous ne déroulerons pas plus avant le grand débat « compilé vs interprété », qui tend à s'estomper de toute façon de nos jours dans de nombreux langages. Mais nous prendrons l'argument de l'obscurcissement comme opportunité pour étudier un des aspects de notre moteur bien aimé.

Après avoir rassemblé l'outillage nécessaire à construire l'exécutable php à partir de ses sources, nous montrerons comment modifier l'extension OPcache pour atteindre notre objectif expérimental.

3.2 Préparation du build

Le guide complet de construction est disponible sur PhpInternalBooks.com [6]. Nous résumons ici les principales étapes pour compiler l'interpréteur php sur un système Debian.

Clonons le dépôt GitHub du PHP [7] :

$ git clone https://github.com/php/php-src

...

$ cd php-src

Il faut installer les packages suivants :

$ sudo apt update

$ sudo apt install build-essential autoconf automake \
 libtool bison re2c libxml2-dev libsqlite3-dev

Ensuite, entrons les commandes suivantes pour préparer la configuration du build :

~/php-src $ ./buildconf

buildconf: checking installation...

buildconf: autoconf version 2.69 (ok)

rebuilding aclocal.m4

rebuilding configure

rebuilding main/php_config.h.in

 

~/php-src $ ./configure

...

checking for egrep... /bin/grep -E

checking for a sed that does not truncate output... /bin/sed

checking for cc... cc

checking whether the C compiler works... yes

...

Generating files

configure: creating ./config.status

creating main/internal_functions.c

creating main/internal_functions_cli.c

...

Thank you for using PHP.

Docker

Si vous ne souhaitez pas modifier votre machine quotidienne pour cette manipulation, vous pouvez également construire le PHP dans un conteneur Docker avec l'image officielle Debian : debian:stretch [8].

Clonez le dépôt git, puis lancez une instance comme ceci. Les autres manipulations restent les mêmes :

~ $ docker run -it -v $(pwd)/php-src:/root/php-src debian:stretch bash

root@b0e9719920fe:/# cd ~/php-src

root@b0e9719920fe:~/php-src # apt update

...

root@b0e9719920fe:~/php-src # apt install ...

Enfin, lançons la compilation :

~/php-src $ make

Attention !

La compilation du projet prend un certain temps : profitez du parallélisme en utilisant l'option make -j4 (où 4 est ici le nombre de cœurs).

Build complete.

Don't forget to run 'make test'.

Il n'est pas nécessaire d'effectuer une installation globale du programme ainsi construit. Notre exécutable php tout neuf se trouve dans sapi/cli relativement au répertoire des sources, et l'extension OPcache a été produite dans ext/opcache/.libs. Nous pouvons nous contenter de les exploiter localement :

~/php-src $ sapi/cli/php -dzend_extension=$(pwd)/ext/opcache/.libs/opcache.so -v

PHP 7.4.0-dev (cli) (built: Dec 17 2018 23:36:49) ( NTS )

Copyright (c) 1997-2018 The PHP Group

Zend Engine v3.4.0-dev, Copyright (c) 1998-2018 Zend Technologies

with Zend OPcache v7.4.0-dev, Copyright (c) 1999-2018, by Zend Technologies

3.3 Un peu de C

La modification que nous proposons dans ces lignes est quelque peu naïve : une contribution en bonne et due forme doit contenir des contrôles solides à chaque étape (allocation mémoire, permissions sur les fichiers, etc.) et s'accompagner d'une suite complète de tests. Mais nous ne faisons que démontrer une hypothèse de laboratoire, plutôt que soumettre une RFC dans les règles.

Nous plaçons notre fonctionnalité derrière un nouveau drapeau ini, de façon à l'activer à la demande et ne rien casser de l'existant : nommons-le allow_inplace_bin.

Vous trouverez le code du patch dans mon fork [9]. Pas de panique si vous ne parlez pas couramment le C, ou ne maîtrisez pas les macros très particulières mises en œuvre un peu partout dans le code source du PHP. Les extraits qui suivent parlent d'eux-mêmes, et il n'est pas nécessaire d'en comprendre tous les détails.

3.3.1 Le flux

La cinématique du module OPcache est résumée en figure 1.

 

opcache_figure_01

 

Fig. 1 : Diagramme schématique du traitement OPcache.

Le chemin qui nous intéresse sur ce diagramme est représenté en contours à tirets.

Nous voulons détourner PHP pour lui faire exécuter le fichier d'oplines qui se situe à l'emplacement normal du script source, au lieu d'aller le chercher dans le cache. Nous allons principalement apporter des modifications au niveau de l'étape « Calculer le chemin du Bin ». Si le réglage pour autoriser les binaires sur place est allumé, PHP devra considérer que le nom complet du ficher de cache est égal à celui du script. Par la suite, si le fichier porte un entête OPcache valide, il sera exécuté directement ; autrement, le flux normal reprend.

Ceci est retracé sur la figure 2, où notre proposition est dessinée en contours pointillés.

 

opcache_figure_02

 

Fig. 2 : Diagramme du déroulement avec flag allow_inplace_bin.

3.3.2 Allow in-place bin

Notre nouveau réglage n'a de sens que lorsque le paramétrage indique déjà opcache.file_cache_only=1 et opcache.validate_timestamps=0. Dans ces conditions, il contrôle le comportement du module OPcache au moment de résoudre l'emplacement d'un candidat en cache. Ceci doit nous permettre de laisser les fichiers compilés au même endroit que leurs scripts d'origine, et même de mélanger les genres au sein d'un même projet.

Le booléen se déclare dans le fichier ZendAccelerator.h :

169 typedef struct _zend_accel_directives {

...

209 zend_bool allow_inplace_bin;

...

218 } zend_accel_directives;

Et le nom public du réglage se spécifie dans zend_accelerator_module.c à l'aide des macros dédiées :

277 ZEND_INI_BEGIN()

...

321 STD_PHP_INI_ENTRY("opcache.allow_inplace_bin", "0", PHP_INI_SYSTEM, OnUpdateBool, accel_directives.allow_inplace_bin, zend_accel_globals, accel_globals)

...

330 ZEND_INI_END()

Et maintenant nous pouvons paramétrer dans le fichier de configuration opcache.ini :

opcache.enable_cli = 1

opcache.file_cache = /tmp/opcache

opcache.validate_timestamps = 0

opcache.file_cache_only = 1

opcache.allow_inplace_bin = 1

3.3.3 Obtenir le System ID

Nous allons ajouter au module une fonction utilitaire pour récupérer la valeur du System ID. Cette donnée sera pratique plus loin, lorsque nous aurons besoin de connaître dans un programme PHP l'emplacement naturel des opfiles en cache.

Dans le fichier zend_accelerator_module.c, déclarons une Zend Function sans argument. Sa valeur de retour est affectée à la chaîne System ID courante.

68 static ZEND_FUNCTION(opcache_get_system_id);

...

81 ZEND_FE(opcache_get_system_id, arginfo_opcache_none)

...

796 static ZEND_FUNCTION(opcache_get_system_id)

797 {

798 ZVAL_STRING(return_value, ZCG(system_id));

799 }

Elle s'utilisera comme ceci en PHP :

$systemId = opcache_get_system_id();

3.3.4 Exécution de l'opfile

Il nous faut encore indiquer à PHP que, lorsqu’il est sur le point de traiter un script (qu’il soit lancé en point d’entrée ou inclus à partir d’un autre), il doit d’abord considérer que le fichier est peut-être déjà un binaire compilé.

Ceci se fait dans zend_file_cache.c, à l’endroit où le programme s’apprête à tirer un candidat du cache.

zend_persistent_script *zend_file_cache_script_load(zend_file_handle *file_handle)

{

...

   /* Check whether the source script is already binary */

   if (zend_file_script_is_inplace_bin(ZSTR_VAL(file_handle->opened_path))) {

      filename = emalloc(ZSTR_LEN(file_handle->opened_path) + 1);

      strcpy(filename, ZSTR_VAL(file_handle->opened_path));

   }

   else {

      filename = zend_file_cache_get_bin_file_path(full_path);

   }

 

   /* Resume normal handling with the cache folder */

   fd = zend_file_cache_open(filename, O_RDONLY | O_BINARY);

   ...

}

Il ne nous reste plus qu’à implémenter cette nouvelle fonction zend_file_script_is_inplace_bin, dans ce même fichier (listing simplifié) :

int zend_file_script_is_inplace_bin(char *filename)

{

   int fd; 

   zend_file_cache_metainfo info;

   void *mem, *checkpoint;

   if (ZCG(accel_directives).allow_inplace_bin) {

      /* Open the source file */

      fd = open(filename, O_RDONLY | O_BINARY);

      /* If file is shorter that OPcache header, it's not a bin candidate */

      if (read(fd, &info, sizeof(info)) != sizeof(info)) {

         close(fd);

         return 0;

      }

      /* verify header */

      if (memcmp(info.magic, "OPCACHE", 8) != 0) {

         close(fd);

         return 0;

      }

      /* verify system id */

      if (memcmp(info.system_id, ZCG(system_id), 32) != 0) {

         close(fd);

         return 0;

      }

      close(fd);

      return 1;

   }

   return 0;

}

Ainsi équipé, notre module OPcache est capable d’exécuter indifféremment un script source ou sa forme compilée portant le même nom.

Les fichiers qui se trouvent dans une archive Phar sont pris en charge différemment. Notre simple code illustratif n’est pas en mesure de les gérer. De plus, nous effectuons une relecture superflue du fichier d’entrée. Elle pourrait être évitée au moyen d’une réécriture un peu plus en profondeur, que nous n’essayons pas de viser ici.

3.4 Construction et observations

Procédons à la reconstruction de l’extension. Pas d’inquiétude, elle est incrémentale ; l’opération sera brève.

~/php-src $ make

Nous allons utiliser les mêmes fichiers que précédemment, a.php et b.php. Mais cette fois, nous remplaçons les fichiers sources par leurs versions compilées, trouvées dans l’arborescence du cache :

$ mv /tmp/opcache/dc0*/home/gabriel/{a,b}.bin/php .

Le dossier du cache est maintenant vide, et celui des scripts ne contient plus que les fichiers compilés. Lançons donc a.php (l’opfile !) à l’aide de notre PHP modifié et de la configuration appropriée :

$ php-src/sapi/cli/php \

-dzend_extension=$(pwd)/php-src/ext/opcache/.libs/opcache.so \

-dopcache.enable_cli=1 \

-dopcache.file_cache=/tmp/opcache \

-dopcache.file_cache_only=1 \

-dopcache.validate_timestamps=0 \

-dopcache.allow_inplace_bin=1 \

a.php

 

I'm A in /home/gabriel/a.php.

I'm B in /home/gabriel/b.php.

Essai concluant.

4. Précompiler tout le projet

Nous avons vu que le fait d’exécuter un script PHP, si la directive opcache.file_cache est configurée, a pour effet de stocker l’opfile dans le répertoire de cache avec un suffixe .bin.

L’extension OPcache expose une fonction opcache_compile_file($filename), qui produit le même effet, mais sans lancer l’exécution du fichier source.

Le listing suivant parcourt toute l’arborescence d’un projet, et en compile les scripts. Les opfiles résultants sont alors déplacés vers le répertoire cible, en respectant la structure des dossiers et sans leur suffixe.

function compileDir($srcDir, $destDir)

{

   // Use our new C function to get the System ID

   $systemId = opcache_get_system_id();

 

   // Walk recursively the source directory tree

   $dirIter = new RecursiveDirectoryIterator($srcDir);

   $recurIter = new RecursiveIteratorIterator($dirIter);

 

   // The natural location where the opfiles are produced:

   $cacheDir = ini_get('opcache.file_cache') . '/' . $systemId;

 

   foreach ($recurIter as $fileInfo) {

      if ($fileInfo->isFile() && ($fileInfo->getExtension() === 'php')) {

         $srcFullname = $fileInfo->getPathname();

 

         // Trigger the creation of the opfile in cache:

         if (! opcache_compile_file($srcFullname)) {

            throw new Exception('Failed to compile file:' . $srcFullname);

         }

 

         // Prepare the full name of the target file by replacing the

         // source dir string:

         $targetFullname = $destDir . substr($srcFullname, strlen($srcDir));

         mkdir(dirname($targetFullname), 0600, true);

 

         // Move the .bin opfile to its target location

         $cacheFullname = $cacheDir . $srcFullname . '.bin';

         rename($cacheFullname, $targetFullname);

      }

   }

}

5. Résultats

Prenons le temps d’interpréter notre expérimentation.

Nous avons une extension OPcache modifiée, qui permet d’exécuter un script PHP pré-compilé, ou même un projet entier. Les fichiers compilés ont le même nom que les sources, et si nous les plaçons dans leur chemin d’origine (ce qui veut dire écraser les sources), le moteur PHP les jouera de façon transparente.

Dès lors, pouvons-nous distribuer des projets PHP compilés, à quiconque disposerait de notre version d’OPcache ? Pas vraiment. Un certain nombre de problèmes nous barrent la route.

5.1 Version

Tout d’abord, le System ID : pour qu’un binaire redistribuable soit considéré un tant soit peu stable, il doit survivre à une montée de version mineure – et à plus forte raison, à un simple patch. Donc, plutôt qu’un hash du numéro de version PHP, l’OPcache devrait utiliser une signature permettant la comparaison ordonnée. Elle devrait permettre de jouer un binaire compilé sur une version antérieure. Le cas est très courant dans le monde Java, où une classe compilée en version 6 est garantie de tourner sur une JVM 8.

5.2 Chemin

Deuxièmement, les constantes magiques, en particulier : __DIR__ et __FILE__. La plupart des projets PHP modernes s’appuient sur une ligne qui ressemble à celle-ci :

require __DIR__.'/../vendor/autoload.php';

L’instruction require demande un chemin absolu (pour éviter l’incertitude liée à l’include path). C’est donc commode, il est vrai, de le rendre en quelque sorte relatif à la racine du projet, au moyen de la valeur __DIR__. Le problème est que cette pseudo-constante est calculée au moment de la compilation ! Lorsque le moteur analyse un script, il la résout et force une chaîne en dur dans l’opfile.

Nous avons pu voir ceci à l’œuvre au début de notre manipulation, quand nous avons forcé PHP à exécuter les fichiers depuis le cache après avoir vidé le contenu des scripts sources. Au lieu d’une valeur dynamique pour __FILE__, dans le fichier situé sous /tmp/opcache, nous avons obtenu la valeur inscrite en dur correspondant au chemin absolu du script lors de sa compilation. Même si nous avions renommé le binaire b.php.bin en c.php.bin, la sortie aurait quand même donné cette valeur :

$ mv b.php c.php
$ cd /tmp/opcache/dc0*/home/gabriel ; mv b.php.bin c.php.bin ; cd -

$ php c.php
I'm B in /home/gabriel/b.php.

Avec l’OPcache standard, dès qu’un script PHP est renommé, son cache est invalidé, car la clé de cache est le chemin absolu du fichier. Par conséquent, les constantes magiques seront recalculées à nouveau dans la phase de compilation.

Mais, avec notre approche, un fichier compilé peut changer d’emplacement et être tout de même exécuté dans son nouveau domicile. Il n’y a plus d’instruction __DIR__ dans le fichier, et sa valeur figée est devenue tout simplement fausse. Le require échouera.

Cela pourrait fonctionner si __DIR__ n’était en fait pas fixé à la compilation, mais se retrouvait plutôt dans l’opline, avec évaluation au runtime.

5.3 Sortie standard

Finalement, <?php n’est pas notre ami !

En fait, tout fichier est un script PHP... qu’il contienne des instructions PHP ou non. On peut toujours faire « exécuter » n’importe quoi à l’interpréteur php : il se contente de déverser le contenu sur la sortie standard.

La plupart des langages n’acceptent dans un fichier source que des instructions valides, et l’opération print est une commande explicite. En PHP, un fichier se retrouve finalement retroussé en une grosse instruction print sous-entendue, à l’exception des blocs <?php ... ?> qui contiennent le vrai programme.

Nous indiquions dans notre énoncé de la cible, que nous souhaitions travailler indifféremment avec des opfiles ou des scripts sources. Une instruction require ne devrait pas se soucier de savoir si le fichier à inclure est un script ou un binaire compilé. Mais, c’est à un niveau plus bas, que le moteur se charge d’en identifier l’emplacement. Par conséquent, notre patch arrive trop tard pour avoir une incidence sur le nom du fichier, et même si nous incluons un opfile, il ne peut avoir aucun suffixe sous-entendu. Il s’agit nécessairement du fichier explicitement spécifié, qui porte donc l’extension .php.

Or, que se passe-t-il si vous lancez un script pré-compilé sans avoir pensé à allumer allow_inplace_bin ? Ou simplement en cas de divergence du System ID ? Pour commencer, très certainement votre programme ne fonctionnera pas. Mais, plus grave : le binaire pré-compilé a toutes les chances de se retrouver dans la réponse HTTP retournée au navigateur. Et, même si le reverse engineering est compliqué et coûteux, la brèche de propriété ou de sécurité est bien là.

Tout serait différent si les scripts PHP étaient exclusivement des fichiers d’instructions, comme en Java ou Python, sans contenu mixte et sans balise de début et de fin de blocs de programme.

5.4 Doc Blocks

Il reste encore un dernier problème avec le code compilé : les commentaires ne parviennent pas (obligatoirement) jusqu’à l’opfile.

L’option opcache.save_comments permet de les retenir dans les fichiers binaires, ce qui est nécessaire avec certains frameworks s’appuyant fortement sur les « annotations » dans les commentaires Doc Blocks. C’est souvent le cas avec les modules de mapping de base de données ou d’injection de dépendances. Malheureusement, en PHP ces annotations n’en sont pas des vraies : elles ne sont disponibles que via le mécanisme d’introspection (réflexion) et l’analyse des commentaires au runtime.

Non seulement ces commentaires, nécessaires, peuvent être trop facilement supprimés accidentellement à la compilation, par omission du flag pré-cité ; mais surtout leur présence en texte clair dans le binaire peut constituer un effet secondaire indésirable.

D’autres langages implémentent le concept des annotations de façon un peu plus responsable : si les annotations ajoutent du comportement à un programme, elles ont une place légitime parmi les « citoyens de première classe » que sont les autres mots clés. Certes faudrait-il imaginer une syntaxe innovante, car le symbole @ est déjà occupé en PHP.

Conclusion

Historiquement, le PHP fut conçu pour les hébergements mutualisés et les scripts faciles à déployer, ainsi qu’en témoignent les niveaux de permissions INI [10]. Un éditeur texte et un client FTP étaient les seuls outils dont il fallait disposer. Licences d’utilisation, archives exécutables ou encore programmation asynchrone n’étaient absolument nulle part dans la feuille de route.

Avec chaque version, le langage s’enrichit et l’écosystème se professionnalise. Néanmoins, il reste certaines choses qui ne sont juste pas naturelles pour cette technologie. PHP n’est pas un bon choix pour distribuer des programmes compilés. Tout au moins pour l’instant !

Références

[1] Liste des opcodes : https://secure.php.net/manual/en/internals2.opcodes.list.php

[2] Vulcan Logic Debugger, Derrick Rethan : https://pecl.php.net/package/vld

[3] Testeur de scripts PHP en ligne, 3V4L : https://3v4l.org

[4] Manuel de configuration OPcache : https://php.net/manual/en/opcache.configuration.php

[5] Calcul du System ID : https://zzgab.com/glmf-opcache/5

[6] Guide du build PHP : http://www.phpinternalsbook.com/build_system/building_php.html

[7] GitHub du PHP  : https://github.com/php/php-src

[8] Docker Debian : https://hub.docker.com/_/debian/

[9] Fork pour le patch OPcache : https://github.com/zzgab/php-src/pull/1

[10] Niveaux de configuration INI : http://php.net/manual/en/configuration.changes.modes.php

 



Article rédigé par

Par le(s) même(s) auteur(s)

Création d'une bibliothèque NPM TypeScript hybride

Magazine
Marque
GNU/Linux Magazine
Numéro
264
Mois de parution
juillet 2023
Spécialité(s)
Résumé

Rassembler dans un même livrable NPM du code serveur bigoût (parfums CommonJS require et ESM import), plus une version minifiée pour le browser, et des déclarations de types TypeScript, c'est possible. Objectifs : centraliser le développement et unifier le cycle des releases. Guide pratique...

Faire une UI en mode texte avec React et Ink

Magazine
Marque
GNU/Linux Magazine
Numéro
262
Mois de parution
mars 2023
Spécialité(s)
Résumé

Parmi les approches pour construire une application interactive en mode console, il en est une, exotique mais véloce, qui s'adresse aux développeurs JavaScript et exploite le framework React, bien connu du monde du front-end. Voyons ce que le projet Ink permet de faire dans ce domaine.

Jouons avec les Linux Pluggable Authentication Modules

Magazine
Marque
GNU/Linux Magazine
Numéro
259
Mois de parution
septembre 2022
Spécialité(s)
Résumé

Au cœur de la gestion des utilisateurs et de leurs permissions, le système GNU/Linux recèle un mécanisme modulaire et extensible pour faire face à tous les usages actuels et futurs, liés à la preuve d'identité. Intéressons-nous, à travers un cas pratique, à ces modules interchangeables d'authentification, utiles tant aux applicatifs qu'au système lui-même.

Les derniers articles Premiums

Les derniers articles Premium

La place de l’Intelligence Artificielle dans les entreprises

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

L’intelligence artificielle est en train de redéfinir le paysage professionnel. De l’automatisation des tâches répétitives à la cybersécurité, en passant par l’analyse des données, l’IA s’immisce dans tous les aspects de l’entreprise moderne. Toutefois, cette révolution technologique soulève des questions éthiques et sociétales, notamment sur l’avenir des emplois. Cet article se penche sur l’évolution de l’IA, ses applications variées, et les enjeux qu’elle engendre dans le monde du travail.

Petit guide d’outils open source pour le télétravail

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Ah le Covid ! Si en cette période de nombreux cas resurgissent, ce n’est rien comparé aux vagues que nous avons connues en 2020 et 2021. Ce fléau a contraint une large partie de la population à faire ce que tout le monde connaît sous le nom de télétravail. Nous avons dû changer nos habitudes et avons dû apprendre à utiliser de nombreux outils collaboratifs, de visioconférence, etc., dont tout le monde n’était pas habitué. Dans cet article, nous passons en revue quelques outils open source utiles pour le travail à la maison. En effet, pour les adeptes du costume en haut et du pyjama en bas, la communauté open source s’est démenée pour proposer des alternatives aux outils propriétaires et payants.

Sécurisez vos applications web : comment Symfony vous protège des menaces courantes

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Les frameworks tels que Symfony ont bouleversé le développement web en apportant une structure solide et des outils performants. Malgré ces qualités, nous pouvons découvrir d’innombrables vulnérabilités. Cet article met le doigt sur les failles de sécurité les plus fréquentes qui affectent même les environnements les plus robustes. De l’injection de requêtes à distance à l’exécution de scripts malveillants, découvrez comment ces failles peuvent mettre en péril vos applications et, surtout, comment vous en prémunir.

Bash des temps modernes

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Les scripts Shell, et Bash spécifiquement, demeurent un standard, de facto, de notre industrie. Ils forment un composant primordial de toute distribution Linux, mais c’est aussi un outil de prédilection pour implémenter de nombreuses tâches d’automatisation, en particulier dans le « Cloud », par eux-mêmes ou conjointement à des solutions telles que Ansible. Pour toutes ces raisons et bien d’autres encore, savoir les concevoir de manière robuste et idempotente est crucial.

Les listes de lecture

9 article(s) - ajoutée le 01/07/2020
Vous désirez apprendre le langage Python, mais ne savez pas trop par où commencer ? Cette liste de lecture vous permettra de faire vos premiers pas en découvrant l'écosystème de Python et en écrivant de petits scripts.
11 article(s) - ajoutée le 01/07/2020
La base de tout programme effectuant une tâche un tant soit peu complexe est un algorithme, une méthode permettant de manipuler des données pour obtenir un résultat attendu. Dans cette liste, vous pourrez découvrir quelques spécimens d'algorithmes.
10 article(s) - ajoutée le 01/07/2020
À quoi bon se targuer de posséder des pétaoctets de données si l'on est incapable d'analyser ces dernières ? Cette liste vous aidera à "faire parler" vos données.
Voir les 65 listes de lecture

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous