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

Раньше, чтобы работать с S3 из Java, использовали AWS SDK v1 — громоздкий, с блокирующим I/O и неудобным API. Сейчас стандарт — AWS SDK v2 (выпущен в 2018): немного другие зависимости, асинхронный HTTP-клиент, immutable-builders, нормальная поддержка modern Java. Разберём, как подключить его к Spring и не наступить на типичные грабли.

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

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, интеграция с Spring. Удобен для стандартного 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) {
            builder.endpointOverride(URI.create(endpoint));
        }

        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. Для настоящего AWS S3 можно оставить дефолт.

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

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

Обычный 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())
        );
    }
}

contentLength указывать обязательно — иначе SDK буферизует весь файл в память, чтобы посчитать размер. RequestBody умеет принимать InputStream, File, Path, byte[] или String.

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

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

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

@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();
    }
}

TransferManager сам решает, когда переходить на multipart (по умолчанию — от 8 MB), и повторяет попытки при сетевых ошибках.

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

// Небольшой файл целиком в память
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 порциями
    }
}

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

Для файлов больше 10 MB не стоит использовать 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")
            .contentLength(5 * 1024 * 1024L)
            .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 вернёт ошибку.

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

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

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

@SpringBootTest
@Testcontainers
class AvatarServiceTest {

    @Container
    static MinIOContainer minio = new MinIOContainer("minio/minio:latest")
        .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() {
        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: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 по presigned URL напрямую в S3.
4. Клиент вызывает POST /api/docs/{id}/confirm.
5. Бэкенд проверяет файл через HeadObject, обновляет status="UPLOADED".

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

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

Когда нужно удалить файл из S3 вместе с записью из БД — используют 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());
    }
}

Если S3-удаление упало — оно повторится при следующем запуске задачи, пока не выполнится успешно.

Коротко

  • AWS SDK v2 — стандарт для S3 в Java: non-blocking HTTP, immutable builders, virtual threads.
  • S3Client — для обычных операций; S3TransferManager — для больших файлов с автоматическим multipart.
  • forcePathStyle(true) обязателен для MinIO и других S3-совместимых серверов.
  • В production credentials берутся из IAM Instance Profile, не из env-переменных.
  • Presigned URLs позволяют клиенту загружать файлы напрямую в S3, минуя бэкенд.
  • InputStream после getObject нужно закрывать вручную — иначе утечка соединений.
  • Для тестов — MinIOContainer из Testcontainers: полноценный S3 без AWS-аккаунта.
  • S3 нетранзакционен: атомарность «БД + S3» достигается через presigned URL или outbox.

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

  • S3 fundamentals — устройство хранилища: bucket, object, key, storage classes, versioning.
  • S3 operations — резервное копирование, репликация, стоимость, мониторинг.