Раньше, чтобы работать с 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 — резервное копирование, репликация, стоимость, мониторинг.