[Перевод] 3 года с Kubernetes в production: вот что мы поняли
Прим. перев.: в очередной статье из категории «lessons learned» DevOps-инженер австралийской компании делится главными выводами по итогам продолжительного использования Kubernetes в production для нагруженных сервисов. Автор затрагивает вопросы Java, CI/CD, сетей, а также сложности K8s в целом.
Свой первый кластер Kubernetes мы начали создавать в 2017 году (с версии K8s 1.9.4). У нас было два кластера. Один работал на bare metal, на виртуальных машинах RHEL, другой — в облаке AWS EC2.
На сегодняшний день наша инфраструктура насчитывает более 400 виртуальных машин, разбросанных по нескольким дата-центрам. Платформа выступает базой для высокодоступных критически важных приложений и систем, которые управляют огромной сетью, включающей почти 4 миллиона активных устройств.
В конечном итоге Kubernetes упростил нам жизнь, однако путь к этому был тернистым и требовал полной смены парадигмы. Произошла тотальная трансформация не только набора навыков и инструментов, но и подхода к проектированию, мышления. Нам пришлось осваивать множество новых технологий и вкладывать значительные средства в развитие инфраструктуры и повышение квалификации команд.
Вот ключевые уроки, которые мы вынесли из опыта использования Kubernetes в production на протяжении трех лет.
1. Занимательная история с Java-приложениями
Когда речь заходит о микросервисах и контейнеризации, инженеры, как правило, избегают использования Java, прежде всего из-за ее печально известного несовершенного управления памятью. Впрочем, сегодня ситуация обстоит иначе и совместимость Java с контейнерами в последние годы улучшилась. В конце концов, даже такие популярные системы, как Apache Kafka и Elasticsearch, работают на Java.
В 2017—2018 годах некоторые наши приложения работали на Java восьмой версии. Частенько они отказывались функционировать в контейнерных средах вроде Docker и падали из-за проблем с heap-памятью и неадекватной работы сборщиков мусора. Как оказалось, эти проблемы были вызваны неспособностью JVM работать с механизмами контейнеризации Linux (cgroups
и namespaces
).
С тех пор Oracle приложила значительные усилия, чтобы повысить совместимость Java с миром контейнеров. Уже в 8-ой версии Java появились экспериментальные флаги JVM для решения этих проблем: XX:+UnlockExperimentalVMOptions
и XX:+UseCGroupMemoryLimitForHeap.
Но, несмотря на все улучшения, никто не будет спорить, что у Java по-прежнему плохая репутация из-за чрезмерного потребления памяти и медленного запуска по сравнению с Python или Go. В первую очередь это связано со спецификой управления памятью в JVM и ClassLoader’ом.
Сегодня, если нам приходится работать с Java, мы по крайней мере стараемся использовать версию 11 или выше. И наши лимиты на память в Kubernetes на 1 Гб выше, чем ограничение на максимальный объем heap-памяти в JVM (-Xmx
) (на всякий случай). То есть, если JVM использует 8 Гб под heap-память, лимит в Kubernetes на память для приложения будет установлен на 9 Гб. Благодаря этим мерам и улучшениям жизнь стала чуточку легче.
2. Обновления, связанные с жизненным циклом Kubernetes
Управление жизненным циклом Kubernetes (обновления, дополнения) — вещь громоздкая и непростая, особенно если кластер базируется на bare metal или виртуальных машинах. Оказалось, что для перехода на новую версию гораздо проще поднять новый кластер и потом перенести в него рабочие нагрузки. Обновление существующих узлов попросту нецелесообразно, поскольку связано со значительными усилиями и тщательным планированием.
Дело в том, что в Kubernetes слишком много «движущихся» частей, которые необходимо учитывать при проведении обновлений. Для того, чтобы кластер мог работать, приходится собирать все эти компоненты вместе — начиная с Docker и заканчивая CNI-плагинами вроде Calico или Flannel. Такие проекты, как Kubespray, KubeOne, kops и kube-aws, несколько упрощают процесс, однако все они не лишены недостатков.
Свои кластеры мы разворачивали в виртуальных машинах RHEL с помощью Kubespray. Он отлично себя зарекомендовал. В Kubespray были сценарии для создания, добавления или удаления узлов, обновления версии и почти все, что необходимо для работы с Kubernetes в production. При этом сценарий обновления сопровождался предостережением о том, что нельзя пропускать даже второстепенные (minor) версии. Другими словами, чтобы добраться до нужной версии, пользователю приходилось устанавливать все промежуточные.
Главный вывод здесь в том, что если вы планируете использовать или уже используете Kubernetes, продумайте свои шаги, связанные с жизненным циклом K8s и то, как он вписывается в ваше решение. Создать и запустить кластер часто оказывается проще, чем поддерживать его в актуальном состоянии.
3. Сборка и деплой
Будьте готовы к тому, что придется пересмотреть пайплайны сборки и деплоя. При переходе на Kubernetes у нас прошла радикальная трансформация этих процессов. Мы не только реструктурировали пайплайны Jenkins, но с помощью инструментов, таких как Helm, разработали новые стратегии сборки и работы с Git’ом, тегирования Docker-образов и версионирования Helm-чартов.
Вам понадобится единая стратегия для поддержки кода, файлов с deployment«ами Kubernetes, Dockerfiles, образов Docker’а, Helm-чартов, а также способ связать все это вместе.
После нескольких итераций мы остановились на следующей схеме:
- Код приложения и его Helm-чарты находятся в разных репозиториях. Это позволяет нам версионировать их независимо друг от друга (семантическое версионирование).
- Затем мы сохраняем карту с данными о том, какая версия чарта к какой версии приложения привязана, и используем ее для отслеживания релиза. Так, например,
app-1.2.0
развертывается сcharts-1.1.0
. Если меняется только файл с параметрами (values
) для Helm, то меняется только patch-составляющая версии (например, с1.1.0
на1.1.1
). Все требования к версиям описываются в примечаниях к релизу (RELEASE.txt
) в каждом репозитории. - К системным приложениям, таким как Apache Kafka или Redis (чей код мы не собирали и не модифицировали), у нас иной подход. Нам не были нужны два репозитория, поскольку Docker-тег был просто частью версионирования Helm-чартов. Меняя Docker-тег для обновления, мы просто увеличиваем основную версию в теге чарта.
(Прим. перев.: сложно пройти мимо такой трансформации и не указать на нашу Open Source-утилиту для сборки и доставки приложений в Kubernetes — werf — как один из способов упростить решение тех проблем, с которыми столкнулись авторы статьи.)
4. Тесты Liveliness и Readiness (обоюдоострый меч)
Проверки работоспособности (liveliness) и готовности (readiness) Kubernetes отлично подходят для автономной борьбы с системными проблемами. Они могут перезапускать контейнеры при сбоях и перенаправлять трафик с «нездоровых» экземпляров. Но в некоторых условиях эти проверки могут превратиться в обоюдоострый меч и повлиять на запуск и восстановление приложения (это особенно актуально для stateful-приложений, таких как платформы обмена сообщениями или базы данных).
Наш Kafka стал их жертвой. У нас был stateful set из 3 Broker
'ов и 3 Zookeeper
'ов с replicationFactor
= 3 и minInSyncReplica
= 2. Проблема возникала при перезапуске Kafka после случайных сбоев или падений. Во время старта Kafka запускал дополнительные скрипты для исправления поврежденных индексов, что занимало от 10 до 30 минут в зависимости от серьезности проблемы. Такая задержка приводила к тому, что liveliness-тесты постоянно завершались неудачей, из-за чего Kubernetes «убивал» и перезапускал Kafka. В результате Kafka не мог не только исправить индексы, но даже стартовать.
Единственным решением на тот момент виделась настройка параметра initialDelaySeconds
в настройках liveliness-тестов, чтобы проверки проводились только после запуска контейнера. Главная сложность, конечно, в том, чтобы решить, какую именно задержку установить. Отдельные запуски после сбоя могут занимать до часа времени, и это необходимо учитывать. С другой стороны, чем больше initialDelaySeconds
, тем медленнее Kubernetes будет реагировать на сбои во время запуска контейнеров.
В данном случае золотой серединой является значение initialDelaySeconds
, которое лучше всего удовлетворяет вашим требованиям к устойчивости и в то же время дает приложению достаточно времени для успешного запуска во всех сбойных ситуациях (отказы диска, проблемы с сетью, системные сбои и т.д.)
Обновление: в свежих версиях Kubernetes появился третий тип тестов под названием startup probe. Он доступен как альфа-версия, начиная с релиза 1.16, и как бета-версия с 1.18.Startup probe позволяет решить вышеописанную проблему, отключая readiness и liveliness-проверки до тех пор, пока контейнер не запустится, тем самым позволяя приложению нормально стартовать.
5. Работа с внешними IP
Как оказалось, использование статических внешних IP для доступа к сервисам оказывает серьезное давление на механизм отслеживания соединений системного ядра. Если его не продумать тщательно, он может «сломаться».
В своем кластере мы используем Calico
как CNI и BGP
в качестве протокола маршрутизации, а также для взаимодействия с пограничными маршрутизаторами. В Kube-proxy задействован режим iptables
. Доступ к нашему очень загруженному сервису в Kubernetes (ежедневно он обрабатывает миллионы подключений) открываем через внешний IP. Из-за SNAT и маскировки, проистекающих от программно-определяемых сетей, Kubernetes нуждается в механизме отслеживания всех этих логических потоков. Для этого K8s задействует такие инструменты ядра, как сonntrack
и netfilter
. С их помощью он управляет внешними подключениями к статическому IP, который затем преобразуется во внутренний IP сервиса и, наконец, в IP-адрес pod’а. И все это делается с помощью таблицы conntrack
и iptables.
Однако возможности таблицы conntrack
небезграничны. При достижении лимита кластер Kubernetes (точнее, ядро ОС в его основе) больше не сможет принимать новые соединения. В RHEL этот предел можно проверить следующим образом:
$ sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_maxnet.netfilter.nf_conntrack_count = 167012
net.netfilter.nf_conntrack_max = 262144
Один из способов обойти это ограничение — объединить несколько узлов с пограничными маршрутизаторами, чтобы входящие соединения на статический IP распределялись по всему кластеру. В случае, если у вас в кластере большой парк машин, такой подход позволяет значительно увеличить размер таблицы conntrack
для обработки очень большого числа входящих соединений.
Это полностью сбило нас с толку, когда мы только начинали в 2017 году. Однако сравнительно недавно (в апреле 2019-го) проект Calico опубликовал подробное исследование под метким названием «Why conntrack is no longer your friend» (есть такой её перевод на русский язык — прим. перев.).
Действительно ли вам нужен Kubernetes?
Прошло три года, но мы до сих пор продолжаем открывать/узнавать что-то новое каждый день. Kubernetes — сложная платформа со своим набором вызовов, особенно в области запуска окружения и поддержания его в рабочем состоянии. Она изменит ваше мышление, архитектуру, отношение к проектированию. Вам придется заниматься расширением масштабов деятельности команд и повышением их квалификации.
С другой стороны, работа в облаке и возможность использовать Kubernetes как услугу избавит вас от большинства забот, связанных с обслуживанием платформы (вроде расширения CIDR внутренней сети и обновления Kubernetes).
Сегодня мы пришли к пониманию того, что главный вопрос, который следует задать себе — действительно ли вам нужен Kubernetes? Он поможет оценить, насколько глобальна имеющаяся проблема и поможет ли с ней справиться Kubernetes.
Дело в том, что переход на Kubernetes обходится дорого. Поэтому плюсы от вашего сценария использования (и то, насколько и как он задействует платформу) должны оправдывать цену, которую вы заплатите. Если это так, то Kubernetes может существенно повысить вашу производительность.
Помните, что технология исключительно ради технологии бессмысленна.
P.S. от переводчика
Читайте также в нашем блоге: