Introduction
Comme nous l’avons vu dans les deux premières parties, mettre en place un service de streaming vidéo n’a rien d’évident. Pourtant, en s’appuyant sur une stack moderne autour de FrankenPHP et de l’encodeur FFmpeg, il est tout à fait possible de construire une architecture cohérente et performante.
Nous avons déjà abordé la mise en place de la stack complète : de l’encodage vidéo et du split en segments HLS (m3u8)via FFmpeg, jusqu’à l’intégration et la diffusion avec Symfony 7 + FrankenPHP.
👉 Dans cette troisième partie, nous passons à un nouvel enjeu : la vitesse et l’optimisation côté distribution. L’objectif est clair : automatiser à 100 % la diffusion HLS grâce à un cache HTTP haute performance : Varnish.
Varnish, késako ?
Dans le monde du DevOps, on croise une multitude d’outils censés améliorer les performances et la fiabilité. Mais quand il s’agit de vidéo en streaming, les choses se corsent rapidement : temps de chargement élevés, proxy mal configurés, CDN coûteux ou mal adaptés…
Ici, pas de cluster AWS tentaculaire ni d’infra surdimensionnée. On reste concentrés sur une stack Docker locale et maîtrisée. Et vous allez vite voir qu’entre un setup “sans cache” et un setup “avec Varnish”, il existe un gouffre abyssalen termes de vitesse et de charge serveur.
Le rôle de Varnish
Varnish est un reverse proxy cache HTTP ultra rapide qui stocke en RAM les contenus demandés :
- pages HTML,
- assets statiques (JS, CSS, images),
- et dans notre cas… les vidéos HLS (playlists et segments).
En pratique, Varnish se place avant votre backend (ici FrankenPHP + Symfony 7). Il reçoit toutes les requêtes, et si le contenu est déjà en cache, il le renvoie instantanément, sans même déranger votre application. Résultat :
et une expérience utilisateur fluide sur la lecture vidéo.
moins de charge CPU/PHP,
temps de réponse réduits,
La performance, comment ?
Maintenant que le contexte est clair, il est temps de parler performances réelles.
Dans une plateforme vidéo, l’idée est simple : à chaque upload d’une vidéo, l’encodage HLS se déclenche, et une fois la vidéo packagée en segments, on aimerait que le cache Varnish soit automatiquement “warmup”. Ainsi, les utilisateurs ne subissent pas le fameux “premier chargement lent” : tout est déjà prêt en RAM, servi à la vitesse de l’éclair.
Le cas particulier du HLS
Contrairement à un fichier MP4 unique que l’on pourrait mettre en cache d’un seul bloc, le HLS fonctionne par fragmentation :
- une playlist
.m3u8
(master + variantes), - des segments
.ts
ou.m4s
, découpés en petits morceaux de 2 à 6 secondes.
C’est là que les choses se compliquent.
Varnish n’est pas optimal pour gérer de très gros fichiers continus en cache (streaming direct type MP4). Mais le découpage naturel du HLS en dizaines ou centaines de petits fichiers devient un avantage : chaque segment est petit, facile à stocker, rapide à délivrer.
👉 En d’autres termes : là où Varnish échoue sur un MP4 géant, il excelle sur des segments HLS fragmentés.
Warmup intelligent
En automatisant le préchargement (warmup) des playlists et segments dès l’upload/encodage :
la RAM de Varnish sert de buffer vidéo, réduisant drastiquement la latence.
la vidéo est instantanément disponible pour les utilisateurs,
les requêtes initiales ne “tapent” pas FrankenPHP/Symfony.

Comment ça marche ?
Maintenant que l’on a posé le contexte et l’importance du cache vidéo, voyons concrètement comment la stack est construite et fonctionne au quotidien.
Architecture générale
Voici le schéma global :
Utilisateur → Varnish Cache (Port 80) → FrankenPHP/Caddy (Port 80 interne) → Vidéos
Les composants
- FrankenPHP avec Caddy : joue le rôle de serveur applicatif et web.
- Varnish Cache : agit comme proxy HTTP haute vitesse, placé en frontal pour servir directement les vidéos depuis la RAM.
- PostgreSQL : base de données pour stocker les métadonnées vidéo.
- RabbitMQ : file de messages pour gérer l’encodage vidéo en asynchrone.
- Workers : exécutent en arrière-plan l’encodage et le packaging des vidéos.
Pipeline de traitement vidéo
1. Upload & encodage
Lorsqu’une vidéo est uploadée :
- Le fichier original est stocké dans
/public/videos/{id}/original.{ext}
- Plusieurs versions sont générées (480p, 720p, 1080p)
- Les segments HLS sont créés pour chaque résolution avec chiffrement AES-128
- Une master playlist assemble toutes les résolutions
2. Segmentation HLS
Chaque version est découpée en segments de ~6 secondes :
/public/videos/{id}/hls_{quality}/
├── playlist.m3u8
├── segment_000.ts
├── segment_001.ts
├── segment_002.ts
├── encryption.key
└── encryption.keyinfo
Paramètres clés :
- Segments de 6s (
HLS_SEGMENT_DURATION
) - Format : MPEG-TS + AES-128
- Segments en fichiers indépendants (pas de byte-range unique)
3. Master playlist
Chaque vidéo a un fichier principal /public/videos/{id}/master.m3u8
qui référence toutes les qualités
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=854x480
hls_480p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720
hls_720p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080
hls_1080p/playlist.m3u8
C’est ce fichier que le player vidéo (hls.js, Safari, ExoPlayer, etc.) charge en premier pour choisir automatiquement la bonne qualité.
Le souci de Caddy
Problème initial : HTTPS et cache inopérant
Par défaut, Caddy (utilisé avec FrankenPHP) force automatiquement toutes les requêtes HTTP vers HTTPS.
Sur le papier, c’est parfait pour la sécurité. Mais dans notre architecture, ça posait un gros problème :
👉 Varnish est placé en frontal sur le port 80, et quand Caddy redirige tout en HTTPS, le cache est contourné. Résultat :
- chaque requête vidéo tapait directement le backend,
- aucun segment HLS n’était servi par Varnish,
- la charge serveur restait inutilement élevée.
Solution mise en place
Pour corriger ce problème, trois ajustements ont été nécessaires :
- Désactiver l’auto-HTTPS dans Caddy grâce à
auto_https off
- Configurer le file_server de Caddy pour servir directement les fichiers vidéo (
.ts
,.m3u8
,.mp4
, etc.) sans passer par PHP - Adapter les règles Varnish afin de cacher agressivement tout le contenu sous
/videos/
(playlists et segments)
Résultats obtenus
- CORS activé :
Access-Control-Allow-Origin: *
pour permettre la lecture des flux HLS depuis n’importe quel player - Première requête :
HTTP 200
avecX-Cache: MISS
(contenu servi par FrankenPHP) - Deuxième requête :
X-Cache: HIT
,X-Cache-Hits: 1
,Age: 4
(contenu servi instantanément par Varnish) - Cache efficace :
Cache-Control: public, max-age=604800, immutable
→ soit 1 semaine de cache pour les segments vidéo
Concrètement, après cette correction, les segments .ts
passent de plusieurs dizaines de millisecondes côté FrankenPHP à moins de 1 ms côté Varnish.
{
frankenphp {
worker {
file /app/public/index.php
watch /app/**/*.php
watch /app/**/*.twig
}
}
# Disable automatic HTTPS redirects
auto_https off
}
:80 {
root * /app/public
encode zstd gzip
# Serve video files directly without PHP processing
@videos path_regexp ^/videos/.*\.(ts|m3u8|mp4|webm|avi|mkv)$
handle @videos {
file_server
}
# Handle all other requests through PHP
php_server
log {
format json
}
}
Warmup automatique post-upload (optionnel mais killer)
# master + variantes
curl -s -o /dev/null http://varnish/videos/118/master.m3u8
for q in 480p 720p 1080p; do
curl -s -o /dev/null http://varnish/videos/118/hls_${q}/playlist.m3u8
# précharger les N premiers segments (start-up boost)
for i in $(seq -w 000 015); do
curl -s -o /dev/null http://varnish/videos/118/hls_${q}/segment_${i}.ts &
done
done
wait
astuce : charger 10–20 segments par qualité suffit pour éliminer le cold start; le reste se remplit au fil de la lecture.
Le service de cache auto :
<?php
namespace App\Service;
use App\Entity\Videos;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
class VarnishCacheWarmupService
{
public function __construct(
private HttpClientInterface $httpClient,
private LoggerInterface $logger,
private string $varnishBase = 'http://varnish:80', // ENV: VARNISH_BASE
private int $segmentsToWarm = 12, // 12 x 6s ≈ 72s
private bool $warmKeys = false // sécurité: off par défaut
) {}
public function warmupVideoCache(Videos $video): void
{
if ($video->getStatus() !== 'completed') {
return;
}
$vid = $video->getId();
$this->logger->info('Starting cache warmup for video', ['video_id' => $vid]);
try {
// 1) Page show (HTML)
$this->warmGet("/video/{$vid}");
// 2) Fichiers vidéo MP4 par résolution déclarée
$this->warmVideoFiles($video);
// 3) HLS: master + variantes + premiers segments (+ clé si activé)
$this->warmHls($video);
$this->logger->info('Cache warmup completed for video', ['video_id' => $vid]);
} catch (\Throwable $e) {
$this->logger->error('Cache warmup failed', ['video_id' => $vid, 'error' => $e->getMessage()]);
}
}
private function warmVideoFiles(Videos $video): void
{
$res = $video->getResolutions() ?? [];
foreach ($res as $quality => $path) {
if (str_starts_with($quality, 'hls')) continue; // on traite via warmHls
if ($quality === 'hls_master') continue;
$url = $this->getVideoUrl($video, $quality);
if ($url) $this->warmGet($url);
}
// Original
if ($url = $this->getVideoUrl($video, 'original')) {
$this->warmGet($url);
}
}
private function warmHls(Videos $video): void
{
$vid = $video->getId();
// Master
if ($master = $this->getHlsUrl($video, 'master')) {
$this->warmGet($master);
}
// Qualités détectées dynamiquement
$qualities = $this->detectHlsQualities($video); // e.g. ['480p','720p','1080p']
// Playlists + segments en parallèle
$requests = [];
foreach ($qualities as $q) {
$playlist = "/videos/{$vid}/hls_{$q}/playlist.m3u8";
$requests[] = $this->asyncGet($playlist);
for ($i = 0; $i < $this->segmentsToWarm; $i++) {
$seg = sprintf('/videos/%d/hls_%s/segment_%03d.ts', $vid, $q, $i);
$requests[] = $this->asyncGet($seg);
}
if ($this->warmKeys) {
$keyUrl = "/api/video/{$vid}/key/{$q}";
$requests[] = $this->asyncGet($keyUrl);
}
}
// Drain parallel requests
foreach ($this->httpClient->stream($requests) as $response => $chunk) {
// no-op: on consomme juste
}
}
private function warmGet(string $path): void
{
$this->requestAndLog('GET', $path);
}
private function asyncGet(string $path): ResponseInterface
{
return $this->httpClient->request('GET', $this->varnishBase . $path, [
'timeout' => 30,
'headers' => $this->defaultHeaders(),
]);
}
private function requestAndLog(string $method, string $path): void
{
try {
$r = $this->httpClient->request($method, $this->varnishBase . $path, [
'timeout' => 30,
'headers' => $this->defaultHeaders(),
]);
$status = $r->getStatusCode();
if ($status >= 200 && $status < 300) {
$this->logger->debug('Warm OK', [
'url' => $path,
'status' => $status,
'x-cache' => $r->getHeaders(false)['x-cache'][0] ?? null,
'age' => $r->getHeaders(false)['age'][0] ?? null,
]);
} else {
$this->logger->warning('Warm non-200', ['url' => $path, 'status' => $status]);
}
} catch (\Throwable $e) {
$this->logger->warning('Warm failed', ['url' => $path, 'error' => $e->getMessage()]);
}
}
private function defaultHeaders(): array
{
return [
'User-Agent' => 'Varnish-Cache-Warmup/1.1',
'X-Cache-Warmup' => '1',
// 'Host' => 'example.local', // si Varnish fait du vhost
];
}
private function getVideoUrl(Videos $video, string $quality): ?string
{
if ($quality === 'original') {
$originalPath = $video->getOriginalPath();
return $originalPath ? '/videos/' . $video->getId() . '/original.' . pathinfo($originalPath, PATHINFO_EXTENSION) : null;
}
$res = $video->getResolutions() ?? [];
return isset($res[$quality]) ? '/videos/' . $video->getId() . '/' . $quality . '.mp4' : null;
}
private function getHlsUrl(Videos $video, string $which): ?string
{
if ($which === 'master') {
$res = $video->getResolutions() ?? [];
return isset($res['hls_master']) ? '/videos/' . $video->getId() . '/master.m3u8' : null;
}
return '/videos/' . $video->getId() . '/hls_' . $which . '/playlist.m3u8';
}
/** Déduit les qualités HLS disponibles à partir de l’entité */
private function detectHlsQualities(Videos $video): array
{
$res = $video->getResolutions() ?? [];
$out = [];
foreach (array_keys($res) as $k) {
if (preg_match('/^hls_(\d+p)$/', $k, $m)) {
$out[] = $m[1];
}
}
// fallback standard si rien trouvé
return $out ?: ['480p','720p','1080p'];
}
}
Explication du service VarnishCacheWarmupService
Ce service Symfony est chargé de pré-chauffer le cache Varnish dès qu’une vidéo est prête.
Fonctionnement étape par étape
- Vérification du statut : le warmup ne démarre que si la vidéo est marquée comme
completed
. - Warmup de la page vidéo (
/video/{id}
) pour accélérer l’affichage de la page show. - Warmup des fichiers MP4 générés dans les différentes résolutions + la version originale.
- Warmup HLS :
- Master playlist (
master.m3u8
) - Playlists par qualité (
playlist.m3u8
) - Les n premiers segments par qualité (par défaut 12, soit ≈ 72s de vidéo).
- Optionnel : les clés AES si l’option
$warmKeys
est activée.
- Master playlist (
- Parallélisation : les playlists et segments HLS sont préchargés en parallèle via l’
HttpClient
Symfony. - Logs : chaque requête est tracée (
Warm OK
,Warm failed
) avec le statut et les headersX-Cache
/Age
pour vérifier que Varnish répond bien.
Pourquoi c’est utile
- Le premier utilisateur n’attend pas : les playlists et segments sont déjà en RAM côté Varnish.
- La latence initiale est quasi supprimée (TTFB < 1 ms sur HIT).
- La charge sur FrankenPHP est réduite, puisque Varnish sert directement les fichiers préchauffés.
curl -I http://localhost/videos/118/hls_720p/segment_000.ts
HTTP/1.1 200 OK
Content-Type: text/vnd.trolltech.linguist; charset=utf-8
Last-Modified: Sun, 07 Sep 2025 13:59:28 GMT
Vary: Accept-Encoding
Date: Sun, 07 Sep 2025 15:01:06 GMT
Cache-Control: public, max-age=604800, immutable
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, HEAD
X-Varnish: 65545 32776
Age: 130
ETag: W/"dcmmegu4jyveohu8-gzip"
Accept-Ranges: bytes
X-Cache: HIT
X-Cache-Hits: 2
X-Cache-Age: 130
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Content-Length: 1142864
Connection: keep-alive
Conclusion :
On partait de rien, et on a construit un POC complet :
- des workers PHP orchestrés avec RabbitMQ,
- FFmpeg pour l’encodage MP4 et HLS,
- un caching layer avec Varnish pour booster la vitesse,
- le tout branché à FrankenPHP et un back-office EasyAdmin pour piloter le système.
👉 Le chemin parcouru est énorme : de la simple mise en ligne d’une vidéo à une architecture où la distribution est optimisée, segmentée et pré-cachée.
Mais il faut rester lucide :
- ce projet est production beta.
- il ouvre des concepts et des usages proches de la prod, mais certains points restent à explorer :
- la charge réelle à tester avec k6,
- la gestion avancée des sous-titres,
- et bien sûr la sécurité/DRM pour aller au bout d’une stack prête à l’emploi.
En clair : le flux est clair, la preuve de concept est là, mais il reste encore un peu de chemin avant une mise en prod stable.
Laisser un commentaire