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

AWS SDK v2 — современный (с 2018) клиент для AWS-сервисов на Java. По сравнению с v1: non-blocking I/O через Netty, immutable builders, поддержка virtual threads, лучшая интеграция со Spring. Для S3 работа идёт через S3Client (sync) или S3AsyncClient (reactive). Эта статья — про реальный код, тесты и production-паттерны.

Подключение

dependencies {
    implementation(platform("software.amazon.awssdk:bom:2.x.x"))
    implementation("software.amazon.awssdk:s3")
    implementation("software.amazon.awssdk:s3-transfer-manager")  // для multipart
    implementation("software.amazon.awssdk:netty-nio-client")
}

Spring Boot AWS-starter существует (Spring Cloud AWS) — он удобен для типичных кейсов, но для нестандартных конфигов (MinIO, custom endpoint) бывает проще писать руками. Покажу оба пути.

Базовая конфигурация — S3Client

@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) {
            // MinIO, Yandex Object Storage, R2 — задаётся endpoint
            builder.endpointOverride(URI.create(endpoint))
                   .forcePathStyle(true);   // MinIO требует path-style addressing
        }

        return builder.build();
    }

    @Bean
    public S3Presigner s3Presigner(/* те же параметры */) {
        // отдельный клиент для генерации presigned URLs
    }
}
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 (использует endpoint/bucket/key вместо bucket.endpoint/key). Для AWS S3 можно оставить дефолт (false), хотя path-style до сих пор работает.

В production credentials берутся через AWS IAM Instance Profile (на EC2/EKS), не через access-key в env. SDK сам подтянет через DefaultCredentialsProvider, если не задать явно.

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

Простой PUT

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

RequestBody принимает InputStream, File, Path, ByteBuffer, String. Указать contentLength обязательно — иначе SDK буферизует весь файл в память для подсчёта.

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

@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 или regular PUT, parallelism, retry на сетевых ошибках. Для файлов от 8 MB (порог по умолчанию) — multipart.

Streaming-загрузка без буферизации

Для очень больших файлов (несколько GB) не хотим держать в памяти. Стримим напрямую в S3:

public void streamUpload(InputStream source, long size, String key) {
    s3.putObject(
        PutObjectRequest.builder().bucket(bucket).key(key).build(),
        RequestBody.fromInputStream(source, size)
    );
}

SDK читает из InputStream и пишет в S3 chunk'ами, в памяти держится только небольшой буфер.

Скачивание

public byte[] download(String key) {
    ResponseBytes<GetObjectResponse> resp = s3.getObjectAsBytes(
        GetObjectRequest.builder().bucket(bucket).key(key).build());
    return resp.asByteArray();
}

public void downloadToFile(String key, Path target) {
    s3.getObject(
        GetObjectRequest.builder().bucket(bucket).key(key).build(),
        target);
}

public InputStream stream(String key) {
    return s3.getObject(GetObjectRequest.builder().bucket(bucket).key(key).build());
    // важно: закрыть InputStream после использования, иначе утечка соединения
}

Presigned URLs

@Service
@RequiredArgsConstructor
public class UploadUrlService {

    private final S3Presigner presigner;

    public PresignedUploadUrl forUserAvatar(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)   // лимит 5 MB
            .build();

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

        return new PresignedUploadUrl(presigned.url().toString(), key);
    }
}

Клиент потом делает PUT presignedUrl с Content-Type: image/jpeg. Любые другие заголовки или меняемые параметры → ошибка подписи.

Для скачивания — presignGetObject. Полезно для шеринга приватного контента с TTL.

MinIO для dev и Testcontainers

В dev и интеграционных тестах вместо реального S3 — MinIO, S3-совместимый сервер. Запускается одной командой Docker, идентичен по API.

Docker Compose для dev

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
aws.s3.access-key=minio
aws.s3.secret-key=minio12345

Testcontainers

@SpringBootTest
@Testcontainers
class S3IntegrationTest {

    @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;

    @Test
    void uploads_avatar() throws Exception {
        // создать bucket в setup
        s3.createBucket(b -> b.bucket("user-avatars"));

        UUID userId = UUID.randomUUID();
        avatarService.upload(userId, new MockMultipartFile(
            "file", "avatar.jpg", "image/jpeg", "binary".getBytes()));

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

Тесты теперь без зависимости от реального S3, без AWS-аккаунта в CI.

Outbox для атомарных операций «БД + S3»

Типичная задача: пользователь грузит документ, в БД появляется запись Document(id, name, s3Key, status="UPLOADED"), в S3 — файл. Если backend упал между PUT в S3 и INSERT в БД — файл-сирота в S3, или запись без файла в БД.

Наивно через @Transactional это не решается: S3 не транзакционный.

Решения:

1. Сначала S3, потом БД, очистка сирот по lifecycle

@Transactional
public Document upload(MultipartFile file) {
    String s3Key = "docs/" + UUID.randomUUID();
    s3.putObject(b -> b.bucket("docs").key(s3Key)...);
    return docRepo.save(new Document(s3Key, file.getName()));
}

Если транзакция БД упала после успешного S3-PUT — в S3 файл-сирота. Решение: lifecycle rule «удалять файлы с префиксом tmp/, которым >7 дней» + загружать в tmp/ и через listener после commit'а переименовывать.

2. Через presigned URL — клиент пишет напрямую

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

S3 атомарна в этой схеме — либо файл там, либо нет. БД-операции делаются последовательно с гарантиями БД. Stale PENDING-записи без файла — чистятся background-job'ом.

Это рекомендованный паттерн для загрузки пользовательского контента в UCP-сервисах.

3. Outbox для S3-операций

Когда нужна гарантированная асинхронная операция «удалить файл из 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 разнесены, но связка через outbox даёт гарантию: либо БД-запись осталась и в outbox ничего нет, либо БД-записи нет и в S3-файл будет удалён (с retry до успеха).

Ловушки

1. Не закрытый InputStream после GetObject

InputStream is = s3.getObject(req).get();
// читаем что-то
// is.close() пропустили

SDK не закрывает stream автоматически. Connection лежит в пуле в «занятом» состоянии. После нескольких таких — пул исчерпан, новые запросы блокируются.

Всегда try-with-resources или явный close().

2. Buffer вместо stream при больших файлах

byte[] content = s3.getObjectAsBytes(req).asByteArray();  // OOM на больших файлах

Для всего, что >10 MB — getObject со стримом, обрабатывать chunk'ами.

3. Region mismatch

S3-клиент знает регион bucket'а из конфигурации. Если bucket в eu-west-1, а клиент сконфигурирован на us-east-1 — запрос редиректится с лишним round-trip'ом. С 2024 AWS бьёт ошибкой 301 (зависит от настроек).

Решение: всегда явно указывать регион в конфиге, и держать all-bucket в одном регионе для сервиса.

4. Дорогие операции listing на больших bucket'ах

listObjects возвращает до 1000 объектов за запрос. На bucket с миллионами объектов — десятки тысяч запросов и плата за каждый. Лучше:

  • Использовать префиксы, чтобы листинг был узким.
  • Хранить инвентарь в БД, S3 — только для blob-данных.
  • Использовать S3 Inventory (ежедневный отчёт .csv.gz со всеми объектами bucket'а).

5. @Transactional поверх S3-операции

@Transactional
public void upload(...) {
    s3.putObject(...);   // НЕ откатится при rollback БД
    docRepo.save(...);
}

S3-операции не транзакционные. Если порядок «сначала S3, потом БД», и БД упала — файл осиротел. Использовать паттерны выше (presigned URL или outbox).

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