Мы уже рассказывали про появление и развитие Kubernetes в одной из прошлых статей. Но в ней история проекта описана достаточно кратко и без технических подробностей. Позже мы нашли текст первого ведущего архитектора проекта, Брайана Гранта, где он описывает историю разработки K8s «изнутри» и объясняет логику, стоявшую за тем или иным инженерным решением. Этот увлекательный материал позволяет проследить предпосылки появления уже привычных фичей и моделей. Мы решили перевести его для всех, кому интересна ретроспектива проекта.

На всякий случай предупредим, что эта статья — компиляция твитов Брайана, поэтому повествование может показаться отрывистым. Каждый тред посвящен одной теме. А ещё здесь почти не будет картинок.

Работа над Kubernetes началась в 2013 году. Однако в его основу легли исследования и разработки, которые велись в течение нескольких лет до этого. Конечно, были и более ранние предшественники, например Linux Containers, Borg, Workqueue и Babysitter, но я сосредоточусь на тех проектах, в которых принимал непосредственное участие. 

Пять лет назад я написал серию тредов в Twitter, посвященных очередному юбилею K8s. Наконец-то я нашёл время, чтобы собрать их воедино.

Содержание

Тема 1. Поды 

В Borg задачи (job tasks) планировались на инстансы Alloc, но почти все прикрепляли группы заданий к каждому инстансу. Часто это были sidecar'ы, например, для сбора логов или кэширования.

Было ясно, что использовать такие группы в качестве явного примитива будет проще. Мы называли их «планируемыми единицами» (Scheduling Units). В Borg они существовали лишь в качестве прототипа, поскольку внедрять новые концепции было слишком сложно. В Omega они превратились в SUnits, в K8s — в Pods — поды, как горошины в стручке.

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

Оно позволяет Kubernetes и другим системам наблюдать за тем, какие приложения запущены (из их образов), когда и почему они сбоят, сколько CPU и RAM используют и так далее. И, что немаловажно, образы можно генерировать во время сборки, а не во время развёртывания.

А ещё — это часто упускается из виду — поды совместно используют сеть, тома и другие ресурсы ОС и машины. Это упрощает переход на них с виртуальных машин.

Мы начали работать над API Kubernetes в октябре 2013 года — ещё до того, как решили, будет ли код проекта открыт или он просто будет проектом на базе управляемого облака. В начале 2014-го исследования активизировались, часть команды начала работать над libcontainer, и в мае 2014 года появился container-agent.

Тема 2. Лейблы 

В Borg были атрибуты «ключ/значение» (key/value), которые можно было использовать как ограничители при планировании. Borgmon использовал целевые лейблы для отражения топологии приложения, окружения и локали. Но у задач (job) изначально не было лейблов «ключ/значение». Поэтому пользователи Borg вставляли значения атрибутов в имена задач, разделяя их точками и тире (длиной до 180 символов), а затем парсили эти имена в других системах и инструментах с помощью мудрёных регулярных выражений.

Было понятно, что балансировщики нагрузки, системы мониторинга, а также инструменты для выпуска, развёртывания и настройки нуждаются в единой базе для присвоения атрибутов, которые могли бы использоваться на протяжении всего жизненного цикла приложения. Однако тегам с одним значением, как в Gmail и GCE, недоставало типов.

Поэтому в середине 2013 года мы предложили использовать лейблы «ключ/значение» как в Borg, так и в Google Cloud. Это облегчило бы высокоуровневое управление приложениями. Понятно, что их было гораздо проще внедрить в молодой проект, каким на тот момент был Kubernetes, где лейблы были с самого начала.

Семантика селекторов лейблов (label selector) изначально была разработана для системы мониторинга. Системы мониторинга и балансировки нагрузки должны были обеспечить возможность построения непересекающихся запросов. Без дизъюнкции общий ключ с разными значениями гарантирует, что два селектора не будут пересекаться.

Селекторы также достаточно просты для того, чтобы их можно было индексировать в обратном порядке. Это можно использовать в watch для поиска активных запросов, соответствующих лейблам нового/изменённого инстанса ресурса. Однако пока это ещё не реализовано в K8s. Примечание переводчика: похожая по смыслу функциональность была реализована в #122717.

Ещё одна вещь, которую мы пока не воплотили и о которой нас просили SRE в первоначальном проекте, — способ задавать ключи и значения лейблов по умолчанию, требовать их, запрещать и проверять. Примечание переводчика: было реализовано в #44703 и #65340.

Тема 3: Аннотации 

В Borg у задач (job) было единственное поле для заметок. Как и в случае с записями DNS TXT, этого оказалось недостаточно. Часто требовалось указать дополнительную информацию.

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

Это общий момент для различных частей API Kubernetes: всегда нужно помнить о том, что может потребоваться несколько значений или пар ключ/значение. Единственного значения часто недостаточно. Я предложил аннотации в issue #1201.

Аннотации стали местом хранения настроек для последующего применения, о чём говорилось в #1178 и #1702, и заменили собой поле description в OpenShift в обновлённой версии API v1beta3.

Кое-кто считал, что два вида строковых метаданных ключ/значение, лейблы и аннотации вызовут путаницу, поэтому я постарался как можно раньше прояснить в документации их различие (например, в #1817). Думаю, объедини мы их, пострадало бы удобство работы.

Тема 4. Контроллеры рабочих нагрузок 

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

Примерно тогда же, когда был открыт исходный код Kubernetes, появилось множество библиотек и инструментов для запуска контейнеров на множестве машин. Одной из них была оригинальная libswarm. Проблема с такими императивными client-side-подходами заключалась в том, что было сложно добавить к ним автоматизацию.

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

Это было реализовано некоторыми другими инструментами, но отсутствие моделирования набора инстансов с явным подсчётом реплик препятствовало автоматизации более высокого уровня вроде горизонтального автомасштабирования и прогрессивных скользящих обновлений, которые появились в Kubernetes 1.1 и 1.2.

Изначально Kubernetes поддерживал только один контроллер рабочих нагрузок — ReplicationController. Он был предназначен для эластичных рабочих stateless-нагрузок с взаимозаменяемыми репликами. Вскоре после того, как был открыт код Kubernetes, мы начали думать над тем, как добавить поддержку дополнительных типов рабочих нагрузок.

Issue #1518 положил начало тому, что в итоге привело  к DaemonSet. Необходимо было понять, стоит ли дополнить ReplicationController или лучше создать новые типы ресурсов. Пользователям других систем не нравилась сложность, связанная с использованием множества типов.

Borg поддерживал только один «контроллер» рабочей нагрузки — Job. (Различия между синхронной машиной состояний Borg и асинхронными контроллерами Kubernetes я рассмотрю позже). Он хорошо описан в научной статье Borg.

Job (массив Tasks) используется для эластичных сервисов, агентов, которые работают на каждом узле, пакетных рабочих нагрузок и stateful-нагрузок. Поэтому у него куча настроек, а для запуска всех этих рабочих нагрузок требуются дополнительные внешние контроллеры.

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

И дело не только в том, что Job является примитивом первого класса в отличие от Tasks, но и в том, что у Task неизменная идентификация (как у StatefulSet в Kubernetes). Это слишком сильное ограничение не только для демонов, но и для рабочих нагрузок с автомасштабированием, рабочих нагрузок CI, корректного (graceful) завершения работы, отладки и так далее.

Job также включает опубликованные BNS-записи для Task — этакий эквивалент эндпоинтов в Kubernetes. Записи BNS хранятся в Chubby, где их можно просматривать (watch-запрос). (Более подробно о watch-запросах в K8s я расскажу позже.)

Разделение подов, контроллеров рабочих нагрузок и эндпоинтов, а также наличие различных контроллеров рабочих нагрузок в Kubernetes показало себя как весьма гибкое решение, позволившее поддерживать разные типы рабочих нагрузок. В итоге мы получили богатое разнообразие контроллеров рабочих нагрузок, «заточенных» под конкретные приложения (они же операторы).

Явное представление PodTemplate в виде отдельного объекта, как предлагалось в #170, также не помешало бы этим сторонним контроллерам, но на практике отсутствие поддержки этой возможности особо никому не помешало. (API существует, но не используется.)

В июне 2013 года я предложил рассматривать контроллеры рабочих нагрузок как слабосвязанные наборы инстансов, сгруппированные с помощью селектора лейблов. Идея была основана на 11-страничном анализе сценариев использования Job'ов в Borg, опубликованном примерно в то же время, когда поступило первоначальное предложение по лейблам.

Это отчасти вдохновило создание replicapool.googleapis.com, хотя отсутствие лейблов в GCE в то время делало реализацию полной модели неосуществимой.

Примечание автора: «шаблон» (template) — это образец, используемый для создания копий одной и той же формы. Думаю, что «шаблон пода» в Kubernetes соответствует этому разговорному определению, но в информатике типичное использование подразумевает параметризацию и/или макродополнение, так что, возможно, «прототип» здесь был бы более уместен.

Ключевой принцип Cloud Native — явное моделирование состояния таким образом, чтобы его можно было контролировать и наблюдать извне. Изначально я включил его в более длинную форму определения для CNCF.

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

Тема 5. Асинхронные контроллеры 

В Borgmaster были синхронные, транзакционные, edge-triggered-машины состояний. Примечание переводчика об edge-triggered-машинах состояний: то есть Borgmaster обновлял свое состояние только в ответ на изменения, сообщаемые Borglet'ами — локальными агентами Borg. Были сложности с их масштабированием, развитием и расширением.

Ресурсы с высокой кардинальностью могли превысить возможности единичной транзакции. Добавление новых состояний «ломало» клиенты. Ненаблюдаемые изменения могли привести к неожиданным переходам из одного состояния в другое. Сложно было добавлять новые типы ресурсов.

В результате, когда новые команды работали над новыми функциями вроде пакетного планирования и автомасштабирования, они встраивали их во внешние компоненты, которые были асинхронными. Получение статуса от узлов (Borglet'ов) также было асинхронным. Omega использовала асинхронные контроллеры.

Omega представляла желаемое и наблюдаемое состояния в виде отдельных записей в своем транзакционном Paxos-хранилище. Поэтому было сложно понять, что происходит. В Kubernetes мы решили представлять status и spec в одном и том же объекте (начиная с v1beta3).

Мы также полностью перешли на модель контроллеров (даже для kubelet, заставив его отчитываться apiserver и менять статус). После этого API можно было использовать в качестве источника истины для других контроллеров.

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

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

Тема 6. Watch 

Глубокая тема, которая продолжает тему контроллеров. Понял, что забыл дать ссылку на документ о контроллерах Kubernetes.

В Borgmaster было две модели: встроенная логика использовала синхронные edge-triggered-машины состояний, в то время как внешние компоненты были асинхронными и основанными на уровнях. Больше о срабатывании по уровню и «сигналу с периферии» (edge trigger) можно узнать из статьи на Hackernoon.

Первым, что я сделал, присоединившись к команде Borgmaster в 2009 году, было распараллеливание обработки запросов на чтение. Около 99% запросов приходилось на чтение, в основном на опрос внешних контроллеров и систем мониторинга.

Только BNS (Borg Name Service — аналог эндпоинтов K8s) писался в Chubby, который обеспечивал репликативное кэширование и уведомление об обновлениях. Это позволяло масштабировать его на гораздо большее число ридеров (приблизительно каждый контейнер в Borg), снижая задержку, которая при опросе могла составлять десятки секунд.

Watch-подобные API уведомлений (они же sync и tail) были характерны для таких систем хранения данных, как Chubby, Colossus и Bigtable. В 2013 году был разработан единый API Watch — больше не нужно было «изобретать велосипед» для каждой системы. Вариант «Observe» добавил последовательность на уровне отдельных сущностей (то есть можно было отслеживать изменения в них).

Мы построили Kubernetes на основе etcd из-за его сходства с Chubby и хранилищем Omega. Когда был открыт доступ к etcd watch через API K8s, наружу просочилось больше данных etcd, чем предполагалось изначально. Нужно было как можно скорее их подчистить.

Модель Kubernetes описана в этом документе на GitHub

В других системах для уведомлений используются шины сообщений. Почему мы не воспользовались ими? Контроллеры должны стартовать с начального состояния. Они не должны отставать или оперировать слишком устаревшим состоянием и должны уметь обрабатывать «пропущенные» события — такова была наша логика в пользу уровней.

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

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

Системы, требующие хранилища ключей/значений для выбора и настройки лидера, базы данных для постоянного хранения данных, внепроцессного кэша для повышения производительности, шины сообщений для обработки событий и хранилища для постоянного хранения данных шины сообщений (3–5 stateful-компонентов), могут быть очень сложными в эксплуатации.

Тема 7. Модель ресурсов Kubernetes: почему мы в конечном итоге сделали её унифицированной и декларативной 

Тема даже более глубокая, чем watch'и. Дополнительную информацию можно найти на GitHub.

Как и большинство внутренних сервисов Google, Borgmaster имел императивный, сложно изменяемый, монолитный RPC API, созданный с помощью предшественника grpc.io, Stubby. Он предоставлял ситуативный набор операций, таких как CreateJob, LookupPackage, StartAllocUpdate и SetMachineAttributes.

Сотни и тысячи клиентов взаимодействовали с этим API. Многие из них были асинхронными контроллерами или агентами мониторинга, как уже говорилось ранее. Ещё был простой инструмент командной строки и два широко используемых конфигурационных CLI.

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

Некоторые расширения основной функциональности, например для пакетного планирования и вертикального автомасштабирования, использовали Borgmaster в качестве хранилища конфигурации, вручную добавляя хранящиеся в объектах Job подструктуры, которые затем извлекались при опросе Job'ов.

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

Omega поддерживала расширяемую объектную модель, и @davidopp предложил разместить API перед персистентным хранилищем (что мы позже и сделали в Kubernetes), но оно не было декларативным. Отдельная работа над единым хранилищем конфигураций была прекращена, поскольку основное внимание стало уделяться Google Cloud.

Google Cloud Platform состояла из независимых сервисов с некоторыми общими стандартами, такими как иерархическая организационная структура и authz. Они использовали REST API, как и вся остальная отрасль, а технология gRPC ещё не существовала. Но API GCP не были нативно декларативными, и Terraform тоже не существовал.

@jbeda предложил создать агрегированное хранилище/сервис конфигураций с последовательными, декларативными CRUD REST API над базовыми API GCP и сторонних сервисов. Впоследствии оно превратилось в Deployment Manager.

Мы объединили опыт этих 5+ систем в модель ресурсов Kubernetes (Kubernetes Resource Model, KRM), которая теперь поддерживает произвольное количество встроенных типов, агрегированных API и централизованных хранилищ (CRDs) и может быть использована для настройки сторонних и независимых сервисов, включая GCP.

KRM последовательна и декларативна. Метаданные и действия (verbs) унифицированы. Спецификация и статус чётко разделены. Идентификаторы ресурсов, созданные по образцу Borgmaster'а, предоставляют декларативные имена. Селекторы лейблов позволяют создавать декларативные наборы.

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

В модели есть некоторые пробелы (например, #34363, #30698, #1698, #22675), но по большей части она облегчает выполнение типовых операций над произвольными типами ресурсов.

В следующей теме я подробнее расскажу о самой конфигурации, например о происхождении kubectl apply.

Кстати, копаясь в старых документах, нашёл диаграмму из предложения по API от декабря 2013 года. Sunit → Pod, SunitPrototype  → PodTemplate, Replicate → ReplicaSet, Autoscale  → HorizontalPodAutoscaler.

Autoscale просто обновляет желаемое количество реплик, а не создаёт новые прототипы; запрос метки идентифицирует набор для мониторинга. Источник
Autoscale просто обновляет желаемое количество реплик, а не создаёт новые прототипы; запрос метки идентифицирует набор для мониторинга. Источник

Тема 8. Декларативная настройка и Apply 

В Google для конфигурирования Borg чаще всего используется язык конфигурации Borg Configuration Language (BCL), полный по Тьюрингу (на нём можно решить любую вычислительную задачу). Фрагмент BCL можно увидеть на слайде 7 в этой презентации:

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

BCL обрабатывался и запускался с помощью CLI borgcfg, который поддерживает такие команды, как «вверх», «вниз» и «обновить». В инструмент была встроена логика для сравнения (diff) и слияния, выполнения скользящих обновлений и других способов обновления «живого» состояния. Логика для общих функций генерации была написана на BCL.

Это привело к созданию монолитной экосистемы конфигураций и инструментов. Даже фреймворки вроде mapreduce и сервисы поверх Borg, такие как BorgCron, были вынуждены использовать BCL и borgcfg для взаимодействия с Borg. Инструменты для начала работы генерировали BCL.

Потом также был разработан язык на основе Python. Он взаимодействовал с логикой обновления через protobuf, который несколько отличался от используемого в Borgmaster'е. Другие языки, например Ruby, в Google не использовались. Было разработано несколько новых языков конфигурации для Borg, но ни один из них не прижился.

BCL послужил источником вдохновения для нескольких проектов (Jsonnet и cue), хотя они и не предназначались для использования в Borg. Aurora и Skycfg были вдохновлены Python’ом.

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

В Kubernetes мы хотели отделить создание и генерацию конфигурации от обновлений до желаемого состояния через API, чтобы пользователи могли писать конфигурацию с использованием привычных языков и инструментов: Jinja, Python, Ruby, JavaScript, Terraform, Ansible и прочих.

Я писал об этом в #1007. Я также считал, что автоматизация должна напрямую писать в API и не должна обновлять какой-либо произвольный язык конфигураций. Для этого нужно было научиться объединять намерения пользователей и автоматические изменения.

Мое первоначальное предложение в issue #1178 состояло в том, чтобы поддерживать и объединять два отдельных слоя желаемого состояния в сервере. Неприятие этой идеи привело к предложению применять Apply на стороне клиента (см. #1702). И вот, наконец, Apply на стороне сервера была реализована.

Приступая к реализации Apply, мы столкнулись с проблемой сложной топологии схем. Объединить два одномерных словаря (maps) очень просто, но у нас, к сожалению, были ассоциативные списки. А ещё были множества (sets) и объединения (unions) без явного дискриминатора . (Решено в KEP-1027.)

Механизм strategic merge patch был разработан для того, чтобы сравнивать и объединять объекты, содержащие ассоциативные списки (неординарные списки с индексными ключами в значениях полей внутри элементов списка).

О мотивации и принципах разработки конфигурации я писал в документации. Первоначальный черновик также содержал наброски того, что стало Applications и kustomize.

В то время как Apply «облегчает совместную работу над конфигурациями для людей и машин» (спасибо @originalavalamp за это описание), kustomize обеспечивает совместную работу между людьми, облегчая модификацию базовых конфигураций, не меняя сами конфигурации.

Декларативный API, Apply и kustomize позволяют хранить конфигурации в виде YAML, JSON или proto (их можно легко править специализированными инструментами), вместо того чтобы использовать YAML, размеченный с помощью макросов, сложные языки конфигурирования или скрипты, написанные на языке программирования общего назначения.

С одной стороны, сотни появившихся с тех пор инструментов доказывают, что разделение формата конфигурации и API работает. Однако некоторые пробелы остаются. Мы надеемся закрыть их с помощью diff, dry run и prune.

Список инструментов можно найти в Google Sheets

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

Несколько лет я сотрудничал с @eric_brewer в Google, в том числе по вопросам конфигурации, работая над Omega и Kubernetes. Во второй половине подкаста Эрик также вкратце рассказывает о декларативной конфигурации.

Кстати, в конечном итоге «production-БД» под названием ProdSpec всё же вышла на рынок. Borg в конечном итоге пришёл к модели, похожей на модель ресурсов Kubernetes и «GitOps» (хотя наша внутренняя система контроля версий — это не Git), описанной в этом видео.

Тема 9. Ограничения при планировании 

Могу много чего ещё написать о конфигурациях, но всё же вернусь к истории. Набор ограничений Borg'а органично развивался с течением времени. Началось всё с требований к памяти, затем последовали многоядерность и NPTL .

Были добавлены другие ресурсы: процессор, диски. Появились жёсткие и мягкие ограничения на атрибуты машин в формате ключ/значение, а также «лимиты атрибутов» для ограничения количества задач на домен отказов. Для реализации выделенных машин использовались автоматически инжектируемые антиограничения.

В Omega были добавлены taints и tolerations, чтобы реализовать ряд специальных средств для запрета планирования большинства задач на определённые машины и/или их вытеснения (evict) с машин, а также средства для отсрочки вытеснения.

Все эти фичи попали непосредственно в Kubernetes: #168, #376, #1574, #17190. @davidopp — TL по планированию в Borg и Omega — работал над некоторыми из этих функций и в K8s.

Мой анализ, написанный в начале 2015 года, вероятно, помог убедить некоторых в том, что Google реально делится своими наработками с проектом. Подробности об устройстве механизмов планирования можно найти на GitHub.

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

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

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

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

Тема 10. Идея создания Kubernetes

В честь KubeConEU и годовщины открытия исходного кода Kubernetes хочу добавить взгляд на эту историю от команд Borg и Omega. В Google большое внимание уделяется как эффективности использования ресурсов, так и эффективности инженерных решений. По этим причинам ещё в июне 2013 года, за несколько месяцев до того, как GCE был готов к статусу Generally Available, команды Borg и GCE начали более тесно работать над совершенствованием обоих инструментов.

Изначально основное внимание уделялось прямой поддержке функций, необходимых Cloud в Borg, чтобы «облачникам» не приходилось выдумывать обходные манёвры из-за отсутствия этих функций (см. мой предыдущий комментарий о двухуровневом планировании).

Google также тратит много сил на постоянное сокращение энтропии в своём внутреннем программном обеспечении и инфраструктуре. Монорепо — один из механизмов для этого. Это также запускает множество попыток «объединить» или «сблизить» несколько систем, которые совместно развиваются для выполнения схожих задач.

В связи с этим через два месяца была создана рабочая группа Unified Compute Working Group, состоящая из Google Cloud и TI — внутренней инфраструктурной группы Google, в которую входил Borg. Задача состояла в том, чтобы разработать «вычислительную платформу», которая могла бы использоваться как облачными, так и внутренними клиентами.

Было очевидно, что виртуальные машины слишком громоздки и неэффективны, а App Engine недостаточно универсальна для запуска широкого спектра внутренних сервисов, таких как веб-поиск и Gmail. Нужна была платформа, похожая на Borg, но основанная на контейнерах.

Шли дискуссии о том, насколько она должна быть совместима с App Engine и Borg. Сравнивались Docker, buildpacks и Omlet (новый агент для узлов, разрабатываемый для замены Borglet). Изначально предполагалось наличие управляемого сервиса вроде GCE, Google App Engine и Borg.

В сентябре 2013 года мнения 9+ участников рабочей группы были собраны и сведены в Unified Compute PRD, ориентированный на обслуживание рабочих нагрузок (например, вместо пакетных). Тогда я впервые узнал о термине «контейнер как услуга».

В октябре были сформированы подгруппы для решения ключевых проблем, в том числе подгруппа по API для управления контейнерами. В ноябре мы привлекли больше людей из Borg и Google Cloud, чтобы обсудить некоторые детали API. В декабре предложение по API было представлено на рассмотрение всей рабочей группы.

На той же встрече было выдвинуто предложение о создании того, что станет App Engine Flexible Environment, а также предложение о создании контейнерной платформы с открытым исходным кодом, чтобы нас не опередили другие OSS-проекты (Hadoop).

Этой контейнерной OSS-платформой был Project 7. Потом было несколько предложений как со стороны Borg, так и со стороны Google Cloud по созданию продуктов с совместимыми API. Углубилось сотрудничество с командой Borg. В апреле 2014 года члены команды Borglet начали работу над libcontainer для Docker.

Вскоре стало ясно, что другие члены команды Borg (я, @thocki, @erictune4, Dawn Chen, @originalavalamp, @davidopp, @vishnukanan) должны проектировать и разрабатывать функциональность по типу той, которая имеется в Borg, в рамках проекта с открытым исходным кодом. Мы глубоко верили в потенциальную ценность для внешних пользователей.

Поэтому мы создали Kubernetes — он был нужен нам, и мы верили, что он пригодится и другим. Глядя на другие решения, доступные в то время (например, docker-cluster, MaestroNG) и впоследствии, мы сделали правильный выбор.

Тема 11. PodDisruptionBudget 

Google постоянно проводит техническое обслуживание программного и аппаратного обеспечения в своих центрах обработки данных: обновление встроенного ПО, ядра и образов, ремонт дисков, обновление коммутаторов, тестирование батарей и т. д. Со временем его разновидностей становится всё больше и больше.

Хотя задачи Borg спроектированы с расчётом на устойчивость, такое обслуживание может привести к серьёзным перебоям. Когда у вас куча задач на обслуживание, неразумно ограничивать их по отдельности — это неэффективно. Кроме того, не всегда реально выполнять все виды обслуживания параллельно.

Даже при ограничении частоты перебоев в работе машины есть риск, что одна и та же задача будет прерываться снова и снова. Поэтому SRE разработали сервис безопасного удаления (Safe Removal Service, он же SRSly).

SRSly отслеживал, как часто задания одной и той же Borg Job прерывались («вытеснялись»). Система технического обслуживания запрашивала у SRSly информацию обо всех задачах, запланированных на машину перед выводом её из эксплуатации. Это позволило Borg придерживаться SLO по прерыванию заданий.

Borgmaster, однако, ничего не знал о SRSly. Вместо этого все критические/production рабочие нагрузки были настроены на работу с одинаковым приоритетом, чтобы исключить их вытеснение друг другом. Делать это для каждой задачи Borg Job в компании было крайне болезненно — подробнее о приоритете/преемственности позже.

Для Omega мы разработали модель, которая может применяться при вытеснении задач как для запуска более приоритетной задачи, так и для обслуживания, — счётчик прерываний. Идея измерять время оказалась неэффективной из-за постоянных изменений, поэтому мы отказались от неё в K8s.

Кажется, я впервые упомянул об этом в Kubernetes в своём большом анализе планирования. Этот вопрос снова возник, когда я предложил maxUnavailable для смягчения одновременных перебоев, вызванных обновлениями, во время разработки Deployment.

Это обсуждение перекочевало в #12611. Примерно в это же время к команде GKE присоединился Мэтт Лиггетт из Borg SRE. В первую очередь он занялся улучшением операции вывода(drain) узлов.

Вместе с @davidopp и @erictune4 мы включили бюджеты перебоев в предложение по проектированию перепланирования (rescheduling). (Перепланирование заслуживает отдельной темы — займусь этим в следующий раз.)

Реализация началась в #24697 и #25551.  

PodDisruptionBudget теперь документирован: disruptions и configure-pdb. Попробуйте его и расскажите, насколько хорошо он работает у вас. Мы планируем перевести его с бета-версии на GA. Примечание переводчика: что и было сделано.

Безопасно вывести нагрузку с узла можно с помощью kubectl drain. Обновления узлов и автоскейлер кластера в Google Kubernetes Engine (GKE) также соблюдают PodDisruptionBudget. Последний описан здесь.

Поведение при обновлении узла описано здесь.

Подробнее о философии Google SRE, лежащей в основе автоматизации, можно узнать из книги по SRE.

Служба безопасного удаления также упоминалась в статье Google «Масштабная миграция виртуальных машин в реальном времени» в VEE 2018.

Тема 12. Продолжение треда про PodDisruptionBudget: депланировщик 

«ДЕпланировщик» тут более уместен, нежели «ПЕРЕпланировщик», потому что его задача — решать, какие поды убить, а не заменять или планировать их.

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

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

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

Простой пример: допустим, автоскейлер кластера добавил новый узел для новых подов. Если эти поды были созданы в результате развёртывания нового Deployment или ReplicaSet, все они могли оказаться на новом узле, если на существующих узлах не хватило места.

Исходя из опыта работы с Borg, мы знали, что планировщик потребуется в Kubernetes с самого начала. По-моему, впервые о нём упоминалось при обсуждении добавления liveness- и readiness-проб.

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

Это разделение обсуждалось при разработке вытеснения для нереагирующих узлов и затем в #12140. Соответствующую документацию можно найти на GitHub

Обратите внимание, что, если текучка в кластере достаточно высока, а вытеснение сильно ограничено из-за PodDisruptionBudgets, депланировщик может не справляться. Это одна из причин, по которой может не получиться достичь «оптимальной» компоновки.

Тема 13. Приоритет и преимущественный запуск 

Некоторые задачи важнее и/или срочнее, чем другие. В Borg это выражалось целым числом — приоритетом. Более высокое значение числа означало, что задача важнее, чем задача с более низким значением. Соответственно, более приоритетная задача могла при необходимости вытеснить менее приоритетную.

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

Бюджеты на перебои так и не были добавлены в планировщик (сделать это было попросту затруднительно). Также были опасения по поводу производительности и инверсии приоритетов. Задачи с более высоким приоритетом могли указывать, сколько они готовы ждать корректного (graceful) завершения задач с более низким приоритетом.

Приоритеты позволяли гарантировать, что production/критические рабочие нагрузки всегда будут получать необходимые ресурсы. Это было необходимо для того, чтобы смешанные рабочие нагрузки могли совместно работать в одних и тех же кластерах. Пакетные и экспериментальные нагрузки выполнялись с более низкими приоритетами, инфраструктурные — с более высокими.

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

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

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

Группы пакетных задач ставились в очередь и поступали в кластер, когда появлялось достаточно ресурсов для их планирования. Квотирование ресурсов по приоритетам предотвращало разрастание приоритетов с течением времени. Между диапазонами оставлялось пространство на случай, если понадобятся новые диапазоны, — по типу нумерации строк в бейсике.

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

Подход, который использовался в Borg, описан в этой статье. Предложения по улучшению K8s были представлены в документации Pod Preemption. О приоритетах в квотировании ресурсов можно почитать здесь, а по этой ссылке — о совместном планировании.

Приоритеты в Kubernetes — сравнительно новое явление, и они всё ещё развиваются. Например, есть предложение добавить политику вытеснения, в первую очередь для того, чтобы не вытеснять другие поды. У Borg есть похожий механизм. Я расскажу о причинах, когда буду говорить о качестве обслуживания.

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

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

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

Тема 14: Качество обслуживания (QoS) с учётом вычислительных ресурсов и избыточная подписка

Что это такое, зачем они нужны и чем QoS отличается от приоритета? Последний вопрос — различие между важностью и срочностью.

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

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

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

И наоборот, свопинг страниц памяти, даже на локальный SSD, обходится очень дорого. Именно поэтому в системах с сервисами, которые должны реагировать на запросы с задержкой в доли секунды, своп отключается. Память считается «нерастяжимым» ресурсом.

Для простоты я сосредоточусь на процессоре и памяти и на минутку позабуду об остальных ресурсах.

Ядро может выделять «растяжимые» ресурсы вроде процессора быстро и с минимальными последствиями для прерванных потоков, если известно, каким потокам срочно нужны ресурсы, а каким — нет. Мы называем их «чувствительными к задержкам» и «толерантными к задержкам» соответственно.

Borg использовал для этого явный атрибут, называемый appclass. В LMCTFY это превратилось в «задержку планирования» (SchedulingLatency).

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

Чтобы быстро перераспределить нерастяжимые ресурсы, необходимо убивать потоки, что, очевидно, нельзя назвать «действием с незначительными последствиями». ( В случае с памятью в Linux это делает OOM Killer.) Именно поэтому Borg использовал приоритет (production-приоритет против non-production) для принятия решений о переподписке памяти.

В статье описывается подход Borg к восстановлению ресурсов: вычислялись резервы, основанные на наблюдаемом использовании, и переизбыток ресурсов (latency-tolerant cpu и non-production memory) сравнивался с резервами, в то время как для гарантированных ресурсов использовались лимиты. Сложно.

Вертикальное автомасштабирование (VA) ещё больше усложнило задачу. VA меняло лимиты, но выделяло собственный запас ресурсов на время реакции и для наблюдения за спросом. Были добавлены специальные механизмы для отключения принудительного ограничения для каждого ресурса — концепция, аналогичная запросам в K8s.

В K8s мне хотелось чего-то более простого; напрямую передать идею переподписки, в то же время не забыв о гибкости при всплесках потребления ресурсов. Обсуждение началось в #147 и #168. Модель, на которой мы остановились, была основана на анализе лимитов и запросов.

Равенство запросов и лимитов (Запрос==Лимит) подразумевает гарантированные ресурсы (не переподписанные). Запрос<Лимит подразумевает возможность увеличения потребления ресурсов (переподписки). Запрос, равный нулю, подразумевает стратегию best effort. Borg планировал best effort, используя резервирование, но на практике не мог гарантировать пропускную способность.

Это описано в проекте модели ресурсов и предложении по QoS, включая привязку к показателям OOM. Привязка к долям CPU cgroup описана в документе о ресурсах пода.

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

При управлении общим доступом на уровне кластера с помощью ResourceQuota и LimitRange переподписка может осуществляться и на этом уровне. Первоначальные версии были описаны на страницах LimitRanger и ResourceQuota, а усовершенствования — на странице Scoping resources.

P. S.

Читайте также в нашем блоге:

Комментарии (0)