Всем привет! В сегодняшнем материале разберемся, как сделать внутренние сетевые ресурсы кластера k8s доступными напрямую с внешнего хоста. Спойлер: в этом нам поможет запуск Flannel в связке с kube-proxy на этом самом хосте.

Так мы получим доступ к приложениям, запущенным в Kubernetes без использования NodePort, LoadBalancer и Ingress Controller.

Мы в hh.ru уже используем это решение для поднятия окружения в разработке и тестировании, решили поделиться с комьюнити. Поехали!

Зачем оно нужно

В hh.ru персональные тестовые стенды есть у каждого разработчика и тестировщика, кроме того присутствует и выделенный пул для запуска e2e тестов. Сегодня их общее количество насчитывает более 200 хостов.

Каждый стенд представляет собой KVM-виртуальную машину с запущенными в ней Docker контейнерами. Фактически это — микро-production с каждым сервисом в единственном экземпляре. KVM изолирует стенды между собой и позволяет поселить несколько хостов на один железный сервер, а Docker изолирует запущенные приложения в пределах одной виртуальной машины. Основной способ доставки артефактов в production у нас — это docker images.

На Хабре уже давно лежит подробная, пусть и немного устаревшая статья о том, как был устроен наш тестовый стенд, так что за подробностями можно сходить туда. При переходе на модную микросервисную архитектуру количество запущенных сервисов, а значит и docker-контейнеров, на стенде стало быстро расти. Поэтому возникла довольно острая проблема: стенд начал упираться в выделенные ресурсы по памяти и CPU.

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

Тогда для решения этих проблем мы предприняли вот что:

  • Некоторые сервисы сделали общими для всех стендов и вынесли на отдельную машину.

  • Сервисы on-demand стали запускаться только если нужны для конкретной задачи.

  • Внедрили так называемые "upstream-стенды". Это когда какой-то проект, состоящий из группы сервисов, поднимается на “легком” стенде, и использует бэкенд hh.ru. При этом в один upstream могут ходить несколько легких стендов. Аналогичный upstream-стенд может использоваться в том числе для мобильных приложений.

Эти шаги позволили нам относительно безбедно прожить еще какое-то время, но из-за постоянного добавления новых сервисов проблема вернулась. На тот момент, а это было начало славного 2020 года, на стенде нужно было запустить > 100 сервисов. Сегодня их количество выросло до ~160-180.  Мы решили действовать радикально: сделать стенд распределенным, а в качестве инструмента взять Kubernetes. Он показался нам самым подходящим и наиболее интересным, поскольку до этого мы с ним еще никогда не работали. 

Однако после разворачивания тестового кластера появилось пара новых вопросов.

К сожалению, господин Друзь на эти вопросы ответить на сможет
К сожалению, господин Друзь на эти вопросы ответить на сможет

Во-первых, переезд в k8s хотелось сделать максимально плавным и переводить сервисы постепенно. При этом нам была нужна сетевая связь в обе стороны — между стендом и кластером.

Во-вторых, требовалось поднять N (по количеству стендов) инстансов сервиса так, чтобы порт, на котором слушает сервис, был неуникальный, но с каждого стенда была бы возможность ходить в свой инстанс. Например, иметь N rabbitmq на порту 5672, и чтобы они не конфликтовали между собой и были доступны извне.

C учетом этих двух требований базовые решения в виде выставления NodePort или Ingress Controller не особо помогают. Еще одна альтернатива — кластер под каждый стенд. Но  здесь был страх, что 200+ кластеров потребуют дополнительного времени на обслуживание и разбор инцидентов. 

В процессе обсуждения возникла идея: каждый стенд нужно сделать частью внутренней сети кластера. В таком случае, если запущенное приложение убрать за ClusterIP Service, к нему можно обращаться по уникальному имени <myapp>.<namespace>.svc.cluster.local:<port> . Имя может резолвиться в ClusterIP сервиса, либо напрямую в IP пода, если использовать Headless Service.

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

В качестве CNI плагина для тестового кластера был выбран Flannel, потому что, во-первых, это простое и популярное решение, а во-вторых, с чего-то надо было начинать. Flannel запускается на каждой ноде, отвечает за распределение адресов Pod-ов в рамках одной Node и сетевую связь между Pod’ами, расположенными на разных нодах.

По умолчанию Flannel использует сеть 10.244.0.0/16 для кластера и 10.244.X.0/24 для каждой ноды. Мы оставили эту конфигурацию для Node на виртуальных машинах и сделали более широкую маску /21 для Node на железе, чтобы иметь возможность на такую ноду зашедулить более 254 Pod-ов. 

У Flannel есть разные бэкенды, а мы использовали VXLAN. Он упаковывает L2-фреймы в UDP-датаграммы и передает их между Node поверх существующей сети. Еще он обеспечивает маршрутизацию между нодами и использует etcd кластера для хранения состояния.

В нашем случае задача сводится к тому, чтобы поднять Flannel и kube-proxy на тестовом стенде и сказать k8s api серверу, что тестовый стенд — это теперь Node. Эта нода всегда будет в статусе Not Ready, и на ней не смогут подниматься Pod-ы, поэтому мы называем ее “Fake Node”, чтобы отличать от реальных Worker Node. 

Как это сделать

Шаг 1. Запуск Flannel на стенде

Бинарник можно собрать самостоятельно, либо использовать предсозданные c github.  Для запуска нужен минимальный конфиг /etc/net-conf.json

{  
 "Network": "10.244.0.0/16",
 "Backend": {
 	"Type": "vxlan",
 	"VNI": 101,
 	"DirectRouting": false
	}
}

Как  описано выше, 10.244.0.0/16 — это сеть всего кластера, VNI — цифровой идентификатор VXLAN. Поскольку у нас используется несколько кластеров в одной физической сети, у каждого из них уникальный VNI. DirectRotung = false в нашем случае — это борьба с вот чем. Если ноды L2 связаны, то при значении true не будет инкапсуляции в UDP-пакеты, а значит сеть должна работать быстрее. Но мы выбираем надежность.

Также для работы Flannel в составе сети кластера k8s потребуется kubeconfig файл. Так как flannel устанавливается при разворачивании кластера, все необходимые данные — flannel serviceAccount и secret — для конфига уже есть. 

Еще для запуска на стенде сделали systemd юнит:

[Unit]
Description=Flannel network agent
Documentation=https://github.com/coreos/flannel
After=network.target
After=network-online.target
Wants=network-online.target

[Service]
Type=notify
Restart=always
RestartSec=5
Environment=NODE_NAME=<имя Fake Node (hostname стенда)>
ExecStart=/usr/local/bin/flanneld-amd64 \
  --kube-subnet-mgr=true \
  --kubeconfig-file=<Путь до kubeconfig > \
  --v=5 \
  --ip-masq=true \ #по желанию
  --iface=eth0

[Install]
WantedBy=multi-user.target

Шаг 2. Kube-Proxy

Если мы хотим использовать kube-proxy, его тоже придется запустить на стенде. Kube-proxy понадобится, если использовать обычный, а не Headless Service. Запущенный kube-proxy будет создавать на тестовом стенде цепочки правил в iptables или виртуальные сервера в IPVS для доступа к Pod-ам через виртуальный IP Service. Бинарник kube-proxy проще взять готовый, например здесь, но не забудьте подставить свою версию. Также можно запустить его в докер-контейнере, мы используем именно этот способ. Образы можно взять здесь.

Сначала мы использовали обычные Service, но из-за количества запущенных приложений размер таблиц с правилами был таков, что kube-proxy обновлял их за несколько десятков секунд. Это порождало проблемы с доступностью приложений: api server и readiness probe считают, что Pod успешно запущен, но при этом со стенда он еще не доступен, так как kube-proxy в процессе синхронизации. Переход на Headless Services помог решить эту проблему.

Для запуска kube-proxy также требуется kubeconfig и собственный конфиг. Пример конфига:

apiVersion: kubeproxy.config.k8s.io/v1alpha1
bindAddress: 0.0.0.0
clientConnection:
  acceptContentTypes: ""
  burst: 0
  contentType: ""
  kubeconfig: /etc/kube-proxy/kubeconfig
  qps: 0
clusterCIDR: 10.244.0.0/16
configSyncPeriod: 0s
conntrack:
  maxPerCore: null
  min: null
  tcpCloseWaitTimeout: null
  tcpEstablishedTimeout: null
enableProfiling: false
healthzBindAddress: ""
hostnameOverride: ""
iptables:
  masqueradeAll: false
  masqueradeBit: null
  minSyncPeriod: 0s
  syncPeriod: 0s
ipvs:
  excludeCIDRs: null
  minSyncPeriod: 0s
  scheduler: ""
  strictARP: false
  syncPeriod: 0s
kind: KubeProxyConfiguration
metricsBindAddress: ""
mode: ""
nodePortAddresses: null
oomScoreAdj: null
portRange: ""
udpIdleTimeout: 0s
winkernel:
  enableDSR: false
  networkName: ""
  sourceVip: ""

Конфиг практически без изменений взят из рабочего Pod-а kube-proxy в кластере.

Шаг 3. Инициализация “Fake” Node

Чтобы всё заработало, нужно сказать API-серверу, что наш стенд теперь тоже Worker Node. Для этого необходимо применить манифест примерно такого содержания:

apiVersion: v1
kind: Node
metadata:
name: <имя ноды/стенда>
annotations:
flannel.alpha.coreos.com/backend-type: vxlan
     flannel.alpha.coreos.com/kube-subnet-manager: "true"
     flannel.alpha.coreos.com/public-ip: <публичный IP стенда>
spec:
podCIDR: 10.244.XXX.0/YY

Если не указать явно podCIDR, по умолчанию для “Fake” Node резервируется блок /24. Чтобы сэкономить адреса для настоящих Worker Node и не выйти за рамки дефолтной сети 10.244.0.0/16, мы делаем для стендов блоки /28, резервируя только 16 адресов на каждый стенд.

Шаг 4. Почти всё

Выставляем coredns наружу кластера. Для этого достаточно запатчить Service coredns, применив такой патч командой kubectl patch (можно и отдельный манифест сделать):

spec:
  ports:
  - name: dns
    nodePort: 32053
    port: 53
    protocol: UDP
    targetPort: 53


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

Шаг 5. Финальный

Дополнительно на стенде установлен nginx, работающий как tcp proxy. Он нужен, чтобы спроксировать запрос в кластер. Так для каждого сервиса в кластере в nginx будет прописан примерно следующий конфиг:

server {
    listen 0.0.0.0:1234;
    proxy_pass app1.<namespace>.svc.cluster.local:1234;
}

А в качестве резолвера добавлен coredns из шага 4:

stream {
    resolver <внешний IP кластера/балансировщика>:32053;
}

Теперь для приложения в кластере на стенде открыт порт, и запрос идет через tcp proxy. Можно отключить прокси и запустить приложение в докере с тем же портом в host network, не меняя конфигурации остальных приложений.

Пример работы: на стенде запущено приложение в dоcker App1, которое ходит в App2 в кластере. В конфиге App1 указано, что-то вроде

external_service = <внешний IP тестового стенда>:1234

Запрос <внешний IP тестового стенда>:1234 попадает в nginx, далее nginx в соответствии с конфигом, резолвит app2.<namespace>.svc.cluster.local:1234 в IP адрес Pod-a с помощью coredns. Поскольку стенд "Fake" Node находится в одной сети с Pod-ами, запрос благополучно доходит до сервиса в кластере. Если перед Pod-ом стоит обычный, а не Headless, Service, то app2.<namespace>.svc.cluster.local сначала резолвится в виртуальный IP Service’a, а затем пакет по правилам iptables/IPVS(за которые отвечает kube-proxy) доходит до Pod’a.

Постепенно наши сервисы переходят на Consul и service discovery, и необходимость в nginx tcp proxy отпадает, так как в Consul можно явно задать IP адрес Pod-a.

Выводы

Это, слегка велосипедное, решение:

  • Достаточно приемлемая платформа для создания распределенного стенда.

  • Позволяет постепенно переводить сервисы в тестовом окружении в кластер k8s.

  • Не требует дополнительных инструментов.

  • Не подойдет для использования в продакшне.

  • Скорее всего не сработает или потребует допиливания, если кластер в облаке.

А вот и немного цифр:

  • В таком виде тестовое окружение работает с 2020 года. Сегодня на стенде запущено ~60 контейнеров и ~100 Pod-ов в кластере.

  • У нас 4 тестовых кластера, разделенные по ролям: 2 для разработчиков и тестировщиков, 1 для стендов релизного пула, и еще 1 тестовый-совсем тестовый, чтобы ничего не сломать и обкатать новые решения.

  • В первых двух кластерах запущено больше 20к подов в сумме, все это работает на более чем 100 Node. Некоторых из них — виртуальные машины KVM, а другие полноценные железки.

На этом, пожалуй, всё. Пишите в комментах про ваш опыт, может вы нашли более изящное решение. Будет здорово, если поделитесь!

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


  1. kefiiir
    04.08.2022 15:12

    А не смотрели на менее велосипедные решения? Типа https://github.com/telepresenceio/telepresence Сейчас возникла похжая необходимость и как раз думаю собирать свой велосипед или найти готовый


    1. debsaw Автор
      04.08.2022 16:44
      +2

      На telepresense смотрели в контексте локальной разработки, а не поднятия распределенного стенда.
      Но в итоге используем devspace и skaffold для синхронизации кода с локальной машины в Pod
      https://github.com/loft-sh/devspace - для фронтенд приложений
      https://skaffold.dev/ - для java сервисов


  1. ckpunT
    05.08.2022 23:53

    Так мы получим доступ к приложениям, запущенным в Kubernetes без использования NodePort, LoadBalancer и Ingress Controller.

    Шаг 4. Почти всё

    ...

    nodePort: 32053

    Шаг 5. Финальный

    ...

    Дополнительно на стенде установлен nginx, работающий как tcp proxy

    podCIDR: 10.244.XXX.0/YY

    ...

    мы делаем для стендов блоки /28, резервируя только 16 адресов на каждый стенд

    ...

    Сегодня на стенде запущено ~60 контейнеров

    Выводы

    Это, слегка костыльное, решение

    kube-proxy: mode: "ipvs" + статическая маршрутизация == вся статья