company_banner

Аварии как опыт #2. Как развалить Elasticsearch при переносе внутри Kubernetes

    В нашей внутренней production-инфраструктуре есть не слишком критичный участок, на котором периодически обкатываются различные технические решения, в том числе и различные версии Rook для stateful-приложений. На момент проведения описываемых работ эта часть инфраструктуры работала на основе Kubernetes-кластера версии 1.15, и возникла потребность в его обновлении.

    За заказ persistent volumes в кластере отвечал Rook версии 0.9. Мало того, что этот оператор сам по себе был старой версии, его Helm-релиз содержал ресурсы с deprecated-версиями API, что препятствовало обновлению кластера. Решив не возиться с обновлением Rook «вживую», мы стали полностью разбирать его.

    Внимание! Это история провала: не повторяйте описанные ниже действия в production, не прочитав внимательно до конца.

    Итак, вынос данных в хранилища StorageClass’ов, не управляемых Rook’ом, шел уже несколько часов успешно…


    «Беспростойная» миграция данных Elasticsearch

    … когда дело дошло до развернутого в Kubernetes кластера Elasticsearch из 3-х узлов:

    ~ $ kubectl -n kibana-production get po | grep elasticsearch
    elasticsearch-0                               1/1     Running     0         77d2h
    elasticsearch-1                               1/1     Running     0         77d2h
    elasticsearch-2                               1/1     Running     0         77d2h

    Для него было принято решение осуществить переезд на новые PV без простоя. Конфиг в ConfigMap был проверен и сюрпризов не ожидалось. Хотя в алгоритме действий по миграции и присутствует пара опасных поворотов, чреватых аварией при выпадении узлов Kubernetes-кластера, эти узлы работают стабильно… да и вообще: «Я сто раз так делал», — так что поехали!

    1. Вносим изменения в StatefulSet в Helm-чарте для Elasticsearch (es-data-statefulset.yaml):

    apiVersion: apps/v1
    kind: StatefulSet
    metadata:
      labels:
        component: {{ template "fullname" . }}
        role: data
      name: {{ template "fullname" . }}
    spec:
      serviceName: {{ template "fullname" . }}-data
    …
    
     volumeClaimTemplates:
      - metadata:
          name: data
          annotations:
            volume.beta.kubernetes.io/storage-class: "high-speed"

    В последней строчке (с определением storage class) было ранее указано значение rbd вместо нынешнего high-speed.

    2. Удаляем существующий StatefulSet с cascade=false. Это опасный поворот, потому что наличие pod’ов с ES больше не контролируется StatefulSet’ом и в случае внезапного отказа какого-либо K8s-узла, на котором запущен pod с ES, этот pod не «возродится» автоматически. Однако операция некаскадного удаления StatefulSet и его редеплоя с новыми параметрами занимает секунды, поэтому риски относительны (т.е. зависят от конкретного окружения, конечно).

    Приступим:

     $ kubectl -n kibana-production delete sts elasticsearch --cascade=false
    statefulset.apps "elasticsearch" deleted

    3. Деплоим заново наш Elasticsearch, а затем масштабируем StatefulSet до 6 реплик:

    ~ $ kubectl -n kibana-production scale sts elasticsearch --replicas=6
    statefulset.apps/elasticsearch scaled

    … и смотрим на результат:

    ~ $ kubectl -n kibana-production get po | grep elasticsearch
    elasticsearch-0                               1/1     Running     0         77d2h
    elasticsearch-1                               1/1     Running     0         77d2h
    elasticsearch-2                               1/1     Running     0         77d2h
    elasticsearch-3                               1/1     Running     0         11m
    elasticsearch-4                               1/1     Running     0         10m
    elasticsearch-5                               1/1     Running     0         10m
    
    ~ $ kubectl -n kibana-production exec -ti elasticsearch-0 bash
    [root@elasticsearch-0 elasticsearch]# curl --user admin:********** -sk https://localhost:9200/_cat/nodes
    10.244.33.142  8 98 49 7.89 4.86 3.45 dim - elasticsearch-4
    10.244.33.118 26 98 35 7.89 4.86 3.45 dim - elasticsearch-2
    10.244.33.140  8 98 60 7.89 4.86 3.45 dim - elasticsearch-3
    10.244.21.71   8 93 58 8.53 6.25 4.39 dim - elasticsearch-5
    10.244.33.120 23 98 33 7.89 4.86 3.45 dim - elasticsearch-0
    10.244.33.119  8 98 34 7.89 4.86 3.45 dim * elasticsearch-1

    Картина с хранилищем данных:

    ~ $ kubectl -n kibana-production get pvc | grep elasticsearch
    NAME                   STATUS        VOLUME       CAPACITY   ACCESS MODES   STORAGECLASS    AGE
    data-elasticsearch-0   Bound   pvc-a830fb81-...   12Gi       RWO            rbd             77d
    data-elasticsearch-1   Bound   pvc-02de4333-...   12Gi       RWO            rbd             77d
    data-elasticsearch-2   Bound   pvc-6ed66ff0-...   12Gi       RWO            rbd             77d
    data-elasticsearch-3   Bound   pvc-74f3b9b8-...   12Gi       RWO            high-speed      12m
    data-elasticsearch-4   Bound   pvc-16cfd735-...   12Gi       RWO            high-speed      12m
    data-elasticsearch-5   Bound   pvc-0fb9dbd4-...   12Gi       RWO            high-speed      12m

    Отлично!

    4. Добавим бодрости переносу данных.

    Если в вас еще жив дух авантюризма и неудержимо влечет к приключениям (т.е. данные в окружении не столь критичны), можно ускорить процесс, оставив одну реплику индексов:

    ~ $ kubectl -n kibana-production exec -ti elasticsearch-0 bash
    [root@elasticsearch-0 elasticsearch]# curl --user admin:********** -H "Content-Type: application/json" -X PUT -sk https://localhost:9200/my-index-pattern-*/_settings -d '{"number_of_replicas": 0}'
    {"acknowledged":true}

    … но мы, конечно, так делать не будем:

    ~ $ ^C
    ~ $ kubectl -n kibana-production exec -ti elasticsearch-0 bash
    [root@elasticsearch-0 elasticsearch]# curl --user admin:********** -H "Content-Type: application/json" -X PUT -sk https://localhost:9200/my-index-pattern-*/_settings -d '{"number_of_replicas": 2}'
    {"acknowledged":true}

    Иначе утрата одного pod’а приведет к неконсистентности данных до его восстановления, а утрата хотя бы одного PV в случае ошибки приведет к потере данных.

    Увеличим лимиты перебалансировки:

    [root@elasticsearch-0 elasticsearch]# curl --user admin:********** -XPUT -H 'Content-Type: application/json' -sk https://localhost:9200/_cluster/settings?pretty -d '{
    >   "transient" :{
    >     "cluster.routing.allocation.cluster_concurrent_rebalance" : 20,
    >     "cluster.routing.allocation.node_concurrent_recoveries" : 20,
    >     "cluster.routing.allocation.node_concurrent_incoming_recoveries" : 10,
    >     "cluster.routing.allocation.node_concurrent_outgoing_recoveries" : 10,
    >     "indices.recovery.max_bytes_per_sec" : "200mb"
    >   }
    > }'
    {
      "acknowledged" : true,
      "persistent" : { },
      "transient" : {
        "cluster" : {
          "routing" : {
            "allocation" : {
              "node_concurrent_incoming_recoveries" : "10",
              "cluster_concurrent_rebalance" : "20",
              "node_concurrent_recoveries" : "20",
              "node_concurrent_outgoing_recoveries" : "10"
            }
          }
        },
        "indices" : {
          "recovery" : {
            "max_bytes_per_sec" : "200mb"
          }
        }
      }
    }
    

    5. Выгоним шарды с первых трех старых узлов ES:

    [root@elasticsearch-0 elasticsearch]# curl --user admin:********** -XPUT -H 'Content-Type: application/json' -sk https://localhost:9200/_cluster/settings?pretty -d '{
    >   "transient" :{
    >       "cluster.routing.allocation.exclude._ip" : "10.244.33.120,10.244.33.119,10.244.33.118"
    >    }
    > }'
    {
      "acknowledged" : true,
      "persistent" : { },
      "transient" : {
        "cluster" : {
          "routing" : {
            "allocation" : {
              "exclude" : {
                "_ip" : "10.244.33.120,10.244.33.119,10.244.33.118"
              }
            }
          }
        }
      }
    }
    

    Вскоре получим первые 3 узла без данных:

    [root@elasticsearch-0 elasticsearch]# curl --user admin:********** -sk https://localhost:9200/_cat/shards | grep 'elasticsearch-[0..2]' | wc -l
    0
    

    6. Пришла пора по одной убить старые узлы Elasticsearch.

    Готовим вручную три PersistentVolumeClaim такого вида:

    ~ $ cat pvc2.yaml
    ---
    apiVersion: v1
    kind: PersistentVolumeClaim
    metadata:
      name: data-elasticsearch-2
    spec:
      accessModes: [ "ReadWriteOnce" ]
      resources:
        requests:
          storage: 12Gi
      storageClassName: "high-speed"

    Удаляем по очереди PVC и pod у реплик 0, 1 и 2, друг за другом. При этом создаем PVC вручную и убеждаемся, что экземпляр ES в новом pod’е, порожденном StatefulSet’ом, успешно «запрыгнул» в кластер ES:

    ~ $ kubectl -n kibana-production delete pvc data-elasticsearch-2 persistentvolumeclaim "data-elasticsearch-2" deleted
    ^C
    
    ~ $ kubectl -n kibana-production delete po elasticsearch-2
    pod "elasticsearch-2" deleted
    
    ~ $ kubectl -n kibana-production apply -f pvc2.yaml
    persistentvolumeclaim/data-elasticsearch-2 created
    
    ~ $ kubectl -n kibana-production get po | grep elasticsearch
    elasticsearch-0                               1/1     Running     0         77d3h
    elasticsearch-1                               1/1     Running     0         77d3h
    elasticsearch-2                               1/1     Running     0         67s
    elasticsearch-3                               1/1     Running     0         42m
    elasticsearch-4                               1/1     Running     0         41m
    elasticsearch-5                               1/1     Running     0         41m
    
    ~ $ kubectl -n kibana-production exec -ti elasticsearch-0 bash
    [root@elasticsearch-0 elasticsearch]# curl --user admin:********** -sk https://localhost:9200/_cat/nodes
    10.244.21.71  21 97 38 3.61 4.11 3.47 dim - elasticsearch-5
    10.244.33.120 17 98 99 8.11 9.26 9.52 dim - elasticsearch-0
    10.244.33.140 20 97 38 3.61 4.11 3.47 dim - elasticsearch-3
    10.244.33.119 12 97 38 3.61 4.11 3.47 dim * elasticsearch-1
    10.244.34.142 20 97 38 3.61 4.11 3.47 dim - elasticsearch-4
    10.244.33.89  17 97 38 3.61 4.11 3.47 dim - elasticsearch-2

    Наконец, добираемся до ES-узла №0: удаляем pod elasticsearch-0, после чего он успешно запускается с новым storageClass, заказывает себе PV. Результат:

    ~ $ kubectl -n kibana-production exec -ti elasticsearch-0 bash
    [root@elasticsearch-0 elasticsearch]# curl --user admin:********** -sk https://localhost:9200/_cat/nodes
    10.244.33.151 17 98 99 8.11 9.26 9.52 dim * elasticsearch-0

    При этом в соседнем pod’е:

    ~ $ kubectl -n kibana-production exec -ti elasticsearch-1 bash
    [root@elasticsearch-0 elasticsearch]# curl --user admin:********** -sk https://localhost:9200/_cat/nodes
    10.244.21.71  16 97 27 2.59 2.76 2.57 dim - elasticsearch-5
    10.244.33.140 20 97 38 2.59 2.76 2.57 dim - elasticsearch-3
    10.244.33.35  12 97 38 2.59 2.76 2.57 dim - elasticsearch-1
    10.244.34.142 20 97 38 2.59 2.76 2.57 dim - elasticsearch-4
    10.244.33.89  17 97 98 7.20 7.53 7.51 dim * elasticsearch-2

    Поздравляю: мы получили split-brain в production! И сейчас новые данные случайным образом сыпятся в два разных кластера ES!

    Простой и потеря данных

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

    Может, скинуть label у pod’а elasticsearch-0, чтобы исключить его из балансировки на уровне Service? Но ведь, исключив pod, мы не сможем его «затолкать» обратно в кластер ES, потому что при формировании кластера обнаружение членов кластера происходит через тот же Service! 

    За это отвечает переменная окружения:

            env:
            - name: DISCOVERY_SERVICE
              value: elasticsearch

    … и ее использование в ConfigMap’е elasticsearch.yaml (см. документацию):

    discovery:
          zen:
            ping.unicast.hosts: ${DISCOVERY_SERVICE}

    В общем, по такому пути мы не пойдем. Вместо этого лучше срочно остановить workers, которые пишут данные в ES в реальном времени. Для этого отмасштабируем все три нужных deployment’а в 0. (К слову, хорошо, что приложение придерживается микросервисной архитектуры и не надо останавливать весь сервис целиком.)

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

    Причина аварии и восстановление

    В чем же дело? Почему узел №0 не присоединился к кластеру? Еще раз проверяем конфигурационные файлы: с ними все в порядке.

    Проверяем внимательно еще раз Helm-чарты… вот же оно! Итак, проблемный es-data-statefulset.yaml:

    apiVersion: apps/v1
    kind: StatefulSet
    metadata:
      labels:
        component: {{ template "fullname" . }}
        role: data
      name: {{ template "fullname" . }}
    …
    
         containers:
          - name: elasticsearch
            env:
            {{- range $key, $value :=  .Values.data.env }}
            - name: {{ $key }}
              value: {{ $value | quote }}
            {{- end }}
            - name: cluster.initial_master_nodes     # !!!!!!
              value: "{{ template "fullname" . }}-0" # !!!!!!
            - name: CLUSTER_NAME
              value: myesdb
            - name: NODE_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            - name: DISCOVERY_SERVICE
              value: elasticsearch
            - name: KUBERNETES_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
            - name: ES_JAVA_OPTS
              value: "-Xms{{ .Values.data.heapMemory }} -Xmx{{ .Values.data.heapMemory }} -Xlog:disable -Xlog:all=warning:stderr:utctime,level,tags -Xlog:gc=debug:stderr:utctime -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.host=127.0.0.1 -Djava.rmi.server.hostname=127.0.0.1 -Dcom.sun.management.jmxremote.port=9099 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false"
    ...

    Зачем же так определены initial_master_nodes?! Здесь задано (см. документацию) жесткое ограничение, что при первичном старте кластера в выборах мастера участвует только 0-й узел. Так и произошло: pod elasticsearch-0 поднялся с пустым PV, начался процесс бутстрапа кластера, а мастер в pod’е elasticsearch-2 был благополучно проигнорирован.

    Ок, добавим в ConfigMap «на живую»: 

    ~ $ kubectl -n kibana-production edit cm elasticsearch
    
    apiVersion: v1
    data:
      elasticsearch.yml: |-
        cluster:
          name: ${CLUSTER_NAME}
          initial_master_nodes:
            - elasticsearch-0
            - elasticsearch-1
            - elasticsearch-2
    ...

    … и удалим эту переменную окружения из StatefulSet:

    ~ $ kubectl -n kibana-production edit sts elasticsearch
    
    ...
          - env:
            - name: cluster.initial_master_nodes
              value: "elasticsearch-0"
    ...

    StatefulSet начинает перекатывать все pod’ы по очереди согласно стратегии RollingUpdate, и делает это, разумеется, с конца, т.е. от 5-го pod’а к 0-му:

    ~ $ kubectl -n kibana-production get po
    NAME              READY   STATUS        RESTARTS   AGE
    elasticsearch-0   1/1     Running       0          11m
    elasticsearch-1   1/1     Running       0          13m
    elasticsearch-2   1/1     Running       0          15m
    elasticsearch-3   1/1     Running       0          67m
    elasticsearch-4   1/1     Running       0          67m
    elasticsearch-5   0/1     Terminating   0          67m

    Что произойдет, когда перекат дойдет до конца? Как отработает бутстрап кластера? Ведь перекат StatefulSet идет быстро… как пройдут выборы в таких условиях, если даже в документации заявляется, что «auto-bootstrapping is inherently unsafe»? Не получим ли мы кластер, забустрапленный из 0-го узла с «огрызком» индекса?Примерно из-за таких мыслей спокойно наблюдать за происходящим у меня ну никак не получалось.

    Забегая вперёд: нет, в заданных условиях всё будет хорошо. Однако 100% уверенности в тот момент не было. А представьте, что это production, где много данных, которые критичны для бизнеса (= это чревато дополнительной возней с бэкапами)…

    Поэтому, пока перекат не докатился до 0-го pod’а, сохраним и убьем сервис, отвечающий за discovery:

    ~ $ kubectl -n kibana-production get svc elasticsearch -o yaml > elasticsearch.yaml
    
    ~ $ kubectl -n kibana-production delete svc elasticsearch 
    service "elasticsearch" deleted

    … и «оторвем» PVC у 0-го pod’а:

    ~ $ kubectl -n kibana-production delete pvc data-elasticsearch-0 persistentvolumeclaim "data-elasticsearch-0" deleted
    ^C
    

    Теперь, когда перекат прошел, elasticsearch-0 в состоянии Pending из-за отсутствия PVC, а кластер полностью развален, т.к. узлы ES потеряли друг друга:

    ~ $ kubectl -n kibana-production exec -ti elasticsearch-1 bash
    [root@elasticsearch-1 elasticsearch]# curl --user admin:********** -sk https://localhost:9200/_cat/nodes
    Open Distro Security not initialized.

    На всякий случай исправим ConfigMap вот так:

    ~ $ kubectl -n kibana-production edit cm elasticsearch
    
    apiVersion: v1
    data:
      elasticsearch.yml: |-
        cluster:
          name: ${CLUSTER_NAME}
          initial_master_nodes:
            - elasticsearch-3
            - elasticsearch-4
            - elasticsearch-5
    ...

    После этого создадим новый пустой PV для elasticsearch-0, создав PVC:

     $ kubectl -n kibana-production apply -f pvc0.yaml
    persistentvolumeclaim/data-elasticsearch-0 created

    И перезапустим узлы для применения изменений в ConfigMap:

    ~ $ kubectl -n kibana-production delete po elasticsearch-0 elasticsearch-1 elasticsearch-2 elasticsearch-3 elasticsearch-4 elasticsearch-5
    pod "elasticsearch-0" deleted
    pod "elasticsearch-1" deleted
    pod "elasticsearch-2" deleted
    pod "elasticsearch-3" deleted
    pod "elasticsearch-4" deleted
    pod "elasticsearch-5" deleted

    Можно возвращать на место сервис из недавно сохраненного нами YAML-манифеста:

    ~ $ kubectl -n kibana-production apply -f elasticsearch.yaml 
    service/elasticsearch created

    Посмотрим, что получилось:

    ~ $ kubectl -n kibana-production exec -ti elasticsearch-0 bash
    [root@elasticsearch-0 elasticsearch]# curl --user admin:********** -sk https://localhost:9200/_cat/nodes
    10.244.98.100  11 98 32 4.95 3.32 2.87 dim - elasticsearch-0
    10.244.101.157 12 97 26 3.15 3.00 2.10 dim - elasticsearch-3
    10.244.107.179 10 97 38 1.66 2.46 2.52 dim * elasticsearch-1
    10.244.107.180  6 97 38 1.66 2.46 2.52 dim - elasticsearch-2
    10.244.100.94   9 92 36 2.23 2.03 1.94 dim - elasticsearch-5
    10.244.97.25    8 98 42 4.46 4.92 3.79 dim - elasticsearch-4
    
    [root@elasticsearch-0 elasticsearch]# curl --user admin:********** -sk https://localhost:9200/_cat/indices | grep -v green | wc -l
    0

    Ура! Выборы прошли нормально, кластер собрался полностью, индексы на месте.

    Осталось только:

    1. Снова вернуть в ConfigMap значения initial_master_nodes для elasticsearch-0..2;

    2. Еще раз перезапустить все pod’ы;

    3. Аналогично шагу, описанному в начале статьи, выгнать все шарды на узлы 0..2 и отмасштабировать кластер с 6 до 3-х узлов;

    4. Наконец, сделанные вручную изменения донести до репозитория.

    Заключение

    Какие уроки можно извлечь из данного случая?

    Работая с переносом данных в production, всегда следует иметь в виду, что что-то может пойти не так: будет допущена ошибка в конфигурации приложения или сервиса, произойдет внезапная авария в ЦОД, начнутся сетевые проблемы… да все что угодно! Соответственно, перед началом работ должны быть предприняты меры, которые позволят либо предотвратить аварию, либо максимально купировать ее последствия. Обязательно должен быть подготовлен «План Б».

    Использованный нами алгоритм действий был неустойчив к внезапным проблемам. Перед выполнением этих работ в более важном окружении было бы необходимо:

    1. Выполнить переезд в тестовом окружении с production-конфигурацией ES.

    2. Запланировать простой сервиса. Либо временно переключить нагрузку на другой кластер. (Предпочтительный путь зависит от требований к доступности.) В случае варианта с простоем следовало предварительно остановить workers, пишущие данные в Elasticsearch, снять затем свежую резервную копию, а после этого приступить к работам по переносу данных в новое хранилище.

    P.S.

    Читайте также в нашем блоге:

    Флант
    DevOps-as-a-Service, Kubernetes, обслуживание 24×7

    Комментарии 3

      +2

      Спасибо, очень развернуто, интересная Ловушка с одной лидер нодой, тоже пару раз попадался на неё, особенно когда искал лидеров через dns и когда первым делом нода находила себя, тогда останавливала поиск остальных контейнеров. Решалось настройкой минимального количества лидеров (хотя бы 2) и тогда каждый ластик искал минимум 2х лидеров через dns чтобы стартануть

        +1
        Для тех кто ничего не понял — не волнуйтесь, это просто плюмбус.
          0
          Очень захватывающая и полезная статья, спасибо. Немного удивился увидев, что попадая в pod — сразу root, это намеренно оставлено?

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое