Всем привет! В данной статье хотелось бы рассказать о небольшом исследовании Kubernetes ограничений и рекомендаций, а так же что будет если их нарушить и о том что это может быть в некоторых случаях полезно и даже экономически выгодно. Будет рубрика "Ээээксперименты!", поищем проблемы, попробуем их решить и посмотрим что получиться.
Рекомендации K8s.
В документации Kubernetes говорится о следующих рекомендациях:
No more than 110 pods per node
No more than 5,000 nodes
No more than 150,000 total pods
No more than 300,000 total containers
Для исследования причин ограничений наиболее всего подходит ограничение в 110 подов на узел, в силу доступности ресурсов для такого исследования (5 000 узлов сложно воспроизвести, хотя бы из экономических соображений). Поэтому далее будем говорить об исследования именно этого ограничения.
Дополнительно был найден такой документ, в котором более детально описано рассматриваемое ограничение в виде формулы:
Pods per node = min(110, 10*#cores), то есть минимальное из двух:
либо 110 подов на узел
либо 10 подов на ядро.
Таким образом если на узле будет, например 16 ядер, то нам говорят что подов должно быть не более 110, хотя по второму значению могло бы быть 160. Почему?
Мы хотим больше подов!
Немного погуглив я нашел что максимальное количество подов на узел имеет настраиваемый параметр "maxPods" в сервисе kubelet, и его дефолтное значение равно 110. Естественно первой мыслью было попробовать изменить это значение и запустить 100500 подов на одном узле. Для этого я поднял кластер K8s (версии 1.29) в конфигурации из одного control-plane и одной worker node со следующими ресурсами:
K8s role | Configuration |
---|---|
k8s Control plane | 2 vCPUs, 4 GiB |
k8s Worker node | 16 vCPUs, 32 GiB |
И поменял параметр "maxPods" указав значение 100500:
Скрытый текст
Для этого на k8s Control plane нужно выполнить:
kubectl edit configmap -n kube-system kubelet-config и в открывщемся редакторе добавить параметр "maxPods: <значение>"
После чего удалить поды kube-proxy тем самым заставив их перезапуститься что бы изменения вступили в силу.
Далее для эксперимента я взял простой эхо-сервер и с помощью следующего манифеста создал namespace, deployment и service.
Скрытый текст
apiVersion: v1
kind: Namespace
metadata:
name: echoserver
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: echoserver
namespace: echoserver
spec:
replicas: 1
selector:
matchLabels:
app: echoserver
template:
metadata:
labels:
app: echoserver
spec:
containers:
- image: ealen/echo-server:latest
imagePullPolicy: IfNotPresent
name: echoserver
ports:
- containerPort: 80
env:
- name: PORT
value: "80"
---
apiVersion: v1
kind: Service
metadata:
name: echoserver
namespace: echoserver
spec:
ports:
- port: 80
targetPort: 80
protocol: TCP
type: NodePort
selector:
app: echoserver
Теперь можно попробовать отмасштабировать наш эхо-сервер до 100500 экземпляров и посмотреть что будет. Выполним команду:
kubectl scale deployment --replicas 100500 -n echoserver echoserver
Тут стоит упомянуть тот факт что после развертывания кластера k8s с включенным metrics-server для мониторинга потребления ресурсов, в системе уже запущено 15 подов ("системные" и metrics-server поды). Так вот спустя небольшое время общее количество подов достигло значение 1024 ("kubectl get po --all-namespaces --no-headers | wc -l"), а количество подов эхо-сервера достигло 1009 штук и далее не увеличивалось. При этом часть подов "зависли" в состоянии "Pending" и в их событиях ("kubectl -n echoserver get events") есть интересные сообщения вида:
Failed to create pod sandbox: rpc error: code = Unknown desc = failed to
create pod network sandbox k8s_hostnames-6b5bcd546d: error adding pod
test_hostnames-6b5bcd546d-rrp5f to CNI network "crio": plugin type="bridge"
failed (add): failed to connect "vethd49b3e6b" to bridge cni0: exchange full
А что собственно происходит?
Тут стоит чуть глубже рассказать о том как собственно реализуется сеть в контейнерах и как обеспечивается сетевое взаимодействие между контейнерами на уровне ядра Linux. На эту тему есть очень хорошая статья, в которой автор на примере показывает как руками можно сделать то что делают все среды контейнеризации. Так же есть цикл статей на хабре, да и уверен еще куча других на эту тему можно найти на просторах интернета. Не буду пересказывать все статьи (советую всем ознакомиться хотя бы с приведенными тут), но для понимания приведу вот такую цитату:
Containers are just Linux cgroups and namesaces.
и вот такую картинку:
Тут схематично изображено то как и с помощью каких абстракций ядра создается и настраивается сеть для контейнеров (это абстрактный пример). Самое главное это сущности которые для этого используются: network namespace, veth, bridge и есть еще другие которые не изображены на рисунке. Так вот даже не особо разбираясь в этих сущностях, но посмотрев на картинку можно заметить (по крайней мере мне так кажется) ключевую роль сущности bridge, а если еще вспомнить приведенное выше сообщение в событиях "зависших" подов (там упоминалось о "bridge cni0: exchange full"), то можно предположить что дело именно в нем.
Bridge всему виной?
Опять не много погуглив, нашел информацию о том что bridge это абстракция ядра Linux, аналогом которой в физическом мире является switch. И погуглив чуть по сильнее нашел что количество портов этого самого bridge задается в ядре при помощи параметра "BR_PORT_BITS". И разработчики ядра Linux на столько суровы что они не используют типы short, unsigned short, int, long и тд, для указания типа и размера переменной, они указывают сколько бит отводится для этого. И для переменной "BR_PORT_BITS" отведено 10 бит, что и составляет максимальное значение в 1024 (что они сэкномили не указав хотя бы short вопрос риторический, и наверное ответ на него из разряда "640 килобайт хватит всем").
То есть если мы хотим больше чем 1024 пода на одной worker node то добро пожаловать в сборку своей версии ядра с измененным значением "BR_PORT_BITS" ;-).
Итого мы поняли что настоящий предел ограничивающий k8s по количеству подов на одном узле это ограничение в 1024 порта linux bridge.
Далее мне стало интересно а это вообще работает? А на сколько хорошо/плохо это работает? И на это есть только один способ ответить - попробовать!
Тестирование.
Для того что бы проверить как работает наш эхо-сервер при различном количестве подов проведем небольшое тестирования.
Для тестирования я использовал инструмент Grafana K6 и сделал первый тест, который имитирует что 3000 пользователей в течении 5,5 минут выполняют GET запрос на наш эхо-сервис и затем сравнил несколько показателей при различном количестве подов. Результаты я привел в таблице:
Кол-во подов | Общее количество запросов | Количество запросов в секунду | Среднее время выполнения 95% запросов, р(95), ms |
100 | 14174127 | 42940 | 114.25 |
200 | 13166918 | 39889 | 135.79 |
300 | 12621770 | 38236 | 157.65 |
400 | 12170240 | 36867 | 164.21 |
500* | 11345838 | 32324 | 188.14 |
600** | 11138653 | 30940 | 152.58 |
Из результатов видно падение производительности (количество выполненных запросов) и рост времени отклика, что объясняется тем что в данном тесте узким местом является CPU и с ростом количества подов процессы эхо-сервера упираются в CPU, встают в очередь и тем самым уменьшается количество обработанных запросов и увеличивается время обработки запросов.
Но я не зря отметил "*" результаты когда количество подов становилось 500 и более. Дело в том что при таком количестве подов стали появляться ошибки выполнения теста и чем больше подов тем все больше ошибок. И ошибки эти двух видов, пример:
WARN[0058] Request Failed error="Get \"http://10.26.1.224:30130\": dial tcp 10.26.1.224:30130: connect: no route to host"
WARN[0058] Request Failed error="Get \"http://10.26.1.224:30130\": dial: i/o timeout"
И если ошибка "i/o timeout" более менее понятна - http клиент не дождался ответа и отвалился по таймауту, то вот ошибка "no route to host" выглядит очень подозрительно и опасно.
Как промежуточный результат проведенного тестирования можно выделить что есть две проблемы:
Снижение производительности.
Появление ошибок.
Попытаемся разобраться с каждой.
Появление ошибок.
В процессе изучения причин появления ошибок, экпериментальным путем было установлено что на появление этих ошибок не влияет:
количество виртуальных пользователей - я пробовал уменьшать количество виртуальных пользователей;
количество ресурсов CPU, RAM - пробовал добавить в 2-3 раза больше, и уменьшить в 2 раза.
Пытаясь понять причину появления ошибок в системном журнале ОС было обнаружено, что во время проведения теста при количестве подов более 500 появляются сообщения вида:
neighbour: arp_cache: neighbor table overflow!
Что и помогло найти причину.
Как выяснилось эти записи говорят о том что arp таблица переполнена. ARP таблица используется arp протоколом для составления соответствия ip адреса и mac адреса, необходимо это для обеспечения взаимодействия на канальном уровне TCP соединения. Записи в этой таблице бывают двух видов - статические и динамические. Статические это добавленные и поддерживаемые вручную администратором, динамические формируются с помощью самого arp протокола. Каждый раз когда в таблице нет соответствия между ip и mac адресом, протокол выполняет широковещательный запрос для получения данных и добавляет запись в таблицу. У каждой записи есть время жизни и есть garbage collector отвечающий за очистку этой таблицы. Для управлением его работы в ядре Linux есть три параметра (на самом деле 6, 3 для ip v4 и 3 для ip v6), имеющие следующее значение по умолчанию:
$ sysctl -a | grep gc_thresh | grep neigh
net.ipv4.neigh.default.gc_thresh1 = 128
net.ipv4.neigh.default.gc_thresh2 = 512
net.ipv4.neigh.default.gc_thresh3 = 1024
net.ipv6.neigh.default.gc_thresh1 = 128
net.ipv6.neigh.default.gc_thresh2 = 512
net.ipv6.neigh.default.gc_thresh3 = 1024
Где:
gc_thresh1: Минимальное количество записей в таблице которые могут хранится постоянно. Но начиная с версии ядра Linux с 2.6.18 garbage collector игнорирует это значение и всегда пытается оставить только актуальные записи.
gc_thresh2: Количество записей в таблице свыше которого garbage collector позволяет быть таким записям в таблице в течении 5 секунд перед тем как он их от туда удалит, независимо от того актуальны эти записи или нет.
gc_thresh3: Максимально количество записей в таблице.
Вот этот второй параметр и был причиной возникновения ошибки "no route to host". Для проверки я изменил эти параметры на следующие значения - экономить не стал увеличил в 8 раз сразу, гулять так гулять :-) (изменил только для ipv4):
net.ipv4.neigh.default.gc_thresh1 = 2048
net.ipv4.neigh.default.gc_thresh2 = 4096
net.ipv4.neigh.default.gc_thresh3 = 8192
net.ipv6.neigh.default.gc_thresh1 = 128
net.ipv6.neigh.default.gc_thresh2 = 512
net.ipv6.neigh.default.gc_thresh3 = 1024
Скрытый текст
Для изменения я добавил указанные выше строки в файл "/etc/sysctl.conf" и выполнил команду "sysctl -p" для того что бы изменения вступили в силу.
После чего наш тест стал проходить без ошибок вплоть до максимального количества подов эхо сервера в 1000 штук. Win!
Снижение производительности.
Теперь когда мы решили проблему с появлением ошибок можно провести наше тестирование для сравнения производительности вплоть до 1000 подов. Тест я чуть изменил так что бы он иммитировал 1000 пользователей, которые делают GET запрос на наш эхо-сервер в течении 40 секунд (изменил что бы быстрее он проходил, но суть от этого не изменилась так как это не НТ или стресс тестирование).
Тут стоит забежать вперед и вспомнить о том что k8s по умолчанию использует iptables для маршрутизации и управлением сетевым трафиком подов. При этом известно что у iptables есть проблемы связанные с тем что при увеличении количество правил и цепочек в таблицах обработки пакетов, падает производительность как при обработке ядром пакетов, так и при управлением этими правилами сервисом kube-proxy.
Во время экпериментов я проверял количество цепочек правил iptables при разном количестве подов и например при 500 подах количество правил было около 12000, а при 1000 подах - около 23000.
Решением проблемы должна была стать новый подход с ipvs, поддержка которого есть в k8s. Стало быть нужно проверить оба этих подходов.
Для проверки выполним наш тест при количестве подов 16, 100, 500 и 1000 для iptables и ipvs (тест проводился не сколько раз, в качестве итоговых значений взято среднее).
Скрытый текст
Почему такие цифры: 16 по количеству ядер worker node, меньше какое то ядро будет не использоваться, остальные цифры просто потому что было лень делать тест с более мелким шагом.
Кстати изменить режим работы iptables на ipvs можно отредактировав конфигурацию сервиса kube-proxy "kubectl edit configmap kube-proxy -n kube-system", параметр "data.config.conf.mode=iptables" поменять на "ipvs", после чего нужно перезапустить сервисы kube-proxy, я это делаю путем удаления соответствующих подов.
Результаты замеров привел в сводной таблице:
Результат | iptables | ipvs |
---|---|---|
16 pods | ||
http_reqs | 1440820 | 1403758 |
http_reqs/s | 35999.68 | 35072.09 |
http_req_duration, p(95),ms | 36.59 | 37.01 |
100 pods | ||
http_reqs | 1146835 | 1115228 |
http_reqs/s | 28639.54 | 27850.78 |
http_req_duration, p(95),ms | 63 | 63.7 |
500 pods | ||
http_reqs | 781818 | 726671 |
http_reqs/s | 19511.96 | 18137.36 |
http_req_duration, p(95),ms | 111.56 | 112.65 |
1000 pods | ||
http_reqs | 688800 | 552387 |
http_reqs/s | 17190.77 | 13777.23 |
http_req_duration, p(95),ms | 122.72 | 179.62 |
Где:
http_reqs – общее количество выполненных запросов;
http_reqs/s – запросов в секунду;
http_req_duration, p(95),ms – среднее время выполнения 95% запросов;
Результат честно был неожиданным. Вопреки обещаниям, ipvs в начале показывает такие же результаты что и iptables, но ближе к 1000 подов откровенно проигрывает: количество обработанных запросы на 24% меньше, среднее время выполнения запросов увеличилось на 46%.
Недоумевая идем опять гуглить и находим KEP в котором прямо говорится "ipvs mode of kube-proxy will not save us". Опять недоумеваем, гуглим и находим issue из которого следует что nftables нас всех спасет! Но это будет в следующих версиях k8s :( (заявлена как бета в версии k8s 1.31 и только на ядре версии от 5.13 и выше).
Таким образом снижение производительности при увеличении количества подов пока не возможно решить.
Стоит отдельно отметить, что если отойти от сравнение iptables vs ipvs и проанализировать только значения производительности для iptables, то можно увидеть например, что метрика "среднее время выполнения 95% запросов" изменяется не линейным образом: разница между 500 и 1000 подов всего 10% (122.72ms против 111.56ms).
Выводы.
Рекомендация в 110 подов на узел – не ограничение, а настраиваемое значение. Реальное ограничение 1024 пода это ограничение на количество портов bridge ядра Linux.
Настройка дефолтных значений garbage collector arp таблицы поможет достичь 1024 пода на узел.
По моему субъективному мнению у этого всего есть возможное практическое применение:
Увеличение количества подов поможет лучше утилизировать ресурсы узлов.
Проблемы связанные с снижением производительности при увеличении количества подов могут быть не столь критичны для каких то приложений и сред:
Профиль нагрузки реальных приложений может быть различный (не только CPU).
Dev и тестовые среды могут пожертвовать скоростью ради экономии ресурсов.
Такое "уплотнение" подов может позволить высвободить ресурсы и использовать их на другие нужды или сократить их потребление тем самым сэкономить деньги на инфраструктуре.
Конечно перед тем как принимать решение о целесообразности применения полученных возможностей по оптимизации использования ресурсов нужно оценить его эффект, и если он есть то почему бы и нет? :-)
Спасибо за внимание!