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-а, но принцип тот же.