José Hisse

Aumentando a disponibilidade no k8s com inter-pod anti-affinity

O objetivo deste artigo é entender como funciona o recurso de anti afinidade entre pods e como os diferentes modos, soft e hard, podem influenciar na disponibilidade de uma aplicação que esteja sendo executada no Kubernetes. Veremos os diferentes modos de anti afinidade e faremos uma sequência de testes para entender seus mais variados comportamentos.

Ambiente de teste

Para realizarmos as simulações propostas ao longo deste artigo iremos precisar de um cluster Kubernetes. Para isso iremos utilizar Kind. O Kind, significa Kubernetes in Docker, ou seja, ele irá levantar alguns containers e recursos de rede, permitindo o acesso ao cluster através do kubectl.

Até agora vimos dois pré-requisitos, o kind e o kubectl. Você pode conferir como instalar essas ferramentas nos respectivos sites indicados acima.

Tendo-os instalados, vamos a configuração do cluster. Em uma pasta crie um arquivo chamado cluster_config.yaml. Ele vai ter o seguinte conteúdo:

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
- role: worker
  kubeadmConfigPatches:
    - |
      kind: JoinConfiguration
      nodeRegistration:
        kubeletExtraArgs:
          node-labels: "topology.kubernetes.io/zone=west-1a,topology.kubernetes.io/region=west"      
- role: worker
  kubeadmConfigPatches:
    - |
      kind: JoinConfiguration
      nodeRegistration:
        kubeletExtraArgs:
          node-labels: "topology.kubernetes.io/zone=west-1c,topology.kubernetes.io/region=west"      
- role: worker
  kubeadmConfigPatches:
    - |
      kind: JoinConfiguration
      nodeRegistration:
        kubeletExtraArgs:
          node-labels: "topology.kubernetes.io/zone=east-1b,topology.kubernetes.io/region=east"      

Esse é um arquivo no formato yaml que descreve como nosso cluster kubernetes será. A chave kind indica o tipo de objeto que estamos criando e a chave apiVersion indica a versão da api. Em seguida, representado pela chave nodes, temos uma lista de nós do nosso cluster. Vamos configurar 4 nós. O primeiro será o nó principal, do tipo control-plane e vamos enriquecer os metadados dos três outros nós do tipo workers com os labels topology.kubernetes.io/zone e topology.kubernetes.io/region. Desta forma vamos simular a alocação de nós em diferentes zonas e regiões. Em clusters hospedados na nuvem, AWS, GCP, Azure, esses labels já são definidos conforme a localidade das instâncias que compõem o cluster.

kind create cluster --config cluster_config.yaml --name affinity

Creating kind cluster

Precisamos checar o contexto do kubectl e verificar se conseguimos acessar nosso cluster:

kubectl config current-context
kubectl get pods --all-namespaces

Labels de topologia

Para este artigo nos interessa 3 labels específicas dos nós:

Escolheremos um deles como domínio quando formos definir anti afinidades dos pods. Repare que existe uma hierarquina na topologia. A lista está ordenada do elemento mais granular, kubernetes.io/hostname, passando pela zona que pode haver repetição, topology.kubernetes.io/zone e seguido pela região topology.kubernetes.io/region que engloba as zonas.

A descrição de todos os nós do nosso cluster pode ser verificada da seguinte forma:

$ kubectl describe nodes
Name:               affinity-control-plane
Roles:              control-plane,master
Labels:             beta.kubernetes.io/arch=amd64
                    beta.kubernetes.io/os=linux
                    kubernetes.io/arch=amd64
                    kubernetes.io/hostname=affinity-control-plane
                    kubernetes.io/os=linux
                    node-role.kubernetes.io/control-plane=
                    node-role.kubernetes.io/master=
...
Name:               affinity-worker
Roles:              <none>
Labels:             beta.kubernetes.io/arch=amd64
                    beta.kubernetes.io/os=linux
                    kubernetes.io/arch=amd64
                    kubernetes.io/hostname=affinity-worker
                    kubernetes.io/os=linux
                    topology.kubernetes.io/region=west
                    topology.kubernetes.io/zone=west-1a
...
Name:               affinity-worker2
Roles:              <none>
Labels:             beta.kubernetes.io/arch=amd64
                    beta.kubernetes.io/os=linux
                    kubernetes.io/arch=amd64
                    kubernetes.io/hostname=affinity-worker2
                    kubernetes.io/os=linux
                    topology.kubernetes.io/region=west
                    topology.kubernetes.io/zone=west-1c
...
Name:               affinity-worker3
Roles:              <none>
Labels:             beta.kubernetes.io/arch=amd64
                    beta.kubernetes.io/os=linux
                    kubernetes.io/arch=amd64
                    kubernetes.io/hostname=affinity-worker3
                    kubernetes.io/os=linux
                    topology.kubernetes.io/region=east
                    topology.kubernetes.io/zone=east-1b
...

Anti afinidade obrigatória ou preferível

Temos dois tipos de anti afinidade, a obrigatória, onde os pods devem ser alocados de acordo com o que configurarmos e a preferível, onde o scheduler do kubernetes tentará alocar da maneira definida na especificação, mas caso não consiga, ele irá alocar da maneira que o algoritmo escolher.

Hard

A anti afinidade obrigatória é caracterizada pela chave requiredDuringSchedulingIgnoredDuringExecution e pode ser definida pela seguinte especificação:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: lorem-ipsum-deployment
  labels:
    app.kubernetes.io/name: lorem-ipsum
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: lorem-ipsum
  template:
    metadata:
      labels:
        app.kubernetes.io/name: lorem-ipsum
    spec:
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchExpressions:
                  - key: app.kubernetes.io/name
                    operator: In
                    values:
                      - lorem-ipsum
              topologyKey: topology.kubernetes.io/region
      containers:
        - name: lorem-ipsum
          image: k8s.gcr.io/pause:3.2
          resources:
            requests:
              cpu: 50m
              memory: 128Mi
            limits:
              cpu: 50m
              memory: 128Mi

O que isso quer dizer? Estamos definindo um deployment para um pod com um container apenas, porém com algumas restrições. Queremos que esses pods residam em nós que estejam em regiões distintas, indicados por topologyKey: topology.kubernetes.io/region. Em uma aplicação real isso é uma garantia que ela seja resiliente a falhas causadas por indisponibilidade em regiões inteiras. Vamos aplicar este deployment e visualizar o comportamento no cluster.

$ kubectl create -f deployment.yaml
deployment.apps/lorem-ipsum-deployment created

$ kubectl get pods -o wide
NAME                                      READY   STATUS    RESTARTS   AGE   IP           NODE               NOMINATED NODE   READINESS GATES
lorem-ipsum-deployment-6944477b78-csnrq   1/1     Running   0          21s   10.244.2.2   affinity-worker3   <none>           <none>
lorem-ipsum-deployment-6944477b78-m7rqq   1/1     Running   0          21s   10.244.1.2   affinity-worker2   <none>           <none>

Recapitulando. Nós queríamos garantir que cada pod estivesse em regiões separadas para aumentar a resiliência à falhas. O nó affinity-worker2 está na região topology.kubernetes.io/region=west e o nó affinity-worker3 está na região topology.kubernetes.io/region=east. Cumprimos assim o objetivo do primeiro caso de teste.

Vamos agora alterar uma pequena linha na nossa definição do deployment.

8c8
<   replicas: 2
---
>   replicas: 3

Lembrando que só temos nós em duas regiões, east e west. O que deve acontecer agora que queremos obrigatoriamente ter 3 réplicas em regiões diferentes?

$ kubectl delete -f deployment.yaml
deployment.apps/lorem-ipsum-deployment deleted

$ kubectl create -f deployment.yaml
deployment.apps/lorem-ipsum-deployment created

$ kubectl get pods -o wide
NAME                                      READY   STATUS    RESTARTS   AGE     IP           NODE               NOMINATED NODE   READINESS GATES
lorem-ipsum-deployment-6944477b78-csnrq   1/1     Running   0          2m35s   10.244.2.2   affinity-worker3   <none>           <none>
lorem-ipsum-deployment-6944477b78-m7rqq   1/1     Running   0          2m35s   10.244.1.2   affinity-worker2   <none>           <none>
lorem-ipsum-deployment-6944477b78-qtf6g   0/1     Pending   0          88s     <none>       <none>             <none>           <none>

O scheduler não conseguiu alocar o novo pod em um nó, pois não tínhamos regiões desalocadas. Como estamos lidando com anti afinidade obrigatória, o estado do novo pod permanecerá em pending, a menos que um novo nó de uma região diferente das duas que já temos seja alocado no cluster.

Vamos checar os eventos do pod que nao pôde ser alocado:

$ kubectl describe pod lorem-ipsum-deployment-6944477b78-qtf6g
Name:           lorem-ipsum-deployment-6944477b78-qtf6g
...
Events:
  Type     Reason            Age                  From               Message
  ----     ------            ----                 ----               -------
  Warning  FailedScheduling  48s (x4 over 3m24s)  default-scheduler  0/4 nodes are available: 1 node(s) had taint {node-role.kubernetes.io/master: }, that the pod didn't tolerate, 3 node(s) didn't match pod affinity/anti-affinity, 3 node(s) didn't match pod anti-affinity rules.

Ficou evidente que a não alocação deste pod foi resultado da política de anti-afinidade. Vamos à mais uma alteração, trocar a topologyKey para trabalharmos com zonas. Lembrando que no cluster temos 3 zonas diferentes.

26c26
<               topologyKey: topology.kubernetes.io/region
---
>               topologyKey: topology.kubernetes.io/zone
$ kubectl delete -f deployment.yaml
deployment.apps/lorem-ipsum-deployment deleted

$ kubectl create -f deployment.yaml
deployment.apps/lorem-ipsum-deployment created

$ kubectl get pods -o wide
NAME                                      READY   STATUS    RESTARTS   AGE   IP           NODE               NOMINATED NODE   READINESS GATES
lorem-ipsum-deployment-548b947664-g9xfh   1/1     Running   0          21s   10.244.2.3   affinity-worker3   <none>           <none>
lorem-ipsum-deployment-548b947664-jz7p5   1/1     Running   0          21s   10.244.3.2   affinity-worker    <none>           <none>
lorem-ipsum-deployment-548b947664-pdzjf   1/1     Running   0          21s   10.244.1.3   affinity-worker2   <none>           <none>

Como observado, nenhum pod foi alocado na mesma zona. Mais uma última alteração, vamos aumentar o número de réplicas para 4 e alterar o topologyKey para kubernetes.io/hostname.

8c8
<   replicas: 3
---
>   replicas: 4
26c26
<               topologyKey: topology.kubernetes.io/zone
---
>               topologyKey: kubernetes.io/hostname
$ kubectl delete -f deployment.yaml
deployment.apps/lorem-ipsum-deployment deleted

$ kubectl create -f deployment.yaml
deployment.apps/lorem-ipsum-deployment created

$ kubectl get pods -o wide
NAME                                      READY   STATUS    RESTARTS   AGE   IP           NODE               NOMINATED NODE   READINESS GATES
lorem-ipsum-deployment-7d6cb547c6-9gtd2   1/1     Running   0          86s   10.244.1.4   affinity-worker2   <none>           <none>
lorem-ipsum-deployment-7d6cb547c6-gmrx9   1/1     Running   0          86s   10.244.3.3   affinity-worker    <none>           <none>
lorem-ipsum-deployment-7d6cb547c6-m9jvs   0/1     Pending   0          86s   <none>       <none>             <none>           <none>
lorem-ipsum-deployment-7d6cb547c6-st5p5   1/1     Running   0          86s   10.244.2.4   affinity-worker3   <none>           <none>

Da mesma forma que só tínhamos 3 topology.kubernetes.io/zone distintas, no caso acima, só temos 3 kubernetes.io/hostname distintos. Isso faz que nosso 4º pod não seja alocado.

Soft

O modo de anti afinidade preferível ou soft, não torna obrigatória a alocação de acordo com as regras pré-estabelecidas, e sim estabelece uma prioridade para a definição. Ela é caracterizada pela chave preferredDuringSchedulingIgnoredDuringExecution e podemos utilizar os mesmo valores do modo hard para a topologyKey.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: lorem-ipsum-deployment
  labels:
    app.kubernetes.io/name: lorem-ipsum
spec:
  replicas: 3
  selector:
    matchLabels:
      app.kubernetes.io/name: lorem-ipsum
  template:
    metadata:
      labels:
        app.kubernetes.io/name: lorem-ipsum
    spec:
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              podAffinityTerm:
                labelSelector:
                  matchExpressions:
                    - key: app.kubernetes.io/name
                      operator: In
                      values:
                        - lorem-ipsum
                topologyKey: topology.kubernetes.io/region
      containers:
        - name: lorem-ipsum
          image: k8s.gcr.io/pause:3.2
          resources:
            requests:
              cpu: 50m
              memory: 128Mi
            limits:
              cpu: 50m
              memory: 128Mi

Já iremos aplicar com 3 réplicas e com a topologyKey igual a topology.kubernetes.io/region. Lembrando que temos nós em somente duas regiões.

$ kubectl delete -f deployment.yaml
deployment.apps/lorem-ipsum-deployment deleted

$ kubectl create -f deployment.yaml
deployment.apps/lorem-ipsum-deployment created

$ kubectl get pods -o wide
NAME                                      READY   STATUS    RESTARTS   AGE   IP           NODE               NOMINATED NODE   READINESS GATES
lorem-ipsum-deployment-57c779f4bd-hklzw   1/1     Running   0          12s   10.244.1.5   affinity-worker2   <none>           <none>
lorem-ipsum-deployment-57c779f4bd-mqnwd   1/1     Running   0          12s   10.244.3.4   affinity-worker    <none>           <none>
lorem-ipsum-deployment-57c779f4bd-wrk98   1/1     Running   0          12s   10.244.2.5   affinity-worker3   <none>           <none>

Não houve problema neste caso, diferente do modo hard de anti afinidade.

Vamos fazer um outro caso de teste, vamos aumentar o número de réplicas para 10 e alterar o topologyKey para kubernetes.io/hostname.

8c8
<   replicas: 3
---
>   replicas: 10
28c28
<                 topologyKey: topology.kubernetes.io/region
---
>                 topologyKey: kubernetes.io/hostname
$ kubectl delete -f deployment.yaml
deployment.apps/lorem-ipsum-deployment deleted

$ kubectl create -f deployment.yaml
deployment.apps/lorem-ipsum-deployment created

$ kubectl get pods -o wide
NAME                                     READY   STATUS    RESTARTS   AGE   IP           NODE               NOMINATED NODE   READINESS GATES
lorem-ipsum-deployment-89456ffdd-dx5pb   1/1     Running   0          9s    10.244.1.6   affinity-worker2   <none>           <none>
lorem-ipsum-deployment-89456ffdd-fqcrn   1/1     Running   0          9s    10.244.2.6   affinity-worker3   <none>           <none>
lorem-ipsum-deployment-89456ffdd-jx2sg   1/1     Running   0          9s    10.244.2.9   affinity-worker3   <none>           <none>
lorem-ipsum-deployment-89456ffdd-klsv6   1/1     Running   0          9s    10.244.3.6   affinity-worker    <none>           <none>
lorem-ipsum-deployment-89456ffdd-nczfg   1/1     Running   0          9s    10.244.3.7   affinity-worker    <none>           <none>
lorem-ipsum-deployment-89456ffdd-pk54d   1/1     Running   0          9s    10.244.1.7   affinity-worker2   <none>           <none>
lorem-ipsum-deployment-89456ffdd-rjztg   1/1     Running   0          9s    10.244.1.8   affinity-worker2   <none>           <none>
lorem-ipsum-deployment-89456ffdd-sbm95   1/1     Running   0          9s    10.244.3.5   affinity-worker    <none>           <none>
lorem-ipsum-deployment-89456ffdd-t47j7   1/1     Running   0          9s    10.244.2.8   affinity-worker3   <none>           <none>
lorem-ipsum-deployment-89456ffdd-vb2vr   1/1     Running   0          9s    10.244.2.7   affinity-worker3   <none>           <none>

Por fim, mais um caso de teste. Vamos determinar pesos para priorizar topologyKey: topology.kubernetes.io/regio e em menor prioridade topologyKey: topology.kubernetes.io/zone.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: lorem-ipsum-deployment
  labels:
    app.kubernetes.io/name: lorem-ipsum
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: lorem-ipsum
  template:
    metadata:
      labels:
        app.kubernetes.io/name: lorem-ipsum
    spec:
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              podAffinityTerm:
                labelSelector:
                  matchExpressions:
                    - key: app.kubernetes.io/name
                      operator: In
                      values:
                        - lorem-ipsum
                topologyKey: topology.kubernetes.io/region
            - weight: 99
              podAffinityTerm:
                labelSelector:
                  matchExpressions:
                    - key: app.kubernetes.io/name
                      operator: In
                      values:
                        - lorem-ipsum
                topologyKey: topology.kubernetes.io/zone
      containers:
        - name: lorem-ipsum
          image: k8s.gcr.io/pause:3.2
          resources:
            requests:
              cpu: 50m
              memory: 128Mi
            limits:
              cpu: 50m
              memory: 128Mi
$ kubectl describe nodes
Name:               affinity-control-plane
Roles:              control-plane,master
Labels:             beta.kubernetes.io/arch=amd64
                    beta.kubernetes.io/os=linux
                    kubernetes.io/arch=amd64
                    kubernetes.io/hostname=affinity-control-plane
                    kubernetes.io/os=linux
                    node-role.kubernetes.io/control-plane=
                    node-role.kubernetes.io/master=
...
Name:               affinity-worker
Roles:              <none>
Labels:             topology.kubernetes.io/region=west
                    topology.kubernetes.io/zone=west-1a
...
Name:               affinity-worker2
Roles:              <none>
Labels:             topology.kubernetes.io/region=west
                    topology.kubernetes.io/zone=west-1c
...
Name:               affinity-worker3
Roles:              <none>
Labels:             topology.kubernetes.io/region=east
                    topology.kubernetes.io/zone=east-1b
...

O esperado é que, com duas réplicas, ele aloque primeiro nos nós que tenha topology.kubernetes.io/region=west e outro topology.kubernetes.io/region=east.

$ kubectl delete -f deployment.yaml
deployment.apps/lorem-ipsum-deployment deleted

$ kubectl create -f deployment.yaml
deployment.apps/lorem-ipsum-deployment created

$ kubectl get pods -o wide
NAME                                      READY   STATUS    RESTARTS   AGE   IP           NODE               NOMINATED NODE   READINESS GATES
lorem-ipsum-deployment-6d49b8d8cd-j5pfr   1/1     Running   0          7s    10.244.3.5   affinity-worker    <none>           <none>
lorem-ipsum-deployment-6d49b8d8cd-zqrrp   1/1     Running   0          7s    10.244.2.6   affinity-worker3   <none>           <none>

Adicionando mais uma réplica é esperado que ele instâncie o pod em um nó de zona diferentes, já que por região não existe mais opções.

8c8
<   replicas: 2
---
>   replicas: 3
$ kubectl apply -f deployment.yaml
deployment.apps/lorem-ipsum-deployment created

$ kubectl get pods -o wide
NAME                                      READY   STATUS    RESTARTS   AGE     IP           NODE               NOMINATED NODE   READINESS GATES
lorem-ipsum-deployment-6d49b8d8cd-7phwb   1/1     Running   0          4m13s   10.244.2.8   affinity-worker3   <none>           <none>
lorem-ipsum-deployment-6d49b8d8cd-cgqqn   1/1     Running   0          4m13s   10.244.3.7   affinity-worker    <none>           <none>
lorem-ipsum-deployment-6d49b8d8cd-nj52g   1/1     Running   0          4m1s    10.244.1.6   affinity-worker2   <none>           <none>

Conclusão

Vimos os diferentes modos de anti-afinidade e alguns comportamentos no deploy de sua aplicação.

O modo soft ou preferível de alocação, geralmente, deve ser a melhor escolha. Ele garantirá que sua aplicação atinja o número de réplicas estipulado no deployment.

De preferência a distribuir os pods por regiões ou zonas, isso garantirá que sua aplicação continue funcionando em caso de indisponibilidade por região ou zona.

Não esqueça de excluir seu cluster de testes.

kind delete clusters affinity

Mais informações sobre o inter-pod anti-affinity podem ser encontradas na documentação oficial.