Если вы когда-либо взаимодействовали с кластером Kubernetes, скорее всего, он был основан на etcd. etcd лежит в основе работы Kubernetes, но несмотря на это, напрямую взаимодействовать с ним приходится не каждый день.

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

Как ETCD вписывается в Kubernetes

В кластере Kubernetes есть три категории control-plane процессов, то есть процессов так называемого «управляющего слоя»:

  1. Централизованные контроллеры. Scheduler (планировщик), controller-manager и сторонние контроллеры, которые настраивают поды и другие ресурсы.

  2. Процессы, специфичные для нод. Наиболее важные: kubelet, который выполняет тонкую настройку пода и сети на основе желаемой конфигурации.

  3. Сервер API, который координирует взаимодействие между всеми процессами control-plane и нодами.

Вот как это выглядит:

Сontrol plane Kubernetes включает в себя controller manager, сервер API, scheduler и etcd (среди прочих компонентов).
Сontrol plane Kubernetes включает в себя controller manager, сервер API, scheduler и etcd (среди прочих компонентов).
На каждой ноде кластера установлен kubelet — агент Kubernetes, выполняющий такие задачи, как создание контейнеров, подключение их к сети, монтирование томов и т. д.
На каждой ноде кластера установлен kubelet — агент Kubernetes, выполняющий такие задачи, как создание контейнеров, подключение их к сети, монтирование томов и т. д.
Kubernetes API — это клей, который соединяет внутренние контроллеры с kubelet.
Kubernetes API — это клей, который соединяет внутренние контроллеры с kubelet.

В Kubernetes есть интересное проектное решение: сам сервер API выполняет очень мало действий.

Когда пользователь или процесс выполняет вызов API, то сервер API:

  1. Определяет, разрешён ли вызов API (используя RBAC).

  2. В зависимости от настроек, изменяет полезную нагрузку вызова API с помощью mutating webhooks.

  3. Проверяет на валидность полезную нагрузку (используя внутреннюю проверку и валидирующие веб-хуки).

  4. Сохраняет полезную нагрузку API и возвращает запрошенную информацию.

  5. Уведомляет подписчиков эндпоинта API, что объект изменился (подробнее об этом позже).

Предположим, вы хотите создать развёртывание с помощью kubectl apply -f deployment.yaml.
Предположим, вы хотите создать развёртывание с помощью kubectl apply -f deployment.yaml.
Сервер API получает запросы и проверяет, являетесь ли вы действительным пользователем (аутентификация) и имеете ли вы права на создание развёртываний (авторизация).
Сервер API получает запросы и проверяет, являетесь ли вы действительным пользователем (аутентификация) и имеете ли вы права на создание развёртываний (авторизация).
Затем определение развертывания сохраняется в etcd.
Затем определение развертывания сохраняется в etcd.

Всё остальное, что происходит в кластере, обрабатывается контроллерами и процессами, специфичными для нод.

Как только Deployment создается в etcd, controller manager получает уведомление о новом ресурсе.
Как только Deployment создается в etcd, controller manager получает уведомление о новом ресурсе.
Контроллеры Deployment и ReplicaSet в конечном итоге будут создавать и хранить поды в etcd.
Контроллеры Deployment и ReplicaSet в конечном итоге будут создавать и хранить поды в etcd.

Если говорить об архитектуре, то сервер API — это по сути CRUD-приложение. То есть предназначенное для чтения, записи, обновления и удаления данных. Оно в принципе не отличается от, например, WordPress. Большая часть его работы — это хранение и предоставление данных.

И, как и WordPress, он нуждается в базе для хранения постоянных данных, и тут в дело вступает etcd.

Почему etcd?

Может ли сервер API использовать базу данных SQL, такую как MySQL или PostgreSQL?

Конечно, может (об этом позже), но она не очень подходит для особых требований Kubernetes.

А какими свойствами должна обладать база данных, на которую опирается сервер API?


Как правило, этими:

1. Согласованность (консистентность)

Так как сервер API — это центральный координационный пункт всего кластера, важна строгая согласованность. 

Будет катастрофой, если, к примеру, две ноды попытаются подключить один и тот же постоянный том по iSCSI, потому что сервер API сообщил им обоим, что он доступен.

Из-за этого требования исключены NoSQL базы данных и некоторые распределённые мультимастерные конфигурации SQL.

2. Доступность

Простои API означают остановку всего Kubernetes control plane, что нежелательно для продакшн-кластеров.

Теорема CAP гласит, что 100%-ная доступность невозможна при строгой согласованности, но минимизация простоев всё ещё является важной целью.

3. Стабильная производительность

Сервер API для загруженного кластера Kubernetes обрабатывает значительное количество запросов на чтение и запись. Непредсказуемые замедления из-за одновременного использования были бы огромной проблемой.

4. Уведомление об изменениях

Так как сервер API выступает как централизованный координатор между разными типами клиентов, потоковая передача изменений в реальном времени была бы отличным функционалом (и оказывается, что она лежит в основе работы Kubernetes на практике).

А вот что не требуется от базы данных API:

  • Большие наборы данных. Так как сервер API хранит только метаданные о подах и других объектах, существует естественное ограничение на объём хранимых данных. В большом кластере будет храниться от сотен мегабайт до нескольких гигабайт данных, но уж точно не терабайты.

  • Сложные запросы. API Kubernetes предсказуем в своих шаблонах доступа: к большинству объектов обращаются по типу, пространству имён, а иногда и по имени. Если есть дополнительное фильтрование, оно обычно выполняется по меткам или аннотациям. Возможности SQL с соединениями и сложными аналитическими запросами избыточны для работы API.

Традиционные SQL базы данных оптимизированы для строгой согласованности с большими и сложными наборами данных, однако зачастую трудно достичь высокой доступности и стабильной производительности «из коробки». Таким образом, они не очень подходят для использования Kubernetes API.

etcd

Согласно информации на его сайте, etcd — это «строго согласованное распределённое хранилище ключ-значение».

Разберём, что это значит:

  • Строгая согласованность. У etcd есть строгая сериализуемость (strict serializability). Она означает последовательное глобальное упорядочивание событий. На практике после того, как запись от одного клиента успешно завершилась, другой клиент никогда не увидит устаревших данных перед записью. (Этого не скажешь о конечных консистентных NoSQL базах данных).

  • Распределённость. В отличие от традиционных SQL баз данных, etcd разработан с нуля для работы с несколькими нодами. Особая конструкция etcd позволяет достичь высокой доступности (хотя и не 100 %) без потери согласованности.

  • Хранилище ключ-значение. В отличие от SQL баз данных, модель данных etcd проста и включает ключи и значения вместо произвольных отношений данных. Это помогает ему обеспечивать сравнительно предсказуемую производительность, по крайней мере, по сравнению с традиционными базами данных SQL.

Кроме того, у etcd есть ещё одна киллер фича, которую Kubernetes активно использует — уведомления об изменениях.

Etcd позволяет клиентам подписываться на изменения определённого ключа или набора ключей.

С учётом этих возможностей etcd относительно легко развернуть (если говорить о распределённых базах данных). Поэтому его выбрали для Kubernetes.

Стоит отметить, что etcd — не единственное доступное распределённое хранилище ключ-значение с похожими характеристиками. Есть и другие варианты: Apache ZooKeeper и HashiCorp Consul.

Как работает etcd

Секрет баланса строгой согласованности и высокой доступности etcd — алгоритм Raft.

Raft решает специфическую задачу: как несколько независимых процессов могут договориться о едином значении чего-либо?

В информатике эта проблема известна как распределённый консенсус; она была решена алгоритмом Paxos Лесли Лампорта, который эффективен, но, как известно, сложен для понимания и реализации на практике.

Raft был разработан для решения подобных задач, как и Paxos, но гораздо более понятным способом.

Raft выбирает лидера среди набора нод и вынуждает все запросы на запись направлять к лидеру.

Вот как это происходит:

Когда формируется кластер, все ноды получают состояние follower (подписчики).
Когда формируется кластер, все ноды получают состояние follower (подписчики).
Если подписчики ничего не получают или ничего не знают о лидере, то они могут стать кандидатами и запросить голоса у других нод.
Если подписчики ничего не получают или ничего не знают о лидере, то они могут стать кандидатами и запросить голоса у других нод.
Ноды отвечают и голосуют.
Ноды отвечают и голосуют.
Кандидат становится лидером, если за него проголосовало большинство нод.
Кандидат становится лидером, если за него проголосовало большинство нод.

Изменения затем реплицируются от лидера на все остальные ноды. Если лидер когда-нибудь отключится, то проводится новое голосование и выбирается новый лидер.

Если вам интересны детали, вот отличное наглядное объяснение. Оригинальная статья Raft также относительно проста для понимания и её стоит прочитать.

Таким образом, в кластере etcd в любой момент времени есть единственная лидер-нода (которую выбрали с помощью протокола Raft).

Записываемые данные проходят одинаковый путь:

  1. Клиент может отправить запрос на запись любой ноде etcd в кластере.

  2. Если клиент обратился к лидер-ноде, запись будет выполнена и реплицирована на другие ноды.

  3. Если клиент выбрал не лидер-ноду, то запрос на запись будет перенаправлен лидеру и далее запись будет перенаправлена и реплицирована на другие ноды, как в пункте 2.

  4. После успешного выполнения записи клиенту отправляется подтверждение.

В кластере etcd ноды достигают консенсуса с помощью протокола RAFT. Нода может быть помечена как подписчик, лидер или кандидат.
В кластере etcd ноды достигают консенсуса с помощью протокола RAFT. Нода может быть помечена как подписчик, лидер или кандидат.
Что происходит, когда вы хотите записать значение в базу данных? Сначала все запросы на запись поступают к лидеру. Лидер добавляет запись в журнал, но она не фиксируется.
Что происходит, когда вы хотите записать значение в базу данных? Сначала все запросы на запись поступают к лидеру. Лидер добавляет запись в журнал, но она не фиксируется.
Чтобы зафиксировать запись, нода сначала реплицирует её на последующие ноды.
Чтобы зафиксировать запись, нода сначала реплицирует её на последующие ноды.

Запросы на чтение проходят по тому же основному пути, хотя в качестве оптимизации можно разрешить выполнение запросов на чтение на нодах-репликах за счёт отказа от линейности.

Если лидер кластера по какой-либо причине отключается, проводится новое голосование, чтобы кластер мог оставаться в сети.

Важно, что для избрания нового лидера должно согласиться большинство нод (2/3, 4/6 и т. д.), и если большинство достичь не удаётся, весь кластер будет недоступен.

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

Сколько нод должно быть в кластере etcd, чтобы обеспечить «достаточно хорошую» доступность?

Как и на большинство вопросов, связанных с проектированием, ответ такой — все зависит от ситуации, но, взглянув на краткую таблицу, можно вывести эмпирическое правило:

Общее количество нод

Допустимое количество отказов среди нод

1

0

2

0

3

1

4

1

5

2

6

2

Из этой таблицы сразу бросается в глаза, что добавление одной ноды в кластер с нечётным числом нод не увеличивает доступность.

Например, кластеры с тремя или четырьмя нодами смогут выдержать сбой только одной ноды, поэтому добавление четвёртой ноды бессмысленно.

Поэтому хорошим эмпирическим правилом является использование нечётного числа нод etcd в кластере.

Сколько нод — правильно?

Опять же, ответ зависит от обстоятельств, но важно помнить, что все записи должны реплицироваться на подчинённые ноды. По мере добавления нод в кластер этот процесс будет становиться медленнее.

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

На практике типичные продакшен кластеры etcd обычно имеют 3 или 5 нод.

etcd на практике

Теперь, когда мы познакомились с etcd в теории, давайте посмотрим, как использовать его на практике. Начнем с простого.

Можем загрузить бинарные файлы etcd непосредственно с его страницы релизов.

Вот пример для Linux:

curl -LO https://github.com/etcd-io/etcd/releases/download/v3.5.0/etcd-v3.5.0-linux-amd64.tar.gz
tar xzvf etcd-v3.5.0-linux-amd64.tar.gz
cd etcd-v3.5.0-linux-amd64

Если вы посмотрите на содержимое релиза, то увидите три бинарных файла (вместе с документацией):

  • etcd, который запускает настоящий сервер etcd.

  • etcdctl, который представляет собой клиентский бинарный файл для взаимодействия с сервером.

  • etcdutl, который предоставляет некоторые вспомогательные утилиты для операций, например, резервного копирования.

Запустить «кластер» etcd из одной ноды так же просто, как выполнить команду:

./etcd
...
{"level":"info","caller":"etcdserver/server.go:2027","msg":"published local member..." }
{"level":"info","caller":"embed/serve.go:98","msg":"ready to serve client requests"}
{"level":"info","caller":"etcdmain/main.go:47","msg":"notifying init daemon"}
{"level":"info","caller":"etcdmain/main.go:53","msg":"successfully notified init daemon"}
{"level":"info","caller":"embed/serve.go:140","msg":"serving client traff...","address":"127.0.0.1:2379"}

Из логов видно, что etcd уже создал «кластер» и начал небезопасно обслуживать трафик на порту 127.0.0.1:2379.

Чтобы взаимодействовать с этим запущенным «кластером», можно использовать бинарный файл etcdctl.

Следует учесть одну сложность: между версиями 2 и 3 API etcd значительно изменился.

Чтобы использовать новый API, перед запуском etcdctl необходимо явно установить переменную среды.

Чтобы использовать API версии 3, можно использовать следующую команду в каждом окне терминала:

export ETCDCTL_API=3

Теперь вы можете использовать etcdctl для записи и чтения данных типа «ключ-значение»:

./etcdctl put foo bar
OK
./etcdctl get foo
foo
bar

Как вы можете видеть, команда etcdctl get выведет на экран ключ и значение. Вы можете использовать флаг --print-value-only, чтобы отключить это поведение.

Чтобы получить более подробный ответ, вы можете использовать опцию --write-out=json.

./etcdctl get --write-out=json foo
{
  "header": {
    "cluster_id": 14841639068965180000,
    "member_id": 10276657743932975000,
    "revision": 2,
    "raft_term": 2
  },
  "kvs": [
    {
      "key": "Zm9v",
      "create_revision": 2,
      "mod_revision": 2,
      "version": 1,
      "value": "YmFy"
    }
  ],
  "count": 1
}

Здесь вы можете увидеть метаданные, которые поддерживает etcd.

Данные имеют следующий вид:

  • version: 1

  • create_revision: 2

  • mod_revision: 2

В документации etcd объясняется значение этих параметров: version относится к версии конкретного ключа.

В отличие от этого, различные значения revision относятся к глобальной ревизии всего кластера.

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

Эта система известна как multiversion concurrency control (управление параллельным доступом посредством многоверсионности), или сокращённо MVCC.

Также стоит отметить, что и ключ, и значение возвращаются в кодировке base64. Это связано с тем, что ключи и значения в etcd представляют собой произвольные массивы байтов, а не строки.

В отличии, например, от MySQL, в etcd нет встроенного понятия строкового кодирования.

Обратите внимание, что при перезаписи значения номера версии и ревизии увеличиваются:

./etcdctl put foo baz
OK
./etcdctl get --write-out=json foo
{
  "header": {
    "cluster_id": 14841639068965180000,
    "member_id": 10276657743932975000,
    "revision": 3,
    "raft_term": 2
  },
  "kvs": [
    {
      "key": "Zm9v",
      "create_revision": 2,
      "mod_revision": 3,
      "version": 2,
      "value": "YmF6"
    }
  ],
  "count": 1
}

В частности, увеличились поля version и mod_revision, а create_revision — нет.

Это логично: mod_revision относится к ревизии, когда ключ был изменён в последний раз, а create_revision — к ревизии, когда он был создан.

etcd также позволяет выполнять «путешествия во времени» с помощью команды --rev, которая показывает значение ключа, существовавшего в определённой ревизии кластера:

./etcdctl get foo --rev=2 --print-value-only
bar
./etcdctl get foo --rev=3 --print-value-only
baz

Как и следовало ожидать, ключи можно удалять. Команда — etcdctl del:

./etcdctl del foo
1
./etcdctl get foo

1, возвращённая командой etcdctl del, обозначает количество удалённых ключей.

Но удаление не является постоянным, и вы всё ещё можете «перенестись во времени» в состояние до удаления ключа с помощью флага --rev:

./etcdctl get foo --rev=3 --print-value-only
baz

Возвращение нескольких результатов

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

./etcdctl put myprefix/key1 thing1
OK
./etcdctl put myprefix/key2 thing2
OK
./etcdctl put myprefix/key3 thing3
OK
./etcdctl put myprefix/key4 thing4
OK

Если вы передадите два аргумента команде etdctctl get, она выполнит запрос по диапазону и вернёт все пары «ключ-значение» в этом диапазоне. Вот пример:

./etcdctl get myprefix/key2 myprefix/key4
myprefix/key2
thing2
myprefix/key3
thing3

Здесь etcd возвращает все ключи от myprefix/key2 до myprefix/key4, включая начальный ключ, но исключая конечный ключ.

Другой способ получить несколько значений — использовать команду --prefix, которая (как неудивительно) возвращает все ключи с определённым префиксом.

Вот как вы получите все пары «ключ — значение», которые начинаются с myprefix/

./etcdctl get --prefix myprefix/
myprefix/key1
thing1
myprefix/key2
thing2
myprefix/key3
thing3
myprefix/key4
thing4

Обратите внимание, что в данном случае в символе / нет ничего особенного, и --prefix myprefix/key работает точно также хорошо.

Хотите верьте, хотите нет, но вы уже увидели большую часть того, что может предложить etcd с точки зрения возможностей базы данных!

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

Однако у etcd есть ещё несколько интересных особенностей.

Наблюдение за изменениями

Одной из ключевых функций etcd, которую вы ещё не видели, является команда etcdctl watch, которая работает более или менее так же, как etcdctl get, но передаёт изменения обратно клиенту.

Давайте посмотрим, как это работает!

В одном окне терминала пронаблюдайте за изменениями всего, что имеет префикс myprefix/:

./etcdctl watch --prefix myprefix/

Затем в другом окне терминала измените некоторые данные и посмотрите, что произойдёт:

./etcdctl put myprefix/key1 anewthing
OK
./etcdctl put myprefix/key5 thing5
OK
./etcdctl del myprefix/key5
1
./etcdctl put notmyprefix/key thing
OK

В исходном окне вы должны увидеть поток всех изменений, которые произошли в префиксе myprefix, даже для ключей, которые ранее не существовали:

PUT
myprefix/key1
anewthing
PUT
myprefix/key5
thing5
DELETE
myprefix/key5

Обратите внимание, что обновление ключа, начинающегося с notmyprefix, не было передано подписчику.

Но команда watch не ограничивается только изменениями в реальном времени — вы также можете увидеть версию событий в формате «путешествия во времени» с помощью параметра --rev, который будет передавать все изменения, начиная с этой ревизии.

Давайте посмотрим историю ключа foo из предыдущего примера:

./etcdctl watch --rev=2 foo
PUT
foo
bar
PUT
foo
baz
DELETE
foo

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

Настройка кластера etcd с несколькими нодами

До сих пор ваш «кластер» etcd состоял только из одной ноды, что не особенно интересно.

Давайте настроим кластер из 3 нод и посмотрим, как высокая доступность работает на практике!

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

Сначала создадим каталоги данных для каждой ноды:

mkdir -p /tmp/etcd/data{1..3}

При настройке кластера etcd необходимо заранее знать IP-адреса и порты нод, чтобы ноды могли обнаруживать друг друга.

Давайте привяжем все три ноды к localhost, назначим «клиентские» порты (порты, которые используют клиенты для подключения) 2379, 3379 и 4379, а «одноранговые» порты (порты, используемые между нодами etcd) — 2380, 3380 и 4380.

Запуск первой ноды etcd сводится к установке правильных флагов CLI:

./etcd --data-dir=/tmp/etcd/data1 --name node1 \
  --initial-advertise-peer-urls http://127.0.0.1:2380 \
  --listen-peer-urls http://127.0.0.1:2380 \
  --advertise-client-urls http://127.0.0.1:2379 \
  --listen-client-urls http://127.0.0.1:2379 \
  --initial-cluster node1=http://127.0.0.1:2380,node2=http://127.0.0.1:3380,node3=http://127.0.0.1:4380 \
  --initial-cluster-state new \
  --initial-cluster-token mytoken

Разберём некоторые из этих параметров:

  • --name: этот параметр указывает имя конкретной ноды.

  • --listen-peer-urls и --initial-advertise-peer-urls: эти параметры определяют URL-адреса, на которых нода будет прослушивать одноранговый (node-to-node) трафик.

  • --advertise-client-urls и --listen-client-urls: эти параметры определяют URL-адреса, на которых нода будет прослушивать клиентский трафик.

  • --initial-cluster-token: этот параметр устанавливает общий токен для кластера, чтобы ноды случайно не присоединились к неправильному кластеру.

  • --initial-cluster: здесь вы предоставляете etcd полную первоначальную конфигурацию кластера, чтобы ноды могли найти друг друга и начать взаимодействовать.

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

./etcd --data-dir=/tmp/etcd/data2 --name node2 \
  --initial-advertise-peer-urls http://127.0.0.1:3380 \
  --listen-peer-urls http://127.0.0.1:3380 \
  --advertise-client-urls http://127.0.0.1:3379 \
  --listen-client-urls http://127.0.0.1:3379 \
  --initial-cluster node1=http://127.0.0.1:2380,node2=http://127.0.0.1:3380,node3=http://127.0.0.1:4380 \
  --initial-cluster-state new \
  --initial-cluster-token mytoken

И запустите третью ноду в третьем терминале:

./etcd --data-dir=/tmp/etcd/data3 --name node3 \
  --initial-advertise-peer-urls http://127.0.0.1:4380 \
  --listen-peer-urls http://127.0.0.1:4380 \
  --advertise-client-urls http://127.0.0.1:4379 \
  --listen-client-urls http://127.0.0.1:4379 \
  --initial-cluster node1=http://127.0.0.1:2380,node2=http://127.0.0.1:3380,node3=http://127.0.0.1:4380 \
  --initial-cluster-state new \
  --initial-cluster-token mytoken

Чтобы взаимодействовать с кластером etcd, вы должны указать etcdctl, с какими эндпоинтами следует обмениваться данными, используя опцию --endpoints, которая представляет собой просто список эндпоинтов etcd (эквивалент --listen-client-urls, упомянутой ранее).

Давайте проверим, что все ноды успешно присоединились, используя команду member list.

export ENDPOINTS=127.0.0.1:2379,127.0.0.1:3379,127.0.0.1:4379
./etcdctl --endpoints=$ENDPOINTS member list --write-out=table
+------------------+---------+-------+-----------------------+-----------------------+------------+
|        ID        | STATUS  | NAME  |      PEER ADDRS       |     CLIENT ADDRS      | IS LEARNER |
+------------------+---------+-------+-----------------------+-----------------------+------------+
| 3c969067d90d0e6c | started | node1 | http://127.0.0.1:2380 | http://127.0.0.1:2379 |      false |
| 5c5501077e83a9ee | started | node3 | http://127.0.0.1:4380 | http://127.0.0.1:4379 |      false |
| a2f3309a1583fba3 | started | node2 | http://127.0.0.1:3380 | http://127.0.0.1:3379 |      false |
+------------------+---------+-------+-----------------------+-----------------------+------------+

Вы видите, что все три ноды успешно присоединились к кластеру!

Не обращайте внимания на поле IS LEARNER, оно относится к специальному типу нод, которые называются learner node. У них свой специализированный сценарий использования.

Давайте подтвердим, что кластер действительно может читать и записывать данные:

./etcdctl --endpoints=$ENDPOINTS put mykey myvalue
OK
./etcdctl --endpoints=$ENDPOINTS get mykey
mykey
myvalue

Что произойдет, если одна из нод выйдет из строя?

Давайте убьем первый процесс etcd (с помощью Ctrl-C) и узнаем!

Первое, что нужно проверить после убийства первой ноды — это команду member list, но результат немного удивляет:

./etcdctl --endpoints=$ENDPOINTS member list --write-out=table
+------------------+---------+-------+-----------------------+-----------------------+------------+
|        ID        | STATUS  | NAME  |      PEER ADDRS       |     CLIENT ADDRS      | IS LEARNER |
+------------------+---------+-------+-----------------------+-----------------------+------------+
| 3c969067d90d0e6c | started | node1 | http://127.0.0.1:2380 | http://127.0.0.1:2379 |      false |
| 5c5501077e83a9ee | started | node3 | http://127.0.0.1:4380 | http://127.0.0.1:4379 |      false |
| a2f3309a1583fba3 | started | node2 | http://127.0.0.1:3380 | http://127.0.0.1:3379 |      false |
+------------------+---------+-------+-----------------------+-----------------------+------------+

Все три участника по-прежнему присутствуют, несмотря на то, что мы убили node1. Смущает то, что перечислены все участники кластера, независимо от их состояния.

Для проверки состояния членов кластера используется команда endpoint status, которая показывает текущее состояние каждого эндпоинта:

./etcdctl --endpoints=$ENDPOINTS endpoint status --write-out=table
{
  "level": "warn",
  "ts": "2021-06-23T15:43:40.378-0700",
  "logger": "etcd-client",
  "caller": "v3/retry_interceptor.go:62",
  "msg": "retrying of unary invoker failed",
  "target": "etcd-endpoints://0xc000454700/#initially=[127.0.0.1:2379;127.0.0.1:3379;127.0.0.1:4379]",
  "attempt": 0,
  "error": "rpc error: code = DeadlineExceeded ... connect: connection refused\""
}
Failed to get the status of endpoint 127.0.0.1:2379 (context deadline exceeded)
+----------------+------------------+-----------+------------+--------+
|    ENDPOINT    |        ID        | IS LEADER | IS LEARNER | ERRORS |
+----------------+------------------+-----------+------------+--------+
| 127.0.0.1:3379 | a2f3309a1583fba3 |      true |      false |        |
| 127.0.0.1:4379 | 5c5501077e83a9ee |     false |      false |        |
+----------------+------------------+-----------+------------+--------+

Здесь вы увидите предупреждение о неисправной ноде и некоторую интересную информацию о текущем состоянии каждой ноды.

Но работает ли кластер?

Теоретически должен (поскольку большинство нод все ещё в сети), так что давайте проверим:

./etcdctl --endpoints=$ENDPOINTS get mykey
mykey
myvalue
./etcdctl --endpoints=$ENDPOINTS put mykey newvalue
OK
./etcdctl --endpoints=$ENDPOINTS get mykey
mykey
newvalue

Все выглядит хорошо: и чтение, и запись работают!

А если вернуть исходную ноду (с помощью той же команды, что и раньше) и снова проверить endpoint status, то можно увидеть, что нода быстро восстанавливает связь с кластером:

./etcdctl --endpoints=$ENDPOINTS endpoint status --write-out=table
+----------------+------------------+-----------+------------+--------+
|    ENDPOINT    |        ID        | IS LEADER | IS LEARNER | ERRORS |
+----------------+------------------+-----------+------------+--------+
| 127.0.0.1:2379 | 3c969067d90d0e6c |     false |      false |        |
| 127.0.0.1:3379 | a2f3309a1583fba3 |      true |      false |        |
| 127.0.0.1:4379 | 5c5501077e83a9ee |     false |      false |        |
+----------------+------------------+-----------+------------+--------+

Что произойдет, если две ноды станут недоступными?

Давайте убьём node1 и node2 с помощью Ctrl-C и снова попробуем определить endpoint status:

./etcdctl --endpoints=$ENDPOINTS endpoint status --write-out=table
{"level":"warn","ts":"2021-06-23T15:47:05.803-0700","logger":"etcd-client","caller":"v3/retry_i ...}
Failed to get the status of endpoint 127.0.0.1:2379 (context deadline exceeded)
{"level":"warn","ts":"2021-06-23T15:47:10.805-0700","logger":"etcd-client","caller":"v3/retry_i ...}
Failed to get the status of endpoint 127.0.0.1:3379 (context deadline exceeded)
+----------------+------------------+-----------+------------+-----------------------+
|    ENDPOINT    |        ID        | IS LEADER | IS LEARNER |        ERRORS         |
+----------------+------------------+-----------+------------+-----------------------+
| 127.0.0.1:4379 | 5c5501077e83a9ee |     false |      false | etcdserver: no leader |
+----------------+------------------+-----------+------------+-----------------------+

На этот раз появляется сообщение об ошибке, в котором говорится, что лидер не доступен.

Если вы попытаетесь выполнить чтение или запись в кластер, вы просто получите ошибки:

./etcdctl --endpoints=$ENDPOINTS get mykey
{
  "level": "warn",
  "ts": "2021-06-23T15:48:31.987-0700",
  "logger": "etcd-client",
  "caller": "v3/retry_interceptor.go:62",
  "msg": "retrying of unary invoker failed",
  "target": "etcd-endpoints://0xc0001da000/#initially=[127.0.0.1:2379;127.0.0.1:3379;127.0.0.1:4379]",
  "attempt": 0,
  "error": "rpc error: code = Unknown desc = context deadline exceeded"
}
./etcdctl --endpoints=$ENDPOINTS put mykey anewervalue
{
  "level": "warn",
  "ts": "2021-06-23T15:49:04.539-0700",
  "logger": "etcd-client",
  "caller": "v3/retry_interceptor.go:62",
  "msg": "retrying of unary invoker failed",
  "target": "etcd-endpoints://0xc000432a80/#initially=[127.0.0.1:2379;127.0.0.1:3379;127.0.0.1:4379]",
  "attempt": 0,
  "error": "rpc error: code = DeadlineExceeded desc = context deadline exceeded"
}
Error: context deadline exceeded

Но если вы вернёте эти две ноды в исходное состояние (используя оригинальные команды), все быстро придёт в норму:

./etcdctl --endpoints=$ENDPOINTS endpoint status --write-out=table
+----------------+------------------+-----------+------------+--------+
|    ENDPOINT    |        ID        | IS LEADER | IS LEARNER | ERRORS |
+----------------+------------------+-----------+------------+--------+
| 127.0.0.1:2379 | 3c969067d90d0e6c |     false |      false |        |
| 127.0.0.1:3379 | a2f3309a1583fba3 |     false |      false |        |
| 127.0.0.1:4379 | 5c5501077e83a9ee |      true |      false |        |
+----------------+------------------+-----------+------------+--------+
./etcdctl --endpoints=$ENDPOINTS get mykey
mykey
newvalue

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

Kubernetes и etcd

Теперь, когда вы знаете, как работает etcd в целом, давайте рассмотрим, как Kubernetes использует etcd внутри.

В этих примерах будет использоваться minikube, но настройки production-ready Kubernetes должны работать очень похоже.

Чтобы увидеть etcd в действии, запустите minikube, подключитесь к нему по SSH и загрузите etcdctl так же, как и раньше:

minikube start
minikube ssh
curl -LO https://github.com/etcd-io/etcd/releases/download/v3.5.0/etcd-v3.5.0-linux-amd64.tar.gz
tar xzvf etcd-v3.5.0-linux-amd64.tar.gz
cd etcd-v3.5.0-linux-amd64

В отличие от тестовой настройки, minikube развёртывает etcd с mutual аутентификацией TLS, поэтому вам нужно предоставлять сертификаты и ключи TLS с каждым запросом.

Это немного утомительно, но переменная Bash может помочь ускорить процесс:

export ETCDCTL=$(cat <<EOF
sudo ETCDCTL_API=3 ./etcdctl --cacert /var/lib/minikube/certs/etcd/ca.crt \n
  --cert /var/lib/minikube/certs/etcd/healthcheck-client.crt \n
  --key /var/lib/minikube/certs/etcd/healthcheck-client.key
EOF
)

Используемые здесь сертификат и ключи генерируются minikube во время начальной загрузки кластера Kubernetes. Они предназначены для использования с хелсчеками etcd, но также хорошо подходят для отладки.

Затем вы можете запустить команды etcdctl следующим образом:

$ETCDCTL member list --write-out=table
+------------------+---------+----------+---------------------------+---------------------------+------------+
|        ID        | STATUS  |   NAME   |        PEER ADDRS         |       CLIENT ADDRS        | IS LEARNER |
+------------------+---------+----------+---------------------------+---------------------------+------------+
| aec36adc501070cc | started | minikube | https://192.168.49.2:2380 | https://192.168.49.2:2379 |      false |
+------------------+---------+----------+---------------------------+---------------------------+------------+

Как вы можете видеть, в кластере minikube работает одна нода etcd.

Как API Kubernetes хранит данные в etcd?

Небольшое исследование показало, что практически все данные Kubernetes имеют префикс /registry:

$ETCDCTL get --prefix /registry | wc -l
5882

Дополнительное исследование показало, что определения подов находятся под префиксом /registry/pods:

$ETCDCTL get --prefix /registry/pods | wc -l
412

Схема именования такая — /registry/pods/<namespace>/<pod-name>.

Здесь вы можете увидеть определение scheduler'а пода:

$ETCDCTL get --prefix /registry/pods/kube-system/ --keys-only | grep scheduler
/registry/pods/kube-system/kube-scheduler-minikube

Здесь используется опция --keys-only, которая, как и следовало ожидать, возвращает только ключи запросов.

Как выглядят фактические данные? Давайте исследуем:

$ETCDCTL get /registry/pods/kube-system/kube-scheduler-minikube | head -6
/registry/pods/kube-system/kube-scheduler-minikube
k8s

v1Pod�
�
kube-scheduler-minikube�
                        kube-system"*$f8e4441d-fb03-4c98-b48b-61a42643763a2��نZ

Выглядит как какой-то беспорядок!

Это потому, что Kubernetes API хранит реальные определения объектов в бинарном формате, а не в удобном для человека виде.

Если вы хотите увидеть спецификацию объекта в понятном формате, таком как JSON, вам нужно обратиться к API, а не обращаться к etcd напрямую.

Теперь схема именования ключей etcd в Kubernetes должна быть абсолютно понятной: она позволяет API запрашивать или отслеживать все объекты определённого типа в конкретном пространстве имён с помощью запроса префикса etcd.

Это широко распространённая практика в Kubernetes, и именно так контроллеры и операторы Kubernetes подписываются на изменения объектов, которые им интересны.

Давайте попробуем подписаться на изменения пода в пространстве имён default, чтобы увидеть это в действии.

Сначала используйте команду watch с соответствующим префиксом:

$ETCDCTL watch --prefix /registry/pods/default/ --write-out=json

Затем в другом терминале создайте под и посмотрите, что произойдёт:

kubectl run --namespace=default --image=nginx nginx
pod/nginx created

Вы должны увидеть несколько JSON-сообщений в выводе etcd watch, по одному на каждое изменение статуса пода (например, переход из статуса Pending в Scheduled и Running).

Каждое сообщение должно выглядеть примерно так:

{
  "Header": {
    "cluster_id": 18038207397139143000,
    "member_id": 12593026477526643000,
    "revision": 935,
    "raft_term": 2
  },
  "Events": [
    {
      "kv": {
        "key": "L3JlZ2lzdHJ5L3BvZHMvZGVmYXVsdC9uZ2lueA==",
        "create_revision": 935,
        "mod_revision": 935,
        "version": 1,
        "value": "azh...ACIA"
      }
    }
  ],
  "CompactRevision": 0,
  "Canceled": false,
  "Created": false
}

Чтобы удовлетворить любопытство по поводу того, что на самом деле содержат данные, вы можете прогнать значение через xxd, чтобы исследовать интересные строки. Например:

$ETCDCTL get /registry/pods/default/nginx --print-value-only | xxd | grep -A2 Run
00000600: 5072 696f 7269 7479 1aba 030a 0752 756e  Priority.....Run
00000610: 6e69 6e67 1223 0a0b 496e 6974 6961 6c69  ning.#..Initiali
00000620: 7a65 6412 0454 7275 651a 0022 0808 e098  zed..True.."....

Из этого можно сделать вывод, что созданный вами под в настоящее время имеет статус Running.

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

Но несложно представить, как API взаимодействует с etcd, используя именно такие запросы.

Замена etcd

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

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

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

Одной из отличительных особенностей, которая отличает k3s от «ванильного» Kubernetes, является его способность заменять etcd на базы данных SQL.

Бэкенд по умолчанию — SQLite, который представляет собой ультралёгкую встроенную библиотеку SQL.

Это позволяет пользователям запускать Kubernetes, не беспокоясь об управлении кластером etcd.

Как k3s это делает?

Kubernetes API не предлагает способа замены баз данных — etcd достаточно жёстко запрограммирован в кодовой базе.

Чтобы иметь возможность подключать другие базы данных в k3s можно было бы переписать Kubernetes API. Это могло бы сработать, но наложило бы огромное бремя обслуживания.

Вместо этого k3s использует специальный проект под названием Kine (что означает «Kine — это не etcd»).

Kine — это прокладка, которая переводит вызовы API etcd в реальные SQL-запросы.

Поскольку модель данных etcd настолько проста, перевод выполняется относительно легко.

Например, вот шаблон для перечисления ключей по префиксу в SQL-драйвере Kine:

SELECT (%s), (%s), %s
FROM kine AS kv
JOIN (
  SELECT MAX(mkv.id) AS id
  FROM kine AS mkv
  WHERE
    mkv.name LIKE ?
    %%s
  GROUP BY mkv.name) maxkv
ON maxkv.id = kv.id
WHERE
    (kv.deleted = 0 OR ?)
ORDER BY kv.id ASC

Kine использует единственную таблицу, в которой хранятся ключи, значения и некоторые дополнительные метаданные. Запросы с префиксом просто переводятся в SQL LIKE запросы.

Конечно, поскольку Kine использует SQL, он не будет иметь такой же производительности или характеристик доступности, как etcd.

Помимо SQLite, Kine также может использовать MySQL или PostgreSQL в качестве бэкенда.

Зачем это делать?

Иногда лучшая база данных — это та, которой управляет кто-то другой!

Предположим, в вашей компании есть команда администраторов баз данных с большим опытом работы с SQL-базами данных, готовыми к работе в продакшене.

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

Заключение

Заглянуть под капот Kubernetes всегда интересно, и etcd — одна из самых важных частей головоломки Kubernetes.

В etcd Kubernetes хранит всю информацию о состоянии кластера. Фактически, это единственная stateful часть control plane Kubernetes.

etcd предоставляет набор функций, идеально подходящий для Kubernetes.

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

А возможность потоковой передачи изменений клиентам — это киллер фича, которая помогает всем компонентам кластера Kubernetes оставаться синхронизированными.

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

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


Если вы изучаете Kubernetes и тонете в документации, пытаясь найти ответы на свои вопросы, приходите в Слёрм на курсы по Kubernetes. Kubernetes База — стартовый курс для администраторов. Он поможет познакомиться с компонентами и абстракциями, получить первый опыт настройки кластера и запуска в нём приложений. Kubernetes Мега — курс для опытных: залезаем под капот Kubernetes, изучаем механизмы обеспечения стабильности и безопасности.

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


  1. gudvinr
    30.03.2024 09:34

    А какими свойствами должна обладать база данных, на которую опирается сервер API?

    В теории, tarantool тоже отвечает этим требованиям. Там и raft есть, и как K-V можно использовать