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

В наших решениях мы используем интеграцию и с помощью Kafka, и с помощью gRPC, и с помощью RabbitMQ.

В этой статье мы поделимся нашим опытом кластеризации RabbitMQ, ноды которого размещены в Kubernetes.

image

До RabbitMQ версии 3.7 его кластеризация в K8S была не очень тривиальной задачей, со множеством хаков и не очень красивых решений. В версии 3.6 использовался autocluster плагин из RabbitMQ Community. А в 3.7 появился Kubernetes Peer Discovery Backend. Он встроен плагином в базовую поставку RabbitMQ и не требует отдельной сборки и установки.

Мы опишем итоговую конфигурацию целиком, попутно комментируя происходящее.

В теории


У плагина существует репозиторий на гитхабе, в котором есть пример базового использования.
Этот пример не предназначен для Production, о чём явно указано в его описании, и более того, часть настроек в нём установлено вразрез с логикой использования в проде. Также в примере никак не упомянута персистентность хранилища, таким образом при любой нештатной ситуации наш кластер превратится в пшик.

На практике


Сейчас расскажем, с чем столкнулись сами и как установили и настроили RabbitMQ.

Опишем конфигурации всех частей RabbitMQ как сервиса в K8s. Сразу уточним, что мы устанавливали RabbitMQ в K8s как StatefulSet. На каждой ноде кластера K8s будет всегда функционировать один экземпляр RabbitMQ (одна нода в классической конфигурации кластера). Мы также установим в K8s панель управления RabbitMQ и дадим доступ до этой панели за пределы кластера.

Права и роли:


rabbitmq_rbac.yaml
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: rabbitmq 
---
kind: Role
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:
  name: endpoint-reader
rules:
- apiGroups: [""]
  resources: ["endpoints"]
  verbs: ["get"]
---
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:
  name: endpoint-reader
subjects:
- kind: ServiceAccount
  name: rabbitmq
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: endpoint-reader

Права доступа для RabbitMQ взяты целиком из примера, никаких изменений в них не требуется. Создаём ServiceAccount для нашего кластера и выдаём ему права на чтение Endpoints K8s.

Персистентное хранилище:


rabbitmq_pv.yaml
kind: PersistentVolume
apiVersion: v1
metadata:
  name: rabbitmq-data-sigma
  labels:
    type: local
  annotations:
    volume.alpha.kubernetes.io/storage-class: rabbitmq-data-sigma
spec:
  storageClassName: rabbitmq-data-sigma
  capacity:
    storage: 10Gi
  accessModes:
    - ReadWriteMany
  persistentVolumeReclaimPolicy: Recycle
  hostPath:
    path: "/opt/rabbitmq-data-sigma"

В качестве персистентного хранилища здесь мы взяли самый простой случай — hostPath (обычную папку на каждой ноде K8s), но можно использовать любой из множества типов персистентных томов, поддерживаемых в K8s.

rabbitmq_pvc.yaml
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: rabbitmq-data
spec:
  storageClassName: rabbitmq-data-sigma
  accessModes:
    - ReadWriteMany
  resources:
    requests:
      storage: 10Gi

Создаём Volume Claim на томе, созданном в предыдущем шаге. Этот Claim затем будет использоваться в StatefulSet как хранилище постоянных данных.

Сервисы:


rabbitmq_service.yaml
kind: Service
apiVersion: v1
metadata:
  name: rabbitmq-internal
  labels:
    app: rabbitmq
spec:
  clusterIP: None
  ports:
   - name: http
     protocol: TCP
     port: 15672
   - name: amqp
     protocol: TCP
     port: 5672
  selector:
    app: rabbitmq

Создаём внутренний headless сервис, через который будет работать Peer Discovery plugin.

rabbitmq_service_ext.yaml
kind: Service
apiVersion: v1
metadata:
  name: rabbitmq
  labels:
    app: rabbitmq
    type: LoadBalancer
spec:
  type: NodePort
  ports:
   - name: http
     protocol: TCP
     port: 15672
     targetPort: 15672
     nodePort: 31673
   - name: amqp
     protocol: TCP
     port: 5672
     targetPort: 5672
     nodePort: 30673
  selector:
    app: rabbitmq

Для работы приложений в K8s с нашим кластером создаём сервис балансировщика.

Так как нам нужен доступ к кластеру RabbitMQ снаружи K8s, прокидываем NodePort. RabbitMQ будет доступен при обращении к любой ноде кластера K8s по портам 31673 и 30673. В реальной работе большой необходимости в этом нет. Вопрос удобства пользования админкой RabbitMQ.

При создании сервиса с типом NodePort в K8s также неявно создаётся сервис с типом ClusterIP для его обслуживания. Поэтому приложения в K8s, которым нужно работать с нашим RabbitMQ, смогут обращаться к кластеру по адресу amqp://rabbitmq:5672

Конфигурация:


rabbitmq_configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: rabbitmq-config
data:
  enabled_plugins: |
      [rabbitmq_management,rabbitmq_peer_discovery_k8s].

  rabbitmq.conf: |
      cluster_formation.peer_discovery_backend  = rabbit_peer_discovery_k8s
      cluster_formation.k8s.host = kubernetes.default.svc.cluster.local
      cluster_formation.k8s.port = 443
      ### cluster_formation.k8s.address_type = ip
      cluster_formation.k8s.address_type = hostname
      cluster_formation.node_cleanup.interval = 10
      cluster_formation.node_cleanup.only_log_warning = true
      cluster_partition_handling = autoheal
      queue_master_locator=min-masters
      cluster_formation.randomized_startup_delay_range.min = 0
      cluster_formation.randomized_startup_delay_range.max = 2
      cluster_formation.k8s.service_name = rabbitmq-internal
      cluster_formation.k8s.hostname_suffix = .rabbitmq-internal.our-namespace.svc.cluster.local

Создаём конфигурационные файлы RabbitMQ. Основная магия.

enabled_plugins: |
  [rabbitmq_management,rabbitmq_peer_discovery_k8s].

Добавляем нужные плагины в разрешенные к загрузке. Теперь мы можем использовать автоматический Peer Discovery в K8S.

cluster_formation.peer_discovery_backend  = rabbit_peer_discovery_k8s

Выставляем в качестве backend для peer discovery нужный плагин.

cluster_formation.k8s.host = kubernetes.default.svc.cluster.local
cluster_formation.k8s.port = 443

Указываем адрес и порт, через которые можно достучаться до kubernetes apiserver. Здесь можно указать напрямую ip-адрес, но более красиво будет сделать так.

В namespace default обычно создан service с именем kubernetes, ведущий на k8-apiserver. В разных вариантах установки K8S namespace, имя сервиса и порт могут быть другими. Если что-то в конкретной установке отличается, нужно, соответственно, поправить здесь.

Например, мы столкнулись с тем, что в некоторых кластерах сервис на порту 443, а в некоторых на 6443. Понять, что что-то не так, можно будет в логах старта RabbitMQ, там явно выделен момент подключения по указанному здесь адресу.

### cluster_formation.k8s.address_type = ip
cluster_formation.k8s.address_type = hostname

По умолчанию в примере был указан тип адресации нод кластера RabbitMQ по ip-адресу. Но при перезапуске pod он каждый раз получает новый IP. Сюрприз! Кластер умирает в муках.

Меняем адресацию на hostname. StatefulSet гарантирует нам неизменность hostname в рамках жизненного цикла всего StatefulSet, что нас полностью устроит.

cluster_formation.node_cleanup.interval = 10
cluster_formation.node_cleanup.only_log_warning = true

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

cluster_partition_handling = autoheal

Этим параметром определяем действия кластера при потере кворума. Тут стоит просто почитать документацию по этой теме и понять для себя, что ближе к конкретному сценарию использования.

queue_master_locator=min-masters

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

cluster_formation.k8s.service_name = rabbitmq-internal

Задаём имя headless сервиса K8s (созданного нами ранее), через который ноды RabbitMQ будут общаться между собой.

cluster_formation.k8s.hostname_suffix = .rabbitmq-internal.our-namespace.svc.cluster.local

Важная штука для работы адресации в кластере по hostname. FQDN пода K8s формируется как короткое имя (rabbitmq-0, rabbitmq-1) + суффикс (доменная часть). Здесь мы и указываем этот суффикс. В K8S он выглядит как .<имя сервиса>.<имя namespace>.svc.cluster.local

kube-dns без какой-либо дополнительной настройки резолвит имена вида rabbitmq-0.rabbitmq-internal.our-namespace.svc.cluster.local в ip-адрес конкретного пода, что и делает возможной всю магию кластеризации по hostname.

Конфигурация StatefulSet RabbitMQ:


rabbitmq_statefulset.yaml
apiVersion: apps/v1beta1
kind: StatefulSet
metadata:
  name: rabbitmq
spec:
  serviceName: rabbitmq-internal
  replicas: 3
  template:
    metadata:
      labels:
        app: rabbitmq
      annotations:
        scheduler.alpha.kubernetes.io/affinity: >
            {
              "podAntiAffinity": {
                "requiredDuringSchedulingIgnoredDuringExecution": [{
                  "labelSelector": {
                    "matchExpressions": [{
                      "key": "app",
                      "operator": "In",
                      "values": ["rabbitmq"]
                    }]
                  },
                  "topologyKey": "kubernetes.io/hostname"
                }]
              }
            }
    spec:
      serviceAccountName: rabbitmq
      terminationGracePeriodSeconds: 10
      containers:        
      - name: rabbitmq-k8s
        image: rabbitmq:3.7
        volumeMounts:
          - name: config-volume
            mountPath: /etc/rabbitmq
          - name: rabbitmq-data
            mountPath: /var/lib/rabbitmq/mnesia
        ports:
          - name: http
            protocol: TCP
            containerPort: 15672
          - name: amqp
            protocol: TCP
            containerPort: 5672
        livenessProbe:
          exec:
            command: ["rabbitmqctl", "status"]
          initialDelaySeconds: 60
          periodSeconds: 10
          timeoutSeconds: 10
        readinessProbe:
          exec:
            command: ["rabbitmqctl", "status"]
          initialDelaySeconds: 10
          periodSeconds: 10
          timeoutSeconds: 10
        imagePullPolicy: Always
        env:
          - name: MY_POD_IP
            valueFrom:
              fieldRef:
                fieldPath: status.podIP
          - name: HOSTNAME
            valueFrom:
              fieldRef:
                fieldPath: metadata.name
          - name: NAMESPACE
            valueFrom:
              fieldRef:
                fieldPath: metadata.namespace
          - name: RABBITMQ_USE_LONGNAME
            value: "true"
          - name: RABBITMQ_NODENAME
            value: "rabbit@$(HOSTNAME).rabbitmq-internal.$(NAMESPACE).svc.cluster.local"
          - name: K8S_SERVICE_NAME
            value: "rabbitmq-internal"
          - name: RABBITMQ_ERLANG_COOKIE
            value: "mycookie"
      volumes:
        - name: config-volume
          configMap:
            name: rabbitmq-config
            items:
            - key: rabbitmq.conf
              path: rabbitmq.conf
            - key: enabled_plugins
              path: enabled_plugins
        - name: rabbitmq-data
          persistentVolumeClaim:
            claimName: rabbitmq-data

Собственно, сам StatefulSet. Отметим интересные моменты.

serviceName: rabbitmq-internal

Прописываем имя headless-сервиса, через который общаются поды в StatefulSet.

replicas: 3

Задаём количество реплик в кластере. У нас оно равно числу рабочих нод K8s.

annotations:
        scheduler.alpha.kubernetes.io/affinity: >
            {
              "podAntiAffinity": {
                "requiredDuringSchedulingIgnoredDuringExecution": [{
                  "labelSelector": {
                    "matchExpressions": [{
                      "key": "app",
                      "operator": "In",
                      "values": ["rabbitmq"]
                    }]
                  },
                  "topologyKey": "kubernetes.io/hostname"
                }]
              }
            }

При падении одной из нод K8s statefulset стремится сохранить количество экземпляров в наборе, поэтому создаёт по нескольку подов на одной и той же ноде K8s. Это поведение совершенно нежелательно и в принципе бессмысленно. Поэтому мы прописываем anti-affinity правило для подов из statefulset. Правило делаем жестким (Required), чтобы kube-scheduler не мог его нарушать при планировании подов.

Суть проста: планировщику запрещено размещать (в пределах namespace) более одного пода с тегом app:rabbitmq на каждой ноде. Ноды различаем по значению метки kubernetes.io/hostname. Теперь если по какой-то причине число работающих нод K8S меньше, чем требуемое количество реплик в StatefulSet, новые реплики не будут создаваться, пока снова не появится свободная нода.

serviceAccountName: rabbitmq

Прописываем ServiceAccount, под которым работают наши поды.

image: rabbitmq:3.7

Образ RabbitMQ совершенно стандартный и берётся с docker hub, не требует никакой пересборки и доработки напильником.

- name: rabbitmq-data
    mountPath: /var/lib/rabbitmq/mnesia

Персистентные данные у RabbitMQ хранятся в /var/lib/rabbitmq/mnesia. Здесь мы монтируем наш Persistent Volume Claim в эту папку, чтобы при перезапуске подов/нод или даже всего StatefulSet данные (как служебные, в том числе о собранном кластере, так и пользовательские) оставались в целости и сохранности. Можно встретить некоторые примеры, когда персистентной делают папку /var/lib/rabbitmq/ целиком. Мы пришли к выводу, что это не самая лучшая идея, так как при этом начинает запоминаться и вся информация, заданная конфигами Rabbit. То есть для того, чтобы изменить что-то в конфигурационном файле, требуется почистить персистентное хранилище, что очень неудобно в эксплуатации.

          - name: HOSTNAME
            valueFrom:
              fieldRef:
                fieldPath: metadata.name
          - name: NAMESPACE
            valueFrom:
              fieldRef:
                fieldPath: metadata.namespace
          - name: RABBITMQ_USE_LONGNAME
            value: "true"
          - name: RABBITMQ_NODENAME
            value: "rabbit@$(HOSTNAME).rabbitmq-internal.$(NAMESPACE).svc.cluster.local"

Этим набором переменных окружения мы, во-первых, говорим RabbitMQ использовать в качестве идентификатора членов кластера FQDN-имя, а во-вторых, задаём формат этого имени. Формат описывался ранее при разборе конфига.

- name: K8S_SERVICE_NAME
            value: "rabbitmq-internal"

Имя headless сервиса для общения членов кластера.

- name: RABBITMQ_ERLANG_COOKIE
            value: "mycookie"

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

volumes:
        - name: rabbitmq-data
          persistentVolumeClaim:
            claimName: rabbitmq-data

Определяем подключаемый том из созданного ранее Persistent Volume Claim.

На этом мы закончили с настройкой в K8s. В результате получился кластер RabbitMQ, равномерно распределяющий очереди по нодам и устойчивый к проблемам в среде выполнения.

image

При недоступности одной из нод кластера, очереди, содержащиеся на ней, перестанут быть доступны, всё остальное продолжит работу. Как только нода вернётся в строй, она вернётся в кластер, и очереди, для которых она была Master'ом, снова станут работоспособными с сохранением всех содержащихся в них данных (если не сломалось персистентное хранилище, разумеется). Все эти процессы проходят полностью автоматически и не требуют вмешательства.

Бонус: настраиваем HA


В одном из проектов был нюанс. В требованиях звучало полное зеркалирование всех содержащихся в кластере данных. Это нужно, чтобы в ситуации, когда хотя бы одна нода кластера работоспособна, с точки зрения прикладного приложения всё продолжало работать. Этот момент никак не связан именно с K8s, описываем просто в качестве mini how-to.

Для включения полного HA необходимо в RabbitMQ dashboard на вкладке Admin -> Policies создать Policy. Имя произвольное, Pattern пустой (все очереди), в Definitions добавить два параметра: ha-mode: all, ha-sync-mode: automatic.

image

image

После этого все создаваемые в кластере очереди будут находиться в режиме High Availability: при недоступности Master-ноды новым мастером автоматически будет выбираться один из Slave’ов. А данные, поступающие в очередь, будут зеркалироваться на все ноды кластера. Что, собственно, и требовалось получить.

image

Подробнее прочитать о HA в RabbitMQ можно тут

Полезная литература:



Успехов!

Комментарии (9)


  1. qwertyRu
    10.08.2018 14:31

    А зачем создавали PV и PVC?
    Если локальная папка, то можно и сразу указать в описании контейнера


    1. maxout Автор
      13.08.2018 11:37

      С папкой в описании контейнера не будет персистентности. Можно сделать через VolumeClaimTemplate в описании StatefulSet, но путь с отдельным объявлением PVC показался немного логичнее. А PV в любом случае необходимо создавать отдельно.


  1. Wimbo
    10.08.2018 15:35

    Практика показала, что эти readiness/liveness Probe очень плохие, так как всегда возвращают exit code = 0 и может таймаутить без причины.
    Пока пришли к таким пробам:

            livenessProbe:
              httpGet:
                path: /api/whoami
                port: 15672
                httpHeaders:
                  - name: Authorization
                    value: {{ printf "Basic %s" ( printf "%s:%s" $rmq_user $rmq_pass | b64enc) | quote }}
              initialDelaySeconds: 120
              timeoutSeconds: 5
              failureThreshold: 6
            readinessProbe:
              httpGet:
                path: /api/whoami
                port: 15672
                httpHeaders:
                  - name: Authorization
                    value: {{ printf "Basic %s" ( printf "%s:%s" $rmq_user $rmq_pass | b64enc) | quote }}
              initialDelaySeconds: 10
              timeoutSeconds: 3
              periodSeconds: 5


    Конечно, стоит не забывать и про rabbitmq exporter.


    1. maxout Автор
      13.08.2018 11:38

      Спасибо, изучим. Пока с дефолтными Probes проблем не было, но будем обязательно иметь ваш опыт в виду.


  1. celebrate
    11.08.2018 12:55

    ReadWriteMany бессмысленен, если используете hostPath. А так спасибо, не знал что кролик уже умеет в кубер из коробки.


    1. maxout Автор
      13.08.2018 11:41

      Да, конечно, для hostPath неактуально, просочилось в пример из боевой конфигурации. Здесь — физического смысла не несёт, вреда, впрочем, тоже :)


  1. Dm3Ch
    13.08.2018 08:30

    Сразу уточним, что мы устанавливали RabbitMQ в K8s как StatefulSet. На каждой ноде кластера K8s будет всегда функционировать один экземпляр RabbitMQ


    По какой причине было решено использовать StatefulSet с affinity для гарантирования того, что на каждом узле будет один под. Почему не использовали для этого DaemonSet, ведь этот примитив изначально был создан для обеспечения наличия на каждой доступной ноде одного пода приложения?


    1. maxout Автор
      13.08.2018 11:53

      А StatefulSet изначально был создан для Stateful-приложений :)
      Основные причины: персистентные ID контейнеров и упорядоченный запуск.


    1. Wimbo
      13.08.2018 11:56

      Нод в кластере может быть и более 50, такое количество подов rabbitmq никому не нужен, а с daemonset придется городить лейблы, селекторы. Поэтому statefulset хорошо подходит под данный кейс.