Les exports Excel volumineux peuvent provoquer des erreurs OOM (Out Of Memory) sur les workers Airflow lorsque l'ensemble des données est matérialisé en mémoire. Découvrez comment le streaming MySQL et le mode write-only d'openpyxl permettent de générer ces fichiers avec une consommation mémoire stable.
Un export Excel qui tue un worker Airflow par erreur OOM en pleine nuit, sans le moindre message d’erreur, sur un volume qui passait pourtant très bien en test : voici l’histoire d’un piège classique du Data Engineering, et de sa solution.
Dans cet article
- Pourquoi un export Excel volumineux peut provoquer une erreur OOM (Out Of Memory) sous Airflow.
- Pourquoi
pandas.read_sql(..., chunksize=...)(qui découpe le résultat déjà chargé, sans réduire la mémoire utilisée) ne suffit pas à résoudre le problème.- Comment lire des données MySQL en streaming avec un curseur non bufferisé (qui ne charge pas tout en mémoire d’un coup).
- Comment générer un fichier Excel avec une consommation mémoire stable grâce à openpyxl (la bibliothèque Python de référence pour Excel).
Le problème
Générer un fichier Excel de plusieurs centaines de milliers de lignes semble trivial : une requête SQL, une bibliothèque Python, un fichier en sortie. Pourtant, exécutée dans une tâche Airflow tournant sur une infrastructure à mémoire limitée, par exemple un conteneur dimensionné au plus juste, cette opération est l’une des façons les plus rapides de voir un worker (le processus qui exécute le code d’une tâche) se faire tuer par une erreur OOM (Out Of Memory : le système d’exploitation met fin au processus dès qu’il dépasse la mémoire qui lui est allouée), sans stack trace (la pile d’appels menant à l’erreur) ni message d’erreur applicatif.
Ce n’est ni un problème d’Airflow ni un problème d’Excel : c’est un problème de gestion mémoire. Le même code, dans un simple script Python autonome, présenterait les mêmes caractéristiques, quelle que soit l’infrastructure sous-jacente. Sur un poste de développement avec 16 ou 32 Go de RAM, personne ne le remarque. Sur AWS ECS Fargate (le mode serverless d’Amazon ECS pour exécuter des conteneurs sans gérer de serveurs), un environnement d’exécution courant pour ce type de pipeline, un conteneur dimensionné à 2 ou 4 Go pour limiter les coûts rend le problème inévitable au-delà d’un certain volume.
Dans le cloud, la mémoire est souvent la ressource la plus coûteuse à augmenter. Mieux vaut donc éviter d’en consommer inutilement plutôt que de simplement agrandir les workers.
Le schéma ci-dessous résume déjà les deux chemins possibles, avant même d’entrer dans le détail : la même source MySQL, mais deux façons radicalement différentes d’arriver au fichier final.

Pourquoi ça consomme autant de mémoire
Le réflexe le plus courant consiste à charger les données dans un DataFrame pandas, puis à appeler DataFrame.to_excel(). Le problème vient de deux amplifications qui s’additionnent.
D’abord, un DataFrame construit à partir d’un ResultSet MySQL (l’ensemble des lignes renvoyées par la requête SQL) de plusieurs centaines de milliers de lignes occupe déjà plusieurs fois la taille des données brutes en mémoire, notamment lorsque les colonnes texte sont nombreuses. Ensuite, to_excel() délègue à openpyxl (la bibliothèque Python de référence pour lire et écrire des fichiers Excel), dont le Workbook (le classeur Excel manipulé par la librairie) par défaut conserve toutes les cellules en mémoire jusqu’à save(). La documentation officielle d’openpyxl chiffre ce surcoût : en mode classique, la consommation mémoire avoisine 50 fois la taille du fichier Excel final, soit environ 2,5 Go de RAM pour un fichier de 50 Mo.
Un export de plusieurs centaines de milliers de lignes peut ainsi facilement dépasser plusieurs gigaoctets de RAM avant même l’écriture du fichier : le DataFrame et le classeur openpyxl coexistent un instant en mémoire, chacun représentant à lui seul un multiple de la taille des données brutes.
Pourquoi Fargate transforme le problème en OOMKilled
Chaque tâche Fargate (l’unité de déploiement ECS qui exécute un ou plusieurs conteneurs, à ne pas confondre avec une Task Airflow) déclare une limite de mémoire dure, pas une indication : dès qu’un conteneur la dépasse, Amazon ECS l’arrête avec OutOfMemoryError: Container killed due to memory usage, sans dégradation progressive ni swap (échange de mémoire avec le disque). Un OOMKilled ne produit pas de traceback (journal d’erreur) Python puisque le processus est tué par l’OS, pas par une exception applicative. Le code fonctionne parfaitement sur un jeu de test de 5 000 lignes, et échoue de façon opaque en production sur 500 000, souvent après de longues minutes d’exécution apparemment normale.
L’implémentation naïve
import pandas as pd
connection = mysql_hook.get_conn()
df = pd.read_sql("SELECT * FROM source_table", connection) # (1)
df.to_excel("/tmp/export.xlsx", engine="openpyxl", index=False) # (2)
s3_hook.load_file("/tmp/export.xlsx", key=s3_key, bucket_name=bucket)
En (1), le driver MySQL rapatrie tout le résultat côté client avant de le restituer à pandas : c’est le comportement buffered par défaut du driver. Le paramètre chunksize de read_sql() (qui découpe a posteriori le DataFrame déjà chargé en plusieurs morceaux) ne change rien avec le comportement par défaut du driver MySQL utilisé ici : il n’agit pas sur la façon dont ce driver a lu le résultat. En (2), to_excel() retranscrit ce DataFrame déjà volumineux dans un Workbook classique avant de sérialiser sur disque : pendant un instant, DataFrame et classeur coexistent en mémoire. C’est ce pic qui fait basculer le worker en OOMKilled.
Bien sûr, un CSV serait encore plus simple et plus léger à produire. Mais dans beaucoup de contextes métier, le format Excel reste une exigence : plusieurs feuilles, filtres automatiques, habitudes des utilisateurs ou traitements ultérieurs.
La solution : ne jamais matérialiser plus qu’un lot
Le principe : ne jamais avoir, à un instant donné, plus qu’un petit lot de lignes en mémoire, côté lecture comme côté écriture.
Côté lecture, cursor.fetchmany(size) (qui récupère un lot de lignes du résultat SQL au lieu de tout charger d’un coup) ne suffit pas seul : encore faut-il un curseur non bufferisé, c’est-à-dire qui laisse les lignes sur le serveur MySQL et ne les transmette qu’à la demande, plutôt que le curseur bufferisé par défaut qui rapatrie tout côté client dès l’exécution de la requête. Avec mysql-connector-python, cela se règle via buffered=False ; PyMySQL et mysqlclient offrent le même mécanisme sous le nom de curseur côté serveur.
Côté écriture, openpyxl propose le mode Workbook(write_only=True) (un mode d’écriture qui ne permet que d’ajouter des lignes, sans jamais les relire ni les modifier) : chaque sheet.append() écrit directement la ligne dans le fichier XML du classeur au fil de l’eau, sans jamais garder les lignes précédentes en objets Python. Selon la documentation officielle d’openpyxl, ce mode limite le classeur lui-même à moins de 10 Mo, quel que soit le nombre de lignes, ce qui permet des volumes largement supérieurs au mode classique. Ces 10 Mo ne couvrent toutefois que le classeur : le lot en cours de lecture (les 100 000 lignes de l’exemple ci-dessous) occupe sa propre mémoire en plus, le temps d’être écrit puis libéré. La mémoire totale du pipeline correspond donc à la taille de ce lot, plus ces quelques Mo pour le classeur.
from openpyxl import Workbook
workbook = Workbook(write_only=True)
sheet = workbook.create_sheet()
sheet.append(column_names) # column_names : liste des en-têtes de colonnes, définie en amont
connection = mysql_hook.get_conn()
cursor = connection.cursor(buffered=False) # curseur non bufferisé : lecture réellement en flux
cursor.execute(query)
while True:
rows = cursor.fetchmany(100_000)
if not rows: # fetchmany() renvoie une liste vide quand il n'y a plus de lignes à lire
break
for row in rows:
sheet.append(row)
del rows # libération immédiate en CPython (comptage de références) ; pas besoin de gc.collect() ici, il n'y a pas de cycle à casser
workbook.save(output_path)
En combinant les deux, le pipeline ne garde jamais simultanément en mémoire plus qu’un lot de données, quel que soit le volume total. Le temps d’exécution reste comparable à celui d’un export classique : le léger surcoût des lectures par lots est généralement négligeable face au gain de stabilité.
Le streaming ne supprime pas le dimensionnement, il le déplace :
- Avant : il fallait faire tenir tout le dataset en mémoire, impossible à maîtriser puisque le volume grossit avec le temps sans que le code ne change.
- Maintenant : il faut faire tenir un lot en mémoire, ce qui est contrôlable, mais reste un calcul à faire, pas un acquis automatique.
Rien n’empêche d’appeler fetchmany(5_000_000) : le code fonctionnerait, mais recréerait le même risque d’OOM, avec un facteur de réduction au lieu d’une élimination. La taille de lot à choisir dépend du poids mémoire réel d’une ligne (nombre de colonnes, longueur des champs texte) et de la mémoire disponible sur le worker, marge de sécurité comprise pour le reste du process. Le 100_000 de l’exemple est un point de départ raisonnable pour des lignes courantes sur un conteneur de 2 à 4 Go, pas une valeur universelle.
Pourquoi ça change tout
Avec l’implémentation naïve, la mémoire croît quasi linéairement jusqu’à un pic unique juste avant l’écriture finale :

Avec l’approche par lots, elle dessine un profil en dents de scie plafonné à la taille d’un seul lot :

Avec cette approche, un export de 200 000 lignes et un export de 800 000 lignes consomment le même pic mémoire : seule la durée d’exécution augmente. La seule limite restante est celle du format Excel lui-même, qui plafonne à 1 048 576 lignes par feuille. Le streaming résout le problème mémoire, pas cette contrainte structurelle.
Intégration Airflow
Rien de spécifique à Airflow n’est nécessaire ici : une Task (l’unité d’exécution d’un DAG Airflow) encapsule la logique (ouvrir la connexion, streamer les lots, écrire le fichier), l’accès à MySQL passe par un MySqlHook (le connecteur MySQL fourni par Airflow), et le fichier est déposé sur S3 via boto3 (le SDK Python d’AWS) une fois généré. Le problème d’OOM n’est pas propre à Airflow : ce dernier ne fait qu’exécuter ce code sur un worker aux ressources plafonnées.
Comparaison des deux approches

Au-delà du gain mémoire, la fiabilité du pipeline augmente : un worker qui ne dépasse jamais son plafond ne se fait plus tuer en cours d’exécution. La contrepartie, détaillée plus bas, tient au mode write_only lui-même : impossible de relire ou de modifier une ligne déjà écrite.
Quand cette approche ne suffit pas
Le streaming résout le problème mémoire, pas toutes les contraintes du format ou du mode write_only :
- Un lot mal dimensionné recrée le problème :
fetchmany()n’a pas de limite intégrée. Une taille de lot trop grande produit le même OOM qu’avant, avec un facteur de réduction au lieu d’une élimination. - Limite du format Excel : une feuille est plafonnée à 1 048 576 lignes. Au-delà, il faut répartir les données sur plusieurs feuilles, ou changer de format.
- Volumes de plusieurs millions de lignes : si le format Excel n’est pas imposé par les utilisateurs finaux, Parquet ou CSV restent plus adaptés à grande échelle, en lecture comme en écriture.
- Pas de relecture ni de mise en forme conditionnelle globale : en mode
write_only, une ligne déjà écrite ne peut plus être modifiée. Toute mise en forme dépendant de l’ensemble du jeu de données doit être calculée avant l’écriture, ou nécessite une seconde passe.
Ce qu’il faut retenir
La solution n’est pas d’allouer davantage de mémoire au worker, mais de changer la façon dont les données circulent : lire par lots avec un curseur non bufferisé, écrire en streaming avec Workbook(write_only=True), et ne jamais conserver plus d’un lot en mémoire à la fois.
À retenir : Un pipeline qui traite un gros volume de données ne devrait jamais charger l’ensemble du jeu de données en mémoire si un traitement en flux est possible : lecture SQL →
fetchmany(), lecture Parquet → row groups (blocs de lignes contigus lus un par un), upload S3 → multipart (envoi du fichier découpé en morceaux), API → pagination, Kafka → consommation incrémentale. Le nom de la technique change, l’idée reste la même : ne matérialiser que ce dont on a besoin, au moment où on en a besoin.Ce changement de paradigme (traiter un lot à la fois plutôt que matérialiser l’ensemble du jeu de données en une seule fois) permet à un pipeline de passer à l’échelle sans ressources supplémentaires. La consommation dépend alors de la taille d’un lot, pas du volume total : un export de plusieurs centaines de milliers de lignes et un export de plusieurs millions de lignes posent exactement la même question de dimensionnement, seule la durée d’exécution change.
Ressources
- openpyxl : documentation officielle
- openpyxl : Optimised Modes (mode write_only)
- openpyxl : Performance (consommation mémoire)
- mysql-connector-python : documentation officielle
- Amazon ECS : Troubleshooting OutOfMemoryError errors
- AWS Fargate : grille tarifaire officielle
- Apache Airflow : documentation officielle
- boto3 : documentation du client S3
