Daily MaverickSPOTLIGHT: Health policy trailblazer Precious Matsoso credits patience, faith and The BeatlesThe Jerusalem PostKing Abdullah’s UNGA warnings on Israel’s actions in Syria reflect wider regional shift - analysisPunchFULL LIST: All-time free-kick scorers, Messi moves to second, Ronaldo ninthBollywood HungamaBigg Boss 20: SHOCKING! Kushal Tanwar, aka Gullu, CONFIRMS he was married, REACTS to wedding picture leak controversy; says, "I got divorced in 2024. It was a very depressing phase"InquirerP500-M needed to finish new 9-storey PUP building – solonUOLAtaque em festa na África do Sul deixa 11 mortosColliderNew Spider-Man Spin-Off Series Officially Announced at MarvelSky TG24Apple Music Hall, a Londra un nuovo spazio per la musica liveسكاي نيوز عربيةما هي "متلازمة الثروة المفاجئة" التي تطارد الأثرياء الجدد؟DeadlineQatar’s Doha Film Festival To Open With Yemeni Female-Focused Drama ‘The Station’Anime News NetworkThe Best Anime of Summer 2026Antara NewsIndonesia explores deployment of Chinese jets to combat forest fires
The Daily Newsstand · Free, Always
Wednesday, September 23, 2026

Семь мест, где «S3-совместимое» хранилище ведёт себя не как AWS

Translate

Я разрабатываю настольный клиент для объектных хранилищ и за это время набрал список мест, где наклейка “S3-совместимый” перестаёт работать. Почти все они выглядят одинаково.

Запрос обычный, на AWS он проходит, а на Ceph RGW или MinIO возвращается ошибка, по тексту которой понять ничего нельзя. Ниже семь таких случаев и то, что пришлось сделать в каждом.

Код на Rust, но сами различия к языку отношения не имеют, на Python или Go вы получите ровно то же самое. Все семь случаев в клиенте закрыты, поэтому дальше речь не о планах, а о рабочих решениях.

1. CRC-заголовки, которых никто не просил

С этого начинаю, потому что ломается вообще всё, вплоть до простой загрузки файла.

Новые версии AWS SDK по умолчанию считают дополнительные контрольные суммы CRC32 и прикладывают их к запросам. В настройках это называется режимом WhenSupported.

Сам AWS такие заголовки, разумеется, понимает. RGW отвечает на них InvalidArgument, причём поле Message в ответе пустое, так что пользователь видит одно слово и никаких подробностей.

Вылечилось переключением режима на WhenRequired, то есть считать суммы только там, где они обязательны по протоколу.

// Ceph / S3-compatible: AWS SDK "latest" defaults (WhenSupported checksums) often
// attach optional CRC headers that RGW rejects with InvalidArgument and an empty Message.
if has_custom_endpoint {
    conf_builder = conf_builder
        .request_checksum_calculation(RequestChecksumCalculation::WhenRequired)
        .response_checksum_validation(ResponseChecksumValidation::WhenRequired);
}

Условие про has_custom_endpoint тут не для красоты. Если в профиле подключения задан свой адрес, значит, это почти наверняка не AWS, и суммы лучше не навязывать.

Для самого AWS поведение SDK остаётся по умолчанию, там эти суммы полезны.

2. Тегов нет, и это не ошибка

У объекта нет тегов. AWS на GetObjectTagging отвечает 200 и пустым TagSet.

RGW в той же ситуации отдаёт 404 с кодом NoSuchTagSet.

Для кода это разные вещи. Пустой список означает, что тегов нет, а 404 обычно означает, что нет самого объекта, и клиент честно показывает пользователю ошибку на объекте, который прекрасно существует.

Поэтому конкретно эту пару приходится перехватывать.

Err(e) => {
    let msg = format!("{}", e);
    // Ceph RGW returns 404 NoSuchTagSet when object has no tags (AWS returns 200 with empty TagSet).
    if msg.contains("404") && msg.contains("NoSuchTagSet") {
        return Ok(vec![]);
    }
    return Err(e);
}

Тот же класс проблем вылезает с настройками бакета, которых у сервера может не быть в принципе. Хороший пример это блокировка публичного доступа.

Часть хранилищ такой метод не реализует и отвечает кто во что горазд, от 501 с текстом “not implemented” до совершенно произвольных кодов. Я в итоге свёл все такие ответы к состоянию “настройка недоступна” и показываю её в интерфейсе серой, иначе на половине хранилищ пользователь видел бы красную ошибку там, где ничего не сломано.

3. 411 на пустом теле

Смена ACL объекта. В AWS достаточно заголовка x-amz-acl и пустого тела запроса.

Документация Ceph описывает вариант с XML в теле, но на практике RGW принимает и заголовок.

Нюанс в том, что пустое тело нужно отправлять явно, вместе с Content-Length: 0. Многие HTTP-клиенты в запросе без тела заголовок длины просто не ставят, и RGW отвечает 411 Length Required.

На AWS тот же самый код работает без нареканий.

4. Необязательный блок XML, который ломает MinIO

Включение журналирования. В документации AWS у структуры BucketLoggingStatus есть необязательный блок TargetObjectKeyFormat с вариантом SimplePrefix.

Слово “необязательный” стоит понимать буквально. Если его отправить, MinIO и ещё несколько серверов ответят MalformedXML, и настройка не применится.

После пары таких находок я стал собирать XML для совместимых хранилищ в самом старом и простом виде, который понимают все, то есть TargetBucket плюс TargetPrefix. Всё, что добавили в API позже, отправляю, только если без этого никак.

Бывает и обратное. У RGW есть запрос POST /{bucket}?logging, который сбрасывает текущий лог-объект, чтобы записи появились сразу, а не когда сервер сам соберётся.

В AWS такого нет, в SDK метода тоже нет, поэтому запрос приходится собирать и подписывать руками.

5. Шифрование, которое требует честного HTTPS

SSE-C, то есть шифрование с ключом, который передаёт клиент, RGW отказывается принимать поверх обычного HTTP и отвечает InvalidRequest.

Самое неприятное в этой ситуации то, что пользователь может подключаться по https://, а до RGW запрос доезжать уже по HTTP, если TLS обрывается на балансировщике. Сервер видит незащищённое соединение и отказывает, а человек смотрит на свой адрес с https и не понимает, чего от него хотят.

У себя я проверяю адрес профиля заранее и отвечаю развёрнуто, с указанием адреса и совета переключить режим шифрования.

/// Ceph RGW rejects SSE over plain HTTP; block client-side SSE headers on http:// endpoints.
if ep.to_ascii_lowercase().starts_with("http://") {
    return Err(CoreError::InvalidInput(format!(
        "SSE client headers require HTTPS (Ceph RGW rejects plain HTTP). Profile endpoint is {ep}."
    )));
}

Из той же области мелочь, на которой легко потерять час. Контрольную сумму MD5 от ключа шифрования SDK за вас не посчитает, хотя и AWS, и RGW её требуют.

6. Подпись и ключи с двумя слэшами

Как только начинаешь собирать запросы руками, вылезает разница в подписи. Для S3 и RGW URI кодируется ровно один раз, а путь не нормализуется.

Если оставить настройки подписи из общей библиотеки как есть, то объекты с // или .. внутри ключа начнут получать SignatureDoesNotMatch, а остальные будут работать нормально, что делает отладку особенно весёлой.

// S3 (and Ceph RGW) require single URI encoding and no path normalization for signature.
let mut signing_settings = SigningSettings::default();
signing_settings.percent_encoding_mode = PercentEncodingMode::Single;
signing_settings.uri_path_normalization_mode = UriPathNormalizationMode::Disabled;

Рядом лежит вечный вопрос path-style против virtual-host. Для своих адресов почти всегда нужен path-style, для AWS наоборот.

Угадать это нельзя, поэтому у меня, как и у большинства клиентов, это настройка профиля подключения, вместе с регионом и tenant для RGW.

7. Multipart, который стоит денег

Размер части ограничен с двух сторон, от 5 MiB до 5 GiB, и частей не может быть больше 10000. Из последнего следует, что значение из настроек нельзя брать как есть.

Для файла в терабайт при части в 8 MiB вы упрётесь в лимит частей на середине загрузки, поэтому размер части надо пересчитывать от размера файла.

/// Размер части multipart: не меньше 5 MiB, не больше 5 GiB и так, чтобы уложиться в 10000 частей.
fn multipart_part_size_for(total: u64, configured: u64) -> u64 {
    let mut part = configured.max(S3_MIN_PART_SIZE);
    let min_for_part_limit = total.saturating_add(S3_MAX_MULTIPART_PARTS - 1) / S3_MAX_MULTIPART_PARTS;
    part = part.max(min_for_part_limit);
    part.min(S3_MAX_PART_SIZE)
}

Вторая часть истории важнее первой. Если передача упала, а AbortMultipartUpload не позвали, залитые части остаются в хранилище и занимают место, за которое идёт оплата.

В обычном списке объектов их не видно, нужен отдельный ListMultipartUploads, и именно поэтому про них почти всегда забывают.

if upload_result.is_err() {
    // Не оставляем осиротевший multipart upload (он тарифицируется).
    let _ = client.abort_multipart_upload()
        .bucket(bucket).key(key).upload_id(&upload_id).send().await;
}

Я сделал в клиенте отдельный экран с незавершёнными загрузками, потому что на рабочих кластерах там регулярно обнаруживается мусор, накопленный чужими скриптами за месяцы. Если вы никогда туда не смотрели, посмотрите, зрелище бывает познавательное.

Что в итоге

Главное, чему меня научили эти семь случаев, это не верить словам “совместимо” и “необязательно”. Совместимость обычно заканчивается там, где начинаются необязательные поля и редкие коды ответов, а документация об этом молчит, потому что каждый производитель описывает своё поведение как правильное.

Ещё одно наблюдение, уже про интерфейс. Ошибку от совместимого хранилища почти всегда стоит дополнять своим контекстом, то есть адресом, способом адресации и режимом шифрования.

Пользователю это экономит вечер, а вам разбор тикета, в котором написано только “не работает”.

Если вы сталкивались с похожим на Ceph RGW или MinIO, расскажите в комментариях. Сейчас я разбираю различия в lifecycle и в правилах хранения версий, это следующая тема, по которой у разных серверов больше всего расхождений.

Все примеры взяты из S3 TM Browser, клиента, который я разрабатываю. Каждый из семи случаев в нём уже закрыт, ссылки есть в профиле.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.