Loading...

Stockage · 11 min de lecture

Une application de chat avec un stockage objet S3 : ce que fait S3, ce qu'il ne fait pas, et comment tout relier

Oui, on peut construire une application de chat sur un stockage objet de type S3, mais pas avec S3 seul. S3 est la couche des fichiers et des archives : photos, messages vocaux, documents et anciens historiques. Livrer les messages en temps réel demande un petit serveur d'application avec WebSocket, et les messages eux-mêmes ont leur place dans une base de données. Ce guide explique pourquoi, puis relie les trois éléments sur CDNTR avec le code d'une démo réelle.

Mis à jour

Une application de chat avec un stockage objet S3 : ce que fait S3, ce qu'il ne fait pas, et comment tout relier

Vidéo en anglais, sous-titrée en français.

La réponse courte : oui, mais S3 n'est qu'une couche sur trois

Une application de chat fait trois choses différentes. Elle livre chaque nouveau message à tous les membres du salon au moment où il part. Elle se souvient des messages : celui qui ouvre le salon voit les dernières dizaines, et l'application sait répondre à « qu'est-ce que je n'ai pas lu ». Et elle stocke des fichiers : photos, courtes vidéos, messages vocaux et documents déposés dans la conversation.

Le stockage objet de type S3 excelle dans la troisième tâche et n'est pas conçu pour les deux premières. La réponse honnête à « puis-je faire un chat sur S3 ? » est donc oui, avec S3 comme couche de fichiers et d'archives, à côté de deux autres éléments :

Un serveur d'application qui garde une connexion WebSocket ouverte avec chaque client et diffuse les messages. Il est petit : quelques centaines de lignes de Node.js avec Socket.io suffisent pour un chat qui fonctionne.

Une base de données qui stocke les messages sous forme de lignes, pour que « les 50 derniers de ce salon » soit une seule requête indexée.

Un stockage objet pour tout ce qui est un fichier. Il allège la base de données et le serveur, puisque ni l'un ni l'autre n'a à conserver ou à transporter des mégaoctets de médias.

La suite du guide montre où passe la frontière entre eux, puis relie les trois sur CDNTR. L'application de démonstration de ce guide tourne en production sur CDNTR (Node.js, Express et Socket.io) : l'application, Postgres, la liaison du bucket ainsi que l'upload et le téléchargement présignés y ont été vérifiés le 2026-11-06. Les parties présentées comme des options, l'adaptateur Redis, la tâche d'archivage et les serveurs existants, ne faisaient pas partie de la démo.

Ce que S3 seul ne peut pas faire pour un chat

Pas de push. S3 répond aux requêtes, il n'en lance jamais. Quand un nouveau message arrive, rien ne prévient les autres clients. Sans serveur à vous, ils devraient interroger S3 en boucle (polling) : lister les objets du salon toutes les une ou deux secondes, depuis chaque onglet et chaque téléphone ouverts. C'est lent à l'usage, coûteux en requêtes, et toujours pas du temps réel.

Pas d'ajout (append). Un objet s'écrit d'un seul bloc. Il n'existe pas d'opération « ajoute une ligne à la fin de ce fichier ». Chaque message devient donc soit son propre objet (des milliers de minuscules objets par salon et par jour), soit la réécriture d'un objet plus gros, avec deux expéditeurs qui se disputent l'écrasement de la version de l'autre.

Pas de requêtes. S3 sait lister des clés par préfixe, et c'est tout. « Les 50 derniers messages de ce salon », « ce qui est arrivé depuis ma dernière visite », « nombre de non-lus », « chercher facture » : ce sont des questions de base de données. Des clés triées par date vous mènent une partie du chemin, puis s'arrêtent.

Pas d'utilisateurs ni de droits. S3 vérifie une seule chose : la requête est-elle signée par une clé valide. Qui est dans quel salon, qui peut voir quel fichier, qui est bloqué : cette logique appartient à votre application. Donner les clés S3 au navigateur pour qu'il lise et écrive directement ouvrirait tout le bucket à chaque utilisateur.

Rien de tout cela n'est un défaut de S3. C'est un magasin de fichiers, et un chat est une conversation en temps réel, avec un état. Chaque outil à sa place.

Là où S3 excelle dans une application de chat

Les pièces jointes. Photos, vidéos, messages vocaux, PDF. Elles sont volumineuses, écrites une fois et lues souvent : exactement ce pour quoi le stockage objet est fait. La base ne garde qu'une clé et un nom de fichier par message ; elle reste petite et rapide, quelle que soit la quantité de médias partagés.

L'archive des messages. Les vieilles conversations sont rarement relues, mais ne doivent pas disparaître. Les sortir de la base pour les ranger dans S3 en blocs compressés (un par salon et par jour, par exemple) garde la table active compacte sans rien perdre de l'historique.

Exports et sauvegardes. « Télécharger mes données », export d'un salon, dumps nocturnes de la base : chacun est un fichier, et chacun a sa place dans un bucket.

Un serveur d'application plus léger. Comme le navigateur envoie directement à S3 et télécharge directement depuis S3 (les sections suivantes montrent comment), le serveur ne transporte jamais de médias. Il signe des URL de courte durée et fait circuler de petits messages JSON ; un seul petit conteneur va donc loin.

Sur CDNTR, le service S3 de s3.cdn.com.tr stocke les objets dans nos centres de données en Turquie, et il est compatible S3 : les SDK AWS fonctionnent avec un endpoint personnalisé et l'adressage path-style.

L'architecture sur CDNTR, montée avec cdnctl

L'application tourne sur Managed Container Apps. La démo est en Node.js avec Express et Socket.io, déployée depuis son dossier source avec cdnctl deploy : l'image est construite dans le cluster, aucun registry n'est nécessaire. L'application reçoit une adresse du type https://xxxx.cdn.com.tr, et son trafic WebSocket passe par le CDN (dans la démo, Socket.io a indiqué websocket comme transport). Si les conteneurs sont nouveaux pour vous, déployer un conteneur sur le CDN couvre les bases.

La base de données est l'extension Postgres gérée. Activée avec --env-prefix DATABASE, elle fournit à l'application DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER et le secret DATABASE_PASSWORD.

Les fichiers vivent dans un bucket du stockage objet. Vous créez le bucket et une clé d'accès pour lui, puis vous liez les deux à l'application avec --env-prefix S3. L'application reçoit S3_ENDPOINT (https://s3.cdn.com.tr), S3_BUCKET, S3_REGION (cdn-tr), S3_ACCESS_KEY_ID et le secret S3_SECRET_ACCESS_KEY. Les clés n'existent que dans l'environnement de l'application, jamais dans le navigateur.

L'ensemble tient en quatre étapes. Remplacez <account>, <app>, <bucket> et <key> par les identifiants qu'affichent les commandes précédentes.

Application, Postgres, bucket et liaison avec cdnctl
# 1. deploy the app from source (built in the cluster, no registry)
cdnctl deploy --dir . --account <account> --name chat-demo --healthcheck /health

# 2. managed Postgres -> DATABASE_HOST, _PORT, _NAME, _USER, _PASSWORD
cdnctl container addons enable-postgres --account <account> --app <app> --env-prefix DATABASE

# 3. a bucket and an access key for it
cdnctl object-storage buckets create --account <account> --name chat-demo-files
cdnctl object-storage access-keys create --account <account> --bucket <bucket>

# 4. bind them -> S3_ENDPOINT, S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY
cdnctl object-storage bindings create --account <account> --app <app> \
  --bucket <bucket> --access-key <key> --env-prefix S3

Les messages : une table Postgres et un handler Socket.io

Un message est une ligne : salon, auteur, texte et, pour les pièces jointes, la clé de l'objet et le nom d'origine du fichier. Un index sur (room, id) transforme la requête qu'un chat exécute sans arrêt, les 50 derniers messages d'un salon, en un court parcours d'index, quelle que soit la taille de la table.

Côté Socket.io, deux événements suffisent. Avec join, le client entre dans un salon et reçoit son historique. Avec message, le serveur écrit d'abord la ligne, puis diffuse la ligne enregistrée à tout le salon, expéditeur compris. Diffuser ce que la base a renvoyé, et non ce que le client a envoyé, donne à tous le même id et le même horodatage ; et un message qui n'a pas été enregistré n'arrive chez personne.

socket.data.user est renseigné par votre propre middleware d'authentification à l'ouverture de la connexion. Le client ne déclare jamais lui-même qui il est dans un message.

La démo tourne avec un seul réplica. Avec plusieurs, un message doit aussi atteindre les clients connectés aux autres ; la voie habituelle est l'extension Redis gérée (cdnctl container addons enable-redis) avec l'adaptateur Redis de Socket.io. Pour Redis lui-même, voir qu'est-ce que Redis.

Schéma et handler des messages (Postgres, socket.io 4)

-- messages table
CREATE TABLE IF NOT EXISTS messages (
  id         bigserial PRIMARY KEY,
  room       text NOT NULL,
  author     text NOT NULL,
  body       text,
  file_key   text,
  file_name  text,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS messages_room_id ON messages (room, id);

// server.js (socket.io 4)
io.on('connection', (socket) => {
  socket.on('join', async (room) => {
    socket.join(room);
    const { rows } = await db.query(
      `SELECT * FROM (SELECT * FROM messages WHERE room = $1
         ORDER BY id DESC LIMIT 50) last50 ORDER BY id`, [room]);
    socket.emit('history', rows);
  });

  socket.on('message', async ({ room, body, fileKey, fileName }) => {
    const { rows } = await db.query(
      `INSERT INTO messages (room, author, body, file_key, file_name)
       VALUES ($1, $2, $3, $4, $5) RETURNING *`,
      [room, socket.data.user, body ?? null, fileKey ?? null, fileName ?? null]);
    io.to(room).emit('message', rows[0]);   // everyone in the room, at once
  });
});

L'upload : des URL présignées, du navigateur directement vers S3

Faire passer les fichiers par le serveur d'application en ferait le goulot d'étranglement : chaque photo serait envoyée deux fois et occuperait mémoire et bande passante au passage. À la place, l'application accorde la permission et s'efface.

1. Le navigateur demande une URL d'upload à l'application, en envoyant le type et la taille du fichier. L'application vérifie l'utilisateur, le salon et la taille (la démo accepte jusqu'à 10 Mo), choisit une clé comme rooms/<room>/<uuid> et renvoie une URL de PUT présignée de courte durée, valable 5 minutes. 2. Le navigateur fait un PUT du fichier directement vers S3 avec cette URL. 3. Le navigateur envoie le message du chat avec la clé de l'objet ; le serveur l'enregistre comme n'importe quel autre message. 4. Pour ouvrir un fichier, le destinataire appelle l'application, qui vérifie l'accès et redirige vers une URL de GET présignée, valable elle aussi 5 minutes.

Une URL présignée est une requête S3 ordinaire dont la signature est dans la query string : qui la détient peut faire exactement cette opération, sur cette clé, jusqu'à expiration. Le guide des URL signées détaille l'idée. L'application vérifie la taille déclarée avant de signer ; gardez la limite, la durée et le format des clés côté serveur, là où les utilisateurs ne peuvent pas les modifier.

PUT présigné avec Content-Type signé, et la redirection de téléchargement

import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'node:crypto';

const s3 = new S3Client({
  endpoint: process.env.S3_ENDPOINT,   // https://s3.cdn.com.tr
  region: process.env.S3_REGION,       // cdn-tr
  forcePathStyle: true,
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY_ID,
    secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
  },
});
const MAX_BYTES = 10 * 1024 * 1024;

app.post('/api/upload-url', express.json(), async (req, res) => {
  const { room, type, size } = req.body;
  if (!type || !(size > 0) || size > MAX_BYTES) {
    return res.status(400).json({ error: 'max 10 MB' });
  }
  const key = `rooms/${room}/${randomUUID()}`;
  const url = await getSignedUrl(s3,
    new PutObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key, ContentType: type }),
    { expiresIn: 300, signableHeaders: new Set(['content-type']) });
  res.json({ url, key });
});

app.get('/api/files', async (req, res) => {
  // check here that the user may read this room
  const url = await getSignedUrl(s3,
    new GetObjectCommand({ Bucket: process.env.S3_BUCKET, Key: req.query.key }),
    { expiresIn: 300 });
  res.redirect(302, url);
});

Le piège du Content-Type : 403 AccessDenied sur un PUT présigné

Ce point coûte souvent un après-midi, d'où sa section à part. Notre S3 repose sur Ceph RGW, et RGW est plus strict qu'AWS sur un en-tête : si le navigateur envoie un Content-Type que la signature ne couvre pas, le PUT présigné est refusé avec 403 AccessDenied. AWS ignore cet en-tête quand il n'est pas signé ; un code qui marche là-bas peut donc échouer ici avec une erreur qui ressemble à un problème de clés.

La solution : signer le type dans l'URL, puis envoyer exactement ce type. Avec AWS SDK v3, passez ContentType dans le PutObjectCommand et ajoutez signableHeaders: new Set(['content-type']) à getSignedUrl, comme dans l'extrait ci-dessus. Dans le navigateur, mettez le même en-tête Content-Type sur le PUT.

Deux détails rendent le tout fiable. Utilisez la même valeur de repli des deux côtés : un fichier sans type (file.type est une chaîne vide) doit être signé et envoyé avec la même valeur, par exemple application/octet-stream. Et ne laissez aucune bibliothèque réécrire l'en-tête : certains utilitaires d'upload ajoutent ; charset=utf-8 aux fichiers texte, ce qui ne correspond plus à la signature.

Côté navigateur : le type exact que le serveur a signé

// browser: ask for a URL, PUT the file to S3, then send the message
const { url, key } = await fetch('/api/upload-url', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ room, type: file.type || 'application/octet-stream', size: file.size }),
}).then((r) => r.json());

const put = await fetch(url, {
  method: 'PUT',
  headers: { 'Content-Type': file.type || 'application/octet-stream' },  // exactly the signed type
  body: file,
});
if (!put.ok) throw new Error(`upload failed: ${put.status}`);

socket.emit('message', { room, fileKey: key, fileName: file.name });

CORS sur le bucket

La page tourne sur l'adresse de votre application et l'upload part vers s3.cdn.com.tr, une autre origine. Le navigateur envoie donc un preflight avant le PUT et ne continue que si le bucket répond avec une règle CORS qui autorise l'origine de votre application, la méthode PUT et l'en-tête Content-Type. Sans elle, l'upload échoue dans le navigateur alors que la même requête passe avec curl : le signe classique d'une règle CORS manquante.

Les règles CORS d'un bucket se définissent par l'API S3 avec PutBucketCors. La démo le fait une fois au démarrage, ce qui garde la règle à côté du code qui en dépend. Autorisez votre propre origine, pas * : la règle fait partie de ce qui empêche d'autres sites d'utiliser votre bucket depuis leurs pages. Qu'est-ce que CORS détaille les preflights et les messages d'erreur courants.

Une règle CORS de bucket pour l'upload et le téléchargement directs

import { PutBucketCorsCommand } from '@aws-sdk/client-s3';

// once at startup: let the app's own origin PUT and GET directly
await s3.send(new PutBucketCorsCommand({
  Bucket: process.env.S3_BUCKET,
  CORSConfiguration: {
    CORSRules: [{
      AllowedOrigins: ['https://xxxx.cdn.com.tr'],   // your app's address
      AllowedMethods: ['PUT', 'GET'],
      AllowedHeaders: ['content-type'],
      MaxAgeSeconds: 3600,
    }],
  },
}));

WebSocket à travers le CDN : délais et reconnexions

Le CDN transporte WebSocket au niveau de chaque règle de diffusion. Sur un domaine Managed Container Apps, WebSocket est activé par défaut, avec un délai d'inactivité de 300 secondes sauf si la règle de diffusion fixe une autre valeur ; une règle peut fixer n'importe quelle valeur entre 1 et 3 600 secondes. Ce délai compte le silence, pas la durée totale ; une connexion qui échange des données reste ouverte. Socket.io envoie son propre heartbeat toutes les 25 secondes par défaut, ce qui garde un salon calme largement sous la valeur par défaut. Les réglages sont décrits dans le guide de connexion à l'origine.

Les clients doivent se reconnecter d'eux-mêmes. Les connexions WebSocket ouvertes peuvent être fermées quand la configuration de l'edge est rechargée, et les téléphones perdent la connexion à chaque changement de réseau. Socket.io se reconnecte tout seul ; avec du WebSocket brut, ajoutez une reconnexion avec backoff.

Après une reconnexion, le client doit récupérer ce qu'il a manqué. Le plus simple est de renvoyer join : le serveur répond avec l'historique récent du salon et le client fusionne par id de message, pour que rien n'apparaisse deux fois.

Archiver les anciens messages, ou utiliser un serveur de chat existant

L'archivage. La démo garde tous les messages dans Postgres, ce qui convient longtemps. Quand la table grossit, un schéma courant est une tâche périodique qui prend les messages de plus de 90 jours, par exemple, les écrit dans S3 en un bloc JSON par salon et par jour (archive/<room>/2026-08-01.json.gz), puis supprime ces lignes. « Charger les messages plus anciens » lit le bon bloc dans S3 via un GET présigné. La base garde ce que les gens font vraiment défiler ; S3 garde le reste.

Les serveurs existants. Si vous préférez ne pas écrire votre propre chat, Mattermost, Rocket.Chat et Matrix Synapse savent stocker les fichiers envoyés sur un stockage compatible S3. Les trois mêmes couches s'appliquent : leur propre processus serveur, leur propre base de données et un bucket pour les fichiers. Sur CDNTR, cela correspond au conteneur, à l'extension de base de données et au bucket de ce guide, configurés avec l'endpoint S3, le bucket et les clés que demande la documentation de chaque projet.

Check-list : les erreurs qui cassent l'upload dans un chat

Règle CORS sur le bucket. L'origine de votre application, PUT et GET, l'en-tête Content-Type. Si elle manque, on ne voit qu'un upload en échec dans le navigateur.

Content-Type signé. Signez-le dans l'URL de PUT et envoyez exactement cette valeur, avec le même repli pour les fichiers sans type. Sinon : 403 AccessDenied.

Expiration courte. Des minutes, pas des jours. La démo utilise 5 minutes pour le PUT comme pour le GET ; une URL qui fuite devient inutile presque aussitôt.

Les fichiers ne passent pas par l'application. Upload et téléchargement se font directement entre le navigateur et S3. L'application signe et redirige ; elle ne transporte rien.

Reconnexion automatique. Attendez-vous à ce que les connexions WebSocket se ferment et se rouvrent. Rejoignez le salon et fusionnez l'historique par id de message.

Limites de taille. Vérifiez la taille déclarée avant de signer et refusez ce qui dépasse votre limite (10 Mo dans la démo).

Les clés restent sur le serveur. Les clés d'accès S3 vivent dans l'environnement de l'application, fournies par la liaison. Ne les envoyez jamais au navigateur, ne les intégrez jamais au code client ; le navigateur ne voit que des URL présignées.

Contrôle d'accès au téléchargement. Faites passer les liens de fichiers par l'application, pour qu'elle confirme que l'utilisateur peut voir ce salon avant de rediriger.

Comment le mettre en place

Guides pas à pas dans le centre d'aide :

Questions fréquentes sur un chat avec S3

Peut-on construire une application de chat uniquement avec S3 ?

Pas une application en temps réel. S3 ne peut pas pousser de nouveaux messages, ni ajouter des données à un objet, ni les interroger : les clients devraient faire du polling et vous reconstruiriez une base de données au-dessus de listes de clés. Utilisez S3 pour les fichiers et les archives, une base de données pour les messages et un petit serveur WebSocket pour la livraison.

Pourquoi ne pas laisser les clients interroger S3 pour les nouveaux messages ?

Chaque client ouvert listerait des objets toutes les une ou deux secondes : c'est lent à l'usage, cela multiplie les requêtes et ne répond toujours pas à « les 50 derniers » ni à « non lus ». Une connexion WebSocket livre le message à tout le salon dès que le serveur l'enregistre.

Les fichiers envoyés passent-ils par mon serveur d'application ?

Non. L'application renvoie une URL de PUT présignée, valable 5 minutes dans la démo, et le navigateur envoie directement à S3. Le téléchargement fonctionne de la même manière : l'application vérifie l'accès et redirige vers une URL de GET présignée.

Pourquoi mon PUT présigné renvoie-t-il 403 AccessDenied ?

Sur notre S3, la cause la plus fréquente est un en-tête Content-Type que la signature ne couvre pas. Signez-le dans l'URL (avec AWS SDK v3 : ContentType dans PutObjectCommand et signableHeaders: new Set(['content-type'])) et envoyez exactement ce type depuis le navigateur. Si le navigateur affiche plutôt une erreur CORS, le bucket a besoin d'une règle CORS.

WebSocket fonctionne-t-il à travers le CDN ?

Oui. Sur un domaine Managed Container Apps, WebSocket est activé par défaut, avec un délai d'inactivité de 300 secondes sauf si la règle de diffusion fixe une autre valeur (une règle peut fixer de 1 à 3 600 secondes). Les clients doivent se reconnecter automatiquement, car les connexions ouvertes peuvent être fermées lors d'un rechargement de la configuration de l'edge ; Socket.io le fait tout seul.

Faut-il Redis pour un chat Socket.io ?

Pas avec un seul réplica ; la démo en a un. Avec plusieurs, un message doit atteindre les clients connectés aux autres, et la voie habituelle est l'extension Redis gérée avec l'adaptateur Redis de Socket.io.