Handling Large Binary Assets with Streaming Responses in Axum

Par Emmanuel Forgues - 6 octobre 2025

Publié initialement le 6 octobre 2025.

Mis à jour le 20 avril 2026.

Migré vers StratoSentry le 6 mai 2026.

Chapô

Dans un contexte où les applications web doivent servir des fichiers de plusieurs gigaoctets – vidéos, archives, images médicales ou bases de données exportées – la capacité à diffuser ces actifs sans saturer la mémoire serveur devient cruciale. Le framework Rust Axum, construit sur Tokio et Hyper, propose une approche native du streaming HTTP qui répond aux exigences de scalabilité, de résilience et de conformité. Cet article décrypte les mécanismes sous‑jacents, montre comment implémenter un service de diffusion performant et sécurisé, expose les compromis techniques et fournit des repères décisionnels pour les dirigeants et les équipes d’ingénierie.

1️⃣ Contexte et enjeux

1.1 Pourquoi le streaming ?

Les architectures monolithiques ou « serve‑all‑in‑memory » fonctionnent tant que la taille des actifs reste contenue. Dès que l’on parle de fichiers dépassant quelques centaines de mégaoctets, deux problèmes majeurs apparaissent :

ProblèmeConséquence opérationnelle
Consommation mémoire – charger le fichier entier en RAM avant de l’envoyerRisque d’out‑of‑memory, limitation du nombre de requêtes concurrentes
Latence de démarrage – le client attend que le serveur lise tout le contenuExpérience utilisateur dégradée, impact sur les indicateurs de performance (TTFB)

Le streaming répond à ces deux points : le serveur lit et envoie les octets au fur et à mesure, libérant la mémoire dès qu’ils sont transmis. Cette technique s’appuie sur Chunked Transfer Encoding (RFC 7230) ou sur l’envoi d’un Content‑Length connu lorsqu’on peut le déterminer à l’avance.

1.2 Axum dans l’écosystème Rust

Axum est un framework web minimaliste et extensible, basé sur :

  • Tokio, runtime asynchrone performant ;
  • Hyper, implémentation HTTP/1.1 et HTTP/2 de bas niveau ;
  • Tower, abstractions middleware (retries, tracing, rate‑limiting).

Ces briques offrent un contrôle fin du flux d’E/S grâce à la primitive hyper::Body, qui accepte tout type implémentant le trait Stream<Item = Result<Bytes, E>>. Axum permet ainsi le streaming de gros actifs sans recourir à des bibliothèques externes lourdes.

2️⃣ Principes du streaming HTTP avec Axum

2.1 Le corps de réponse (hyper::Body)

Dans Hyper, le type Body représente un flux d’octets découpés en chunks. Deux modes sont possibles :

ModeDescription
Chunked (sans Content‑Length)Chaque chunk est préfixé par sa taille ; la fin du flux indique la clôture de la réponse. Idéal quand la longueur n’est pas connue à l’avance ou pour les réponses générées dynamiquement.
Fixed length (Content‑Length présent)Le client sait exactement combien d’octets il recevra, ce qui facilite le buffering côté navigateur et permet le support des requêtes Range.

Axum expose la fonction IntoResponse ; lorsqu’on retourne un Body, le framework négocie le mode approprié.

2.2 Le trait Stream de Tokio

Un stream est l’équivalent asynchrone d’un itérateur. En pratique, on crée souvent un tokio::fs::File et on le convertit en Stream via la méthode ReaderStream::new(file) (crate tokio-util). Chaque appel lit une portion du fichier (généralement 8 KB) puis la pousse dans le pipeline HTTP.

use axum::{
    response::{IntoResponse, Response},
    http::StatusCode,
};
use tokio_util::io::ReaderStream;
use hyper::Body;

async fn stream_file(path: &str) -> impl IntoResponse {
    match tokio::fs::File::open(path).await {
        Ok(file) => {
            let stream = ReaderStream::new(file);
            // Le corps est un flux de Bytes
            Response::builder()
                .status(StatusCode::OK)
                .header("Content-Type", "application/octet-stream")
                .body(Body::wrap_stream(stream))
                .unwrap()
        }
        Err(_) => (StatusCode::NOT_FOUND, "File not found").into_response(),
    }
}

Cette implémentation ne charge jamais le fichier complet en mémoire ; chaque chunk est lu, envoyé, puis libéré.

3️⃣ Mise en œuvre : lecture de fichiers volumineux

3.1 Choisir la bonne taille de buffer

Le paramètre ReaderStream::new(file) utilise par défaut une taille de tampon de 8<0xE2><0x80><0xAF>KB. Pour des contenus multimédias (vidéo, audio), augmenter le tampon à 64<0xE2><0x80><0xAF>KB ou 256<0xE2><0x80><0xAF>KB peut réduire le nombre d’appels système et améliorer le débit, avec une consommation mémoire marginale par connexion.

let stream = ReaderStream::with_capacity(file, 256 * 1024); // 256 KB

3.2 Utiliser tokio::fs::File::try_clone pour la concurrence

Lorsque plusieurs clients demandent le même fichier simultanément, on pourrait être tenté de partager un handle unique. En pratique, chaque requête doit disposer d’un descripteur distinct afin que les positions de lecture restent indépendantes. La méthode try_clone() crée un nouveau descripteur pointant vers le même inode, sans copier les données :

let file = tokio::fs::File::open(path).await?;
let cloned = file.try_clone().await?; // chaque requête utilise son clone

Cette approche évite la contention sur la position du curseur et permet de ne pas charger le fichier en RAM.

3.3 Exploiter sendfile via le crate tokio-sendfile (optionnel)

Sur les systèmes Unix, l’appel système sendfile transfère directement des octets entre un descripteur de fichier et une socket, sans passer par l’espace utilisateur. Le crate tokio-sendfile expose cette fonctionnalité dans un contexte asynchrone ; il peut être intégré comme middleware pour les réponses « static file » où aucune transformation n’est requise.

Note : sendfile ne fonctionne pas avec TLS (car le socket est chiffré) et n’est pas disponible sur Windows. Son usage doit donc être conditionné à la configuration du serveur.

4️⃣ Gestion des requêtes partielles (Range) et reprise

4.1 Pourquoi les Range ?

Les clients modernes (navigateurs, lecteurs vidéo) utilisent l’en-tête Range pour :

  • Reprendre un téléchargement interrompu ;
  • Lire uniquement la partie d’un fichier nécessaire à la lecture en flux (ex. : seeking dans une vidéo).

Sans support de Range, le serveur renvoie toujours le fichier complet, ce qui augmente la bande passante et la latence.

4.2 Implémentation dans Axum

Axum ne fournit pas d’implémentation prête à l’emploi pour les réponses partielles ; il faut analyser l’en‑tête Range, calculer les offsets et retourner un statut 206 Partial Content avec les en‑têtes appropriées (Content-Range, Accept-Ranges). Le code suivant illustre le principe :

use axum::extract::TypedHeader;
use hyper::{header, StatusCode};

async fn range_file(
    TypedHeader(range): TypedHeader<header::Range>,
    Path(path): Path<String>,
) -> impl IntoResponse {
    // Ouverture du fichier et récupération de sa taille
    let file = match tokio::fs::File::open(&path).await {
        Ok(f) => f,
        Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(),
    };
    let metadata = file.metadata().await.unwrap();
    let total_len = metadata.len();

// On ne supporte qu'un seul range pour la simplicité
    let (start, end) = match range.iter().next() {
        Some(r) => (r.start, r.end.unwrap_or(total_len - 1)),
        None => return (StatusCode::BAD_REQUEST, "Invalid Range").into_response(),
    };
    // Sécurisation des bornes
    if start >= total_len || end >= total_len || start > end {
        return (StatusCode::RANGE_NOT_SATISFIABLE, "").into_response();
    }

// Seek vers le point de départ
    let mut file = file;
    file.seek(std::io::SeekFrom::Start(start)).await.unwrap();

// Taille du fragment à transmettre
    let length = end - start + 1;
    let stream = ReaderStream::with_capacity(file.take(length), 256 * 1024);

let body = Body::wrap_stream(stream);
    (StatusCode::PARTIAL_CONTENT,
     [
        ("Content-Type", "application/octet-stream"),
        ("Accept-Ranges", "bytes"),
        ("Content-Range", format!("bytes {}-{}/{}", start, end, total_len).as_str()),
        ("Content-Length", length.to_string().as_str())
    ],
     body)
}

Cette implémentation :

  • lit uniquement la portion demandée ;
  • préserve la mémoire grâce à take(length) qui limite le nombre d’octets lus ;
  • renvoie les en‑têtes obligatoires pour que le client sache qu’il s’agit d’une réponse partielle.

4.3 Gestion des multiples ranges

Le standard autorise plusieurs intervalles séparés par des virgules, mais la plupart des navigateurs ne les utilisent pas. Gérer ce cas nécessite de créer un multipart/byteranges, une opération plus lourde qui doit être justifiée par le besoin métier.

5️⃣ Optimisations côté serveur

OptimisationImpact attenduConditions d’utilisation
Compression on‑the‑fly (gzip, brotli)Réduction du trafic pour les fichiers textuels ; augmentation de la charge CPUNe pas compresser des médias déjà compressés (MP4, JPEG)
Cache‑Control & ETagDiminution du nombre de requêtes grâce au caching client ou CDNLes actifs sont immuables ou versionnés
TLS termination en amontPermet l’utilisation de sendfile et réduit la charge cryptographique sur le worker AxumUn reverse proxy (Envoy, Nginx) gère le TLS
Limitation du débit par IPProtection contre les abus de bande passanteImplémentation via middleware Tower‑RateLimit

5.1 Exemple de middleware de limitation de débit

use tower::limit::ConcurrencyLimitLayer;
use axum::Router;

let app = Router::new()
    .route("/files/:name", get(stream_file))
    .layer(ConcurrencyLimitLayer::new(100)); // max 100 requêtes simultanées

Ce contrôle prévient les Denial‑of‑Service liés à l’épuisement des sockets ou du pool de threads Tokio.

6️⃣ Sécurité et conformité

6.1 Validation du chemin d’accès

Un attaquant peut tenter une injection de ../ pour accéder à des fichiers hors du répertoire prévu (path traversal). La bonne pratique consiste à :

use std::path::{Path, PathBuf};

fn sanitize_path(base: &Path, user_input: &str) -> Option<PathBuf> {
    let mut candidate = base.to_path_buf();
    for part in Path::new(user_input).components() {
        match part {
            std::path::Component::Normal(p) => candidate.push(p),
            _ => return None, // rejette les .. ou les chemins absolus
        }
    }
    if candidate.starts_with(base) { Some(candidate) } else { None }
}

6.2 Authentification & autorisation

Les actifs sensibles (données médicales, documents juridiques) requièrent une couche d’autorisation avant le streaming. Axum supporte les extractors personnalisés ; on peut y placer un middleware qui vérifie un token JWT ou consulte un service d’authN/Z.

6.3 Journalisation et traçabilité

Chaque flux doit être enregistré avec :

  • l’identifiant de la requête (trace‑id) ;
  • l’adresse IP client ;
  • le nom du fichier, la taille envoyée, le statut HTTP ;
  • les en‑têtes Range le cas échéant.

Ces logs sont indispensables pour répondre aux exigences du RGPD (droit à l’accès) et de l’ANSSI concernant la traçabilité des accès aux données sensibles.

6.4 Gestion des erreurs

Ne jamais renvoyer d’informations détaillées sur les raisons d’un échec (ex. : “file not found” vs “permission denied”). Un code générique 404 ou 403 suffit, afin de ne pas aider un éventuel attaquant à cartographier le système de fichiers.

7️⃣ Observabilité, métriques et résilience

MétriqueDescriptionOutil recommandé
Throughput (bytes/s)Débit moyen par connexion ou agrégéPrometheus (axum::extract::Extension<Metric>).
Latency du premier octet (TTFB)Temps entre la requête et l’envoi du premier chunkOpenTelemetry trace.
Nombre de connexions activesCharge concurrente sur le serveurGrafana dashboards via tokio-metrics.
Taux d’erreurs 4xx/5xxQualité du service, incidents réseau ou disqueAlertmanager.

Axum expose des extensions permettant d’injecter des collecteurs de métriques dans chaque handler ; le code suivant montre un compteur de réponses :

use prometheus::{IntCounterVec, Encoder, TextEncoder};
use axum::Extension;

static RESPONSE_COUNTER: Lazy<IntCounterVec> = Lazy::new(|| {
    IntCounterVec::new(
        opts!("http_responses_total", "Total HTTP responses by status"),
        &["status"]
    ).unwrap()
});

async fn counted_handler(Extension(counter): Extension<IntCounterVec>) -> impl IntoResponse {
    counter.with_label_values(&["200"]).inc();
    // … réponse …
}

7.1 Résilience face aux pannes de disque

  • Fallback en lecture : configurer une réplication des fichiers sur plusieurs volumes (RAID‑10 ou stockage objet S3) et, en cas d’erreur IO, basculer vers le second backend.
  • Timeouts : appliquer un délai maximal (tokio::time::timeout) autour de la lecture du fichier pour éviter qu’un disque défectueux bloque indéfiniment les workers.

8️⃣ Cas d’usage : service de diffusion de vidéos pour une plateforme e‑learning

8.1 Contexte métier

Une startup française propose des cours en ligne contenant des vidéos HD (jusqu’à 4 Go chacune). Les exigences principales :

  • Disponibilité 99,9 % ;
  • Reprise instantanée pour les apprenants avec connexion mobile intermittente ;
  • Conformité GDPR – les vidéos contiennent parfois des données personnelles d’étudiants.

8.2 Architecture proposée

┌───────────────────────┐
│   Reverse Proxy (NGINX)│
│ - TLS termination      │
│ - Rate limiting        │
└─────────▲─────────────┘
          │
 ┌────────▼─────────┐
 │    Axum Service  │
 │ - Handlers       │
 │ - Auth JWT       │
 │ - Streaming (ReaderStream) │
 │ - Range support  │
 └───────▲──────────┘
         │
   ┌─────▼───────┐
   │ Object Store│
   │ (MinIO S3)  │
   └─────────────┘
  • NGINX gère le TLS, la mise en cache HTTP et les limites de débit.
  • Axum valide le JWT, vérifie les droits d’accès et diffuse le fichier depuis MinIO via le SDK S3 qui fournit un stream (sans passer par le disque local).

8.3 Implémentation clé (extrait)

async fn stream_video(
    TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
    Path(video_id): Path<String>,
) -> impl IntoResponse {
    // 1️⃣ Vérification du JWT et des droits d’accès

Retour au blog

StratoSentry - 125 boulevard Saint-Denis, 92400 Courbevoie, France - contact@stratosentry.com