Kravchenko

Web Lab

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

Kravchenko

Web Lab

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

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

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

•

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

•

ОГРНИП: 324784700339743

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

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

Разработка и технологии9 февраля 2026 г.
Покажу, как перевести загрузку файлов с «бэкенд — прокси» на прямые загрузки в хранилище через подписанные ссылки (pre‑signed URL/POST). Это ускоряет UX, разгружает серверы приложений и сокращает расходы на трафик и автоскейлинг, при этом остаётся безопасным: доступ точечный, по времени и размеру.
Прямые загрузки в S3 через подписанные ссылки: быстрее файлы, меньше нагрузка на бэкенд и ниже счёт

  • Зачем бизнесу прямые загрузки
  • Архитектура потока: от клиента до хранилища
  • Безопасность: права, ограничения и шифрование
  • Примеры кода: PUT/POST и multipart‑upload
  • Пост‑обработка: валидация, хэши, предпросмотры
  • CORS и политика бакета
  • Сколько это экономит
  • Типичные ошибки и отладка
  • Пошаговый план внедрения
  • Чек‑лист

Зачем бизнесу прямые загрузки

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

Прямые загрузки (клиент —> S3/MinIO/Blob Storage) через подписанные ссылки решают это:

  • Быстрее: клиент пишет напрямую в хранилище по ближним сетям провайдера.
  • Дешевле: бэкенд больше не проксирует гигабайты, уменьшаются счета за трафик и нагрузка на CPU/RAM.
  • Надёжнее: меньше точек отказа, проще горизонтально масштабировать фронт и воркеры.
  • Контролируемо: доступ по времени, по префиксу, по типу контента и размеру; ключи не утекут — ссылки короткоживущие.

Архитектура потока: от клиента до хранилища

Идея проста:

  1. Клиент запрашивает у бэкенда «разрешение на загрузку»: имя файла, тип, ожидаемый размер.
  2. Бэкенд проверяет права и выдаёт подписанную ссылку на загрузку (PUT) или набор полей для HTML‑формы (POST Policy) с ограничениями.
  3. Клиент грузит файл напрямую в хранилище.
  4. После успешной загрузки клиент сообщает бэкенду «готово» (или бэкенд получает событие из хранилища). Бэкенд помечает файл verified, запускает пост‑обработку: предпросмотры, трансформация, индексация.

Когда нужен multipart‑upload: для больших файлов (сотни мегабайт и гигабайты) — загрузка частями с возможностью докачки.

Безопасность: права, ограничения и шифрование

  • Ограничение области: подписываем ссылки только на префикс пользователя или проекта: uploads/{tenantId}/{uuid}.
  • Срок жизни: 1–10 минут достаточно. Длиннее — риск утечки, короче — проблемы с медленной сетью.
  • Тип контента: фиксируем Content‑Type и/или требуем Content‑MD5.
  • Размер: жёстко ограничиваем в POST Policy (content-length-range). Для PUT — проверяем после загрузки через HEAD, иначе отклоняем/удаляем излишек.
  • Шифрование: включаем серверное шифрование SSE (AES256 или KMS). Для персональных данных — SSE‑KMS с отдельным ключом и аудитом.
  • Политики доступа: ключ, который подписывает URL, должен иметь право только на нужный бакет/префикс.

Примеры кода: S3, Node.js/TypeScript

Ниже — рабочие примеры c AWS SDK v3. Они легко адаптируются под MinIO (совместим по API S3) и другие облака с S3‑совместимым слоем.

Бэкенд: выдача pre‑signed PUT (просто и быстро)

// file: server.ts
import express from 'express';
import crypto from 'crypto';
import { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

// Конфиг из окружения (пример значений):
// AWS_REGION=eu-central-1
// UPLOADS_BUCKET=my-company-uploads
// MAX_UPLOAD_SIZE=52428800 (50MB)
// TENANT_PREFIX=tenant- (для примера, обычно берётся из auth)
const REGION = process.env.AWS_REGION || 'eu-central-1';
const BUCKET = process.env.UPLOADS_BUCKET!;
const MAX_SIZE = Number(process.env.MAX_UPLOAD_SIZE || 50 * 1024 * 1024);

const s3 = new S3Client({ region: REGION });
const app = express();
app.use(express.json());

// Простая аутентификация-псевдо: в проде берём tenantId из JWT/сессии
function getTenantId(req: express.Request) {
  // пример: req.user.tenantId
  return 'tenant123';
}

app.post('/uploads/initiate-put', async (req, res) => {
  try {
    const { fileName, contentType, expectedSize } = req.body as {
      fileName: string; contentType: string; expectedSize: number;
    };
    if (!fileName || !contentType || !expectedSize) return res.status(400).json({ error: 'invalid_input' });
    if (expectedSize > MAX_SIZE) return res.status(413).json({ error: 'too_large' });

    const tenantId = getTenantId(req);
    const ext = fileName.includes('.') ? fileName.split('.').pop() : 'bin';
    const key = `uploads/${tenantId}/${crypto.randomUUID()}.${ext}`;

    const cmd = new PutObjectCommand({
      Bucket: BUCKET,
      Key: key,
      ContentType: contentType,
      // Шифрование на стороне сервера
      ServerSideEncryption: 'AES256',
      // Можно потребовать Content-MD5 для целостности
    });

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

    // Запоминаем ожидаемый размер и тип в БД (опущено) — пригодится для валидации после загрузки

    res.json({
      uploadUrl: url,
      method: 'PUT',
      key,
      headers: { 'Content-Type': contentType },
      maxSize: MAX_SIZE
    });
  } catch (e) {
    console.error(e);
    res.status(500).json({ error: 'internal' });
  }
});

// Эндпоинт подтверждения: клиент вызывает после 200 OK от S3
app.post('/uploads/complete', async (req, res) => {
  try {
    const { key, expectedSize } = req.body as { key: string; expectedSize: number };
    if (!key || !expectedSize) return res.status(400).json({ error: 'invalid_input' });

    const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key }));
    const size = head.ContentLength ?? 0;
    if (size !== expectedSize) {
      // Политика на ваше усмотрение: удалить, пометить ошибкой или оставить в карантине
      return res.status(400).json({ error: 'size_mismatch', actual: size });
    }

    // Здесь: помечаем файл verified в БД, пускаем пост-обработку (очередь)

    res.json({ status: 'ok', key });
  } catch (e) {
    console.error(e);
    res.status(500).json({ error: 'internal' });
  }
});

app.listen(3000, () => console.log('Upload server on :3000'));

Клиентская загрузка через fetch:

// Предполагаем, что уже получили от бэкенда uploadUrl, headers и key
async function uploadFile(file, uploadUrl, headers) {
  const res = await fetch(uploadUrl, {
    method: 'PUT',
    headers,
    body: file,
  });
  if (!res.ok) throw new Error('upload_failed');
}

Бэкенд: pre‑signed POST с лимитом размера (жёстче контроль)

POST‑политика позволяет задать content-length-range — это прямое ограничение размера на стороне S3.

// file: post-policy.ts
import crypto from 'crypto';
import { S3Client } from '@aws-sdk/client-s3';
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';

const s3 = new S3Client({ region: process.env.AWS_REGION || 'eu-central-1' });
const BUCKET = process.env.UPLOADS_BUCKET!;
const MAX_SIZE = Number(process.env.MAX_UPLOAD_SIZE || 50 * 1024 * 1024);

export async function createPostPolicy(tenantId: string, fileName: string, contentType: string) {
  const ext = fileName.includes('.') ? fileName.split('.').pop() : 'bin';
  const key = `uploads/${tenantId}/${crypto.randomUUID()}.${ext}`;

  const { url, fields } = await createPresignedPost(s3, {
    Bucket: BUCKET,
    Key: key,
    Conditions: [
      ['content-length-range', 1, MAX_SIZE],
      ['starts-with', '$Content-Type', contentType.split('/')[0]],
    ],
    Fields: {
      'Content-Type': contentType,
    },
    Expires: 300, // секунд
  });
  return { url, fields, key, maxSize: MAX_SIZE };
}

Клиент для POST (через FormData):

async function uploadViaPost(url, fields, file) {
  const form = new FormData();
  Object.entries(fields).forEach(([k, v]) => form.append(k, v));
  form.append('file', file);
  const res = await fetch(url, { method: 'POST', body: form });
  if (!res.ok) throw new Error('upload_failed');
}

Большие файлы: multipart‑upload с докачкой

Для файлов >100–200 МБ используйте multipart: делим файл на части (например, по 8–16 МБ), подписываем каждую часть.

// file: multipart.ts
import { S3Client, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import crypto from 'crypto';

const s3 = new S3Client({ region: process.env.AWS_REGION || 'eu-central-1' });
const BUCKET = process.env.UPLOADS_BUCKET!;

export async function initiateMultipart(tenantId: string, fileName: string, contentType: string) {
  const key = `uploads/${tenantId}/${crypto.randomUUID()}-${fileName}`;
  const resp = await s3.send(new CreateMultipartUploadCommand({
    Bucket: BUCKET,
    Key: key,
    ContentType: contentType,
    ServerSideEncryption: 'AES256',
  }));
  return { uploadId: resp.UploadId!, key };
}

export async function signPart(key: string, uploadId: string, partNumber: number) {
  const cmd = new UploadPartCommand({ Bucket: BUCKET, Key: key, UploadId: uploadId, PartNumber: partNumber });
  const url = await getSignedUrl(s3, cmd, { expiresIn: 60 * 10 });
  return { url };
}

export async function completeMultipart(key: string, uploadId: string, parts: { ETag: string; PartNumber: number }[]) {
  await s3.send(new CompleteMultipartUploadCommand({
    Bucket: BUCKET,
    Key: key,
    UploadId: uploadId,
    MultipartUpload: { Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber) },
  }));
}

export async function abortMultipart(key: string, uploadId: string) {
  await s3.send(new AbortMultipartUploadCommand({ Bucket: BUCKET, Key: key, UploadId: uploadId }));
}

На клиенте для multipart сохраняем ETag каждой части из ответа S3 (заголовок ETag), чтобы корректно завершить сессию и уметь докачивать.

Пост‑обработка: валидация, хэши, предпросмотры

  • HEAD/GET для проверки размера и типа. Сверяем с ожидаемыми значениями, заданными при выдаче ссылки. Несовпадение — удаляем или отправляем в карантин.
  • Хэш целостности. Для защиты от «тихих» битых загрузок можно требовать Content‑MD5 в PUT, а после загрузки вычислить sha256 и записать как тег.
  • Генерация предпросмотров (изображения, видео) — лучше выносить в очереди/воркеры, чтобы не блокировать ответ пользователю.

Пример Lambda, которая по событию S3 считает SHA‑256 и ставит тег файла:

# file: lambda_sha256.py
import boto3
import hashlib
import urllib.parse

iam = boto3.client('s3')
s3 = boto3.client('s3')

def handler(event, context):
    for rec in event.get('Records', []):
        bucket = rec['s3']['bucket']['name']
        key = urllib.parse.unquote_plus(rec['s3']['object']['key'])
        h = hashlib.sha256()
        # Потоково читаем объект
        obj = s3.get_object(Bucket=bucket, Key=key)
        for chunk in obj['Body'].iter_chunks(chunk_size=1024 * 1024):
            if chunk:
                h.update(chunk)
        digest = h.hexdigest()
        # Добавляем тег sha256
        s3.put_object_tagging(
            Bucket=bucket,
            Key=key,
            Tagging={
                'TagSet': [
                    {'Key': 'sha256', 'Value': digest},
                    {'Key': 'status', 'Value': 'verified'}
                ]
            }
        )
    return {'ok': True}

Для вирус‑сканирования часто используют ClamAV в Lambda с EFS/Layers. Если обнаружен вредоносный файл — перемещаем в карантинный префикс и помечаем в БД.

CORS и политика бакета

Чтобы браузер мог отправлять запросы непосредственно в хранилище, настройте CORS бакета.

Пример CORS для PUT/POST/GET:

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

Пример минимальной IAM‑политики для роли бэкенда, которая подписывает URL только под конкретный бакет и префикс:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:AbortMultipartUpload",
        "s3:CreateMultipartUpload",
        "s3:CompleteMultipartUpload",
        "s3:PutObjectTagging",
        "s3:HeadObject",
        "s3:GetObject"
      ],
      "Resource": [
        "arn:aws:s3:::my-company-uploads/uploads/*"
      ]
    }
  ]
}

И политика бакета, запрещающая публичный доступ и требующая TLS:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyInsecureTransport",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::my-company-uploads",
        "arn:aws:s3:::my-company-uploads/*"
      ],
      "Condition": { "Bool": { "aws:SecureTransport": "false" } }
    }
  ]
}

Сколько это экономит

Оценка на реальном порядке величин:

  • Было: 200 ГБ/день через бэкенд, egress из инстансов + автоскейлинг под пиковые PUT‑запросы. На 8 vCPU инстансах при TLS‑терминации и проксировании — 5–6 инстансов в час пик.
  • Стало: проксирование ушло. Бэкенд только выдаёт короткие ссылки и обрабатывает события. Пиково — +1–2 инстанса, а не +6. Трафик с инстансов падает на десятки процентов. Экономия в месяц — 20–40% на группе приложений (без учёта вычислений предпросмотров, которые и так нужны).

Плюс: пользователи видят более быстрый аплоад (особенно при multipart), меньше обрывов и повторов.

Типичные ошибки и отладка

  • Утечки ссылок в логах. Не пишите полный pre‑signed URL в логи и аналитики — он содержит подпись и параметры. Логируйте только key и метаданные.
  • Неверный CORS. Проверяйте AllowedOrigins: точный домен приложения, без звёздочек на проде.
  • Несогласованность часов. Убедитесь, что системные часы на серверах и клиентах синхронизированы (NTP), иначе «срок подписи истёк» раньше времени.
  • PUT без Content‑Type. Если подписывали с ContentType, на клиенты обязуйте выставлять тот же заголовок.
  • Проверка размера только на клиенте. Клиент может ошибаться. Делайте HEAD/GET и политику POST.
  • Мусор от прерванных multipart. Запускайте периодическую очистку «висящих» multipart через AbortMultipartUpload по времени.

Пошаговый план внедрения

  1. Выделите бакет и префиксы по арендаторам/проектам. Включите SSE (AES256/KMS).
  2. Настройте CORS и политики (TLS‑обязателен, публичный доступ запрещён).
  3. Реализуйте эндпоинты: initiate (PUT или POST), complete, и (опционально) multipart trio.
  4. Сохранение метаданных в БД: ожидаемый размер/тип, ключ, статус (pending/uploaded/verified/failed).
  5. Клиент: обновите форму загрузки на прямую отправку в S3. Добавьте прогресс и обработку ошибок.
  6. Пост‑обработка: очередь задач для предпросмотров/транскодирования. Подписка на события S3 или явное подтверждение клиента.
  7. Миграция трафика: включите новую схему для небольших типов файлов, потом для всех. Держите старый путь как запасной на 1–2 недели.
  8. Наблюдаемость: метрики успешных загрузок, среднее время загрузки, доля ошибок, рост хранилища. Алёрты на всплески 4xx/5xx.

Чек‑лист

  • Подписанные ссылки живут не дольше 5–10 минут
  • Ограничения по размеру: POST Policy и/или серверная проверка HEAD
  • Префиксы на уровне арендатора/пользователя
  • Шифрование на стороне сервера (SSE), аудит доступа
  • CORS: только нужные домены и методы
  • Очистка незавершённых multipart
  • Пост‑обработка вынесена в очередь
  • Метрики и алёрты настроены
  • Логи не содержат полных подписанных URL

Альтернативы и совместимость

  • Облака: GCS и Azure Blob поддерживают аналогичные механизмы (Signed URL/SAS). Принципы те же: короткая жизнь, ограничение размеров, префиксы, шифрование, события для пост‑обработки.
  • Локальные/гибридные инсталляции: MinIO (совместим по API S3), те же паттерны и кодовая база.

Итог: прямые загрузки через подписанные ссылки — это редкий случай, когда и быстрее для пользователя, и дешевле для компании. Настроили один раз — и забыли про «боль гигабайт через бэкенд», сосредоточившись на ценности: обработке контента и бизнес‑логике.


S3загрузка файловподписанные ссылки