Команда Go for Devs подготовила перевод k8s-1m — кейса о том, как не догадки, а измерения двигают пределы Kubernetes. Ключевые идеи: изоляция QPS по типам ресурсов, смягчение гарантий хранения для эфемерных данных, и шардирование планировщика. Полезно всем, кто проектирует крупные кластеры или хочет работать с ними.


Зачем?

Несколько лет назад, работая в OpenAI, я был соавтором статьи Scaling Kubernetes to 7500 Nodes, которая до сих пор остаётся одной из самых популярных публикаций CNCF. Alibaba опубликовала материал о запуске кластеров Kubernetes с 10 тысячами узлов. Google — о 15 тысячах узлов в сотрудничестве с Bayer Crop Science. Сегодня GKE поддерживает кластеры до 65 тысяч узлов, а AWS недавно объявила о поддержке до 100 тысяч узлов.

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

Суть проекта k8s-1m — выявить реальные ограничения масштабируемости. Где проходят настоящие пределы того, насколько можно «разогнать» систему, и почему именно эти факторы становятся ограничениями? Что потребуется, чтобы преодолеть их и сделать систему ещё более масштабируемой? Какие типы нагрузок лучше поддаются масштабированию, а какие — нет?

Большинство ограничений накладывается самим программным обеспечением. И именно нам, инженерам, предстоит с ними разбираться. Лишь когда мы исчерпаем всё возможное со стороны софта и упремся в ограничения железа, можно будет ждать новое поколение аппаратных решений, чтобы двигаться дальше.

Я не ставлю целью коммерциализировать этот проект — по крайней мере, в его нынешнем виде. Мне вполне комфортно сделать код открытым и свободно доступным. Реалистично говоря, хотя все утверждения и результаты в этом проекте верны и проверяемы, каждый крупный кластер Kubernetes имеет свои уникальные требования к нагрузке, которые могут — или не могут — хорошо сочетаться с этим подходом. Поэтому я не стал бы называть проект «готовым к продакшену» и не рекомендовал бы использовать его для критически важных систем.

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

Компоненты

В самом начале работы над проектом я спросил у ChatGPT: «Я хочу масштабировать Kubernetes до 1 миллиона узлов. С какими проблемами мне придётся столкнуться?»

Вот часть ответа:

Масштабирование Kubernetes до 1 миллиона узлов — колоссальная задача, требующая преодоления множества технических препятствий. Ниже приведены основные направления, над которыми придётся работать:

Масштабируемость и производительность etcd. etcd — это основа хранилища Kubernetes, отвечающая за все данные объектов API. При миллионе узлов объём данных, управляемых etcd, резко возрастёт. Критически важно оптимизировать производительность etcd: реализовать эффективное разделение данных (шардирование), настроить базу данных для работы под высокой нагрузкой и, возможно, рассмотреть альтернативы или дополнительные решения к etcd для определённых типов данных.

Производительность kube-apiserver. В кластере такого масштаба на kube-apiserver придётся колоссальная нагрузка. Оптимизация его работы включает горизонтальное масштабирование (несколько экземпляров kube-apiserver), ограничение частоты запросов (rate limiting) и эффективные механизмы кэширования. Также важно минимизировать количество событий watch и общее число запросов к kube-apiserver.

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

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

Далее перечисляются и другие проблемы — в том числе логирование и мониторинг, безопасность и управление хранилищем.

Я считаю, что самыми критичными являются первые пункты. Вариантов поддержки логирования или PVC достаточно много, но если вы не можете запустить etcd, спланировать pod’ы или обеспечить их взаимодействие, дальше вы не продвинетесь.

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

Сеть

Типичные сетевые проблемы в Kubernetes связаны с адресным пространством IP, сервис-дискавери и сетевыми политиками (то есть настройкой файрвола). По сравнению с последующими задачами, настройка сети для поддержки миллиона узлов оказалась относительно простой.

IP-адреса pod’ов

Планирование адресного пространства IP в больших кластерах может стать серьёзной задачей. Эффективная маршрутизация обычно подразумевает выделение каждому узлу непрерывного блока CIDR, что означает предварительное резервирование количества IP-адресов (а значит и pod’ов), которые этот узел сможет обслуживать. В результате некоторые узлы, рассчитанные на множество мелких задач, оказываются ограничены числом доступных IP-адресов ещё до того, как исчерпают другие аппаратные ресурсы.

Например, диапазон 10.0.0.0/8 содержит 16 миллионов IP-адресов. Для кластера с миллионом узлов это даёт всего по 15 IP-адресов на узел — очевидно, этого недостаточно.

Решение — полностью перейти на IPv6. Огромное адресное пространство IPv6 позволяет без проблем выделить каждому pod’у собственный глобально доступный IP-адрес.

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

Моя цель — чтобы у каждого узла был префикс IPv6 с таким диапазоном, чтобы каждому pod’у на этом узле можно было назначить собственный IP из этого диапазона. И, разумеется, как минимум один адрес должен оставаться для самого хоста.

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

В основном я сосредоточился на AWS, GCP и Vultr. (Можете посмеяться над Vultr, но у них дешёвые вычислительные ресурсы, а проект я запускаю на собственные средства.) Однако у каждого из этих провайдеров есть свои особенности поддержки IPv6-адресации внутри виртуальных машин. Чтобы показать разницу в подходах, кратко опишу каждый из них ниже:

Vultr: каждый узел получает диапазон /64. Основной IP-адрес узла — это ::1 из этого диапазона, и сервер автоматически принимает весь трафик, адресованный любому IP внутри этого диапазона /64.

GCP: каждый узел получает диапазон /96. Основной IP-адрес узла выбирается случайным образом из этого диапазона. Сервер должен отправлять корректные пакеты NDP RA для тех IP-адресов, по которым он хочет принимать трафик.

AWS: каждый узел получает /128. Через API можно добавить префикс /80 (из другого диапазона) к существующему сетевому интерфейсу или виртуальной машине. (API Create формально позволяет указать как отдельный IPv6-адрес, так и диапазон при создании, но на практике это вызовет ошибку.) Сервер должен отправлять корректные NDP RA-пакеты для IP-адресов, по которым он хочет принимать трафик, а все исходящие пакеты должны использовать единственный MAC-адрес, соответствующий основному IP.

Чтобы удовлетворить все эти требования, особенно ограничение, связанное с MAC-адресами, я создаю один bridge, который используется всеми интерфейсами pod’ов. Интерфейс хоста при этом остаётся отдельным, а для обмена трафиком между bridge и интерфейсом хоста включается пересылка (forwarding). Host-local IPAM получает от провайдера префикс IPv6 /96. Это даёт каждому узлу полный диапазон из 2³² IP-адресов — более чем достаточно для pod’ов.

Так как это глобальные публичные IPv6-адреса, никакая особая маршрутизация не требуется. Не используется ни инкапсуляция пакетов, ни NAT. Трафик от каждого pod’а идёт напрямую, с его реального IP-адреса, независимо от пункта назначения.

Внешние сервисы, работающие только по IPv4

Если у вас есть только IPv6-адрес, вы можете обращаться лишь к другим IPv6-адресам в интернете. Всё, что доступно исключительно по IPv4, становится недостижимым напрямую.

Большинство сервисов, которые я использовал в этом проекте, работали без проблем: пакеты Ubuntu, PyPi, docker.io — всё отлично. Главным исключением оказался GitHub. Сайт github.com по-прежнему упрямо остаётся только на IPv4. Эх.

Многие сервисы AWS имеют dual-stack-эндпоинты, но, что особенно неприятно для этого проекта, Elastic Container Registry (ECR) к ним не относится. Эх, и им — tsk tsk.

Чтобы устройства с IPv6 могли обращаться к хостам, работающим только по IPv4, большинство облачных провайдеров предлагают какой-либо вариант NAT64-шлюза. Можно также поднять свой собственный шлюз на виртуальной машине с Linux. Я слегка перестарался и сделал это через кастомный сервер WireGuard: все виртуальные машины подключаются к нему по WireGuard и используют его как шлюз для доступа к IPv4.

Сетевые политики

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

Кластер из миллиона узлов означает миллион отдельных IPv6-префиксов — слишком много, чтобы какая-либо система файрволов могла с этим справиться. Любители безопасности могут схватиться за сердце, но я не использую масштабное фильтрование трафика, чтобы изолировать кластер от интернета. Я применяю лишь несколько правил файрвола, ограничивающих доступ к нескольким конкретным портам, которые действительно должны быть открыты, а в остальном приходится полагаться на другие методы защиты от несанкционированного доступа к серверам и pod’ам.

Широкое использование TLS покрывает большинство сценариев этого проекта. Огромное адресное пространство IPv6 делает сканирование сети практически невозможным. Cilium, kube-proxy или другие сетевые плагины также могут ограничивать, какие pod’ы могут обращаться друг к другу, но это приведёт к значительной нагрузке на control plane из-за большого количества дополнительных watch-подписок.

Если вы используете одного провайдера для всех узлов, возможно, что все узлы будут получать IPv6-префиксы из одного или нескольких крупных диапазонов — их количество может быть достаточно небольшим, чтобы их реально было прописать вручную в правила файрвола.

Потоки сети (число TCP-соединений)

И kube-apiserver, и etcd поддерживают HTTP/2 и gRPC. Множество отдельных запросов и потоков мультиплексируется через одно TCP-соединение. В Kubernetes по умолчанию для HTTP/2 установлено ограничение в 100 одновременных запросов (или, точнее, потоков) на одно соединение. (HTTP/2 способен поддерживать гораздо больше, но при увеличении числа потоков начинают проявляться проблемы с производительностью — например, head-of-line blocking). Таким образом, каждый kubelet нуждается как минимум в одном соединении с control plane kube-apiserver. Можно также ожидать ещё одно соединение для kube-proxy или аналогичных CNI, таких как Cilium или Calico. При миллионе узлов это означает, что каждый kube-apiserver должен обслуживать не менее 2 миллионов TCP-соединений. Если использовать 8 экземпляров kube-apiserver, то на каждом сервере придётся примерно по 250 тысяч соединений с kubelet.

Сам Linux способен выдерживать такое количество соединений при небольшой настройке. И, разумеется, нужно убедиться, что у вас разрешено достаточно дескрипторов файлов. Тем не менее это число может превышать возможности вашего сетевого провайдера. Например, в документации Azure указано, что одна виртуальная машина поддерживает максимум 500 000 входящих и 500 000 исходящих соединений. GCP и AWS подобных лимитов публично не публикуют, но в любой системе существуют ограничения — как на общее количество одновременных соединений, так и на скорость установления новых.

Управление состоянием

Под «управлением состоянием» я имею в виду API-интерфейс, который Kubernetes предоставляет для взаимодействия с ресурсами. При грамотной настройке kube-apiserver может масштабироваться до достаточно высокого уровня пропускной способности. Однако узким местом остаётся etcd. В этом разделе я объясню, почему так происходит, и опишу альтернативную реализацию, способную справиться с нагрузкой кластера из миллиона узлов.

kube-apiserver и etcd

Сначала краткий обзор того, как в Kubernetes происходит работа с состоянием. Неограниченное число клиентов взаимодействует с kube-apiserver, а тот, в свою очередь, обращается к etcd.

kube-apiserver — без состояния. etcd является постоянным хранилищем всех ресурсов Kubernetes. Все операции CRUD, которые вы отправляете в kube-apiserver, на самом деле сохраняются в etcd.

У kube-apiserver есть семь основных действий, связанных с состоянием:

  • create

  • get

  • list

  • update (или replace)

  • patch

  • delete

  • watch

У etcd есть четыре основных операции:

  • put

  • range — включает get с пустым range_end

  • deleteRange

  • watch

Операции create, update, patch и delete в kube-apiserver в итоге сводятся к выполнению etcd put. (Удаление — это просто put с пустым значением.) etcd не поддерживает частичные обновления значений — можно только полностью перезаписать всё значение целиком. Поэтому любая операция, связанная с изменением ресурса, приводит к новому put всего содержимого этого ресурса.

Операция watch в kube-apiserver может приводить, а может и не приводить к watch в etcd — об этом подробнее ниже.

Обеспечение нужного QPS для кластера из 1 миллиона узлов

Kubelet взаимодействует с kube-apiserver в основном через два типа ресурсов:

  • Node — ресурс, представляющий сервер, на котором запускаются pod’ы.

  • Lease — лёгкий объект «пульса» (heartbeat), который kubelet обновляет, чтобы сигнализировать о своей активности.

Lease — критически важный объект: если он не обновляется вовремя, NodeController помечает узел как NotReady. По умолчанию каждый kubelet обновляет свой Lease каждые 10 секунд. При масштабе в 1 миллион узлов это означает 100 тысяч записей в секунду — только для того, чтобы поддерживать узлы «живыми».

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

Для kube-apiserver такая нагрузка вполне посильна. Он не хранит состояние, поэтому увеличить QPS можно просто запустив больше реплик. Если один экземпляр не справляется с потоком запросов — можно добавить ещё, и трафик распределится между ними.

С etcd всё иначе. Это компонент с состоянием, и из-за этого масштабировать его производительность по QPS значительно сложнее.

etcd слишком медленный

С помощью инструмента etcd-benchmark я измерил производительность одной инстанции etcd, работающей на NVMe-хранилище — около 50 000 записей в секунду. И важно отметить: добавление реплик не помогает. Наоборот, пропускная способность при записи падает, потому что каждая операция должна быть согласована между кворумом реплик для сохранения согласованности данных. Поэтому при типичной конфигурации из трёх реплик реальный QPS на запись оказывается даже ниже этих 50 000 в секунду. Это несопоставимо с тем, что нужно для поддержки кластера из миллиона узлов.

На первый взгляд, 50 000 QPS выглядят подозрительно низким результатом, учитывая возможности современного оборудования. Один NVMe-диск способен выполнять более 1 млн 4K-записей в секунду, а одна планка DDR5 обеспечивает пропускную способность в 10 раз выше. Почему же тогда etcd так сильно отстаёт от реальных аппаратных пределов?

Ответ кроется в интерфейсе и гарантиях, которые предоставляет etcd. Прежде всего, etcd гарантирует, что все операции записи будут надёжно сохранены на диск. Для каждой операции put или delete система выполняет fsync, чтобы записать изменения на диск перед тем, как подтвердить успешное выполнение. Это обеспечивает отсутствие потери данных даже в случае сбоя хоста или отключени�� питания. Однако такая гарантия надёжности резко снижает количество операций ввода-вывода, которое способен выдержать даже современный NVMe-диск.

Кроме того, у etcd довольно широкий набор функций:

  • Это key-value хранилище, которое, естественно, поддерживает чтение, запись и удаление отдельных объектов.

  • Оно позволяет выполнять запросы по диапазону отсортированных ключей.

  • Оно хранит историю всех изменений, поэтому можно запросить более старую версию конкретного ключа или даже диапазон ключей. Со временем старые изменения «сжимаются» (compaction), чтобы уменьшить объём состояния.

  • Оно поддерживает механизм watch, позволяющий в реальном времени получать поток изменений, затрагивающих определённый ключ или диапазон ключей.

  • Есть API для lease, где к ключам можно привязать TTL, после истечения которого они автоматически удаляются, если не продлеваются.

  • Поддерживаются транзакции с атомарной логикой If/Then/Else.

Реализация всего этого набора возможностей делает систему сложной. Помимо простых put и delete, etcd должен поддерживать транзакции, вести многоверсионную историю и обеспечивать консенсус между репликами на основе Raft.

Именно эти возможности дают Kubernetes согласованность и надёжность, но они же накладывают жёсткие ограничения на производительность. Интуитивно понятно: сильная согласованность означает больше сериализации — операции нельзя свободно параллелить. Записи часто обязаны идти по строго упорядоченному пути через Raft, WAL и компактацию, чтобы каждая реплика пришла к единому состоянию до того, как операция будет подтверждена.

В итоге мы имеем «железо», способное на миллионы записей в секунду, но etcd выдаёт на порядки меньшую скорость из-за интерфейсов и гарантий, которые он обязан обеспечивать.

Но действительно ли нам нужно всё это?

Снизить надёжность и отказаться от реплик

Возможно, самое провокационное утверждение во всём этом проекте: большинству кластеров на самом деле не нужна та степень надёжности и устойчивости, которую обеспечивает etcd.

Как будет показано в следующем разделе, большинство операций записи в кластере Kubernetes касается временных (эфемерных) ресурсов.

  • События (Kubernetes Events) живут всего несколько минут.

  • Объекты Lease обычно истекают в течение десятков секунд.

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

  • Узлы постоянно обновляют свой статус через kubelet.

  • Контроллеры синхронизируют состояние DaemonSet и Deployment с реальным состоянием Pod’ов.

Если перестать выполнять fsync для этих временных записей — или вовсе отказаться от их записи на диск и хранить только в RAM, — кластеры смогли бы обрабатывать значительно больше операций и работать заметно быстрее.

Более того, даже полная потеря данных control plane не всегда катастрофична. Многие кластеры сами по себе эфемерны: вся их конфигурация описана в Terraform, Helm или GitOps. В таких случаях восстановить кластер зачастую проще, чем сохранять каждую последнюю запись. Некоторые организации уже относятся к кластерам Kubernetes как к «скоту» — то есть к ресурсу, который можно легко пересоздать.

Если вы ещё не возмутились, я подолью масла в огонь: скорее всего, вам вообще не нужны реплики etcd.

За те пять лет, что я управлял кластерами Kubernetes в OpenAI, у нас ни разу не произошло незапланированного сбоя виртуалки с etcd. Потребности etcd минимальны: база данных ограничена 8 ГБ, а процессорной мощности требуется всего 2–4 ядра. Большинство облачных провайдеров способны выполнять живую миграцию для таких небольших ВМ. А при использовании сетевых хранилищ вроде EBS восстановление элементарно: запускаете новую ВМ, подключаете том — и работа продолжается без потери данных.

Если у вас есть только один экземпляр etcd, и он выходит из строя, — да, control plane Kubernetes временно недоступен. Но pod’ы продолжат работать, узлы останутся достижимыми, и, возможно, трафик всё ещё будет обслуживаться. Если etcd использует EBS, восстановление займёт лишь время, необходимое для запуска новой виртуалки и подключения диска — при этом без потери данных.

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

Всегда следует отключать запись объектов Event и Lease на диск. Всё остальное — вопрос выбора:

  • Вам не нужна надёжность хранения: запустите одну реплику и держите всё состояние в памяти.

  • Вы можете позволить себе потерю нескольких миллисекунд обновлений: запустите одну реплику с сетевым диском, но без fsync.

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

  • Вы крайне осторожны в отношении потери данных: запустите одну реплику с сетевым диском и включите fsync.

Сокращение интерфейса

Как я уже говорил, у etcd довольно широкий интерфейс. Но действительно ли Kubernetes использует все его возможности?

Чтобы это проверить, я написал небольшой инструмент под названием etcd proxy. Он располагается между Kubernetes и etcd, прозрачно перенаправляя весь трафик и при этом записывая логи каждого запроса и ответа.

После этого я развернул кластер Kubernetes и запустил Sonobuoy — стандартный набор тестов на соответствие спецификации Kubernetes. Sonobuoy системно проверяет весь API-интерфейс Kubernetes, чтобы убедиться, что кластер соответствует ожиданиям апстрима. Прогон через proxy позволил получить полный, реальный трейс запросов и нагрузок, с которыми сталкивается etcd в корректно работающем кластере.

Оказалось, что Kubernetes на деле использует лишь небольшую часть интерфейса etcd.

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

Txn-Put

Kubernetes действительно использует запросы Txn, но всегда в следующем виде:

{
  "method": "/etcdserverpb.KV/Txn",
  "request": {
    "compare": [
      {
        "key": "SOMEKEY",
        "modRevision": "SOMEREV",
        "target": "MOD"
      }
    ],
    "success": [
      {
        "requestPut": {
          "key": "SOMEKEY",
          "value": "..."
        }
      }
    ],
    "failure": [
      {
        "requestRange": {
          "key": "SOMEKEY"
        }
      }
    ]
  }
}

Иными словами: выполнить put, если modRev этого ключа совпадает с заданным значением; в противном случае просто вернуть текущую версию. Это логично, ведь Kubernetes часто вносит частичные изменения или обновляет существующие ресурсы, а безопасная реализация этого через put всего объекта требует уверенности, что за это время ресурс не изменился.

Leases

Важно отметить, что Kubernetes Leases — это не то же самое, что etcd Leases. В Kubernetes эти объекты реализованы как обычные пары ключ–значение в etcd. Сам Kubernetes использует etcd Leases крайне редко.

Основная область, где Kubernetes действительно применяет etcd leases, — это объекты Events, например:

{
  "method": "/etcdserverpb.Lease/LeaseGrant",
  "request": {
    "TTL": "3660"
  },
  "response": {
    "ID": "7587883212297104637",
    "TTL": "3660"
  }
}
{
  "method": "/etcdserverpb.KV/Txn",
  "request": {
    "compare": [
      {
        "key": "/registry/events/NAMESPACE/SOMEEVENT",
        "modRevision": "205",
        "target": "MOD"
      }
    ],
    "failure": [
      {
        "requestRange": {
          "key": "/registry/events/NAMESPACE/SOMEEVENT",
        }
      }
    ],
    "success": [
      {
        "requestPut": {
          "key": "/registry/events/NAMESPACE/SOMEEVENT",
          "lease": "7587883212297104637",
          "value": "..."
        }
      }
    ]
  }
}

Цель этого механизма — задать разумное TTL для событий. Он не играет критической роли в модели согласованности Kubernetes.

Ranges

Теоретически etcd мог бы быть реализован как простая хеш-таблица с вставкой за O(1), если бы не поддержка range-запросов. Такие запросы возвращают отсортированный список ключей в заданном диапазоне, а это требует хранения данных в отсортированной структуре. Вставка в отсортированный список или B-дерево выполняется за O(log n). По моему мнению, поддержка Range — самое сложное ограничение, которое etcd должен реализовывать, чтобы оставаться совместимым с Kubernetes. Тем не менее, эта возможность — критически важная.

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

/registry/[$APIGROUP/]$APIKIND/[$NAMESPACE/]$NAME

Range-запросы обычно ограничиваются либо конкретным namespace, либо выполняются по всем namespace для одного типа ресурса (Kind). Kubernetes никогда не делает range-запросов, охватывающих несколько разных типов ресурсов (например, одновременно Pods и ConfigMaps).

Это открывает интересную возможность: вместо одного глобального B-дерева для всего пространства ключей можно хранить отдельное B-дерево для каждого типа ресурса. Таким образом, значение n в O(log n) уменьшается до количества объектов только данного типа, что ускоряет как операции вставки, так и выполнение запросов.

Ещё одна особенность — использование параметра limit в range-запросах. Kubernetes редко запрашивает все объекты сразу; чаще всего запросы возвращают по 500, 1000 или 10 000 результатов за раз. Однако в ответе также ожидается поле count, показывающее общее количество оставшихся объектов. Это сводит пользу от limit на нет, ведь даже простое подсчёт оставшихся ключей может быть весьма затратным.

На практике, однако, Kubernetes не требует, чтобы count был абсолютно точным. Ему достаточно знать, что доступно больше, чем limit результатов. Такая менее строгая зависимость позволяет использовать приблизительные значения — и это как раз одно из направлений, где возможны дополнительные оптимизации.

mem_etcd: собственный in-memory etcd

Я написал новую программу под названием mem_etcd, которая реализует интерфейс etcd, но с описанными выше упрощениями. Она написана на Rust и предоставляет полностью корректную семантику тех API etcd, от которых зависит Kubernetes.

mem_etcd поддерживает две основные структуры данных:

  • Хеш-таблица, в которой хранится всё пространство ключей.

  • B-дерево, индексирующее ключи внутри каждого префикса.

Каждое значение также хранит некомпактированную историю ревизий для своего ключа. Такая архитектура делает записи по существующим ключам O(1), а записи новых ключей и range-запросы — O(log n), где n — количество ресурсов данного типа (Kind). Кроме того, range-запросы требуют дополнительной линейной работы, пропорциональной значению параметра limit.

Несмотря на своё название, mem_etcd может обеспечивать надёжность хранения, записывая журнал предварительных изменений (WAL) на диск. Каждый префикс вида /registry/[$APIGROUP/]$APIKIND/[$NAMESPACE/] сохраняется в отдельный файл. По умолчанию файлы записываются в буферизованном режиме, то есть вызовы put завершаются до того, как данные физически записаны на диск. Это поведение можно изменить с помощью флага командной строки, который включает fsync, заставляя все записи сбрасываться на диск перед завершением put. Также можно настроить, чтобы некоторые префиксы вообще не записывались на диск.

Рисунок 1. Производительность etcd и mem_etcd. При включённом fsync производительность ограничивается примерно 100 000 операций в секунду, тогда как при буферизованной записи на диск она превышает 1 миллион. etcd испытывает серьёзные затруднения при масштабировании, даже если писать на ramdisk, где fsync фактически не должен оказывать никакого влияния.
Рисунок 1. Производительность etcd и mem_etcd. При включённом fsync производительность ограничивается примерно 100 000 операций в секунду, тогда как при буферизованной записи на диск она превышает 1 миллион. etcd испытывает серьёзные затруднения при масштабировании, даже если писать на ramdisk, где fsync фактически не должен оказывать никакого влияния.
Рисунок 2. Средняя задержка на операцию put в etcd и mem_etcd. При включённом fsync задержка резко возрастает — записи начинают накапливаться в очереди, что приводит к заметному росту времени отклика.
Рисунок 2. Средняя задержка на операцию put в etcd и mem_etcd. При включённом fsync задержка резко возрастает — записи начинают накапливаться в очереди, что приводит к заметному росту времени отклика.
% (cd /tmpfs ; etcd-3.5.16 --snapshot-count=9999999999 --quota-backend-bytes=9999999999) &
% parallel -j $X --results out_{#}.txt './benchmark put --total 10000000 --clients 1000 --conns 10 --key-space-size 10000000 --key-size=48 --val-size=1024' ::: {1..$X}

Тесты проводились на паре экземпляров c4d-standard-192-lssd: на одной виртуальной машине запускался mem_etcd, на другой — клиентский бенчмарк.
Результаты наглядно показывают, насколько сильно включение fsync ухудшает пропускную способность и увеличивает задержку. Для сравнения в качестве базового уровня использовалась одна реплика etcd v3.5.16, работающая на диске tmpfs (то есть в оперативной памяти). Это должно быть идеальной средой для etcd, поскольку фактической записи на диск нет, а fsync, хоть и остаётся системным вызовом, не выполняет реальных операций. mem_etcd, напротив, сохраняет свой WAL на локальный NVMe-диск, который в GCE называется Titanium SSD. Несмотря на то, что тип этой инстанции включает 16 локальных дисков, в тесте использовался только один.

 Рисунок 3. Результаты теста etcd-lease-flood.
Рисунок 3. Результаты теста etcd-lease-flood.
% timeout 10 parallel -j $X --results out_{#}.txt   './etcd-lease-flood -num-keys 1000 -workers 100 -key-prefix {#}' ::: {1..$X}

etcd-lease-flood — это пользовательский бенчмарк, созданный для имитации основного типа нагрузки в крупном кластере Kubernetes. Каждый клиент создаёт 100 объектов Lease напрямую в etcd, используя ту же protobuf-схему, что и Kubernetes. Затем для каждого Lease клиент в цикле непрерывно выполняет операции put, стараясь обновлять объект как можно быстрее.

Watch()

Существует несколько типов watch-запросов, и каждый из них имеет свои особенности производительности. Разберёмся подробнее.

Полезные детали о том, как kube-apiserver обрабатывает параметры watch, можно найти в официальной документации:
https://kubernetes.io/docs/reference/using-api/api-concepts/#semantics-for-watch

resourceVersion не задан: Получить состояние и начать с последней версии
Запускается watch с самой последней версии ресурса, при этом обеспечивается согласованность данных (
то есть чтение выполняется из etcd через кворум). Чтобы установить начальное состояние, watch начинается с синтетических событий "Added" для всех экземпляров ресурса, существующих на момент запуска. Все последующие события отражают изменения, произошедшие после этой версии ресурса.

resourceVersion="0": Получить состояние и начать с любой версии
Запускается watch с любой доступной версии ресурса — предпочтительно с самой последней, но это не обязательно. Для установления начального состояния watch также начинается с синтетических событий "Added" для всех экземпляров ресурса, существующих на момент запуска. Все последующие события показывают изменения, произошедшие после стартовой версии ресурса.

resourceVersion="{значение, отличное от 0}": начать с точной версии В этом случае watch запускается с конкретной указанной версии ресурса. События watch будут отражать все изменения, произошедшие после этой версии. В отличие от режимов «Получить состояние и начать с последней версии» и «Получить состояние и начать с любой версии», здесь watch не начинается с синтетических событий "Added" для ресурсов текущей версии.

Сведём ключевые различия в таблицу:

Параметр resourceVersion

Не задан

= 0

> 0

Обслуживается из состояния kube-apiserver (вместо etcd)

Включает начальный список ресурсов

Таким образом, watch часто предваряется запросом list. Запрос list возвращает моментальный снимок набора ресурсов, помеченный определённым номером ревизии. Затем запускается watch с этой версии — и он начинает передавать все изменения, произошедшие после указанной ревизии.

Когда параметр resourceVersion установлен, watch против kube-apiserver не создаёт новый watch в etcd. При запуске kube-apiserver сам открывает потоки watch к etcd для каждого стандартного типа ресурсов. Когда клиент создаёт свой watch, kube-apiserver обслуживает этот поток самостоятельно — на основе уже существующего потока watch к etcd. Таким образом, клиентские watch могут быть затратными для kube-apiserver, но не создают дополнительной нагрузки на etcd. А значит, при необходимости можно масштабировать kube-apiserver горизонтально, добавляя новые экземпляры.

Более того, watch-запросы не так уж сильно нагружают etcd. Каждый watch имеет начальный и конечный диапазон, и эти диапазоны попадают в те же префиксы, что и range-запросы. При каждом put требуется выполнить поиск O(log n) по списку активных watch, чтобы определить, какие из них относятся к данному ключу. Однако watch’ей значительно меньше, чем самих объектов, поэтому n здесь невелик, а поиск выполняется асинхронно после завершения записи, что никак не влияет на время выполнения запроса.

Тем не менее watch-запросы создают сетевое усиление. На каждую запись в etcd может приходиться несколько watch’ей, отслеживающих этот объект. Это приводит к большому количеству исходящего сетевого трафика из etcd. На принимающей стороне находятся kube-apiserver’ы: они объединяют свои собственные watch-потоки, но etcd всё равно отправляет копию данных каждому из них. Хотя добавление дополнительных экземпляров kube-apiserver действительно помогает решить многие проблемы масштабируемости Kubernetes, каждая новая реплика увеличивает нагрузку на сетевой интерфейс etcd. Именно пропускная способность сети etcd становится наиболее очевидным аппаратным узким местом при масштабировании больших кластеров Kubernetes. Тем не менее, эта нагрузка ограничивается только трафиком между etcd и kube-apiserver’ами, и в рамках одного дата-центра с современным оборудованием остаётся достаточно ресурсов для организации высокоскоростного взаимодействия между этими серверами.

Количество watch-подписок на узел

Масштабируя число узлов, я смог измерить, сколько watch’ей создаёт каждый узел. Для kubelet и kube-proxy наблюдается следующее:

  • 4 watch’а на configmaps

  • 2 watch’а на каждый из следующих ресурсов: pods, secrets, services, nodes

  • 1 watch на каждый из следующих ресурсов: namespaces, endpoints, csidrivers, runtimeclasses, endpointslices, networkpolicies

Итого выходит 18 watch’ей на один узел, а для миллиона узлов — 18 миллионов watch-подписок.Однако все они обращаются только к kube-apiserver и не проходят напрямую к etcd. При достаточном количестве kube-apiserver’ов такая нагрузка вполне приемлема.

Update()

Вернёмся к требованию по миллиону Lease от kubelet. kubelet выполняет Update (он же Replace, он же PUT) своего ресурса Lease каждые 10 секунд.

Это старый Lease:

apiVersion: coordination.k8s.io/v1
kind: Lease
metadata:
  creationTimestamp: "2025-06-26T18:27:28Z"
  name: my-node
  namespace: kube-node-lease
  ownerReferences:
  - apiVersion: v1
    kind: Node
    name: my-node
    uid: ef4d9943-841b-49cc-9fc2-a5faab77e63f
  resourceVersion: "1556549"
  uid: 7e2ec4e2-263f-4350-9397-76f37ceb83cd
spec:
  holderIdentity: my-node
  leaseDurationSeconds: 40
  renewTime: "2025-07-01T21:41:50.646654Z"

Это тело запроса Update() при продлении этого Lease:

apiVersion: coordination.k8s.io/v1
kind: Lease
metadata:
  creationTimestamp: "2025-06-26T18:27:28Z"
  name: my-node
  namespace: kube-node-lease
  ownerReferences:
  - apiVersion: v1
    kind: Node
    name: my-node
    uid: ef4d9943-841b-49cc-9fc2-a5faab77e63f
  resourceVersion: "1556549"
  uid: 7e2ec4e2-263f-4350-9397-76f37ceb83cd
spec:
  holderIdentity: my-node
  leaseDurationSeconds: 40
  renewTime: "2025-07-01T21:51:50.650000Z"

Обратите внимание, что поле renewTime обновлено — его значение стало на 10 секунд больше. (На самом деле renewTime всегда устанавливается на 40 секунд вперёд, чтобы можно было пережить отдельные сбои или задержки при обновлении Lease.)

Другим ключевым полем является resourceVersion. Когда клиент отправляет Update() в kube-apiserver, он включает туда тот же resourceVersion, что был у предыдущей версии обновляемого ресурса. Это делается для безопасности — чтобы убедиться, что никакой другой клиент не изменил ресурс за это время. Каждый раз, когда ресурс обновляется на сервере, ему присваивается новая, монотонно увеличивающаяся версия resourceVersion. Операция Update должна включать значение resourceVersion, соответствующее версии ресурса, которую клиент собирается заменить. Таким образом, мы избегаем случайного перезаписывания изменений, внесённых кем-то другим между обновлениями.

Можно было бы подумать, что kube-apiserver просто преобразует эту операцию Update в Txn-Put в etcd, «пробрасывая» команду напрямую и без собственного состояния. К сожалению, реализация Update в kube-apiserver всегда требует получить полную старую версию ресурса. На это есть несколько причин:

  1. Поля, формируемые на стороне сервера: у некоторых ресурсов есть поля вроде status и managedFields, которые обновляются исключительно сервером.

  2. Проверки Admission: интерфейс Admission принимает и старый, и новый вариант ресурса.

Чтобы операции Update выполнялись быстро, kube-apiserver поддерживает watch-кэши для большинства часто используемых ресурсов. Когда происходит обновление, сервер извлекает старую версию ресурса из этого локального кэша. Если по какой-то причине старая версия отсутствует в watch-кэше, kube-apiserver сначала выполняет Range-запрос к etcd, чтобы получить актуальную версию ресурса, а затем вызывает Txn-Put.

Если бы для каждой операции Update приходилось делать два синхронных запроса к etcd, это удвоило бы нагрузку по QPS и увеличило бы задержку. Поэтому крайне важно, чтобы watch-кэш был актуальным и надёжным — это существенно повышает производительность.

Однако это влечёт новое требование и ограничение для кластера на 1 млн узлов: kube-apiserver’ы должны уметь обрабатывать Watch со скоростью не менее 100 тыс. событий в секунду.

В моих тестах именно здесь всё начинает упираться в пределы.

Кэширование и блокировки

kube-apiserver десериализует (и — что критичнее — выделяет память под) 100 тыс. вложенных словарей в секунду. Он хранит их в кэше, который реализован на основе B-дерева, защищённого RWMutex. Этот RWMutex испытывает серьёзную конкуренцию за доступ из-за:

  • вызовов Update(), которые пытаются прочитать кэш, чтобы получить старые объекты;

  • вызовов Update(), которые завершаются (финализатор GuaranteedUpdate()) и записывают новое значение в кэш;

  • событий из потока Watch в etcd, которые также записывают новые значения в кэш.

Добавление новых экземпляров kube-apiserver помогает уменьшить конкуренцию, вызванную операциями Update(), но не снижает нагрузку от watch — ведь каждый kube-apiserver всё равно должен обрабатывать полный поток событий обо всех изменениях. А увеличение числа реплик, в свою очередь, повышает нагрузку на etcd, особенно на его способность распространять копии потоков watch по сети ко всем экземплярам kube-apiserver.

Относительно недавно реализация кэша kube-apiserver была изменена: теперь он работает на основе B-дерева. Ранее он использовал hash map. Переход был введён через флаг BtreeWatchCache, который с версии Kubernetes 1.32 включён по умолчанию. Насколько я понимаю, причина перехода к B-дереву заключалась в ускорении ответа на List() — ведь этот метод должен возвращать элементы в отсортированном порядке, и хранение их в B-дереве позволяет существенно сократить время ответа. Однако теперь операции Get() и Update() существующих элементов выполняются за O(n log n) вместо O(1), что делает их заметно более затратными.

В моих тестах кэш на основе B-дерева не смог масштабироваться выше 40 000 обновлений в секунду на экземпляре c4a-standard-72 в GCP. Кэш быстро устаревает — он просто не успевает обрабатывать поток событий watch, а слишком много времени тратится на ожидание освобождения блокировки кэша.

С прежним кэшем на основе hash map и при наличии 11 экземпляров kube-apiserver реплик оказывалось достаточно, чтобы выдерживать нагрузку в 100 000 обновлений Lease в секунду.

Сборка мусора

kube-apiserver разбирает и декодирует все ресурсы по отдельным полям. Поэтому ресурсы с большим количеством полей создают множество мелких объектов в Go, что увеличивает нагрузку на garbage collector. Добавление дополнительных экземпляров kube-apiserver не помогает, если все они наблюдают одни и те же потоки событий. Полноценного решения этой проблемы нет, но можно немного улучшить ситуацию, настроив параметры GOMEMLIMIT и GOGC.

Я устанавливаю GOMEMLIMIT примерно на 10–20% меньше, чем доступный объём памяти, а GOGC — в диапазоне нескольких сотен.

Планировщик (Scheduler)

Нет смысла иметь кластер на миллион узлов, если вы не можете разместить на нём pod’ы. Планировщик Kubernetes часто становится узким местом при работе с крупными заданиями. В моём бенчмарке размещение 50 000 pod’ов на 50 000 узлах заняло около 4,5 минут — и это уже тревожно долгий результат.

Если pod’ы создаются с помощью контроллеров репликации — таких как Deployment, DaemonSet или StatefulSet, — узким местом может стать не только сам планировщик, но и эти контроллеры. Например, DaemonSet создаёт всплеск примерно из 500 pod’ов за раз, а затем ждёт, пока поток Watch подтвердит, что они действительно созданы, прежде чем продолжить (скорость зависит от множества факторов, но обычно не превышает 5 000 pod’ов в секунду). Планировщик в этот момент даже не успевает включиться в работу, пока pod’ы не будут созданы.

В рамках проекта кластера на миллион узлов я поставил амбициозную цель — размещать миллион pod’ов за одну минуту. Число, конечно, выбрано довольно произвольно, но уж слишком красиво выглядит эта симметрия с миллионами «m».

Кроме того, я хотел сохранить полную совместимость со стандартным kube-scheduler. Конечно, гораздо проще было бы написать с нуля упрощённый планировщик, который показывал бы впечатляющую производительность в узких сценариях, но проваливался бы с треском в реальных условиях эксплуатации. Существующий планировщик оброс множеством сложностей не случайно — они появились в результате многолетнего обкатанного опыта работы в самых разных продакшн-средах. Убирать эти «мешающие» функции ради более быстрого планировщика — значит вводить в заблуждение.

Итак, мы максимально сохраняем функциональность и реализацию kube-scheduler. Что же мешает сделать его более масштабируемым?

kube-scheduler поддерживает состояние всех узлов и работает в цикле O(n·p): для каждого pod’а он оценивает каждый узел. Сначала отбрасываются узлы, на которые pod вообще не помещается. Затем для каждого оставшегося узла вычисляется оценка того, насколько хорошо этот узел подходит под требования pod’а. В итоге pod назначается на узел с наивысшей оценкой (или на случайный из числа лучших, если есть равенство).

У kube-scheduler есть несколько приёмов для повышения производительности:

  • Когда число подходящих узлов велико, он оценивает лишь их часть — вплоть до 5% для крупных кластеров.

  • Он параллелит как фильтрацию неподходящих узлов, так и вычисление оценок узлов для конкретного pod’а.

Это можно распараллелить. И, справедливости ради, планировщик действительно параллелит фильтрацию и вычисление оценок узлов для конкретного pod’а. Но его по-прежнему тянет вниз необходимость делать это для всех узлов. Здесь дело не только в параллелизме — это ещё и задача, которую можно распределять.

Базовый дизайн: шардировать по узлам

Это похоже на классический паттерн scatter/gather из распределённых систем поиска. Представьте, что каждый узел — это документ корпуса, а каждый pod — поисковый запрос. Запрос рассылается на множество шардов, каждый из которых отвечает за свою долю «документов». Каждый шард выбирает своего лучшего кандидата(ов) и отправляет результат центральному агрегатору, который в итоге определяет общий лучший вариант.

Общий паттерн scatter-gather

Ключевое отличие в том, что в системах поиска документы являются только для чтения, поэтому запросы можно выполнять параллельно без конфликтов. В случае же с планированием pod’ов выполнение решения изменяет состояние «документов» — то есть выделяет ресурсы узлов. Если два pod’а будут одновременно назначены на один и тот же узел, один из них может успешно разместиться, а другой — нет, из-за нехватки ресурсов.

Тем не менее для большого кластера разумно закладываться на «оптимистичную конкурентность». То есть предполагать, что несколько pod’ов можно планировать одновременно без конфликтов. При этом мы всё равно проверяем, возник ли конфликт, перед фиксацией результата. А если конфликт случился, мы поступаем корректно — «откатываем» другой pod, то есть он должен быть запланирован заново. Но вероятность и последствия таких ситуаций невелики — настолько, что параллельная работа и допущение небольших потерь усилий дают значительно более высокую пропускную способность.

Итак, моя первоначальная архитектурная идея распределённого планировщика:

Паттерн scatter-gather для планировщика pod’ов в Kubernetes

Relay запускает watch на несScheduled pod’ы в kube-apiserver. По мере поступления потоков pod’ов Relay распределяет их между разными планировщиками.

Каждый Scheduler отвечает за фильтрацию и выставление оценок по своей подвыборке узлов кластера, затем отправляет в Relay узел-победитель и его оценку.

Затем Relay агрегирует эти оценки, выбирает общего победителя и отправляет планировщику ответ true/false, который и определяет, должен ли планировщик действительно привязать pod к выбранному узлу. Таким образом, scheduler здесь — это слегка модифицированная версия стандартного kube-scheduler. Он имеет собственный gRPC-серверный эндпоинт для получения новых pod’ов, содержит кастомную логику, определяющую, за какие узлы кластера он отвечает, и использует расширение Permit для отправки предложенного узла обратно в Relay. Точка расширения Permit выполняется после этапов фильтрации и выставления оценок, но до фактического назначения pod’а. Она возвращает true или false, подтверждая, следует ли действительно размещать pod на указанном узле.

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

Сегодняшний планировщик работает фактически с сложностью O(n × p), где n — количество узлов, а p — количество pod’ов. При росте n эта сложность становится неприемлемой. Шардированный подход помогает справиться с этой проблемой масштабирования: если у нас есть n узлов, можно распределить работу между r репликами, где r — некоторый делитель n, тем самым возвращая рост вычислительной нагрузки к более управляемым значениям.

Стоит, однако, упомянуть одно довольно крупное исключение — эвикции pod’ов. Эвикция происходит, когда появляется новый pod, который нужно запланировать, но в кластере в данный момент недостаточно свободных ресурсов. В такой ситуации планировщик выполняет сканирование всех запущенных pod’ов, чтобы найти набор менее приоритетных, удаление которых освободит место для нового. Честно говоря, я не реализовал этот механизм. Можно вообразить, как распределить вычисления по эвикциям между несколькими шардированными планировщиками, но в этой реализации я этого не делал.

Болезненный длинный хвост: как всё это работает в реальности

На моём оборудовании один планировщик мог фильтровать и оценивать pod по 1 000 узлов примерно за 1 мс. То есть — 1 000 pod’ов на 1 000 узлов за 1 секунду. Напомню, цель — 1 миллион pod’ов на 1 миллион узлов за 60 секунд. Но общая сложность задачи — O(n × p): каждый pod нужно оценить относительно каждого узла. А значит, при переходе от 1K pod’ов и узлов к 1M pod’ов и узлов нагрузка растёт не в 1 000 раз, а в 1 000 × 1 000, то есть в миллион раз. И даже если дать себе 60 секунд вместо одной, всё равно придётся задействовать намного больше планировщиков, чтобы достичь поставленной цели.

Добавить больше Relay и распределить сбор оценок

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

Аналогично можно распределить этап сбора — агрегирование всех оценок и определение победителя. У каждого планировщика и каждого Relay есть эндпоинт Score Gather, а выбор ответственного за сбор оценок для конкретного pod’а определяется хешем его имени: по этому хешу решается, какой планировщик собирает оценки для данного pod’а.

Вот упрощённый пример того, как выглядит увеличение числа Relay. Здесь показан fanout = 3, тогда как на практике я использовал fanout = 10. Цель была — максимально нагрузить, но не превысить предельную пропускную способность каждого сетевого интерфейса, чтобы передать 1M × 4 КБ данных о pod’ах за 60 секунд.

Обратите внимание: схема упакована. Не все планировщики находятся на одном уровне.

Борьба с «длинным хвостом» задержек

Я рассчитывал, что с добавлением реплик время будет сокращаться линейно. На практике я упёрся в плато: сколько реплик ни добавляй, всё оставалось по-прежнему или даже ухудшалось. В среднем большинство планировщиков делали меньше работы и завершались быстрее, но всё чаще появлялись один-два «отстающих», у которых ускорения не было вовсе. Это стало проблемой, потому что нам нужно, чтобы все планировщики вернули свой лучший узел, прежде чем мы сможем выбрать победителя.

Существует известная статья Дже��фа Дина из Google — The Tail at Scale, — где подробно описана именно эта проблема. Наши серверы не работают под управлением реального времени и не изолированы от фоновых процессов. Они заняты множеством второстепенных задач — наблюдением, обновлениями, сборкой мусора и т.д. Сборка мусора особенно критична для приложений на Go, когда речь идёт о плотно синхронизированных системах. Она может прервать выполняющуюся задачу или отложить ожидающую. В итоге операция, которая обычно занимает 300 микросекунд, внезапно растягивается до 1 миллисекунды. И если у вас достаточно серверов, почти гарантированно в каждый момент кто-то из них “зависает” на эти 1 мс. В системах, где требуется тесная координация и все должны ответить прежде, чем двигаться дальше, 99% узлов ждут тот 1% отстающих.

Чтобы уменьшить влияние этого эффекта, я реализовал несколько приёмов:

Используйте закреплённые (pinned) CPU. Это способ гарантировать, что ядра процессора будут полностью выделены под процессы одного контейнера, без постоянных переключений контекста между другими. В kubelet это задаётся через параметр “CPU Manager Policy”. Просто включение этой настройки сделало выполнение моих задач гораздо более стабильным и предсказуемым.

Настройка сборки мусора. Увеличение параметра GOGC выше 100 снижает общее время, затрачиваемое на сборку мусора, — ценой большего потребления памяти. Использование «агрессивного» значения GOGC в сочетании с GOMEMLIMIT, установленным близко к реальному лимиту памяти, позволяет запускать сборку мусора только тогда, когда это действительно необходимо. Я использую GOGC = 800 и GOMEMLIMIT = 90% от лимита памяти контейнера.

Отказ от ожидания отстающих. Проще говоря, не ждите ответа от последних N % участников — это эффективно «обрубает» длинный хвост задержек. Но нужно быть осторожным: если есть один стабильно медленный узел, это может запустить обратную петлю — ему начинают слать всё больше запросов, он становится ещё медленнее и в итоге просто «плавится».

Вообще, стоит просто прочитать статью The Tail at Scale — там подробно описаны и другие возможные сценарии и способы их смягчения.

Одна вещь, которую я не стал делать, — это перекрытие нагрузок между несколькими серверами. Можно было бы назначать каждый узел Kubernetes сразу нескольким шардам, чтобы любой из них мог вычислять и оценивать pod’ы для этого узла.Но я опасался, что это приведёт к несогласованности данных: разные шарды могли бы считать, что на узел запланированы разные pod’ы, в результате чего узел оказался бы переполнен.

Заменить watcher на AdmissionWebHook

Эта часть до сих пор остаётся немного загадочной. Обычно kube-scheduler получает информацию о pod’ах для планирования через watch с фильтром fieldSelector 'spec.nodeName=' — то есть отслеживает pod’ы, у которых поле nodeName ещё не задано. Однако при быстром создании большого количества pod’ов (более 5 000 в секунду) поток watch часто начинал застревать — зависал на десятки секунд.

Вот один особенно показательный пример:

Рисунок 4. Большие промежутки между моментами, когда планировщик видит новые pod’ы. (Заметьте: шкала по оси Y уменьшена в 100 раз относительно реального количества pod’ов.)
Рисунок 4. Большие промежутки между моментами, когда планировщик видит новые pod’ы. (Заметьте: шкала по оси Y уменьшена в 100 раз относительно реального количества pod’ов.)

Иногда, даже когда в кластере было достаточно pod’ов, ожидающих планирования, поток watch зависал настолько, что планировщик фактически оставался без входящих pod’ов для обработки.

Чтобы решить эту проблему, я пошёл на довольно радикальное изменение интерфейса: вместо использования watch, я сделал планировщик ValidatingWebhook. Теперь kube-apiserver при создании каждого нового pod’а напрямую отправлял HTTP-запрос на эндпоинт планировщика, синхронно с операцией create. Обычно Validating Webhook’и применяются для обеспечения безопасности — чтобы разрешать или запрещать определённые поля ресурсов, создаваемые клиентами. В моём случае планировщик одобрял все pod’ы, а webhook использовался исключительно для того, чтобы узнавать о каждом новом pod’е быстрее и синхронно, вместо того чтобы полагаться на задерживающийся поток watch.

Результаты

Я создал кластер из 100 000 узлов и измерил, сколько времени требуется, чтобы запланировать 100 000 pod’ов. Pod’ы не имели ни nodeSelector, ни affinity.

Каждый планировщик запускался на отдельном экземпляре c4d-standard-32, что соответствует 32 ядрам AMD Turin и 128 ГБ памяти DDR5. �� экспериментах, где распределённый планировщик (dist-scheduler) имел более одной реплики, для него также использовалась отдельная виртуальная машина с dist-scheduler-relay.

Каждая реплика dist-scheduler была настроена на запуск 30 внутренних планировщиков, каждый из которых работал с параллелизмом = 2.

В качестве контрольной группы использовался стандартный kube-scheduler 1.32.3, без каких-либо модификаций.

Один из неожиданных результатов — насколько лучше себя показал dist-scheduler с одной репликой по сравнению со стандартным kube-scheduler. Изменение параметра параллелизма не оказало никакого влияния ни на производительность, ни на суммарное потребление CPU — оно стабилизировалось примерно на уровне 20 ядер, оставляя ещё 12 свободными.

Стоит отметить, что добавление реплик dist-scheduler дало почти линейное ускорение: удвоение числа реплик приводило примерно к двукратному сокращению времени выполнения. Эта тенденция сохранялась вплоть до масштаба 256 реплик / 1 млн pod’ов, о чём речь пойдёт в следующем разделе.

Эксперименты

1 миллион узлов, 1 миллион pod’ов с kwok

Конфигурация теста:

  • kube-apiserver: 5× c4d-standard-192s, запущенные через k3s v1.32.4+k3s1

    • kube-scheduler и kube-controller-manager v1.32.4 — отдельные процессы на тех же ВМ

    • feature-gates: kube:BtreeWatchCache=false

    • Без cloud-controller-manager, traefik и servicelb

  • etcd:
    1× c4d-highmem-16, на котором работает кастомная реализация mem_etcd

  • kubelet:

    • Для dist-scheduler: 285× c4d-highcpu-32

    • Для kwok: 7× c4a-highcpu-32

    • Обе группы запускали kubelet через k3s v1.32.4+k3s1

  • kwok: 100 pod’ов, использующих модифицированную версию kwok v0.6.0

  • Планировщик pod’ов: 289 реплик (8670 ядер AMD Turin) пользовательской реализации распределённого планировщика, состоящей из 256 schedulers и 29 relays.

Процедура

  1. Запустить все виртуальные машины. Дождаться, пока kwok и dist-scheduler полностью запустятся.

  2. Создать 1 миллион узлов с помощью команды make_nodes.

  3. Дождаться, пока в базе etcd появятся 1 миллион узлов и 1 миллион объектов Lease.

  4. Создать 1 миллион pod’ов с помощью create-pods.

  5. Дождаться, пока у всех pod’ов появится поле spec.nodeName.

Результаты

На графиках ниже: зелёная линия показывает момент создания первого pod’а, красная линия — момент, когда был распланирован миллионный pod.

etcd

Рисунок 5. ~100–125 тыс. запросов в секунду к etcd.
Рисунок 5. ~100–125 тыс. запросов в секунду к etcd.
Рисунок 6. 99.9-й перцентиль запросов etcd завершается менее чем за 1 мс.
Рисунок 6. 99.9-й перцентиль запросов etcd завершается менее чем за 1 мс.
Рисунок 7. Размер базы etcd растёт примерно до 28 ГБ без компактации.
Рисунок 7. Размер базы etcd растёт примерно до 28 ГБ без компактации.
Рисунок 8. Количество элементов в базе etcd: 1M узлов, 1M lease’ов, 1M pod’ов, примерно 123K событий.
Рисунок 8. Количество элементов в базе etcd: 1M узлов, 1M lease’ов, 1M pod’ов, примерно 123K событий.

kube-apiserver

Рисунок 9. Скорость запросов к kube-apiserver — около 100K lease-запросов в секунду.
Рисунок 9. Скорость запросов к kube-apiserver — около 100K lease-запросов в секунду.
Рисунок 10. Задержка выполнения запросов kube-apiserver.
Рисунок 10. Задержка выполнения запросов kube-apiserver.
Рисунок 11. Потоки Watch по типам ресурсов (максимум среди всех kube-apiserver’ов).
Рисунок 11. Потоки Watch по типам ресурсов (максимум среди всех kube-apiserver’ов).
Рисунок 12. Максимальное количество одновременных запросов: один kube-apiserver достигает лимита 1K во время планирования.
Рисунок 12. Максимальное количество одновременных запросов: один kube-apiserver достигает лимита 1K во время планирования.
Рисунок 13. Средняя нагрузка (load1) на kube-apiserver достигает 156 при 192 ядрах, нагрузка на etcd — 5.
Рисунок 13. Средняя нагрузка (load1) на kube-apiserver достигает 156 при 192 ядрах, нагрузка на etcd — 5.
Рисунок 14. Пиковое потребление памяти: kube-apiserver — ~256 ГБ, etcd — ~38 ГБ.
Рисунок 14. Пиковое потребление памяти: kube-apiserver — ~256 ГБ, etcd — ~38 ГБ.

scheduler

Рисунок 15. Общее количество запланированных pod’ов (смещение красной линии связано с задержкой выборки метрик).
Рисунок 15. Общее количество запланированных pod’ов (смещение красной линии связано с задержкой выборки метрик).
Рисунок 16. Примерно 14 000 pod’ов планируется в секунду.
Рисунок 16. Примерно 14 000 pod’ов планируется в секунду.
Рисунок 17. Среднее время планирования — около 560 мкс на pod.
Рисунок 17. Среднее время планирования — около 560 мкс на pod.

Kwok против kubelet

До сих пор все эксперименты проводились с использованием kwok вместо реальных kubelet. Но насколько это реалистично? Возможно, kwok создаёт совсем иной профиль нагрузки, чем настоящие kubelet, и тогда эти эксперименты не отражали бы работу реального кластера на 1 млн узлов, где на каждом запущен kubelet.

К сожалению, запуск 1 млн реальных kubelet выходит за рамки моего бюджета. Но, возможно, мы можем провести эксперимент меньшего масштаба с настоящими kubelet и сравнить его нагрузку с эквивалентным по размеру кластером на kwok.

При аккуратной настройке я могу запустить тест кластера из 100 000 kubelet. Хитрость в том, чтобы запускать множество kubelet одновременно на одной и той же ВМ. Каждый из них работает в отдельном пространстве имён Linux на хосте. У каждого — собственный IPv6-адрес и собственный диапазон для выделения адресов pod’ов. У каждого — своя копия containerd, с помощью которой создаются вложенные pod’ы.

Развёртывание и управление 100 000 контейнерами kubelet на множестве ВМ звучит непросто. Если бы только существовало ПО для оркестрации всего этого… Ага! Можно создать Kubernetes Deployment из kubelet’ов!

Тем не менее остаются единые точки конкуренции из-за общего ядра. По умолчанию kube-proxy использует iptables, а изменения в iptables выполняются под mutex. nftables быстрее и дружелюбнее к параллелизму, но всё равно остаётся узким местом. Поэтому лучше использовать много маленьких ВМ, а не несколько больших, чтобы распределить ограничения по конкурентному доступу.

Кроме того, чтобы IPv6-подсети каждого kubelet были достижимы от облачного провайдера, нужно распространять пакеты соседских объявлений. Я разворачиваю ndppd на каждой ВМ (как DaemonSet), чтобы обеспечить это.

Конфигурация теста

  • kube-apiserver: 6× c4d-standard-192, запущенные через k3s v1.32.4+k3s1.

    • Без cloud-controller-manager, traefik и servicelb.

  • etcd: 1× c4d-highmem-8 с кастомной реализацией mem_etcd.

  • Kubelet:

    • Для kubelet-in-pod: 426× c4d-highmem-8 (3408 ядер AMD Turin и 27264 GiB ОЗУ)

      • Запущено k3s v1.32.4+k3s1.

    • Для kwok: 2× c4a-highcpu-32.

  • kubelet-pod: используется слегка модифицированный образ k3s v1.32.4+k3s1 с установленной libjansson (добавляет поддержку JSON в nftables, что требуется для kubelet).

Процедура

  1. Дождаться запуска кластера и перехода всех узлов ВМ в состояние Ready.

  2. Развернуть Deployment с kubelet-as-pod.

  3. Масштабировать Deployment до 100 000 реплик.

  4. Снять метрики нагрузки kube-apiserver и etcd.

  5. Уничтожить и пересоздать кластер.

  6. Развернуть kwok и создать 100 000 узлов kwok.

  7. Снова зафиксировать графики нагрузки kube-apiserver и etcd.

Результаты

etcd

Рисунок 18. Частота запросов к etcd почти полностью состоит из операций put (в легенде они ошибочно обозначены как set).
Рисунок 18. Частота запросов к etcd почти полностью состоит из операций put (в легенде они ошибочно обозначены как set).
Рисунок 19. Количество элементов в etcd значительно выше при использовании kubelet — из-за записей Events и реальных pod’ов.
Рисунок 19. Количество элементов в etcd значительно выше при использовании kubelet — из-за записей Events и реальных pod’ов.

 

Рисунок 20. Соответственно, размер базы etcd также существенно больше для kubelet.
Рисунок 20. Соответственно, размер базы etcd также существенно больше для kubelet.

kube-apiserver

Рисунок 21. Частота запросов к kube-apiserver примерно одинакова для обоих случаев.
Рисунок 21. Частота запросов к kube-apiserver примерно одинакова для обоих случаев.
Рисунок 22. kubelet создаёт заметно больше watch’ей к apiserver, чем kwok, однако они не транслируются в watch-запросы к etcd.
Рисунок 22. kubelet создаёт заметно больше watch’ей к apiserver, чем kwok, однако они не транслируются в watch-запросы к etcd.
Рисунок 23. kwok и kubelet демонстрируют существенно разные паттерны watch-событий.
Рисунок 23. kwok и kubelet демонстрируют существенно разные паттерны watch-событий.

 

Рисунок 24. kubelet создаёт более высокую нагрузку на apiserver, чем kwok, но в обоих случаях нагрузка остаётся вполне управляемой.
Рисунок 24. kubelet создаёт более высокую нагрузку на apiserver, чем kwok, но в обоих случаях нагрузка остаётся вполне управляемой.
Рисунок 25. Аналогично, kubelet требует немного больше памяти у apiserver, но и это остаётся в пределах нормы.
Рисунок 25. Аналогично, kubelet требует немного больше памяти у apiserver, но и это остаётся в пределах нормы.

Вывод: насколько большим может быть кластер Kubernetes?

На самом деле размер кластера сам по себе играет куда меньшую роль, чем частота операций над отдельным типом ресурса — особенно созданий и обновлений. Операции с разными типами (Kind) изолированы друг от друга: каждая выполняется в собственной goroutine, защищённой своим mutex. Более того, можно распределить данные по нескольким кластерам etcd в зависимости от типа ресурса, и тогда изменения между разными типами будут масштабироваться практически независимо.

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

Обычная установка etcd на современном оборудовании выдерживает около 50 000 модификаций в секунду. При аккуратном шардировании — например, разделив кластеры etcd для Nodes, Leases и Pods — можно достичь поддержки примерно 500 000 узлов при использовании стандартного etcd.

Замена etcd на более масштабируемое хранилище смещает узкое место в сторону watch-кэша kube-apiserver. Каждый тип ресурса (Kind) в текущей реализации защищён одним RWMutex поверх B-дерева. Замена этой структуры на hash map способна обеспечить примерно 100 000 событий в секунду, чего достаточно для обслуживания 1 миллиона узлов на современном оборудовании. Чтобы выйти за этот предел, можно просто увеличить интервал Lease (например, больше 10 секунд), тем самым снизив частоту модификаций.

На больших масштабах основным совокупным ограничением становится сборщик мусора Go. kube-apiserver создаёт и уничтожает огромное количество мелких объектов при парсинге и декодировании ресурсов, что создаёт сильное давление на GC.
Добавление новых реплик kube-apiserver не решает проблему, ведь все они подписаны на одни и те же потоки событий.

Русскоязычное Go сообщество

Друзья! Эту статью подготовила команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!

Как запустить самому

См. файл RUNNING для инструкций по самостоятельному запуску кластера.