Что происходит в Kubernetes при запуске kubectl run? Часть 2

в 8:30, , рубрики: devops, kubelet, kubernetes, Блог компании Флант, контейнеры, системное администрирование

Что происходит в Kubernetes при запуске kubectl run? Часть 2 - 1

Прим. перев.: Вторая и заключительная часть перевода материала, озаглавленного в оригинале как «What happens when… Kubernetes edition!» и рассказывающего о том, какие процессы (каких компонентов и в какой последовательности) происходят в Kubernetes на примере выполнения команды, разворачивающей в кластере 3 пода с nginx.

Если первая часть была посвящена работе kubectl, kube-apiserver, etcd и инициализаторам, то теперь речь пойдёт про контроллеры Deployments и ReplicaSets, информаторы, планировщик и kubelet. Напомню, что мы остановились на моменте, когда переданный пользователем (через kubectl) запрос был авторизован и выполнен в Kubernetes, новые объекты (ресурсы) — созданы и сохранены в базу данных (etcd), после чего — инициализированы (т.е. стали видимыми для apiserver).

Управляющие циклы

Контроллер Deployments

К этому этапу запись Deployment существует в etcd и вся логика инициализации выполнена. Следующие шаги посвящены настройке топологии ресурсов, используемых в Kubernetes. Если задуматься, то Deployment — это в действительности просто коллекция ReplicaSets, а ReplicaSet — коллекция подов. Что же происходит в Kubernetes для создания этой иерархии из одного HTTP-запроса? Здесь берутся за дело встроенные контроллеры K8s.

Kubernetes активно использует «контроллеры» повсюду в своей системе. Контроллер — это асинхронный скрипт, сверяющий текущее состояние системы Kubernetes с желаемым. Каждый контроллер отвечает за свою небольшую часть и запускается компонентом kube-controller-manager. Давайте представим себя первому из них, кто вступает в дело, — контроллеру Deployment.

После того, как запись с Deployment сохранена в etcd и инициализирована, он становится видимой в kube-apiserver. Когда появляется новый ресурс, его обнаруживает контроллер Deployment, в задачи которого входит отслеживание изменений среди соответствующих записей (Deployments). В нашем случае контроллер регистрирует специальный callback для событий создания через информатор (подробности о том, что это такое, смотрите ниже).

Этот обработчик будет вызван, когда Deployment впервые станет доступным, и начнёт свою работу с добавления объекта во внутреннюю очередь. К тому времени, когда он дойдёт до обработки этого объекта, контроллер проинспектирует Deployment и поймёт, что нет связанных с ним записей ReplicaSet и подов. Эту информацию он получает, опрашивая kube-apiserver по label selectors (подробнее о них читайте в документации Kubernetesприм. перев.). Интересно заметить, что этот процесс синхронизации ничего не знает о состоянии (является state agnostic): он проверяет новые записи точно так же, как и уже существующие.

Узнав, что нужных записей не существует, контроллер начинает процесс масштабирования, чтобы прийти к ожидаемому состоянию. Этот процесс осуществляется с помощью выкатывания (например, создания) ресурса ReplicaSet, назначая ему label selector и присваивая первую ревизию. PodSpec и другие метаданные для ReplicaSet копируются из манифеста Deployment. Иногда после этого может потребоваться также обновить запись Deployment (например, если установлен progress deadline; т.е. определено поле спецификации .spec.progressDeadlineSecondsприм. перев.).

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

Контроллер ReplicaSets

На предыдущем этапе контроллер Deployments создал первый ReplicaSet для Deployment, однако у нас до сих пор нет подов. Здесь-то и приходит на помощь контроллер ReplicaSets. Его задача — следить за жизненным циклом ReplicaSets и зависимых ресурсов (подов). Как и у большинства других контроллеров, это происходит благодаря обработчикам триггеров определённых событий.

Событие, в котором мы заинтересованы, — создание. Когда создаётся ReplicaSet (в результате деятельности контроллера Deployments), контроллер RS инспектирует состояние нового ReplicaSet и понимает, что есть разница между тем, что существует, и тем, что требуется. Поэтому он корректирует состояние, выкатывая поды, которые будут принадлежать ReplicaSet. Процесс их создания происходит аккуратно, соответствуя числу всплесков ReplicaSet (унаследованных от родительского Deployment).

Операции создания для подов тоже выполняются пачками, начиная со SlowStartInitialBatchSize и увеличивая это значение вдвое при каждой успешной итерации операции «медленного старта». Такой подход призван снизить риск забрасывания kube-apiserver ненужными HTTP-запросами в случае частых ошибок загрузки подов (например, из-за квот на ресурсы). Если у нас ничего не получится, то пусть это произойдёт с минимальным влиянием на остальные компоненты системы.

Kubernetes реализует иерархию объектов через ссылки на владельца, Owner References (поле в дочернем ресурсе, ссылающееся на ID родителя). Это не только гарантирует, что сборщик мусора найдёт все дочерние ресурсы при удалении ресурса, управляемого контроллером (каскадное удаление), но и обеспечивает эффективный способ для родительских ресурсов не бороться за своих детей (представьте себе сценарий, в котором два потенциальных родителя думают, что владеют одним и тем же ребёнком).

Ещё одно преимущество архитектуры с Owner References — она является stateful: если любой контроллер должен перезагрузиться, его простой не затронет другие части системы, поскольку топология ресурсов не зависит от контроллера. Ориентация на изоляцию проникла и в архитектуру самих контроллеров: они не должны работать с ресурсами, которыми не владеют явным образом. Наоборот, контроллеры должны быть избирательными в своих притязаниях на владение ресурсами, не вмешивающимися (non-interfering) и не разделяющими (non-sharing).

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

  1. удаляется родитель, но не его дети;
  2. политики сбора мусора запрещают удаление ребёнка.

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

Информаторы

Как вы могли заметить, для функционирования таких контроллеров, как авторизатор RBAC или контроллер Deployments, требуется получить состояние кластера. Возвращаясь к примеру с авторизатором RBAC, мы знаем: когда придёт запрос, аутентификатор сохранит для дальнейшего использования начальное представление состояния пользователя. Позже авторизатор будет его использовать для получения из etcd всех ролей и role bindings, связанных с пользователем. Как контроллеры должны получать доступ к чтению и модификации таких ресурсов? Оказывается, это общепринятый сценарий использования и решается он в Kubernetes с помощью информаторов (informers).

Информатор — это паттерн, позволяющий контроллерам подписываться на события из хранилища и получать список ресурсов, в которых они заинтересованы. Помимо предоставления удобной в работе абстракции он также реализует множество базовых механизмов, таких как кэширование (оно важно, потому что снижает количество подключений к kube-apiserver и необходимость в повторной сериализации на стороне сервера и контроллера). Кроме того, такой подход позволяет контроллерам взаимодействовать с учётом потоковой безопасности (thread safety), не боясь наступить кому-нибудь на ноги.

Подробнее о том, как работают информаторы применительно к контроллерам, читайте в этой записи в блоге. (Прим. перев.: О работе информаторов также рассказывалось в этой переводной статье из нашего блога.)

Планировщик

После того, как отработали все контроллеры, у нас есть Deployment, ReplicaSet и 3 пода, хранимые в etcd и доступные в kube-apiserver. Наши поды, однако, застряли в состоянии Pending, потому что ещё не были запланированы/назначены на узел. Планировщик (scheduler) — последний контроллер, который это и делает.

Планировщик запускается как самостоятельный компонент control plane и работает аналогично другим контроллерам: отслеживает события и пытается привести состояние к нужному. В данном случае он отбирает поды с пустым полем NodeName в PodSpec и пытается найти подходящий узел, на который можно назначить под. Для поиска подходящего узла используется специальный алгоритм планирования. По умолчанию он работает так:

  1. Когда планировщик стартует, регистрируется цепочка предикатов по умолчанию. Эти предикаты по сути являются функциями, которые при своём вызове отфильтровывают узлы, подходящие для размещения пода. Например, если в PodSpec заданы явные требования по ресурсам CPU или RAM, а узел не удовлетворяет таким требованиям из-за недостатка ресурсов, этот узел не будет выбран для пода (ресурсоёмкость узла считается как общая ёмкость за вычетом суммы запрошенных ресурсов контейнеров, запущенных в данный момент).
  2. Когда подходящие узлы были выбраны, запускается набор функций приоритетизации, чтобы отранжировать их, выбрав наиболее подходящие. Например, для лучшего распространения рабочей нагрузки по системе приоритет отдаётся узлам, у которых меньше всех запрошенных ресурсов (т.к. это служит индикатором наличия меньшей рабочей нагрузки). По мере запуска этих функций каждому узлу присваивается числовой рейтинг. Узел с самым высоким рейтингом выбирается для планирования (назначения).

Когда алгоритм определяет узел, планировщик создаёт Binding-объект, значения Name и UID которого соответствуют поду, а поле ObjectReference содержит название выбранного узла. Он отправляется в apiserver через POST-запрос.

Когда kube-apiserver получит Binding-объект, registry десериализует его и обновит следующие поля в объекте пода: установит ему NodeName из ObjectReference, добавит соответствующие аннотации (annotations), установит статус PodScheduled в True.

Когда планировщик назначил поду узел, находящийся на этом поде kubelet начинает свою работу.

Замечание по кастомизации планировщика: Что интересно, и предикаты, и функции приоритетизации расширяются и могут определяться флагом --policy-config-file. Это придаёт определённую гибкость. Администраторы также могут запускать свои планировщики (контроллеры с произвольной логикой обработки) для отдельных Deployments. Если в PodSpec содержится schedulerName, Kubernetes передаст планирование этого пода любому планировщику, зарегистрированному под соответствующим названием.

kubelet

Синхронизация пода

Окей, основной цикл контроллеров завершён, уф! Давайте резюмируем: HTTP-запрос прошёл этапы аутентификации, авторизации, контроля допуска; в etcd были созданы ресурсы Deployment, ReplicaSet и три пода; отработал набор инициализаторов; наконец, каждому поду был назначен подходящий узел. До сих пор, однако, обсуждавшееся нами состояние существовало только в etcd. Следующие шаги включают в себя распространение этого статуса по рабочим узлам, в чём и заключается основной смысл работы распределённой системы вроде Kubernetes. Происходит это посредством компонента под названием kubelet. Поехали!

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

Kubelet удобно представлять опять же как контроллер. Он опрашивает поды в kube-apiserver каждые 20 секунд (это настраивается) [о подобных интервалах в Kubernetes рассказывалось в этом материале нашего блога — прим. перев.], отфильтровывая те из них, у которых значения NodeName соответствуют названию узла, где запущен kubelet. Получив список подов, он сравнивает его со своим внутренним кэшем, обнаруживает новые пополнения и начинает синхронизацию состояния, если отличия существуют. Посмотрим, как выглядит этот процесс синхронизации:

  1. Если под создаётся (наш случай), kubelet регистрирует начальную метрику, которая используется в Prometheus для трекинга задержек у подов.
  2. Создаётся объект PodStatus, представляющий состояние текущей фазы (phase) пода. Фаза пода — это высокоуровневое обозначение положения пода в его жизненном цикле. Примеры: Pending, Running, Succeeded, Failed и Unknown. Она определяется не так просто, поэтому давайте рассмотрим, что конкретно происходит:
    • во-первых, последовательно вызывается цепочка PodSyncHandlers. Каждый обработчик проверяет, должен ли под оставаться на узле. Если любой из них решает, что поду здесь делать нечего, фаза пода меняется на PodFailed, а сам под убирается с узла. Например, под должен быть удалён с узла, если превышено значение activeDeadlineSeconds (используется во время Jobs);
    • затем фаза пода определяется по состоянию его init- и реальных контейнеров. Поскольку контейнеры в нашем случае ещё не были запущены, они классифицируется как «в ожидании» (waiting). Любой под с контейнером в ожидании находится в фазе Pending;
    • наконец, условие (condition) для пода определяется условием его контейнеров. Поскольку ни один из наших конетйнеров ещё не был создан исполняемой средой для контейнеров, условие PodReady будет выставлено в False.
  3. После того, как PodStatus создан, он будет отправлен менеджеру состояний пода, который асинхронно обновляет запись в etcd через apiserver.
  4. Далее стартует набор обработчиков допуска, проверяющих, что у пода корректные права безопасности. В частности, применяются профили AppArmor и NO_NEW_PRIVS. Поды, получившие отказ на этом этапе, останутся в состоянии Pending на неопределённое время.
  5. Если указан runtime-флаг cgroups-per-qos, kubelet создаст cgroups для пода и применит параметры по ресурсам. Это сделано для возможности лучшей реализации Quality of Service (QoS) для подов.
  6. Создаются директории с данными подов. К ним относятся каталоги пода (обычно /var/run/kubelet/pods/<podID>), его томов (<podDir>/volumes) и плагинов (<podDir>/plugins).
  7. Менеджер томов подключит все необходимые тома, определённые в Spec.Volumes, и дождётся их. Некоторым подам может потребоваться больше времени в зависимости от типа монтируемых томов (например, облачные или NFS-тома).
  8. Из apiserver будут получены все секреты, указанные в Spec.ImagePullSecrets, для возможности их дальнейшей вставки в контейнер.
  9. Затем исполняемая среда контейнеров запустит контейнер (подробнее описано ниже).

CRI и pause-контейнеры

Теперь мы на этапе, где основная подготовительная часть завершена и контейнер готов к запуску. Программное обеспечение, осуществляющее этот запуск, называется исполняемой средой контейнеров (Container Runtime) — например, docker или rkt.

Стремление стать более расширяемым привело kubelet к тому, что с версии 1.5.0 он использует концепцию под названием CRI (Container Runtime Interface) для взаимодействия с конкретными исполняемыми средами контейнеров. Если вкратце, CRI предлагает абстракцию между kubelet и конкретной реализацией исполняемой среды. Взаимодействие происходит через Protocol Buffers (нечто вроде более быстрого JSON) и gRPC API (тип API, хорошо подходящий для выполнения операций в Kubernetes). Это очень классная идея, потому что при использовании оговоренного соглашения между kubelet и исполняемой средой в значительной степени теряют свою значимость фактические детали реализации того, как контейнеры оркестрируются. Значение имеет только это соглашение. Такой подход позволяет добавлять новые исполняемые среды с минимальными издержками, поскольку основной код Kubernetes менять не требуется.

(Прим. перев.: Подробнее об интерфейсе CRI в Kubernetes и его реализации CRI-O мы писал в этой статье.)

Хватит лирических отступлений — вернёмся к развёртыванию контейнера… Когда под впервые стартует, kubelet выполняет удалённый вызов процедуры (RPC) RunPodSandbox. Слово «sandbox» в её названии — это термин CRI, описывающий набор контейнеров, что на языке Kubernetes означает, как вы догадались, под. Этот термин умышленно является весьма широким, чтобы не утрачивать своё значение для других исполняемых сред, которые в действительности могут использовать не контейнеры (представьте себе исполняемую среду на базе гипервизора, где «песочницей» [sandbox] является виртуальная машина).

В нашем случае мы используем Docker. В этой исполняемой среде создание песочницы включает в себя создание «приостановленного» (pause) контейнера. Pause-контейнер выполняет роль родителя для всех остальных контейнеров в поде, размещая у себя множество ресурсов уровня пода, которые будут использоваться нагруженными контейнерами. Эти «ресурсы» — пространства имён Linux (IPC, сеть, PID). Если не знакомы с тем, как контейнеры работают в Linux, давайте быстро освежим эту информацию. У ядра Linux есть концепция пространств имён (namespaces), позволяющих хостовой операционной системе забирать определённый набор ресурсов (например, CPU или памяти) и назначать его на процессы так, словно они и только они потребляют этот набор ресурсов. Важны ещё и cgroups, поскольку они являются способом, которым Linux управляет распределением ресурсов (подобно полицейскому, контролирующему использование ресурсов). Docker использует обе эти возможности ядра для размещения процесса, для которого гарантированы ресурсы и обеспечена изоляция. Больше информации о работе Linuxx-контейнеров можно получить в этой замечательной публикации b0rk: «What even is a Container?».

Pause-контейнер предоставляет способ размещения всех этих пространств имён и позволяет дочерним контейнерам совместно их использовать. Будучи частью единого сетевого пространства имён, контейнеры одного пода могут обращаться друг к другу через localhost. Вторая роль pause-контейнера связана с тем, как работают пространства имён PID. В пространствах имён такого типа процессы образуют иерархическое дерево, и верхний процесс «init» берёт на себя ответственность за «извлечение» мёртвых процессов (удаление их записей из таблицы процессов операционной системы — прим. перев.). С подробностями о том, как это работает, можно ознакомиться в этой прекрасной статье. После того, как pause-контейнер был создан, для него делается контрольная точка (checkpoint) на диске и он запускается.

CNI и сеть пода

У нашего пода появился скелет: pause-контейнер, приютивший все пространства имён для возможности взаимодействия внутри пода. Но как работает и настраивается сеть?

Когда kubelet настраивает сеть для пода, он делегирует эту задачу плагину CNI. CNI означает Container Network Interface и работает по принципу, похожему на Container Runtime Interface. Если вкратце, CNI — абстракция, позволяющая разным сетевым провайдерам использовать разные сетевые реализации для контейнеров. Плагины регистрируются, и kubelet взаимодействует с ними через данные в JSON (конфигурационные файлы расположены в /etc/cni/net.d), отправляемые соответствующему исполняемому файлу CNI (находится в /opt/cni/bin) через stdin. Вот пример конфигурации в JSON:

{
    "cniVersion": "0.3.1",
    "name": "bridge",
    "type": "bridge",
    "bridge": "cnio0",
    "isGateway": true,
    "ipMasq": true,
    "ipam": {
        "type": "host-local",
        "ranges": [
          [{"subnet": "${POD_CIDR}"}]
        ],
        "routes": [{"dst": "0.0.0.0/0"}]
    }
}

Дополнительные метаданные для пода — например, название и пространство имён — определяются через переменную окружения CNI_ARGS.

Дальнейшие события зависят от плагина CNI — посмотрим на пример с плагином bridge:

  1. Первым делом плагин настраивает локальный Linux-мост в корневом сетевом пространстве имён для обслуживания всех контейнеров хоста.
  2. Затем добавляется интерфейс (один конец veth-пары) в сетевое пространство имён pause-контейнера, а другой его конец подключается к мосту. О veth-паре лучше всего думать как о большой трубе: одна сторона подключена к контейнеру, а другая — к корневому сетевому пространству имён, что позволяет пакетам путешествовать между ними.
  3. Затем интерфейсу pause-контейнера назначается IP и настраиваются маршруты. Результатом станет получение подом своего IP-адреса. Назначение IP делегируется IPAM-провайдерам, заданным в JSON-конфигурации.
    • Плагины IPAM похожи на основные сетевые плагины: они вызываются через исполняемый файл и имеют стандартизированный интерфейс. Каждый должен определить IP/подсеть интерфейса контейнера, шлюз, маршруты и вернуть эту информацию основному плагину. Самый общий IPAM-плагин называется host-local и назначает IP-адреса из предопределённого набора диапазона адресов. Он хранит состояние локально на файловой системе хоста, гарантируя таким образом уникальность IP-адресов на одном хосте.
  4. Для работы DNS kubelet определит внутренний IP-адрес DNS-сервера плагина CNI, который позаботится о правильной настройке файла resolv.conf в контейнере.

Когда этот процесс завершён, плагин вернёт в kubelet данные в формате JSON, сообщая о результате операции.

(Прим. перев.: Про CNI мы также рассказывали в этой статье.)

Сеть между хостами

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

Обычно для реализации этой задачи используется концепция под названием оверлейная сеть (overlay networking), предлагающая способ динамической синхронизации маршрутов на множестве хостов. Одним из популярных провайдеров оверлейной сети является Flannel. Его главная забота после установки — обеспечивать L3 IPv4-сеть между узлами кластера. Flannel не контролирует, как контейнеры подсоединены к хосту (как помните, это работа CNI), а занят транспортировкой трафика между хостами. Для этого он выбирает подсеть для хоста и регистрирует её в etcd. Затем он хранит локальное представление маршрутов кластера и инкапсулирует пакеты в UDP-датаграммы, гарантируя их доставку до нужного хоста. Больше информации можно получить в документации CoreOS.

Запуск контейнера

Все сетевые дела сделаны и остались позади. Что же ещё? Надо выполнить непосредственный запуск рабочих контейнеров.

Когда песочница закончила инициализацию и стала активной, kubelet может начать создание контейнеров для неё. Первым делом он стартует init-контейнеры, определённые в PodSpec, а затем — основные. Процесс таков:

  1. Забирается образ для контейнера. Для частных реестров используются секреты, определённые в PodSpec.
  2. Через CRI создаётся контейнер. Для этого наполняется структура ContainerConfig (в которой определяются команда, образ, лейблы, монтирования, устройства, переменные окружения и т.п.) по данным родительского PodSpec и отправляется через protobufs в плагин CRI. Для Docker перед отправкой данных в Daemon API десериализуется payload и наполняются структуры конфигов. В процессе также контейнеру добавляются несколько лейблов (например, тип контейнера, путь до лога, ID песочницы).
  3. После этого контейнер регистрируется у CPU Manager — это новая возможность, появившаяся в версии 1.8 в статусе alpha и назначающая контейнеры наборам CPU локального узла с помощью метода UpdateContainerResources в CRI.
  4. После этого контейнер запускается.
  5. Если зарегистрированы какие-либо хуки после запуска контейнера (post-start), они запускаются. Хуки могут быть типа Exec (исполняют конкретную команду внутри контейнера) или HTTP (выполняют HTTP-запрос к endpoint контейнера). Если хук выполняется слишком долго, зависает или завершается с ошибкой, контейнер никогда не перейдёт в статус Running.

Итоги

Окей. Готово. Конец.

После всего этого у нас должно быть 3 контейнера, запущенных на одном или нескольких рабочих узлах. Сеть, тома и секреты были подготовлены kubelet и внедрены в контейнеры через плагин CRI.

P.S. от переводчика

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

Автор: Андрей Сидоров

Источник

Поделиться

* - обязательные к заполнению поля