← назад к разделу

Надо принять от пользователя файл на 200 МБ, не прогоняя его через своё приложение, а потом отдать обратно по ссылке, которая живёт десять минут. Для этого в Java берут AWS SDK v2: у него есть предподписанные ссылки, потоковая загрузка и асинхронный клиент, а старый SDK v1 с блокирующим вводом-выводом остался в унаследованном коде. Разберём, как подключить его к Spring и не наступить на типичные грабли.

PUT файла напрямую — байты не проходят через приложение клиент бэкенд S3 / MinIO метаданные ссылка, 10 минстрока заводится сразу confirm HeadObject users/42doc.pdf таблица documentskey = users/42status = PENDING UPLOADED

Бэкенд подписывает ссылку и заводит строку со статусом PENDING, а байты идут от клиента прямо в хранилище, минуя память и сеть приложения. Подтверждение переводит строку в UPLOADED — но только после HeadObject, который проверяет, что объект действительно появился. Строки, у которых файла так и не случилось, подчищает фоновая задача.

Обязательно

Что добавить в зависимости

dependencies {
    implementation(platform("software.amazon.awssdk:bom:2.x.x"))
    implementation("software.amazon.awssdk:s3")
    implementation("software.amazon.awssdk:s3-transfer-manager")  // для больших файлов
    implementation("software.amazon.awssdk:netty-nio-client")      // async HTTP
}

bom выравнивает версии всех AWS-модулей — версию у каждой зависимости указывать не нужно.

Альтернатива — Spring Cloud AWS: авто-конфигурация и properties из application.yml. Удобен для стандартного AWS; для MinIO и других S3-совместимых серверов проще настроить SDK руками, как показано ниже.

Как настроить S3Client в Spring

Главный объект для работы с S3 — S3Client. Регистрируем его как Spring-бин:

@Configuration
public class S3Config {

    @Bean
    public S3Client s3Client(
            @Value("${aws.s3.region}") String region,
            @Value("${aws.s3.endpoint:#{null}}") String endpoint,
            @Value("${aws.s3.access-key}") String accessKey,
            @Value("${aws.s3.secret-key}") String secretKey) {

        var builder = S3Client.builder()
            .region(Region.of(region))
            .credentialsProvider(StaticCredentialsProvider.create(
                AwsBasicCredentials.create(accessKey, secretKey)));

        if (endpoint != null) {
            builder.endpointOverride(URI.create(endpoint))
                   .forcePathStyle(true);
        }

        return builder.build();
    }

    @Bean
    public S3Presigner s3Presigner(
            @Value("${aws.s3.region}") String region,
            @Value("${aws.s3.endpoint:#{null}}") String endpoint,
            @Value("${aws.s3.access-key}") String accessKey,
            @Value("${aws.s3.secret-key}") String secretKey) {

        var builder = S3Presigner.builder()
            .region(Region.of(region))
            .credentialsProvider(StaticCredentialsProvider.create(
                AwsBasicCredentials.create(accessKey, secretKey)));

        if (endpoint != null) {
            // для MinIO и других S3-совместимых хранилищ: адрес бакета в пути,
            // а не поддоменом — иначе подписанная ссылка не откроется
            builder.endpointOverride(URI.create(endpoint))
                   .serviceConfiguration(S3Configuration.builder()
                       .pathStyleAccessEnabled(true).build());
        }

        return builder.build();
    }
}

Настройки в application.properties:

aws.s3.region=eu-west-1
aws.s3.access-key=${S3_ACCESS_KEY}
aws.s3.secret-key=${S3_SECRET_KEY}

# Для AWS S3 — endpoint не нужен, оставить пустым.
# Для MinIO:
# aws.s3.endpoint=http://minio:9000
# Для Yandex Object Storage:
# aws.s3.endpoint=https://storage.yandexcloud.net
# aws.s3.region=ru-central1
# Для Cloudflare R2:
# aws.s3.endpoint=https://<account-id>.r2.cloudflarestorage.com
# aws.s3.region=auto

forcePathStyle(true) нужен для MinIO: он ожидает URL вида endpoint/bucket/key, тогда как AWS по умолчанию использует bucket.endpoint/key.

В production пару access-key/secret-key обычно не задают: права даёт роль, а SDK находит её сам через DefaultCredentialsProvider — достаточно не передавать credentialsProvider. На EC2 это IAM Instance Profile, в EKS — роль сервис-аккаунта (IRSA).

Загрузка файла

Обычный PUT

Для небольших файлов достаточно putObject:

@Service
@RequiredArgsConstructor
public class AvatarService {

    private final S3Client s3;
    private final String bucket = "user-avatars";

    public void upload(UUID userId, MultipartFile file) throws IOException {
        String key = "users/%s/avatar.jpg".formatted(userId);

        s3.putObject(
            PutObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .contentType(file.getContentType())
                .contentLength(file.getSize())
                .build(),
            RequestBody.fromInputStream(file.getInputStream(), file.getSize())
        );
    }
}

Длину SDK не угадывает: RequestBody.fromInputStream принимает её вторым параметром, и перегрузки без длины у этого метода просто нет. Когда длина заранее неизвестна, остаётся RequestBody.fromContentProvider без указания длины — но он вычитывает поток в память целиком, чтобы её посчитать. RequestBody умеет принимать InputStream, File, Path, byte[] или String.

Большие файлы через TransferManager

Для файлов от нескольких мегабайт удобнее S3TransferManager — он сам режет файл на части и грузит их параллельно (multipart upload):

@Bean
public S3AsyncClient s3AsyncClient(/* те же параметры */) {
    // аналогично S3Client, но через S3AsyncClient.builder()
    // и обязательно .multipartEnabled(true)
}

@Bean
public S3TransferManager transferManager(S3AsyncClient s3Async) {
    return S3TransferManager.builder().s3Client(s3Async).build();
}

@Service
@RequiredArgsConstructor
public class VideoService {

    private final S3TransferManager tm;

    public void upload(Path videoFile, UUID userId) {
        String key = "users/%s/video-%s.mp4".formatted(userId, UUID.randomUUID());

        FileUpload upload = tm.uploadFile(b -> b
            .source(videoFile)
            .putObjectRequest(p -> p.bucket("videos").key(key)));

        upload.completionFuture().join();
    }
}

Многочастность включает сам клиент: multipartEnabled(true) у обычного S3AsyncClient либо CRT-клиент S3AsyncClient.crtBuilder(). Без этого TransferManager отправит файл одним запросом. Порог по умолчанию — 8 MiB, сетевые ошибки он повторяет сам.

Механика многочастной загрузки видна и без S3: файл режется на части, у каждой считается контрольная сумма, и ETag собирается из этих сумм.

файл 20 МиБ режем по 8 МиБ 3 части 8 + 8 + 4 МиБ 3 суммы MD5 по одной на часть ETag md5 склейки и «-3»

ETag многочастного объекта считается от сумм частей, поэтому он не совпадает с md5 самого файла.

живой пример

import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Random;

public class MultipartEtag {

    public static void main(String[] args) throws Exception {
        byte[] file = new byte[20 * 1024 * 1024];
        new Random(42).nextBytes(file);
        int partSize = 8 * 1024 * 1024;

        List<byte[]> parts = new ArrayList<>();
        for (int offset = 0; offset < file.length; offset += partSize) {
            int length = Math.min(partSize, file.length - offset);
            MessageDigest part = MessageDigest.getInstance("MD5");
            part.update(file, offset, length);
            parts.add(part.digest());
            System.out.println("часть " + parts.size() + ": " + length + " байт");
        }

        MessageDigest all = MessageDigest.getInstance("MD5");
        parts.forEach(all::update);
        System.out.println("ETag объекта:      "
            + HexFormat.of().formatHex(all.digest()) + "-" + parts.size());
        System.out.println("md5 файла целиком: "
            + HexFormat.of().formatHex(MessageDigest.getInstance("MD5").digest(file)));
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Хвост -3 — это число частей, и значение не равно md5 файла: сверять целостность по ETag после многочастной загрузки нельзя. Для проверки просят S3 посчитать сумму явно — checksumAlgorithm(ChecksumAlgorithm.SHA256) в запросе.

Скачивание файла

// Небольшой файл целиком в память
public byte[] download(String key) {
    return s3.getObjectAsBytes(
        GetObjectRequest.builder().bucket(bucket).key(key).build()
    ).asByteArray();
}

// Сохранить сразу в файл на диске
public void downloadToFile(String key, Path target) {
    s3.getObject(
        GetObjectRequest.builder().bucket(bucket).key(key).build(),
        target);
}

// Потоковое чтение — важно закрыть InputStream
public void processStream(String key) {
    try (InputStream stream = s3.getObject(
            GetObjectRequest.builder().bucket(bucket).key(key).build())) {
        // читаем stream порциями
    }
}

SDK не закрывает InputStream сам, а незакрытое соединение остаётся «занятым» в пуле — после нескольких таких случаев новые запросы начнут зависать.

Для файлов больше 10 МБ getObjectAsBytes не годится — он держит содержимое в памяти целиком.

Presigned URLs

Иногда файл должен попадать в S3 напрямую от клиента, минуя бэкенд. Для этого сервер генерирует presigned URL — временную подписанную ссылку, по которой браузер или мобильное приложение делает PUT прямо в S3.

@Service
@RequiredArgsConstructor
public class UploadUrlService {

    private final S3Presigner presigner;

    public String generateUploadUrl(UUID userId) {
        String key = "users/%s/avatar.jpg".formatted(userId);

        PutObjectRequest objectRequest = PutObjectRequest.builder()
            .bucket("user-avatars")
            .key(key)
            .contentType("image/jpeg")
            .build();

        PresignedPutObjectRequest presigned = presigner.presignPutObject(p -> p
            .signatureDuration(Duration.ofMinutes(10))
            .putObjectRequest(objectRequest));

        return presigned.url().toString();
    }
}

Клиент получает URL и делает PUT с Content-Type: image/jpeg напрямую в S3. Если добавить другие заголовки или изменить параметры — подпись не совпадёт и S3 вернёт ошибку.

Первым делом хочется дописать в этот запрос contentLength и считать, что размер ограничен. Не выйдет: в подписи это поле означает «ровно столько байт», и файл любого другого размера просто не загрузится. Когда нужно именно «не больше 5 МБ», подписывают не PUT, а форму — POST policy с условием content-length-range.

Аналогично работает presignGetObject — для скачивания приватного файла по временной ссылке.

Пул соединений клиента

У предупреждения про незакрытый поток есть числовая сторона. Синхронный клиент держит пул HTTP-соединений, и по умолчанию их пятьдесят. Каждое незакрытое чтение объекта занимает одно соединение до тех пор, пока его не закроют; пятьдесят таких — и следующий запрос встаёт в очередь, а дальше падает по таймауту получения соединения. Диагностируется это характерно: приложение «зависает на скачивании», хотя хранилище отвечает нормально.

Настраивается пул на построителе клиента:

@Bean
S3Client s3Client(S3Properties props) {
    return S3Client.builder()
            .region(Region.of(props.region()))
            .httpClientBuilder(ApacheHttpClient.builder()
                    .maxConnections(100)
                    .connectionTimeout(Duration.ofSeconds(2))
                    .socketTimeout(Duration.ofSeconds(30)))
            .build();
}

Правило размера то же, что у пула к базе: считают по числу одновременных операций с хранилищем, а не «побольше». И главное — поток закрывают всегда (try-with-resources), а для передачи файла клиенту не тянут его в память целиком.

Как отдавать файл пользователю

Три способа, и выбор между ними — это выбор, кто платит трафиком.

Перенаправление на подписанную ссылку. Приложение проверяет права и отвечает 302 с подписанной ссылкой; байты идут из хранилища прямо клиенту. Самый дешёвый вариант: трафик не проходит через приложение, соединения не занимаются. Минусы: ссылку можно переслать (она живёт минуты), и в журнале приложения не видно, скачал ли клиент файл.

@GetMapping("/documents/{id}")
ResponseEntity<Void> download(@PathVariable UUID id, Principal principal) {
    Document doc = documents.requireAccessible(id, principal.getName());
    URL url = presigner.presignGetObject(r -> r
            .signatureDuration(Duration.ofMinutes(5))
            .getObjectRequest(g -> g.bucket(bucket).key(doc.key())
                    .responseContentDisposition("attachment; filename=\"" + doc.fileName() + "\"")))
            .url();
    return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(url.toString())).build();
}

Обратите внимание на responseContentDisposition: имя файла для пользователя задаётся в подписи, а не в самом объекте — тогда в хранилище можно держать ключи вида documents/<uuid>, а скачиваться будет «Договор №12.pdf».

Проксирование потоком. Приложение читает объект и отдаёт его дальше. Нужно, когда права проверяются на каждый байт (платный доступ), надо считать факт скачивания или подменять содержимое на лету. Цена: трафик и занятое соединение на всё время скачивания — то есть именно тот случай, ради которого настраивают пул. Отдавать надо потоком (StreamingResponseBody или InputStreamResource), никогда не собирая файл в массив байтов.

Через сеть доставки. Для публичных файлов (картинки товаров) правильный ответ — не приложение и не хранилище, а сеть доставки перед бакетом: бакет закрыт от прямого доступа, кеш живёт вечно за счёт неизменяемых ключей, приложение в раздаче не участвует вовсе.

CORS: почему загрузка из браузера не работает

Подписанная ссылка на запись — рекомендуемый путь, и он не работает из браузера, пока на бакете не настроен CORS. Браузер перед запросом с другого источника отправляет предварительный запрос OPTIONS, и если бакет на него не отвечает разрешением, сам PUT даже не уйдёт. В консоли это выглядит как ошибка CORS, в журналах приложения — как тишина, потому что приложение в этом обмене не участвует.

Минимальная настройка на бакете:

[{
  "AllowedOrigins": ["https://app.example.com"],
  "AllowedMethods": ["PUT", "GET", "HEAD"],
  "AllowedHeaders": ["content-type", "content-md5", "x-amz-*"],
  "ExposeHeaders": ["ETag"],
  "MaxAgeSeconds": 3000
}]

ExposeHeaders: ETag обязателен для составной загрузки: браузеру нужен ETag каждой части, чтобы потом собрать список для завершения. И правило, которое экономит часы отладки: набор заголовков в запросе должен совпадать с тем, что попало в подпись, — лишний Content-Type, добавленный библиотекой в браузере, ломает подпись и даёт SignatureDoesNotMatch.

Проверить то, что загрузили

Байты идут мимо приложения — значит, приложение не знает, что именно лежит в бакете, пока не посмотрит. Минимальный набор проверок на шаге подтверждения.

Размер. Ограничение задают дважды: условием в самой подписи (content-length-range), чтобы клиент физически не смог положить больше, и проверкой HeadObject после загрузки.

Настоящий тип содержимого. Клиент подписал image/jpeg и положил что угодно: заголовок — это его слово, а не факт. Тип определяют по первым байтам (Apache Tika или собственная проверка сигнатуры) и сверяют с ожидаемым; несовпадение — отказ и удаление объекта.

Антивирус. Для файлов от пользователей — обязательный шаг между загрузкой и переводом записи в рабочее состояние; до проверки объект лежит в отдельном бакете или под префиксом «на проверке» и недоступен никому, кроме проверяющего.

Контрольная сумма. Клиент передаёт Content-MD5 или контрольную сумму вида x-amz-checksum-*; хранилище проверяет её при записи, а вы сверяете с тем, что ожидали, — так отлавливается «файл загрузился наполовину».

Как тестировать без реального S3

В тестах удобно использовать MinIO — S3-совместимый сервер, который запускается в Docker и ведёт себя как настоящий S3. Testcontainers запускает его прямо из теста:

@SpringBootTest
@Testcontainers
class AvatarServiceTest {

    @Container
    static MinIOContainer minio = new MinIOContainer("minio/minio:RELEASE.2025-04-22T22-12-26Z")
        .withUserName("test")
        .withPassword("testtest");

    @DynamicPropertySource
    static void s3Props(DynamicPropertyRegistry registry) {
        registry.add("aws.s3.endpoint", () -> minio.getS3URL());
        registry.add("aws.s3.access-key", minio::getUserName);
        registry.add("aws.s3.secret-key", minio::getPassword);
        registry.add("aws.s3.region", () -> "us-east-1");
    }

    @Autowired private AvatarService avatarService;
    @Autowired private S3Client s3;

    @BeforeEach
    void createBucket() {
        // повторный createBucket падает с BucketAlreadyOwnedByYou,
        // поэтому сначала смотрим, есть ли бакет
        if (s3.listBuckets().buckets().stream().noneMatch(b -> b.name().equals("user-avatars"))) {
            s3.createBucket(b -> b.bucket("user-avatars"));
        }
    }

    @Test
    void uploads_avatar() throws Exception {
        UUID userId = UUID.randomUUID();
        avatarService.upload(userId, new MockMultipartFile(
            "file", "avatar.jpg", "image/jpeg", "binary".getBytes()));

        var meta = s3.headObject(b -> b
            .bucket("user-avatars")
            .key("users/" + userId + "/avatar.jpg"));
        assertThat(meta.contentLength()).isEqualTo(6);
    }
}

Тест работает без AWS-аккаунта, в CI, изолированно. MinIOContainer доступен в org.testcontainers:minio.

Для локальной разработки MinIO запускается через Docker Compose:

services:
  minio:
    image: minio/minio:RELEASE.2025-04-22T22-12-26Z   # тег фиксируем: latest ломает сборку без предупреждения
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: minio
      MINIO_ROOT_PASSWORD: minio12345
    ports:
      - "9000:9000"   # S3 API
      - "9001:9001"   # Web UI
    volumes:
      - minio-data:/data

volumes:
  minio-data:

После запуска ставим aws.s3.endpoint=http://localhost:9000 — сервис работает с локальным MinIO.

Проблема: «БД + S3» без гарантий

Типичная ситуация: пользователь загружает документ, нужно сохранить файл в S3 и создать запись в базе данных. Проблема — S3 не поддерживает транзакции. Если между PUT в S3 и INSERT в БД случится сбой, появится либо файл без записи в БД, либо запись без файла.

@Transactional здесь не поможет — S3 не участвует в транзакции Spring.

Рекомендованный паттерн: загрузка через presigned URL

Клиент загружает файл напрямую в S3, бэкенд только координирует:

1. Клиент присылает метаданные: { name, size, contentType }
2. Бэкенд создаёт Document(s3Key, status="PENDING"),
   генерирует presigned URL и отдаёт клиенту.
3. Клиент делает PUT по этой ссылке напрямую в S3.
4. Клиент вызывает POST /api/docs/{id}/confirm.
5. Бэкенд проверяет объект через HeadObject, ставит status="UPLOADED".

Каждый шаг атомарен, а строки PENDING без файла фоновая задача чистит раз в N минут.

Паттерн: Outbox для удаления

Когда файл нужно удалить вместе с записью, берут outbox: в одной транзакции удаляем запись и кладём задание в outbox_events, а фоновая задача делает удаление в S3:

@Transactional
public void deleteDocument(UUID id) {
    var doc = docRepo.findById(id).orElseThrow();
    docRepo.delete(doc);
    outboxRepo.save(new OutboxEvent("s3.delete", doc.getS3Key()));
}

@Scheduled(fixedDelay = 5000)
public void processS3Outbox() {
    var events = outboxRepo.fetchUnpublished("s3.delete", 100);
    for (var event : events) {
        s3.deleteObject(b -> b.bucket("docs").key(event.payload()));
        outboxRepo.markPublished(event.id());
    }
}

Упавшее удаление повторится при следующем запуске задачи.

Две оговорки к этому шаблону, без которых он ломается на двух экземплярах сервиса.

fetchUnpublished обязан брать строки под блокировкой с пропуском занятых (FOR UPDATE SKIP LOCKED), иначе два экземпляра выберут одни и те же записи и выполнят работу дважды. Для удаления объекта это безобидно (удаление идемпотентно), а для «отправить письмо» или «списать деньги» — нет, и шаблон копируют именно в такие места.

Само задание по расписанию на нескольких экземплярах тоже запускается на каждом. Если работа идемпотентна и порядок не важен, это допустимо; если нет — нужен внешний замок (ShedLock) или транзакционный advisory-замок вокруг цикла. Подробно оба приёма разобраны в статье про блокировки в PostgreSQL.

Дополнительно: при первом чтении можно пропустить

Глубже: события бакета: как узнать, что файл появилсярасширенное

Когда файл идёт в бакет по presigned URL, байты минуют сервис, и он узнаёт о загрузке только если клиент честно вызовет «я закончил». Клиент может закрыть вкладку между загрузкой и этим вызовом, и в бакете появится объект, о котором база не знает. Единственный надёжный источник правды здесь само хранилище: у бакета есть уведомления о событиях.

Настраивается на бакете: событие s3:ObjectCreated:* (в него входят и Put, и завершение multipart) уходит в очередь SQS, в тему SNS, в функцию Lambda или в EventBridge. Для сервиса на Java правильный адресат это очередь: сервис читает её обычным потребителем, а функция или веб-хук требуют, чтобы сервис был доступен в момент события. MinIO и другие совместимые хранилища умеют то же самое, только адресатом бывает Kafka, веб-хук или очередь по AMQP.

Событие приходит JSON-документом с именем бакета, ключом объекта, его размером и ETag. Три особенности, о которых узнают после первого инцидента. Ключ в событии URL-кодирован: пробел приходит как +, кириллица как проценты, и перед поиском в базе его декодируют. Доставка «хотя бы один раз»: одно событие может прийти дважды, поэтому обработчик идемпотентен, обычно по паре «ключ, ETag». Порядок не гарантирован: событие об удалении может обогнать событие о создании того же ключа, и обработчик перед действием сверяется с хранилищем (headObject), а не верит событию слепо.

Из этого складывается схема, где сервис не зависит от честности клиента: presigned URL с ключом, который сервис сам придумал и записал в базу со статусом «ожидается»; событие из очереди переводит запись в «загружен» и запускает то, что нужно сделать с файлом; всё, что осталось в «ожидается» дольше часа, чистит фоновая задача. Вызов «я закончил» от клиента остаётся, но лишь как ускоритель.

Глубже: производные файлы: превью заранее или по запросурасширенное

Загрузили фотографию товара, а на витрине нужны три размера: миниатюра для списка, средний для карточки, полный по клику. Где и когда их считать, решается один раз и потом дорого меняется.

Первый способ: заранее, по событию загрузки. Обработчик из предыдущего раздела получает событие, скачивает оригинал, делает три размера и кладёт рядом под предсказуемыми ключами: products/42/photo.jpg и thumbs/200/products/42/photo.jpg. Витрина отдаёт готовые файлы, ничего не считая. Минус в том, что между загрузкой и появлением превью проходят секунды, и интерфейс обязан это пережить: показывать заглушку или оригинал. И каждый новый размер требует прогона по всему архиву.

Второй способ: по первому запросу. Отдельный сервис изменения размеров (imgproxy, Thumbor или своя ручка) получает адрес вида /img/200x200/products/42/photo.jpg, считает нужный размер на лету и отдаёт; перед ним стоит CDN, который запоминает ответ. Считается один раз на размер, новые размеры появляются правкой адреса, архив трогать не нужно. Минусы: первый запрос медленный, сервис пересчёта это ещё один компонент под нагрузкой, а адреса с произвольными размерами защищают подписью, иначе чужой скрипт заставит сервис считать тысячи вариантов.

Выбор по двум вопросам. Набор размеров стабилен, а файлов много и они горячие сразу после загрузки (лента, каталог)? Заранее. Размеров много и они меняются, а старые файлы просят редко? По запросу с CDN. Гибрид тоже обычен: миниатюру считают заранее, остальное по запросу. В обоих случаях производные файлы не хранят в базе и не считают источником правды: их можно выкинуть и пересчитать из оригинала, а при смене набора размеров заводят новый префикс (thumbs/v2/...) и переключают адреса, не удаляя старое до выката.

Глубже: когда S3 недоступен: таймауты и повторы клиентарасширенное

Хранилище с «одиннадцатью девятками» долговечности бывает недоступно: сетевая проблема, троттлинг с ответом 503 SlowDown при слишком частых запросах к одному префиксу, регион с инцидентом. Сервис должен решить заранее, что он в этот момент делает, потому что клиент SDK по умолчанию решает за него так, как удобно не всем.

У S3Client из SDK v2 есть стратегия повторов, по умолчанию три попытки с растущей паузой, и она применяется к ошибкам, которые считаются временными: сетевые, 500, 503. Таймауты по умолчанию щедрые, и запрос может висеть десятки секунд. Для сервиса, который обслуживает HTTP-запросы пользователя, это слишком: пул потоков забивается ожиданием бакета. Пределы задают явно:

S3Client s3 = S3Client.builder()
    .overrideConfiguration(c -> c
        .apiCallTimeout(Duration.ofSeconds(10))
        .apiCallAttemptTimeout(Duration.ofSeconds(3))
        .retryStrategy(AwsRetryStrategy.standardRetryStrategy()))
    .build();

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

Дальше про деградацию. Чтение: карточка товара без картинки лучше, чем ошибка 500, поэтому отсутствие превью это пустой блок в ответе, а не исключение наверх. Запись: файл, который не удалось положить в бакет, не теряют, а откладывают: временный файл на диске и запись в таблицу отложенных загрузок, фоновая задача повторяет позже; если файл пришёл по presigned URL, сервису вообще нечего повторять. И третье, что забывают: при инциденте хранилища сервис не должен усугублять его лавиной повторов. Размыкатель цепи (Resilience4j CircuitBreaker вокруг вызовов клиента) после серии ошибок на минуту отвечает отказом сам, не ходя в бакет, о чём подробнее в статье про устойчивость интеграций.

Коротко

  • AWS SDK v2 — стандарт для S3 в Java: неизменяемые построители, готовые повторы и таймауты, при необходимости — асинхронный клиент.
  • S3Client — для обычных операций, S3TransferManager — для больших; многочастность даёт multipartEnabled(true) или CRT-клиент, и ETag такого объекта уже не равен md5 файла.
  • В production права даёт роль (IAM Instance Profile на EC2, IRSA в EKS), а не пара ключей в переменных окружения; forcePathStyle(true) обязателен для MinIO, и S3Presigner требует того же через pathStyleAccessEnabled(true).
  • Presigned URL пускает клиента прямо в хранилище, минуя бэкенд, — им же отдают файл перенаправлением (имя файла задаётся в подписи); загрузка из браузера требует CORS на бакете с ExposeHeaders: ETag, а заголовки запроса должны совпадать с подписанными.
  • S3 нетранзакционен: атомарность «БД + S3» даёт двухфазная загрузка со статусом или outbox (выборка FOR UPDATE SKIP LOCKED, задание по расписанию с внешним замком, если работа не идемпотентна), а проверка без AWS-аккаунта — MinIOContainer.
  • Что файл появился, надёжно сообщает само хранилище: событие ObjectCreated в очередь; ключ в событии URL-кодирован, доставка хотя бы один раз, порядок не гарантирован.
  • Превью считают заранее по событию (стабильный набор размеров) или по первому запросу за CDN (много меняющихся размеров); производные файлы пересчитываемы и живут под версионным префиксом.
  • Клиенту S3 задают apiCallTimeout и apiCallAttemptTimeout, повторы только для временных ошибок; чтение деградирует до ответа без файла, запись откладывается в очередь, лавину повторов гасит размыкатель.
  • Проксируют файл потоком только когда нужны права на каждый байт, публичное отдают через сеть доставки; InputStream после getObject закрывают всегда, а пул соединений клиента (около пятидесяти) иначе кончается и скачивания зависают.
  • Загруженное проверяют: размер условием в подписи и HeadObject, настоящий тип по первым байтам, антивирус до перевода в рабочее состояние, контрольную сумму при записи.

Что пощупать

Подписанные ссылки из AWS SDK v2 в реальном сервисе практикума remodov/marketplace-system собираются без S3Client вообще: для выдачи ссылки нужен только S3Presigner, и он не ходит в сеть. Хранилище на стенде — MinIO из infra/compose.yaml, тот же протокол, тот же SDK.

Код: image.

Сделаем сами

Ветка step-12-tokens-and-files.

Что почитать дальше