Надо принять от пользователя файл на 200 МБ, не прогоняя его через своё приложение, а потом отдать обратно по ссылке, которая живёт десять минут. Для этого в Java берут AWS SDK v2: у него есть предподписанные ссылки, потоковая загрузка и асинхронный клиент, а старый SDK v1 с блокирующим вводом-выводом остался в унаследованном коде. Разберём, как подключить его к Spring и не наступить на типичные грабли.
Бэкенд подписывает ссылку и заводит строку со статусом 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 собирается из этих сумм.
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.
Что почитать дальше
- Что такое object storage — устройство хранилища: bucket, объект, ключ, классы хранения, версионирование.
- S3 в production — резервное копирование, репликация, стоимость, мониторинг.
- Файлы: в базе данных или в object storage — где держать сам файл и что оставить в таблице.
- AWS из Spring Boot — цепочка получения прав без ключей в коде и тесты на LocalStack.