В нашей внутренней 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
Ура! Выборы прошли нормально, кластер собрался полностью, индексы на месте.
Осталось только:
-
Снова вернуть в ConfigMap значения
initial_master_nodes
дляelasticsearch-0..2
; -
Еще раз перезапустить все pod’ы;
-
Аналогично шагу, описанному в начале статьи, выгнать все шарды на узлы 0..2 и отмасштабировать кластер с 6 до 3-х узлов;
-
Наконец, сделанные вручную изменения донести до репозитория.
Заключение
Какие уроки можно извлечь из данного случая?
Работая с переносом данных в production, всегда следует иметь в виду, что что-то может пойти не так: будет допущена ошибка в конфигурации приложения или сервиса, произойдет внезапная авария в ЦОД, начнутся сетевые проблемы… да все что угодно! Соответственно, перед началом работ должны быть предприняты меры, которые позволят либо предотвратить аварию, либо максимально купировать ее последствия. Обязательно должен быть подготовлен «План Б».
Использованный нами алгоритм действий был неустойчив к внезапным проблемам. Перед выполнением этих работ в более важном окружении было бы необходимо:
-
Выполнить переезд в тестовом окружении с production-конфигурацией ES.
-
Запланировать простой сервиса. Либо временно переключить нагрузку на другой кластер. (Предпочтительный путь зависит от требований к доступности.) В случае варианта с простоем следовало предварительно остановить workers, пишущие данные в Elasticsearch, снять затем свежую резервную копию, а после этого приступить к работам по переносу данных в новое хранилище.
P.S.
Читайте также в нашем блоге:
-
«Аварии как опыт #1. Как сломать два кластера ClickHouse, не уточнив один нюанс»;
-
«Как мы Elasticsearch в порядок приводили: разделение данных, очистка, бэкапы»;
-
«elasticsearch-extractor — утилита для извлечения индексов из снапшотов Elasticsearch».
Автор: Andrew Fishday