Как правило, всегда возникает необходимость обеспечить выделенный пул ресурсов какому-либо приложению для его корректной и стабильной работы. Но что, если на одних и тех же мощностях работают сразу несколько приложений? Как обеспечить минимально необходимыми ресурсами каждое из них? Каким образом можно ограничить потребление ресурсов? Как грамотно распределить нагрузку между нодами? Как обеспечить работу механизма горизонтального масштабирования в случае роста нагрузки на приложения?
Начать нужно с того, какие основные типы ресурсов существуют в системе — это, конечно же, процессорное время и оперативная память. В манифестах k8s эти типы ресурсов измеряются в следующих единицах:
- CPU — в ядрах
- RAM — в байтах
Причем для каждого ресурса есть возможность задавать два типа требований — requests и limits. Requests — описывает минимальные требования к свободным ресурсам ноды для запуска контейнера (и пода в целом), в то время как limits устанавливает жесткое ограничение ресурсов, доступных контейнеру.
Важно понимать, что в манифесте не обязательно явно определять оба типа, при этом поведение будет следующим:
- Если явно задан только limits ресурса, то requests для этого ресурса автоматически принимает значение, равное limits (в этом можно убедиться, вызвав describe сущности). Т.е. фактически работа контейнера будет ограничена таким же количеством ресурсов, которое он требует для своего запуска.
- Если для ресурса явно задан только requests, то никаких ограничений сверху на этот ресурс не задается — т.е. контейнер ограничен только ресурсами самой ноды.
Также существует возможность настроить управление ресурсами не только на уровне конкретного контейнера, но и на уровне namespace при помощи следующих сущностей:
- LimitRange — описывает политику ограничения на уровне контейнера/пода в ns и нужна для того, чтобы описать дефолтные ограничения на контейнер/под, а также предотвращать создание заведомо жирных контейнеров/подов (или наоборот), ограничивать их количество и определять возможную разницу значений в limits и requests
- ResourceQuotas — описывают политику ограничения в целом по всем контейнерам в ns и используется, как правило, для разграничения ресурсов по окружениям (полезно, когда среды жестко не разграничены на уровне нод)
Ниже приведены примеры манифестов, где устанавливаются ограничения на ресурсы:
На уровне конкретного контейнера:
containers: - name: app-nginx image: nginx resources: requests: memory: 1Gi limits: cpu: 200m
Т.е. в данном случае для запуска контейнера с nginx потребуется как минимум наличие свободных 1G ОП и 0.2 CPU на ноде, при этом максимум контейнер может съесть 0.2 CPU и всю доступную ОП на ноде.
На уровне целого ns:
apiVersion: v1 kind: ResourceQuota metadata: name: nxs-test spec: hard: requests.cpu: 300m requests.memory: 1Gi limits.cpu: 700m limits.memory: 2Gi
Т.е. сумма всех request контейнеров в дефолтном ns не может превышать 300m для CPU и 1G для ОП, а сумма всех limit — 700m для CPU и 2G для ОП.
Дефолтные ограничения для контейнеров в ns:
apiVersion: v1 kind: LimitRange metadata: name: nxs-limit-per-container spec: limits: - type: Container defaultRequest: cpu: 100m memory: 1Gi default: cpu: 1 memory: 2Gi min: cpu: 50m memory: 500Mi max: cpu: 2 memory: 4Gi
Т.е. в дефолтном namespace для всех контейнеров по умолчанию будет установлен request в 100m для CPU и 1G для ОП, limit — 1 CPU и 2G. При этом также установлено ограничение на возможные значение в request/limit для CPU (50m < x < 2) и RAM (500M < x < 4G).
Ограничения на уровне подов ns:
apiVersion: v1 kind: LimitRange metadata: name: nxs-limit-pod spec: limits: - type: Pod max: cpu: 4 memory: 1Gi
Т.е. для каждого пода в дефолтном ns будет установлено ограничение в 4 vCPU и 1G.
Теперь хотелось бы рассказать какие преимущества может нам дать установка данных ограничений.
Механизм балансировки нагрузки между нодами
Как известно, за распределение подов по нодам отвечает такой компонент k8s, как scheduler, который работает по определенному алгоритму. Этот алгоритм в ходе выбора оптимального узла для запуска проходит две стадии:
- Фильтрация
- Ранжирование
Т.е. согласно описанной политике изначально выбираются ноды, на которых возможен запуск пода на основе набора predicates (в том числе проверяется достаточно ли у ноды ресурсов для запуска пода — PodFitsResources), а затем для каждой из этих нод, согласно priorities начисляются очки (в том числе, чем больше свободных ресурсов у ноды — тем больше очков ей присваивается — LeastResourceAllocation/LeastRequestedPriority/BalancedResourceAllocation) и под запускается на ноде с наибольшим количеством очков (если этому условию удовлетворяют сразу несколько нод, то выбирается случайная из них).
При этом нужно понимать, что scheduler при оценке доступных ресурсов ноды ориентируется на данные, которые хранятся в etcd — т.е. на сумму requested/limit ресурса каждого пода, запущенного на этой ноде, но не на фактическое потребление ресурсов. Эту информацию можно получить в выводе команды kubectl describe node $NODE
, например:
# kubectl describe nodes nxs-k8s-s1
..
Non-terminated Pods: (9 in total)
Namespace Name CPU Requests CPU Limits Memory Requests Memory Limits AGE
--------- ---- ------------ ---------- --------------- ------------- ---
ingress-nginx nginx-ingress-controller-754b85bf44-qkt2t 0 (0%) 0 (0%) 0 (0%) 0 (0%) 233d
kube-system kube-flannel-26bl4 150m (0%) 300m (1%) 64M (0%) 500M (1%) 233d
kube-system kube-proxy-exporter-cb629 0 (0%) 0 (0%) 0 (0%) 0 (0%) 233d
kube-system kube-proxy-x9fsc 0 (0%) 0 (0%) 0 (0%) 0 (0%) 233d
kube-system nginx-proxy-k8s-worker-s1 25m (0%) 300m (1%) 32M (0%) 512M (1%) 233d
nxs-monitoring alertmanager-main-1 100m (0%) 100m (0%) 425Mi (1%) 25Mi (0%) 233d
nxs-logging filebeat-lmsmp 100m (0%) 0 (0%) 100Mi (0%) 200Mi (0%) 233d
nxs-monitoring node-exporter-v4gdq 112m (0%) 122m (0%) 200Mi (0%) 220Mi (0%) 233d
Allocated resources:
(Total limits may be over 100 percent, i.e., overcommitted.)
Resource Requests Limits
-------- -------- ------
cpu 487m (3%) 822m (5%)
memory 15856217600 (2%) 749976320 (3%)
ephemeral-storage 0 (0%) 0 (0%)
Тут мы видим все поды, запущенные на конкретной ноде, а также ресурсы, которые запрашивает каждый из подов. А вот как выглядят логи scheduler при запуске пода cronjob-cron-events-1573793820-xt6q9 (данная информация в логе scheduler появится при установке 10-го уровня логирования в аргументах команды запуска --v=10 ):
I1115 07:57:21.637791 1 scheduling_queue.go:908] About to try and schedule pod nxs-stage/cronjob-cron-events-1573793820-xt6q9
I1115 07:57:21.637804 1 scheduler.go:453] Attempting to schedule pod: nxs-stage/cronjob-cron-events-1573793820-xt6q9
I1115 07:57:21.638285 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s5 is allowed, Node is running only 16 out of 110 Pods.
I1115 07:57:21.638300 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s6 is allowed, Node is running only 20 out of 110 Pods.
I1115 07:57:21.638322 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s3 is allowed, Node is running only 20 out of 110 Pods.
I1115 07:57:21.638322 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s4 is allowed, Node is running only 17 out of 110 Pods.
I1115 07:57:21.638334 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s10 is allowed, Node is running only 16 out of 110 Pods.
I1115 07:57:21.638365 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s12 is allowed, Node is running only 9 out of 110 Pods.
I1115 07:57:21.638334 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s11 is allowed, Node is running only 11 out of 110 Pods.
I1115 07:57:21.638385 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s1 is allowed, Node is running only 19 out of 110 Pods.
I1115 07:57:21.638402 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s2 is allowed, Node is running only 21 out of 110 Pods.
I1115 07:57:21.638383 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s9 is allowed, Node is running only 16 out of 110 Pods.
I1115 07:57:21.638335 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s8 is allowed, Node is running only 18 out of 110 Pods.
I1115 07:57:21.638408 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s13 is allowed, Node is running only 8 out of 110 Pods.
I1115 07:57:21.638478 1 predicates.go:1369] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s10 is allowed, existing pods anti-affinity terms satisfied.
I1115 07:57:21.638505 1 predicates.go:1369] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s8 is allowed, existing pods anti-affinity terms satisfied.
I1115 07:57:21.638577 1 predicates.go:1369] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s9 is allowed, existing pods anti-affinity terms satisfied.
I1115 07:57:21.638583 1 predicates.go:829] Schedule Pod nxs-stage/cronjob-cron-events-1573793820-xt6q9 on Node nxs-k8s-s7 is allowed, Node is running only 25 out of 110 Pods.
I1115 07:57:21.638932 1 resource_allocation.go:78] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s10: BalancedResourceAllocation, capacity 39900 millicores 66620178432 memory bytes, total request 2343 millicores 9640186880 memory bytes, score 9
I1115 07:57:21.638946 1 resource_allocation.go:78] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s10: LeastResourceAllocation, capacity 39900 millicores 66620178432 memory bytes, total request 2343 millicores 9640186880 memory bytes, score 8
I1115 07:57:21.638961 1 resource_allocation.go:78] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s9: BalancedResourceAllocation, capacity 39900 millicores 66620170240 memory bytes, total request 4107 millicores 11307422720 memory bytes, score 9
I1115 07:57:21.638971 1 resource_allocation.go:78] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s8: BalancedResourceAllocation, capacity 39900 millicores 66620178432 memory bytes, total request 5847 millicores 24333637120 memory bytes, score 7
I1115 07:57:21.638975 1 resource_allocation.go:78] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s9: LeastResourceAllocation, capacity 39900 millicores 66620170240 memory bytes, total request 4107 millicores 11307422720 memory bytes, score 8
I1115 07:57:21.638990 1 resource_allocation.go:78] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s8: LeastResourceAllocation, capacity 39900 millicores 66620178432 memory bytes, total request 5847 millicores 24333637120 memory bytes, score 7
I1115 07:57:21.639022 1 generic_scheduler.go:726] cronjob-cron-events-1573793820-xt6q9_nxs-stage -> nxs-k8s-s10: TaintTolerationPriority, Score: (10)
I1115 07:57:21.639030 1 generic_scheduler.go:726] cronjob-cron-events-1573793820-xt6q9_nxs-stage -> nxs-k8s-s8: TaintTolerationPriority, Score: (10)
I1115 07:57:21.639034 1 generic_scheduler.go:726] cronjob-cron-events-1573793820-xt6q9_nxs-stage -> nxs-k8s-s9: TaintTolerationPriority, Score: (10)
I1115 07:57:21.639041 1 generic_scheduler.go:726] cronjob-cron-events-1573793820-xt6q9_nxs-stage -> nxs-k8s-s10: NodeAffinityPriority, Score: (0)
I1115 07:57:21.639053 1 generic_scheduler.go:726] cronjob-cron-events-1573793820-xt6q9_nxs-stage -> nxs-k8s-s8: NodeAffinityPriority, Score: (0)
I1115 07:57:21.639059 1 generic_scheduler.go:726] cronjob-cron-events-1573793820-xt6q9_nxs-stage -> nxs-k8s-s9: NodeAffinityPriority, Score: (0)
I1115 07:57:21.639061 1 interpod_affinity.go:237] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s10: InterPodAffinityPriority, Score: (0)
I1115 07:57:21.639063 1 selector_spreading.go:146] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s10: SelectorSpreadPriority, Score: (10)
I1115 07:57:21.639073 1 interpod_affinity.go:237] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s8: InterPodAffinityPriority, Score: (0)
I1115 07:57:21.639077 1 selector_spreading.go:146] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s8: SelectorSpreadPriority, Score: (10)
I1115 07:57:21.639085 1 interpod_affinity.go:237] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s9: InterPodAffinityPriority, Score: (0)
I1115 07:57:21.639088 1 selector_spreading.go:146] cronjob-cron-events-1573793820-xt6q9 -> nxs-k8s-s9: SelectorSpreadPriority, Score: (10)
I1115 07:57:21.639103 1 generic_scheduler.go:726] cronjob-cron-events-1573793820-xt6q9_nxs-stage -> nxs-k8s-s10: SelectorSpreadPriority, Score: (10)
I1115 07:57:21.639109 1 generic_scheduler.go:726] cronjob-cron-events-1573793820-xt6q9_nxs-stage -> nxs-k8s-s8: SelectorSpreadPriority, Score: (10)
I1115 07:57:21.639114 1 generic_scheduler.go:726] cronjob-cron-events-1573793820-xt6q9_nxs-stage -> nxs-k8s-s9: SelectorSpreadPriority, Score: (10)
I1115 07:57:21.639127 1 generic_scheduler.go:781] Host nxs-k8s-s10 => Score 100037
I1115 07:57:21.639150 1 generic_scheduler.go:781] Host nxs-k8s-s8 => Score 100034
I1115 07:57:21.639154 1 generic_scheduler.go:781] Host nxs-k8s-s9 => Score 100037
I1115 07:57:21.639267 1 scheduler_binder.go:269] AssumePodVolumes for pod "nxs-stage/cronjob-cron-events-1573793820-xt6q9", node "nxs-k8s-s10"
I1115 07:57:21.639286 1 scheduler_binder.go:279] AssumePodVolumes for pod "nxs-stage/cronjob-cron-events-1573793820-xt6q9", node "nxs-k8s-s10": all PVCs bound and nothing to do
I1115 07:57:21.639333 1 factory.go:733] Attempting to bind cronjob-cron-events-1573793820-xt6q9 to nxs-k8s-s10
Здесь мы видим, что изначально scheduler осуществляет фильтрацию и формирует список из 3-х нод, на которых возможен запуск (nxs-k8s-s8, nxs-k8s-s9, nxs-k8s-s10). Затем осуществляет подсчет очков по нескольким параметрам (в том числе BalancedResourceAllocation, LeastResourceAllocation) для каждой из этих нод с целью определить наиболее подходящий узел. В конечном итоге под планируется на ноде с наибольшим количеством очков (здесь сразу две ноды имеют одинаковое количество очков 100037, поэтому выбирается случайная из них — nxs-k8s-s10).
Вывод: если на ноде работают поды, для которых не заданы ограничения, то для k8s (с точки зрения потребления ресурсов) это будет равносильно тому, как если бы на этой ноде такие поды вообще отсутствовали. Поэтому, если у вас, условно, есть pod с прожорливым процессом (например, wowza) и для него не заданы ограничения, то может возникнуть такая ситуация, когда фактически данный под съел все ресурсы ноды, но при этом для k8s это нода считается ненагруженной и ей будут начисляться такое же количество очков при ранжировании (именно в пунктах с оценкой доступных ресурсов), как и ноде, на которой нет рабочих подов, что в конечном итоге может привести к неравномерному распределению нагрузки между нодами.
Выселение пода
Как известно — каждому поду присваивается один из 3 QoS-классов:
- guaranuted — назначается тогда, как для каждого контейнера в поде для memory и cpu задан request и limit, причем эти значения должны совпадать
- burstable — хотя бы один контейнер в поде имеет request и limit, при этом request < limit
- best effort — когда ни один контейнер в поде не ограничен по ресурсам
При этом, когда на ноде наблюдается нехватка ресурсов (диска, памяти), kubelet начинает ранжировать и выселять под’ы по определенному алгоритму, который учитывает приоритет пода и его QoS-класс. Например, если речь идет о RAM, то на основе QoS класса начисляются очки по следующему принципу:
- Guaranteed: -998
- BestEffort: 1000
- Burstable: min(max(2, 1000 — (1000 * memoryRequestBytes) / machineMemoryCapacityBytes), 999)
Т.е. при одинаковом приоритете, kubelet в первую очередь будет выселять с ноды поды с QoS-классом best effort.
Вывод: если вы хотите уменьшить вероятность выселения нужного пода с ноды в случае нехватки ресурсов на ней, то наравне с приоритетом необходимо также позаботиться и о задании request/limit для него.
Механизм горизонтального автомасштабирования подов приложения (HPA)
Когда стоит задача автоматически увеличивать и уменьшать количество pod в зависимости от использования ресурсов (системного — CPU/ RAM или пользовательского — rps) в её решении может помочь такая сущность k8s как HPA (Horizontal Pod Autoscaler). Алгоритм которого заключается в следующем:
- Определяются текущие показания наблюдаемого ресурса (currentMetricValue)
- Определяются желаемые значения для ресурса (desiredMetricValue), которые для системных ресурсов задаются при помощи request
- Определяется текущее количество реплик (currentReplicas)
- По следующей формуле рассчитывается желаемое количество реплик (desiredReplicas)
desiredReplicas = [ currentReplicas * ( currentMetricValue / desiredMetricValue )]
При этом масштабирования не произойдет, когда коэффициент (currentMetricValue / desiredMetricValue) близок к 1 (при этом допустимую погрешность мы можем задавать сами, по умолчанию она равна 0.1).
Рассмотрим работу hpa на примере приложения app-test (описанное как Deployment), где необходимо изменять количество реплик, в зависимости от потребления CPU:
Манифест приложения
kind: Deployment apiVersion: apps/v1beta2 metadata: name: app-test spec: selector: matchLabels: app: app-test replicas: 2 template: metadata: labels: app: app-test spec: containers: - name: nginx image: registry.nixys.ru/generic-images/nginx imagePullPolicy: Always resources: requests: cpu: 60m ports: - name: http containerPort: 80 - name: nginx-exporter image: nginx/nginx-prometheus-exporter resources: requests: cpu: 30m ports: - name: nginx-exporter containerPort: 9113 args: - -nginx.scrape-uri - http://127.0.0.1:80/nginx-status
Т.е. мы видим, что под с приложением изначально запускается в двух экземплярах, каждый из которых содержит два контейнера nginx и nginx-exporter, для каждого из которых задан requests для CPU.
Манифест HPA
apiVersion: autoscaling/v2beta2 kind: HorizontalPodAutoscaler metadata: name: app-test-hpa spec: maxReplicas: 10 minReplicas: 2 scaleTargetRef: apiVersion: extensions/v1beta1 kind: Deployment name: app-test metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 30
Т.е. мы создали hpa, который будет следить за Deployment app-test и регулировать количество подов с приложением на основе показателя cpu (мы ожидаем, что под должен потреблять 30% процентов от запрашиваемого им CPU), при этом количество реплик находится в промежутке 2-10.
Теперь, рассмотрим механизм работы hpa, если подать нагрузку на один из подов:
# kubectl top pod NAME CPU(cores) MEMORY(bytes) app-test-78559f8f44-pgs58 101m 243Mi app-test-78559f8f44-cj4jz 4m 240Mi
Итого имеем следующее:
- Желаемое значение (desiredMetricValue) — согласно настройкам hpa у нас равно 30%
- Текущее значение (currentMetricValue) — для рассчета controller-manager рассчитывает среднее значение потребления ресурса в %, т.е. условно делает следующее:
- Получает абсолютные значения метрик подов из metric-сервера, т.е. 101m и 4m
- Высчитывает среднее абсолютное значение, т.е. (101m + 4m ) / 2 = 53m
- Получает абсолютное значение для желаемого потребления ресурса (для этого суммируются request всех контейнеров) 60m + 30m = 90m
- Рассчитывает средний процент потребления CPU относительно request пода, т.е. 53m / 90m * 100% = 59%
Теперь у нас есть все необходимое для того, чтобы определить нужно ли изменять количество реплик, для этого рассчитываем коэффициент:
ratio = 59% / 30% = 1.96
Т.е. количество реплик должно быть увеличено в ~2 раза и составить [2 * 1.96] = 4.
Вывод: Как можно заметить, для того чтобы этот механизм работал необходимым условием является в том числе наличие requests для всех контейнеров в наблюдаемом поде.
Механизм горизонтального автомасштабирования нод (Cluster Autoscaler)
Для того, чтобы нивелировать негативное влияние на систему при всплесках нагрузки, наличие настроенного hpa бывает недостаточно. Например, согласно настройкам в hpa controller manager принимает решение о том, что количество реплик необходимо увеличить в 2 раза, однако на нодах нет свободных ресурсов для запуска такого количества подов (т.е. нода не может предоставить запрашиваемые ресурсы пода requests) и эти поды переходят в состояние Pending.
В этом случае, если у провайдера имеется соответствующий IaaS/PaaS (например, GKE/GCE, AKS, EKS и т.д.), нам может помочь такой инструмент, как Node Autoscaler. Он позволяет задать максимальное и минимальное количество нод в кластере и автоматически регулировать текущее количество нод (путем обращения к API облачного провайдера для заказа/удаления ноды), когда наблюдается нехватка ресурсов в кластере и поды не могут быть запланированы (находятся в состоянии Pending).
Вывод: для возможности автомасштабирования нод необходимо задавать requests в контейнерах подов, чтобы k8s мог правильно оценить нагруженность нод и соответственно сообщать, что для запуска очередного пода ресурсов в кластере нет.
Заключение
Следует заметить, что установка ограничений ресурсов контейнера не является обязательным условием для успешного запуска приложения, однако это все же лучше сделать по следующим причинам:
- Для более точной работы scheduler в части балансировки нагрузки между нодами k8s
- Для уменьшения вероятности возникновения события “выселение пода”
- Для работы горизонтального автомасштабирования подов приложения (HPA)
- Для работы горизонтального автомасштабирования нод (Cluster Autoscaling) у облачных провайдеров
Также читайте другие статьи в нашем блоге:
- Tekton Pipeline — Kubernetes-нативные pipelines
- Cборка динамических модулей для Nginx
- К чему привела миграция с ClickHouse без авторизации на ClickHouse с авторизацией
- Разбираемся с пакетом Context в Golang
- Три простых приема для уменьшения Docker-образов
- Резервное копирование большого количества разнородных web-проектов