Как мы увеличили глобальную мощность сканирования в 10 раз: инсайты безопасности Cloudflare

Security Insights предоставляет практические рекомендации по безопасности для каждой учетной записи Cloudflare. Чтобы находить эти инсайты, мы регулярно сканируем все учетные записи, зоны и DNS-записи, выискивая потенциальные риски безопасности и неверные конфигурации.

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

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

Мы подсчитали, что для увеличения частоты сканирований и включения автоматического сканирования для всех учетных записей нам потребуется увеличить пропускную способность сканирования примерно в 10 раз — с 10 до 100 сканирований в секунду. Но наша система уже с трудом справлялась с нагрузкой: миллионы событий заполняли очередь ожидания обработки; наш API часто выдавал тайм-ауты; процессы «падали». Нам нужно было исправить систему и сделать ее масштабируемой.

Это история о том, как мы увеличили пропускную способность сканирования для Security Insights более чем в 10 раз, включили инсайты безопасности для миллионов клиентов и удвоили частоту сканирования для всех пользователей. Читайте далее, чтобы узнать, как мы добились этих улучшений.

Как мы сканируем на предмет инсайтов безопасности

На высоком уровне наши автоматические сканирования безопасности запускаются планировщиком. Когда учетная запись или зона подлежит сканированию, планировщик публикует сообщение (или несколько сообщений) в Apache Kafka — открытую распределенную платформу потоковой передачи событий. Эти сообщения распределяются по ряду проверяющих модулей (checkers): специализированным Go-микросервисам, которые сканируют конкретные активы или конфигурации.

Для каждого сообщения каждый проверяющий отправляет свои результаты (найденные инсайты безопасности) в наш внутренний API, который затем сохраняет их в базе данных Postgres.

Scaling Security Insights: how we achieved a 10x increase in global scanning capacity

Обеспечение масштабирования

Масштабирование Kafka

Apache Kafka — это не строго очередь: это секционированный поток событий (хотя недавно он обзавелся семантикой очереди). Внутри раздела сообщения должны потребляться и обрабатываться по порядку. Это отличается от типичных очередей, где сообщения могут потребляться по порядку, но обрабатываться не по порядку. В результате у нас может быть только один активный потребитель на раздел в рамках группы потребителей.

Это имеет для нас два последствия:

  • Медленно обрабатываемые сообщения блокируют переход потребителя к следующему сообщению

  • Для каждого проверяющего модуля мы можем иметь не больше потребителей, чем разделов (у каждого проверяющего своя группа потребителей)

Scaling Security Insights: how we achieved a 10x increase in global scanning capacity

Мы могли бы попытаться масштабироваться, добавив больше разделов. Однако это увеличило бы использование ресурсов самого брокера Kafka, который используется многими другими сервисами. Мы оставили это как крайнюю меру, стремясь сначала улучшить наш код и архитектуру.

Внедрение параллельной обработки

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

Мы изменили наши проверяющие модули так, чтобы они потребляли сообщения пакетами, обрабатывая каждое сообщение в отдельной горутине. Компромиссы таковы: нам пришлось бы переделывать больше работы, если бы наш процесс «упал» в середине пакета, и использование памяти немного возросло бы. В нашем случае оба этих момента были приемлемыми.

Избежание блокировки головы очереди

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

Мы выбрали очень простой подход: разделили наши группы потребителей и проверяющие модули на две части — «медленную полосу» и «быструю полосу». Мы могли быстро определить, будет ли сообщение обрабатываться медленно или быстро. Если проверяющий из «быстрой полосы» встречает медленное сообщение, он его пропускает.

Scaling Security Insights: how we achieved a 10x increase in global scanning capacity

Это решило проблему: медленные сообщения получили выделенные ресурсы и время для обработки с минимальной задержкой, а быстрые сообщения смогли обрабатываться с обычной высокой скоростью.

Оптимизация запросов к базе данных

Каждый найденный инсайт записывается в нашу базу данных Postgres. Это обрабатывается одной конечной точкой API, которую наши проверяющие модули вызывают со списком инсайтов. Реализация выглядела так:

for _, issue := range issues {
	_, err = tx.Exec(ctx, `INSERT INTO table ... VALUES ($1, $2, ...) ON CONFLICT DO UPDATE ...`, ...)
	if err != nil {
		return err
	}
}

Внимательный читатель заметит, что для больших наборов инсайтов этот код совершает один круговой обход к базе данных на каждый инсайт. При максимальном наблюдаемом размере в 500 000 это составляло полмиллиона круговых обходов, запросов и транзакций в одном вызове API.

Изначально мы попробовали золотой стандарт для массовых вставок в Postgres: COPY во временную таблицу. Однако мы обнаружили, что этот подход приводит к раздуванию системных таблиц Postgres.

Мы остановились на гибридном подходе:

  • Использование UNNEST, когда количество проблем ниже порогового значения

  • Использование COPY, когда количество проблем превышает этот порог

Это дало лучшее из двух миров: достаточно быстрые вставки для огромных наборов инсайтов (секунды) и еще более быстрые вставки (миллисекунды) для небольших наборов инсайтов.

Исследование тайм-аутов нашего API

При попытке масштабирования мы заметили несколько странных поведений нашего внутреннего API:

  • Большое количество запросов вызывало тайм-ауты на стороне клиента

  • Многие проверяющие модули тратили от 20% до 90% времени обработки на один вызов API

  • При запуске большого объема сканирований наша пропускная способность начиналась высокой, а затем ухудшалась

Все эти проблемы имели одну и ту же первопричину: задержка.

Наша основная база данных находится в Портленде, штат Орегон. Однако наш API работал в активном режиме как в Портленде, так и в Амстердаме. Даже на скорости света время кругового обхода между Портлендом и Амстердамом составляло 50 миллисекунд.

В результате этой задержки запросы к базе данных из экземпляра API в Амстердаме выполнялись гораздо дольше, удерживая соединения из нашего пула соединений на стороне клиента. При большом объеме запросов к API пул соединений быстро истощался, что приводило к тайм-аутам при ожидании свободного соединения. Среднее время выполнения вызова API составляло 10 мс в Портленде, но почти 3 секунды в Амстердаме!

Но почему же падала пропускная способность сообщений? Каждому процессу проверяющего назначается набор разделов потока Kafka для потребления. Наш API балансирует нагрузку. Поскольку мы удерживаем соединение открытым в течение всего времени жизни процесса, некоторые процессы имели соединение с API в Амстердаме, а другие — с API в Портленде. Разделы, связанные с Портлендом, обрабатывались быстро, но разделы, потребляемые процессами, направленными в Амстердам, отставали:

Scaling Security Insights: how we achieved a 10x increase in global scanning capacity

Отставание Kafka (количество сообщений, ожидающих обработки в рамках одной группы потребителей) по разделам для одного из наших проверяющих модулей. Обратите внимание, что в данном случае у нас 30 разделов. Ровно 15 разделов можно видеть отстающими (линии, которые достигают нуля или приближаются к нему позже ~03/10 03:00). Это связано с тем, что балансировщик нагрузки распределяет трафик равномерно между нашими конечными точками API.

Это было простое исправление: мы переключили наш API на активно-пассивный режим, обеспечив, чтобы активный API следовал за нашей основной базой данных. Проблемы с задержкой исчезли в одночасье.

Переосмысление планировщика

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

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

Планировщик запускает сканирования через фиксированные повторяющиеся периоды. В псевдокоде планировщик выглядел так:

Loop forever:
    Find accounts where last_scheduled_at + scanning frequency <= now
    For each account:
        Trigger scan for account
        Trigger scan for all zones in the account
        Update last_scheduled_at = now

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

Однако даже при идеально равномерном распределении увеличение частоты сканирования усугубило бы эту проблему. Например, изменение частоты сканирования с каждых 15 дней до каждых семи дней означало бы, что 53% аккаунтов внезапно потребуют сканирования.

Была и еще одна проблема с этой логикой. У некоторых аккаунтов очень большое количество зон. При планировании таких аккаунтов возникал каскад сканирований всех их зон. Это насыщало наши разделы Kafka и приводило к задержкам сканирований гораздо меньших аккаунтов.

Чтобы исправить эти проблемы, мы внесли три ключевых изменения:

  • Планировать зоны независимо от аккаунтов: каждая зона получает собственное поле last_scheduled_at.

  • Рандомизировать время last_scheduled_at для существующих аккаунтов и зон.

  • Внедрить адаптивное ограничение скорости для планирования сканирований.

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

Адаптивное ограничение скорости немного интереснее. Ограничение скорости позволило бы нам решить проблему всплеска сканирований при изменении частоты сканирования. Например, если бы мы хотели увеличить частоту сканирования до каждых 7 дней и у нас было 50 миллионов аккаунтов, то ограничение скорости ~83 сканирования в секунду обеспечило бы их равномерное распределение в течение 7 дней.

Но что, если мы добавим еще 10 миллионов аккаунтов? Тогда это ограничение скорости вынудит нас потратить 8 дней на сканирование всех этих аккаунтов. Именно здесь вступает в дело адаптивная часть: ограничение скорости асинхронно пересчитывается каждые полчаса на основе общего количества аккаунтов и зон, а также частоты сканирования. Это гарантирует, что мы продолжим сканирование вовремя, даже если добавим тысячи или миллионы новых аккаунтов и зон.

func computeRate(free, pro, biz, ent int64) rate.Limit {
   r := float64(free)/freeScanInterval.Seconds() +
      float64(pro)/proScanInterval.Seconds() +
      float64(biz)/bizScanInterval.Seconds() +
      float64(ent)/entScanInterval.Seconds()


   // Защита от нулевых значений. Мы всегда хотим планировать хотя бы одно сканирование в секунду.
   if r < 1 {
      r = 1
   }


   // Увеличиваем лимит скорости за пределы 'идеального' значения, чтобы иметь запас на случай простоев
   // или всплесков нагрузки.
   r *= rateLimitBufferFactor


   return rate.Limit(r)
}

Где мы находимся сегодня

Scaling Security Insights: how we achieved a 10x increase in global scanning capacity

Благодаря этим исправлениям наше среднее пропускное количество сканирований в сутки за 7 дней увеличилось более чем в 10 раз.

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

Но наши исправления все изменили. Сегодня Security Insights выдерживает более 120 сканирований в секунду в пиковые периоды планирования, превышая нашу цель в 10-кратное улучшение. Наш внутренний API больше не выходит по таймауту, а метрики задержки Kafka выглядят гораздо здоровее. Эти улучшения масштабируемости позволили нам включить автоматическое сканирование для всех бесплатных аккаунтов и зон и увеличить частоту сканирования для всех клиентов:

  • Free: каждые 7 дней

  • Pro и Business: каждые 3 дня

  • Enterprise: ежедневно

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

Scaling Security Insights: how we achieved a 10x increase in global scanning capacity

Запуск детального сканирования по запросу со страницы Security Overview в панели управления Cloudflare

Урок, который мы извлекли, заключается в том, что крайне важно глубоко понимать существующую систему, прежде чем что-то выбрасывать. Внимательно изучая наш код, SQL-запросы, логи и метрики (особенно метрики!), мы смогли увеличить нашу пропускную способность без простого добавления большего количества подов или разделов. Подвергая сомнению наши предположения, вникая в странные метрики и отказываясь от легких путей (таких как увеличение таймаутов на стороне клиента API), мы построили более стабильную и устойчивую систему.

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