Как мы пересобрали кластер и мигрировали MongoDB RS, чтобы минимизировать простой приложения
Привет, меня зовут Андрей Товстоног, я DevOps-инженер в команде GMEM компании Genesis. Данная статья поможет выполнить бесшовную миграцию БД почти в любых кейсах, к примеру, как случилось у нас.
Мы в GMEM разработали собственную CMS, которую и используем на всех наших проектах. Она состоит из трех компонентов: бэкенд, фронтенд и административная панель. По сути, это монолит, но вокруг крутятся дополнительные сервисы, и в данной статье я поделюсь опытом миграции со старого кластера K8s на новый — одного из таких сервисов, который в качестве базы данных использует MongoDB. Также бегло рассмотрим функционирование ReplicaSet. Еще нужно обратить внимание на то, что данное решение затрагивает небольшими изменениями темплейт официального Helm чарта, и немного будут изменены имена MongoDB инстансов.
Статья будет интересна любому, кто пытается минимизировать время простоя приложения. Если интересно — велкам под кат.
Иллюстрация Алины Самолюк
С чего вообще все началось
Почему мы вдруг задумались о такой задаче? Ведь проще взять дамп и катнуть его. Но остается проблема — это дельта, то есть разница данных в старом и новом кластере. Мы же решили немного заморочиться и решить данную «проблемку».
У нас, на текущий момент, есть два кластера на EKS — прод и, куда же без него, стейдж. Но как оказалось, стейдж был сделан куда лучше прода. Все потому, что стейдж понемногу дорабатывался, а вот прод — нет. Доработки касались, в основном, terraform’a, но не суть — это другая история и не про сетап кластеров.
Так вот, для того чтобы привести прод к нужному виду — его проще пересобрать, как говорится, с нуля. Но есть одно «но»: на проде у нас есть два очень важных сервиса, которые используют в качестве базы данных MongoDB.
Идея
Все как обычно — сидели на кухне и общались за чашкой кофе (тут может показаться, что мы вообще нифига не делаем, а только кофе пьём).
Коллега, который любит иногда подкидывать на вентилятор (это в позитивном смысле, а не то, что вы могли подумать), говорит: «Слушай, а как нам сделать так, чтобы и приложение постоянно работало, и базу перенести, и от дельты избавиться? Нужно учиться делать так, чтобы сервисы были максимально доступны, даже при выполнении каких-либо работ». А чего, идея хорошая — нужно стремиться к минимальному даунтайму сервиса.
Первым делом лезем в поиск и ищем подобные кейсы: скорее всего, таким вопросом задавались до нас, и решение где-то есть.
По итогам поиска было найдено два неплохих варианта: «Migrating MongoDB Replica Set Between Kubernetes Clusters With Zero Downtime» и «Беспростойная миграция MongoDB в Kubernetes». По нашему мнению, здесь все же немного не хватает инфы, поэтому решили написать небольшую статейку с бОльшим количеством деталей.
Начало положено.
Поехали!
Что ж, как настоящие «я ж у мамы инженер», начинаем прорабатывать план захвата мира схему, как это все реализовать. Начали, конечно же, с малого:
Схема 1. Драфт схемы миграции
Итак, что мы имеем:
- Старый кластер K8s
- Новый кластер K8s
В обоих кластерах накатан одинаковый Helm Chart с приложением. В качестве чарта для MongoDB использовали stable репозиторий Helm’a.
Прежде чем приступить к хардкору, необходимо понять, как работает Replica Set в MongoDB. Рассмотрим, что это и как оно действует.
Replica Set в MongoDB
Что такое Replica Set вообще? Как говорят мои друзья, коллеги и наставники: «В официальной документации можно найти много ответов на большинство вопросов, и даже больше». Поэтому погружаемся с этим вопросом в официальную документацию.
Replica Set в MongoDB — это группа демонов Mongod, которые содержат и обрабатывают одинаковый набор данных. Replica Set обеспечивает излишество и высокую доступность, а также является стандартом для продакшн-окружения.
Replica Set состоит из primary и secondary нод. Primary нода отвечает за запись данных в кластер, а secondary — за выдачу данных потребителю. Primary нода оперирует такой штукой, как oplog (operation logs), которая находится в отдельной коллекции и регистрирует туда все изменения в наборе данных. Это коллекция фиксированного размера и работает по принципу буфера, перезаписывая самые старые данные по мере заполнения (принцип first-in — first-out).
По сути, primary заносит данные в oplog, а secondary эти данные реплицируют и вычитывают — собственно, ничего необычного.
Для того чтобы выбрать мастера, в Replica Set производится операция, которая называется election.
Выбор мастера может производиться в следующих случаях:
- добавление новой ноды в Replica Set;
- инициализация Replica Set;
- проведение каких-либо работ с нодой с использованием методов rs.stepDown() или rs.reconfigure();
- потеря связи secondary нод с текущим мастером на время большее, чем заданный timeout (10 секунд по умолчанию).
В Replica Set все ноды обмениваются специальными сигналами, которые называются Heartbeats (каждые 2 секунды).
Стоит отметить, что мы имеем возможность влиять на выбор мастера и делать мастером ту ноду, которую считаем нужной. Делается это путем выставления приоритетов. Приоритет — это обычное число от 0 до 100, где 0 и 1 — это дефолтный приоритет арбитра, также «0» не позволяет инициировать процедуру выборов.
И забавно то, что если в процессе выбора будет выбрана нода с меньшим приоритетом (да, такое может быть), выборы продолжатся до тех пор, пока не будет выбрана нода с наивысшим приоритетом.
Почему это важно? Потому что дальше мы будем влиять на выбор primary в Replica Set для корректной его конфигурации.
Replica Set синхронизация/репликация secondary ноды
MongoDB использует две формы синхронизации данных:
- инициализация — полный перенос набора данных на новую ноду в Replica Set;
- репликация — применение текущих изменений в существующем наборе данных.
При инициализации определяется источник данных согласно параметру «initialSyncSourceReadPreference», и он может принимать значения:
- primary — получение данных только с мастера, если мастер недоступен, то получим ошибку;
- primaryPreferred — получение данных предпочтительно с мастера, если мастер недоступен — происходит выбор любой доступной ноды в Replica Set’e;
- nearest — использовать ближайшую ноду в Replica Set’e.
И это тоже достаточно интересно реализовано. Новая нода имеет ряд критериев выбора точки копирования данных и может выполняться в два подхода, так называемые «First Pass» (жесткий вариант выбора с бОльшим количеством критериев) и «Second Pass» (более мягкий вариант). Если новая нода не нашла подходящего «донора» по первому проходу, произойдет инициализация второго этапа.
Если «донор» не был выбран и после второго этапа — это запишется в ошибку, нода войдет в таймаут на 1 секунду и повторит процесс инициализации. Процесс инициализации может повторяться до 10 раз, после чего процесс выйдет с ошибкой.
После того как новая нода завершила инициализацию данных, она продолжит вносить изменения в набор данных путем репликации oplog со своего источника синхронизации.
Настройка
Теперь, когда понятен процесс работы Replica Set MongoDB, приступим к настройке нашей схемы.
Что нам понадобится:
- Helm Chart — mongodb из стейбл репозитория;
- Headless-сервис K8s;
- сервис K8s с типом NodePort;
- Nginx proxy.
Зачем нам это все нужно?
Сервис K8s с типом «NodePort» — позволит выставить наши поды с MongoDB в злой внешний мир, чтобы мы могли собрать Replica Set из двух разных кластеров K8s. Принцип работы NodePort заключается в том, что на всех нодах кластера K8s выставляются наружу порты из определенного диапазона (по умолчанию
Headless сервис K8s — позволит нам выполнить обращение к подам удаленного кластера по именам, по сути, headless-сервис не создает правил перенаправления через kube-proxy, а обеспечивает динамический резолвинг DNS-имен, а nginx-прокси поможет с обращением на удаленный кластер.
Теперь это все закрепим небольшой упрощенной схемой, в которой указаны элементы одного кластера.
Схема 2. Упрощенная схема кластера
Поняв элементы, находящиеся в кластере — усложним и прикинем полную схему MongoDB по кластеру.
Старый K8s кластер
Схема 3. Структура существующего старого кластера со всеми сервисами
Выглядит сложно, правда? Но это только на первый взгляд. Теперь давайте разбираться, что тут да как. Это существующий кластер, в котором уже развернут Replica Set.
Начнем с nginx-proxy. Тут все достаточно просто, но для них необходимо знать публичные адреса воркер нод, на которых расположены MongoDB, и номера портов сервиса с типом NodePort в новом кластере. Да-да, я знаю, как работает NodePort, но указание конкретного IP ноды в nginx-proxy позволит избавиться от лишнего роутинга внутри кластера, и мы будем попадать напрямую куда необходимо.
Так как мы используем Affinity для заселения подов, нам заранее известны IP-адреса воркер нод, а порты сервиса NodePort мы можем задать хардкодом.
Допустим, публичные адреса и порты удаленного нового кластера у нас следующие:
- mongo-primary-0 — 2.2.2.1:31010
- mongo-secondary-0 — 2.2.2.2:31011
- mongo-secondary-1 — 2.2.2.3:31012
Количество nginx-proxy равно трем, по одному на каждый удаленный NodePort.
Ок, создаем конфиги для nginx-proxy, каждый конфиг — это отдельный файл, и выглядит он вот так:
apiVersion: v1 kind: ConfigMap metadata: name: CONFIG_MAP_NAME namespace: example data: nginx.conf: | worker_processes auto; worker_rlimit_nofile 200000; events { worker_connections 10000; multi_accept on; use epoll; } stream { upstream backend { server PUBLIC_IP_OF_REMOTE_NODE:REMOTE_NODE_PORT; } server { listen 27017; proxy_pass backend; } }
Все значения, выделенные жирным, необходимо заменить на следующие (заменяя IP на свои):
CONFIG_MAP_NAME: cm-nginx-proxy-mongo-primary-0 PUBLIC_IP_OF_REMOTE_NODE:REMOTE_NODE_PORT: 2.2.2.1:31010
CONFIG_MAP_NAME: cm-nginx-proxy-mongo-secondary-0 PUBLIC_IP_OF_REMOTE_NODE:REMOTE_NODE_PORT: 2.2.2.2:31011
CONFIG_MAP_NAME: cm-nginx-proxy-mongo-secondary-1 PUBLIC_IP_OF_REMOTE_NODE:REMOTE_NODE_PORT: 2.2.2.3:31012
В итоге должно получиться три файла с такими названиями:
- cm-nginx-proxy-mongo-primary-0.yaml
- cm-nginx-proxy-mongo-secondary-0.yaml
- cm-nginx-proxy-mongo-secondary-1.yaml
Теперь создадим манифесты подов nginx-proxy (их 3 шт.) и примаунтим к ним configmap’ы, созданные в предыдущем шаге, каждый конфиг — это отдельный файл:
apiVersion: v1 kind: Pod metadata: namespace: example name: MONGO_NGINX_PROXY labels: name: MONGO_NGINX_PROXY role: mongo-proxy spec: terminationGracePeriodSeconds: 10 hostname: MONGO_NGINX_PROXY subdomain: MONGO_NGINX_PROXY containers: - name: nginx-proxy image: nginx:latest ports: - name: mongo containerPort: 27017 volumeMounts: - name: config-volume mountPath: /etc/nginx/ volumes: - name: config-volume configMap: name: CONFIG_MAP_NAME restartPolicy: Never
Все значения, выделенные жирным, необходимо заменить на следующие:
MONGO_NGINX_PROXY: example-mongo-primary-0 CONFIG_MAP_NAME:cm-nginx-proxy-mongo-primary-0
MONGO_NGINX_PROXY: example-mongo-secondary-0 CONFIG_MAP_NAME:cm-nginx-proxy-mongo-secondary-0
MONGO_NGINX_PROXY: example-mongo-secondary-1 CONFIG_MAP_NAME:cm-nginx-proxy-mongo-secondary-1
В итоге должно получиться три файла с такими названиями:
- mongo-proxy-primary-0.yaml
- mongo-proxy-secondary-0.yaml
- mongo-proxy-secondary-1.yaml
Далее необходимо создать headless-сервис для подов nginx-proxy:
--- apiVersion: v1 kind: Service metadata: name: example-mongo-headless namespace: example labels: name: example-mongo-headless spec: clusterIP: None ports: - name: mongo port: 27017 selector: role: mongo-proxy
И создаем сервис NodePort для каждого пода MongoDB, чтобы пробросить их наружу кластера. В этом файле необходимо выставить правильный «селектор», чтобы сервис смотрел на нужный под в кластере:
--- apiVersion: v1 kind: Service metadata: namespace: example name: example-mongodb-primary-0 labels: name: example-mongodb-primary-0 spec: type: NodePort selector: name: example-mongodb-primary-0 ports: - name: mongo port: 27017 nodePort: 31001 protocol: TCP --- apiVersion: v1 kind: Service metadata: namespace: example name: example-mongodb-secondary-0 labels: name: example-mongodb-secondary-0 spec: type: NodePort selector: name: example-mongodb-secondary-0 ports: - name: mongo port: 27017 nodePort: 31002 protocol: TCP --- apiVersion: v1 kind: Service metadata: namespace: example name: example-mongodb-secondary-1 labels: name: example-mongodb-secondary-1 spec: type: NodePort selector: name: example-mongodb-secondary-1 ports: - name: mongo port: 27017 nodePort: 31003 protocol: TCP
Итого, у нас должна быть следующая структура директорий и файлов:
В итоге у нас должно получиться:
- 1 headless-сервис с селектором mongo-proxy для доступа к трем подам с nginx-proxy;
- 3 пода c nginx-proxy — проксируют запросы на удаленный кластер;
- 3 сервиса с типом NodePort — выставляем наружу инстансы MongoDB для доступа с удаленного кластера.
Новый K8s кластер
Теперь отобразим зеркальную полную схему нового кластера:
Схема 4. Структура нового кластера со всеми сервисами
В новом кластере нам необходимо сделать некоторые манипуляции с Helm-чартом. Для этого нужно выкачать репозиторий и поправить темплейт стейтфулсета Primary.
Для начала, в Chart.yaml нашего приложения укажем зависимость и путь к скачанному чарту:
dependencies: - name: mongodb version: 7.8.10 repository: "file://dep/charts/stable/mongodb"
Далее, выполним команду: helm dep up
Эта команда подтянет указанные зависимости. После чего мы можем выполнить правки темплейтов. Переходим в директорию charts/mongodb/templates/, находим файл с именем statefulset-primary-rs.yaml и вносим следующие изменения:
- name: MONGODB_REPLICA_SET_MODE value: "primary" ----> заменяем на "secondary" - name: MONGODB_ROOT_PASSWORD заменяем на MONGODB_PRIMARY_ROOT_PASSWORD
На этом изменения в темплейте заканчиваем. Этими изменениями мы говорим мастеру, который должен выполнять инициализацию Replica Set’a при деплое Helm-чарта, что он не мастер, но при этом он сохраняет важные для нас атрибуты, такие как имя пода и имя PVC.
Теперь еще необходимо добавить env-переменную к Helm-чарту в файле values.yaml:
nameOverride: mongo extraEnvVars: - name: MONGODB_INITIAL_PRIMARY_HOST value: "example-mongodb-primary-0.example-mongodb- headless.example.svc.cluster.local" - name: MONGODB_PRIMARY_HOST value: "example-mongodb-primary-0.example-mongodb- headless.example.svc.cluster.local"
nameOverride — позволяет сделать небольшое приемлемое отличие в именовании подов. Так как мы не можем добавить в Replica Set уже существующие там имена. По дефолту присваивается имя — mongodb, и с этим именем развернут Replica Set в старом кластере.
Через «extraEnvVars» мы говорим новым нодам, что мастером для них должен быть хост в старом кластере. Здесь очень важны именования, для правильного резолвинга DNS-имен. Именно для этого поды nginx-proxy в кластере именуются как поды с MongoDB в другом кластере, а также при создании headless-сервиса добавляются такие ключи, как hostname и subdomain (example-mongodb-primary-0.example — mongodb-headless.example.svc.cluster.local). Из этих полей и формируется полное fqdn-имя, по которому мы обращаемся в другой кластер, используя nginx-proxy.
Затем создаем все тот же сет конфигов, но с небольшими изменениями.
Допустим, публичные адреса и порты старого кластера у нас следующие (обращаем внимание на именование mongo и mongodb, в новом кластере мы именуем базы как mongo!):
- mongodb-primary-0 — 1.1.1.1:31001
- mongodb-secondary-0 — 1.1.1.2:31002
- mongodb-secondary-1 — 1.1.1.3:31003
Создаем конфиги для nginx-proxy:
apiVersion: v1 kind: ConfigMap metadata: name: CONFIG_MAP_NAME namespace: example data: nginx.conf: | worker_processes auto; worker_rlimit_nofile 200000; events { worker_connections 10000; multi_accept on; use epoll; } stream { upstream backend { server PUBLIC_IP_OF_REMOTE_NODE:REMOTE_NODE_PORT; } server { listen 27017; proxy_pass backend; } }
Все значения, выделенные жирным, необходимо заменить на следующие (заменяя IP на свои):
CONFIG_MAP_NAME: cm-nginx-proxy-mongodb-primary-0 PUBLIC_IP_OF_REMOTE_NODE:REMOTE_NODE_PORT: 1.1.1.1:31001
CONFIG_MAP_NAME: cm-nginx-proxy-mongodb-secondary-0 PUBLIC_IP_OF_REMOTE_NODE:REMOTE_NODE_PORT: 1.1.1.2:31002
CONFIG_MAP_NAME: cm-nginx-proxy-mongodb-secondary-1 PUBLIC_IP_OF_REMOTE_NODE:REMOTE_NODE_PORT: 1.1.1.3:31003
В итоге должно получиться три файла с такими названиями:
- cm-nginx-proxy-mongodb-primary-0.yaml
- cm-nginx-proxy-mongodb-secondary-0.yaml
- cm-nginx-proxy-mongodb-secondary-1.yaml
Создадим манифесты подов nginx-proxy и примаунтим к ним configmap’ы, созданные в предыдущем шаге:
--- apiVersion: v1 kind: Pod metadata: namespace: example name: MONGO_NGINX_PROXY labels: name: MONGO_NGINX_PROXY role: mongo-proxy spec: terminationGracePeriodSeconds: 10 hostname: MONGO_NGINX_PROXY subdomain: MONGO_NGINX_PROXY containers: - name: nginx-proxy image: nginx:latest ports: - name: mongo containerPort: 27017 volumeMounts: - name: config-volume mountPath: /etc/nginx/ volumes: - name: config-volume configMap: name: CONFIG_MAP_NAME restartPolicy: Never
Все значения, выделенные жирным, необходимо заменить на следующие:
MONGO_NGINX_PROXY: example-mongodb-primary-0 CONFIG_MAP_NAME:cm-nginx-proxy-mongodb-primary-0
MONGO_NGINX_PROXY: example-mongodb-secondary-0 CONFIG_MAP_NAME:cm-nginx-proxy-mongodb-secondary-0
MONGO_NGINX_PROXY: example-mongodb-secondary-1 CONFIG_MAP_NAME:cm-nginx-proxy-mongodb-secondary-1
В итоге должно получиться три файла с такими названиями:
- mongodb-proxy-primary-0.yaml
- mongodb-proxy-secondary-0.yaml
- mongodb-proxy-secondary-1.yaml
Далее необходимо создать headless-сервис для подов nginx-proxy:
И создаем сервис NodePort для каждого пода MongoDB, для выставления наружу кластера. В этом файле необходимо выставить правильный «селектор», чтобы сервис смотрел на нужный под в кластере:
--- apiVersion: v1 kind: Service metadata: namespace: example name: example-mongo-primary-0 labels: name: example-mongo-primary-0 spec: type: NodePort selector: name: example-mongo-primary-0 ports: - name: mongo port: 27017 nodePort: 31001 protocol: TCP --- kind: Service metadata: namespace: example name: example-mongo-secondary-0 labels: name: example-mongo-secondary-0 spec: type: NodePort selector: name: example-mongo-secondary-0 ports: - name: mongo port: 27017 nodePort: 31002 protocol: TCP --- kind: Service metadata: namespace: example name: example-mongo-secondary-1 labels: name: example-mongo-secondary-1 spec: type: NodePort selector: name: example-mongo-secondary-1 ports: - name: mongo port: 27017 nodePort: 31003 protocol: TCP
Итого, у нас должна быть следующая структура директорий и файлов:
После того как мы прошлись раздельно по кластерам, необходимо создать общую схему для полного представления, как это должно выглядеть:
Схема 5. Общая схема миграции
После всех проделанных манипуляций у вас должны успешно резолвить имена как для старого, так и для нового кластера. Сделать это можно, зайдя в под с nginx-proxy и установив там пакет dnsutils, также можно проверить с помощью telnet, постучавшись на порт 27017.
Старый кластер:
- dig example-mongo-primary-0.example-mongo-headless.example.svc.cluster.local
- dig example-mongo-secondary-0.example-mongo— headless.example.svc.cluster.local
- dig example-mongo-secondary-1.example-mongo— headless.example.svc.cluster.local
Новый кластер:
- dig example-mongodb-primary-0.example-mongodb— headless.example.svc.cluster.local
- dig example-mongodb-secondary-0.example-mongodb— headless.example.svc.cluster.local
- dig example-mongodb-secondary-1.example-mongodb— headless.example.svc.cluster.local
Тут еще раз обращаю внимание на то, что все пароли MongoDB должны быть одинаковыми для обоих кластеров.
Далее, нам необходимо зайти на primary ноду Replica Set старого кластера и добавить новые ноды в существующий Replica Set. Делается это следующим образом:
mongo -uroot -p$MONGODB_ROOT_PASSWORD --authenticationDatabase admin rs0:PRIMARY> rs.add( { host: "example-mongo-primary-0.example-mongo- headless.example.svc.cluster.local:27017", priority: 0, votes: 0 } ) rs0:PRIMARY> rs.add( { host:"example-mongo-secondary-0.example- mongo-headless.example.svc.cluster.local:27017", priority: 0, votes: 0 } ) rs0:PRIMARY> rs.add( { host:"example-mongo-secondary-1.example- mongo-headless.example.svc.cluster.local:27017", priority: 0, votes: 0 } )
И тут есть интересный момент. При добавлении новой ноды документация говорит о том, что необходимо выставлять «priority: 0» и «votes: 0», так как в противном случае невыставленные ключи могут привести к тому, что новая нода начнет принимать участие в процессе выбора мастера. После того как нода перейдет в статус «SECONDARY», можно обновить ее «priority: 0» и «votes: 0».
После того как добавились новые ноды в Replica Set, необходимо убрать изменения, которые были внесены в темплейт Helm-чарта mongodb, а также убрать изменения в values.yaml (там мы определяли переменные с указанием мастер ноды).
Изменим Chart.yaml и обновим зависимости:
dependencies: - name: mongodb version: 7.8.10 repository: https://kubernetes-charts.storage.googleapis.com
helm dep up
Удалим extraEnvVars в values.yaml:
extraEnvVars: - name: MONGODB_INITIAL_PRIMARY_HOST value: "example-mongodb-primary-0.example-mongodb- headless.example.svc.cluster.local" - name: MONGODB_PRIMARY_HOST value: "example-mongodb-primary-0.example-mongodb- headless.example.svc.cluster.local"
И выполним передеплой Helm-чарта нашего приложения:
helm upgrade --install --namespace example example ./ --reuse-values -f env/stage/values.yaml .
После деплоя ввиду изменений переменных у нас начнется процесс передеплоя example-mongo-primary-0, но это не затронет наш PVC, соответственно, новый под примаунтит pvc с уже существующими данными, и процесс инициализации Replica Set не будет запущен.
Проверяем статус Replica Set и убеждаемся в том, что пересозданный под успешно добавлен в Replica Set. Сделать это можно с помощью команды на мастер ноде: rs0:PRIMARY> rs.status()
Если все прошло успешно — сменим мастер ноду путем прямого выбора, по типу: «Эй, ты! Да, ты — сегодня ты будешь мастером