Ранее статья была опубликована на medium.
Спойлер: Вся статья в несколько предложений
Проблема: при обновлении k8s кластера не менее половины POD'в застряли в состоянии ContainerCreating
из-за длительной загрузки и распаковки образов размером примерно 3 ГБ.
Для решения этой проблемы в корне (не только кейс с обновлением) предложена стратегия предварительного прогрева узлов кластера:
Создание отдельной группы узлов для каждой feature-ветки (FB): Это обеспечило изоляцию и гибкость, позволяя легко изменять тип узлов.
Загрузка базовых образов через cloud-init: При запуске новых узлов с помощью скрипта cloud-init автоматически загружаются необходимые базовые образы, что снижало нагрузку при последующем развертывании.
Использование DaemonSet с initContainers: Перед основным развертыванием запускался DaemonSet с initContainers, которые последовательно загружали необходимые образы на узлы, предотвращая проблемы с сверхутилизацией дисков и ускоряя запуск POD'ов.
Проблема
Однажды, в ходе планового обновления кластера Kubernetes, мы обнаружили, что почти все POD'ы (около 500 из 1000) на новых(обновленных) узлах не смогли запуститься. Минуты ожидания быстро превратились в часы. Мы активно искали первопричину...3 hours later... Спустя три часа POD'ы всё ещё находились в состоянии ContainerCreating
.
Хорошо спланированное поражение - это не поражение.
© Кто-то
К счастью, это был не PROD env, а окно обслуживания было запланировано на выходные(время с наименьшим трафиком, но с нужным количество инженеров). У нас было время разобраться в проблеме без дополнительного давления.
С чего начать поиск причины? Хотите узнать больше о найденном решении? Добро пожаловать под кат!
Подробнее о проблеме
Основной причиной подобного поведения - загрузка(pull) и запуск большого количества Docker-образов на каждом узле кластера одновременно. При одновременной загрузке нескольких(множества, например > 20) образов на одном узле возникает высокая нагрузка на диск, что приводит к длительному "холодному старту".
Иногда CD(Continuous Delivery) процесс занимал до трёх часов для загрузки образов. Но в этот раз он полностью завис из-за большого количества POD'ов при обновлении EKS(Elastic Kubernetes Service) одновременной замене множества узлов (узел стартует успешно, поэтому k8s переходит к обновлению следующего узла) в кластере.
Детали окружения:
Все приложения работают в Kubernetes (на основе EKS). Для сокращения затрат в DEV-среде мы используем spot-инстансы.
Узлы работают на базе образа Amazon Linux 2.
В среде разработки много feature-веток (FB), которые непрерывно(с помощью CI/CD) развёртываются в кластере Kubernetes. Каждая ветка имеет свой набор приложений и зависимостей (внутри образа).
Проект включает почти 200 приложений, и их число растёт. Каждое приложение использует один из семи базовых образов размером около 2 ГБ. Максимальный общий размер архива образа (в ECR) достигает 3 ГБ.
Все образы хранятся в ECR(Amazon Elastic Container Registry).
Мы используем тип томов EBS по умолчанию - gp3.
Симптомы
Увеличенное время холодного старта: Запуск нового POD'а с новым образом может занимать более часа, особенно при одновременной загрузке нескольких образов на одном узле.
Ошибки ErrImagePull: Частые ошибки
ErrImagePull
или "зависание" в состоянииContainerCreating
, указывающее на проблемы с загрузкой образов в POD'е.Высокая утилизация диска: Утилизация была около 100% во время загрузки(pull) образов из registry. Кажется это было из-за интенсивного ввода-вывода, связанного с распаковкой (я застукал утилиту
unpigz
).Проблемы с DaemonSet: Некоторые системные DaemonSet'ы (
aws-node
илиebs-csi-node
) переходили в состояние "not ready", что влияло на работоспособность узла(node).Отсутствие кеша Docker образов на узлах: Из-за использования spot-инстансов (и динамических Feature-Branch веток) локальный диск ноды не может быть использован для кеширования образов.
Этот набор проблем привел к тому, что CD процесс зачастую очень долго выполняется или вовсе зависает на час или более. Особенно это было заметно в Feature-Branches, т.к. эти окружения имеют разный набор базовых Docker образов.
После быстрого изучения проблемы, мы обнаружили, что основной проблемой была высокая утилизация диска, процесс unpigz
использовал ресурсы по максимуму.
Процесс unpigz отвечает за распаковку Docker образов.
Кроме того, мы не меняли настройки по умолчанию EKS storage тип тома gp3 EBS, поскольку более скоростные типы томов не входят в наш бюджет и на каждый тип томов имеется свой лимит (который мы бы быстро достигли в наших кластерах).
Восстановление работоспособности кластера
В качестве первого шага мы решили уменьшить количество POD'ов на узлах:
Перевели новые узлы в состояние
Cordon
(новые POD'ы не будут планироваться на этих узлах).Удалили все застрявшие POD'ы, чтобы снизить нагрузку на диск.
Поочерёдно запускали по одному или несколько POD'ов для прогрева узлов с базовыми образами (у нас их 7).
После этого перевели прогретые узлы в рабочее состояние (
unCordon
).Удалили все узлы, находившиеся в застрявшем состоянии.
Все POD'ы успешно запустились, используя кеш Docker-образов (шаг 3).
Изначальный дизайн CI/CD
Основная идея решения - начинать прогревать узлы(как можно раньше) еще перед началом CI/CD, загружая самые объемные части Docker-образов (слои с JS зависимостями), которые используются как базовые образы для всех приложений.
У нас есть как минимум семь типов базовых образов с JS-зависимостями, которые соответствуют типам приложений.
В нашем CI/CD процесс состоит из трёх этапов:
Init: Подготовка окружения/переменных, определение набора образов для сборки и т.д.
Build: Сборка образов и их загрузка в ECR.
Deploy: Деплой образов в Kubernetes.
Чуть больше деталей про оригинальный CI/CD (для понимания проблемы)
Наши feature-ветки (FB) создаются из основной ветки.
В процессе CI мы анализируем набор образов, которые изменились в FB(по сравнению с main), и пересобираем их.
Основная ветка всегда стабильна, и, по определению, в ней используется самая последняя версия базовых образов.Мы раздельно собираем Docker-образы с JS-зависимостями (для каждого окружения) и загружаем их в ECR, чтобы повторно использовать их в качестве базового образа в Dockerfile. У нас есть около 5–10 типов Docker-образов с JS-зависимостями.
FB(feature-branches) разворачиваются в dev Kubernetes в отдельном namespace, но на общих узлах, выделенных для FB. Каждая FB может включать около 200 приложений, с размером образа до 3 ГБ.
Мы используем систему автоскейлинга(нет, это не AWS autoscaler, и нет, это не Karpenter, свои варианты пишите в комментах) кластера, которая масштабирует узлы в кластере на основе данных о нагрузке или ожидающих планирования POD'ов, используя соответствующие nodeSelector и toleration.
В dev k8s используем spot-инстансы.
Добавляем прогрев k8s node в дизайн нашего CI/CD
Изобретаем колесо
Начать пожалуй стоит с требований.
Обязательные:
Решение основной проблемы: Устранены проблемы с созданием контейнеров
ContainerCreating
.Улучшение производительности: Сокращение времени старта за счёт предварительно загруженных базовых образов.
Дополнительные (nice to have):
Гибкость: возможность легко менять типы узлов без аффекта для приложений
Прозрачность: наличие метрик использования и производительности узлов для каждого окружения.
Экономия: удаление узлов сразу после удаления feature-ветки, которая использовала эти узлы.
Изоляция: новый CI/CD процесс не должен затрагивать другие FB среды.
Решение
После анализа обязательных и дополнительных требований мы решили внедрить процесс прогрева узлов. Процесс загружает базовые образы (JS-кэш) на узлы и запускается он перед началом CD-процесса, чтобы узлы были готовы к моменту начала развертывания feature-ветки (FB), а вероятность использования кэша была максимальной.
Мы разделили это улучшение на три ключевых этапа:
Создание набора узлов (Virtual Node Group, VNG) для каждой FB.
Добавление базовых образов(инструкции для загрузки) в скрипт
cloud-init
для новых узлов.Добавление шага предварительного деплоя (
pre-deploy
) с использованием DaemonSet и секцииinitContainers
, чтобы загрузить необходимые Docker-образы на узлы до начала CD-процесса.
Это дополнительный шаг. Этот шаг позволяет загрузить кеш на ноды, которые имеют уже устаревший docker кеш.
Обновленная CI/CD схема:
Init step (оригинальный шаг)
1.1. init deploy (новый шаг) Инициализация деплоя: Если это первый запуск FB, создается новый персональный(для FB) набор узлов (в нашем случае это Virtual Node Group или VNG) и загружаются все базовые JS-образы (5–10 образов) из основной ветки. Это оправдано, так как FB создается из основной ветки. Важно отметить, что это неблокирующая операция.Build step (оригинальный шаг, без изменений)
На этом этапе происходит стандартная сборка образов.Pre deploy (новый шаг)
На этом шаге загружаются свежесобранные базовые JS-образы (на узлы) с тегом, специфичным для данной FB, из ECR.
Ключевые моменты:
- Это блокирующая операция, так как необходимо уменьшить нагрузку на диск. Базовые образы загружаются поочередно для каждого узла.
- Кстати, благодаря шагу "инициализация деплоя" (init deploy), базовые Docker-образы из основной ветки уже доступны, что дает высокую вероятность использования кэша при первом запуске.Deploy (оригинальный шаг, без изменений).
Однако благодаря предыдущим шагам на всех необходимых узлах уже имеются самые тяжелые слои Docker-образов, что значительно ускоряет развертывание.
Подробнее про "Init deploy" шаг
Создание нового набора узлов для каждой FB через API вызов (в сторонней системе автоскейлинга) из нашего CI-пайплайна.
Решенные проблемы:
Изоляция: Каждая FB имеет собственный набор узлов, что гарантирует, что другие FB не оказывают влияния друг на друга.
Гибкость: Можно легко менять тип узлов для разных окружений.
Экономия: Узлы можно удалить сразу после удаления FB.
Прозрачность: Легко отслеживать использование и производительность узлов (каждый узел имеет тег, связанный с FB).
Эффективное использование spot-инстансов: Spot-инстансы запускаются уже с предзагруженными базовыми образами. Это означает, что после запуска узлов базовые образы (из основной ветки) уже находятся на них.
Загрузка базовых Docker образов(JS зависимости) на новые узлы через скрипт cloud-init
Пока образы загружаются в фоновом режиме, процесс CD может продолжать сборку новых образов без каких-либо проблем. Более того, последующие узлы (создаваемые системой автоскейлинга) в этой группе будут создаваться с обновлёнными данными cloud-init, которые уже содержат инструкции по загрузке образов до начала работы.
Решенные проблемы:
Устранение избыточной нагрузки на диск: Использование диска прило в норму. Скрипт cloud-init был обновлён для загрузки базовых образов из основной ветки, что позволило использовать кэш при первом запуске FB.
Эффективное использование spot-инстансов: Spot-инстансы запускаются с обновлёнными данными cloud-init. Это означает, что после запуска узлов базовые образы уже находятся на них. Кроме того, эти иснтансы запускаются не только через CI/CD, но и в процессе автоскейлинга.
Улучшили время доставки код до окружения: Устранили туболчное горлышко на моменте CD, когда CD застревал при одновременном деплое большого количества новых контейнеров с большими образами.
Влияние на CI/CD пайплайн
Эта операция добавила примерно 17 секунд (на API вызов) к нашему CI/CD пайплайну.
Этот шаг имеет смысл только при первом запуске FB. В последующие запуски приложения будут развертываться на уже существующих узлах, которые содержат базовые образы, загруженные в ходе предыдущего деплоя.
Подробнее про "Pre deploy" шаг
Этот шаг необходим, поскольку образы для FB(feature-branch) отличаются от образов основной(main) ветки. Мы должны загрузить базовые образы для FB на узлы до начала процесса CD. Это сильно сократит время холодного старта и уменьшит высокую нагрузку на диск, возникающую при одновременной загрузке нескольких тяжёлых образов.
Задачи этого шага:
Предотвращение нагрузки на диск:
Последовательная загрузка базовых (наиболее тяжёлых) Docker-образов.
После выполнения шага инициализации деплоя (Init-deploy) базовые образы(из main ветки) уже находятся на узлах, что существенно увеличивает вероятность использования кэша.Улучшение эффективности деплоя (CD):
Узлы предварительно прогреваются с необходимыми Docker-образами, что приводит к практически мгновенному запуску POD'ов.Повышение стабильности:
Минимизируется вероятность появления ошибокErrImagePull
иContainerCreating
.
Кроме того, системные DaemonSet'ы остаются в состоянииready
, что обеспечивает нормальную работоспособность узлов.
Подробности шага Pre-deploy
В процессе CD создаётся DaemonSet с секцией initContainers.
InitContainers запускаются перед основным контейнером, чтобы убедиться, что все необходимые образы загружены на каждую ноду у нужной тегом.
В процессе CD непрерывно проверяется статус DaemonSet.
Когда DaemonSet перейдет в состояниеready
, мы можем вернуться к остальным шагам деплоя. В противном случае процесс ожидает готовности DaemonSet.
Сравнение
Шаг |
Init deploy |
Pre deploy |
Deploy |
Суммарное время |
Разница |
Без прогрева |
0 |
0 |
11 мин 21 сек |
11 мин 21 сек |
0 |
С прогревом |
8 сек |
58 сек |
25 сек |
1 мин 31 сек |
-9 мин 50сек |
Скрытый текст
Важно отметить, что это не все шаги CI/CD, в таблице показаны только значимые шаги, на которые повлияло улучшение.
Главное достижение: время шага "Deploy" (от первого выполнения команды apply
до состояния Running для POD'ов) сократилось с 11 минут 21 секунды до 25 секунд. Общее время выполнения всего процесса уменьшилось с 11 минут 21 секунды до 1 минуты 31 секунды.
Важно отметить: если базовые образы из основной ветки отсутствуют в ECR, то время шага "Deploy" останется таким же(как и до улучшений), как исходное, или немного увеличится. Однако, несмотря на это, проблема с нагрузкой на диск и длительным временем холодного старта будет решена в любом случае.
Заключение
Основная проблема с ContainerCreating
была решена с помощью процесса прогрева. В результате мы значительно сократили время холодного старта POD'ов и получили дополнительные бенефиты.
Нагрузка на диск исчезла, так как базовые образы теперь уже находятся на узлах.
Системные DaemonSet'ы находятся в состоянии "ready" и "healthy" (благодаря отсутствию нагрузки на диск).
Ошибки
ErrImagePull
, связанные с этой проблемой, больше не возникают.
Другие возможные решения и полезные ссылки
-
Использование on-demand инстансов вместо spot-инстансов
Мы не можем использовать этот подход, так как он выходит за рамки нашего бюджета для не PROD окружений.
-
Использование Amazon EBS gp3 (или лучше) с увеличенным количеством операций ввода-вывода (IOPS)
Этот вариант также выходит за рамки бюджета для не PROD окружений. Более того, AWS накладывает лимиты на IOPS для учетной записи в каждом регионе.
-
Уменьшение времени запуска контейнеров в Amazon EKS с помощью Bottlerocket data volume
Мы не можем использовать этот способ, т.к. это принесет слишком много изменений в существующую инфрастурктуру, в частности в продакшен окружение. Хотя это действительно хорошее решение для нашей проблемы.
-
Troubleshoot Kubernetes Cluster Autoscaler takes 1 hr to scale 600 pods (статья на medium, решение похожей проблемы)
Этот подход также имеет потенциал, но требует дополнительной проработки.
P.S.: Хочу выразить благодарность технической команде Justt за их неустанную работу и действительно креативный подход к решению любых возникающих задач.
Статья опубликована в рамках набора на курс «DevOps практики и инструменты». На странице курса вы сможете подробнее узнать о программе, а также посмотреть записи прошедших вебинаров.
Комментарии (8)
Negash
20.11.2024 11:46https://nydus.dev ещё есть, но это прямо для сильных ребят, я все ни как не начну пользоваться
kksudo Автор
20.11.2024 11:46Для AI\ML выглядит как must have.
Оч интересная вещь.
P.S.: Нужно конвертировать образы в проприетарный формат, не каждый проект пойдет на это. Но оч любопытно, да.a userspace filesystem called
rafs
on top of a container image formatan image manifest that is compatible with OCI spec of image and distribution
uburro
20.11.2024 11:46Прочитал только спойлер, но для таких задач, я бы брал бы опен круиз и их apps.kruise.io/image-predownload-parallelism
Мое имхо)
kksudo Автор
20.11.2024 11:46Интересное решение, не работал с ним.
Да, кажется они решают тот же кейс, что и описан в статье. Но не уверен, что тут покрывается шаг с cloud-init(который описан в статье). Хотя вероятно нужно лучше доку почитать.
https://openkruise.io/docs/best-practices/ci-pipeline-image-predownload/
antonaleks605
20.11.2024 11:46Основная проблема больших образов это экстрактинг слоев, так как он последовательный, в отличие от загрузки, которая происходит параллельно. При этом в мире ML образы могут быть и по 20-30 гб.
Есть интересные варианты, связанные с lazy-loading. Многие уже примеры выше присылали. eStargz, SOCI, Nydus
Также стоит посмотреть на формат сжатия образа ZSTD. Gzip старый формат и Facebook придумали алгоритмы, как все это дело оптимизировать. Билдим образ через buildx с форматом сжатия zstd и вуаля - образ экстрактится в разы быстрее.
say_TT_plz
я не настоящий девопсер, но есть же kraken или dragonfly - p2p распространение докер образов. Хотя это не решит проблему высокой нагрузки на диск при pull, только ускорит само скачивание образов. С другой стороны у dragonfly тоже есть прогрев, но прогреет он только кэш самого dragonfly, а не кэш докера.
kksudo Автор
Спасибо за комментарий. У AWS есть более нативные методы, которые по некоторым причинам не подошли, ссылки на них в конце текста.
Про dragonfly почитаю, спасибо )
Проблемы с самим скачиванием образов нет, внутри AWS используются VPC endpoints, что означает что трафик даже в публичную сеть не выходит.
Тут больше про то, что много больших образов в один момент делают пулл и что самое важное, после этого делают операцию разархивирования (и вот тут начинаются проблемы с системными daemon set, да и в целом с нодой). А т.к. изначально нет кеша, то все образы выкачиваются полностью в один момент времени (
JuriM
Есть еще https://github.com/spegel-org/spegel