Чтобы работать с S3-совместимым хранилищем из кода, кроме ключа доступа и секрета нужны ещё четыре вещи: URL endpoint, имя региона, включённая path-style адресация и - в SDK, выпущенных с начала 2025 года, - контрольные суммы в режиме «когда требуется». В Python это boto3.client("s3", endpoint_url=..., region_name="us-east-1", config=Config(s3={"addressing_style": "path"})). В Node - new S3Client({ endpoint, region: "us-east-1", forcePathStyle: true }) из @aws-sdk/client-s3. Всё остальное - загрузка, скачивание, листинг, удаление - тот же код, что вы написали бы для Amazon S3. В этой статье оба SDK настраиваются как следует, а затем разбираются операции, которые реально нужны приложению, случаи с большими файлами и ошибки, с которыми вы столкнётесь.
Настройки, которые нужны любому клиенту#
| Настройка | Python (boto3) | Node (@aws-sdk/client-s3) | Зачем |
|---|---|---|---|
| Endpoint | endpoint_url= | endpoint: | Иначе SDK обращается к AWS |
| Регион | region_name= | region: | Входит в подпись запроса |
| Path-style | Config(s3={'addressing_style': 'path'}) | forcePathStyle: true | Бакет в пути, а не в имени хоста |
| Учётные данные | aws_access_key_id, aws_secret_access_key | credentials: {...} | Или переменные окружения |
| Контрольные суммы | request_checksum_calculation="when_required" | requestChecksumCalculation: "WHEN_REQUIRED" | Совместимость с серверами не от AWS |
Path-style - настройка, о которой забывают. Без неё SDK отправляет запрос на bucket-name.your-endpoint, у которого нет DNS-записи, и в ошибке сказано, что хост не найден. Почему S3-совместимые endpoint используют path-style и что на самом деле делает имя региона, объясняет статья path-style и virtual-hosted адресация.
Настройки контрольных сумм появились позже. В январе 2025 года AWS SDK начали по умолчанию добавлять к загрузкам контрольные суммы CRC для проверки целостности (boto3 с версии 1.36, JavaScript SDK с 3.729). Многие S3-совместимые серверы не понимают новые заголовки, и в результате загрузки падают с SignatureDoesNotMatch, MissingContentLength или XAmzContentSHA256Mismatch, а чтение продолжает работать. Если выставить обе опции в «когда требуется», вернётся прежнее поведение. Если ваше хранилище принимает значение по умолчанию, их можно не указывать; в любом случае указать их ничего не стоит.
Python: рабочий клиент boto3#
Установка - pip install boto3. Держите конфигурацию в переменных окружения, а не в коде - почему и как, объясняет статья переменные окружения и секреты.
import osimport boto3from botocore.config import Configs3 = boto3.client( "s3", endpoint_url=os.environ["S3_ENDPOINT"], # https://s3.example.com region_name=os.environ.get("S3_REGION", "us-east-1"), aws_access_key_id=os.environ["S3_ACCESS_KEY_ID"], aws_secret_access_key=os.environ["S3_SECRET_ACCESS_KEY"], config=Config( signature_version="s3v4", s3={"addressing_style": "path"}, request_checksum_calculation="when_required", response_checksum_validation="when_required", retries={"max_attempts": 5, "mode": "standard"}, ),)BUCKET = os.environ["S3_BUCKET"]Несколько замечаний о выбранных значениях:
signature_version="s3v4"- значение по умолчанию в текущем botocore; явное указание страхует от старых установок, которые для некоторых операций по умолчанию использовали SigV2.- Блок
retriesвключает режимstandardв botocore, который повторяет запросы при троттлинге и временных сетевых ошибках с нарастающей паузой. Режим по умолчаниюlegacyповторяет меньше случаев. - Клиент boto3 потокобезопасен. Создайте один на процесс и переиспользуйте его. Создание клиента на каждый запрос стоит десятков миллисекунд и каждый раз нового пула соединений.
- Опциям контрольных сумм в
Configнужен botocore 1.36 или новее. На более старой версии удалите эти две строки; старая версия всё равно не отправляет новые контрольные суммы.
При желании те же значения можно взять из стандартных переменных AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION и AWS_ENDPOINT_URL_S3 - тогда boto3.client("s3", config=...) подхватит их без аргументов. Явные аргументы читать проще, если тот же процесс ещё и обращается к настоящему AWS ради чего-то другого.
Python: загрузка, скачивание и листинг#
Операции, которыми приложение пользуется каждый день:
# Upload a local file. Handles multipart automatically above 8 MB.s3.upload_file("report.pdf", BUCKET, "reports/2026/report.pdf", ExtraArgs={"ContentType": "application/pdf"})# Upload bytes you already have in memorys3.put_object(Bucket=BUCKET, Key="notes/hello.txt", Body=b"hello", ContentType="text/plain; charset=utf-8")# Download to a file, or read into memorys3.download_file(BUCKET, "reports/2026/report.pdf", "/tmp/report.pdf")body = s3.get_object(Bucket=BUCKET, Key="notes/hello.txt")["Body"].read()# Does it exist?from botocore.exceptions import ClientErrortry: s3.head_object(Bucket=BUCKET, Key="notes/hello.txt")except ClientError as e: if e.response["Error"]["Code"] in ("404", "NoSuchKey", "NotFound"): print("missing") else: raise# Deletes3.delete_object(Bucket=BUCKET, Key="notes/hello.txt")Указывайте ContentType при загрузке. S3 хранит то, что вы ему дали, и возвращает это при скачивании; если не указать тип, объекты вернутся как binary/octet-stream, и браузеры будут скачивать картинки вместо того, чтобы их показывать.
Листинг - место, где пишут баги. list_objects_v2 возвращает не больше 1000 ключей за вызов, и код, который вызывает его один раз, молча игнорирует всё после первой тысячи. Используйте paginator:
paginator = s3.get_paginator("list_objects_v2")total = 0for page in paginator.paginate(Bucket=BUCKET, Prefix="reports/2026/"): for obj in page.get("Contents", []): total += obj["Size"] print(obj["Key"], obj["Size"], obj["LastModified"])print(f"{total / 1e6:.1f} MB")Передайте Delimiter="/", чтобы получать по одному уровню «папок» за раз: ключи глубже следующего слеша сворачиваются в CommonPrefixes. Настоящих папок в S3 нет - ключ это одна плоская строка, - но префиксы и разделители позволяют просматривать содержимое так, будто они есть.
upload_file и download_file используют менеджер передачи, который делит большие файлы на части и передаёт их параллельно. По умолчанию у него порог 8 MB, части по 8 MB и 10 потоков. На небольшом сервере с малым объёмом памяти параллельность лучше уменьшить, а не увеличить:
from boto3.s3.transfer import TransferConfigcfg = TransferConfig(multipart_threshold=16 * 1024 * 1024, multipart_chunksize=16 * 1024 * 1024, max_concurrency=4)s3.upload_file("world-backup.tar.zst", BUCKET, "backups/world.tar.zst", Config=cfg)Каждая передаваемая часть держится в памяти, так что 4 потока по 16 MB - это примерно 64 MB буферов. На тарифе приложения с 1 GB это существенно.
boto3 в асинхронных фреймворках
boto3 синхронный. Если вызвать его прямо из async def во view FastAPI, Starlette или асинхронном view Django, каждая загрузка блокирует event loop, и все остальные запросы на этом worker ждут её. Либо объявите endpoint обычным def (тогда FastAPI сам запустит его в пуле потоков), либо явно вынесите вызов в поток:
import asyncioasync def save_avatar(data: bytes, key: str) -> None: await asyncio.to_thread( s3.put_object, Bucket=BUCKET, Key=key, Body=data, ContentType="image/png" )Существуют сторонние асинхронные обёртки (aiobotocore и aioboto3), но они жёстко привязаны к версиям botocore и отстают от него. Для приложения, которое делает несколько загрузок в секунду, поток проще и вполне достаточен.
Node: рабочий клиент SDK v3#
JavaScript SDK v3 модульный: устанавливайте только то, чем пользуетесь. Для большинства приложений это два-три пакета.
$ npm install @aws-sdk/client-s3 @aws-sdk/lib-storage @aws-sdk/s3-request-presignerimport { S3Client } from "@aws-sdk/client-s3";export const s3 = new S3Client({ endpoint: process.env.S3_ENDPOINT, // https://s3.example.com region: process.env.S3_REGION ?? "us-east-1", forcePathStyle: true, credentials: { accessKeyId: process.env.S3_ACCESS_KEY_ID, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, }, requestChecksumCalculation: "WHEN_REQUIRED", responseChecksumValidation: "WHEN_REQUIRED", maxAttempts: 5,});export const BUCKET = process.env.S3_BUCKET;Как и с boto3, создайте клиент один раз на уровне модуля и импортируйте его везде. Поддержка SDK v2 (aws-sdk) закончилась в сентябре 2025 года; если вы сопровождаете код, который всё ещё его использует, аналогичными опциями там были s3ForcePathStyle: true и endpoint, а миграция стоит усилий уже ради меньшего размера установки и поддерживаемого кода.
Node: загрузка, скачивание и листинг#
Каждая операция - это объект команды, который передаётся в s3.send():
import { PutObjectCommand, GetObjectCommand, HeadObjectCommand, DeleteObjectCommand, paginateListObjectsV2 } from "@aws-sdk/client-s3";import { readFile } from "node:fs/promises";// Upload a small file from memoryawait s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: "notes/hello.txt", Body: "hello", ContentType: "text/plain; charset=utf-8",}));// Download: Body is a stream with helper methodsconst res = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: "notes/hello.txt" }));const text = await res.Body.transformToString();// Exists?try { await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: "notes/hello.txt" }));} catch (err) { if (err.name !== "NotFound" && err.$metadata?.httpStatusCode !== 404) throw err;}// List everything under a prefix, all pagesfor await (const page of paginateListObjectsV2({ client: s3 }, { Bucket: BUCKET, Prefix: "reports/" })) { for (const obj of page.Contents ?? []) console.log(obj.Key, obj.Size);}await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: "notes/hello.txt" }));Body ответа в Node - это поток для чтения, расширенный методами transformToString() и transformToByteArray(). Большой объект не буферизуйте, а передавайте через pipe:
import { createWriteStream } from "node:fs";import { pipeline } from "node:stream/promises";const { Body } = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: "backups/world.tar.zst" }));await pipeline(Body, createWriteStream("/tmp/world.tar.zst"));Одна ловушка: если запросить объект и так и не прочитать и не уничтожить его тело, сокет остаётся занятым в пуле соединений. Сделайте так пятьдесят раз (по умолчанию maxSockets у HTTP-агента SDK равен 50), и каждый следующий запрос зависнет в ожидании свободного сокета. Всегда дочитывайте или уничтожайте Body.
Большие загрузки и потоки в Node#
PutObjectCommand с потоком неизвестной длины падает, потому что одиночному PUT нужен Content-Length. Для файлов неизвестного размера или просто больших используйте Upload из @aws-sdk/lib-storage, который делит входные данные на части multipart:
import { Upload } from "@aws-sdk/lib-storage";import { createReadStream } from "node:fs";const upload = new Upload({ client: s3, params: { Bucket: BUCKET, Key: "backups/world.tar.zst", Body: createReadStream("world.tar.zst") }, queueSize: 4, // parts in flight partSize: 16 * 1024 * 1024, // 16 MB, minimum 5 MB leavePartsOnError: false,});upload.on("httpUploadProgress", (p) => console.log(p.loaded, p.total));await upload.done();У multipart-загрузок есть правила, заложенные в сам API S3: каждая часть, кроме последней, должна быть не меньше 5 MB, а частей в одной загрузке может быть не больше 10 000. С частями по 16 MB это ограничивает один объект примерно 160 GB; для чего-то большего увеличьте partSize. Multipart-загрузка, которую начали и так и не завершили и не отменили, оставляет свои части на сервере, и они занимают место. leavePartsOnError: false отменяет загрузку при ошибке, но процесс, убитый посреди загрузки, убрать за собой не может. Время от времени просматривайте брошенные загрузки через ListMultipartUploadsCommand (или aws s3api list-multipart-uploads) и отменяйте старые - в хранилище без правил жизненного цикла больше никто этого не сделает.
Ключи, метаданные и заголовки#
SDK сохранит что угодно под любым ключом - именно поэтому структура ключей заслуживает пяти минут размышлений до первой загрузки. Несколько привычек избавят от проблем в будущем.
Стройте префиксы по тому, что будете листить или удалять вместе. Если однажды понадобится «всё для пользователя 1042» или «все выгрузки старше марта», вынесите это в начало ключа: users/1042/avatar.png, exports/2026/03/14/orders.csv. Листинг по префиксу дёшев; поиск разбросанных ключей означает листинг всего бакета и фильтрацию в коде.
Никогда не используйте имя файла пользователя как ключ. Два пользователя загрузят photo.jpg, и второй перезапишет первого. К тому же имена файлов приносят с собой пробелы, Unicode, слеши и ... Генерируйте ключ сами - UUID или хеш содержимого плюс правильное расширение, - а исходное имя храните в базе данных или в метаданных объекта, если его нужно показывать.
Делайте ключи удобными для URL. Допустима любая строка UTF-8 длиной до 1024 байт, но ключи попадают в URL, логи и shell-команды. Строчные латинские буквы, цифры, дефисы, подчёркивания, точки и слеши нигде не создают проблем.
Метаданные путешествуют вместе с объектом. Стандартные HTTP-заголовки - ContentType, CacheControl, ContentDisposition - возвращаются всем, кто скачивает объект, а пользовательские метаданные кладутся в Metadata как пары строк и возвращаются заголовками x-amz-meta-*:
s3.upload_file("avatar.png", BUCKET, "users/1042/avatar-3f9c.png", ExtraArgs={ "ContentType": "image/png", "CacheControl": "public, max-age=31536000, immutable", "Metadata": {"original-name": "my photo.png", "uploaded-by": "1042"},})ContentDisposition: attachment; filename="report.pdf" заставляет браузер скачивать файл, а не показывать его, - это то, что нужно для пользовательских файлов, отображению которых вы не доверяете. Долгий CacheControl безопасен, когда ключ меняется при каждом изменении содержимого, - это ещё одна причина добавлять в ключ хеш или версию. Значения подробно разобраны в статье заголовки HTTP-кэширования простыми словами.
Метаданные нельзя отредактировать на месте. Чтобы их изменить, нужно скопировать объект сам в себя с новыми метаданными (copy_object с MetadataDirective="REPLACE"), поэтому задавайте их правильно сразу при загрузке.
Ошибки, которые вы встретите#
| Ошибка | Что значит | Решение |
|---|---|---|
ENOTFOUND bucket.s3.example.com | Стиль virtual-hosted | forcePathStyle: true / addressing_style: path |
SignatureDoesNotMatch на каждом вызове | Неверный секрет или proxy меняет Host | Скопируйте секрет заново; проверьте proxy |
SignatureDoesNotMatch только на загрузках | Новые контрольные суммы по умолчанию | Опции контрольных сумм «когда требуется» |
InvalidAccessKeyId | Этот endpoint не знает ключ | Проверьте, не обращаетесь ли вы случайно к AWS |
NoSuchBucket | Опечатка в имени бакета или не тот стиль | Проверьте имя; проверьте path-style |
RequestTimeTooSkewed | Часы сбиты больше чем на 15 минут | Настройте NTP на клиентской машине |
EPROTO / wrong version number | HTTPS к порту с обычным HTTP | http:// для голого порта или HTTPS-имя хоста |
К InvalidAccessKeyId стоит присмотреться внимательнее. Самая частая причина - не неверный ключ, а отсутствующий endpoint: переменная окружения не загрузилась, SDK переключился на Amazon, а Amazon о вашем ключе никогда не слышал. Пишите в лог итоговый endpoint клиента при старте - и загадка исчезнет.
Когда ошибка неочевидна, включите на один запуск собственное логирование SDK. В boto3 boto3.set_stream_logger("botocore", logging.DEBUG) печатает каждый запрос с URL и заголовками, и сразу видно, такие ли endpoint, стиль и регион, как вы ожидали. В Node передайте logger: console в конструктор S3Client для облегчённого варианта или изучите err.$metadata и err.$response у пойманной ошибки - там HTTP-статус и сырой ответ сервера. Перед продакшеном уберите и то и другое: отладочный вывод содержит заголовки, которым не место в логах.
Второй сюрприз - RequestTimeTooSkewed. Подписанные запросы несут метку времени, и серверы отклоняют те, что расходятся с их собственными часами больше чем на 15 минут. На сервере у хостинга такого почти не бывает, а вот на ноутбуке после сна или в контейнере без синхронизации времени - часто.
Использование с сервера приложения#
Обычно приложение обращается к S3, чтобы держать пользовательские файлы вне собственного диска: загрузки, сгенерированные отчёты, выгрузки. На небольшом сервере это даёт два преимущества: диск приложения остаётся маленьким и быстрым, а повторный deploy или пересборка приложения не трогают файлы.
На RE:NODE части складываются так. Линейки Node.js и Python запускают ваш код; линейка хранилища S3 даёт endpoint с ключом доступа и секретом, созданными для сервера, и уже созданный первый бакет. Укажите endpoint, ключи и бакет в переменных окружения приложения на вкладке Startup, используйте HTTPS-имя хоста из proxy-слота тарифа хранилища как S3_ENDPOINT - и код выше заработает без изменений. Обычный HTTP на порту хранилища подходит для быстрой проверки, но объекты и тела запросов идут по сети без шифрования, поэтому рабочий трафик должен идти через HTTPS-имя.
Используйте отдельный бакет для каждого окружения - скажем, media-dev и media-prod, - выбираемый переменной S3_BUCKET. Набор тестов, который убирает за собой, удаляя префикс, никогда не должен быть в одной опечатке от рабочего бакета. Помните также, что линейка хранилища держит одну копию каждого объекта на NVMe в одной площадке, без репликации: это разумное место для пользовательских файлов, но всё, что нельзя воссоздать, стоит скопировать куда-то ещё.
Когда пользователям нужно загружать файлы прямо из браузера, вообще не пропускайте файл через приложение: выдайте браузеру presigned URL, и пусть он общается с хранилищем сам. Эта схема на обоих языках описана в статье presigned URL для загрузок. А если вы храните не пользовательские файлы, а backup, специальный инструмент подойдёт лучше самописного кода - backup с restic в S3 берёт на себя шифрование, дедупликацию и срок хранения.
FAQ#
Нужен ли в Node полный пакет aws-sdk?
Нет. Версия 3 разбита по сервисам; для базовых операций достаточно @aws-sdk/client-s3, плюс @aws-sdk/lib-storage для multipart-загрузок и @aws-sdk/s3-request-presigner для presigned URL. Старый монолитный aws-sdk v2 больше не поддерживается.
Можно ли использовать один и тот же код с AWS и собственным хранилищем?
Да, если endpoint, регион и флаг path-style берутся из конфигурации. Для AWS оставьте endpoint пустым и path-style выключенным; для своего endpoint задайте их. Сами вызовы API идентичны.
Почему загрузки падают, а скачивание работает?
Чаще всего виноваты контрольные суммы по умолчанию, добавленные в релизах SDK 2025 года. Выставьте опции расчёта контрольных сумм запросов и проверки контрольных сумм ответов в «когда требуется». Если не помогло, сверьте размер объекта с лимитом 5 GB для одиночного PUT и переходите на multipart.
Как создать бакет из кода?
s3.create_bucket(Bucket="name") в boto3 или CreateBucketCommand в Node. В AWS вне us-east-1 нужно также передать ограничение местоположения; S3-совместимое хранилище его обычно игнорирует. Многим приложениям проще, если бакет один раз создан вручную, а код только им пользуется.
Безопасно ли держать секретный ключ в коде?
Нет. Держите его в переменных окружения или в файле секретов вне репозитория. Секрет, попавший в коммит Git, нужно считать опубликованным, даже в приватном репозитории, и заменить.




Комментарии
Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.