Наш опыт работы с данными в etcd Kubernetes-кластера напрямую (без K8s API)

Все чаще к нам обращаются клиенты с просьбой обеспечить доступ в Kubernetes-кластер для возможности обращения к сервисам внутри кластера: чтобы можно было напрямую подключиться к какой-то базе данных или сервису, для связи локального приложения с приложениями внутри кластера…

rimhduzpzbuwtylhnil4dorhzw4.png

Например, возникает потребность подключиться со своей локальной машины к сервису memcached.staging.svc.cluster.local. Мы предоставляем такую возможность с помощью VPN внутри кластера, к которому подключается клиент. Для этого анонсируем подсети pod'ов, сервисов и push'им кластерные DNS клиенту. Таким образом, когда клиент пытается подключиться к сервису memcached.staging.svc.cluster.local, запрос уходит в DNS кластера и в ответ получает адрес данного сервиса из сервисной сети кластера или адрес pod'а.

K8s-кластеры мы настраиваем с помощью kubeadm, где по умолчанию сервисная подсеть — 192.168.0.0/16, а сеть pod'ов — 10.244.0.0/16. Обычно всё хорошо работает, но есть пара моментов:

  • Подсеть 192.168.*.* часто используется в офисных сетях клиентов, а еще чаще — в домашних сетях разработчиков. И тогда у нас получаются конфликты: домашние роутеры работают в этой подсети и VPN push'ит эти подсети из кластера клиенту.
  • У нас есть несколько кластеров (кластеры production, stage и/или несколько dev-кластеров). Тогда во всех них по умолчанию будут одинаковые подсети для pod'ов и сервисов, что создает большие сложности для одновременной работы с сервисами в нескольких кластерах.


Мы уже довольно давно приняли практику использования различных подсетей для сервисов и pod'ов в рамках одного проекта — в общем, чтобы все кластеры были с разными сетями. Однако есть большое количество кластеров в работе, которые не хотелось бы перекатывать с нуля, так как в них запущены многие сервисы, stateful-приложения и т.п.

И тогда мы задались вопросом: как бы поменять подсеть в существующем кластере?

Поиск решений


Наиболее распространенная практика — пересоздать все сервисы с типом ClusterIP. Как вариант, могут посоветовать и такое:

The following process has a problem: after everything configured, the pods come up with the old IP as a DNS nameserver in /etc/resolv.conf.
Since I still did not find the solution, i had to reset the entire cluster with kubeadm reset and init it again.


Но не всем это подходит… Вот более детальные вводные для нашего случая:

  • Используется Flannel;
  • Есть кластера как в облаках, так и на железе;
  • Хотелось бы избежать повторного деплоя всех сервисов в кластере;
  • Есть потребность вообще сделать всё с минимальным количеством проблем;
  • Версия Kubernetes — 1.16.6 (впрочем, дальнейшие действия будут аналогичны и для других версий);
  • Основная задача сводится к тому, чтобы в кластере, развернутом с помощью kubeadm с сервисной подсетью 192.168.0.0/16, заменить её на 172.24.0.0/16.


И так уж совпало, что нам давно было интересно посмотреть, что и как в Kubernetes хранится в etcd, что вообще с этим можно сделать… Вот и подумали: «Почему бы просто не обновить данные в etcd, заменив старые IP-адреса (подсеть) на новые

Поискав готовые инструменты для работы с данными в etcd, мы не нашли ничего полностью решающего поставленную задачу. (Кстати, если вы знаете о любых утилитах для работы с данными напрямую в etcd — будем признательны за ссылки.) Однако хорошей отправной точкой стала etcdhelper от OpenShift (спасибо его авторам!).

Эта утилита умеет подключаться к etcd с помощью сертификатов и читать оттуда данные с помощью команд ls, get, dump.

Дописываем etcdhelper


Следующая мысль закономерна: «Что мешает дописать эту утилиту, добавив возможность записи данных в etcd?»

Она воплотилась в модифицированную версию etcdhelper с двумя новыми функциями changeServiceCIDR и changePodCIDR. На её код можно посмотреть здесь.

Что делают новые функции? Алгоритм changeServiceCIDR:

  • создаем десериализатор;
  • компилируем регулярное выражение для замены CIDR;
  • проходим по всем сервисам с типом ClusterIP в кластере:
    • декодируем значение из etcd в Go-объект;
    • с помощью регулярного выражения заменяем первые два байта адреса;
    • присваиваем сервису IP-адрес из новой подсети;
    • создаем сериализатор, преобразуем Go-объект в protobuf, записываем новые данные в etcd.


Функция changePodCIDR по сути аналогична changeServiceCIDR — только вместо редактирования спецификации сервисов мы делаем это для узла и меняем .spec.PodCIDR на новую подсеть.

Практика


Смена serviceCIDR


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

Подготовительные действия:

  • установка необходимого ПО и сборка пропатченного etcdhelper;
  • бэкап etcd и /etc/kubernetes.


Краткий план действий по смене serviceCIDR:

  • изменение манифестов apiserver'а и controller-manager'а;
  • перевыпуск сертификатов;
  • изменение ClusterIP сервисов в etcd;
  • рестарт всех pod'ов в кластере.


Далее представлена полная последовательность действий в деталях.

1. Устанавливаем etcd-client для дампа данных:

apt install etcd-client


2. Собираем etcdhelper:

  • Ставим golang:
    GOPATH=/root/golang
    mkdir -p $GOPATH/local
    curl -sSL https://dl.google.com/go/go1.14.1.linux-amd64.tar.gz | tar -xzvC $GOPATH/local
    echo "export GOPATH=\"$GOPATH\"" >> ~/.bashrc
    echo 'export GOROOT="$GOPATH/local/go"' >> ~/.bashrc
    echo 'export PATH="$PATH:$GOPATH/local/go/bin"' >> ~/.bashrc
  • Сохраняем себе etcdhelper.go, загружаем зависимости, собираем:
    wget https://raw.githubusercontent.com/flant/examples/master/2020/04-etcdhelper/etcdhelper.go
    go get go.etcd.io/etcd/clientv3 k8s.io/kubectl/pkg/scheme k8s.io/apimachinery/pkg/runtime
    go build -o etcdhelper etcdhelper.go


3. Делаем бэкап etcd:

backup_dir=/root/backup
mkdir ${backup_dir}
cp -rL /etc/kubernetes ${backup_dir}
ETCDCTL_API=3 etcdctl --cacert=/etc/kubernetes/pki/etcd/ca.crt --key=/etc/kubernetes/pki/etcd/server.key --cert=/etc/kubernetes/pki/etcd/server.crt --endpoints https://192.168.199.100:2379 snapshot save ${backup_dir}/etcd.snapshot


4. Меняем сервисную подсеть в манифестах Kubernetes control plane. В файлах /etc/kubernetes/manifests/kube-apiserver.yaml и /etc/kubernetes/manifests/kube-controller-manager.yaml изменяем параметр --service-cluster-ip-range на новую подсеть: 172.24.0.0/16 вместо 192.168.0.0/16.

5. Поскольку мы меняем сервисную подсеть, на которую kubeadm выпускает сертификаты для apiserver’а (в том числе), их необходимо перевыпустить:

  1. Посмотрим, на какие домены и IP-адреса выпущен текущий сертификат:
    openssl x509 -noout -ext subjectAltName </etc/kubernetes/pki/apiserver.crt
    X509v3 Subject Alternative Name:
        DNS:dev-1-master, DNS:kubernetes, DNS:kubernetes.default, DNS:kubernetes.default.svc, DNS:kubernetes.default.svc.cluster.local, DNS:apiserver, IP Address:192.168.0.1, IP Address:10.0.0.163, IP Address:192.168.199.100
  2. Подготовим минимальный конфиг для kubeadm:
    cat kubeadm-config.yaml
    apiVersion: kubeadm.k8s.io/v1beta1
    kind: ClusterConfiguration
    networking:
      podSubnet: "10.244.0.0/16"
      serviceSubnet: "172.24.0.0/16"
    apiServer:
      certSANs:
      - "192.168.199.100" # IP-адрес мастер узла
  3. Удалим старые crt и key, так как без этого новый сертификат не выпустится:
    rm /etc/kubernetes/pki/apiserver.{key,crt}
  4. Перевыпустим сертификаты для API-сервера:
    kubeadm init phase certs apiserver --config=kubeadm-config.yaml
  5. Проверим, что сертификат выпустился для новой подсети:
    openssl x509 -noout -ext subjectAltName </etc/kubernetes/pki/apiserver.crt
    X509v3 Subject Alternative Name:
        DNS:kube-2-master, DNS:kubernetes, DNS:kubernetes.default, DNS:kubernetes.default.svc, DNS:kubernetes.default.svc.cluster.local, IP Address:172.24.0.1, IP Address:10.0.0.163, IP Address:192.168.199.100
  6. После перевыпуска сертификата API-сервера перезапустим его контейнер:
    docker ps | grep k8s_kube-apiserver | awk '{print $1}' | xargs docker restart
  7. Перегенерируем конфиг для admin.conf:
    kubeadm alpha certs renew admin.conf
  8. Отредактируем данные в etcd:
    ./etcdhelper -cacert /etc/kubernetes/pki/etcd/ca.crt -cert /etc/kubernetes/pki/etcd/server.crt -key /etc/kubernetes/pki/etcd/server.key -endpoint https://127.0.0.1:2379 change-service-cidr 172.24.0.0/16 

    Внимание! В этот момент в кластере перестает работать резолвинг доменов, так как в уже существующих pod'ах в /etc/resolv.conf прописан старый адрес CoreDNS (kube-dns), а kube-proxy изменил правила iptables со старой подсети на новую. Далее в статье написано о возможных вариантах минимизировать простой.
  9. Поправим ConfigMap'ы в пространстве имен kube-system:
    kubectl -n kube-system edit cm kubelet-config-1.16

    — здесь заменим clusterDNS на новый IP-адрес сервиса kube-dns: kubectl -n kube-system get svc kube-dns.
    kubectl -n kube-system edit cm kubeadm-config

    — исправим data.ClusterConfiguration.networking.serviceSubnet на новую подсеть.
  10. Так как изменился адрес kube-dns, необходимо обновить конфиг kubelet на всех узлах:
    kubeadm upgrade node phase kubelet-config && systemctl restart kubelet
  11. Осталось перезапустить все pod'ы в кластере:
    kubectl get pods --no-headers=true --all-namespaces |sed -r 's/(\S+)\s+(\S+).*/kubectl --namespace \1 delete pod \2/e'


Минимизация простоя


Мысли, как можно минимизировать даунтайм:

  1. После изменений манифестов control plane’а создать новый сервис kube-dns, к примеру, с названием kube-dns-tmp и новым адресом 172.24.0.10.
  2. Сделать if в etcdhelper, который не будет модифицировать сервис kube-dns.
  3. Заменить во всех kubelet'ах адрес ClusterDNS на новый, при этом старый сервис продолжит работать одновременно с новым.
  4. Дождаться, пока pod'ы с приложениями перекатятся либо сами по естественным причинам, либо в согласованное время.
  5. Удалить сервис kube-dns-tmp и поменять serviceSubnetCIDR для сервиса kube-dns.


Этот план позволит минимизировать даунтайм до ~минуты — на время удаления сервиса kube-dns-tmp и замены подсети для сервиса kube-dns.

Модификация podNetwork


Заодно мы решили посмотреть, как модифицировать podNetwork с помощью получившегося etcdhelper'а. Последовательность действий получается следующей:

  • исправляем конфиги в kube-system;
  • исправляем манифест kube-controller-manager'а;
  • изменяем podCIDR напрямую в etcd;
  • перезагружаем все узлы кластера.


Теперь подробнее об этих действиях:

1. Модифицируем ConfigMap'ы в пространстве имен kube-system:

kubectl -n kube-system edit cm kubeadm-config


— исправляем data.ClusterConfiguration.networking.podSubnet на новую подсеть 10.55.0.0/16.

kubectl -n kube-system edit cm kube-proxy


— исправляем data.config.conf.clusterCIDR: 10.55.0.0/16.

2. Модифицируем манифест controller-manager'а:

vim /etc/kubernetes/manifests/kube-controller-manager.yaml


— исправляем --cluster-cidr=10.55.0.0/16.

3. Смотрим на текущие значения .spec.podCIDR, .spec.podCIDRs, .InternalIP, .status.addresses для всех узлов кластера:

kubectl get no -o json | jq '[.items[] | {"name": .metadata.name, "podCIDR": .spec.podCIDR, "podCIDRs": .spec.podCIDRs, "InternalIP": (.status.addresses[] | select(.type == "InternalIP") | .address)}]'
[
  {
    "name": "kube-2-master",
    "podCIDR": "10.244.0.0/24",
    "podCIDRs": [
      "10.244.0.0/24"
    ],
    "InternalIP": "192.168.199.2"
  },
  {
    "name": "kube-2-master",
    "podCIDR": "10.244.0.0/24",
    "podCIDRs": [
      "10.244.0.0/24"
    ],
    "InternalIP": "10.0.1.239"
  },
  {
    "name": "kube-2-worker-01f438cf-579f9fd987-5l657",
    "podCIDR": "10.244.1.0/24",
    "podCIDRs": [
      "10.244.1.0/24"
    ],
    "InternalIP": "192.168.199.222"
  },
  {
    "name": "kube-2-worker-01f438cf-579f9fd987-5l657",
    "podCIDR": "10.244.1.0/24",
    "podCIDRs": [
      "10.244.1.0/24"
    ],
    "InternalIP": "10.0.4.73"
  }
]


4. Заменим podCIDR, внеся правки напрямую в etcd:

./etcdhelper -cacert /etc/kubernetes/pki/etcd/ca.crt -cert /etc/kubernetes/pki/etcd/server.crt -key /etc/kubernetes/pki/etcd/server.key -endpoint https://127.0.0.1:2379 change-pod-cidr 10.55.0.0/16


5. Проверим, что podCIDR действительно изменился:

kubectl get no -o json | jq '[.items[] | {"name": .metadata.name, "podCIDR": .spec.podCIDR, "podCIDRs": .spec.podCIDRs, "InternalIP": (.status.addresses[] | select(.type == "InternalIP") | .address)}]'
[
  {
    "name": "kube-2-master",
    "podCIDR": "10.55.0.0/24",
    "podCIDRs": [
      "10.55.0.0/24"
    ],
    "InternalIP": "192.168.199.2"
  },
  {
    "name": "kube-2-master",
    "podCIDR": "10.55.0.0/24",
    "podCIDRs": [
      "10.55.0.0/24"
    ],
    "InternalIP": "10.0.1.239"
  },
  {
    "name": "kube-2-worker-01f438cf-579f9fd987-5l657",
    "podCIDR": "10.55.1.0/24",
    "podCIDRs": [
      "10.55.1.0/24"
    ],
    "InternalIP": "192.168.199.222"
  },
  {
    "name": "kube-2-worker-01f438cf-579f9fd987-5l657",
    "podCIDR": "10.55.1.0/24",
    "podCIDRs": [
      "10.55.1.0/24"
    ],
    "InternalIP": "10.0.4.73"
  }
]


6. По очереди перезагрузим все узлы кластера.

7. Если хотя бы у одного узла оставить старый podCIDR, то kube-controller-manager не сможет запуститься, а pod'ы в кластере не будут планироваться.

На самом деле, изменение podCIDR можно произвести и проще (например, так). Но ведь нам хотелось научиться работать с etcd напрямую, потому что существуют случаи, когда правка объектов Kubernetes в etcd — единственный возможный вариант. (Например, нельзя просто так без простоя изменить у Service поле spec.clusterIP.)

Итог


В статье рассмотрена возможность работы с данными в etcd напрямую, т.е. в обход Kubernetes API. Иногда такой подход позволяет делать «хитрые штуки». Приведенные в тексте операции мы тестировали на реальных K8s-кластерах. Однако их статус готовности к широкому применению — PoC (proof of concept). Поэтому, если вы хотите использовать модифицированную версию утилиты etcdhelper на своих кластерах, делайте это на свой страх и риск.

P.S.


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

© Habrahabr.ru