Как мы пересобрали кластер и мигрировали 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. Драфт схемы миграции

Итак, что мы имеем:

В обоих кластерах накатан одинаковый 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 все ноды обмениваются специальными сигналами, которые называются Heartbeats (каждые 2 секунды).

Стоит отметить, что мы имеем возможность влиять на выбор мастера и делать мастером ту ноду, которую считаем нужной. Делается это путем выставления приоритетов. Приоритет — это обычное число от 0 до 100, где 0 и 1 — это дефолтный приоритет арбитра, также «0» не позволяет инициировать процедуру выборов.

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

Почему это важно? Потому что дальше мы будем влиять на выбор primary в Replica Set для корректной его конфигурации.

Replica Set синхронизация/репликация secondary ноды

MongoDB использует две формы синхронизации данных:

При инициализации определяется источник данных согласно параметру «initialSyncSourceReadPreference», и он может принимать значения:

И это тоже достаточно интересно реализовано. Новая нода имеет ряд критериев выбора точки копирования данных и может выполняться в два подхода, так называемые «First Pass» (жесткий вариант выбора с бОльшим количеством критериев) и «Second Pass» (более мягкий вариант). Если новая нода не нашла подходящего «донора» по первому проходу, произойдет инициализация второго этапа.

Если «донор» не был выбран и после второго этапа — это запишется в ошибку, нода войдет в таймаут на 1 секунду и повторит процесс инициализации. Процесс инициализации может повторяться до 10 раз, после чего процесс выйдет с ошибкой.

После того как новая нода завершила инициализацию данных, она продолжит вносить изменения в набор данных путем репликации oplog со своего источника синхронизации.

Настройка

Теперь, когда понятен процесс работы Replica Set MongoDB, приступим к настройке нашей схемы.

Что нам понадобится:

Зачем нам это все нужно?

Сервис K8s с типом «NodePort» — позволит выставить наши поды с MongoDB в злой внешний мир, чтобы мы могли собрать Replica Set из двух разных кластеров K8s. Принцип работы NodePort заключается в том, что на всех нодах кластера K8s выставляются наружу порты из определенного диапазона (по умолчанию 30000–32767).

Headless сервис K8s — позволит нам выполнить обращение к подам удаленного кластера по именам, по сути, headless-сервис не создает правил перенаправления через kube-proxy, а обеспечивает динамический резолвинг DNS-имен, а nginx-прокси поможет с обращением на удаленный кластер.

Теперь это все закрепим небольшой упрощенной схемой, в которой указаны элементы одного кластера.

Схема 2. Упрощенная схема кластера

Поняв элементы, находящиеся в кластере — усложним и прикинем полную схему MongoDB по кластеру.

Старый K8s кластер

Схема 3. Структура существующего старого кластера со всеми сервисами

Выглядит сложно, правда? Но это только на первый взгляд. Теперь давайте разбираться, что тут да как. Это существующий кластер, в котором уже развернут Replica Set.

Начнем с nginx-proxy. Тут все достаточно просто, но для них необходимо знать публичные адреса воркер нод, на которых расположены MongoDB, и номера портов сервиса с типом NodePort в новом кластере. Да-да, я знаю, как работает NodePort, но указание конкретного IP ноды в nginx-proxy позволит избавиться от лишнего роутинга внутри кластера, и мы будем попадать напрямую куда необходимо.

Так как мы используем Affinity для заселения подов, нам заранее известны IP-адреса воркер нод, а порты сервиса NodePort мы можем задать хардкодом.

Допустим, публичные адреса и порты удаленного нового кластера у нас следующие:

Количество 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

В итоге должно получиться три файла с такими названиями:

Теперь создадим манифесты подов 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

В итоге должно получиться три файла с такими названиями:

Далее необходимо создать 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

Итого, у нас должна быть следующая структура директорий и файлов:

В итоге у нас должно получиться:

Новый 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!):

Создаем конфиги для 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

В итоге должно получиться три файла с такими названиями:

Создадим манифесты подов 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

В итоге должно получиться три файла с такими названиями:

Далее необходимо создать 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.

Старый кластер:

Новый кластер:

Тут еще раз обращаю внимание на то, что все пароли 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()

Если все прошло успешно — сменим мастер ноду путем прямого выбора, по типу: «Эй, ты! Да, ты — сегодня ты будешь мастером ?».

Снова возвращаемся на наш текущий мастер и выполняем команды:

rs0:PRIMARY> rs.status() - смотрим ID нужной ноды 
rs0:PRIMARY> var cfg = rs.conf(); 
rs0:PRIMARY> cfg.members[NODE_ID].priority = 10 
rs0:PRIMARY> cfg.members[NODE_ID].votes = 1 
rs0:PRIMARY> rs.reconfig(cfg)

После этого должен начаться процесс выбора нового мастера.

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

rs0:PRIMARY> rs.remove("example-mongodb-primary-0.example-mongodb- headless.example.svc.cluster.local:27017") 

rs0:PRIMARY> rs.remove("example-mongodb-secondary-0.example-mongodb- headless.example.svc.cluster.local:27017") 

rs0:PRIMARY> rs.remove("example-mongodb-secondary-1.example-mongodb- headless.example.svc.cluster.local:27017") 

Вот такой нехитрый способ бесшовного переезда на новый кластер. Ах да, забыл сказать про проблемы, с которыми столкнулись... А не было их ?.

Выводы

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

Опубліковано: 10/09/20 @ 10:00
Розділ Різне

Рекомендуємо:

Писати код недостатньо. Як я створював продукт
Универсальный vs узкопрофильный программист. Определяем путь
Триллер-дневник. Поиск работы в Канаде — 2020 (часть 1)
Нішевість і спеціалізація ІТ-компаній. Як вийти на ринок з низькою конкуренцією і високою пропозицією
Як провести вдалий онбординг ІТ-проєкту. Підхід структурованого менеджменту для PM/BA