golang

Как мы законтрибьютили целую строчку в HashiCorp Vault

  • суббота, 5 октября 2024 г. в 00:00:05
https://habr.com/ru/companies/ozontech/articles/845290/

Привет! Меня зовут Пётр Жучков, я руководитель группы хранения секретов и конфигураций в отделе Message Bus в Ozon. Мы отвечаем за поддержку и развитие системы хранения и использование секретов, активно сотрудничаем с ребятами из департамента информационной безопасности, чтобы все сервисы могли безопасно работать с секретами.

Основной наш инструмент для управления секретами — Vault. Он отличается хорошей функциональностью, а также имеет подробную документацию, благодаря которой можно быстро начать его использовать. Конечно, запустить Vault и подключить к своему сервису — это совсем не то же самое, что надёжно и безопасно предоставить платформенный доступ более 6000 сервисов и других инфраструктурных систем. Для нас крайне важно отдавать данные быстро и хранить безопасно.

Например, в обычное время у нас 1000—2000 запросов в секунду, но бывают и больше — до 7000.

Если вы хотите безопасно хранить секреты или просто погрузиться в gRPC и Go, то, думаю, вам будет интересно и полезно не повторять наши ошибки.

Далее я расскажу историю о том, как во время стандартной процедуры обслуживания Vault мы смогли положить его и потратили много времени и нервных клеток, чтобы вернуть в рабочее состояние. Для того, чтобы лучше понять причины, давайте познакомимся с Vault и его устройством. Если вы в курсе, что такое Vault, то можете перейти сразу к разделу с экшеном.

Предыстория

Мы в Ozon для хранения различных секретов, таких как доступы к базам данных, API- ключи, сертификаты, Service2Service-секреты, используем HashiСorp Vault. Он стал практически стандартом для большинства крупных компаний, которые уделяют внимание информационной безопасности. И вот open-source продукт, который имеет около 30 000 звёзд на GitHub и поддерживается сообществом, сломался без каких-либо причин и объяснений. 

Ниже я расскажу обо всех наших шагах, камнях, о которые мы споткнулись, а также о том, как мы нашли проблему и предложили решить её.

Сразу скажу, что мы используем комьюнити-версию. Она предоставляется на условиях, которые нам подходят. 

Немного о Vault. Это инструмент/сервис для хранения секретов, например доступов к базам данных, токенов и других данных, которые не должны попасть в чужие руки. В Vault имеется огромный набор опций для разграничения прав доступа. Также он предоставляет HTTP-интерфейс для взаимодействия, что позволяет использовать его API даже в bash-скриптах. Все данные зашифрованы в оперативной памяти, и просто так получить их нельзя. Благодаря доступным интерфейсам для взаимодействия есть возможность интегрировать Vault с разными системами, а также множество способов доставить секреты до вашего сервиса: configs, переменные окружения и т. д.

В Ozon есть платформенные библиотеки, которые позволяют упростить разработку и повысить стабильность проектов. Одна из возможностей, которую они предоставляют, — быстрый и безопасный доступ к секретам. Общая схема доставки секретов выглядит следующим образом: 

  1. Под стартует в K8s-кластере.

  2. Он получает K8s service account token.

  3. С токеном под идёт в Vault.

  4. Vault проверяет токен — и, если всё хорошо, меняет его на токен от Vault. 

  5. С новым токеном сервис получает доступ к секретам. 

Также Vault поддерживает множество других способов авторизации помимо авторизации kubernetes, например с помощью JWT, — его мы используем на GitLab Runner.

HashiCorp Vault спроектирован таким образом, что все секреты достаточно сильно абстрагированы от реального хранения, что позволяет хранить данные в любом key/value-хранилище. Вот полный список поддерживаемых баз данных, наиболее распространенные ниже:

 Мы выбрали etcd в качестве бэкенда для хранения данных. Одна из его важных особенностей — возможность создать кластер в режиме high availability. Наш выбор был обусловлен большой экспертизой в etcd, а также результатами наших нагрузочных тестов.

Конфигурация

Тип подключения

Количество запросов

Время отклика 98 квантиль

Время отклика 50 квантиль

Raft

65% close

4777 RPS

180 мс

20 мс

Raft

keepalive

6809 RPS

344 мс

2,79 мс

Raft

close

4087 RPS

135 мс

36 мс

etcd

65% close

5200 RPS

94 мс

44 мс

etcd

keepalive

8145 RPS

33 мс

11 мс

etcd

close

4053 RPS

81 мс

39 мс

Как видно, максимальную нагрузку и минимальное время отклика обеспечивает etcd.

etcd — это, как указано на сайте: 

A distributed, reliable key-value store for the most critical data of a distributed system A three member etcd cluster finishes a request in less than one millisecond under light load, and can complete more than 30,000 requests per second under heavy load.  

То есть это key/value-база данных, которая использует Raft.

Все инстансы Vault и etcd распределены между всеми дата-центрами для обеспечения максимальной отказоустойчивости. Сами клиенты, как правило, используют балансировку DNS, при которой сама DNS опрашивает ноды Vault и отдаёт адрес мастер-ноды.

Vault оснащён механизмом защиты данных, то есть все секреты хранятся в бэкенде, а также в оперативной памяти в зашифрованном виде. Это позволяет обеспечить максимальную безопасность данных. При старте Vault «запечатан», то есть ещё не готов к работе с секретами. Чтобы его «распечатать», нужно провести процедуру unseal — ввести несколько мастер-ключей (так называемых секреты Шамира).

То есть шаги должны быть следующие:

  1. Старт etcd.

  2. Старт Vault.

  3. Ввод ключей по очереди. 

  4. Начало работы с Vault.

Ключи находятся у разных сотрудников, которые должны хранить их отдельно.

Вообще Vault может сам «запечататься» из-за различных ситуаций:

  • сменился лидер хранилища,

  • долгие ответы хранилища,

  • сменился лидер Vault.

Для того чтобы уменьшить риски «запечатывания» Vault и ускорить его «распечатывание», можно использовать схему с ещё одним Vault — Vault transit. То есть для «распечатывания» Vault нужен ещё один Vault =) Обычно второй (transit) Vault не имеет нагрузки, и его задача — только «распечатать» основной Vault.

Данная схема позволяет быстро «распечатать» основной Vault без участия людей(хранителей ключей).

Но Vault transit всё-таки нужно «распечатывать» вручную. Выглядит это так:

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

Основные сущности Vault

Прежде чем приступить к описанию нашей истории, расскажу немного о том, как устроен Vault. Начнём с того, что это достаточно гибкое ПО, которое может очень многое, а также очень хорошо масштабируется с помощью плагинов. Например, доступно очень много разных способов авторизации. Почти всё взаимодействие внутри Vault происходит через gRPC-сообщения, что даёт нам возможности для интеграций с различными системами. Ниже я расскажу о некоторых важных особенностях Vault. Я не буду охватывать все его возможности — о них в интернете есть много информации. Если вы знакомы с общим устройством Vault, то можете смело пропускать эту часть. 

Пути

Работа с Vault происходит при помощи чтения и записи данных находящихся по разным путям, например:

secret-mount/group/project-2377212/
  • secret-mount — это точка монтирования, она может иметь разные движки, а следовательно, может выполнять различные операции, такие как работа с секретами или генерация сертификатов;

  • group/project-2377212/ — это путь до нашей директории, где хранятся секреты.

Токены

Это одна из ключевых сущностей, которая указывается в каждом запросе к Vault, по которому он идентифицирует, кто пришёл.

В токене есть много различной информации, например: 

  • дата создания,

  • время жизни — определяется на основе аренды (leases),

  • политики доступа — отдельная сущность,

  • счётчик использований,

  • признак возможности продления токена,

  • и т. д.

Токены бывают нескольких видов:

  • service — наиболее часто используемые токены, имеют широкую функциональность;

  • batch — более лёгкие токены, по сути, это зашифрованные BLOB.

Это абсолютно разные токены. Для корректного использования нужно понимать, какие их возможности будут использоваться для авторизации и администрирования доступов, и выбрать нужный вид. Например, batch-токены самодостаточные, но нет возможности отозвать такой токен, в отличие от service-токенов.  Но service-токены хранятся в Vault и занимают место. Именно поэтому за ними нужно обязательно следить: сколько их, какой объём данных в хранилище. 

Вот пример отношения авторизаций в Vault и ключей в etcd:

Видно, что при активных походах в Vault создаётся много токенов (а, как мы помним, это часть авторизации), которые хранятся в etcd.

Аренды

Lease — может выдаваться на многие сущности в vault, которые имеют срок службы. Пример — время жизни токена. Можно привязать аренду к данным — и данные будут удалены, как только истечет время жизни токена, то есть это TTL, который можно повесить почти на любую сущность. Также аренды можно продлить и отзывать.

Обязательно настраивайте TTL для токенов, иначе они бесконечно будут храниться в etcd, и рано или поздно это приведёт к проблеме — как случилось у нас.

Роли

Эти сущности чем-то схожи с группами пользователей. Обычно при авторизации в Vault необходимо указать роль, с которой необходимо авторизоваться, а у самой роли уже есть различные параметры, например время жизни токена (как раз создаются аренды, описанные выше), политики, пространства имен авторизации и другие, которые влияют на то, какие настройки в итоге получит токен.

Политики доступа

По сути, это набор правил, которые описывают доступы к секретам. Выглядят так:

path "secret-mount/group/project-2377212/*" {
    capabilities = ["read", "list", "create", "update", "delete"]
}

Итого, давайте резюмируем, для разграничения доступа сервисов к секретам:

  1. Добавляем политики доступа.

  2. Создаём роли, которые содержат в себе одну или несколько политик и одно или несколько временных ограничений.

  3. Раскладываем секреты согласно «купленным билетам».

Profit!

Использование авторизации Kubernetes-подов в Vault

Итого, стандартный процесс выглядит так:

  1. Создаётся сервис. На этом этапе также создаётся роль с политикой, у роли есть доступ к определенного пути в Vault.

  2. В этот путь также записываются доступы к БД.

  3. При старте сервис, используя платформенную библиотеку, вычитывает секрет:

    1. При старте сервис получает K8s service account token.

    2. С этим токеном и ролью (которая была создана при создании сервиса) идёт в Vault.

    3. Vault валидирует этот сервис в K8s — и, если всё хорошо, то выдаётся токен, в котором прописана ролевая политика с доступом к секретам сервиса.

    4. Используя последний токен, сервис получает все необходимые секреты и начинает работу.

Как видно, достаточно много этапов связаны с работой нашей платформенной библиотеки, которая является частью нашего фреймворка для создания сервисов. Благодаря унификации на разных языках мы получаем одинаковое поведение сервисов, что очень сильно помогает развивать инфраструктуру Ozon.

Maint

Всё началось с одного рутинного сервисного обслуживания нашего хранилища секретов. 

Как я писал выше, если не указывать TTL для токенов, то рано или поздно количество хранимых данных сильно увеличится. Так получилось и у нас.

Мы решили изменить политику работы с токенами, уменьшив время жизни токена, так как большинству сервисов чтение секрета необходимо только в момент старта, а значит, долгоживущие токены не нужны. Также мы решили удалить все бесконечные токены, которые были выданы для инфраструктурных систем и не имели возможности производить авторизацию стандартным способом.

В целом всё выглядело как обычная процедура, которая не должна влиять на работу Vault.

Итак, для начала нужно было найти долгоживущие токены. Для этого мы нашли все роли, которые имели политики с длительным TTL своих токенов (а, как мы помним, токены выдаются для ролей). Тут нужно уточнить: сами токены найти, конечно же, нельзя, даже администраторам, но можно найти алиасы, которые позволяют получить всю необходимую информацию о токенах. Это сделано в целях безопасности — чтобы даже администраторы не могли что-то сделать от имени пользователя. Более подробно можно посмотреть в разделе «Token management».

Ну а далее в отношении токенов была запущена процедура отзыва: 

vault token revoke -accessor ....

Все токены были успешно отозваны — всё прошло успешно. 

Мы также нашли роли, где можно было создавать бесконечные токены, и изменили у них политики, чтобы в будущем токены не жили бесконечно.

Как я писал выше, время жизни токена основывается на арендах, то есть процедура отзыва является, по сути, операцией, которая помечает аренды как просроченные, а именно перемещает все аренды в директорию:

/vault/sys/expire/id/auth/ 

В итоге мы переместили около 400 000 записей, то есть около 30% всех аренд. 

Тут стоит заметить, что Vault не сразу считывает данные из базы, а обращается к ней, когда приходит клиент с токеном и идёт проверка аренды. Поэтому после отзыва аренды всё было отлично.

Так как при удалении данных из etcd объём на дисках, конечно же, не уменьшается, чтобы освободить место, в etcd есть операция defrag, которая производит фрагментацию данных в базе. Можно понять, что это достаточно дорогостоящая операция, так как в этот момент база перестает быть доступной. Эта операция схожа с VACUUM FULL в PostgreSQL. И если выполнять её поочерёдно на всех нодах кластера, то проблем не должно быть. Но по ошибки операцию запустили на всех нодах сразу.

Это привело к проблемам в самом Vault. В Vault есть механизм переключения с одной ноды на другую в случае, если первая перестала отвечать. Но, так как все ноды были заняты дефрагментацией, после перебора Vault «запечатался» и был недоступен для клиентов. Вообще это стандартное поведение Vault, так как для обеспечения безопасного хранения секретов он должен стабильно иметь связь с etcd.

Проблемы с Vault мы сразу заметили —  и начались попытки «распечатать» его, с потом и слезами… Потому как все запросы за секретами перестали обрабатываться. Но Vault уверенно говорил, что не может распечататься сделать и выдавал ошибки вроде такой:

[ERROR] expiration: error restoring leases: error="failed to scan for leases: list failed at path \"\": rpc error: code = ResourceExhaust ed desc = grpc: trying to send message larger than max (2513015459 vs. 2147483647)

Как я писал выше, мы используем vault transit, который позволяет это делать в автоматическом режиме, но в данном случае это не помогло — основной Vault не «распечатывался». При этом в логах было видно, что он выполняет процедуру «распечатки», но буквально через пять секунд опять происходили «запечатывание» Vault и блокировка всех запросов к нему.

Мы начали строить гипотезы и изменять конфигурации, например max_receive_size и max_send_size. Но, к сожалению, результата это не принесло. Параллельно мы нашли в репозитории HashiCorp информацию о том, что подобные ошибки уже возникали и причиной было увеличившееся количество токенов. Эта проблема была решена путём увеличения максимального размера ответа для gRPC-клиента.

В целом, из этой ошибки понятно, что аренды куда-то не пролезают при старте Vault. И мы начали искать ошибки со стороны нашей базы — etcd.

etcd[20536]: read-only range request "key:\"/vault/sys/expire/id/\" range_end:\"/vault/sys/expire/id0\" " with result "range_response_count:1475648 size:2504927582" took too long (6.962914353s) to execute

После анализа этой проблемы, было высказано предположение, что после удаления большого количество токенов в /vault/sys/expire/id остались сущности, которые удаляются отложенно, а не сразу. То есть в базе они помечаются как просроченные, а далее Vault их удаляет. 

В данном разделе хранятся токены для разных auth points, например /vault/sys/expire/id/auth/o-dev/login для dev-окружения, /vault/sys/expire/id/auth/o-stg/login — для stage-окружения и т. д.

И тогда мы приняли решение удалить записи из dev- и stage-окружений. 

После этого у нас получилось «распечатать» Vault — и он смог корректно отдавать секреты всем сервисам. 

Что же это было?

Конечно, после такого инцидента мы предприняли большое количество действией, чтобы ситуация не повторилась.

Для начала нужно было понять, что вообще делает Vault, когда мы пытаемся его «распечатать». Так как логи не давали нам точного ответа на вопрос, где что упало, мы решили выяснять это шаг за шагом.

Нашли функцию Restore, которая запускалась как раз при старте Vault. В процессе происходит сбор наших аренд:

m.logger.Debug("collecting leases")
existing, leaseCount, err := m.collectLeases()
if err != nil {
	return err
}

А дальше эти аренды обрабатываются и удаляются все связанные токены. Vault берёт каждую аренду и в несколько потоков обновляет связанные токены. 

// Distribute the collected keys to the workers in a go routine
wg.Add(1)
go func() {
	defer wg.Done()
	i := 0
	for ns := range existing {
		for _, leaseID := range existing[ns] {
			i++
			if i%500 == 0 {
				m.logger.Debug("leases loading", "progress", i)
			}

			select {
			case <-quit:
				return
				case <-m.quitCh:
				return
				default:
				broker <- &lease{
					namespace: ns,
					id:        leaseID,
				}
			}
		}
	}
	// Close the broker, causing worker routines to exit
	close(broker)
}()

Процесс обновления можно посмотреть в функции processRestore. Но, если вкратце, мы выгружаем отдельно ещё раз каждую аренду и следим за ней.

После анализа кода стало ясно, что проблема в collectLeases, потому что именно на этом этапе мы получали ошибку. Внутри функции есть абстракция для работы с различными БД (в нашем случае, как вы помните, это etcd). И нужно было узнать, что именно происходит и какие действия приводят к ошибке.

Vault работает с etcd, используя gRPC-протокол. collectLeases использует функцию List, которая имеет общий интерфейс для всех БД, а в реализация интерфейса использует GET запрос:

etcdctl get /vault/sys/expire/id/auth/ 

То есть библиотека пытается выгрузить все записи из данного пути, а, как я писал выше, их больше 400 000. Это и есть причина, блокирующая «распечатывание» Vault.

Как известно, есть возможность конфигурировать gRPC запросы, даже для объема данных в самих запросах. Например в Go есть ограничение количества байтов, которые можно получить:

func MaxCallRecvMsgSize(bytes int) CallOption

и которые можно отправить:

func MaxCallSendMsgSize(bytes int) CallOption

Все эти настройки принимают значение, которое по умолчанию имеет тип int, где максимум — это math.MaxInt32. То есть, по сути, мы в какой-то момент пытаемся выкачать очень много данных за один запрос, и у нас это не получается.

Что мы придумали?

Первая мысль, которая у нас появилась, — увеличить количество байтов и попробовать перезапустить etcd. Но int мы не сможем увеличить, да и не совсем правильно, что за один запрос мы выкачиваем все данные.

Другим вариантом была пагинация — простая, но надёжная. Она позволит избежать толстого запроса. Но, как я писал выше, Vault выгружает аренды, а после этого в несколько потоков их обрабатывает. В момент обработки он ещё раз выгружает из БД данные, но уже по одной записи. То есть двойная работа и лишние запросы. 

Выглядело так, что нам не нужно выгружать все данные, а нужны всего лишь ключи. Самым простым способом было посмотреть, как устроена работа в других драйверах. В PostgreSQL, MySQL и во всех остальных есть выгрузка только ключей, а не всех данных.

Так как у нас etcd, то в методе GET есть опция: 

--keys-only[=false]		Get only the keys

Она позволяет выгрузить только ключи, которые нам необходимы. И в ходе тестов выяснилось, что объём данных уменьшился примерно в 20 раз. И, конечно, мы начали проходить по ограничению MaxCallSendMsgSize.

Странно, что такой просчёт был допущен ребятами из HashiCorp. Думаю, связано это с тем, что в первых версиях etcd опции –keys-only не было, а драйверы дорабатывали, основываясь на предыдущих версиях.

Так как Vault — open source продукт, мы решили сделать issue, где и предложили изменения для pull request. Он был «очень сложный» в функцию List:

-	resp, err := c.etcd.Get(ctx, prefix, clientv3.WithPrefix())
+	resp, err := c.etcd.Get(ctx, prefix, clientv3.WithPrefix(), clientv3.WithKeysOnly())

После недолгого обсуждения pull request был принят и сейчас уже в мастере.

Мы решили сейчас не делать пагинацию, так как объём данных должен быть в 20 раз больше, чтобы мы поймали эту же проблему и у нас есть немного времени для роста объемов данных. По примерным подсчётам, это четыре — пять лет.  Думаю, мы отправим ещё один pull request, но это будет уже другая история.

Чему мы научились?

Конечно, никто не застрахован от ошибок, и какими бы средствами мы ни обложились, шанс столкнуться с проблемой есть. Но тем не менее мы решили сделать несколько подходов к снаряду, чтобы минимизировать риски.

  • Добавили в тренировочные учения шаги по «распечатыванию» Vault с проблемными бэкапами.

  • Добавили процедуру автоматического разворачивания Vault из бэкапов на ежедневной основе, чтобы быть уверенными в том, что свежий бэкап без каких-либо проблем развернулся.

  • В четыре раза уменьшили объём данных в Vault благодаря тому, что уменьшили TTL токенов и отозвали долгоживущие токены.

  • Законтрибьютили необходимые строчки кода.

Вообще, чтобы не повторять такие ошибки в будущем, нужно обязательно проводить стресс-тестирование и углубляться в особенности работы самой системы и связанных компонентов. Даже самим разработчикам Vault потребовалось время на то, чтобы разобраться с проблемой и принять предложенные изменения.