어피니티

개요

어피니티는 스케줄링 시 사용할 수 있는 대표적인 기법 중 하나이다.
문자 그대로 친화도, 선호도를 나타내는데, 그래서 원하는 노드를, 혹은 파드를 선호하게 만들 수 있다.
반대로 안티 어피니티라 하여 꺼리게 만드는 것도 가능하다.

노드 셀렉터와 비교했을 때, 훨씬 표현성이 높다.
선호도를 표현할 수 있다.
즉, 희망하는 노드를 지정할 수 있어서

유형

구체적으로 어피니티는 3가지 유형으로 분류되는데 이들은 양식 작성법이 비슷하면서도 살짝 다르다.
대충 봤다간 헷갈리기 매우 쉽기 때문에 각각의 양식 작성법도 따로 구분하여 작성한다.

일단 한가지 미리 알아둘 것은 두 필드에 대한 값이다.

모든 유형의 어피니티가 이 필드들을 활용하는데, 각 유형에 따라 이것을 설정하는 방법이 조금씩 달라서 자칫 혼동이 오기 쉽다.
이 각 필드를 어떻게 활용하는지는 아래에서 본격적으로 다루겠다.

nodeAffinity

apiVersion: v1
kind: Pod
metadata:
  name: with-node-affinity
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: topology.kubernetes.io/zone
            operator: In
            values:
            - antarctica-east1
            - antarctica-west1
      preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 1
        preference:
          matchExpressions:
          - key: another-node-label-key
            operator: In
            values:
            - another-node-label-value
  containers:
  - name: with-node-affinity
    image: registry.k8s.io/pause:3.8

노드 어피니티는 어떤 노드에 배치되도록할지 지정하는 어피니티이다.
파드 스펙에 .affinity.nodeAffinity 필드에 작성한다.

노드 어피니티에 있어 required는 잘 생각해보면 스케줄링#nodeSelector와 비슷하게 동작할 것이라 생각할 수 있다.
대신 그보다 훨씬 세밀하게 설정을 할 수 있다는 것이 장점이다.
nodeSelectorTerms를 두고 그 아래로 여러 라벨 셀렉터를 넣어줄 수 있는데, 이 원소 간에는 OR 연산이 일어난다.
그리고 그 라벨 셀렉터 안속에 다시 한번 리스트가 들어가는데, 이건 AND 연산이 이뤄진다.
각 리스트에는 라벨 셀렉터로 또 리스트가 들어가면 된다.
전자의 리스트는 OR 연산, 후자의 리스트는 AND 연산이라는 것을 참고하자.

prefer에서는 가중치 값을 weight로 작성한다.
이 값은 스코어링을 할 때 반영되는데, 적힌 값만큼 점수에 정직하게 합산된다!
스코어링을 할 때는 다른 스케줄링 기법들의 점수도 고려되는데, 값의 범위가 대충 2,3 정도 수준이다.
그러니까 여기에 100같은 값을 때려박으면 해당 기준에 매칭되는 노드는 거의 무조건 선택되는 거라 보면 된다.
prefer 부분은 nodeSelectorTerms를 뒀던 require와 달리 바로 리스트를 작성한다.
이 리스트에 각각의 조건과 선호도를 작성해주면 된다.
그리고 각 원소의 preference 필드 라벨 셀렉터 하위로 다시금 리스트로 선택할 라벨들을 적어주면 된다.
위와 같이 여기의 라벨 리스트는 AND 연산이 일어난다.

참고로 matchExpressions.operator는 다음의 연산자를 지원한다.

이건 아래 유형들에도 적용되는데, 노드 어피니티의 경우엔 다음의 추가 연산자도 지원한다.

아니 라벨에 누가 숫자로 비교를 해요;

라고 처음에 생각했다.
근데 다시 생각해보면 노드에 대해서는 이 값이 충분히 의미가 있다.
가령 EKS만 하더라도 노드에 인스턴스 타입, 스펙 정보들을 추가 라벨로 달아둔다.
그럼 이걸 기반으로 노드를 선택하도록 유도하는 것이 가능한 것이다!
가령 cpu가 8개 이상인 노드에 배치하도록 하고 싶다면 이 연산자를 쓰면 되겠다.

podAffinity & podAntiAffinity

파드 어피니티는 어떤 파드가 있는 노드에 배치되도록 할지 지정하는 어피니티이다.
가령 웹서버 파드는 WAS서버 파드가 있는 노드에 배치돼야 상대적으로 통신이 빠르게 이뤄질 것이다.
그럴 때 이런 식으로 넣어주면 된다.
반대로 파드 안티 어피니티는 어떤 파드가 있는 노드에 배치되지 않도록 할지 지정한다.

이 어피니티들에 대한 설정은 다음과 같은 식으로 해석된다.
Y 기준을 만족하는 파드들이 있는 X 토폴로지를 가진 노드에 배치하도록 하는 어피니티

말이 벌써 어려운데, 일단 Y라는 것은 그냥 파드 라벨에 대한 정보를 적어주는 부분이다.
X가 무엇이냐가 살짝 중요한데, 이것은 노드의 그룹을 나타내는 토폴로지 정보이다.
위 그림처럼 스케줄링에 있어서 먼저 고려되는 것은 토폴로지 정보로, 토폴로지를 기준으로 노드들을 뭉텅이로 취급한다.
그 다음에 비로소 파드의 기준을 따지며 조건을 만족하는 파드가 있는 토폴로지 그룹에 스케줄링을 한다는 것이다.
즉, 노드를 그룹화시켜서 하나로 바라보는 것이 바로 토폴로지라고 보면 되겠다.
그룹화가 되면 실상 한 노드에는 조건을 만족하는 파드가 없더라도 그 노드에 파드가 배치되는 것도 가능한 일이다.

  affinity:
    podAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchExpressions:
          - key: security
            operator: In
            values:
            - S1
        topologyKey: topology.kubernetes.io/zone
    podAntiAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 100
        podAffinityTerm:
          labelSelector:
            matchExpressions:
            - key: security
              operator: In
              values:
              - S2
          topologyKey: topology.kubernetes.io/zone

이제 양식 작성법을 볼텐데, [[#nodeAffinity]]와 다르게 require에서 nodeSelectorTerms 없이 바로 라벨 셀렉터 리스트가 나온다.
사용 방식 자체는 동일한데, 이번에는 topologyKey라는 필드를 통해 노드 토폴로지를 지정한다.

prefer 부분을 보면 또 골때리는 게, 이번에는 preference가 아니라 podAffinityTerm.labelSelector을 쓴다..
사용법은 또 똑같으면서...

아니 진짜 왜 그러는 거임?

왜 어피니티마다 살짝살짝 필드 이름이 바뀌고 사용법도 바뀌는 것인가?
아직까지는 이런 식으로 필드 명을 구분했어야 했던 이유를 모르겠다.
그냥 혼란만 가중시키는 방식이라고 생각한다.

아무튼 파드 어피니티를 쓸 때 토폴로지키는 필수값이다.

파드 어피니티는 연산량을 많이 소모할 수 있어서 큰 규모의 클러스터에서 성능 저하가 일어날 수 있다.

이건 기본적으로는 이미 배치된 파드를 대상으로 거는 것이지만 한꺼번에 같은 파드가 배치될 때도 값이 고려된다.
가령 워크로드를 통해 같은 어피니티 정보를 가진 파드가 배치된다고 했을 때, 결국 파드는 하나씩 스케줄링된다.
그래서 한 파드가 먼저 배치되면 다음 파드는 그 정보를 고려하여 스케줄링된다는 것이다.

namespaces & namespaceSelector

    podAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        - labelSelector:
            matchExpressions:
              - key: app
                operator: In
                values:
                  - some-app
          topologyKey: "kubernetes.io/hostname"
          namespaceSelector:
            matchLabels:
              project: myproject
          namespaces: ['default', 'kube-system']

이제 슬슬 골이 아파진다..
파드를 고르는 건 좋은데, 네임스페이스 별로 같은 이름의 파드를 가지고 있거나 하는 이슈가 있을 수 있고, 또 스케줄링하는 파드의 네임스페이스에 없는 다른 파드를 이용해 스케줄링을 하고 싶을 수 있다.
이럴 때 네임스페이스 관련 필드를 써주면 된다.[1]

여기에는 두 가지 방식이 가능한데, 하나는 namespaceSelector로 네임스페이스의 라벨을 이용해 선택하는 것이다.
그리고 다른 하나는 namespaces를 이용해 정직하게 네임스페이스 이름을 적어주는 것이다.
이 필드를 넣고 나서 빈 값(namspaces: []namespaceSelector: {})을 넣으면 모든 네임스페이스를 고려하겠다는 뜻이다.
아예 이 필드를 생략한다면 현재 스케줄링되는 파드의 네임스페이스만 고려하겠다는 뜻이 된다.

matchLabelKeys

이제 골이 더 아파진다!
matchLabelKeys, misMatchLabelKeys는 나온지 얼마 안 된 기능인데, 나오게 된 경위부터 살펴보자.[2]

    podAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchExpressions:
          - key: app
            operator: In
            values:
            - database
        topologyKey: topology.kubernetes.io/zone

이런 식으로 어피니티가 짜여진 디플로이먼트를 업데이트하는 상황을 생각해보자.
이 디플로이먼트는 파드들이 서로 가까이 있는 것이 요구되는 어플리케이션을 실행하고 있는 것이다.
근데 업데이트 간에는 레플리카셋을 통해 파드는 구버전과 새버전이 공존하는 기간이 생긴다.
이때 문제가 발생하는데, 스케줄러는 파드들의 버전들의 차이를 인지할 수 없어 최적의 스케줄링을 해주지 못한다.

그럼 어떻게 하지?
사실 여기에 한 가지 해결책이 있다.

    podAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchExpressions:
          - key: pod-template-hash
            operator: In
            values:
            - ???
        topologyKey: topology.kubernetes.io/zone

모든 디플로이먼트는 각 버전에 대해서 pod-template-hash라는 라벨을 추가적으로 두어 버전 별 파드들이 엮이지 않도록 만든다.
그럼 해당 라벨을 이용해 어피니티를 만들면 문제가 없을 것이다!
그런데... 이 값은 디플로이먼트가 업데이트될 때나 생성될 때 만들어지기 때문에, 디플 조작 명령만 내리는 우리로선 디플의 동작이 수행되기 이전까지 pod-template-hash라는 키에 대한 값을 알 수가 없다..

  affinity:
    podAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchExpressions:
          - key: app
            operator: In
            values:
            - database
        topologyKey: topology.kubernetes.io/zone
        matchLabelKeys:
        - pod-template-hash

이 문제를 해결하기 위해 나온 것이 바로 matchLabelKeys이다.
스케줄러는 명시된 키에 대한 값에 대해서는 확실하게 값이 라벨셀렉터로 제대로 들어가도록 보장해준다.
디플로이먼트 업데이트 명령이 api서버에 들어와서 etcd에 해당 정보가 저장됐다.
그럼 kube-controller-manager의 워크로드 컨트롤러가 동작하여 업데이트 간 새롭게 만들어져야 할 파드, 없어져야 할 파드들을 etcd에 업데이트하도록 api서버에 요청을 날릴 것이다.
이때 pod-template-hash 키가 새로운 파드들에 채워질 것이다.

kind: Pod
metadata:
  name: application-server
  labels:
    pod-template-hash: xyz
...
  affinity:
    podAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchExpressions:
          - key: app
            operator: In
            values:
            - database
          - key: pod-template-hash
            operator: In
            values:
            - xyz
        topologyKey: topology.kubernetes.io/zone
        matchLabelKeys:
        - pod-template-hash

스케줄러는 matchLabelKeys에 나온 키를 보고 해당하는 라벨에 대한 값을 labelSelector에 추가해준다.
위 예제를 보면 파드 라벨에 pod-template-hash가 생겼고, 이 값을 스케줄러가 그대로 labelSelector에 등록을 한 것이 보인다.

사용케이스를 기반으로 정리하자면 matchLabelKeys워크로드 업데이트 간 다른 버전 간 구분을 명확히 하기 위해 사용한다.

misMatchLabelKeys

위의 필드의 반대로 동작하는 것이 misMatchLabelKeys이다.
모든 파드들이 테넌트 간 분리를 명확히 하기 위해 tenant라는 키를 기반으로 라벨을 가진다고 쳐보자.
구체적으로 tenant라는 키 뒤에 어떤 값이 들어올지를 모르더라도 테넌트 간 명확한 노드 분리를 보장하고 싶다면 다음과 같이 필드를 사용하면 된다.

apiVersion: v1
kind: Pod
metadata:
  labels:
    # 관련된 모든 파드가 이 라벨의 키는 가지고 있다.
    tenant: tenant-a
...
spec:
  affinity:
    podAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
      # 같은 테넌트끼리는 무조건 같은 토폴로지 그룹에 배치된다.
      - matchLabelKeys:
          - tenant
        topologyKey: node-pool
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
      # 다른 테넌트끼리는 다른 토폴로지 그룹에 배치되도록 하기 위한 설정
	  # 이 파드가 실제 어떤 테넌트인지는 관리자가 모르더라도 간단하게 테넌트 간 분리를 달성할 수 있다.
      - mismatchLabelKeys:
        - tenant
		# tenent 라벨이 존재하는 파드들만 고려하기 위해서 이 필드를 또 넣어준다.
		# 이게 있어야 하는 이유는 아래에서 후술
        labelSelector:
          matchExpressions:
          - key: tenant
            operator: Exists
        topologyKey: node-pool

이렇게 세팅을 한다면 현재 내가 만드는 파드가 어떤 테넌트인지 일일히 신경쓰면서 양식을 작성하지 않아도 알아서 테넌트 간 분리가 달성된다.

        labelSelector:
          matchExpressions:
          - key: tenant
            operator: NotIn
            values:
            - tenant-a

misLabelMatchKeys는 이렇게 라벨 셀렉터를 추가해준다.
위에서 라벨셀렉터 추가 조건으로 tenant라는 키가 있어야 한다는 규칙을 넣었는데, 이게 바로 그 이유다.
저 규칙이 없으면 tenant란 라벨에 tenant-a가 없는 모든 파드들에 대해 안티어피니티가 설정되는데, 이건 테넌트 라벨이 붙지 않는 모든 파드들과 격리되도록 설정하는 꼴이다.
그래서 테넌트 라벨을 붙이지 않은, 그냥 노드 세팅을 위해 존재하는 데몬셋 파드까지 고려대상이 되어버리는 불상사가 발생할 수 있다.

사용 케이스를 기반으로 정리하자면 misLabelMatchKeys휴먼 에러 없이 명확하게 노드를 테넌시 별로 구분하여 사용하기 위해 사용한다.

스케줄링 프로필에서

apiVersion: kubescheduler.config.k8s.io/v1beta3
kind: KubeSchedulerConfiguration

profiles:
  - schedulerName: default-scheduler
  - schedulerName: foo-scheduler
    pluginConfig:
      - name: NodeAffinity
        args:
          addedAffinity:
            requiredDuringSchedulingIgnoredDuringExecution:
              nodeSelectorTerms:
              - matchExpressions:
                - key: scheduler-profile
                  operator: In
                  values:
                  - foo

이렇게 addedAffinity 필드에 똑같은 양식으로 적어주면 된다.

관련 문서

이름 noteType created

참고


  1. https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#PodAffinity ↩︎

  2. https://kubernetes.io/blog/2024/08/16/matchlabelkeys-podaffinity/ ↩︎