Loading...

Хранилище · 11 мин чтения

Чат-приложение на объектном хранилище S3: что S3 делает, чего не делает и как всё связать

Да, чат можно построить на объектном хранилище типа S3, но не на одном S3. S3 — это слой файлов и архива: фотографии, голосовые сообщения, документы и старая история переписки. Чтобы доставлять сообщения в реальном времени, нужен небольшой сервер приложения с WebSocket, а сами сообщения должны жить в базе данных. В этом руководстве объясняется почему, а затем три части связываются на CDNTR рабочим кодом из живой демонстрации.

Обновлено

Чат-приложение на объектном хранилище S3: что S3 делает, чего не делает и как всё связать

Видео на английском с русскими субтитрами.

Коротко: да, но S3 — лишь один из трёх слоёв

Чат выполняет три разные задачи. Он доставляет каждое новое сообщение всем участникам комнаты в момент отправки. Он помнит сообщения: открывший комнату видит последние несколько десятков, а приложение может ответить на вопрос «что у меня непрочитано». И он хранит файлы: фотографии, короткие видео, голосовые сообщения и документы, которые участники присылают в разговор.

Объектное хранилище типа S3 отлично справляется с третьей задачей и не рассчитано на первые две. Поэтому честный ответ на вопрос «можно ли сделать чат на S3?» — да, если S3 будет слоем файлов и архива рядом с двумя другими частями.

Сервер приложения держит открытое WebSocket-соединение с каждым клиентом и рассылает сообщения. Он небольшой: для рабочего чата хватает нескольких сотен строк Node.js с Socket.io.

База данных хранит сообщения строками, и «последние 50 в этой комнате» превращаются в один запрос по индексу.

Объектное хранилище — для всего, что является файлом. Оно разгружает и базу, и сервер: ни тому, ни другому не нужно хранить и перекачивать мегабайты медиа.

Дальше в руководстве разбирается, где проходит граница между слоями, а затем все три связываются на CDNTR. Демо-приложение из этого руководства работает на CDNTR вживую (Node.js, Express и Socket.io): приложение, Postgres, привязка бакета, а также presigned-загрузка и скачивание проверены на нём 2026-11-06. Части, отмеченные как варианты, — Redis-адаптер, архивирование и готовые серверы — в демо не входили.

Чего один S3 не может дать чату

Нет push. S3 отвечает на запросы, но сам их никогда не инициирует. Когда приходит новое сообщение, ничто не сообщает об этом остальным клиентам. Без собственного сервера им пришлось бы опрашивать хранилище (polling): каждые одну-две секунды получать список объектов комнаты из каждой открытой вкладки и с каждого телефона. Это ощутимо медленно, расточительно по запросам и всё равно не реальное время.

Нет дописывания (append). Объект записывается целиком; операции «добавь строку в конец файла» не существует. Поэтому каждое сообщение становится либо отдельным объектом (тысячи крошечных объектов на комнату в день), либо перезаписью большого объекта, где два отправителя наперегонки затирают версии друг друга.

Нет запросов. S3 умеет перечислять ключи по префиксу, и только. «Последние 50 сообщений в комнате», «что пришло после моего последнего визита», «число непрочитанных», «найти слово счёт» — это вопросы к базе данных. Ключи, отсортированные по времени, проведут вас часть пути и остановятся.

Нет пользователей и прав. S3 проверяет одно: подписан ли запрос действующим ключом. Кто в какой комнате, кому какой файл можно видеть, кто заблокирован — эта логика принадлежит приложению. Отдать ключи S3 браузеру, чтобы он читал и писал напрямую, значит дать каждому пользователю доступ ко всему бакету.

Всё это не недостатки S3. Это файловое хранилище, а чат — разговор в реальном времени с состоянием. Каждому инструменту — своя задача.

В чём S3 силён внутри чата

Вложения. Фотографии, видео, голосовые сообщения, PDF. Они большие, записываются один раз и читаются много раз — ровно та нагрузка, для которой создано объектное хранилище. База хранит на каждое сообщение только ключ и имя файла, поэтому остаётся маленькой и быстрой, сколько бы медиа ни присылали.

Архив сообщений. Старые переписки читают редко, но терять их нельзя. Если выносить их из базы в S3 сжатыми блоками (например, один блок на комнату в день), рабочая таблица остаётся компактной, а история не теряется.

Экспорт и резервные копии. «Скачать мои данные», выгрузка комнаты, ночные дампы базы — каждый из них файл, и место каждого — в бакете.

Более лёгкий сервер приложения. Поскольку браузер загружает файлы прямо в S3 и скачивает прямо оттуда (как именно — в следующих разделах), сервер никогда не пропускает через себя медиа. Он подписывает короткоживущие ссылки и передаёт маленькие JSON-сообщения, так что одного небольшого контейнера хватает надолго.

На CDNTR сервис S3 по адресу s3.cdn.com.tr хранит объекты в наших дата-центрах в Турции и совместим с S3: AWS SDK работают с ним через собственный endpoint и адресацию path-style.

Архитектура на CDNTR и настройка через cdnctl

Приложение работает на Managed Container Apps. Демо — Node.js с Express и Socket.io, развёрнутое из папки с исходниками командой cdnctl deploy: образ собирается внутри кластера, registry не нужен. Приложение получает адрес вида https://xxxx.cdn.com.tr, а его WebSocket-трафик идёт через CDN (в демо Socket.io сообщил транспорт websocket). Если контейнеры для вас в новинку, основы описаны в руководстве Docker-контейнер на CDN.

База данных — управляемое дополнение Postgres. Включённое с --env-prefix DATABASE, оно передаёт приложению DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER и секрет DATABASE_PASSWORD.

Файлы лежат в бакете объектного хранилища. Вы создаёте бакет и ключ доступа к нему, а затем привязываете оба к приложению с --env-prefix S3. Приложение получает S3_ENDPOINT (https://s3.cdn.com.tr), S3_BUCKET, S3_REGION (cdn-tr), S3_ACCESS_KEY_ID и секрет S3_SECRET_ACCESS_KEY. Ключи существуют только в окружении приложения и никогда не попадают в браузер.

Вся настройка — четыре шага. Подставьте вместо <account>, <app>, <bucket> и <key> идентификаторы, которые выводят предыдущие команды.

Приложение, Postgres, бакет и привязка через 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

Сообщения: таблица в Postgres и обработчик Socket.io

Сообщение — это строка: комната, автор, текст, а для вложений — ключ объекта и исходное имя файла. Индекс по (room, id) превращает запрос, который чат выполняет постоянно, — последние 50 сообщений комнаты — в короткий проход по индексу, как бы ни росла таблица.

На стороне Socket.io всего два события. По join клиент входит в комнату и получает её историю. По message сервер сначала записывает строку, а затем рассылает сохранённую строку всей комнате, включая отправителя. Если рассылать то, что вернула база, а не то, что прислал клиент, у всех будет один и тот же id и одна и та же метка времени, а несохранённое сообщение не дойдёт ни до кого.

socket.data.user заполняет ваш собственный middleware аутентификации при открытии соединения. Клиент никогда сам не указывает в сообщении, кто он.

Демо работает в одной реплике. Если реплик несколько, сообщение должно дойти и до клиентов, подключённых к другим; обычный способ — управляемое дополнение Redis (cdnctl container addons enable-redis) с Redis-адаптером Socket.io. О самом Redis — в руководстве что такое Redis.

Схема и обработчик сообщений (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
  });
});

Загрузки: presigned-ссылки, из браузера прямо в S3

Если пропускать файлы через сервер приложения, он станет узким местом: каждая фотография будет загружаться дважды и по пути занимать память и канал. Вместо этого приложение выдаёт разрешение и уходит с дороги.

1. Браузер запрашивает у приложения ссылку для загрузки, передавая тип и размер файла. Приложение проверяет пользователя, комнату и размер (демо допускает до 10 МБ), выбирает ключ вроде rooms/<room>/<uuid> и возвращает короткоживущую presigned-ссылку для PUT, действующую 5 минут. 2. Браузер отправляет файл методом PUT прямо в S3 по этой ссылке. 3. Браузер отправляет сообщение чата с ключом объекта; сервер сохраняет его как любое другое сообщение. 4. Чтобы открыть файл, получатель обращается к приложению, которое проверяет доступ и перенаправляет на presigned-ссылку для GET, тоже действующую 5 минут.

Presigned-ссылка — обычный запрос к S3, подпись которого лежит в строке запроса: её владелец может выполнить ровно эту операцию над этим ключом, пока ссылка не истечёт. Подробнее идея разобрана в руководстве о подписанных ссылках. Приложение проверяет заявленный размер до подписи; держите лимит, срок действия и формат ключа на сервере, где пользователи не могут их изменить.

Presigned PUT с подписанным Content-Type и редирект на скачивание

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);
});

Ловушка Content-Type: 403 AccessDenied на presigned PUT

На этом люди теряют по полдня, поэтому у ловушки свой раздел. Наш S3 работает на Ceph RGW, а RGW строже AWS в отношении одного заголовка: если браузер присылает Content-Type, не покрытый подписью, presigned PUT отклоняется с 403 AccessDenied. AWS неподписанный заголовок игнорирует, поэтому код, работающий там, здесь может упасть с ошибкой, похожей на проблему с ключами.

Решение — включить тип в подпись ссылки и затем отправлять ровно этот тип. В AWS SDK v3 передайте ContentType в PutObjectCommand и добавьте signableHeaders: new Set(['content-type']) в getSignedUrl, как в примере выше. В браузере поставьте тот же заголовок Content-Type на запрос PUT.

Надёжность обеспечивают две детали. Используйте одно и то же значение по умолчанию с обеих сторон: файл без типа (file.type — пустая строка) должен подписываться и отправляться с одинаковым значением, например application/octet-stream. И не позволяйте библиотеке переписывать заголовок: некоторые помощники загрузки добавляют ; charset=utf-8 к текстовым файлам, и это уже не совпадает с подписью.

Сторона браузера: тот же тип, что подписал сервер

// 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 на бакете

Страница работает на адресе вашего приложения, а загрузка идёт на s3.cdn.com.tr — это другой origin. Поэтому перед PUT браузер отправляет preflight и продолжает, только если бакет отвечает правилом CORS, разрешающим origin вашего приложения, метод PUT и заголовок Content-Type. Без него загрузка падает в браузере, а тот же запрос через curl проходит — классический признак отсутствующего правила CORS.

Правила CORS бакета задаются через S3 API вызовом PutBucketCors. Демо делает это один раз при запуске, и правило хранится рядом с кодом, который от него зависит. Разрешайте свой origin, а не *: правило — часть того, что мешает чужим сайтам использовать ваш бакет со своих страниц. Preflight-запросы и типичные сообщения об ошибках подробно разобраны в руководстве что такое CORS.

Правило CORS бакета для прямых загрузок и скачиваний

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 через CDN: таймауты и переподключение

CDN передаёт WebSocket в рамках правила доставки. На домене Managed Container Apps WebSocket включён по умолчанию, а таймаут простоя составляет 300 секунд, если правило доставки не задаёт другое значение; правило может задать любое значение от 1 до 3600 секунд. Таймаут считает тишину, а не общую длительность; соединение, по которому идут данные, остаётся открытым. Socket.io по умолчанию отправляет собственный heartbeat каждые 25 секунд, так что даже тихая комната с запасом укладывается в значение по умолчанию. Настройки описаны в руководстве по подключению к источнику.

Клиенты должны переподключаться сами. Открытые WebSocket-соединения могут закрываться при перезагрузке конфигурации edge, а телефоны теряют соединение при каждой смене сети. Socket.io переподключается сам; если вы используете голый WebSocket, добавьте переподключение с backoff.

После переподключения клиенту нужно получить пропущенное. Проще всего снова отправить join: сервер ответит недавней историей комнаты, а клиент объединит её по id сообщения, чтобы ничего не показалось дважды.

Архивирование старых сообщений или готовый чат-сервер

Архивирование. Демо хранит все сообщения в Postgres, и долгое время это нормально. Когда таблица разрастается, распространённый приём — периодическая задача, которая берёт сообщения старше, скажем, 90 дней, записывает их в S3 одним JSON-блоком на комнату в день (archive/<room>/2026-08-01.json.gz) и затем удаляет эти строки. «Загрузить более ранние сообщения» читает нужный блок из S3 через presigned GET. В базе остаётся то, что люди действительно листают; остальное хранит S3.

Готовые серверы. Если писать свой чат не хочется, Mattermost, Rocket.Chat и Matrix Synapse умеют хранить загруженные файлы в S3-совместимом хранилище. Те же три слоя остаются и там: собственный серверный процесс, собственная база данных и бакет для файлов. На CDNTR это контейнер, дополнение базы данных и бакет из этого руководства, настроенные на S3 endpoint, бакет и ключи, которые требует документация каждого проекта.

Чек-лист: ошибки, которые ломают загрузку файлов в чате

Правило CORS на бакете. Origin вашего приложения, PUT и GET, заголовок Content-Type. Если правила нет, это видно только как неудачная загрузка в браузере.

Подписанный Content-Type. Включите его в подпись ссылки для PUT и отправляйте ровно это значение, с тем же значением по умолчанию для файлов без типа. Иначе — 403 AccessDenied.

Короткий срок действия. Минуты, а не дни. Демо использует 5 минут и для PUT, и для GET; утёкшая ссылка почти сразу становится бесполезной.

Файлы не идут через приложение. Загрузка и скачивание происходят напрямую между браузером и S3. Приложение подписывает и перенаправляет, но не передаёт данные.

Автоматическое переподключение. Рассчитывайте на то, что WebSocket-соединения будут закрываться и открываться снова. Повторно входите в комнату и объединяйте историю по id сообщения.

Ограничения размера. Проверяйте заявленный размер до подписи и отклоняйте всё, что выше лимита (в демо — 10 МБ).

Ключи остаются на сервере. Ключи доступа S3 живут в окружении приложения благодаря привязке. Никогда не отправляйте их в браузер и не встраивайте в клиентский код; браузер видит только presigned-ссылки.

Проверка доступа при скачивании. Пропускайте ссылки на файлы через приложение, чтобы оно до редиректа убедилось, что пользователь может видеть эту комнату.

Как настроить

Пошаговые руководства в справочном центре:

Частые вопросы о чате на S3

Можно ли сделать чат только на S3?

Чат в реальном времени — нет. S3 не умеет доставлять новые сообщения, дописывать объекты и выполнять по ним запросы, поэтому клиентам пришлось бы опрашивать хранилище, а вам — строить базу данных поверх списков ключей. Используйте S3 для файлов и архива, базу данных для сообщений и небольшой WebSocket-сервер для доставки.

Почему бы клиентам просто не опрашивать S3 на новые сообщения?

Каждый открытый клиент получал бы список объектов каждые одну-две секунды: это ощутимо медленно, умножает число запросов и всё равно не отвечает на вопросы «последние 50» и «непрочитанные». WebSocket-соединение доставляет сообщение всей комнате сразу, как только сервер его сохранил.

Проходят ли загружаемые файлы через мой сервер приложения?

Нет. Приложение возвращает presigned-ссылку для PUT, в демо действующую 5 минут, и браузер загружает файл прямо в S3. Скачивание устроено так же: приложение проверяет доступ и перенаправляет на presigned-ссылку для GET.

Почему presigned PUT возвращает 403 AccessDenied?

В нашем S3 самая частая причина — заголовок Content-Type, не покрытый подписью. Включите его в подпись ссылки (в AWS SDK v3: ContentType в PutObjectCommand и signableHeaders: new Set(['content-type'])) и отправляйте из браузера ровно этот тип. Если же браузер показывает ошибку CORS, бакету нужно правило CORS.

Работает ли WebSocket через CDN?

Да. На домене Managed Container Apps WebSocket включён по умолчанию с таймаутом простоя 300 секунд, если правило доставки не задаёт другое значение (правило может задать от 1 до 3600 секунд). Клиенты должны переподключаться автоматически, потому что открытые соединения могут закрываться при перезагрузке конфигурации edge; Socket.io делает это сам.

Нужен ли Redis для чата на Socket.io?

С одной репликой — нет; демо работает в одной. Если реплик несколько, сообщение должно дойти до клиентов, подключённых к другим, и обычный способ — управляемое дополнение Redis с Redis-адаптером Socket.io.