RE:NODE

Эксплуатация12 мин чтения

S3 из Node.js и Python: свой endpoint в boto3 и SDK v3

Подключение boto3 и AWS SDK для JavaScript v3 к S3-совместимому endpoint: path-style, регион, загрузки, потоки, листинг, ошибки и настройки контрольных сумм.

0 прочтений

Чтобы работать с 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)Зачем
Endpointendpoint_url=endpoint:Иначе SDK обращается к AWS
Регионregion_name=region:Входит в подпись запроса
Path-styleConfig(s3={'addressing_style': 'path'})forcePathStyle: trueБакет в пути, а не в имени хоста
Учётные данныеaws_access_key_id, aws_secret_access_keycredentials: {...}Или переменные окружения
Контрольные суммы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. Держите конфигурацию в переменных окружения, а не в коде - почему и как, объясняет статья переменные окружения и секреты.

storage.py
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: загрузка, скачивание и листинг#

Операции, которыми приложение пользуется каждый день:

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:

python
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 потоков. На небольшом сервере с малым объёмом памяти параллельность лучше уменьшить, а не увеличить:

python
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 сам запустит его в пуле потоков), либо явно вынесите вызов в поток:

python
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 модульный: устанавливайте только то, чем пользуетесь. Для большинства приложений это два-три пакета.

bash
$ npm install @aws-sdk/client-s3 @aws-sdk/lib-storage @aws-sdk/s3-request-presigner
storage.js
import { 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():

javascript
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:

javascript
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:

javascript
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-*:

python
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-hostedforcePathStyle: true / addressing_style: path
SignatureDoesNotMatch на каждом вызовеНеверный секрет или proxy меняет HostСкопируйте секрет заново; проверьте proxy
SignatureDoesNotMatch только на загрузкахНовые контрольные суммы по умолчаниюОпции контрольных сумм «когда требуется»
InvalidAccessKeyIdЭтот endpoint не знает ключПроверьте, не обращаетесь ли вы случайно к AWS
NoSuchBucketОпечатка в имени бакета или не тот стильПроверьте имя; проверьте path-style
RequestTimeTooSkewedЧасы сбиты больше чем на 15 минутНастройте NTP на клиентской машине
EPROTO / wrong version numberHTTPS к порту с обычным HTTPhttp:// для голого порта или 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. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000