Générer un ZIP de plusieurs gigaoctets sans jamais toucher au disque
La demande d’un client de longue date tenait en une phrase : récupérer toutes ses factures en PDF, dans une seule archive. Pas les vingt dernières, pas celles du mois : toutes. Soit 42 000 fichiers, accumulés sur plusieurs années.
C’est une fonctionnalité que nous avons livrée récemment sur Regate, une plateforme de comptabilité et de finance du groupe Qonto. Sur le papier, c’est un ZIP. En pratique, c’est un bon révélateur de ce qui se passe quand une fonctionnalité produit simple rencontre les contraintes réelles de l’infrastructure sur laquelle elle doit tourner. Voici comment nous l’avons traitée, et ce que nous en retenons.
La méthode évidente ne passe pas
L’implémentation à laquelle on pense en premier tient en trois lignes : télécharger les fichiers depuis l’object storage, les zipper sur le disque local, puis renvoyer l’archive sur S3.
Chacune de ces trois lignes pose un problème dès qu’on quitte son poste de développement.
Le disque. Il faut de la place pour les fichiers sources et pour l’archive finale, soit environ deux fois la taille de l’export. Pour un export de plusieurs gigaoctets, c’est un dimensionnement qu’on ne veut pas provisionner sur chaque worker « au cas où ».
Le système de fichiers. Nos workers tournent dans des pods Kubernetes dont le système de fichiers est en lecture seule. Ce n’est pas un détail de configuration à contourner : c’est une propriété de sécurité qu’on ne négocie pas pour une fonctionnalité d’export.
La mémoire. La variante « tout en mémoire » ne sauve rien : elle déplace simplement le problème de dimensionnement du disque vers la RAM, avec un worker limité à 2 Go.
Le temps. Trois opérations séquentielles — téléchargement complet, compression complète, upload complet — se cumulent. Sur un gros volume, on arrive dans des durées où tout devient fragile : timeouts, redémarrages de pods, jobs qui repartent de zéro.
La bonne question n’était donc pas « comment faire tenir l’export sur le disque », mais « comment ne jamais avoir besoin de disque ». Autrement dit : lire les fichiers depuis le bucket source, les compresser au fil de l’eau, et pousser le résultat vers le bucket de destination en un seul passage.
Le vrai obstacle est dans le format ZIP lui-même
Techniquement, rien n’empêche de streamer. S3 sait recevoir un objet par chunks via le multipart upload, et l’algorithme DEFLATE travaille très bien en stream. L’obstacle est ailleurs : il est dans la spécification du format ZIP.
Dans un ZIP standard, chaque fichier est précédé d’un local header qui contient sa taille compressée et son CRC32. Or ces deux valeurs ne sont connues qu’après avoir compressé le fichier. Les bibliothèques de compression résolvent ça en écrivant un local header provisoire, puis en faisant un seek arrière pour le corriger une fois la compression terminée.
Ce seek arrière est précisément ce qu’un stream S3 ne permet pas. On écrit une fois, dans un seul sens, et ce qui est parti est parti.
Le format ZIP prévoit heureusement ce cas depuis longtemps, via une option qu’on croise rarement : le data descriptor. En positionnant le bit 3 des general purpose flags d’une entrée, on déclare que la taille et le CRC seront écrits après les données, dans un petit bloc dédié. Le local header reste à zéro, le lecteur de ZIP sait où aller chercher l’information, et plus aucun seek arrière n’est nécessaire.
En Ruby, cela tient en une ligne au moment de créer l’entrée :
Restait à convaincre Rubyzip de ne pas tenter le seek arrière malgré tout. Sa classe Zip::OutputStream appelle systématiquement update_local_headers à la fermeture. Nous l’avons donc sous-classée pour neutraliser cet appel — puisque le data descriptor rend l’opération inutile — et pour l’initialiser sur un StringIO plutôt que sur un fichier, afin qu’aucun file descriptor ne soit jamais ouvert sur un système de fichiers en lecture seule.
Trois classes, une responsabilité chacune
Le reste s’est construit autour de trois objets aux rôles nettement séparés.
S3::StreamUploader se fait passer pour un fichier ouvert en écriture. Il accepte des write, accumule dans un buffer, et dès que celui-ci atteint 5 Mo — la taille minimale d’une part imposée par S3 — il envoie la part et vide le buffer. Il expose aussi les quelques méthodes que Rubyzip attend d’un fichier (tell, flush, binmode, rewind), dont certaines sont volontairement inopérantes. En cas d’erreur, il annule le multipart upload pour ne pas laisser d’uploads orphelins facturés sur le bucket.
S3::ZipOutputStream est la sous-classe décrite plus haut : c’est elle qui rend le format ZIP compatible avec un stream à sens unique.
S3::StreamFilesToZip orchestre les dfusioeux, avec une API volontairement banale — start, add_file, finalize — qui masque entièrement la mécanique sous-jacente :
À l’intérieur d’add_file, chaque fichier est lu en chunks depuis le bucket source et réinjecté directement dans le stream de compression, sans jamais être matérialisé :
Ce dup.force_encoding('BINARY') est le genre de détail qui coûte une demi-journée quand on l’oublie. Les chunks renvoyés par le SDK AWS n’ont pas tous le même encodage, et concaténer de l’UTF-8 avec de l’ASCII-8BIT lève une exception au milieu d’un export de deux minutes. Le dup est là parce que force_encoding modifie la chaîne en place et que certains chunks arrivent gelés. Même logique côté noms de fichiers, normalisés en UTF-8 avec remplacement des caractères invalides : les noms de fournisseurs réels contiennent des apostrophes, des accents et parfois des caractères qu’aucune spécification n’avait anticipés.
Passer à l’échelle : découper plutôt que grossir
Le service de streaming règle la question de la mémoire et du disque. Il ne règle pas celle du temps : un export de plusieurs milliers de fichiers traité par un seul job reste un long job séquentiel, et un long job séquentiel est un point de fragilité.
Nous avons donc découpé le travail. À la création d’un export, un premier job répartit les transactions concernées en chunks de 100 transactions et crée un BuildChunkJob par chunk, regroupés dans un Sidekiq::Batch. Chaque chunk produit sa propre archive partielle, directement sur S3, via le service de streaming. Quand le batch se termine, un callback vérifie l’état de tous les chunks et déclenche l’étape de fusion.
Ce découpage apporte trois choses que le job monolithique ne donnait pas.
Du parallélisme. Plusieurs workers travaillent en même temps au lieu d’un seul, et le temps total suit le nombre de workers disponibles plutôt que le volume à traiter. Les 42 000 fichiers du client se sont ainsi répartis sur 420 chunks.
De la granularité dans l’échec. Un chunk qui échoue n’emporte pas les autres. Mieux : nous distinguons les erreurs S3 transitoires — SlowDown, ServiceUnavailable, RequestTimeout — pour lesquelles le chunk est remis en attente et confié au mécanisme de reprise de Sidekiq, des erreurs définitives qui marquent uniquement ce chunk en échec. À l’intérieur d’un chunk, un fichier introuvable est consigné et ignoré : les 99 autres partent quand même.
De la traçabilité. Chaque chunk conserve son état, son nombre de fichiers effectivement archivés, sa taille, et la liste des erreurs rencontrées. Le back-office affiche le tout en temps réel, ce qui change complètement la conversation avec le support quand un export ne rend pas exactement ce que le client attendait.
Reste l’étape de fusion, et c’est la seule que nous n’avons pas pu streamer. Pour assembler les archives partielles dans un stream S3, il aurait fallu dézipper chaque chunk puis le recompresser à la volée — autrement dit refaire intégralement le travail CPU que les BuildChunkJob venaient de paralléliser. Le découpage y perdait tout son intérêt.
Il fallait donc fusionner les ZIP en place, ce qui suppose un disque. D’où les deux ingrédients de cette étape : un volume EFS monté sur le worker — le disque élastique d’AWS, qui ne demande aucun dimensionnement à l’avance — et zipmerge, un outil bas niveau qui recopie les entrées déjà compressées sans jamais toucher à leur contenu. Le MergeJob rapatrie les archives partielles une à une et supprime du disque chaque chunk dès qu’il est fusionné : l’espace occupé ne dépasse jamais l’archive en cours de construction plus un chunk. Ce qui traîne encore est nettoyé dans un bloc ensure, que le job réussisse ou échoue.
Ce que ça donne
En production, l’export du client est passé : une archive de 4,5 Go générée en 5 minutes, pour les 42 000 factures demandées. Aucun disque saturé, aucun worker redémarré, aucun dimensionnement revu à la hausse.
L’anatomie du pipeline se lit mieux sur un export plus petit. Sur une exécution de recette portant sur 366 transactions, le pipeline a produit une archive de 99,6 Mo contenant 347 fichiers, répartie sur quatre chunks traités en parallèle, en une vingtaine de secondes entre la création et l’envoi du lien de téléchargement. Les 19 transactions manquantes n’avaient tout simplement pas de justificatif attaché — chacune consignée nommément dans la table des erreurs de chunk, consultable dans le back-office.
Côté utilisateur, il ne reste qu’un formulaire de filtres, une barre de progression, et un email contenant une URL présignée valable sept jours.
Ce que nous en retenons
Trois choses, qui dépassent largement le cas du ZIP.
La contrainte d’infrastructure est une donnée d’entrée, pas un obstacle à contourner. Un système de fichiers en lecture seule et un worker à 2 Go ne sont pas des problèmes à résoudre : ce sont les conditions dans lesquelles la fonctionnalité doit exister. Les prendre au sérieux dès la conception a produit une architecture dont la consommation mémoire ne dépend pas du volume exporté : 42 000 fichiers et 4,5 Go passent dans le même buffer de 5 Mo que 300 fichiers. Aucune version « on augmentera la taille du disque » n’aurait donné ça.
Les vieux formats ont souvent déjà prévu le cas. Le data descriptor existe dans la spécification ZIP depuis les années 1990. La solution n’était pas dans une bibliothèque plus moderne, mais dans une option peu utilisée d’un format que tout le monde croit connaître. Cela vaut la peine de lire la spécification avant de conclure qu’une chose est impossible.
Le découpage est ce qui rend une fonctionnalité exploitable. Le streaming règle un problème technique ; les chunks, les états et les erreurs consignées règlent un problème d’exploitation. Sans eux, un export qui échoue est une boîte noire. Avec eux, c’est une ligne dans un tableau qu’on peut relancer.
Questions fréquentes
Pourquoi ne pas zipper les fichiers sur le disque du worker ?
Parce que le disque n'est souvent pas une option. Il faut de la place pour les sources et pour l'archive, soit environ deux fois la taille de l'export. Sur un worker Kubernetes en lecture seule, limité à 2 Go de RAM, zipper « comme d'habitude » ne passe pas. Le bon réflexe est de streamer : lire depuis S3, compresser au fil de l'eau, écrire vers S3, sans jamais matérialiser l'archive.
Comment streamer un ZIP vers S3 alors que le format exige un seek arrière ?
Un ZIP standard écrit la taille compressée et le CRC32 dans le local header, donc avant les données — or ces valeurs ne sont connues qu'après compression. S3 n'autorise pas de revenir en arrière. La spécification prévoit ce cas depuis les années 1990 : le data descriptor (bit 3 des general purpose flags) reporte taille et CRC après les données. Plus aucun seek, le stream reste à sens unique.
Faut-il vraiment un disque pour fusionner les archives partielles ?
Oui, pour cette étape seulement. Recompresser chaque chunk à la volée annulerait le gain du parallélisme. On fusionne donc en place, avec un volume EFS (aucun dimensionnement à l'avance) et zipmerge, qui recopie les entrées déjà compressées. L'espace occupé ne dépasse jamais l'archive en cours plus un chunk.
Comment exporter des dizaines de milliers de fichiers sans un job monolithique ?
On découpe. Un premier job répartit le travail en chunks (ici 100 transactions), chacun produit son ZIP partiel en streaming, un callback de batch déclenche la fusion. 42 000 fichiers deviennent 420 chunks traités en parallèle. Un chunk qui échoue n'emporte pas les autres, et les erreurs transitoires S3 sont relancées.
Que se passe-t-il si un fichier manque ou qu'un chunk échoue ?
Un fichier introuvable est consigné et ignoré : les autres partent. Un chunk en erreur S3 transitoire (SlowDown, timeout) est remis en attente. Une erreur définitive ne marque que ce chunk. Chaque chunk garde son état, sa taille, ses fichiers archivés et ses erreurs — visibles dans le back-office, relançables une par une.
Vous avez un
produit en tête ?
Construisons-le ensemble.

