"How to take control of your Kubernetes cluster with affinity and tolerations"
When working with Kubernetes clusters, it’s common to have applications that need to be on specific nodes. This is even more common when we have a series of nodes that are part of our cluster through Node Pools.
In this article we’ll learn to control our nodes with selectors, we’ll learn the concept of Node Pools and also the concept of Node Affinity. Finally we’ll understand the entire idea behind taints and tolerations. And with this, we’ll learn to have complete control of our cluster and where we want our applications.
Why do we need control?#
By default, Kubernetes has a scheduler that makes a very good separation of its pods and containers, for example, it will always try to make an equal distribution of all applications across the cluster, avoiding placing applications that need more resources than the node currently has.
In general, this is a good separation and a good strategy, however many times we have the need to have more than one type of machine for our applications, and that’s where we need to have the exact control of where we need to place our applications. For example, for Machine Learning applications, for instance, the best machines are those that have an integrated GPU, this way we can have more than one node pool with different machines.
However we can’t place all of our applications inside a node with GPU, because if we did we would exhaust the node without having the applications that really need it. In the same way, we can’t have only GPU nodes because those machines are very expensive.
So that’s where the concept of selectors comes in.
Node Selectors#
Every way of restricting an application to some node is called a node constraint. The simplest way to create a restriction is through a node selector. With this technique, you essentially force a Pod to be registered to run inside a specific node.
Like any other Kubernetes resource, nodes also allow grouping and tagging through labels. A label is a key and value pair that can be created of your choosing. It will be through these pairs that you will restrict an application.
Creating a label for a node#
When we create a node in a managed Kubernetes cluster, like AKS, we can see that the cloud itself already places some labels on these nodes, in addition to these, there are other known and common labels that are added to all nodes by default in a cluster.
We can get this information with the command kubectl describe nodes {name}, as we can see on this node in a cluster I created:
Labels: agentpool=nodepool1 beta.kubernetes.io/arch=amd64 beta.kubernetes.io/instance-type=Standard_B2s beta.kubernetes.io/os=linux failure-domain.beta.kubernetes.io/region=eastus failure-domain.beta.kubernetes.io/zone=0 kubernetes.azure.com/cluster=MC_keda_keda_eastus kubernetes.azure.com/mode=system kubernetes.azure.com/node-image-version=AKSUbuntu-1804-2021.01.06 kubernetes.azure.com/role=agent kubernetes.io/arch=amd64 kubernetes.io/hostname=aks-nodepool1-24389357-vmss000000 kubernetes.io/os=linux kubernetes.io/role=agent node-role.kubernetes.io/agent= node.kubernetes.io/instance-type=Standard_B2s storageprofile=managed storagetier=Premium_LRS topology.kubernetes.io/region=eastus topology.kubernetes.io/zone=0Let’s create a new label for this node, let’s say it has a GPU through a label processingtype=gpu, to do this we’ll use the label command:
kubectl label nodes {node name} processingtype=gpuIf we want to change a label that already exists we can add the
--overwriteflag with the label name, for example, if we want to change the value of theprocessingtypelabel to CPU we can runkubectl label nodes {name} processingtype=cpu --overwrite
It’s important to note that labels can only have one value per key, that is, we can’t have two keys processingtype with two different values.
Using Node Selectors#
Now that we have the application of labels on a node, let’s create a simple Pod.
apiVersion: v1kind: Podmetadata: name: nginx labels: env: testspec: containers: - name: nginx image: nginxNow we can add another key inside spec that specifies that we want a selector so that the application runs only on nodes that have the key processingtype with the value gpu:
apiVersion: v1kind: Podmetadata: name: nginx labels: env: testspec: containers: - name: nginx image: nginx nodeSelector: processingtype: gpuNow this application will always be sent to this node.
Node Affinity#
Node Affinity is another concept very close to what we just talked about as Node Selectors. The difference is the slightly more expressive syntax that allows you to create more complex selectors with models like label in (value, value) or label=value.
The main difference between the two is that we have another specific key for the affinity within a Pod spec. In this spec we’ll have two types of affinity:
requiredDuringSchedulingIgnoredDuringExecution: Think of this as a node selector, pods with this key will have to be on a node that is compatible with the descriptions of this affinity.preferredDuringSchedulngIgnoredDuringExecution: This is a softer version of the previous one. It says that, basically, the pod will try to run on a node with these labels, but if there isn’t one available, then it will run on another node.
Both can coexist on the same pod, and it’s important to pay attention to the IgnoredDuringExecution part, this means that, if a node loses a certain label that allows a series of pods to run on it, these pods continue to exist until they are recreated.
See an example of a Pod that will forcibly create the containers within zones 1 or 2 and will prefer Linux environments, but if there aren’t any, then they can be run on other OSes:
apiVersion: v1kind: Podmetadata: name: node-affinityspec: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: topology.kubernetes.io/zone operator: In values: - 1 - 2 preferredDuringSchedulingIgnoredDuringExecution: - weight: 1 preference: matchExpressions: - key: kubernetes.io/os operator: In values: - Linux containers: - name: affinity image: khaosdoctor/go-vote-apiYou can use the operators
In,NotIn,Exists,DoesNotExist,Gt,Lt. UsingNotInandDoesNotExistwe can achieve what is called anti-affinity, which is to repel a Pod from running on certain nodes
Weight#
The weight key is a quite interesting option because we can use the weight so that a node has more priority than others. What happens is that, when the scheduler goes through these rules, it will sum the weight of all rules for a node that are satisfied by the labels of this node. The nodes that get the highest sum will be preferred to have this container.
Taints and Tolerations#
We know that affinity is a property of a Pod that attracts this pod to a group of nodes. We also have the opposite of this property, another property that pushes Pods away from a group of nodes, we call these properties taints.
Taints are applied to nodes, so a node has one or more taints that will repel pods from being sent to it. On the other hand, we have tolerations that are applied to Pods. A pod that has a toleration that is compatible with a taint will be able to be scheduled to start on this node, otherwise it will be permanently repelled.
Think of taints and tolerations as a permanent way to repel Pods from nodes. While a NodeSelector or NodeAffinity work when you explicitly specify a series of labels, if you don’t specify these properties, the Pod will continue to be scheduled on another node. With a taint on a node, you repel permanently all pods that don’t have tolerations.
Remember: Taints and Tolerations work together, we can have one without the other, but they simply wouldn’t work. Every toleration needs a taint.
Creating a taint#
In the same way as we create labels, we can create taints with the command kubectl taint nodes {name}, but unlike labels, taints have an effect. Let’s look at an example:
kubectl taint node {name} processingtype=gpu:NoScheduleSee that we have the template key=value:effect, the NoSchedule effect will prevent any pod that doesn’t have a compatible toleration from being created on this node. Besides this effect, we have two others:
PreferNoSchedule: a soft version ofNoSchedule, the system will try to avoid any pod that doesn’t have a toleration to this from being created on this node.NoExecute: It’s an even more radical form, it means that if a taint of this type is added to a node, all Pods that don’t support this taint with a compatible toleration will be immediately removed from the node. This type of effect has another property calledtolerationSeconds, which says how long it will take for the node to be able to remove the pods that don’t have a compatible toleration.
Let’s look at examples of all this. First, let’s imagine we have 3 nodes, each will have a different taint:
kubectl taint nodes node1 processingtype=cpu:PreferNoSchedule && \kubectl taint nodes node2 processingtype=gpu:NoSchedule && \kubectl taint nodes node3 processingtype=tpu:NoExecuteA taint can have more than one effect at the same time, although that doesn’t make as much sense
What we have here are:
node1will allow the execution of pods inside it if there’s no kind of toleration on the pods. Since it’s a simple CPU nodenode2won’t allow any Pod that doesn’t have a compatible toleration to be executed, since we only want GPU pods running therenode3besides not allowing any kind of scheduling for it, will also automatically remove all pods that are already being executed that don’t have the given toleration. This is an expensive node since it uses TPUs, so we want maximum control over there
Tolerating Taints#
Now that we created the taint on our node, let’s create a series of pods that tolerate certain taints.
First let’s create the pod that tolerates the TPU taint:
apiVersion: v1kind: Podmetadata: name: tpu labels: env: testspec: containers: - name: tpu image: khaosdoctor/go-vote-api tolerations: - key: "processingtype" operator: "Equals" value: "tpu" effect: "NoExecute"Unlike an affinity, tolerations can have the operators
EqualsandExists
This Pod will be able to run on the node that contains TPUs. Now let’s do the same for the CPU and GPU setup:
apiVersion: v1kind: Podmetadata: name: gpu labels: env: testspec: containers: - name: gpu image: khaosdoctor/go-vote-api tolerations: - key: "processingtype" operator: "Equals" value: "gpu" effect: "NoSchedule"---apiVersion: v1kind: Podmetadata: name: cpu labels: env: testspec: containers: - name: cpu image: khaosdoctor/go-vote-api tolerations: - key: "processingtype" operator: "Equals" value: "cpu" effect: "PreferNoSchedule"Besides this, when we’re working with tolerations, the value key is not mandatory, we can have just a taint of key without needing a value.
For these two pods, we’ll have that they can be included on each of the nodes that they tolerate. However, a new Pod that is created without any toleration will probably be created on the CPU node.
Default taints#
When a Kubernetes node enters certain states, such as “no disk”, “no memory” or any other state that prevents it from having new Pods being scheduled for it, the system automatically adds default taints that remove Pods according to each of those taints.
You can create tolerations for these taints, for example, when a node becomes unreachable, we can tell its Pods that they should wait at least 5 minutes before being removed and reallocated with the tolerationSeconds key:
apiVersion: v1kind: Podmetadata: name: gpu labels: env: testspec: containers: - name: gpu image: khaosdoctor/go-vote-api tolerations: - key: "node.kubernetes.io/unreachable" operator: "Exists" effect: "NoExecute" tolerationSeconds: 300This way the nodes won’t immediately remove the Pods that aren’t compatible in the hope that your network comes back before that. You can also apply this toleration time to any other taint.
Best practices for labels#
There are some practices for naming your labels so that they stay efficient and you can accomplish your tasks in a practical way
Prefixes#
As you could see in the previous example, some of the labels look like a fully qualified domain name (FQDN) separated by a resource, like in topology.kubernetes.io/region, the DNS part is called a prefix.
Prefixes are used to give the intention that the prefixed labels are public, any label without any prefix is considered a private label only to the user, although it can be seen by other users.
As the official documentation says, any application that adds labels to user objects (as is the case with Azure itself), must have a label with a prefix.
Reserved prefixes#
All labels with prefixes that end in kubernetes.io or k8s.io are generally reserved for core Kubernetes objects, this is not an obligation or something that is forced on the user, but it’s a convention that should be followed.
Recommended labels#
The official documentation recommends a series of labels to be placed on all resources created within the cluster. They are only recommended and are not mandatory on all applications. Besides these labels, some other labels that I particularly like to put are:
- Labels that define the team responsible for the application like
team=campaign - Labels to define the people responsible for the application with
owner=lucas_santos - Definition of the application environment with
env=production - Last commit of the current version with
sha=5acffe34d, just for version control
Conclusion#
With taints, tolerations, affinity and selectors we take a step forward in controlling our clusters so that we can have even better control of our ecosystem!
See you later!
Footnotes#
-
Since this article already assumes that you know a little bit more about Kubernetes, I won’t give the basics to understand it, but if you want to understand a little more, check out my free Kubernetes course on Channel9 and, to dig deeper, take a look at my Kubernetes book from Casa do Código. ↩