Chez Bearstech on retombe régulièrement sur un client avec un schéma de stockage utilisant des chemins particulièrement profonds, par ex. :
/var/ww/app/release/shared/html/public/media/images/cache/crop/rc/Ds/1L/PbzH/uploads/media/image/20201207122604000000_pre_322.jpg.webp
Quel est le problème ? Si votre filesystem possède plusieurs millions de fichiers, au minimum d'assez fortes chances de performances dégradées, quel que soit votre filesystem. Au pire des performances fortement dégradées si ce chemin est sur un stockage réseau (GFS, Gluster, NFS, dans une moindre mesure pour du stockage block comme Ceph).
Si l'intention d'un tel chemin vient uniquement d'un désir maniaque de rangement hiérarchique, alors tâchez de contrôler vos pulsions. Vous savez bien qu'il n'existe aucune hiérarchie définitive qui permet de représenter une taxonomie, ça finit toujours par "hum, mais /images je le met dans /media ou /cache ? Ou alors dans /cache/media/ ? Ou /media/cache ?". Et ça ne résoud aucun problème technique. Préférez des hiérarchies peu profondes, mettez tous vos objets au même niveau dans un seul répertoire de premier niveau, et c'est réglé (c'est d'ailleurs un des mantras de REST).
Par ailleurs petit rappel sur la constitution d'un filesystem : un répertoire est l'équivalent d'un index. Il est conçu de telle sorte que la question à "quelle est l'adresse sur disque de foo/bar.txt ?" permette de demander au répertoire foo/ de fournir la réponse de façon efficiente, donc normalement en o(log(n)) où n est le nombre d'éléments répertoriés par le ... répertoire. Révisez vos classiques de base de données, on ne peut pas faire mieux.
Mais alors tout est au mieux si ces répertoires sont des indexes efficents ? Sauf que pour résoudre un chemin, s'il faut consulter 15 indexes, vous mettez en échec le principe même d'utiliser un index. C'est comme si comme en SQL vous décidiez de faire une jointure sur 15 tables pour trouver un objet élémentaire : ça ne vous viendrait jamais à l'idée, et vous savez que ce serait un échec algorithmique.
Stockage local
Sur un SSD/NVMe local ça peut presque ne pas se sentir : une opération ponctuelle (mettons démarrer votre superbe framework qui charge 5000 classes, ce qui est hélas en dessous de la vraie vie) qui mettait 0,1 s va soudainement en prendre 1,5 s. Ça peut aller, mais sur des opérations à forte fréquence, ça peut être douloureux.
Si vous êtes sur disque rotatif (ça existe encore), ce sera bien pire. Le contenu des répertoires est stocké à des endroits arbitraires, et la tête de lecture du disque va s'affoler : non seulement votre application sera beaucoup plus lente, mais toutes vos IOs sur ce même serveur seront plus lentes. Une grande quantité de répertoires sur un filesystem peut souvent faire écrouler les temps de réponses de vos IOs. Dans le meilleur des cas, votre disque SATA qui faisait du 150 MB/s en accès séquentiel, va chuter à quelques kB/s car il ne faut pas lui demander plus de 50 à 100 IOPS en accès aléatoire.
Mais en théorie ça devrait être compensé par un cache me dites-vous ? Il y a effectivement un cache très stratégique appelé dentry cache chez Linux qui sert précisément à éviter de trop consulter sur le disque le contenu de moults répertoires éparpillés, mais il a une taille limite. Par ailleurs c'est un cache, et Linux va donc le réduire en priorité si les applications demandent de la mémoire avec malloc(). Ces mauvaises conditions croisées arrivent hélas souvent.
Stockage réseau
Vous avez le même problème qu'avec un disque SATA, mais vous remplacez le délai minimum de positionnement de tête de lecture (environ 15 ms) par le RTT du réseau de stockage (0,1 ms max si tout va bien). La résolution des chemins ne peut être que séquentielle : la consultation de bar/ dans foo/bar/ ne peut se faire qu'une fois que foo/ a été consulté pour obtenir l'adresse de bar/ et également vérifier que vous avez le droit de le traverser.
Donc les RTTs se multiplient proportionnellement à la profondeur de votre chemin, résoudre un chemin complet devient plus long. Et bonus, les IOPS sont également multipliées par autant, chargeant d'autant plus votre backend de stockage.
Au mieux vos IOs sont légèrement plus lentes, au pire vous devenez très dépendant de la météo de votre réseau de stockage (que par ailleurs vous surchargez). Et que vous soyez on premises ou sur un cloud, êtes-vous sûr de maîtriser la météo de votre réseau ? Ce dernier est quasiment toujours mutualisé, alors que pour un stockage local vous avez en général une forte garantie de performances minimales (beaucoup moins de contention, voire aucune quand votre opérateur met directement un NVME physique en passthru dans votre VM).
Vieilles habitudes tenaces
Si on remonte fin des années 90, la plupart des filesystems étaient petits, et implémentaient la structure de leur répertoire comme de simples listes. Comme pour SQL, vous savez peut être que pour des petites structures (cela dépend de votre CPU, cache L1/L2, etc) il est plus efficient de scanner une liste que d'invoquer une structure de type B-Tree. C'était simple et efficient pour le contexte de cette époque.
Mais les limites ont été vites ressenties par des applications stockant beaucoup plus de fichiers que ce que prévoyaient les concepteurs des filesystems, et les développeurs d'application ont rapidement contourné le problème en implémentant un index à base de ... répertoires imbriqués ! Si chaque répertoire reste petit et est donc considéré o(1), alors consulter une arborescence de répertoires ramène à une complexité de o(log(n)) (c'est le principe du "Diviser pour régner"). C'était malin et assez efficace.
Mais les développeurs système n'ont pas tardé à améliorer leurs filesystem, on voit par exemple que Linux a obtenu des répertoires indexés dans ext3 en 2002. A partir de là stocker jusqu'à environ 100,000 fichiers par répertoire ne pose pas de problème - mais pas non plus des millions car la conception de l'index reste assez loin de la sophistiquation moteur SQL !
Cependant le mythe tenace du folder hashing s'est installé précisément à cette époque, et une foule d'applications (caches comme Squid, moteurs de template comme Smarty, etc.) se sont mises à être contre-productives. Il est temps que l'on sorte de cette ornière.
Aimez votre filesystem
Certains vont alors rebondir sur ces problèmes : "mais justement S3 a résolu ce problème, il n'y a plus de hiérarchie mais seulement une clé dans un unique index logique !". Certes, au niveau de la conception c'est aussi scalable qu'une base de données peut l'être, et les noSQL clé/valeur sont très scalables.
Mais vous avez peut être remarqué (ou pas si vous n'ouvrez jamais le capot), la machinerie derrière un stockage S3 est autrement plus complexe qu'un filesystem.
Un filesystem bien utilisé, c'est plusieurs ordres de grandeurs performants que n'importe quel stockage S3. Il y a des problèmes d'échelle, de réplication ou de distribution que ça ne peut pas régler, mais tant que vous n'en avez pas besoin, utilisez votre filesystem : utilisez le bien, il vous le rendra bien.
Notes pratiques
Pour un administrateur, des répertoires avec beaucoup de fichiers peuvent être lents à manipuler. Un gros répertoire peut rester efficient en tant qu'index (donc pour obtenir le descripteur d'un fichier dont vous connaissez le nom), mais moins pour lister son contenu. Et ce n'est en général pas la faute du filesystem ni de son index.
Truc 1 : oubliez les fonctions de tri de ls puisque par design vous n'aurez la réponse qu'une fois tous les fichiers énumérés. Avec gnu ls, c'est ce que l'option -f fait.
Truc 2 : ne dépendez pas des méta-données des fichiers listés, car si obtenir la liste peut être très rapide, ensuite demander les méta-données de chacun de ces fichiers avec lstat() va être très coûteux. Là encore l'option -f résoud ce problème, mais évitez aussi les -l et consorts.
Truc 3 : préférez le format -1 avec un fichier par ligne, qui simplifie plein de traitements subséquents que vous pourriez faire à ce long listing.
Nous avons déjà eu des cas ou lister le contenu de gros répertoires sous NFS ou GlusterFS prenait plusieurs minutes, et nous avons pu le réduire à quelques secondes avec ces observations.
Moralité : quand ls ne semble pas aboutir, remplacez-le par ls -f1.
/var/ww/app/release/shared/html/public/media/images/cache/crop/rc/Ds/1L/PbzH/uploads/media/image/20201207122604000000_pre_322.jpg.webp
Quel est le problème ? Si votre filesystem possède plusieurs millions de fichiers, au minimum d'assez fortes chances de performances dégradées, quel que soit votre filesystem. Au pire des performances fortement dégradées si ce chemin est sur un stockage réseau (GFS, Gluster, NFS, dans une moindre mesure pour du stockage block comme Ceph).
Si l'intention d'un tel chemin vient uniquement d'un désir maniaque de rangement hiérarchique, alors tâchez de contrôler vos pulsions. Vous savez bien qu'il n'existe aucune hiérarchie définitive qui permet de représenter une taxonomie, ça finit toujours par "hum, mais /images je le met dans /media ou /cache ? Ou alors dans /cache/media/ ? Ou /media/cache ?". Et ça ne résoud aucun problème technique. Préférez des hiérarchies peu profondes, mettez tous vos objets au même niveau dans un seul répertoire de premier niveau, et c'est réglé (c'est d'ailleurs un des mantras de REST).
Par ailleurs petit rappel sur la constitution d'un filesystem : un répertoire est l'équivalent d'un index. Il est conçu de telle sorte que la question à "quelle est l'adresse sur disque de foo/bar.txt ?" permette de demander au répertoire foo/ de fournir la réponse de façon efficiente, donc normalement en o(log(n)) où n est le nombre d'éléments répertoriés par le ... répertoire. Révisez vos classiques de base de données, on ne peut pas faire mieux.
Mais alors tout est au mieux si ces répertoires sont des indexes efficents ? Sauf que pour résoudre un chemin, s'il faut consulter 15 indexes, vous mettez en échec le principe même d'utiliser un index. C'est comme si comme en SQL vous décidiez de faire une jointure sur 15 tables pour trouver un objet élémentaire : ça ne vous viendrait jamais à l'idée, et vous savez que ce serait un échec algorithmique.
Stockage local
Sur un SSD/NVMe local ça peut presque ne pas se sentir : une opération ponctuelle (mettons démarrer votre superbe framework qui charge 5000 classes, ce qui est hélas en dessous de la vraie vie) qui mettait 0,1 s va soudainement en prendre 1,5 s. Ça peut aller, mais sur des opérations à forte fréquence, ça peut être douloureux.
Si vous êtes sur disque rotatif (ça existe encore), ce sera bien pire. Le contenu des répertoires est stocké à des endroits arbitraires, et la tête de lecture du disque va s'affoler : non seulement votre application sera beaucoup plus lente, mais toutes vos IOs sur ce même serveur seront plus lentes. Une grande quantité de répertoires sur un filesystem peut souvent faire écrouler les temps de réponses de vos IOs. Dans le meilleur des cas, votre disque SATA qui faisait du 150 MB/s en accès séquentiel, va chuter à quelques kB/s car il ne faut pas lui demander plus de 50 à 100 IOPS en accès aléatoire.
Mais en théorie ça devrait être compensé par un cache me dites-vous ? Il y a effectivement un cache très stratégique appelé dentry cache chez Linux qui sert précisément à éviter de trop consulter sur le disque le contenu de moults répertoires éparpillés, mais il a une taille limite. Par ailleurs c'est un cache, et Linux va donc le réduire en priorité si les applications demandent de la mémoire avec malloc(). Ces mauvaises conditions croisées arrivent hélas souvent.
Stockage réseau
Vous avez le même problème qu'avec un disque SATA, mais vous remplacez le délai minimum de positionnement de tête de lecture (environ 15 ms) par le RTT du réseau de stockage (0,1 ms max si tout va bien). La résolution des chemins ne peut être que séquentielle : la consultation de bar/ dans foo/bar/ ne peut se faire qu'une fois que foo/ a été consulté pour obtenir l'adresse de bar/ et également vérifier que vous avez le droit de le traverser.
Donc les RTTs se multiplient proportionnellement à la profondeur de votre chemin, résoudre un chemin complet devient plus long. Et bonus, les IOPS sont également multipliées par autant, chargeant d'autant plus votre backend de stockage.
Au mieux vos IOs sont légèrement plus lentes, au pire vous devenez très dépendant de la météo de votre réseau de stockage (que par ailleurs vous surchargez). Et que vous soyez on premises ou sur un cloud, êtes-vous sûr de maîtriser la météo de votre réseau ? Ce dernier est quasiment toujours mutualisé, alors que pour un stockage local vous avez en général une forte garantie de performances minimales (beaucoup moins de contention, voire aucune quand votre opérateur met directement un NVME physique en passthru dans votre VM).
Vieilles habitudes tenaces
Si on remonte fin des années 90, la plupart des filesystems étaient petits, et implémentaient la structure de leur répertoire comme de simples listes. Comme pour SQL, vous savez peut être que pour des petites structures (cela dépend de votre CPU, cache L1/L2, etc) il est plus efficient de scanner une liste que d'invoquer une structure de type B-Tree. C'était simple et efficient pour le contexte de cette époque.
Mais les limites ont été vites ressenties par des applications stockant beaucoup plus de fichiers que ce que prévoyaient les concepteurs des filesystems, et les développeurs d'application ont rapidement contourné le problème en implémentant un index à base de ... répertoires imbriqués ! Si chaque répertoire reste petit et est donc considéré o(1), alors consulter une arborescence de répertoires ramène à une complexité de o(log(n)) (c'est le principe du "Diviser pour régner"). C'était malin et assez efficace.
Mais les développeurs système n'ont pas tardé à améliorer leurs filesystem, on voit par exemple que Linux a obtenu des répertoires indexés dans ext3 en 2002. A partir de là stocker jusqu'à environ 100,000 fichiers par répertoire ne pose pas de problème - mais pas non plus des millions car la conception de l'index reste assez loin de la sophistiquation moteur SQL !
Cependant le mythe tenace du folder hashing s'est installé précisément à cette époque, et une foule d'applications (caches comme Squid, moteurs de template comme Smarty, etc.) se sont mises à être contre-productives. Il est temps que l'on sorte de cette ornière.
Aimez votre filesystem
Certains vont alors rebondir sur ces problèmes : "mais justement S3 a résolu ce problème, il n'y a plus de hiérarchie mais seulement une clé dans un unique index logique !". Certes, au niveau de la conception c'est aussi scalable qu'une base de données peut l'être, et les noSQL clé/valeur sont très scalables.
Mais vous avez peut être remarqué (ou pas si vous n'ouvrez jamais le capot), la machinerie derrière un stockage S3 est autrement plus complexe qu'un filesystem.
Un filesystem bien utilisé, c'est plusieurs ordres de grandeurs performants que n'importe quel stockage S3. Il y a des problèmes d'échelle, de réplication ou de distribution que ça ne peut pas régler, mais tant que vous n'en avez pas besoin, utilisez votre filesystem : utilisez le bien, il vous le rendra bien.
Notes pratiques
Pour un administrateur, des répertoires avec beaucoup de fichiers peuvent être lents à manipuler. Un gros répertoire peut rester efficient en tant qu'index (donc pour obtenir le descripteur d'un fichier dont vous connaissez le nom), mais moins pour lister son contenu. Et ce n'est en général pas la faute du filesystem ni de son index.
Truc 1 : oubliez les fonctions de tri de ls puisque par design vous n'aurez la réponse qu'une fois tous les fichiers énumérés. Avec gnu ls, c'est ce que l'option -f fait.
Truc 2 : ne dépendez pas des méta-données des fichiers listés, car si obtenir la liste peut être très rapide, ensuite demander les méta-données de chacun de ces fichiers avec lstat() va être très coûteux. Là encore l'option -f résoud ce problème, mais évitez aussi les -l et consorts.
Truc 3 : préférez le format -1 avec un fichier par ligne, qui simplifie plein de traitements subséquents que vous pourriez faire à ce long listing.
Nous avons déjà eu des cas ou lister le contenu de gros répertoires sous NFS ou GlusterFS prenait plusieurs minutes, et nous avons pu le réduire à quelques secondes avec ces observations.
Moralité : quand ls ne semble pas aboutir, remplacez-le par ls -f1.