… и как бы это могло выглядеть в таком случае.

image

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

Мир задач и мир микросервисов


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

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

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

Но в чем же существенная разница?


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

Но мы также можем и описать задачи таким образом, что они будут похожи на сервисы, приравнивая каждый запуск задачи к запросу в сервис. С этой точки зрения, планировщик задач выполняет роль балансировщика нагрузки, а каждый запуск задачи это и есть обработка запроса. А схема, когда каждый запрос это отдельный процесс, уже очень смахивает на архитектуру приложения вида «forking server».

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

Давайте сразу проясним одну напрашивающуюся изначально мысль: наше представление о сервисах, как о работающих вечно — не совсем точно. Действительно, многие сервисы не завершаются сами по себе, но они могут завершаться из-за критической ошибки, как часть процесса автомасштабирования или при обновлении версии приложения. Так что же именно мы подразумеваем, утверждая, что они «работают вечно»? Самый главный аспект этой мысли — сервисы не хранят свое состояние (stateless), что иначе можно сформулировать как «легко перезапускаемые». Ввиду того, что каждый цикл «запрос-ответ» обычно исчисляется в секундах, и обычно мы минимально храним состояние на стороне сервера вне обработчика запроса, перезагрузка сервиса ведет к потере лишь нескольких секунд работы. В свою очередь, одноразовые задачи в целом не подлежат перезагрузке, т.е. если мы запускаем многочасовую задачу и прерываем ее после одного часа работы, то этот час придётся пройти заново, что является крайне неприятным эффектом. Но если же мы создадим задачу, которая будет создавать и хранить чекпоинты каждые 5 секунд, делая ее в должной мере «stateless», то мы получим перезагружаемые задачи, которые очень похожи на сервисы.

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

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

Как видит мир Kubernetes


Technology is neither good nor bad; nor is it neutral.
Melvin Kranzberg

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

Kubernetes — для сервисов


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

— «Service discovery and load balancing» решает вопрос разрешения доменных имен для одной и более копий сервиса. Это не предназначено для разовых задач, в которых просто нет необходимости использования доменов или распределения запросов по множеству реплик.

— «Automated rollouts and rollbacks» упрощает обновление приложений, отключая и обновляя только несколько копий за раз и повторяя это, пока все копии не подменятся новой версией. Это не применимо для задач, которые тяжело перезагружаются и завершаются сами, поэтому в их случае правильнее просто дождаться завершения, и чтобы новый запуск уже произошел на новой версии, не теряя никаких промежуточных результатов работ.

— «Self-healing»: перезагрузка задач, которые завершились с ошибкой, это полезное свойство, однако у задач нет как такового понятия «health check», и проверка задачи на готовность тоже не применима.

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

— Принципы “Secret and configuration management” и “Storage orchestration” в целом одинаково применимы и для задач, и для сервисов.

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

Kubernetes не верит в оркестрацию


Все та же страница «Overview» говорит нам:

Kubernetes — это не просто система оркестрации. Фактически мы убираем саму необходимость в оркестрации. Технически, оркестрация определяется как исполнение определенного рабочего процесса, сначала сделать A, потом B, а затем C. А Kubernetes представляет собой набор независимых контролирующих процессов которые постоянно следят за тем, чтобы текущее состояние совпадало с желаемым. Вас не должно заботить как из состояния A происходит переход в состояние C. В единой точке контроля тоже нет необходимости. Это рождает систему, которая проще в использовании и является более мощной, надежной и расширяемой.

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

Напротив, планировщики задач вроде Airflow — это именно инструменты оркестрации в точности как их описывает этот отрывок. Да, мы можем просто запустить Airflow поверх Kubernetes и закрыть этот пробел. Но сам Kubernetes намеренно делает такой подход сложнее для реализации нативным образом.

У Kubernetes есть концепт «Job» для запуска одноразовых задач, но полное противодействие поддержке сценария «сначала сделать А, затем Б» говорит нам, что Job API вероятно никогда не добавит такой концепт на фундаментальном уровне. Единственная надежда остается на то, что мы когда-то сможем декларативно описать состояние «Чтобы запустить задачу Б, задача А должна завершиться успешно». Но пока что это невозможно, и несмотря на то, что полностью декларативная модель имеет массу преимуществ, она по-прежнему не является предпочтительной для определения зависимостей между задачами.

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

Больше недостающих функций


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

Внешнее прерывание подов

И сервисы, и задачи запускаются в виде подов, в теории полностью абстрактные, но по факту отдающие предпочтение именно сервисам. Один пример — это прерывание подов ради других «более важных» подов, что сразу же предполагает их легковесный перезапуск. Документация упоминает, что этот принцип не сильно подходит запуску задач, и предлагаемый совет опять же ломает сам концепт — предлагается установить параметр preemptionPolicy как Never. Поды с такой настройкой не будут прерывать другие, что в целом нормально работает только когда все поды кластера ведут себя так же. А в идеале же мы бы хотели видеть кластер, в котором свободно живут и сервисы, и задачи, и существует возможность гарантированность непрерывность одних и эфемерность других. Существуют обходные решения, вроде выставления большего приоритета задачам или использования «pod disruption budget», но это здесь не упоминается. И возвращает нас к мысли, что нам требуется больше усилий в тонкой настройке запуска задач в Kubernetes, чем это должно быть.

Компонуемость

Kubernetes работает только с контейнерами, которые тоже больше подходят для сервисов при связке друг с другом. Если мы хотим, чтобы два контейнера могли взаимодействовать друг с другом, выставить сервисы в сеть — это единственно возможный способ. К примеру, нам нужно запустить код на Python, который делает вызов в приложение ImageMagick для обработки картинки. Мы выберем контейнер на базе образа Python, а для ImageMagick соответственно возьмем свой отдельный образ. И теперь рассмотрим варианты настройки их взаимодействия:

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

Мы можем запускать контейнер с ImageMagick как консольное приложение. Kubernetes поддерживает обмен файлами между контейнерами в рамках одного пода, поэтому мы как минимум можем обмениваться вводом-выводом через файлы, но нет надежного способа запускать команду и обработать ее завершение. Конечно, сделать возможно все (к примеру, запускать Job и постоянно звать Pod API, чтобы среагировать на завершение), но философия Kubernetes не особенно поддерживает такие решения.

Мы могли бы обернуть ImageMagick в сервис. Звучит нелепо, но именно так и поступают в большинстве случаев — вместо разработки command-line инструментов, оборачивают такие решения в сервис, чтобы обойти стороной проблему взаимодействия.

По сути, в Docker, «Compose» означает следующее:

Compose является инструментом для определения и запуска мультиконтейнерных Docker-приложений. В Compose используется YAML формат для определения сервисов приложения. И далее одной командой можно создать и запустить эти сервисы из конфигурации.

Хорошо, даже если мы неохотно согласимся с идеей подачи любого приложения как набора сервисов, Kubernetes не дает нам хороших путей одновременно запуска таких приложений и запуска Job в качестве клиента:

Самый простой способ — иметь постоянно запущенными одну или несколько копий сервиса ImageMagick. Но это — постоянная трата ресурсов, пока мы не запускаем ничего, что вызывает его. И наоборот, этого будет недостаточно при одномоментном запуске тысячи распределенных задач, который начнут массовую обработку картинок.

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

Еще одним решением могут стать «sidecar containers», когда мы запускаем и исходное приложение, и ImageMagick в одном поде. В целом это работает, но не предоставляется никаких способов автоматически останавливать такие сторонние контейнеры при завершении основной задачи. Предложение внести нужные изменения были отклонены как «шаг в неверном направлении развития».

Рассматривая все возможности обращения задачи к другим контейнерам, мы заключаем, что это возможно с имеющимися инструментами, но сам Kubernetes делает это гораздо сложнее, чем нужно.

Задачи по требованию


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

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

Однако в случае любого добавочного сервиса у нас встает вопрос подключения к нему снаружи кластера. Как возможное решение, можно сделать машину разработчика частью кластера с возможностью подключения к очереди RabbitMQ изнутри. Или использовать проброс портов, что обычно рекомендуется использовать для дебаггинга. Или предоставить доступ через Ingress, что для одной пары клиент-сервис является избыточным. Идеального простого способа добиться подключения снаружи, не прибегая к дополнительным инструментам, нет.

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

Распределенные задачи, которые вроде как работают


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

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

Самый легкий путь это просто создавать один Job на задачу. Согласно документации, работать это будет плохо на больших объемах задач. По сообщениям пользователей тяжело становится уже на уровне нескольких тысяч задач. Похоже, лучший способ решения это использование индексированных задач, что является сравнительно новой возможностью, которая позволяет запускать задачи одного типа в несколько копий, предоставляя их уникальный индекс через переменную среды JOB_COMPLETION_INDEX. Это дает нам базовую основу для распределенных задач, покуда нашим задачам достаточно иметь просто указатель на свой кусок данных, и не требуется предоставлять куда-либо данные на выходе. В самом простом случае, где нужно обработать файл, и каждый экземпляр задачи получает n строк из этого файла, пропуская JOB_COMPLETION_INDEX * n первых строк, с возможной записью результата в базу данных, это сработает.

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

Распределенная группировка


Еще одна продвинутая возможность распределенных задач — это распределенная группировка. Это когда наши данные уже сгруппированы по одному свойству (например, дата), а задачи мы хотим сгруппировать по другой колонке (например, почтовый индекс), перед выполнением вычислений. И это требует перераспределения обрабатываемых слепков данных, известного как «shuffle» (или по-другому map-reduce). Взаимодействие между разными обработчиками задачи критично для таких «шаффлов». Мы получим два уровня обработчиков: первый тип, который получает кусок данных по дате, перегруппирует данные по индексу и передает нужные строки представителям второго типа.

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

Это как раз то, что делает Spark, но и в его случае присутствуют элементы несоответствия основным парадигмам. Реализация методики Shuffle у Spark предполагает интенсивную работу с локальным диском для обработки данных, не уместившихся в оперативную память. Но Kubernetes не позволяет использовать на полную производительность локальных дисков, потому что разрешает только локальное кэширование внутри контейнера без использования кэша в ядре. Как итог, производительность shuffle страдает на Spark в Kubernetes.

Кэширование данных


Поговорим еще о кэшировании при распределенных задачах. Большинство решений по распределенным вычислениям предполагают переиспользуемый кэш данных, хранящийся на стороне обработчика. На примере Spark это известно как RDDs — «устойчивый распределенный набор данных». Мы можем создать кэш RDD в Spark, что будет означать, что каждый обработчик Spark будет хранить один и более кусков данных RDD. Для любых дальнейших вычислений на этом RDD, обработчик, хранящий определенный кусок данных, будет производить работу, предназначенную именно для него. Подход «запускать код там, где лежат нужные данные» — критический компонент эффективной системы распределенных вычислений.

Kubernetes в целом недоброжелателен по отношению к идее хранения локальных данных на своих рабочих нодах, активно рекомендуя избегать использования HostPath. И даже несмотря на весь спектр возможностей привязки рабочих нагрузок к определенным группам нод через настройки affinity и anti-affinity, taints и tolerations, а также topology spread constraints, ни одна из этих настроек не даст нам понять какие именно данные хранятся на определенной ноде.

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

Будущее пакетных задач в Kubernetes


Целью этой статьи не является утверждение, что Kubernetes плохо спроектирован или реализован. Здесь можно долго спорить, что для сервисов-монолитов Kubernetes излишен, а для администратора 120 микросервисов это прекрасный выбор. Если нужно связать веб приложение с набором DNS записей, базой данных и выпуском SSL сертификатов, то нет решения, где это будет сделать проще. Однако также находятся и те, кто предрекает смерть платформы через 5 лет. Мы лишь пробуем показать, что Kubernetes склоняется к определенной точке зрения, которая не благоприятствует системам, основанным на запуске множества задач. Было бы очень закономерно и разумно услышать от сообщества Kubernetes, что эта платформа ставит во главу угла запуск сервисов. Это бы дало возможность рождения альтернативных платформ, заточенных все-таки под запуск завершающихся задач.

С другой стороны, конечно, раз Kubernetes уже стал «платформой для создания платформ», то мы наблюдаем как Spark-в-Kubernetes получает все большую поддержку. Но также звучит маловероятным, что в недалеком будущем Kubernetes сможет сменить свою философию и позволит задачам стать полноценными рабочими нагрузками наравне с сервисами.

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


  1. jenki
    22.11.2022 17:34
    +4

    Намешано и вместе тем перепутано многое несовместимое.

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

    Это про событийно ориентированную архитектуру

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

    Микросервисов - это важно. Потому что есть разница между сервис ориентированной архитектурой и микросервисной архитектурой.

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

    Вот сами сформулировали разницу между архитектурами.

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

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

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

    Эта функциональность про горизонтальное масштабирование экземпляров пода, а не про запуск автоматом требуемого количества копий. Чтобы запустить требуемое количество копий пода, есть специальная опция replicaset.

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

    Для этих целей в Kubernetes имеется сущность services.

    Kubernetes - это прежде всего специализированный инструмент, а не серебрянная пуля. У него есть спектр задач, для которых он разрабатывался и предназначен. Есть для которых он совсем не предназначался. И когда в Kubernetes пхают кластер с реляционной СУБД и думают, что кубы разрулят переключение мастера базы (бывает и такое), то тут сами себе злобные буратины. Тоже самое как в случае запуска монолита с кешами (кеши они хранят в S3 хранилище, чтобы не потерять).
    Поэтому стоит хорошо понимать предметную область, в которой этот инстумент работает и вашу, которую хотите реализовать. В вашем собитийно ориентированном подходе лучше всего подходят брокеры сообщений, многие их которых давно и успешно запускаются в Kubernetes, где реализуется высокая доступность и масштабируемость.


    1. artazar Автор
      23.11.2022 04:42
      +1

      Спасибо, ваши доводы верны. Автор скорее раскрывает мысль о том, что Kubernetes дает возможность выполнения задач, при этом долгое время пренебрегая расширением функциональности этой возможности. И соответственно предостерегает читателя от того, чтобы идти этим путем.

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