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).
Что почитать дальше
- Fundamentals — модель S3.
- Operations — backup, replication, costs.
- AWS SDK v2 Developer Guide.
- Distributed Patterns Style Guide — Outbox для атомарных операций.