Plongée dans l'OPcache

Magazine
Marque
GNU/Linux Magazine
Numéro
224
|
Mois de parution
mars 2019
|
Domaines


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

 

Sur le même sujet

Mise en œuvre d’autotools

Magazine
Marque
GNU/Linux Magazine
Numéro
234
|
Mois de parution
février 2020
|
Domaines
Résumé

Le vénérable autoconf reste très utilisé parmi les projets bien établis. Un minimum de compréhension de sa syntaxe et de son fonctionnement permet donc de contribuer efficacement à ceux-ci, voire de proposer un toilettage.

Un oscilloscope pour le traitement de signaux radiofréquences : gr-oscilloscope pour GNU Radio 3.7 et 3.8

Magazine
Marque
GNU/Linux Magazine
Numéro
234
|
Mois de parution
février 2020
|
Domaines
Résumé

Nous proposons d’utiliser un oscilloscope radiofréquence comme source de données GNU Radio pour les applications nécessitant une large bande passante, telles que les mesures de temps de vol. Cette exploration sera l’occasion de découvrir la nouvelle mouture de GNU Radio attendue depuis 6 ans, la version 3.8, avec son lot de nouveautés et d’incompatibilités.

C++ Moderne : C++20 et au-delà

Magazine
Marque
GNU/Linux Magazine
Numéro
234
|
Mois de parution
février 2020
|
Domaines
Résumé

Suite à la conférence de Cologne du mois de juillet 2019, le périmètre de la version C++20 a été figé, et cette version est la plus riche depuis C++11, elle introduit quelques nouveaux concepts significatifs.

Coder une interface CLI avec des selectbox, des barres de progression, de la complétion… le tout en Python

Magazine
Marque
GNU/Linux Magazine
Numéro
233
|
Mois de parution
janvier 2020
|
Domaines
Résumé

Dans cet article, nous allons découvrir le module Python cleo qui permet de créer des consoles en CLI avec des couleurs, du formatage de texte et de tableaux, des selectbox, des champs de saisie avec complétion et un module de complétion pour bash/zsh, et même fish !

Les bases de la modélisation en UML

Magazine
Marque
GNU/Linux Magazine
HS n°
Numéro
106
|
Mois de parution
janvier 2020
|
Domaines
Résumé

Ah, l'UML et ses diagrammes qui font fuir certains développeurs, persuadés qu'il s'agit de documents inutiles : j'ai une idée, je code et ça marche… Certes, pour un petit script la technique fonctionne, mais pour un projet de plus grande envergure, il n'est pas inutile de travailler la modélisation !

Par le même auteur

Automatiser les tests end-to-end en PHP

Magazine
Marque
GNU/Linux Magazine
Numéro
232
|
Mois de parution
décembre 2019
|
Domaines
Résumé

La partie frontale d'une application orientée utilisateur est généralement perçue comme difficile à tester de manière automatisée, et ces vérifications sont souvent reléguées à une campagne manuelle. Dans cet article, nous verrons comment utiliser l'outil Puppeteer dans un projet PHP, afin de garantir la validation déterministe de la partie d'une application web qui se joue dans le navigateur.

Automatiser la production de PDF avec Chromium

Magazine
Marque
GNU/Linux Magazine
Numéro
228
|
Mois de parution
juillet 2019
|
Domaines
Résumé
La conversion de documents HTML en fichiers PDF peut s’obtenir de différentes manières, chacune avec ses limites que ce soit dans l'automatisation, la souplesse ou la fidélité. Nous étudierons ici une solution mettant en œuvre Chromium pour un rendu professionnel et riche en fonctionnalités.

Plongée dans l'OPcache

Magazine
Marque
GNU/Linux Magazine
Numéro
224
|
Mois de parution
mars 2019
|
Domaines
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.

L'auto-hébergement léger de dépôts git avec Gitolite

Magazine
Marque
GNU/Linux Magazine
Numéro
214
|
Mois de parution
avril 2018
|
Domaines
Résumé
Vous souhaitez mettre en place un serveur de dépôts Git privé pour vos projets personnels ou d'équipe, mais vous ne voulez pas d'une offre payante ni d'une usine à gaz, ni d'un service hébergé chez un tiers. Des solutions existent, et parmi elles l'outil Gitolite : simple, sûr, efficace et non captif.

Démystifier l’injection de dépendances en PHP

Magazine
Marque
GNU/Linux Magazine
Numéro
208
|
Mois de parution
octobre 2017
|
Domaines
Résumé
Du code propre et lisible, dans lequel chaque classe reçoit du ciel les composants avec lesquels elle doit travailler, sans avoir à les passer explicitement : c’est l’ambition des outils d’injection de dépendances.