Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Прямые загрузки в S3/GCS через предподписанные URL: быстрее пользователи, меньше нагрузка и расходы

Разработка и технологии15 января 2026 г.
Большие файлы тормозят бэкенд, раздувают счета и увеличивают риск инцидентов. Решение — отправлять загрузки напрямую в облачное хранилище по предподписанным ссылкам, а бэкенд использовать только для выдачи прав и финализации. Разберём архитектуру, безопасность, CORS, простую реализацию для файлов до 5 ГБ и многосоставные (resumable) загрузки для больших. В конце — чеклист внедрения и тонкости, о которых часто забывают.
Прямые загрузки в S3/GCS через предподписанные URL: быстрее пользователи, меньше нагрузка и расходы

Оглавление

  • Зачем прямые загрузки вместо проксирования через бэкенд
  • Архитектура решения: кто за что отвечает
  • Безопасность и политика доступа: как не открыть ведро всему миру
  • PUT vs POST: когда какой способ подписывать
  • Пример: простая загрузка (до 5 ГБ) по предподписанному PUT
    • Настройка CORS для ведра S3
    • Бэкенд: выдаём предподписанный URL (Node.js, AWS SDK v3)
    • Фронтенд: загрузка через fetch с прогресс‑баром
  • Большие файлы и возобновление: многосоставная (multipart) загрузка
    • Бэкенд: инициализация, подпись частей и завершение
    • Фронтенд: разбиение на части и параллельная отправка
  • Проверка файлов: размер, тип, антивирус, итоговая валидация
  • Нюансы и подводные камни: CORS, ETag, сроки жизни ссылок, отмена
  • Мониторинг и расходы: как оценить экономию и настроить наблюдаемость
  • Чеклист внедрения
  • Итог

Зачем прямые загрузки вместо проксирования через бэкенд

Классическая схема «клиент — бэкенд — S3/GCS» выглядит простой, но на практике у неё три проблемы:

  1. Дорогой трафик и узкое горлышко. Бэкенд гоняет каждый мегабайт дважды: из интернета в сервер и из сервера в хранилище. На пике это забивает сеть и CPU, растут счета, таймауты и жалобы пользователей.
  2. Долгие ответы API. Пока файл летит через сервер, HTTP‑соединение висит, а пул воркеров занят.
  3. Надёжность. Любая временная просадка между сервером и хранилищем равна ошибке пользователя. Повтор — риск дублирования и «битых» загрузок.

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

Архитектура решения: кто за что отвечает

Высокоуровневая схема:

  1. Клиент запрашивает у бэкенда разрешение на загрузку (файл: имя, размер, тип). Бэкенд проверяет квоты и бизнес‑правила.
  2. Бэкенд генерирует предподписанную ссылку (URL) или форму (POST‑политику) для S3/GCS с коротким сроком жизни и ограничениями (тип, размер, префикс ключа).
  3. Клиент отправляет файл напрямую в хранилище.
  4. После загрузки клиент дергает «финализацию» на бэкенде, передаёт ключ, хэш, размер и ETag. Бэкенд убеждается, что объект на месте и соответствует ожиданиям.
  5. События из хранилища (создан объект) летят в очередь. Воркер/лямбда делает антивирусную проверку, проставляет метаданные, переводит объект из «карантина» в «готово».

Важная часть — разделение ответственности: бэкенд хранит бизнес‑логику и выдаёт минимально необходимые права; загрузку и масштабирование трафика берёт на себя S3/GCS.

Безопасность и политика доступа: как не открыть ведро всему миру

  • Отдельное ведро для загрузок. Производственные данные отдельно от пользовательских загрузок. Права минимальные, публичный доступ полностью запрещён (S3 Block Public Access — включить).
  • Префиксы на пользователя/организацию. Ключи вида uploads///-. Так проще наводить порядок и ограничивать доступ.
  • Шифрование по умолчанию. Включите шифрование на стороне сервера (SSE‑S3 или KMS). Тогда клиенту не нужно указывать заголовки шифрования при загрузке.
  • Срок жизни предподписанной ссылки 1–15 минут. Ротация ключей — по политике безопасности.
  • Ограничения на размер и тип. Для POST‑политики — Content-Length-Range. Для PUT — проверяем размер на финализации и отклоняем слишком большие файлы.
  • CORS в ведре — только с доверенных источников. Методам PUT/POST/HEAD разрешить нужные заголовки и отдать только необходимые ответные заголовки (ETag и т. п.).
  • Финализация обязательна. Пока бэкенд не подтвердил файл — не привязываем его к бизнес‑объекту, не показываем в интерфейсе.

PUT vs POST: когда какой способ подписывать

  • Предподписанный PUT — простая загрузка одним запросом. Хорош для файлов до 5 ГБ. Проще отлаживать и использовать через fetch/XHR.
  • Предподписанный POST с политикой — удобен для браузерной формы, позволяет строго задать условия (размер, префикс ключа) и не включать их в подпись заголовков. Но тоже ограничен 5 ГБ на одну операцию.
  • Для больших файлов используем многосоставную (multipart) загрузку: разбиваем файл на части, загружаем параллельно и возобновляем при обрывах.

Пример: простая загрузка (до 5 ГБ) по предподписанному PUT

Настройка CORS для ведра S3

{
  "CORSRules": [
    {
      "AllowedOrigins": ["https://app.example.com"],
      "AllowedMethods": ["PUT", "POST", "HEAD"],
      "AllowedHeaders": ["*"],
      "ExposeHeaders": ["ETag", "x-amz-request-id"],
      "MaxAgeSeconds": 3000
    }
  ]
}

Бэкенд: выдаём предподписанный URL (Node.js, AWS SDK v3)

import { randomUUID } from 'node:crypto';
import { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import type { Request, Response } from 'express';

const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.UPLOADS_BUCKET!;

// POST /uploads/presign
export async function presignUpload(req: Request, res: Response) {
  const { fileName, contentType, size } = req.body as { fileName: string; contentType: string; size: number };

  // Бизнес‑проверки: тип, размер, квоты пользователя
  if (!fileName || !contentType || !Number.isFinite(size)) {
    return res.status(400).json({ error: 'Некорректные параметры' });
  }
  const MAX_SIZE = 5 * 1024 * 1024 * 1024; // 5 ГБ
  if (size <= 0 || size > MAX_SIZE) {
    return res.status(413).json({ error: 'Слишком большой файл для обычной загрузки' });
  }

  // Генерируем безопасное имя и ключ
  const safeName = fileName.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 200);
  const userId = (req as any).user.id as string; // из аутентификации
  const key = `uploads/${userId}/${randomUUID()}-${safeName}`;

  const putCmd = new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    ContentType: contentType,
    Metadata: {
      uploader: userId,
    },
  });

  const expiresIn = 60 * 10; // 10 минут
  const url = await getSignedUrl(s3, putCmd, { expiresIn });

  return res.json({ url, key, expiresIn });
}

// POST /uploads/finalize
export async function finalizeUpload(req: Request, res: Response) {
  const { key, expectedSize, etag } = req.body as { key: string; expectedSize: number; etag?: string };

  if (!key || !Number.isFinite(expectedSize)) {
    return res.status(400).json({ error: 'Некорректные параметры' });
  }

  try {
    const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key }));
    const actualSize = Number(head.ContentLength ?? 0);
    if (actualSize !== expectedSize) {
      return res.status(400).json({ error: 'Размер файла не совпадает' });
    }

    // Дополнительно можно проверить content-type, префикс ключа, владельца из head.Metadata

    // Помечаем файл как «ожидает проверки» в базе и передаём ключ доменной сущности
    // await db.attachPendingFile({ userId, key, size: actualSize, etag: head.ETag });

    return res.json({ status: 'pending_scan', key, size: actualSize, etag: head.ETag });
  } catch (e: any) {
    if (e?.$metadata?.httpStatusCode === 404) {
      return res.status(404).json({ error: 'Файл не найден' });
    }
    throw e;
  }
}

Фронтенд: загрузка через fetch с прогресс‑баром

async function uploadFile(file) {
  // 1) Запрашиваем предподписанный URL
  const presignResp = await fetch('/uploads/presign', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ fileName: file.name, contentType: file.type || 'application/octet-stream', size: file.size }),
  }).then(r => r.json());

  // 2) PUT напрямую в S3
  await new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('PUT', presignResp.url);
    xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) {
        const percent = Math.round((e.loaded / e.total) * 100);
        console.log('progress', percent);
      }
    };
    xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error('Upload failed')));
    xhr.onerror = reject;
    xhr.send(file);
  });

  // 3) Финализируем на бэкенде
  const finalize = await fetch('/uploads/finalize', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ key: presignResp.key, expectedSize: file.size }),
  }).then(r => r.json());

  return finalize; // { status: 'pending_scan' | 'ready', ... }
}

Эта схема проста, быстра и подходит большинству сценариев, если файл не превышает 5 ГБ. Для больших и «ломких» сетей переходим к многосоставной загрузке.

Большие файлы и возобновление: многосоставная (multipart) загрузка

Идея: разбиваем файл на части (обычно 5–64 МБ), загружаем параллельно, при обрыве докидываем недостающие части и завершаем.

Бэкенд: инициализация, подпись частей и завершение

import {
  S3Client,
  CreateMultipartUploadCommand,
  UploadPartCommand,
  CompleteMultipartUploadCommand,
  AbortMultipartUploadCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.UPLOADS_BUCKET!;

// POST /uploads/multipart/init
export async function initMultipart(req, res) {
  const { fileName, contentType, size } = req.body;
  const userId = req.user.id;
  if (size <= 0) return res.status(400).json({ error: 'Размер обязателен' });

  const key = `uploads/${userId}/${randomUUID()}-${fileName.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 200)}`;
  const cmd = new CreateMultipartUploadCommand({ Bucket: BUCKET, Key: key, ContentType: contentType, Metadata: { uploader: userId } });
  const resp = await s3.send(cmd);
  return res.json({ uploadId: resp.UploadId, key });
}

// POST /uploads/multipart/sign-parts
export async function signParts(req, res) {
  const { key, uploadId, partNumbers } = req.body as { key: string; uploadId: string; partNumbers: number[] };
  const expiresIn = 60 * 20; // 20 минут
  const urls = await Promise.all(
    partNumbers.map(async (partNumber) => {
      const cmd = new UploadPartCommand({ Bucket: BUCKET, Key: key, UploadId: uploadId, PartNumber: partNumber });
      const url = await getSignedUrl(s3, cmd, { expiresIn });
      return { partNumber, url };
    })
  );
  res.json({ urls, expiresIn });
}

// POST /uploads/multipart/complete
export async function completeMultipart(req, res) {
  const { key, uploadId, parts } = req.body as { key: string; uploadId: string; parts: { ETag: string; PartNumber: number }[] };
  const cmd = new CompleteMultipartUploadCommand({
    Bucket: BUCKET,
    Key: key,
    UploadId: uploadId,
    MultipartUpload: { Parts: parts.sort((a,b) => a.PartNumber - b.PartNumber) },
  });
  const out = await s3.send(cmd);
  // Финализируем как в обычной загрузке
  return res.json({ status: 'pending_scan', key, etag: out.ETag });
}

// POST /uploads/multipart/abort (по требованию)
export async function abortMultipart(req, res) {
  const { key, uploadId } = req.body;
  await s3.send(new AbortMultipartUploadCommand({ Bucket: BUCKET, Key: key, UploadId: uploadId }));
  res.json({ aborted: true });
}

Фронтенд: разбиение на части и параллельная отправка

async function multipartUpload(file) {
  // 1) init
  const init = await fetch('/uploads/multipart/init', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ fileName: file.name, contentType: file.type, size: file.size }),
  }).then(r => r.json());

  const PART_SIZE = 8 * 1024 * 1024; // 8 МБ
  const totalParts = Math.ceil(file.size / PART_SIZE);
  const partNumbers = Array.from({ length: totalParts }, (_, i) => i + 1);

  // 2) Запрашиваем ссылки для первой порции частей (можно батчами)
  const { urls } = await fetch('/uploads/multipart/sign-parts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ key: init.key, uploadId: init.uploadId, partNumbers }),
  }).then(r => r.json());

  // 3) Грузим параллельно
  const concurrency = 4;
  const results = [];
  let index = 0;

  async function worker() {
    while (index < urls.length) {
      const current = urls[index++];
      const start = (current.partNumber - 1) * PART_SIZE;
      const end = Math.min(start + PART_SIZE, file.size);
      const blob = file.slice(start, end);
      const resp = await fetch(current.url, { method: 'PUT', body: blob });
      if (!resp.ok) throw new Error(`Part ${current.partNumber} failed`);
      const etag = resp.headers.get('ETag');
      results.push({ ETag: etag, PartNumber: current.partNumber });
    }
  }

  await Promise.all(Array.from({ length: concurrency }, worker));

  // 4) Завершаем
  const complete = await fetch('/uploads/multipart/complete', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ key: init.key, uploadId: init.uploadId, parts: results }),
  }).then(r => r.json());

  return complete;
}

Замечание: ETag у многосоставных объектов — не MD5 файла. Для контроля целостности используйте стороннюю проверку (см. ниже) или современные «чексуммы» в S3 (опционально, усложняет схему подписи).

Проверка файлов: размер, тип, антивирус, итоговая валидация

  • Быстрая проверка на финализации: размер, content-type, допустимый префикс ключа. Это защищает от очевидных ошибок.
  • Антивирусная проверка. Типовая схема: S3 генерирует событие ObjectCreated -> кладём его в очередь SQS -> воркер (например, AWS Lambda в контейнере с ClamAV) скачивает объект, сканирует, сохраняет результат (OK/INFECTED) в метаданные или базу. Если заражён — перемещаем в карантинный префикс и блокируем привязку к бизнес‑объекту.
  • Контроль содержимого. Если важно убедиться, что файл соответствует заявленному типу (например, изображение) — читаем первые байты (magic numbers) во воркере и валидируем.
  • Чексуммы. В строгом режиме можно требовать от клиента предварительный подсчёт sha256, передавать его в бэкенд и подписывать URL с обязательным заголовком (Content-MD5 или x-amz-checksum-sha256). Это усложняет клиент (нужно сначала считать хэш), зато даёт железобетонную целостность.

Пример простого воркера на Node.js, который помечает файл как «готов» (без антивируса, для краткости):

import { S3Client, HeadObjectCommand } from '@aws-sdk/client-s3';
import { SQSClient, ReceiveMessageCommand, DeleteMessageCommand } from '@aws-sdk/client-sqs';

const s3 = new S3Client({ region: process.env.AWS_REGION });
const sqs = new SQSClient({ region: process.env.AWS_REGION });

async function processOnce() {
  const msg = await sqs.send(new ReceiveMessageCommand({ QueueUrl: process.env.QUEUE_URL, MaxNumberOfMessages: 10, WaitTimeSeconds: 10 }));
  if (!msg.Messages?.length) return;
  for (const m of msg.Messages) {
    const body = JSON.parse(m.Body);
    const record = JSON.parse(body.Message || body.Records?.[0] ? JSON.stringify(body.Records[0]) : '{}');
    const bucket = record.s3?.bucket?.name;
    const key = decodeURIComponent(record.s3?.object?.key || '');
    if (bucket && key) {
      const head = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
      // TODO: антивирус, валидация
      // await db.markFileReady(key, Number(head.ContentLength));
    }
    await sqs.send(new DeleteMessageCommand({ QueueUrl: process.env.QUEUE_URL, ReceiptHandle: m.ReceiptHandle }));
  }
}

В проде лучше использовать нативные триггеры S3 -> Lambda без SQS, либо S3 -> EventBridge -> очередь/воркер, чтобы не терять события и лучше контролировать ретраи.

Нюансы и подводные камни: CORS, ETag, сроки жизни ссылок, отмена

  • CORS. Если видите ошибку в браузере до запроса — смотрите CORS в ведре: AllowedOrigins должен совпадать с origin вашего приложения, методы PUT/POST/HEAD разрешены, и в ExposeHeaders перечислите заголовки, которые читаете в JS (например, ETag).
  • ETag и целостность. Для одиночной PUT‑загрузки ETag чаще всего совпадает с MD5 содержимого (если нет нестандартных режимов). Для многосоставной — нет. Не используйте ETag как единственный критерий целостности для больших файлов.
  • Срок жизни ссылок. Держите короткими (10–15 минут). Если пользователю нужно «поставить на паузу» — пусть клиент умеет обновлять подписи (особенно для multipart часть‑за‑частью).
  • Отмена загрузки. Для multipart обязательно вызывайте AbortMultipartUpload при отмене, иначе «висящие» части будут занимать место до очистки жизненного цикла.
  • Ограничения по размеру. PUT/POST — до 5 ГБ на операцию. Больше — только через multipart. Минимальный размер части в multipart обычно 5 МБ (кроме последней).
  • CDN. Для загрузки лучше бить напрямую в S3/GCS, а не через CDN. CDN кэширует GET, а не PUT/POST; лишняя прослойка иногда ломает заголовки и увеличивает латентность. Исключения — специализированные сценарии и частные CDN с проксированием методов записи.
  • Ключи и приватность. Не кладите в ключи персональные данные. ID/UUID достаточно. Имя файла — можно, но «очищайте» и ограничивайте длину.

Мониторинг и расходы: как оценить экономию и настроить наблюдаемость

  • Трафик. Сравните исходящий трафик с бэкенда «до/после». При прямых загрузках он должен резко упасть. Это снижение сетевых расходов и нагрузки на балансировщики/воркеры.
  • Латентность. P95/P99 по операциям загрузки с точки зрения клиента сократится. Замерьте от «начал загрузку» до «файл подтверждён».
  • Ошибки. Отслеживайте коды 4xx/5xx от S3/GCS на фронте и ретраи. Для multipart полезно логировать долю неудачных частей и среднее число повторов.
  • События доступа. Включите CloudTrail Data Events для S3 или Server Access Logs, чтобы расследовать злоупотребления и инциденты. Настройте алерты на аномально много PutObject/AbortMultipart.
  • Холодная экономия. Уберите промежуточное хранение на бэкенде (tmp‑директории, диски), настройте lifecycle-политику для «карантина» и нечитаемых объектов (удалять через N дней).

Грубая оценка экономии: если сегодня вы гоняете через бэкенд 2 ТБ/мес пользовательских загрузок, то прямые загрузки сэкономят до 2 ТБ исходящего трафика с серверов и соответствующее время CPU/IO. Для команд с сотнями гигабайт — это ощутимо.

Чеклист внедрения

  • Отдельное ведро под загрузки, шифрование по умолчанию, публичный доступ заблокирован
  • CORS: разрешены только нужные Origin/методы/заголовки
  • Префиксы ключей с user/org ID, безопасная нормализация имён файлов
  • Предподписанные URL/POST с TTL 10–15 минут; квоты на размер и число попыток
  • Обычная PUT‑загрузка до 5 ГБ; multipart для больших и возобновляемости
  • Обязательная «финализация» на бэкенде, проверка размера/типа/прав
  • Триггеры S3 -> воркер для проверки (антивирус/валидатор), статусная модель: pending -> ready/blocked
  • AbortMultipart при отмене; lifecycle для очистки «висящих» частей и карантина
  • Мониторинг: латентность, ошибки, объём PUT/POST, алерты на аномалии
  • Документация для фронта: прогресс, ретраи, обновление подписей при истечении

Итог

Прямые загрузки в S3/GCS по предподписанным ссылкам — это простой способ ускорить продукт, снять нагрузку с бэкенда и сократить расходы. Ключ к безопасному внедрению — короткие и строго ограниченные права, финализация на бэкенде и автоматическая проверка загруженного файла. Начните с PUT для файлов до 5 ГБ, добавьте multipart для больших — и вы увидите эффект уже в первую неделю после релиза.


S3загрузка файловархитектура