ADG Оставить заявку
Блог DevOps 4 мин чтения

Kubernetes Operators и CRD: написали первый production-оператор для автоматического failover PostgreSQL

Паттерн операторов выходит за пределы etcd-operator: написали первый production-оператор для failover PostgreSQL - контроллер следит за StatefulSet и переключает Service сам.

Контекст момента

Kubernetes Operators и CRD - паттерн операторов выходит за пределы etcd-operator и набирает экосистему

Когда CoreOS в 2016 году выпустил etcd-operator, идея выглядела изящно: упаковать операционные знания о конкретном приложении прямо в Kubernetes-контроллер. Смотришь на состояние кластера, сравниваешь с желаемым, делаешь шаг к сближению. Reconciliation loop. Но тогда это воспринималось как умная штука специально для etcd, а не как универсальный паттерн.

Сейчас картина меняется. Operator SDK от Red Hat вышел в beta, Red Hat выпускает operator-framework, появляется OperatorHub - то есть инфраструктура под экосистему уже строится. Паттерн перестал быть экзотикой и начал превращаться в стандартный способ расширения Kubernetes. Мы решили, что пора написать что-то своё - не демо, а реальный оператор под задачу, которая нас давно раздражала в ручном режиме.

Задача: failover PostgreSQL без ночных звонков

У нас несколько managed-проектов с PostgreSQL в Kubernetes в конфигурации primary + replica через Patroni. Patroni сам по себе умеет автоматический failover, но в Kubernetes есть специфика: после того как Patroni переключает primary, нужно обновить Service, чтобы он указывал на нового лидера. Patroni callback справляется - но это скрипт в Pod, который легко пропустить при обновлении образа или потерять при пересоздании Pod-а. Несколько раз ловили ситуацию, когда Patroni лидера переключил, а Service продолжал слать трафик на бывший primary, которой уже перешёл в read-only.

Решение через ручной мониторинг - дежурный замечает, дежурный идёт переключать - это не то, ради чего мы строим автоматизированную инфраструктуру.

Как устроен оператор

Оператор написан на Go с использованием controller-runtime из operator-framework. Регистрируем CRD - PostgreSQLCluster - в котором описываем кластер:

  • primarySelector - лейблы, по которым оператор находит Pod с текущим primary.
  • serviceRef - ссылка на Service, который должен указывать на primary.
  • healthCheckPath - эндпойнт в Patroni REST API, по которому проверяем статус (/master возвращает 200 только если нода - действующий лидер).

Reconciliation loop работает так: берём все Pod-ы из StatefulSet, опрашиваем Patroni REST API каждого, находим Pod который отвечает 200 на /master, сравниваем с текущим selector-ом Service-а, обновляем если не совпадает. Никаких дополнительных callback-ов в Pod-ах Patroni трогать не нужно - оператор сам находит лидера.

PostgreSQLCluster CR
       |
  reconcile loop
       |
  опрос Patroni /master
       |
  сравниваем с Service.spec.selector
       |
  Service.patch если расхождение

CRD регистрируется обычным манифестом, никаких специальных шаманств:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: postgresqlclusters.adg.io
spec:
  group: adg.io
  names:
    kind: PostgreSQLCluster
    plural: postgresqlclusters
  scope: Namespaced
  version: v1alpha1

v1alpha1 - потому что это первая версия, и мы честны сами с собой насчёт стабильности.

Что получилось на практике

Задеплоили на двух проектах. Тестировали failover намеренно: убивали primary Pod, смотрели что происходит. Patroni переключает лидера за несколько секунд, оператор замечает изменение при следующем цикле reconciliation (интервал - 15 секунд) и обновляет Service. Итоговый downtime для клиентского трафика - порядка 15-30 секунд в зависимости от того, как быстро Patroni переключится.

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

Несколько вещей, которые выяснились в процессе:

  • RBAC для оператора. Оператору нужны права на get/list/watch для Pod-ов и StatefulSet-ов, и get/update/patch для Service-ов. Не меньше - иначе reconcile падает с 403. Не больше - иначе это уже неприятно. Мы прошли несколько итераций пока выработали минимальный ServiceAccount.
  • Оператор сам должен быть отказоустойчивым. Запускаем в двух репликах с leader election через аннотации на Endpoints. Иначе получается, что компонент который должен восстанавливать PostgreSQL - сам single point of failure.
  • Логирование reconciliation. Без structured logging в operator-runtime очень быстро теряешь что происходит при параллельной работе нескольких CR-ов. Добавили поле cluster в каждую запись лога - стало сильно читаемее.

Про operator-sdk и экосистему

Operator SDK заметно снижает порог входа - берёт на себя boilerplate вокруг регистрации контроллера, watch-ей, очереди событий. Код собственно логики получается компактным. Документация пока местами сырая, особенно в части тестирования - пришлось много смотреть в исходники.

OperatorHub как каталог только запускается, операторов там немного. Но уже есть операторы для etcd, Prometheus, Vault, некоторых баз данных. Видно, что паттерн набирает серьёзную экосистему - это уже не история про одного etcd-operator.

Следующий шаг у нас - добавить в оператор управление резервными копиями: CR описывает расписание, оператор запускает Job с pg_basebackup. Это чуть сложнее чем переключение Service-а, но принцип тот же.

Контакт

Нужна такая же инженерная работа?

Опишите задачу и контекст. Ответим в течение рабочего дня, при необходимости подпишем NDA.