Загрузка файлов в S3-совместимое хранилище в Django: как сделать правильно
Прямая загрузка файлов через Django-бэкенд упирается в память и таймауты на объёмах от сотен мегабайт. Разбираем три подхода к S3-совместимому хранилищу и цену каждого.

Пользователь грузит видео на 800 МБ. Файл идёт через ваш Django-воркер: сначала в память или во временный файл на диске приложения, потом — в S3. Один такой запрос держит воркера gunicorn на всё время загрузки, съедает RAM и упирается в таймаут балансировщика (по умолчанию 60 секунд у многих прокси). Три параллельных загрузчика на 4 воркерах — и сайт не отвечает. Разберём, как отдать файлы напрямую в хранилище и когда какой подход выбирать.
Актуально для Django 4.2 и 5.x, django-storages 1.14.x, boto3 1.34.x. S3-совместимое — это не только AWS: Yandex Object Storage, VK Cloud, Selectel, MinIO работают по тому же протоколу, отличаются endpoint и регионом.
Три подхода и что каждый стоит
Задача одна — файл должен оказаться в бакете, а ссылка на него — в базе. Способы отличаются тем, через что проходит тело файла.
- Через бэкенд (proxy upload). Файл идёт браузер → Django → S3. Просто настроить, но каждый байт проходит через воркер приложения.
- Presigned POST/PUT. Бэкенд выдаёт подписанную ссылку, браузер грузит файл прямо в S3, минуя Django. Тело файла ваш сервер не видит.
- Presigned + multipart для крупных файлов. То же, но файл режется на части, каждая грузится отдельно. Нужно для файлов больше 5 ГБ (лимит одной PUT-операции в S3) и для устойчивости к обрывам.
Сравнение
| Критерий | Через бэкенд | Presigned | Presigned + multipart |
|---|---|---|---|
| Нагрузка на воркер | Высокая, держит на всё время | Нулевая по телу | Нулевая по телу |
| Сложность | Низкая | Средняя (нужен JS) | Высокая |
| Валидация до сохранения | Полная | Только по метаданным | Только по метаданным |
| Разумный потолок размера | До ~10-20 МБ | До 5 ГБ на файл | Без практического потолка |
| Прогресс на клиенте | Тяжело | Есть | Есть, по частям |
Базовая настройка django-storages
Начните с этого в любом случае — даже для presigned нужен настроенный клиент, чтобы генерировать ссылки и хранить пути. В Django 4.2+ появился формат STORAGES, старый DEFAULT_FILE_STORAGE в 5.x объявлен устаревшим.
# settings.py — Django 5.x, django-storages 1.14.x
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": {
"bucket_name": "my-media",
"endpoint_url": "https://storage.yandexcloud.net",
"region_name": "ru-central1",
# querystring_auth=True — ссылки на приватные объекты подписываются
"querystring_auth": True,
"default_acl": None, # приватный бакет по умолчанию
},
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
# Ключи — только из окружения, не в коде и не в репозитории
import os
AWS_ACCESS_KEY_ID = os.environ["S3_ACCESS_KEY"]
AWS_SECRET_ACCESS_KEY = os.environ["S3_SECRET_KEY"]
Имена ключей AWS_ACCESS_KEY_ID и AWS_SECRET_ACCESS_KEY — django-storages читает их из настроек проекта. Точный список опций S3Storage зависит от версии пакета, сверяйтесь с документацией django-storages для вашей: набор параметров между 1.13 и 1.14 менялся.
Presigned upload: файл минует Django
Идея: клиент просит у бэкенда разрешение на загрузку, бэкенд генерирует временную подписанную ссылку с ограничениями (размер, тип, срок жизни), клиент грузит файл напрямую в S3. Ваш воркер участвует только в двух коротких запросах.
Генерация presigned POST на бэкенде
# views.py — генерация подписанной формы загрузки
import uuid
import boto3
from django.conf import settings
from django.http import JsonResponse
def create_upload_url(request):
# генерируем уникальный ключ, чтобы клиент не диктовал путь
file_key = f"uploads/{uuid.uuid4()}/{request.GET['filename']}"
s3 = boto3.client(
"s3",
endpoint_url="https://storage.yandexcloud.net",
region_name="ru-central1",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)
# Conditions ограничивают то, что клиент реально загрузит
presigned = s3.generate_presigned_post(
Bucket="my-media",
Key=file_key,
Fields={"Content-Type": request.GET["content_type"]},
Conditions=[
{"Content-Type": request.GET["content_type"]},
["content-length-range", 1, 20 * 1024 * 1024], # 1 байт — 20 МБ
],
ExpiresIn=300, # ссылка живёт 5 минут
)
return JsonResponse({"upload": presigned, "key": file_key})
Метод generate_presigned_post возвращает словарь с url и fields — их нужно передать в форму как есть, порядок полей значения не имеет, но само поле файла всегда добавляется последним.
Загрузка с клиента
// frontend: получаем ссылку, грузим напрямую в S3
const res = await fetch(
`/api/create-upload-url/?filename=${file.name}&content_type=${file.type}`
);
const { upload, key } = await res.json();
const form = new FormData();
// сначала все поля из presigned, потом файл — порядок важен
Object.entries(upload.fields).forEach(([k, v]) => form.append(k, v));
form.append("file", file);
const uploadRes = await fetch(upload.url, { method: "POST", body: form });
// S3 отвечает 204 No Content при успехе
if (uploadRes.status === 204) {
// отправляем key обратно на бэкенд — привязать к записи в БД
await fetch("/api/confirm-upload/", {
method: "POST",
body: JSON.stringify({ key }),
});
}
Presigned-ссылка ограничивает размер и тип на уровне S3 — клиент физически не загрузит файл больше заявленного лимита. Но само тело файла ваш бэкенд не видит: проверить содержимое (реальный ли это JPEG, нет ли вредоносного payload) до сохранения нельзя. Валидацию контента делайте асинхронно после загрузки.
Подтверждение и привязка к модели
После успешной загрузки клиент присылает key. Не доверяйте ему слепо: проверьте, что объект действительно существует в бакете и укладывается в ожидаемый размер, прежде чем писать путь в модель.
# views.py — подтверждение загрузки
from botocore.exceptions import ClientError
def confirm_upload(request):
key = json.loads(request.body)["key"]
s3 = boto3.client("s3", endpoint_url="https://storage.yandexcloud.net",
region_name="ru-central1",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY)
try:
# head_object подтверждает наличие и отдаёт метаданные без скачивания
head = s3.head_object(Bucket="my-media", Key=key)
except ClientError:
return JsonResponse({"error": "file not found"}, status=400)
if head["ContentLength"] > 20 * 1024 * 1024:
return JsonResponse({"error": "too large"}, status=400)
# FileField.name хранит относительный ключ, storage знает бакет
doc = Document(file=key)
doc.save()
return JsonResponse({"id": doc.id})
Когда что выбирать
- Файлы до 10-20 МБ, редкие загрузки (аватары, вложения в тикеты) — грузите через бэкенд. Настройка в две строки, presigned здесь избыточен.
- Регулярные загрузки от пользователей, десятки-сотни МБ — presigned POST. Это снимает файл с воркеров и даёт прогресс на клиенте.
- Файлы больше 5 ГБ или загрузка по нестабильному каналу — multipart с presigned на каждую часть. Обрыв на 4-й части из 10 не заставляет грузить всё заново.
Multipart — отдельная большая тема с инициализацией, presigned на каждый part и финальным complete_multipart_upload; здесь её не разворачиваем, ориентир — раздел Multipart upload в документации boto3.
Что ломается
- CORS на бакете. Браузер не даст загрузить на другой домен без правильной CORS-политики бакета: разрешите методы POST/PUT и заголовки для вашего origin. Ошибка в консоли — заблокированный preflight-запрос.
- Content-Type в подписи не совпал с реальным. Если в
Conditionsзадан тип, а браузер отправил другой, S3 вернёт 403. Либо не фиксируйте тип жёстко, либо берите его на клиенте до генерации ссылки. - Приватный бакет и публичные ссылки. При
querystring_auth=Trueкаждый вызовobj.urlгенерирует подписанную ссылку с ограниченным сроком. Она не годится для кеширования в CDN — используйте отдельную стратегию для публичного контента. - Срок жизни presigned-ссылки истёк. При
ExpiresIn=300и медленном соединении крупный файл может не успеть — увеличивайте срок под ожидаемый размер и скорость. - Загрузка прошла, а подтверждения нет. Клиент закрыл вкладку между загрузкой в S3 и вызовом confirm — объект висит в бакете «сиротой». Настройте lifecycle-правило: удалять объекты из
uploads/старше N дней, у которых нет записи в БД.
Что настроить дальше
После базовой схемы стоит закрыть три вещи. Первое — lifecycle-политику бакета на удаление неподтверждённых загрузок, иначе бакет копит мусор. Второе — асинхронную валидацию содержимого через Celery: проверять реальный формат файла после загрузки, а не только заявленный Content-Type. Третье — метрику времени между presigned-запросом и confirm: рост этого времени сигналит о проблемах с сетью у пользователей. Точный синтаксис lifecycle и CORS-конфигурации зависит от провайдера — сверяйтесь с документацией вашего S3-хранилища, у Yandex, Selectel и MinIO детали различаются.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: django-storages: Amazon S3 backend, boto3: generate_presigned_post
Частые вопросы
У меня django-storages 1.13, а не 1.14 — код заработает?
Presigned-загрузка не работает — браузер пишет про CORS
Можно ли проверить, что загружен именно картинка, а не переименованный exe?
Нужен ли boto3, если уже стоит django-storages?
Файл больше 5 ГБ через presigned PUT загрузить нельзя?
Загрузка в S3 прошла, но запись в базе не создалась — как не копить мусор?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.