Hot reload секретов под нагрузкой в Java-сервисах на Spring

Публикации в СМИ
19.02.2025
Опубликовано на Хабре

Привет! На связи Андрей Чернов, Java‑архитектор в СберТехе. В прошлой своей статье я рассказал про особенности работы с секретами в Java‑сервисах на Spring Boot — где их брать и как применять к вашему сервису, на примере того, как мы делаем это в Platform V Sessions Data.

Работа с секретами в современных реалиях, где ни с чем не интегрированных сервисов почти не осталось, очень важна. Она помогает снизить риски утечек и атак, а значит, сохранить деньги, время и репутацию компании. Секретами могу быть, например, сертификаты и учётные данные (имя пользователя, пароль и т. п.).

Как я уже говорил, файлы с секретами по разным причинам меняются, поэтому сервису нужно вовремя реагировать на это и применять новые секреты. В своём сервисе Platform V Sessions Data мы решили применять обновления секретов прямо «на горячую», не останавливая, не перезапуская сервисы, и даже не снимая с них нагрузку. Мы называем это hot reload.

Содержание

Почему нам не подошли стандартные средства

В прошлой статье я рассказывал, почему нам не подошли стандартные средства, которыми можно выполнить hot reload секретов:

  • @RefreshScope из Spring Cloud неприменим для обновления серверных SSL‑сертификатов Tomcat, а при использовании для HikariDataSource обрываются все текущие соединиения с БД;
  • SSL bundles, появившиеся в Spring Boot 3.2, неприменимы для обновления секретов, примонтированных в контейнер из манифестов kind: Secret, а также не следят за изменением паролей от keyStore и trustStore.

Поэтому мы были вынуждены реализовать свой, универсальный инструмент для hot reload любых секретов в Java‑сервисах на Spring Boot, которые могут развёртываться как в Kubernetes, так и на виртуалки. В этой статье расскажу, как нам это удалось.

Как мы следим за изменениями файлов с секретами

В Sessions Data мы следим за изменениями секретных файлов с помощью стандартного класса java.nio.file.WatchService. Использовать его довольно просто:

  • регистрируем WatchService для каталога с секретными файлами, указывая события, за которыми мы хотим следить: создание, изменение или удаление файлов;
3c18fe23a9d3abe8e11894d84e3e18d8.png
  • в бесконечном цикле вызываем у WatchService блокирующий метод take(), засыпающий до изменения файлов в каталоге. Этот цикл крутим в отдельном daemon‑потоке;
19e8731379acac7f5c4b0491ae843054.png
  • когда метод take() просыпается, он сообщает нам, какие файлы изменились, а мы читаем измененные секреты. Остается только понять, как применить к сервису «на горячую» эти новые секреты.
e79fdfbccf93c9ccd893fae63ab843d3.png

Любопытный факт: SSL bundles из Spring Boot для слежения за файлами сертификатов тоже используют WatchService. Получается, что в своем решении мы идём в ногу со Spring Boot.

Слежение за изменениями файлов с секретами от Vault Agent

В ситуации, когда файлы с секретами приносит Vault Agent sidecar, есть свои нюансы. При изменении секрета в Vault его агент

  • создаёт в каталоге с секретами временный файл: название у него какое‑то странное, состоящее из одних цифр, но мы ждём изменений не в этом файле;
245eb4046aea2a89b4b7f2ef58709a68.png
  • затем Vault Agent наполняет этот временный файл новым секретным содержимым;
6a84b67bd01847575688491b369e285a.png
  • и перемещает (!) временный файл по целевому имени файла, затирая старый файл с секретом.
c3aaf9e95b9a6d7621cc6c84fa96628e.png

Vault Agent делает именно так, чтобы обеспечить атомарность изменения файла и миновать промежуточное состояние, когда файл ещё не полностью обновлен. WatchService при этом замечает только событие ENTRY_CREATE для наблюдаемого файла, а событие ENTRY_MODIFY — нет. Поэтому при регистрации WatchService для каталога с секретами мы стали указывать только событие ENTRY_CREATE.

Слежение за изменениями файлов с секретами из kind: Secret

А теперь давайте рассмотрим ситуацию, когда поды приложения монтируют себе в файловую систему секретные файлы из манифестов kind: Secret, которые на лету обновляются с помощью External Secrets Operator. Главный нюанс в том, что в указанный каталог с секретами попадают вовсе не настоящие файлы, а симлинки на них.

Например, вот здесь мы видим, что эти симлинки смотрят в каталог ..data, который сам является симлинком и смотрит на каталог с timestamp в имени. И именно он — уже настоящий каталог, где лежат настоящие файлы с секретами.

f13ce601a5477b0696ab75c25ad30cea.png

Kubernetes делает так для атомарности изменения всех файлов, которые примонтированы в контейнер из конкретного kind: Secret.

Что происходит, когда External Secrets Operator обновляет kind: Secret? По шагам это выглядит так:

  • Kubernetes создает новый timestamp‑каталог и наполняет его новыми секретными файлами;
  • затем атомарно меняет symlink ..data на этот новый каталог;
  • после этого просто удаляет старый timestamp‑каталог в контейнере.

Что при этом видит WatchService:

  • для наблюдаемых файлов, то есть для симлинков, ничего не видит (сами эти симлинки Kubernetes не меняет при изменении kind: Secret‑а);
  • для реальных файлов, куда изначально смотрели симлинки, нам один раз приходит событие ENTRY_DELETE, когда на третьем шаге Kubernetes удаляет старый timestamp‑каталог;
  • и больше для этих удаленных файлов никаких событий в файловой системе не происходит.

И это проблема, с которой сталкивались многие. И нам пришлось поломать голову, чтобы найти решение, которое бы работало при таких нюансах Kubernetes.

Вот что мы делаем:

1. Регистрируем WatchService для реального каталога с секретами (того, что с timestamp в имени). Для этого у наблюдаемого файла, который симлинк, мы вызываем метод toRealPath() и берём от этого родительский каталог. Заметьте, что следим мы теперь только за событиями удаления файлов

076c3b38f8289b0f7d3db70c709fbf07.png

2. Во время произошедшего события удаления файла проверяем следующее:

  • наблюдаемый файл, который симлинк, всё ещё почему‑то существует (потому что symlink смотрит уже на новый реальный файл);
  • реальный родительский каталог у этого наблюдаемого файла уже другой: он поменялся.

Если всё так, то при обработке события ENTRY_DELETE мы перерегистрируем WatchService для нового реального каталога с секретами, то есть для нового timestamp‑каталога.

Чего мы этим добились?

  • Когда External Secrets Operator обновляет kind: Secret, мы получаем событие ENTRY_DELETE для файлов с секретами.
  • Читая эти файлы, мы получаем уже новые секреты благодаря симлинкам, как бы это странно не выглядело.
  • За счёт того, что мы каждый раз перерегистрируем WatchService для нового каталога с секретами, событие ENTRY_DELETE приходит нам вновь и вновь при каждом следующем обновлении kind: Secret. Это именно то, что нам было нужно.

Остался только маленький нюанс: используя Vault Agent, мы слушаем события ENTRY_CREATE, а в случае с External Secrets Operator — события ENTRY_DELETE. Поэтому мы настраиваем это отдельно.

Наше решение работает с использованием WatchService и в Kubernetes, и на виртуалках, я выложил на GitVerse.

Подчеркну, что SSL bundles из Spring Boot пока не умеют следить за файлами, примонтированными в контейнер из kind: Secret. Там они запинаются о симлинки подписывают WatchService на все три события изменения файлов — create, modify и delete — хотя, как выяснилось, в Kubernetes нужно только на delete.

Как мы обновляем секреты «на горячую»

Наконец, мы подошли к самому интересному — к hot reload секретов без снятия нагрузки с сервиса.

Как мы уже убедились, у слежения за изменениями секретных файлов есть довольно много нюансов. Поверьте, но у hot reload изменившихся секретов тоже нюансов не меньше. Поэтому мы чётко отделили слой слежения за файлами от слоя hot reload секретов с помощью паттерна Publisher‑Subscriber. Это позволит при необходимости легко поменять слой слежения, не трогая слой хорошо работающего hot reload.

Пусть для hot reload каждой группы секретов используется вот такой Subscriber. В его методе subscriptions() мы говорим, за какими файлами хотим следить, а в методе onChange() делаем, собственно, hot reload, когда эти файлы поменяются.

df591392cffd065237bb45c54d137bb1.png

Все такие subscriber-ы передаются singleton‑объекту publisher, который с помощью WatchService следит за всеми файлами секретов и дергает subscriber-ы, когда надо. Как именно происходит такое слежение, мы уже знаем. Поэтому сейчас посмотрим только на hot reload секретов.

Hot reload серверных SSL-сертификатов Tomcat

Начнём с обновления серверных SSL‑сертификатов в Tomcat. Напомню, что при запуске сервиса мы задали ему пути до keyStore и trustStore, а также пароли от них.

1441c9360d5f57b85b08c26e36175cf5.png

Теперь наш subscriber должен как‑то «на горячую» всё это обновить. Для этого нужно сказать «волшебное слово» для Tomcat, которое звучит как reloadSslHostConfig, но сначала важно докопаться до нужного объекта.

Для этого мы создаем Spring‑овый bean с Tomcat‑овской фабрикой и задаём для неё connector customizer.

fd8d868ab566bbe8bd56009fedddac9c.png

С его помощью достаем из Tomcat объект класса Http11NioProtocol — вот у него уже можно попросить выполнить hot reload серверных сертификатов.

c7a7f6cfd8c6bb038fca0f6bc59a4dea.png

Здесь важно вспомнить, что на старте мы задавали для Tomcat пароли от хранилищ сертификатов в виде обычных строк, а не в виде путей до файлов, как сами keyStore и trustStore. Так что придётся снова самим прочитать пароли из файлов и программно их задать для Tomcat. К счастью, это тоже можно сделать с помощью объекта класса Http11NioProtocol, достав из него дефолтный sslHostConfig.

07a8218287a42bbd9431cc9f88b614d9.png

Теперь мы готовы к волшебству и произносим reloadSslHostConfig. Это приводит к hot reload серверных сертификатов Tomcat.

be8b9c4b14768f9e007ea2b3b93fa8ad.png

Наши тесты показали, что hot reload под нагрузкой занимает от 100 до нескольких сотен миллисекунд. При этом все клиенты Tomcat дожидаются завершения hot reload, не получая ошибок.

Эта схема будет работать и во втором, и в третьем Spring Boot. Более того, появившиеся в версии 3.2 SSL бандлы выполняют hot reload сертификатов Tomcat аналогичным образом: тоже через connector customizer и Http11NioProtocol.\

Hot reload клиентских SSL-сертификатов для HTTPS

На очереди subscriber, подписанный на изменения клиентских SSL‑сертификатов. Напомню, что мы используем Jersey‑клиент, и при старте сервиса передали ему SSLContext, который сами собрали, прочитав файлы с клиентскими сертификатами.

9913bba1aa0ad2a7e813d0f74c53868a.png

Теперь нужно обновить это «на горячую». В этот раз мы идём простым путём и не ищем в Jersey‑клиенте какую‑то встроенную фичу по hot reload SSL‑сертификатов. Мы просто создаём новый экземпляр Jersey‑клиент: заново собираем для него SSLContext, читаем обновлённые файлы с сертификатами. После этого меняем старый экземпляр, которым приложение пользуется сейчас, на новый Jersey‑клиент, который мы только что собрали. Поскольку используется ссылка volatile, для приложения такое обновление клиентских сертификатов происходит атомарно.

19c171473d2f2748cc9c33bd308c7466.png

Как обычно, маленький нюанс: для старого экземпляра Jersey‑клиента мы закрываем установленные им ранее соединения, но делаем это отложенно в отдельном треде, чтобы все потоки, ожидающие сейчас HTTP‑ответов, успели их дождаться. Для этого отложенный abort connections мы выполняем спустя время, превышающее read timeout, заданный для Jersey‑клиента.

68d4089b7989e864cfee9b43147dba26.png

На этом этапе вспоминается проблема с HikariDataSource, который при обновлении через @RefreshScope без всякого ожидания рвёт все текущие соединении с базой данных. Выглядит так, что HikariDataSource тоже мог бы использовать наш подход с отложенным закрытием существующих соединений. Надеюсь, что мы увидим такое решение в очередной версии библиотеки HikariCP.

Hot reload SSL-сертификатов для JDBC-соединений с PostgreSQL

Теперь разберём subscriber, подписанный на изменения SSL‑сертификатов для связи с БД. Напомню, что используются SSL‑сертификаты в pem‑формате (просто кодированные в base64), пути до этих трёх файлов зашиты в JDBC URL для PostgreSQL, а URL передан бину HikariDataSource при запуске сервиса.

1225969d8581f3654693fde09d3bd676.png

Поэтому для hot reload нам самим даже не приходится читать файлы с этими SSL‑сертификатами — их перечитывает JDBC драйвер Postgre. Для этого hikariDataSource нужно сказать «волшебное слово», которое на этот раз звучит как softEvictConnections().

26d0a82ff66136c2a6d08e534352765f.png

Вызов этого метода приводит к плавному вырождению старых соединений с базой данных и их замене на новые. Про этом работа с базой данных остается доступной. Магия от HikariCP!

Hot reload учётных данных БД

Такая же простая схема hot reload у нас используется и для subscriber, который подписан на изменения учётных данных базы. Напомню, что на старте сервиса мы сами прочитали имя пользователя и пароль из секретных файлов и задали их при создании бина HikariDataSource.

e26fa1f193ed95b57e8a7a06209fae47.png

Поэтому, в отличие от SSL-сертификатов для БД, мы снова сами перечитываем из файлов имя пользователя и пароль и передаем их бину HikariDataSource. Для применения новых кредов используем уже знакомое «волшебное слово» softEvictConnections().

886299d62072202aef98fc07cfbba633.png

После этого постепенно создаются новые соединения с БД, используя новые учётные данные, а старые соединения потихоньку отмирают.

Неодновременный hot reload в разных узлах сервиса

Осталось учесть последний нюанс — неодновременный hot reload в разных инстансах сервиса. С ним проблема такая: если одновременно обновить клиентские SSL‑сертификаты во всех подах сервиса или даже в нескольких сервисах, то на серверной стороне это может создать лавину пересозданий SSL‑соединений. Установка SSL‑соединений — это ресурсоемкий процесс. Особенно сильно это нагружает процессор.

Снова разберем на примере нашего сервиса Platform V Sessions Data. Если во всех подах servant одновременно обновить секреты для базы данных, то на стороне PostgreSQL (или PgBouncer, если он используется) придется за короткий промежуток времени пересоздать много SSL соединений, что приведет к пиковой нагрузке на CPU.

36e2d3479ba7e49c3a5a3ec692b4cf78.png

Мы на практике сталкивались с тем, что PgBouncer не выдерживал нагрузку, когда в целом ряде сервисов происходил одновременный hot reload сертификатов для БД.

Чтобы решить эту проблему, мы избегаем одновременного hot reload секретов во всех инстансах сервиса. Для этого у нас используется рандомное ожидание перед применением новых секретов (мы называем это jitter). То есть наши subscriber-ы не сразу бегут применять все измененные секреты. Они сначала засыпают на несколько секунд (от 5 до 150), и только проснувшись, применяют изменения секретов к приложению.

73aad0a90bd411d6b7c8f321ce16c776.png

Благодаря этому в разных экземплярах нашего сервиса новые секреты применяются в разное время, за счет чего мы избегаем пиковой нагрузки на процессор PgBouncer (в этом примере), а значит экономим на его железе. В прошлой статье я уже говорил, что мы в Platform V стараемся максимально рационально подходить к его расходованию. В этом случае мы смогли сэкономить на железе благодаря тому, что просто подождали перед применением секретов несколько секунд. Просто и элегантно!

Стоит отметить, что у SSL bundles из Spring Boot jitter не настраивается — там hot reload сертификатов выполняется одновременно во всех инстансах сервиса, со всеми вытекающими.

Так что наша схема обновления секретов «на горячую» прямо под нагрузкой, действительно, готова к production, а мы сами успешно применяем ее на практике, продолжая попутно совершенствовать.

Итоги и выводы

  1. Не бойтесь выполнять hot reload секретов прямо под нагрузкой. В Java приложениях на Spring Boot любой секрет можно обновить «на горячую», что гораздо дешевле, чем rolling update. Здесь также стоит разделить слой обнаружения изменений в секретах и слой применения этих изменений. В случае чего, вам будет легко поменять способ обнаружения изменений секретов, не трогая отлаженный и хорошо работающий слой hot reload. Например, можно будет заменить WatchService на что‑нибудь другое.
  2. Стоит выполнять hot reload секретов в разных подах в разные моменты времени. Это позволит избежать пиковых нагрузок на CPU в используемых вами серверах, а значит позволит сэкономить на железе.

Другие новости

Все новости
Все новости