Допустим, ваше приложение на Spring Boot должно отправить файл в облачное хранилище, прочитать сообщение из очереди или достать пароль из хранилища секретов AWS. Как это сделать из кода? Хорошая новость: один и тот же код будет работать и на вашем ноутбуке, и на сервере в облаке — без единой правки. Чтобы это получилось, нужно понять три вещи: SDK (библиотеку для разговора с AWS), credentials chain (как код узнаёт, под чьими правами он работает) и LocalStack (как тестировать всё это локально, не трогая настоящее облако).
Эта статья — про интеграцию именно из Spring Boot, поэтому примеры будут на Java. Но сами идеи (SDK, цепочка получения прав, локальная имитация) одинаковы для любого языка — у AWS есть SDK для Python, Node.js, Go и других.
SDK идёт по цепочке сверху вниз и берёт первый найденный источник. Поэтому один и тот же код на ноутбуке работает под профилем, а в кластере под ролью пода, и поэтому забытая переменная окружения перебивает настроенный профиль.
SDK v2 и клиенты как бины
SDK (Software Development Kit) — это библиотека от AWS, через которую ваш код общается с облаком. Вместо того чтобы вручную собирать HTTP-запросы к API, вы вызываете методы вроде s3Client.putObject(...), а SDK сам формирует запрос, подписывает его и отправляет.
Важная деталь: у AWS SDK для Java два поколения. Старое (v1, пакеты начинаются с com.amazonaws) считается устаревшим — новые проекты на нём не пишут. Актуальное — v2, его пакеты начинаются с software.amazon.awssdk. Дальше речь только про v2.
Для каждого сервиса AWS — свой клиент: S3Client для хранилища, SqsClient для очередей и так далее. Эти клиенты потокобезопасны (их можно безопасно использовать из нескольких потоков одновременно) и тяжеловесны при создании, поэтому создавать их нужно один раз и переиспользовать. В Spring это означает: оформить клиент как бин.
@Configuration
public class AwsConfig {
@Bean
SqsClient sqsClient() {
return SqsClient.builder().build();
}
@Bean
S3Client s3Client() {
return S3Client.builder()
.overrideConfiguration(c -> c.apiCallTimeout(Duration.ofSeconds(10)))
.build();
}
}
Обратите внимание: региона в коде нет. И это намеренно — регион SDK находит сам, по тому же принципу, что и права: смотрит переменную AWS_REGION, профиль на ноутбуке, метаданные машины в облаке. Если прописать eu-central-1 руками, обещание «код один и тот же» ломается сразу же: на стенде во Франкфурте и в бою в Ирландии понадобятся разные сборки.
Через overrideConfiguration настраиваются таймауты и встроенные повторы запросов (retry) — в примере выше так задан предел на весь вызов. По умолчанию SDK сам повторяет неудачные запросы с нарастающей паузой между попытками: режимов несколько (standard, adaptive, legacy), работает standard, и на запрос отводится три попытки всего. Если поверх этого добавить ещё свой механизм повторов, получится «повтор поверх повтора» — при сбое сервиса это превращается в лавину запросов. Поэтому retry настраивают либо в SDK, либо свой — но не оба сразу.
Credentials chain: откуда берутся права доступа
Посмотрите на код выше ещё раз. Где там логин и пароль для AWS? Их нет — и это не упущение, а главная идея. Credentials chain (цепочка получения учётных данных) — это механизм, по которому SDK сам ищет, под чьими правами работать, проверяя источники по порядку, пока не найдёт первый подходящий.
Аналогия: вы приходите в офис и прикладываете пропуск к турникету. Вы не несёте с собой сейф с правами — система сама знает, что вам можно. Credentials chain работает похоже: код не хранит ключи, он просто «прикладывает пропуск», который ему выдаёт окружение.
Порядок, в котором SDK v2 ищет права (упрощённо):
- Java System Properties —
aws.accessKeyIdиaws.secretAccessKey. - Переменные окружения —
AWS_ACCESS_KEY_IDиAWS_SECRET_ACCESS_KEY. - Web identity token — так роль выдаётся приложению в Kubernetes/EKS (это называется IRSA — роль, привязанная к ServiceAccount).
- Профиль из файла
~/.aws/credentials— то, что вы настраиваете на своём ноутбуке черезaws configureили SSO. - Роль контейнера (ECS task role) — если задана переменная
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI. - Роль инстанса (EC2) — права запрашиваются у служебного сервиса метаданных машины.
Эффект мощный: один и тот же код на ноутбуке работает под вашим личным профилем, в Kubernetes — под ролью пода, на EC2 — под ролью машины. Меняется не код, а окружение.
Отсюда же самая обидная ловушка локальной отладки. Переменные окружения стоят в списке выше профиля, поэтому забытый AWS_ACCESS_KEY_ID в ~/.zshrc перебьёт настроенный ~/.aws/credentials — и приложение молча пойдёт в другой аккаунт. Если что-то «ходит не туда», первым делом смотрите переменные окружения, а не профиль. А когда нужно взять права в обход цепочки, их передают явно — через credentialsProvider(...) у builder'а.
Самая частая ошибка — прописать ключи прямо в application.yml:
aws:
accessKeyId: AKIA...
secretKey: wJalr...
Так делать нельзя. Ключи попадут в git, в логи, в копии конфигов; их никто не ротирует (не меняет периодически), а перенос сервиса в другое окружение становится мукой. Если в репозитории встречается строка вроде aws.secret — считайте, что утечка уже произошла. Права отдавайте credentials chain, ключи в коде не держите никогда.
Клиент надо закрывать — и обычно это делают за вас
У клиента SDK внутри пул соединений и поток-исполнитель, поэтому у него есть метод close(). Если клиент не закрыть, при остановке приложения остаются висящие соединения, а в тестах — растущее число потоков от прогона к прогону.
Хорошая новость: бин Spring закрывается сам. Контейнер при уничтожении бина ищет у него метод close (или shutdown) и вызывает его — отдельной настройки для этого не нужно.
@Bean
S3Client s3Client() {
return S3Client.builder().region(Region.EU_CENTRAL_1).build(); // закроется сам
}
Плохая новость: это работает только для бинов. Клиент, созданный руками внутри метода, в статическом поле утилитного класса или в тесте, никто не закроет:
// так делать не надо: клиент на каждый вызов и никто его не закрывает
public void upload(byte[] data) {
S3Client client = S3Client.create();
client.putObject(...);
}
Здесь две ошибки сразу: новый клиент на каждый вызов (десятки TLS-рукопожатий и лишняя память) и утечка ресурсов. Правильно — один бин на сервис; если клиент действительно нужен разово, его берут в блок try (var client = S3Client.create()).
В тестах то же правило: клиент к имитации сервисов создают один раз на класс тестов и закрывают в методе после всех тестов, иначе прогон из сотни тестов оставит сотню пулов.
Spring Cloud AWS: меньше шаблонного кода
Поверх SDK существует Spring Cloud AWS (проект awspring) — надстройка, которая берёт на себя рутину: создаёт клиентов из настроек в application.yml и даёт удобные высокоуровневые инструменты.
Сначала про версию, потому что на этом спотыкаются в первый же час. У проекта две живые ветки, и они несовместимы по настройкам: третья ветка (3.x) — под Spring Boot 3, вторая (2.x) — под Boot 2. Различаются в том числе префиксы свойств: в третьей это spring.cloud.aws.*, во второй — cloud.aws.*. Руководства в интернете чаще описывают вторую, и типичный симптом — «свойства не подхватываются, регион не определяется», хотя написано всё по инструкции. Поэтому версию берут по своей версии Boot, а примеры сверяют с документацией этой ветки:
dependencyManagement {
imports { mavenBom("io.awspring.cloud:spring-cloud-aws-dependencies:3.4.0") }
}
``` Самое полезное — слушатель очереди SQS в привычном Spring-стиле, как обработчик сообщений:
```java
@Component
public class OrderEventsSqsConsumer {
@SqsListener("order-events")
public void handle(OrderEventPayload payload) {
orderService.register(payload);
}
}
Аннотация @SqsListener сама опрашивает очередь, превращает JSON-сообщение в объект и удаляет сообщение после успешной обработки. От вас требуется одно — сделать обработчик идемпотентным, то есть безопасным к повторной обработке одного и того же сообщения. Это важно, потому что SQS гарантирует доставку «хотя бы один раз» (at-least-once): одно сообщение может прийти дважды, и ваш код не должен от этого ломаться (например, создавать дубль заказа).
А что происходит, когда обработчик бросил исключение. Сообщение не удаляется — и это правильно, но дальше цепочка событий такая: сообщение остаётся невидимым до конца времени видимости (по умолчанию 30 секунд), потом снова становится доступным, и его получает любой экземпляр приложения. Счётчик получений (ApproximateReceiveCount) растёт с каждой попыткой.
Останавливает этот круг только очередь недоставленных (DLQ). Настраивается она не в приложении, а у самой очереди: указывают целевую очередь и число попыток, после которого сообщение переезжает туда.
{ "deadLetterTargetArn": "arn:aws:sqs:eu-central-1:123456789012:order-events-dlq",
"maxReceiveCount": "5" }
Без этой настройки «отравленное» сообщение — то, на котором обработчик падает всегда, — будет ходить по кругу до истечения срока хранения (по умолчанию 4 дня, максимум 14), тратя вызовы и место в журнале.
Три настройки слушателя, которые стоит задать осознанно:
spring:
cloud:
aws:
sqs:
listener:
max-messages-per-poll: 10 # сколько сообщений забирать за раз
max-concurrent-messages: 10 # сколько обрабатывать параллельно
poll-timeout: 20s # длинный опрос вместо частых пустых запросов
Длинный опрос (poll-timeout около 20 секунд) — первое, что включают: без него приложение опрашивает очередь непрерывно и платит за пустые ответы круглосуточно.
Число сообщений за опрос и параллельность подбирают под длительность обработки: десять сообщений по три секунды — это тридцать секунд работы, и они должны укладываться во время видимости.
Долгий обработчик — отдельный случай. Если обработка занимает больше времени видимости, сообщение выдадут второму экземпляру ещё до того, как первый закончит, и работа выполнится дважды. Два выхода: увеличить время видимости у очереди (правило — вшестеро больше обычной длительности обработки) или продлевать видимость по ходу работы — Spring Cloud AWS умеет это через параметр Visibility в методе обработчика, который просит очередь дать ещё времени.
Вторая полезная вещь — подгрузка секретов и настроек из AWS прямо как обычных свойств Spring. Достаточно одной строки в конфиге:
spring:
config:
import: aws-secretsmanager:/prod/order-service/
После этого пароли к базе и прочие секреты живут только в AWS Secrets Manager, а приложение получает их при старте. В коде вы обращаетесь к ним как к обычным @Value-свойствам — и нигде не храните сами значения.
И сразу важная оговорка, которая стыкует это с обещанием «смена пароля без простоя». Значения, подтянутые через spring.config.import, читаются один раз при старте и дальше живут в памяти приложения. Сменили пароль в хранилище секретов — приложение продолжает работать со старым, пока его не перезапустят. Само по себе это не катастрофа (смена пароля базы обычно и делается с двумя действующими значениями), но полагаться на «поменяли — подхватилось» нельзя.
Что с этим делают, по возрастанию сложности:
- Перезапуск после смены — самый честный вариант для обычного сервиса: смена секретов происходит редко, а выкат и так автоматизирован.
- Запрос значения в момент использования. Для пароля базы это неприменимо (пул уже создан), а вот для ключа к внешнему API — вполне: клиент запрашивает секрет у SDK перед обращением, а SDK кэширует его на несколько минут. У AWS для этого есть отдельная библиотека кэширования секретов; без кэша каждый вызов будет платным обращением к хранилищу.
- Обновление настроек на ходу (
@RefreshScopeплюс обновление контекста) — работает, но требует, чтобы бины умели пересоздаваться; для пула соединений это отдельная работа. - Смена с перекрытием. Правильный ответ для пароля базы: у базы одновременно действуют два пароля (или две учётные записи), сначала выкатывают приложение с новым, потом отзывают старый. Тогда перезапуск перестаёт быть проблемой вовсе.
Подробнее про секреты, их ротацию и метрики — в статье про безопасность и наблюдаемость.
Тестирование через LocalStack
Как протестировать код, который ходит в AWS, не платя за настоящее облако и не завися от интернета? Ответ — LocalStack: программа, которая запускается у вас локально (в Docker) и притворяется набором сервисов AWS. Ваш код думает, что говорит с настоящим S3 или SQS, а на самом деле — с локальной имитацией.
Связка LocalStack + Testcontainers (библиотека, которая поднимает Docker-контейнеры прямо из теста и гасит их после) делает интеграционный тест против «AWS» таким же простым, как тест против локальной базы данных:
@Testcontainers
class OrderEventsSqsConsumerTest {
@Container
static LocalStackContainer localstack =
new LocalStackContainer(DockerImageName.parse("localstack/localstack:2026.08.0"));
@DynamicPropertySource
static void aws(DynamicPropertyRegistry registry) {
registry.add("spring.cloud.aws.endpoint", () -> localstack.getEndpoint().toString());
registry.add("spring.cloud.aws.region.static", localstack::getRegion);
}
@Test
void processesOrderEvent() {
sendToQueue("order-events", paidOrderPayload());
await().untilAsserted(() -> assertThat(repository.findEvents()).hasSize(1));
}
}
Ключевая строка — spring.cloud.aws.endpoint: она говорит Spring Cloud AWS обращаться не к реальному облаку, а к адресу LocalStack. Контейнер сам выбирает свободный порт, поэтому адрес берём из localstack.getEndpoint().
await() здесь — из библиотеки Awaitility: она опрашивает условие, пока оно не выполнится, вместо Thread.sleep. А sendToQueue — вспомогательный метод теста, который кладёт сообщение через тот же SqsClient, что и приложение.
Две мелочи про сам контейнер. Версию образа фиксируют явно, а не берут latest, иначе тест однажды поедет на другом наборе поведения; с весны 2026 у LocalStack теги по дате выпуска, вида 2026.08.0. И перечислять сервисы через withServices(...) больше не нужно — свежий образ поднимает их по требованию, а сам метод помечен устаревшим.
Важно понимать границы: LocalStack хорошо имитирует поведение сервисов (очереди принимают и отдают сообщения, бакеты хранят файлы), но не проверяет права доступа (IAM-политики) и не воспроизводит лимиты и тарифы настоящего AWS. Поэтому правильное распределение проверок такое: чистую бизнес-логику тестируем вообще без AWS (через подмену зависимости заглушкой), интеграцию с облачными сервисами — на LocalStack, а реальные права и сеть — уже на тестовом стенде в настоящем облаке.
Типичные ошибки
| Ошибка | Чем кончается | Что делать |
|---|---|---|
Ключи в application.yml или git | Утечка, ручная ротация, инцидент | Полагаться на credentials chain: IRSA, task role, SSO |
| SDK v1 в новом коде | Две библиотеки в одном проекте, старые баги | Только software.amazon.awssdk (v2) |
| Свой retry поверх retry SDK | Лавина повторов при сбое | Настроить retry в SDK (по умолчанию режим standard, три попытки), свой — выключить |
| Тесты против настоящего AWS | Медленно, дорого, нестабильно, нужен интернет | Заглушка для логики, LocalStack для интеграции |
| Короткий опрос SQS | Оплата пустых ответов круглосуточно | Long polling, waitTimeSeconds: 20 |
| Новый клиент на каждый вызов | Сотни TLS-рукопожатий, утечка ресурсов | Один клиент-бин на сервис |
Где это применяется
Связка «SDK + credentials chain + LocalStack» — это базовый каркас любого приложения, которое живёт в AWS: микросервис, читающий заказы из очереди; backend, складывающий загруженные пользователем файлы в хранилище; сервис, который при старте достаёт пароли из Secrets Manager. Везде один и тот же приём: код не знает про ключи, права приходят из окружения, а локальные тесты идут через имитацию.
Самые частые грабли: захардкоженные ключи доступа (главная причина утечек), создание нового клиента на каждый запрос (исчерпание ресурсов под нагрузкой), забытая идемпотентность обработчика очереди (дубли заказов и платежей при повторной доставке) и попытка тестировать прямо против настоящего AWS (флаки-тесты и неожиданный счёт).
- Fundamentals — как устроены аккаунты, регионы и базовые сервисы.
- IAM — роли, политики и то, откуда credentials chain берёт права.
- Безопасность и наблюдаемость — Secrets Manager и метрики CloudWatch из кода.
- Работа с объектным хранилищем — загрузка файлов, временные ссылки, multipart.
- DynamoDB — если из приложения нужна управляемая NoSQL-база.
Глубже: асинхронные клиенты и постраничные выборкирасширенное
Два инструмента SDK, которых обычно не хватает как раз там, где начинаются деньги и время.
Постраничная выборка. Почти все операции со списками возвращают данные страницами, и ручной цикл по маркеру продолжения — то место, где чаще всего появляется ошибка. У SDK v2 для этого есть готовые обёртки, которые сами ходят по страницам:
s3Client.listObjectsV2Paginator(r -> r.bucket("orders").prefix("2026/09/"))
.contents()
.forEach(object -> process(object.key()));
Это один вызов вместо цикла, и он же решает главную ошибку: выбирать список без префикса и фильтровать в коде. Запрос списка в хранилище объектов платится за каждую тысячу ключей, и просмотр миллиона объектов ради сотни нужных — это счёт за просмотр миллиона. Префикс и правильная раскладка ключей (год, месяц, день) превращают это в сотню.
Асинхронные клиенты. У каждого сервиса есть асинхронная версия (S3AsyncClient, SqsAsyncClient), которая возвращает CompletableFuture и не занимает поток на время ожидания. Брать их стоит в двух случаях: когда нужно выполнить десятки независимых обращений параллельно (загрузка пачки файлов, опрос нескольких очередей) и когда приложение построено на реактивном стеке, где блокирующий вызов недопустим.
Отдельно стоит клиент хранилища на основе CRT (S3CrtAsyncClient) — реализация транспорта на C с многопоточной загрузкой частями. Для больших файлов (от сотен мегабайт) он быстрее обычного в разы, сам разбивает файл на части и умеет докачку. Если сервис гоняет большие файлы, это самая дешёвая оптимизация из существующих: замена типа клиента без изменения логики.
И правило, которое экономит больше всего: не пишите свой параллелизм поверх синхронного клиента. Пул потоков, в котором каждый поток блокируется на сети, — это худший из вариантов и по памяти, и по управляемости; для этого и существуют асинхронные клиенты и менеджер передачи файлов (S3TransferManager), который сам делает параллельную загрузку и выгрузку.
Глубже: цена вызововрасширенное
В облаке привычки, безобидные на своём сервере, стоят денег: каждый запрос к AWS — строчка в счёте. Два места, где это заметнее всего:
- Запросы к S3 в цикле по одному объекту — лучше пакетами или листингом по префиксу, иначе каждый объект превращается в отдельный платный запрос.
- Трафик через NAT — обращения к AWS-сервисам наружу тарифицируются; чтобы их убрать, есть VPC endpoints (см. networking).
Порядок цен, чтобы это были не абстрактные рассуждения (ориентиры для европейских регионов, точные значения смотрят в калькуляторе):
| За что | Порядок цены |
|---|---|
| Запись объекта или запрос списка в хранилище | около 5 долларов за миллион запросов |
| Чтение объекта из хранилища | около 40 центов за миллион запросов |
| Хранение в хранилище объектов | порядка 2,3 цента за гигабайт в месяц |
| Запросы к очереди | около 40 центов за миллион |
| Обращение к хранилищу секретов | около 5 центов за 10 тысяч запросов, плюс 40 центов в месяц за сам секрет |
| Трафик через преобразование адресов (NAT) | около 4,5 цента за гигабайт плюс почасовая плата за шлюз |
| Исходящий трафик в интернет | порядка 9 центов за гигабайт |
Что из этой таблицы важнее всего: запись и список стоят в десять раз дороже чтения, а трафик через NAT — самая дорогая строка на гигабайт. Отсюда два практических вывода: цикл, который для каждого объекта делает запрос списка, стоит в десятки раз дороже, чем кажется; а обращения к хранилищу объектов из закрытой подсети обязательно заворачивают через бесплатную точку доступа, иначе каждый скачанный гигабайт оплачивается дважды — и как трафик хранилища, и как обработка на шлюзе.
Это не преждевременная оптимизация, а гигиена: цену за запрос видно в прайс-листе заранее.
Глубже: если хранилище не AWS, а совместимоерасширенное
Отдельный случай, который встречается чаще, чем кажется: код написан под SDK AWS, а хранилище — другое, лишь совместимое по протоколу (MinIO на своём железе, хранилище российского или европейского провайдера, эмулятор в тестах). Клиент SDK к такому хранилищу подключается, и нужны для этого три настройки.
@Bean
S3Client s3Client(@Value("${storage.endpoint}") String endpoint) {
return S3Client.builder()
.endpointOverride(URI.create(endpoint)) // 1. свой адрес
.region(Region.of("ru-central1")) // 2. регион: любой непустой
.forcePathStyle(true) // 3. путь вместо поддомена
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(key, secret)))
.build();
}
Зачем каждая. Адрес — очевидно: без него клиент пойдёт в AWS. Регион формально не нужен совместимому хранилищу, но клиент требует его для подписи запроса, поэтому подставляют то, что просит провайдер, или любое непустое значение. Обращение по пути (forcePathStyle) — самая частая причина неработающей интеграции: по умолчанию SDK обращается по поддомену (bucket.endpoint), а у совместимых хранилищ и у локальных эмуляторов работает форма endpoint/bucket.
Что ещё стоит проверить при переезде на совместимое хранилище: подпись запросов (версия 4 поддерживается почти всеми, но у части старых реализаций есть особенности), набор поддерживаемых операций (многочастная загрузка, теги, версионирование, политики жизненного цикла есть не везде), и способ выдать права — у совместимых хранилищ обычно нет ролей, поэтому возвращаются ключи доступа, которые надо держать в хранилище секретов.
Общая архитектурная мысль: доступ к хранилищу стоит держать за своим интерфейсом в коде, а адрес и стиль обращения — в настройках. Тогда переезд между AWS, совместимым провайдером и локальным эмулятором в тестах стоит правки настроек, а не кода; подробнее про сам класс хранилищ — в статье про объектное хранилище.
Коротко
- SDK v2 (пакеты software.amazon.awssdk), по клиенту на сервис, клиент потокобезопасен и дорог в создании: один бин на сервис, региона в коде нет, таймауты и повторы через overrideConfiguration.
- Credentials chain ищет права по порядку: системные свойства, переменные окружения, web identity token в EKS, профиль на ноутбуке, роль контейнера, роль машины; один код везде, ключей в application.yml нет никогда.
- Переменные окружения стоят выше профиля: забытый AWS_ACCESS_KEY_ID в оболочке молча уводит в другой аккаунт.
- Бин Spring закроет клиент сам, клиент в статическом поле или созданный на каждый вызов не закроет никто; в тестах один клиент на класс и закрытие после всех тестов.
- Постраничные обёртки вместо цикла по маркеру и обязательный префикс в листинге; асинхронные клиенты и S3TransferManager вместо своего параллелизма поверх синхронного клиента.
- Spring Cloud AWS 3.x под Boot 3 с префиксом spring.cloud.aws; @SqsListener требует идемпотентного обработчика, длинного опроса, пачки и параллельности под время видимости и очереди недоставленных у самой очереди.
- Секреты через spring.config.import читаются один раз при старте: пароль базы меняют с перекрытием и перезапуском, ключ внешнего API запрашивают в момент использования с кэшем SDK.
- LocalStack в Testcontainers проверяет поведение сервисов, но не права IAM и не лимиты; версию образа фиксируют.
- Запись и список в хранилище в десять раз дороже чтения, трафик через NAT самая дорогая строка на гигабайт; совместимое хранилище подключают адресом, регионом и path-style, держа доступ за своим интерфейсом.
Что почитать дальше
- IAM в AWS — откуда credentials chain берёт роли и права.
- Безопасность и наблюдаемость — Secrets Manager и метрики из кода.
- Объектное хранилище из Spring — загрузка файлов, подписанные ссылки и multipart.
- DynamoDB — если приложению нужна управляемая NoSQL-база.