Sommaire (7 parties)
Mon site mesure ses visites avec un petit script PHP, le tracker. À chaque page vue, il ajoute une ligne à un fichier texte, visits.tsv (le détail est dans l'article sur les statistiques sans cookies). Le site vit sur un hébergement mutualisé OVH : un serveur partagé avec d'autres sites. J'y dépose mes fichiers par FTP, avec un quota d'environ 100 Mo.
Le 9 novembre, un déploiement s'est arrêté sur 553 Disk quota exceeded, la réponse du serveur FTP quand le quota est plein. Le fichier de visites pesait 23,7 Mo. Pire : pendant que le quota était plein, le tracker n'a plus rien écrit, sans afficher la moindre erreur. Quelques heures de visites sont parties dans le vide.
Le remède s'appelle la rotation de logs. En fin de mois, on range le fichier dans une archive compressée (gzip). On repart ensuite sur un fichier vide. D'habitude, un programme planifié (un cron) s'en charge. Mon offre n'en a pas. Le premier visiteur de chaque mois fera donc le travail, à une condition : qu'il ne s'en aperçoive pas.
Un quota qui ne prévient pas
Je voulais d'abord savoir combien de place il restait. Le serveur FTP ne répond à aucune commande de quota, alors j'ai additionné les tailles avec du depuis lftp. Pour voir si la limite bloquait vraiment, j'ai envoyé un fichier factice de 100 Mo (supprimé ensuite). Il est passé. J'en ai conclu un peu vite que le quota était bien plus grand.
C'était faux. L'envoi suivant, le MP3 de 6,5 Mo d'un clip que je voulais mettre en ligne, a pris un 553. Mon explication, qui reste une déduction : le serveur ne contrôle le quota qu'à l'ouverture d'un fichier. Tant qu'on est sous la limite à ce moment-là, un fichier géant passe, quitte à dépasser. Le fichier d'après est refusé.
Le dossier a fini à 106 Mo, au-dessus de la limite : le dépassement ne bloque rien sur le moment. Il contenait les 23,7 Mo du fichier de visites (tous les mois depuis février), des PDF, des images et de l'audio. Le tracker ajoute sa ligne avec file_put_contents et ne vérifie pas son retour : l'écriture a échoué et personne ne l'a su. Après le ménage (visites compressées, PDF du livre déplacés sur GitHub, MP3 réencodé, fichiers de développement retirés), le dossier est passé sous les 80 Mo. Il regrossira chaque mois avec les visites. D'où la rotation : ranger le mois écoulé en archive compressée.
Pas de cron : le premier visiteur du mois s'en charge
Un cron est une tâche que le serveur lance à heure fixe, sans visite. La documentation OVHcloud décrit ces tâches planifiées, mais mon offre ne les propose pas.
Or une visite, c'est déjà un script PHP qui tourne. Il suffit qu'il se demande : « la dernière ligne écrite date-t-elle du mois précédent ? ». La réponse est dans la date de dernière modification de visits.tsv (filemtime). Dans le code, $live est ce visits.tsv et $pending est visits-pending.tsv, le fichier « en attente » de compression. Les extraits sont abrégés : le verrou, décrit plus bas, n'y figure pas.
$live_is_old_month = is_file($live)
&& date('Y-m', filemtime($live)) < date('Y-m');
if (!$live_is_old_month && !is_file($pending)) {
return; // nothing to do on almost every hit
}
// Atomic: the visitor's own hit then lands in a fresh visits.tsv
if ($live_is_old_month && !is_file($pending)) {
rename($live, $pending);
}
Le fichier du mois écoulé change de nom en un seul geste. La ligne du visiteur recrée un visits.tsv neuf. Il reste à compresser visits-pending.tsv en visits-AAAA-MM.tsv.gz. La condition is_file($pending) sert à un autre cas : une compression ratée. Le fichier en attente existe alors encore, et une visite suivante reprend le travail (détail plus bas).
Côté statistiques, rien ne casse. L'outil lit d'abord les archives compressées, dans l'ordre de leurs noms, puis le fichier en attente, puis le fichier courant.
Le premier visiteur ne doit pas payer
Compresser un mois de visites n'est pas instantané. Il y a trois endroits où lancer le gzip :
- dans la requête, avant d'envoyer la page ;
- dans une fonction de fin, enregistrée avec
register_shutdown_function: PHP l'exécute quand le script est terminé ; - dans cette même fonction, mais après
fastcgi_finish_request, qui demande au serveur d'envoyer la page tout de suite.
J'ai mesuré ces trois cas, plus l'absence de rotation, sur le serveur OVH. Les données sont celles d'octobre : 60 828 lignes, soit 4,7 Mo. Le script de test était jetable : déployé, utilisé, puis supprimé. Chaque ligne du tableau est la médiane de 15 requêtes, c'est-à-dire le temps du milieu une fois les 15 triés. Elles sont faites le même jour, sur un seul serveur.
Le temps inclut le réseau : quelques millisecondes d'écart ne se distinguent pas du bruit. Mon test mesure le gzip seul, pas la fonction de rotation complète, qui relit en plus l'archive pour la vérifier. Les écarts sont calculés sur les médianes arrondies.
| Variante | Page complète reçue | Surcoût |
|---|---|---|
| Sans rotation (référence) | 155 ms | |
| Gzip dans la requête | 216 ms | +61 ms |
register_shutdown_function seule | 205 ms | +50 ms |
Avec fastcgi_finish_request | 158 ms | +3 ms |
La surprise est la ligne du milieu. register_shutdown_function exécute une fonction quand le script est fini. On croit alors le visiteur servi. Mais sous php-fpm, la connexion reste ouverte tant que cette fonction n'a pas rendu la main. php-fpm est le gestionnaire qui lance les processus PHP derrière le serveur web. Elle ne gagne que 11 ms sur 61, un écart que le bruit de mes mesures suffit à expliquer : autant dire rien.
Ce qui coupe le lien est fastcgi_finish_request(). Elle envoie la réponse, ferme la connexion avec le visiteur, et laisse le script continuer. Mon hébergement tourne sur PHP 8.2.35 en php-fpm, où elle existe.
La solution garde donc la fonction de fin, et appelle fastcgi_finish_request() à l'intérieur : la réponse part d'abord, le gzip vient après. Ce n'est pas un vrai asynchrone, où le travail tournerait dans un autre processus. Le processus PHP qui sert la page, le worker, reste occupé jusqu'à la fin du gzip. Un serveur n'a qu'un nombre limité de workers, mais celui-ci travaille une fois par mois. Voici la fonction de fin, abrégée. $lock est le verrou décrit plus bas. ignore_user_abort évite que PHP s'arrête si le visiteur ferme sa page, et set_time_limit(120) lui laisse deux minutes.
register_shutdown_function(function () use ($lock, $lock_file, $logs_dir) {
ignore_user_abort(true); // the visitor is gone, keep going
set_time_limit(120);
fastcgi_finish_request(); // response sent, connection closed
if (!visit_log_archive_pending($logs_dir)) {
touch($lock_file); // failed: stamp the time, retry later
}
flock($lock, LOCK_UN);
});
Ce qui peut mal tourner
Un travail lancé par un visiteur au hasard doit supposer que tout casse : le visiteur part, deux visiteurs arrivent ensemble, le disque est plein. Chaque cas a sa parade.
- Le script est interrompu en plein gzip (redémarrage du serveur, temps dépassé). Le
renamea déjà eu lieu, donc rien n'est perdu :visits-pending.tsvreste lisible par l'outil de statistiques. Le gzip s'écrit d'abord dans un fichier temporaire,visits-archive.tmp, renommé en archive définitive seulement une fois l'archive vérifiée. Une visite suivante reprend le travail et écrase un temporaire resté à moitié écrit. - Deux visiteurs en même temps. Un verrou
flockest posé sur un petit fichier nommé.rotate.lock, et un seul processus à la fois peut le tenir. Il est non bloquant (LOCK_NB) : le second visiteur repart tout de suite, sans attendre. - Le gzip échoue, par exemple faute de place. Avant de supprimer le fichier en attente, le code relit l'archive et compare le nombre de lignes. Un échec laisse tout en place. Ce même
.rotate.locksert alors d'horloge : le code met à jour sa date (touch) pour marquer l'échec. Personne ne réessaie avant une heure, au lieu d'un essai à chaque visite.
Le gzip part dans la fonction de fin, qui ne s'exécute qu'à la fin d'un script PHP. Un test ne peut donc pas lancer la rotation puis lire le résultat dans le même script. Il lance chaque rotation dans un petit programme PHP à part, et attend la fin de ce programme. Les 18 contrôles couvrent le mois courant, une rotation normale et un site resté deux mois sans visite. Ils couvrent aussi le verrou déjà pris et un échec de quota. Cet échec se simule en posant un dossier nommé visits-archive.tmp : le gzip ne peut plus créer son fichier.
Mon avis : où ça s'arrête
Mon avis : sur un mutualisé sans cron, le premier visiteur est une bonne solution à trois conditions. Le travail reste court, il se rejoue sans dégât, et il peut échouer sans rien détruire. Un gzip de 4,7 Mo les remplit. Un export de base de données de plusieurs minutes, non.
Je n'ai pas mesuré le cas d'un site très sollicité. Un worker occupé en coulisse en prive un autre pendant ce temps. Mon site est petit et la rotation ne tourne qu'une fois par mois, donc je n'ai cherché aucun effet. Sur un gros site, il faudrait le vérifier avant de copier la méthode.
Conclusion
Tout est parti d'une panne muette : le tracker écrivait dans le vide et personne ne le savait. La rotation est construite à l'inverse. Elle renomme avant de compresser et vérifie avant de supprimer. Quand elle échoue, elle laisse une trace. Un visits-pending.tsv ou un visits-archive.tmp qui traîne des heures plus tard sur le serveur signale une rotation ratée.
Elle n'a pourtant jamais tourné sur mes vrais fichiers de visites. Les tests sont verts, la mesure est faite, mais le seul vrai essai est le premier visiteur du 1er décembre 2026. Je jetterai un œil au dossier ce jour-là.
Sources
Documentation consultée le 9 novembre 2026 :
- Fonctions PHP :
fastcgi_finish_request,register_shutdown_function,flocketfilemtime. - Tâches planifiées côté hébergeur : documentation OVHcloud sur les cron.
- Le tracker et ses statistiques : analytics PHP sans cookies.
- Les mesures viennent de ma session du 9 novembre 2026, faites sur le serveur de production (médiane de 15 requêtes par variante).