Kubernetes: Pods та WorkerNodes – контроль розміщення подів на нодах

Автор |  17/08/2023

Kubernetes дозволяє дуже гнучко керувати тим, як його Pods будуть розташовані на серверах, тобто WorkerNodes.

Це може бути корисним, якщо вам треба запускати под на специфічній конфігурації ноди, наприклад – WorkerNode повинна мати GPU, або SSD замість HDD. Інший приклад, це коли вам потрібно розміщати окремі поди поруч, щоб зменшити затримку їхньої комунікації, або зменшити cross Availability-zone трафік (див. AWS: Grafana Loki, InterZone трафік в AWS, та Kubernetes nodeAffinity).

І, звісно, це важливе для побудування High Availability та Fault Tolerance архітектури, коли вам потрібно розділити поди по окремим нодам або Availability-зонам.

Ми маємо чотири основних підходи для контролю того, як Kubernetes Pods будуть розміщатись на WorkerNodes:

  • налаштувати Nodes таким чином, що вони будуть приймати тільки окремі поди, які відповідають заданим на ноді крітеріям
    • taints and tolerations: на ноді задаємо taint, для якого поди повинні мати відповідний toleration, щоб запустись на цій ноді
  • нашалтувати сам Pod таким чином, що він буде вибирати тільки окремі Nodes, які відповідають заданим у поді крітеріям
    • для цього використовуємо nodeName – вибирається нода тільки с заданним ім’ям
    • або nodeSelector для вибору ноди з відповідними labels і їх значеннями
    • або nodeAffinity та nodeAntiAffinity – правила, за якими Kubernetes Scheduler буде вибирати ноду, на якій запустить под, в залежності від параметрів цієї ноди
  • налаштувати сам Pod таким чином, що він буде вибирати Node в залежності від того, як запущені інші Pods
    • для цього використовуємо podAffinity та podAntiAffinity – правила, за якими Kubernetes Scheduler буде вибирати ноду, на якій запустить под, в залежності від інших подів на цій ноді
  • і окрема тема – Pod Topology Spread Constraints, тобто правила розміщення Pods по failure-domains – регіонам, Availability-зонам чи нодам

kubectl explain

Ви завжди можете прочитати відповідну документацію по будь-якому параметру або ресурсу, використовуючи kubectl explain:

[simterm]

$ kubectl explain pod
KIND:       Pod
VERSION:    v1

DESCRIPTION:
    Pod is a collection of containers that can run on a host. This resource is
    created by clients and scheduled onto hosts.
...

[/simterm]

Або:

[simterm]

$ kubectl explain Pod.spec.nodeName
KIND:       Pod
VERSION:    v1

FIELD: nodeName <string>

DESCRIPTION:
    NodeName is a request to schedule this pod onto a specific node. If it is
    non-empty, the scheduler simply schedules this pod onto that node, assuming
    that it fits resource requirements.

[/simterm]

Node Taints та Pods Tolerations

Отже, перший варіант – це задати на нодах обмеження того, які поди на ній можуть бути запущені з використанням Taints та Tolerations.

Тут taint “відштовхує” поди які не мають відповідної toleration від ноди, а toleration – “тягне” под до специфічної ноди, яка має відповідний taint.

Наприклад, ми можемо створити ноду, на якій будуть запускатись тільки поди з якимись критичними сервісами типу контроллерів.

Задаємо tain з effect: NoSchedule – тобто, забороняємо створювати нові поди на цій ноді:

[simterm]

$ kubectl taint nodes ip-10-0-3-133.ec2.internal critical-addons=true:NoSchedule
node/ip-10-0-3-133.ec2.internal tainted

[/simterm]

Далі, створюємо под, якому вказуємо toleration з ключем "critical-addons":

apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  containers:
  - name: my-container
    image: nginx:latest
  tolerations:
    - key: "critical-addons"
      operator: "Exists"
      effect: "NoSchedule"

І перевіряємо поди на цій ноді:

[simterm]

$ kubectl get pod --all-namespaces -o wide --field-selector spec.nodeName=ip-10-0-3-133.ec2.internal
NAMESPACE           NAME                                                              READY   STATUS    RESTARTS   AGE     IP           NODE                         NOMINATED NODE   READINESS GATES
default             my-pod                                                            1/1     Running   0          2m11s   10.0.3.39    ip-10-0-3-133.ec2.internal   <none>           <none>
dev-monitoring-ns   atlas-victoriametrics-loki-logs-zxd9m                             2/2     Running   0          10m     10.0.3.8     ip-10-0-3-133.ec2.internal   <none>           <none>
...

[/simterm]

Але звідки тут Loki? Тому що поки задали Taint – Scheduler встиг перенести на цю ноди под з Loki.

Щоб запобігти цьому – в Tain додаємо ключ NoExecute – тоді scheduler виконає Pod eviction, тобто “виселить” вже запущені поди з цієї ноди:

[simterm]

$ kubectl taint nodes ip-10-0-3-133.ec2.internal critical-addons=true:NoExecute

[/simterm]

Перевіряємо taints тепер:

[simterm]

$ kubectl get node ip-10-0-3-133.ec2.internal -o json | jq '.spec.taints'
[
  {
    "effect": "NoExecute",
    "key": "critical-addons",
    "value": "true"
  },
  {
    "effect": "NoSchedule",
    "key": "critical-addons",
    "value": "true"
  }
]

[/simterm]

Для нашого поду додаємо другий toleration, інакше і він буде evicted з цієї ноди:

...
  tolerations:
    - key: "critical-addons"
      operator: "Exists"
      effect: "NoSchedule"
    - key: "critical-addons"
      operator: "Exists"
      effect: "NoExecute"

Деплоїмо, та перевіряємо поди ще раз:

[simterm]

$ kubectl get pod --all-namespaces -o wide --field-selector spec.nodeName=ip-10-0-3-133.ec2.internal
NAMESPACE     NAME                                               READY   STATUS    RESTARTS   AGE   IP           NODE                         NOMINATED NODE   READINESS GATES
default       my-pod                                             1/1     Running   0          3s    10.0.3.246   ip-10-0-3-133.ec2.internal   <none>           <none>
kube-system   aws-node-jrsjz                                     1/1     Running   0          16m   10.0.3.133   ip-10-0-3-133.ec2.internal   <none>           <none>
kube-system   csi-secrets-store-secrets-store-csi-driver-cctbj   3/3     Running   0          16m   10.0.3.144   ip-10-0-3-133.ec2.internal   <none>           <none>
kube-system   ebs-csi-node-46fts                                 3/3     Running   0          16m   10.0.3.187   ip-10-0-3-133.ec2.internal   <none>           <none>
kube-system   kube-proxy-6ztqs                                   1/1     Running   0          16m   10.0.3.133   ip-10-0-3-133.ec2.internal   <none>           <none>

[/simterm]

Тепер на цій ноді тільки наш под, та поди з DaemonSets, які по дефолту мають запускатись на всіх нодах і мають відповідні tolerations, див. How Daemon Pods are scheduled.

Окрім Equal в умовах toleration, яка тільки перевіряє наявність заданої лейбли, можна виконати і перевірку значення ціїє лейбли.

Для цього в operator замість Exists вказуємо Equal, і додаємо value з потрібним значенням:

...
  tolerations:
    - key: "critical-addons"
      operator: "Equal"
      value: "true"
      effect: "NoSchedule"
    - key: "critical-addons"
      operator: "Equal"
      value: "true"
      effect: "NoExecute"

Щоб видалити tain – додаємо в кінці мінус:

[simterm]

$ kubectl taint nodes ip-10-0-3-133.ec2.internal critical-addons=true:NoSchedule-
node/ip-10-0-3-133.ec2.internal untainted
$ kubectl taint nodes ip-10-0-3-133.ec2.internal critical-addons=true:NoExecute-
node/ip-10-0-3-133.ec2.internal untainted

[/simterm]

Вибір ноди подом – nodeName, nodeSelector та nodeAffinity

Інший підхід, коли ми налаштовуємо пд таким чином, що “він” вибирає на якій ноді йому запускатись.

Для цього маємо nodeName, nodeSelector, nodeAffinity та nodeAntiAffinity. Див. Assign Pods to Nodes.

nodeName

Найпростіший способ. Має перевагу над всіма іншими:

apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  containers:
  - name: my-container
    image: nginx:latest
  nodeName: ip-10-0-3-133.ec2.internal

nodeSelector

З nodeSelector можемо вибирати ті ноди, які мають відповідні labels.

Додаємо лейблу:

[simterm]

$ kubectl label nodes ip-10-0-3-133.ec2.internal service=monitoring
node/ip-10-0-3-133.ec2.internal labeled

[/simterm]

Перевіряємо її:

[simterm]

$ kubectl get node ip-10-0-3-133.ec2.internal -o json | jq '.metadata.labels'
{
  ...
  "kubernetes.io/hostname": "ip-10-0-3-133.ec2.internal",
  "kubernetes.io/os": "linux",
  "node.kubernetes.io/instance-type": "t3.medium",
  "service": "monitoring",
  ...

[/simterm]

В маніфесті поду задаємо nodeSelector:

apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  containers:
  - name: my-container
    image: nginx:latest
  nodeSelector:
    service: monitoring

Якщо поду задано кілька лейбл в nodeSelector – то відповідна нода повинна мати всі ці лейбли, щоб на ній запустився цей под.

nodeAffinity та nodeAntiAffinity

nodeAffinity та nodeAntiAffinity діють так само, як і nodeSelector, але мають більш гнучкі можливості.

Наприклад, можна задати hard або soft ліміти запуску – для soft-ліміту scheduler спробує запустити под на відповідній ноді, а якщо не зможе – то запустить на іншій. Відповідно, якщо задати hard-ліміт, і scheduler не зможе запустити под на обраній ноді – то под залишиться в статусі Pending.

Hard-ліміт задається в полі .spec.affinity.nodeAffinity за допомогою requiredDuringSchedulingIgnoredDuringExecution, а soft – з preferredDuringSchedulingIgnoredDuringExecution.

Наприклад, можемо запустити под в AvailabiltyZone us-east-1a або us-east-1b, використовуючи node-label topology.kubernetes.io/zone:

[simterm]

$ kubectl get node ip-10-0-3-133.ec2.internal -o json | jq '.metadata.labels'
{
  ...
  "topology.kubernetes.io/region": "us-east-1",
  "topology.kubernetes.io/zone": "us-east-1b"
}

[/simterm]

Задаємо hard-limit:

apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  containers:
  - name: my-container
    image: nginx:latest
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: topology.kubernetes.io/zone
            operator: In
            values:
            - us-east-1a
            - us-east-1b

Або софт-ліміт. Наприклад, з неіснуючою лейблою:

apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  containers:
  - name: my-container
    image: nginx:latest
  affinity:
    nodeAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 1
        preference:
          matchExpressions:
          - key: non-exist-node-label
            operator: In
            values:
            - non-exist-value

У такому випидаку под все одно буде запущено на будь-якій найбільш вільній ноді.

Також можна комбінувати умови:

apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  containers:
  - name: my-container
    image: nginx:latest
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: topology.kubernetes.io/zone
            operator: In
            values:
            - us-east-1a
            - us-east-1b
      preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 1
        preference:
          matchExpressions:
          - key: non-exist-node-label
            operator: In
            values:
            - non-exist-value

При використанні декількох умов в requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms – буде обрано перше, що збіглось з лейблами ноди.

При використанні декількох умов в matchExpressions – вони мають відповідати всі.

В operator можна використовувати оператори In, NotIn, Exists, DoesNotExist, Gt (greater than) та Lt (less than).

soft-limit та weight

У preferredDuringSchedulingIgnoredDuringExecution за допомогою weight можна задати “вагу” умови, задавши значення від 1 до 100.

В такому випадку, якщо всі інші умови співпали, scheduler вибере ту ноду, умова якої має найбільший weight:

apiVersion: v1
kind: Pod
metadata:
  name: my-pod
spec:
  containers:
  - name: my-container
    image: nginx:latest
  affinity:
    nodeAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 1
        preference:
          matchExpressions:
          - key: topology.kubernetes.io/zone
            operator: In
            values:
            - us-east-1a
      - weight: 100
        preference:
          matchExpressions:
          - key: topology.kubernetes.io/zone
            operator: In
            values:
            - us-east-1b

Цей под буде запущено на ноді в регіоні us-east-1b:

[simterm]

$ kubectl get pod my-pod -o wide
NAME     READY   STATUS    RESTARTS   AGE   IP           NODE                         NOMINATED NODE   READINESS GATES
my-pod   1/1     Running   0          3s    10.0.3.245   ip-10-0-3-133.ec2.internal   <none>           <none>

[/simterm]

І зона цієї ноди:

[simterm]

$ kubectl get node ip-10-0-3-133.ec2.internal -o json | jq -r '.metadata.labels."topology.kubernetes.io/zone"'
us-east-1b

[/simterm]

podAffinity та podAntiAffinity

Аналогічно до вибору ноди за допомогою hard- та soft-лімітів, можна налаштувати Pod Affinity в залежності від того, які лейбли будуть у подів, які вже запущені на ноді. Див. Inter-pod affinity and anti-affinity.

Наприклад, є три поди Grafana Loki – Read, Write та Backend.

Ми хочемо запускати Read та Backend в одній AvailabilityZone, щоб уникнути cross-AZ трафіку, але при цьому хочемо, що вони не запускались на тих нодах, де є поди з Write.

Поди Loki мають відповідні до компоненту лейбли – app.kubernetes.io/component=read, app.kubernetes.io/component=backend та app.kubernetes.io/component=write.

Тож для Read задаємо podAffinity до подів з лейблою app.kubernetes.io/component=backend, та podAntiAffinity до подів з лейблою app.kubernetes.io/component=read:

...
    spec:
      affinity:
        podAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: app.kubernetes.io/component
                operator: In
                values:
                - backend
            topologyKey: "topology.kubernetes.io/zone"
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: app.kubernetes.io/component
                operator: In
                values:
                - write
            topologyKey: "kubernetes.io/hostname"
...

Тут в podAffinity.topologyKey ми вказуємо, що хочемо розміщати поди, використовуючи домен topology.kubernetes.io/zone – тобто topology.kubernetes.io/zone для Read має співпадати з подом Backend.

А в podAntiAffinity.topologyKey задаємо kubernetes.io/hostname – тобто не розміщати на WorkerNodes, де є поди з лейблою app.kubernetes.io/component=write.

Деплоїмо, та перевіряємо де є под з Write:

[simterm]

$ kubectl -n dev-monitoring-ns get pod loki-write-0 -o json | jq '.spec.nodeName'
"ip-10-0-3-53.ec2.internal"

[/simterm]

Та в якій AvailabilityZone ця нода:

[simterm]

$ kubectl -n dev-monitoring-ns get node ip-10-0-3-53.ec2.internal -o json | jq -r '.metadata.labels."topology.kubernetes.io/zone"'
us-east-1b

[/simterm]

Перевіряємо де знаходиться под з Backend:

[simterm]

$ kubectl -n dev-monitoring-ns get pod loki-backend-0 -o json | jq '.spec.nodeName'
"ip-10-0-2-220.ec2.internal"

[/simterm]

І його зона:

[simterm]

$ kubectl -n dev-monitoring-ns get node ip-10-0-2-220.ec2.internal -o json | jq -r '.metadata.labels."topology.kubernetes.io/zone"'
us-east-1a

[/simterm]

І тепер под з Read:

[simterm]

$ kubectl -n dev-monitoring-ns get pod loki-read-698567cdb-wxgj5 -o json | jq '.spec.nodeName'
"ip-10-0-2-173.ec2.internal"

[/simterm]

Нода інша, ніж у пода Write або Backend, але:

[simterm]

$ kubectl -n dev-monitoring-ns get node ip-10-0-2-173.ec2.internal -o json | jq -r '.metadata.labels."topology.kubernetes.io/zone"'
us-east-1a

[/simterm]

Та сама AvailabilityZone, що й в поду Backend.

Pod Topology Spread Constraints

Ми можемо налаштувати Kubernetes Scheduler таким чином, щоб він розподіляв под по “доменам”, тобто – по нодам, регіонам або Availability-зонам. Див. Pod Topology Spread Constraints.

Для цього в полі spec.topologySpreadConstraints задаються параметри, які описують як саме будуть створені поди.

Наприклад, у нас є 5 WorkerNode в двох AvailabilityZone.

Ми хочемо запустити 5 подів, і задля fault tolerance ми хочемо, щоб кожен под був розміщений на окремій ноді.

Тоді наш конфіг для Deployment може виглядати так:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-deployment
spec:
  replicas: 5
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      containers:
        - name: my-container
          image: nginx:latest
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: my-app

Тут:

  • maxSkew: максимальна різниця в кількості подів в одному домені (topologyKey)
    • грає роль тільки якщо whenUnsatisfiable=DoNotSchedule, при whenUnsatisfiable=ScheduleAnyway под буде створено незалежно від умов
  • whenUnsatisfiable: може мати значення DoNotSchedule – не дозволяти створювати поди, або ScheduleAnyway
  • topologyKey: WorkerNode label, по якій буде обрано домен, тобто по якій лейблі групуємо ноди, на яких розраховується розміщення подів
  • labelSelector: які поди враховувати при розміщенні подів (наприклад, якщо поди з різних Deployment, але мають розміщувати однаково – то в обох Deployment налаштовуємо topologySpreadConstraints з взаємними labelSelector)

Крім того, можна задати параметри nodeAffinityPolicy та/або nodeTaintsPolicy зі значеннями Honor або Ignore – враховувати nodeAffinity або nodeTaints при розрахунку розміщення подів, чи ні.

Деплоїмо та перевіряємо ноди цих подів:

[simterm]

$ kk get pod -o json | jq '.items[].spec.nodeName'
"ip-10-0-3-53.ec2.internal"
"ip-10-0-3-22.ec2.internal"
"ip-10-0-2-220.ec2.internal"
"ip-10-0-2-173.ec2.internal"
"ip-10-0-3-133.ec2.internal"

[/simterm]

Всі розміщені на окремих нодах.