How to Control Your Kubernetes Cluster with Affinity and Tolerations
When we’re working with Kubernetes clusters, it’s common to have applications that need to sit on specific nodes. This gets even more common when we have a bunch of nodes that are part of our cluster through Node Pools.
In this article we’re going to learn how to control our nodes with selectors, learn the concept of Node Pools and also the concept of Node Affinity. Finally we’ll understand the whole idea behind taints and tolerations. With that, we’ll learn how to have complete control of our cluster and of where we want our applications to run.Since this article already assumes you know a bit more about Kubernetes, I’m not going to lay down the basics, but if you want to dig deeper, check out my free Kubernetes course on Channel9 and, to go even further, take a look at my Kubernetes book from Casa do Código.
Why do we need control?#
By default, Kubernetes has a scheduler that does a pretty good job of separating your pods and containers. For example, it will always try to distribute all applications evenly across the cluster, avoiding placing applications that need more resources than a node currently has.
In general, this is a good separation and a good strategy, but we often need more than one type of machine for our applications, and that’s where we need exact control over where we need to place our applications. For example, for Machine Learning applications, the best machines are the ones with an integrated GPU, so we might have more than one node pool with different machines.
But we can’t put all of our applications inside a GPU node, because then we’d exhaust the node without room for the applications that actually need it. Likewise, we can’t have only GPU nodes because those machines are very expensive.
That’s where the concept of selectors comes in.
Node Selectors#
Any way of restricting an application to a given 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 scheduled to run inside a specific node.
Like any other Kubernetes resource, nodes also support grouping and tagging through labels. A label is a key-value pair that you can create as you like. It’s through these pairs that you’ll 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 provider already adds some labels to these nodes. Besides these, there are other well-known and common labels that get added to every node by default in a cluster.
We can get this information with the command kubectl describe nodes {name}, as we can see in this node from 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, we’ll say it has a GPU through a label processingtype=gpu. For that we’ll use the label command:
kubectl label nodes {node name} processingtype=gpuIf we want to change an existing label 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, meaning we can’t have two processingtype keys with two different values.
Using Node Selectors#
Now that we’ve applied a label to 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 we want a selector so the application only runs on nodes that have the processingtype key with the gpu value:
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 with Node Selectors. The difference is a somewhat more expressive syntax that lets you create more complex selectors with patterns like label in (value, value) or label=value.
The main difference between the two is that we have another specific key for affinity inside a Pod’s 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 mandatorily placed on a node that’s compatible with this affinity’s descriptions.preferredDuringSchedulingIgnoredDuringExecution: This is a softer version of the previous one. It basically says that the pod will try to run on a node with these labels, but if none is available, it’ll run on another node.
Both can coexist in the same pod, and it’s important to pay attention to the IgnoredDuringExecution part, which means that if a node loses a given label that allows a bunch of pods to run on it, those pods keep existing until they’re recreated.
Here’s an example of a Pod that will mandatorily create containers inside zones 1 or 2 and will prefer Linux environments, but if there aren’t any, it can 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’s called anti-affinity, which is meant to keep a Pod from running on certain nodes
Weight#
The weight key is a pretty interesting option because we can use the weight so that one node gets higher priority than others. What happens is that, when the scheduler goes through these rules, it will add up the weight of every rule for a node that’s satisfied by that node’s labels. The nodes that get the highest sum will be the preferred ones to host this container.
Taints and Tolerations#
We know that affinity is a Pod property that attracts that 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 side, we have tolerations, which are applied to Pods. A pod that has a toleration compatible with a taint will be able to be scheduled to start on that 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 set of labels, if you don’t specify those properties, the Pod will keep being scheduled on another node anyway. With a taint on a node, you permanently repel every pod that doesn’t have a matching toleration.
Remember: Taints and Tolerations work together, we can have one without the other, but they simply wouldn’t do anything. Every toleration needs a taint.
Creating a taint#
The same way we created labels, we can create taints with the kubectl taint nodes {name} command, but unlike labels, taints have an effect. Let’s look at an example:
kubectl taint node {name} processingtype=gpu:NoScheduleNotice we have the template key=value:effect. The NoSchedule effect will forbid 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 matching toleration from being created on this node.NoExecute: This is an even more radical form, it means that if a taint of this type is added to a node, every Pod that doesn’t support this taint with a compatible toleration will be immediately removed from the node. This effect type has another property calledtolerationSeconds, which says how long the node will wait before removing pods that don’t have a compatible toleration.
Let’s go through some examples. First, let’s imagine we have 3 nodes, each with 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 much sense
What we have here is:
node1will allow pods to run on it if they have no toleration at all, since it’s a plain CPU nodenode2won’t allow any Pod without a compatible toleration to run, since we only want GPU pods running therenode3besides not allowing any kind of scheduling on it, will also automatically remove every pod already running that doesn’t have the matching toleration. This is an expensive node since it uses TPUs, so we want maximum control over it
Tolerating Taints#
Now that we’ve created the taint on our node, let’s create a set of pods that tolerate specific 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 affinity, tolerations can have the operators
EqualsandExists
This Pod will be able to run on the node that has 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"On top of that, when we’re working with tolerations, the value key isn’t mandatory, we can have a taint with just a key and no value.
For these two pods, we’ll have them get included on each of the nodes they tolerate. That said, a new Pod created with no toleration at all will most likely land on the CPU node.
Default taints#
When a Kubernetes node enters certain states, like “out of disk”, “out of memory” or any other state that stops it from having new Pods scheduled to it, the system automatically adds default taints that remove Pods according to each of these taints.
You can create tolerations for these taints, for example, when a node becomes unreachable, we can tell its Pods to wait at least 5 minutes before being evicted and rescheduled 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 incompatible Pods in the hope that your network comes back before then. You can also apply this toleration time to any other taint.
Best practices for labels#
There are some practices for naming your labels so they stay efficient and let you get your tasks done practically.
Prefixes#
As you might have noticed in the earlier example, some labels look like a fully qualified domain name (FQDN) separated by a resource, like in topology.kubernetes.io/region. The DNS part is called the prefix.
Prefixes are used to signal the intent that prefixed labels are public. Any label without a prefix is considered a label private to the user, although they can still 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 ending in kubernetes.io or k8s.io are generally reserved for Kubernetes core objects. This isn’t a requirement or something enforced on the user, but it’s a convention that should be followed.
Recommended labels#
The official documentation recommends a set of labels to be placed on every resource created inside the cluster. They’re just recommended and not required on every application. Besides these labels, some other labels I personally like to add are:
- Labels that define the team responsible for the application, like
team=campaign - Labels to define who’s responsible for the application with
owner=lucas_santos - Definition of the application’s environment with
env=production - Last commit of the current version with
sha=5acffe34d, just for version tracking
Conclusion#
With taints, tolerations, affinity and selectors we take a step forward in controlling our clusters, so we can control our ecosystem even better!
See you next time!