Пришло время окунуться в написание операторов на Golang.

Контекст

Предположим, что у нас есть несколько k8s-кластеров, в которых хотят жить несколько продуктовых команд.

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

Мы тут типа SRE, и хотим устранять toil не через личное геройство, а выдачу коллегам инструментов. Поэтому, мы не будем писать для разработчиков “гайды по заказу и приёмке нод” и просить создавать таски в Jira, а просто напишем оператора, который будет делать всё за нас, а от клиенту протранслируем необходимость заполнить спеку желаемого.

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

Самые торопливые могут сразу пройти посмотреть на код 🏍

Планируем работу

Логика работы нам нужна примерно такая:

graph LR subgraph resources[Custom Resources] node-agreement-a[NodeAgreement A] node-agreement-b[NodeAgreement B] end subgraph reconcilation-loop[Reconcilation Loop] step1(Detect Non-conventional Nodes) step2(Run Satisfy Agreement Job) step1 --> step2 end resources -.- reconcilation-loop
  1. Пользователи описывают свои ожидания в объектах NodeAgreement, а наш оператор на них смотрит
  2. Если надо, оператор создаст задачу в нативной Job-е, чтобы привести ноду к ожидаемому состоянию

Грубо прикинем, что в объект NodeAgreement должны входить:

  • Условия для определения, соответствует ли Node ожиданиям
  • Спецификация Job-ы, которая ноду приведёт к нужному состоянию

Наверное, что-то подобное уже можно найти в CNCF или OperatorHub, но мы тут хотим погрузиться чуть дальше, чем простой helm install, ага.

Начинаем писать код

Генерируем скелет

Базовый код сильно шаблонный, и его возможно инициализировать несколькими командами:

$ operator-sdk init \
  --domain agrrh.com \
  --owner agrrh \
  --repo github.com/agrrh/k8s-node-agreements-operator

DEBU[0000] Debug logging is set
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
Get controller runtime:
$ go get sigs.k8s.io/controller-runtime@v0.15.0
Update dependencies:
$ go mod tidy
Next: define a resource with:
$ operator-sdk create api

SDK сразу предлагает сгенерить наши объекты, сделаем это.

Генерируем объекты

Тут у меня выпадала вот эта ошибка, решилось правкой в Makefile версии CONTROLLER_TOOLS_VERSION на актуальную v0.15.0. Тут самый внимательный читатель может заметить, что статью я написал чуть раньше, чем опубликовал 😼

Создадим наш объект NodeAgreement:

operator-sdk create api \
  --controller \
  --resource \
  --group iaas \
  --kind NodeAgreement \
  --namespaced false \
  --plural nodeagreements \
  --version v1alpha1

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

В целом, это вся подготовительная работа по части кодогенерации.

У вас получится полупустой репозиторий, где модели объектов будут лежать в api/v1alpha1/ и мы, очевидно, захотим добавить что-то в их спецификацию.

Место для внедрения reconcilation-логики будет в internal/controller и мы захотим поправить методы Reconcile().

├── api/
├── bin/
├── cmd/
├── config/
├── hack/
├── internal/
├── Dockerfile
├── go.mod
├── go.sum
├── Makefile
├── PROJECT
└── README.md

Но делать это всё мы будем не сейчас.

Продумываем логику работы

Как мы будем выбирать ноды?

Так как у нас есть Kube API, которое отдаёт JSON, то проще всего будет прогонять его через jq-выражение и ожидать true/false, чтобы понять, подлежит нода обработке, или нет.

Пользователь сможет написать jq-выражение и тут же его проверить:

$ kubectl get node node1 -o json \
  | jq -e '
    .metadata.labels["kubernetes.io/arch"] == "amd64"
    and
    .metadata.annotations["iaas.agrrh.com/team1-node-prepared"] == "true"
  '
false

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

Как запускать полезную нагрузку на конкретной ноде?

Мы можем использовать нативные Job-ы, где дочерний Pod нацелен на ноду через nodeSelector.

Как мы поймём, что теперь с нодой всё хорошо?

Очень просто, нагрузка должна будет отредактировать ноду в случае успеха.

За это будут отвечать RBAC-сущности - Role и ServiceAccount, которые можно создать на уровне оператора. Пусть они позволяют работать с ресурсом node и этого будет достаточно.

Далее по метке или аннотации мы поймём, что нода готова.

Надо ли очищать ноду от нагрузки?

Это может варьироваться. И этим можно управлять из ресурса.

Мы добавим параметр, drainRequired: bool, и пусть он регулирует, надо ли освобождать ноду для выполнения нашей задачи.

Как ограничить blast radius?

Мы сможем задать некий параметр, например, propagationLimit: "N%", который позволит обрабатывать не более N% нод за один раз.

Ревьюим логику

Ещё раз посмотрим, что получилось:

graph TD subgraph NodeAgreement querySpec propagationLimit jobSpec end node["Node"] job["Job"] subgraph NodeAgreementReconcile reconcile-start(("Start")) check-matches{"Is matches?"} check-limits{"Is limit allows?"} create-job("Create Job") reconcile-finish(("Finish")) reconcile-start --> check-matches check-matches -->|Yes| check-limits check-matches -->|No| reconcile-finish check-limits -->|Yes| create-job check-limits -->|No| reconcile-finish create-job --> reconcile-finish end node -.->|when updated| reconcile-start querySpec -..- check-matches propagationLimit -..- check-limits jobSpec -..- create-job create-job -..-> job

Ветка с Drain-ом и прочие “если” тут опущены для простоты. Например, мы не будем пытаться подчищать за собой Job-ы, и прогнать задачу второй раз будет невозможно, пока она не будет удалена. Ну, это всё-таки бложик.

А какие-то особенные ситуации и сообщения можно будет докинуть, скажем, в status объекта NodeAgreement.

Подытожим

На этом с планированием мы закончили, во второй части перейдём к написанию кода.

  • Модель операторов хороша, она позволяет автоматизировать всё то, что ваши пользователи обычно пишут в Jira-задачи
  • Кодогенерация - это прекрасно
  • Kubernetes - это фреймворк, над которым можно надстроить всё, что угодно

Ссылки