Всем привет! В сегодняшнем материале разберемся, как сделать внутренние сетевые ресурсы кластера k8s доступными напрямую с внешнего хоста. Спойлер: в этом нам поможет запуск Flannel в связке с kube-proxy на этом самом хосте.
Так мы получим доступ к приложениям, запущенным в Kubernetes без использования NodePort, LoadBalancer и Ingress Controller.
Мы в hh.ru уже используем это решение для поднятия окружения в разработке и тестировании, решили поделиться с комьюнити. Поехали!
![](https://habrastorage.org/getpro/habr/upload_files/241/10d/c88/24110dc88d4c3a42f88dc88fccb4303a.jpg)
Зачем оно нужно
В 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. Он показался нам самым подходящим и наиболее интересным, поскольку до этого мы с ним еще никогда не работали.
Однако после разворачивания тестового кластера появилось пара новых вопросов.
![К сожалению, господин Друзь на эти вопросы ответить на сможет К сожалению, господин Друзь на эти вопросы ответить на сможет](https://habrastorage.org/getpro/habr/upload_files/128/6e3/423/1286e3423faf28f2aadad83b7d9c7769.jpg)
Во-первых, переезд в 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, не меняя конфигурации остальных приложений.
![](https://habrastorage.org/getpro/habr/upload_files/f7a/e69/9dc/f7ae699dc72bd88072b741eabdcd4aaa.png)
Пример работы: на стенде запущено приложение в 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)
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" + статическая маршрутизация == вся статья
kefiiir
А не смотрели на менее велосипедные решения? Типа https://github.com/telepresenceio/telepresence Сейчас возникла похжая необходимость и как раз думаю собирать свой велосипед или найти готовый
debsaw Автор
На telepresense смотрели в контексте локальной разработки, а не поднятия распределенного стенда.
Но в итоге используем devspace и skaffold для синхронизации кода с локальной машины в Pod
https://github.com/loft-sh/devspace - для фронтенд приложений
https://skaffold.dev/ - для java сервисов