Typically, there is always a need to provide a dedicated pool of resources for an application to ensure its proper and stable operation. But what if several applications are running on the same infrastructure at the same time? How can we ensure that each of them has the minimally necessary resources? How can resource consumption be limited? How can the load be distributed between nodes? How can horizontal scaling be managed in case of increased load on the applications?

We should start by identifying the main types of resources present in the system — specifically, CPU time and RAM. In k8s manifests, these resource types are measured in the following units:
- CPU — in cores
- RAM — in bytes
Moreover, for each resource, there is an option to set two types of requests — requests and limits. Requests describe the minimum resource requirements of a node to run a container (and the pod as a whole), while limits impose a strict cap on the resources available to the container.
It is important to understand that in the manifest it is not necessary to explicitly define both types, and the behavior will be as follows:
- If only limits for a resource are explicitly defined, then requests for that resource will automatically take on the value equal to limits (this can be confirmed by calling the describe function on the entity). That is, the actual operation of the container will be limited to the same amount of resources that it requires for its start.
- If only requests for a resource are explicitly set, then there are no upper limits imposed on that resource — meaning the container is limited only by the resources of the node itself.
There is also an option to configure resource management not only at the container level but also at the namespace level using the following entities:
- LimitRange — describes the resource limitation policy at the container/pod level in the namespace and is needed to outline default limits for the container/pod, as well as to prevent the creation of excessively large containers/pods (or vice versa), limit their quantity, and define possible variations in limits and requests.
- ResourceQuotas – describe the resource quota policy generally across all containers in the namespace and is typically used to allocate resources across environments (useful when environments are not strictly isolated at the node level)
Below are examples of manifests where resource limits are set:
At the level of a specific container:
containers: - name: app-nginx image: nginx resources: requests: memory: 1Gi limits: cpu: 200mThat is, in this case, to run a container with nginx, at least 1Gi of memory and 0.2 CPU must be available on the node, while the maximum that the container can consume is 0.2 CPU and all available memory on the node.
At the namespace level:
apiVersion: v1 kind: ResourceQuota metadata: name: nxs-test spec: hard: requests.cpu: 300m requests.memory: 1Gi limits.cpu: 700m limits.memory: 2GiThat is, the total of all container requests in the default namespace cannot exceed 300m for CPU and 1Gi for memory, while the total of all limits cannot exceed 700m for CPU and 2Gi for memory.
Default limits for containers in the namespace:
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: 4GiThat is, in the default namespace, all containers will have a default request of 100m for CPU and 1Gi for memory, and a limit of 1 CPU and 2Gi. There is also a limit on the possible values in request/limit for CPU (50m < x < 2) and RAM (500Mi < x < 4Gi).
Limits at the pod level in the namespace:
apiVersion: v1 kind: LimitRange metadata: name: nxs-limit-pod spec: limits: - type: Pod max: cpu: 4 memory: 1GiThat is, for each pod in the default namespace, a limit of 4 vCPU and 1Gi will be set.
Now, I would like to discuss the advantages that setting these limits can provide.
The load balancing mechanism between nodes
As is known, the distribution of pods across nodes is handled by a k8s component called scheduler, which operates according to a specific algorithm. This algorithm goes through two stages in choosing the optimal node for launching:
- Filtering
- Ranking
That is, according to the described policy, initially nodes are selected where the pod can be launched based on a set of predicates (including checking whether there are enough resources on the node to launch the pod — PodFitsResources), and then for each of these nodes, according to priorities points are awarded (the more available resources the node has, the more points it is assigned — LeastResourceAllocation/LeastRequestedPriority/BalancedResourceAllocation) and it runs on the node with the highest number of points (if multiple nodes meet this condition, one is selected at random).
It's important to understand that the scheduler evaluates the available resources of a node based on data stored in etcd — that is, on the sum of requested/limit resources of each pod running on that node, not on the actual resource consumption. This information can be obtained from the output of the command kubectl describe node $NODE, for example:
# 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%)Here we see all the pods running on the specific node, as well as the resources requested by each pod. Below is how the scheduler logs look when the pod cronjob-cron-events-1573793820-xt6q9 is launched (this information will appear in the scheduler log when the 10th level of logging is set in the command start arguments —v=10):
log
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-s10Here we see that the scheduler initially filters and forms a list of 3 nodes on which launching is possible (nxs-k8s-s8, nxs-k8s-s9, nxs-k8s-s10). It then counts the scores based on several parameters (including BalancedResourceAllocation, LeastResourceAllocation) for each of these nodes to determine the most suitable node. Ultimately, the pod is scheduled on the node with the highest score (in this case, two nodes have the same score of 100037, so one is selected randomly — nxs-k8s-s10).
OutputIf pods are running on the node without specified limits, then for k8s (in terms of resource consumption), this would be equivalent to the node having no such pods at all. Therefore, if you have a pod with a resource-intensive process (for example, wowza) and no limits set for it, a situation may arise where this pod consumes all the node's resources, while for k8s, this node is considered underutilized and will receive the same number of points during ranking (particularly in terms of the available resources evaluation) as a node with no running pods, which may ultimately lead to an uneven load distribution between nodes.
Pod eviction
As we know, each pod is assigned one of 3 QoS classes:
- guaranteed — assigned when each container in the pod has both a request and a limit set for memory and CPU, and these values must match
- burstable — at least one container in the pod has a request and limit defined, with the condition that request < limit
- best effort — when no container in the pod is limited in resources
When there is a resource shortage (disk, memory) on the node, the kubelet starts to rank and evict pods based on a specific algorithm that considers the pod's priority and its QoS class. For example, regarding RAM, points are awarded based on QoS class using the following principle:
- Guaranteed: -998
- BestEffort: 1000
- Burstable: min(max(2, 1000 - (1000 * memoryRequestBytes) / machineMemoryCapacityBytes), 999)
That is, with the same priority, the kubelet will first evict the pods with the best effort QoS class from the node.
Output: if you want to reduce the likelihood of evicting an important pod from the node in case of resource shortage, then alongside priority, you also need to ensure that request/limit are set for it.
Horizontal Pod Autoscaling Mechanism (HPA)
When the task is to automatically increase or decrease the number of pods depending on resource usage (system resources — CPU/RAM or user resources — rps), such an entity in k8s can help: HPA (Horizontal Pod Autoscaler). Its algorithm is as follows:
- The current readings of the observed resource (currentMetricValue) are determined.
- Desired values for the resource (desiredMetricValue) are established, which for system resources are set using request.
- The current number of replicas (currentReplicas) is determined.
- The desired number of replicas (desiredReplicas) is calculated using the following formula:
desiredReplicas = [ currentReplicas * ( currentMetricValue / desiredMetricValue )]
Scaling will not occur when the ratio (currentMetricValue / desiredMetricValue) is close to 1 (the allowable error can be set by us, by default it is 0.1).
Let's consider the operation of HPA using the example of the app-test application (described as a Deployment), where it is necessary to change the number of replicas depending on CPU consumption:
Application Manifest
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-statusThat is, we see that the pod with the application is initially launched in two instances, each containing two containers, nginx and nginx-exporter, for each of which the requests for CPU.
HPA Manifest
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: 30That is, we have created an HPA that will monitor the Deployment app-test and regulate the number of pods with the application based on the CPU metric (we expect that the pod should consume 30% of the requested CPU), while the number of replicas is in the range of 2-10.
Now, let's consider how the HPA mechanism works if we load one of the pods:
# kubectl top pod NAME CPU(cores) MEMORY(bytes) app-test-78559f8f44-pgs58 101m 243Mi app-test-78559f8f44-cj4jz 4m 240Mi
In total, we have the following:
- The desired value (desiredMetricValue) — according to hpa settings, is set to 30%.
- The current value (currentMetricValue) — for calculation, the controller-manager computes the average resource consumption in %, i.e., it performs the following:
- It obtains the absolute metric values of the pods from the metric server, i.e., 101m and 4m.
- It calculates the average absolute value, i.e., (101m + 4m) / 2 = 53m.
- It retrieves the absolute value for the desired resource consumption (by summing the requests of all containers) 60m + 30m = 90m.
- It computes the average percentage of CPU consumption relative to the pod's requests, i.e., 53m / 90m * 100% = 59%.
Now we have everything needed to determine if we should change the number of replicas, for this we calculate the ratio:
ratio = 59% / 30% = 1.96.
This means that the number of replicas should be increased by about 2 times to [2 * 1.96] = 4.
Output: As can be noted, for this mechanism to work, it is also necessary to have requests for all containers in the observed pod.
The horizontal node autoscaling mechanism (Cluster Autoscaler)
To mitigate the negative impact on the system during load spikes, having a configured hpa may not be sufficient. For example, according to hpa settings, the controller manager decides that the number of replicas needs to be doubled, but there are no available resources on the nodes to run that number of pods (i.e., the node cannot provide the requested pod resources). Therefore, these pods enter a Pending state.
In this case, if the provider has the relevant IaaS/PaaS (e.g., GKE/GCE, AKS, EKS, etc.), a tool like Node Autoscalercan assist. It allows you to set a maximum and minimum number of nodes in the cluster and automatically adjust the current number of nodes (by calling the cloud provider's API to add/remove a node) when resource shortages are observed in the cluster and pods cannot be scheduled (they are in a Pending state).
Output: To enable node autoscaling, it is necessary to specify requests in the pod containers so that k8s can accurately assess the load on the nodes and appropriately report that there are insufficient resources in the cluster to launch the next pod.
Conclusion
It should be noted that setting resource limits for a container is not a strict requirement for the successful launch of an application; however, it is still advisable for the following reasons:
- To improve the accuracy of the scheduler in balancing the load between k8s nodes
- To reduce the likelihood of an eviction event for the pod
- To enable horizontal pod autoscaling (HPA) for the application
- To facilitate horizontal node autoscaling (Cluster Autoscaling) for cloud providers
Also, read other articles in our blog:
Source: habr.com
