Привет! Меня зовут Антон Жуков, я руковожу группой разработки в Сбермаркете. В профессии я уже более 12 лет, с Golang работаю с 2016 года, а с Kubernetes — с 2018 года.  В этой статье расскажу об основах Kubernetes, возможных проблемах и решениях, а также о том, как грамотно использовать ресурсы этой платформы, чтобы выжать максимум из Go-приложений. Кроме того, в конце статьи я опишу кейс настройки GOMAXPROCS на примере нашего приложения и расскажу, как нам удалось повысить его производительность на 20-50%. Проблемы и вызовы Kubernetes Под капотом Kubernetes: CFS (Completely Fair Scheduler) Goroutines Cgroups Requests & Limits в Kubernetes CFS Bandwidth Control Наш опыт Go-приложений в больших Kubernetes-кластерах Пример работы с GOMAXPROCS из опыта СберМаркета Рекомендации по оптимизации ресурсов Kubernetes для Go-приложений Kubernetes — отличный инструмент, который создает абстракцию над физическими и виртуальными машинами. Он помогает нам не думать о том, сколько у нас запущено машин и какие ресурсы какой машины задействованы. Есть просто совокупная мощность, которую мы можем использовать под наше приложение.  Выкатывается приложение буквально одной командой kubectl apply, что очень удобно. Плюс, можно масштабировать как индивидуальное приложение — с помощью HPA (Horizontal Pod Autoscaling), так и сам кластер, — с помощью CA (Cluster Autoscaling), увеличивая количество нод по мере потребности. Звучит все это очень круто, но иногда Kubernetes — это проблемы с производительностью приложения: CPU Throttling приложений;  иногда поды выселяются с одной ноды на другую; порой мы видим зашкаливающие 99 персентиль по длительности ответа и всплески, которым не находим объяснения.  А ещё случается ряд проблем с кластером:  непредсказуемое масштабирование кластера; или, наоборот, из живых примеров, когда кластер использует 20-25% имеющихся ресурсов процессора и памяти нод. А за пустующие 75-80% мы все равно платим. Что же со всем этим делать? Давайте разберемся, как Kubernetes управляет ресурсами через Requests & Limits, и как те настройки, которые мы выставим, будут влиять на утилизацию ресурсов. А в завершение расскажу, как мы тюнили наше приложение в Сбермаркете и в результате улучшили 99 percentile длительности ответа gRPC-сервера на 20%, CPU Usage на 33% и длительность Go's Garbage Collection на 50%. Для начала расскажу об основах, потому что Kubernetes — операционная система, а внутри этой операционной системы есть определенные механизмы, которые помогают построить кластер. Знание этих основ поможет лучше понять, что такое Requests и Limits и как они применяются на уровне операционной системы —  в нашем случае на уровне Linux. Начнем с CFS.  Если вы хорошо знаете теорию и хотите посмотреть на наш практический опыт, можно сразу перескочить на этот раздел. Давайте представим ситуацию, что у нас есть компьютер с одним процессором, как это было когда-то в прошлом. И этот компьютер исполняет только одно приложение в один момент времени. Допустим, вы открываете чатик, начинаете что-то писать и только после того, как вы написали, у вас загружается переписка с другом. Или, например, часы тикают и занимают полностью все процессорное время.  Представить такое, конечно, очень сложно. Современные процессоры — это всегда многозадачность, многопоточность. И CFS (Completely Fair Scheduler) — это как раз тот алгоритм, который реализует многозадачность в рамках одного процессора, (поскольку один процессор всегда может исполнять только одну задачу в один момент времени). CFS идеально точно моделирует многозадачность процессора. Он может выполнять все задачи с равной скоростью.  Ниже на схеме — шкала времени и два примера распределения задач. Первый пример — распределение двух задач, второй — четырех. Мы видим, что в обоих случаях выделен равный отрезок по времени, и задачи равномерно распределены по шкале времени и работают одновременно. Таким образом осуществляется многозадачность, которая глазу не видна, но есть на уровне процессора за счет механизма CFS. Чтобы осуществлять многозадачность, CFS использует бинарное дерево, которое упорядочено по virtual runtime. Virtual runtime — это время, которое уже было затрачено процессором на исполнение данной задачи. На схеме ниже в левой части дерева отображена задача с минимальным количеством virtual runtime, в правой — с максимальным количеством virtual runtime.  CFS берет задачи, которые были исполнены на процессоре  минимальное количество времени, вытаскивает их из левой части дерева, исполняет на процессоре и потом возвращает в правую часть дерева. Таким образом происходит движение задач по дереву и равномерное по длительности исполнение каждой задачи. Теперь попробуем нарисовать общую картину, как CFS исполняет наши задачи в рамках приложения на Go. Для этого напомню, как работают Goroutines. Каждый раз, когда вы пишете go funс() с вашим кодом внутри, ваша Goroutine попадает в глобальную очередь. Как мы знаем, в Go есть планировщик (Go scheduler), который занимается тем, что растаскивает ваши Goroutines из глобальной очереди в локальные очереди по каждому треду операционной системы.  На схеме ниже — пример с двумя процессорами, на каждом из них есть два активных треда. Также видим, что есть локальные очереди. Задача планировщика — равномерно распределять Goroutines между этими активными тредами. Серые «М» — это заблокированные треды операционной системы, которые на данный момент активно не участвуют в работе процессора.  Теперь давайте посмотрим, как все это связано. На следующей схеме показан уровень абстракции сверху вниз. У нас есть Goroutines, есть планировщик. Планировщик приземляет наши Goroutines на потоки. CFS, в свою очередь, приземляет треды на процессоры. Вот, как в конечном итоге Goroutines обрабатываются процессором.  А что если мы не хотим, чтобы все процессы в нашей системе имели одинаковый приоритет? Для таких случаев был придуман прекрасный механизм под названием Cgroups. Cgroups — это механизм для организации процессов в иерархию и контролируемого распределения ресурсов по этой иерархии в соответствии с конфигурацией. Эта иерархия представляет из себя дерево. В нём в каждой группе выделяется какое-то количество ресурсов, которыми эта группа может пользоваться.  Механизм Сgroups — это основа для контейнеризации приложений (docker, containerd и т.д.). Чаще всего все наши контейнеры построены на основе Cgroups. Память, процессор, сеть и диск — это как раз те ресурсы, которые мы можем распределять между группами и четко контролировать использование ресурсов несколькими процессами. Теперь, ознакомившись с основой, перейдем к более детальному разбору Kubernetes, в частности, Requests & Limits. Итак, теперь более детально разберем настройки, которыми мы пользуемся каждый день.  У каждой ноды в кластере есть Capacity по каждому типу ресурса — CPU, память. Планировщик запускает под на ноде и вычитает Requests этого пода из общей Capacity ноды. У ноды всегда есть какая-то остаточная Capacity.  Важно понимать, что планировщик не будет размещать под с Requests, которые превышают этот остаточный Capacity на ноде. Соответственно, если Capacity на одной ноде не хватает, значит планировщик пойдет на другую ноду. Если свободных нод не найдено, тогда планировщик с помощью механизма Cluster autoscaling создаст новую ноду и там разместит под. Requests & Limits измеряются в CPU units или в millocores. 1 CPU = 1000 millocores. Такая абстракция применяется в любом облаке: 1 AWS vCPU; 1 GCP Core; 1 Azure vCore; 1 Hyperthread on a bare-metal Intel processor with Hyperthreading. CPU Requests применяются для того, чтобы приоритизировать процессорное время при стопроцентной загрузке нормы. Представим ситуацию, что нода загружена процессором полностью, на сто процентов, и нам нужно понимать, какому контейнеру выделить большее количество ресурсов, а какому — меньшее. Для этого есть технология, тоже из Cgroups, которая называется CPU Shares. CPU Shares — это механизм приоритезации процессорного времени в Cgroups. Когда процессор загружен на 100%, Cgroups понимает, как на основе выделенных контейнерам весов он будет распределять процессорное время. Это значит, что с помощью CPU Requests можно выдавать каким-то приложениям больший приоритет, а каким-то — меньший. При стопроцентной загрузке процессора можно понять, кому выделяются какие пропорции процессорного времени.  Контейнеру гарантированы ресурсы, запрошенные в Requests. Нода обещает вам, что у нее всегда будут доступны те ресурсы, которые вы запросили изначально. В то же время, контейнер может получить или не получить ресурсы сверх запрошенного. И при стопроцентной загрузке процессорное время будет распределяться пропорционально Requests. Что, если мы указали Requests, а реальное потребление ниже выставленных Requests?  Это значит, что CPU и память на ноде не будут заняты эффективно. Реальное потребление меньше, а платим мы за весь ресурс ноды целиком. Таким образом,  будет простой ресурсов на ноде и мы будем платить лишние деньги за кластер. Соответственно, деньги уходят впустую.  Что, если реальное потребление выше выставленных Requests?  Такая ситуация может привести к замедлению производительности приложения. Нода не будет справляться с нагрузкой, будут постоянно переселятся поды. Например, достигаются пороги памяти (MemoryPressure) и нода начинает расселять поды по другим нодам. В результате может произойти непредсказуемое масштабирование кластера (Cluster Autoscaler). В таком случае мы не знаем точно, когда кластер начнет масштабироваться, не можем планировать наши ресурсы и затраты.  Что, если реальное потребление соответствует Requests? Это означает что CPU, память заняты эффективно, что мы используем те ресурсы, за которые платим, ноды заняты постоянно и нет простоя. Соответственно, в такой ситуации можно увидеть больше взаимосвязи между тем, как растет нагрузка на кластер, и тем, как масштабируется кластер. Становится возможно применять бюджетирование и планировать, например, на ближайшие полгода, каковы будут затраты на кластер. Все становится более предсказуемым.  Подводя итог, Request нужны для учета и планирования ресурсов ноды. При стопроцентной утилизации CPU: Requests нужны для приоритизации процессорного времени; при стопроцентной утилизации Memory — для расчета, в каком порядке планировщик будет останавливать контейнеры. По умолчанию, когда мы запускаем контейнеры в Kubernetes, то у пода нет каких-либо ограничений. Он может использовать все доступные ресурсы на ноде — CPU, память. Но если выставить Limits, то при достижении потолка по CPU, контейнер будет тротлиться. Это значит, что процессорное время не будет выделяться контейнеру и он будет простаивать. А при достижении лимитов по памяти, Kubernetes будет полностью останавливать контейнер (OOMKilled).  Углубляясь в реализацию CPU Limits, увидим, что Kubernetes использует механизм CFS Bandwidth Control. CFS Bandwidth Control — это механизм, который позволяет определять потолок CPU, используемого группой (Cgroup). Механизм заключается в том, что Cgroup выдается квота (Quota) процессорного времени за определенный отрезок времени (Period). Когда вся квота использована, потоки в группе ограничиваются, или тротлятся (Throttle), что значит, что они не будут исполняться на процессоре остаток этого периода (Period). Как только исчерпана квота за один период, нужно ждать следующего периода, когда можно будет пользоваться новой квотой.  Объясню на простом примере конфигурации CFS. Если нужно выделить один процессор, то мы выделяем квоту, равную периоду: 250ms, 250ms. Если мы хотим взять 2 процессора, то мы выделяем квоту в два раза больше — 1000ms квота и 500ms период. 1000 делим на 500, получается 2 CPU. Если же мы хотим взять 20% от одного CPU, берём значение квоты в пять раз меньше периода, — квота 10ms и период 50ms. Важно понимать, что ресурс CPU, если мы говорим про процессор в целом, может выделяться сверх Requests, а может и отниматься. Процессорное время сжимаемое (анг. compressible), что значит оно может как выдаваться чуть больше, так и в следующие периоды забираться у контейнера.  Что же касается памяти, то выделенная память, которую уже дали контейнеру, нельзя частично забрать. Так, при достижении лимитов, например, контейнер полностью останавливается с OOMKilled, потому что у Kubernetes нет другого варианта ограничивать приложение по памяти. На потребление памяти нельзя повлиять иначе, — она или выдается полностью приложению или полностью забирается.  Таким образом, Limits нужны для выставления жестких порогов утилизации ресурсов контейнерами. При достижении этих порогов по процессору контейнер будет тротлиться, а по достижению лимитов по памяти контейнер будет останавливаться.  Теперь я могу более предметно рассказать о наших Go-приложениях и о том, как они себя ведут в больших Kubernetes-кластерах.  Есть переменная GOMAXPROCS — это количество активных тредов, которые может использовать Go-приложение. Обычно, когда мы запускаем наше Go-приложение в облаке, то наши облачные серверы имеют десятки или сотни CPU. По умолчанию, если мы никак не настраиваем наше приложение, оно будет использовать все доступные и, соответственно, на каждом CPU будет создавать тред операционной системы.  Но возникает проблема: когда наше Go-приложение использует все доступные CPU, это может вызывать высокие накладные расходы на переключение контекста CPU.  Когда создается слишком много тредов, и Go-планировщик начинает по всем этим тредам раскладывать Goroutines, получается, так что для исполнения всех задач контекст процессора постоянно переключается. Треды живут на разных процессорах, процессоры физически находятся на материнской плате в разных местах. Соответственно, взаимодействие этих процессоров может быть где-то медленнее, где-то быстрее. Возникает проблема высоких накладных расходов при переключении контекста на процессорах (Context Switching).  В то же время если мы вдруг захотим ограничивать наше приложение с низкими CPU Limits, это значит, что эти CPU Limits будут размазываться по всем ядрам. CPU Quota будет делиться на все процессоры, на каждом процессоре будет выделена слишком маленькая гранулярная квота, и это может привести к тротлингу приложения.  Если GOMAXPROCS сильно превышает CPU Limits контейнера, то может наблюдаться тротлинг. Мы тоже с этим столкнулись. Есть наблюдение, что если выставить GOMAXPROCS с равным CPU Limits, то вероятность CPU тротлинга минимальная. Получается, что если, например, ваше приложение использует 4 треда операционной системы и в то же время вы ограничиваете контейнер CPU-лимитами до 4 с помощью CFS, — это идеальная ситуация. Так можно достичь пика производительности.  Для того чтобы помочь все это настраивать, есть простейшая в использовании библиотека Automaxprocs от сотрудников компании Uber. Библиотека позволяет привязывать GOMAXPROCS к Container CPU Limit. Рекомендуется выставлять целые числа в CPU Limit, потому что эти целые числа тут же транслируются в число в GOMAXPROCS.  В моей практике была похожая ситуация. У нас было приложение, которое крутилось без выставленных лимитов в облаке и использовало полностью все процессоры облака.  Мы сейчас используем серверы по 80 ядер. Мы провели множество тестов с различными числами GOMAXPROCS, CPU Limit. В результате мы выявили очевидное преимущество работы с GOMAXPROCS=4, CPU Limit=4 по сравнению с изначальной настройкой, когда у нас не было выставлено лимитов и использовались все процессоры. На графике ниже видно, чего нам удалось добиться. В левой части — GOMAXPROCS=80, в правой части — GOMAXPROCS=4. Деплой осуществляется с помощью канарейки, поэтому он занимает некоторое длительное время. Но уже видна разница на 50-м персентиле, что Duration падает с 1,83мс до 1,73мс. Это рост производительности примерно на 20%.  Еще лучшую картину мы наблюдаем на 90-м персинтиле (график ниже). В левой части графика видны пики, а после деплоя график выравнивается. Это означает, что Go-приложение, используя меньшее количество ядер и меньшее количество тредов на сервере, может более эффективно переключать Goroutines между этими тредами.  Если посмотреть на использование процессорного времени, то здесь еще более интересные результаты: по сравнению со 120% использования CPU каждым подом мы увидели падение до 80%. И опять же видно выравнивание графика. То есть Go-приложение работает более предсказуемо, нет каких-то пиков, мы понимаем, как оно работает с нагрузкой, можем лучше прогнозировать, например, как будет расти CPU Usage при росте нагрузки. И еще один график (на рисунке ниже) показывает длительность GC (Garbage Collection). Здесь показатель Duration тоже падает разительно, в два раза, — со 170 микросекунд до 80 микросекунд. Положительное изменение видно на графиках по всем персентилям: 50, 90, 99. Результаты всех наших тестов с GOMAXPROCS и Limits показали, что выставляя правильные значения (для нас правильные — GOMAXPROCS=4 и CPU Limit=4), мы улучшили 90-й персентиль длительности нашего GRPC-сервера на 20%, потребление CPU — на 33% и GC — на 50%. В целом наше приложение стало более предсказуемым, графики выровнялись и мы можем эффективнее выявлять потребление ресурсов относительно той нагрузки, которая дается на наш сервис.  Подводя итоги, GOMAXPROCS определяет, сколько потоков приложение будет использовать, и, выставляя оптимальное значение этой переменной, он помогает снизить накладные расходы на переключение контекста и улучшить графики. Библиотека Automaxprocs привязывает GOMAXPROCS к CPU Limits и помогает снизить переключение контекстов, и, соответственно, сократить тротлинг приложений. На основе нашего опыта с Kubernetes и GOMAXPROCS собрали небольшой чек-лист, которому рекомендуем следовать. Оптимизируем ресурсы Kubernetes: ✅ Всегда определяйте CPU, Memory Requests. Это нужно для более эффективного использования ресурсов. ✅ CPU Limits используйте в связке с GOMAXPROCS. Всегда нужно контролировать, сколько CPU Limits, сколько GOMAXPROCS сейчас приложение использует. ✅ Memory Limits выставляйте равными Requests. Это поможет избежать выселений подов с одной ноды на другую. ✅ Мониторьте CPU Throttling ваших контейнеров. Это поможет выявлять проблемы с производительностью на ранних стадиях. Для Go-приложений: ✅ Всегда контролируйте значение GOMAXPROCS ✅ Оптимальный GOMAXPROCS всегда зависит от вашего контекста, у каждого приложения свой профиль нагрузки ✅ Пробуйте разные значения, деплойте их на проде, пока не найдете оптимальную комбинацию. Во время эскпериментов, смотрите и анализируйте графики по: — Server Latency (50, 90, 99, 99.9 персентили) — CPU Usage — GC Duration Выставляя оптимальные значения, мы оптимизируем бюджет на Kubernetes и делаем его более предсказуемым, а также повышаем производительность Go приложений в целом. Буду рад, если материал окажется полезным и получится применить на практике. Если возникннут вопросы, стучитесь в комментариях. Tech-команда СберМаркета ведет соцсети с новостями и анонсами. Если хочешь узнать, что под капотом высоконагруженного e-commerce, следи за нами в Telegram и на  YouTube. А также слушай подкаст «Для tech и этих» от наших it-менеджеров.  Разработчик Golang, Тимлид