복사되었습니다.

Python으로 kubernetes 노드 선택 기능을 개발하는 방법

Cover Image for Python으로 kubernetes 노드 선택 기능을 개발하는 방법

클러스터를 구성하면 목적에 따라 노드의 역할을 구분하는 경우가 많다. 따라서, kubernetes에서 pod를 특정 노드에만 배포해야 한다는 제약사항은 당연히 나올 수 밖에 없다. Python을 통해 이를 구현하는 방법과 고려해야 할 점들에 대해 알아보자.

Introduction

노드 선택 기능의 필요성

Kubernetes 클러스터를 구성할 때 여러 노드를 사용한다면, 노드들이 서로 다른 역할을 수행하도록 구분하는 경우가 보통이다. 노드마다 하드웨어 스펙이 제각각인 경우 역할을 구분하여 사용하는 것이 전체적인 비용 절감이나 시스템의 효율성 향상으로 이어지기 때문이다.

예를 들어, 대용량의 메모리를 탑재한 노드는 사용자의 트래픽을 받아주는 웹 서버로 사용할 수 있다. 고성능의 CPU나 GPU를 탑재한 노드는 복잡하고 무거운 연산을 담당하는 것이 좋다. 그리고 대용량의 디스크를 탑재한 노드는 DB 서버로 사용하는 것이 보통이다. 때로는 이렇게까지 세분화하지 않고 단순히 master 노드와 worker 노드로 구분하는 경우도 있다. 따라서, 다양한 역할을 수행하는 서비스들을 목적에 맞는 노드에 배포하는 기능은 필수라고 할 수 있다.

어떤 서비스를 특정 노드에 배포하는 것은 개발자보다는 인프라 엔지니어의 영역에 가깝다고 생각할 수도 있다. 이 글에서는 사용자가 직접 특정 서비스를 띄우도록 요청하는 기능을 제공하는 일종의 플랫폼 소프트웨어를 개발한다고 가정하겠다. AWS에서 EC2를 띄우도록 요청하거나, GCP에서 Compute Engine을 띄우도록 요청하는 등 가상 머신 요청 기능을 생각하면 이해가 쉬울 것이다. 사용자가 요청한 VM이 특정 노드에만 배포되도록 만드는 것이다.

이 글에서 다루는 것

이 글에서는 사용자가 특정 pod를 배포하도록 요청하였을 때, 미리 지정된 일부 노드에만 배포되도록 유도하는 기능을 구현하는 방법에 대해서 다룬다. 이때, 사용자에게 할당할 노드 목록은 소프트웨어에서 동적으로 컨트롤할 수 있도록 만들어볼 것이다. Python의 kubernetes 패키지를 활용하여 최대한 간단한 코드 예제들을 작성해보고, 다양한 고민거리들을 소개해보겠다.

이러한 노드 선택 기능을 개발하는 방식은 다양하다. Taint, toleration, node selector, node affinity 등 다양한 도구들을 활용할 수 있다. 그런데, 이 글에서는 node label과 node affinity를 활용한 방법을 위주로 소개할 것이다. 이러한 의사결정을 한 이유에 대해서 한꺼번에 다루기에는 글의 분량이 너무 방대해지기 때문에 다른 글에서 자세히 다루도록 하겠다.

이 글은 taint, toleration, node affinity같은 "노드 제한" 방법들에 대한 기초적인 선행지식이 있다고 가정하고 이야기를 진행한다. 각 방식에 대한 자세한 설명은 다른 글에서 다룰 것이다. 단, 이 글을 이해하기 위해 깊이있는 내용이 필요한 것은 아니다. 적어도 이 글에서 주로 사용하는 node affinity와 node label이 어떤 원리로 동작하는지만 가볍게 알고 있으면 충분하다.

이 글에서 소개하는 간단한 코드 예제들을 활용하더라도 기본적인 사용자 시나리오를 구현할 수 있다. 그런데, 실제 제품을 개발한다는 측면에서는 턱없이 부족한게 현실이다. 글 후반부에서는 필자가 MLOps 제품 개발에 직접 참여하여 노드 선택 기능을 개발했던 경험을 담아 추가적으로 고려할 사항들에 대해서도 다루어보겠다.

두괄식 요약 정리

  • Node affinity와 node label을 통해 pod가 특정 node에 배포되도록 유도할 수 있다.
  • Node 목록 조회 시 label과 taint 필터링 로직은 직접 코드로 작성해야 한다.
  • Node label은 {domain}/{user}={namespace} 형식으로 정의한다.
  • 유지보수를 위해 node affinity는 yaml 파일이 아니라 코드로 삽입하는 것을 권장한다.
  • 소프트웨어에서 지정한 노드 목록과 실제 인프라 노드 목록의 불일치를 해소해야 한다.
  • Pod 강제 퇴출 정책에 대해 결정해야 한다.

문제 정의 및 분석

기능 요구사항 정의

이 글에서는 여러 노드를 kubernetes 클러스터로 구성했다고 가정하겠다. 그리고, 노드 선택 기능을 제공하는 app은 python으로 개발한다. 요구사항을 간단하게 정리해보면 다음과 같다. Kubernetes와 python으로 요구사항을 만족하려면 어떻게 해야 할까?

  • 시스템은 사용자마다 사용할 수 있는 노드들을 제한할 수 있어야 한다.
  • 사용자는 시스템에 pod를 띄우도록 요청할 수 있어야 한다.
  • 시스템은 사용자가 사용 가능한 노드들 중 하나에 pod를 배포할 수 있어야 한다.

시나리오 예시

노드: 총 3개
사용자: 총 2명

사용자1: 노드1, 노드2 사용 가능
사용자2: 노드2, 노드3 사용 가능
  • 노드1에는 반드시 사용자1의 pod만 존재해야 한다.
  • 노드2에는 사용자1과 사용자2의 pod가 동시에 존재할 수 있다.
  • 노드3에는 반드시 사용자2의 pod만 존재해야 한다.

구현해야 하는 동작

(1) 사용자는 시스템에 존재하는 노드 목록을 조회한다.
 -> 시스템은 클러스터에 존재하는 노드 정보를 조회하여 반환한다.
(2) 사용자가 시스템에 특정 노드를 사용하겠다고 요청한다.
 -> 시스템은 사용자가 요청한 노드를 해당 사용자에게 할당한다.
(3) 사용자가 시스템에 pod를 배포해달라고 요청한다.
 -> 시스템은 해당 pod를 사용자에게 할당된 노드 중 하나에 배포한다.

노드 선택 기능 구현

노드 목록 조회 기능

Python의 kubernetes 패키지를 사용하면 클러스터에 존재하는 노드의 목록을 조회하는 기능을 간단하게 구현할 수 있다. 문제를 간단하게 만들기 위해 클러스터에 존재하는 모든 노드들을 반환해주는 메서드를 만들어보자.

# node_selection.py

from kubernetes import client, config
from kubernetes.client.models.v1_node import V1Node
from typing import List

config.load_config()
core_v1_api = client.CoreV1Api()

def get_nodes() -> List[V1Node]:
  nodes = core_v1_api.list_node().items
  result = []
  for node in nodes:
    # Write filtering logic here.
    result.append(node)
  return result
>>> from node_selection import get_nodes
>>> nodes = get_nodes()
>>> [node.metadata.name for node in nodes]
['node1', 'node2', 'node3']

위와 같이 구현하고 끝이라면 좋겠지만, 실제 제품에 적용하기에는 아직 무리가 있다. 추가적으로 node에 붙어있는 label이나 taint들도 고려해주어야 한다. 쉽게 말해서, "사용 가능한 노드" 목록만 추릴 수 있어야 한다는 뜻이다. 예를 들어, 사용이 가능한 노드라는 의미를 담은 label이 붙어있는 노드들만 반환하도록 제한할 수 있어야 한다. 그리고, 일종의 master 노드같은 경우 특정 관리용 pod들 외에 다른 pod들의 배포를 허용하지 않는다는 taint가 붙어있는 경우가 있다. 이렇게 사용자의 의지와 상관없이 infra 레벨에서 사용이 제한되어 있는 노드들은 제외해야 한다.

특정 label이 붙어있는 노드들만 가져오는 방법은 다음과 같이 list_node 메서드에 label_selector라는 인자를 활용하면 된다. 그런데, 이 인자에는 복잡한 조건을 주기가 어렵다. 예를 들어, "label1"은 있지만, "label2"는 없는 노드라든가, "label3"의 값은 "value3"이면서 "label4"는 "value4"가 아닌 노드만 가져오는 등의 복잡한 조건은 설정할 수 없다.

core_v1_api.list_node(label_selector='label1=value1,label2=value2')

Taint의 경우에는 pod를 배포하는 단계에서 toleration을 통해 뚫을 수 있기 때문에 조건을 설계하는 것이 조금 더 복잡해진다. 예를 들어, 특정 taint가 붙어있는 노드들만 "사용 가능하다"는 의미를 부여한다고 생각해보자. 그렇다면 노드 목록을 조회할 때 해당 taint가 붙어있는 노드들만 가져오면 될 것이다. 그런데, 실제로 인프라에 이렇게 설정하게 되면 해당 taint가 붙어있는 노드에 배포하기 위한 모든 pod에는 toleration을 적용해줘야 한다. 오픈소스로 공개된 helm chart들을 배포할 때도 번거롭게 신경을 써줘야 한다는 얘기다.

$ kubectl taint nodes node1 key1=value1:PreferNoSchedule
node/node1 tainted
>>> node = core_v1_api.list_node().items[0]
>>> node.spec.taints
[{'effect': 'PreferNoSchedule',
 'key': 'key1',
 'time_added': None,
 'value': 'value1'}]
$ kubectl taint nodes node1 key1=value1:PreferNoSchedule-
node/node1 untainted
>>> node = core_v1_api.list_node().items[0]
>>> node.spec.taints is None
True

또, 만약 "사용 가능한 노드"라는 taint가 붙은 노드 중 일부에 또 다른 "taint2"가 붙어있다면 어떨까? 사용 가능한 노드 목록을 애써 반환해줬지만, "taint2"가 붙은 노드에는 pod를 배포할 수 없게 된다. 사용자에게 거짓말을 한 것이나 마찬가지다. 이러한 경우를 방지하기 위해서는 label selector와 마찬가지로 특정 taint를 가지면서 또 다른 taint는 없는 노드들만 추릴 수 있어야 한다. 결국 label과 taint에 대한 복잡한 필터링 로직을 직접 코드로 작성해주어야 한다는 얘기다. 필터링 로직은 "사용 가능한 노드"를 정의하는 정책적인 요소와 큰 관련이 있기 때문에 이 글에서는 다루지 않겠다. 각 플랫폼 정책에 맞는 필터링 코드를 휴리스틱하게 작성하자.

사용자 노드 지정 기능

사용자가 요청한 노드들을 해당 사용자에게 할당하기 위해서는 우선 사용자가 지정한 노드 목록을 저장할 곳이 필요하다. 보통의 경우 DB에 저장하게 되겠지만, 이 글에서는 쉬운 이해를 위해 간단히 python의 전역 변수로 선언한 dictionary에 저장하도록 하겠다.

node_assignments = {
  "user1": ["node1", "node2"],
  "user2": ["node2", "node3"],
}

단순히 python dictionary에 사용자와 노드 매핑 정보를 저장해둔다고 해서 자동으로 pod들이 적절한 곳에 배포되는 것은 아니다. 실제 kubernetes node에 별도의 설정을 해주어야 한다. Node 할당을 구현하는 방법은 매우 다양하지만, 이 글에서는 label과 node affinity를 사용한 방법을 위주로 소개하겠다. 이렇게 의사결정한 이유는 다른 글에서 자세히 설명하도록 하겠다. 우선 사용자 이름과 요청 노드 목록을 받아서 할당 과정을 진행해주는 메서드부터 살펴보자. 메서드의 코드 내용을 요약하면 다음과 같다.

  • 사용자 요청 노드 중 클러스터에 존재하지 않는 노드가 있다면 오류를 발생시킨다.
  • 전체 노드 중 사용자가 요청한 노드에 user_label을 붙인다.
  • 전체 노드 중 사용자가 요청하지 않은 노드에 user_label이 있다면 제거한다.
  • 사용자 노드 할당 정보를 업데이트한다.
domain = "littlemobs.com"
namespace = "default"

def assign_nodes_to_user(
  user: str,
  nodes: List[str],
) -> None:
  all_nodes = get_nodes()
  all_node_names = [node.metadata.name for node in all_nodes]
  user_requested_node_names = set(nodes)
  for user_requested_node_name in user_requested_node_names:
    if user_requested_node_name not in all_node_names:
      raise ValueError(f"Node '{user_requested_node_name}' is not in node list.")
  user_label = f"{domain}/{user}"
  for node in all_nodes:
    name = node.metadata.name
    labels = node.metadata.labels if node.metadata.labels else {}
    if name in user_requested_node_names and user_label not in labels:
      core_v1_api.patch_node(
        name=name,
        body={"metadata": {"labels": {user_label: namespace}}},
      )
    if name not in user_requested_node_names and user_label in labels:
      core_v1_api.patch_node(
        name=name,
        body={"metadata": {"labels": {user_label: None}}},
      )
  node_assignments[user] = list(user_requested_node_names)
>>> from node_selection import assign_nodes_to_user
>>> assign_nodes_to_user(user='user1', nodes=['node1'])
>>> node_assignments
{'user1': ['node1']}
$ kubectl get node node1 --show-labels
NAME               STATUS   ROLES                  AGE   VERSION         LABELS
node1              Ready    control-plane,master   16d   v1.27.15        ...,littlemobs.com/user1=default,...

코드에서 주의깊게 살펴볼 점이 몇 가지 있다. 우선 label을 정의하는 방식부터 살펴보자. Key와 value를 f"{domain}/{user}={namespace}" 형식으로 정의하였다. 다른 종류의 label들과 구분하기 위해 앱이나 플랫폼을 나타내는 domain을 붙여주었다. 참고로, kubernetes에서 자동으로 붙여주는 label들의 형식도 kubernetes.io/hostname=node1, kubernetes.io/os=linux처럼 domain을 앞에 붙여준다.

Label value에는 사실 아무것도 넣지 않아도 상관없다. 굳이 namespace 정보를 넣은 이유는 우리가 개발하는 앱 혹은 플랫폼이 특정 namespace에 배포되어 해당 namespace에만 관여한다고 가정했기 때문이다. 다시 말해서, 서로 다른 namespace에 독립적으로 앱 혹은 플랫폼을 배포해서 운영할 수 있는 구조로 만든다는 뜻이다. 그리고 이 namespace에 대한 정보는 뒤에서 설명할 노드 라벨 가비지 컬렉션에서 사용된다. 자세한 내용은 뒤에서 소개하겠다.

Python의 kubernetes 패키지를 통해 노드 라벨을 붙이거나 떼려면 patch_node 메서드를 사용하면 된다. body 인자에 key와 value를 dictionary 형태로 전달하면 되는데, value에 None을 넣으면 label을 제거하는 것으로 간주한다. 빈 값을 넣으려면 None 대신 빈 문자열 ""을 전달해야 한다.

Pod 배포 기능 개발

사용자의 요청을 받아서 pod를 배포해주어야 하므로, 우선 pod를 띄울 수 있는 기틀을 마련해야 한다. Python과 jinja2로 pod를 배포하는 방법은 이전 글에서 자세히 다루었으므로 먼저 읽어볼 것을 권한다. 이 글에서는 간단히 다음과 같은 yaml 파일과 문자열 치환을 활용하여 pod 배포 기능을 구현하겠다. 단순 pod보다는 deployment 형태로 배포하는 것이 이점이 많기 때문에 deployment 예제로 진행해보자.

# deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ${USER}
spec:
  replicas: 1
  selector:
    matchLabels:
      app: ${USER}
  template:
    metadata:
      labels:
        app: ${USER}
    spec:
      containers:
      - name: user-container
        image: alpine
        command: ["/bin/sh", "-c"]
        args: ["sleep 600"]

다음은 deployment를 배포하는 python 코드를 작성할 차례다. Deployment마다 사용자의 식별 정보가 필요한데, 복잡성을 최소화하기 위해 사용자의 이름과 동일한 이름의 deployment를 띄운다고 가정하겠다. 이렇게 하면 원하는 노드에 원하는 이름의 pod가 떴는지를 확인하면 되므로 테스트가 간편해질 것이다. 다음과 같이 간단하게 사용자의 이름을 받아서 default namespace에 deployment를 생성하는 메서드를 만들어보자.

from string import Template
import yaml

apps_v1_api = client.AppsV1Api()
manifest_path = "deployment.yaml"

def deploy(user: str, namespace: str = "default") -> None:
  with open(manifest_path) as f:
    manifest_str = Template(f.read()).substitute(
      USER=user,
    )
  manifest = yaml.safe_load(manifest_str)
  apps_v1_api.create_namespaced_deployment(
    namespace=namespace,
    body=manifest,
  )
>>> from node_selection import deploy
>>> deploy(user="test-user")
$ kubectl get deployments
NAME         READY   UP-TO-DATE   AVAILABLE   AGE
test-user   1/1     1            1           67s
$ kubectl get pods -o wide
NAME                         READY   STATUS    RESTARTS   AGE   IP            NODE               NOMINATED NODE   READINESS GATES
test-user-74bb4df4c5-h2cnf   1/1     Running   0          64s   10.42.0.175   node2   <none>           <none>

그런데, 이렇게만 구성하면 pod가 사용자 지정 노드에 배포되지 않고 무작위의 노드에 배포된다. 따라서, 특정 노드에만 배포될 수 있도록 pod 설정에 node affinity를 추가해줘야 한다. 이때 node affinity 설정을 추가해주는 위치에 대해 고민해봐야 한다. 가장 직관적인 방법은 yaml 파일에 node affinity 부분을 미리 작성해두는 것이다. 앞서 Template을 활용한 문자열 치환으로 적절한 user_label이 입력되도록 할 수 있다.

그런데, 이러한 방식을 사용한다면 플랫폼에서 사용자의 요청을 받아서 띄워주는 모든 pod의 yaml 파일들에 node affinity 설정을 일일이 작성해주어야 한다는 문제가 있다. 이를 잘 숙지하지 못한 다른 팀원들이 있다면, 매번 새로운 pod를 정의할 때마다 "왜 이상한 노드에 배포되지?"라는 문의가 반복적으로 발생하게 될 것이 뻔하다. 따라서, 코드는 다소 복잡해질 수 있지만 편의상 yaml파일이 아니라 코드 레벨에서 node affinity를 붙여주는 것을 권장한다.

from typing import Dict, Any

def add_node_affinity(user: str, affinity: Dict[str, Any]) -> None:
  # aliases
  nA = "nodeAffinity"
  rDSIDE = "requiredDuringSchedulingIgnoredDuringExecution"
  nST = "nodeSelectorTerms"
  mE = "matchExpressions"

  user_expression = {
    "key": f"{domain}/{user}",
    "operator": "Exists",
  }

  if nA not in affinity:
    affinity[nA] = {
      rDSIDE: {
        nST: [{mE: [user_expression]}],
      },
    }
    return None

  if rDSIDE not in affinity[nA]:
    affinity[nA][rDSIDE] = {
      nST: [{mE: [user_expression]}],
    }
    return None

  if nST not in affinity[nA][rDSIDE]:
    affinity[nA][rDSIDE][nST] = [{mE: [user_expression]}]
    return None

  for node_selector_term in affinity[nA][rDSIDE][nST]:
    if (
      mE in node_selector_term
      and user_expression not in node_selector_term[mE]
    ):
      node_selector_term[mE].append(user_expression)
  if len(affinity[nA][rDSIDE][nST]) == 0:
    affinity[nA][rDSIDE][nST].append(
      {mE: [user_expression]},
    )
  return None

코드가 다소 복잡하지만, 결국 핵심은 최종적으로 affinity[nA][rDSIDE][nST] = [{mE: [user_expression]}]} 형식의 node affinity 데이터를 manifest에 삽입해주는 것이라고 할 수 있다. 메서드 후반부의 코드들은 이미 다른 node affinity가 작성되어 있는 manifest에 충돌 없이 user_label 정보를 넣어주기 위한 작업이라고 보면 된다. Node affinity는 작성하는 사람의 자유이므로, 위 코드가 완벽하게 모든 상황을 충돌없이 해결해주진 못할 수도 있다. 기존 node affinity 값들과의 충돌을 의식해야 한다는 점을 강조하기 위해 rough하게 작성했다.

add_node_affinity 메서드를 사용하려면, python에서 yaml 파일을 통해 불러온 manifest에서 affinity가 들어가는 위치를 숙지하고 있어야 한다. deployment.yaml의 경우, manifest["spec"]["template"]["spec"]["affinity"]에 해당한다. 따라서, 앞서 미리 작성해둔 deploy 메서드에서 kubernetes client를 통해 배포를 요청하는 부분 직전에 다음 코드를 삽입해두면 된다.

if "affinity" not in manifest["spec"]["template"]["spec"]:
  manifest["spec"]["template"]["spec"]["affinity"] = {}
add_node_affinity(
  user=user,
  affinity=manifest["spec"]["template"]["spec"]["affinity"],
)

위 코드를 실행해보면, 이전과 달리 다음과 같이 생성되는 pod가 Pending 상태에 빠지게 되는 것을 확인할 수 있다. 이는 아직 사용자에게 아무런 노드도 할당하지 않은 상태이기 때문이다. 이 상태에서 assign_nodes_to_user 메서드를 실행시키면 pod가 지정된 노드에 정확히 배포되면서 Running 상태로 변경된다. 의도한 기능이 잘 동작한다고 볼 수 있다.

>>> deploy(user="test-user")
$ kubectl get pods -o wide
NAME                         READY   STATUS    RESTARTS   AGE   IP           NODE               NOMINATED NODE   READINESS GATES
test-user-5d86bbdfdf-6qj85   0/1     Pending   0          8s    <none>       <none>             <none>           <none>
>>> assign_nodes_to_user(user="test-user", nodes=["node1"])
$ kubectl get pods -o wide
NAME                         READY   STATUS              RESTARTS   AGE    IP           NODE               NOMINATED NODE   READINESS GATES
test-user-5d86bbdfdf-6qj85   0/1     ContainerCreating   0          9m2s   <none>       node1   <none>           <none>
$ kubectl get pods -o wide
NAME                         READY   STATUS    RESTARTS   AGE     IP            NODE               NOMINATED NODE   READINESS GATES
test-user-5d86bbdfdf-6qj85   1/1     Running   0          9m23s   10.42.0.177   node1   <none>           <none>

추가 고려사항

소프트웨어와 인프라 사이의 동기화 문제

이 블로그에서는 DDD에 대한 여러 글들을 다루면서 소프트웨어와 인프라 사이의 의존성을 최대한 배제하여 격리시키는 구조를 지향해야 한다는 관점을 고수해왔다. 그런데, 아이러니하게도 "사용자의 요청을 받아서 특정 노드에 pod를 띄운다"라는 요구사항부터 굉장히 인프라의 냄새가 짙게 나지 않는가? Kubernetes의 경우 kubectl 명령어를 통해 쉽게 관리될 수 있고, 인프라 구성을 변경함에 따라 kubectl에서 조회되는 내용에 그대로 반영이 되기 때문에 소프트웨어와의 동기화 문제가 더욱 대두된다.

노드 선택 기능에서의 동기화 관련 문제는 다양한 경우에 발생할 수 있다. 앞서 살펴본 코드 예제들에서 사용자가 지정한 노드를 DB에다가 저장한다는 언급이 있었다. 이는 소프트웨어에서 정한 결정사항이다. 그런데, 만약 kubernetes 클러스터 자체에 인프라 구성이 변경되어서 노드의 이름이 변경되면 어떻게 될까? 소프트웨어에서 존재하지 않는 노드를 바라보고 있으므로 비정상적인 동작들이 발생할 수 있다. 또는, 노드에 붙여둔 label들이 초기화되거나 일시적인 네트워크 문제로 label을 붙이기에 실패했다면 어떻게 될까? 사용자가 요청한 pod는 무한 Pending 상태에 빠지게 될 것이다.

어려운 점은 인프라에서 변경된 정보를 어떻게 해석하는지가 주관적이기 때문이다. 이는 곧 소프트웨어를 통해 아름답게 자동으로 처리하는 것이 아니라, 정책적으로 결정해야 할 수도 있음을 암시한다. 예를 들어, DB에 저장된 node들 중 일부가 실제 인프라 환경에서 소실되었다고 생각해보자. 이 상태는 일시적인 network 문제로 인해 잠시 node가 down된 것이 원인일 수도 있다. 또는 인프라 담당자가 의도적으로 노드를 제거한 것일 수도 있다. 이렇게 원인이 불명확한 상태에서 DB에 남아있는 "쓰레기 data"는 어떻게 처리해야 할까? 정답은 없다. 정하기 나름이다.

Node label GC

필자는 이러한 고민들을 어떻게 해결했을까? 결론부터 말하면, 해결까진 아니고 보조 장치를 마련해두었다. 일종의 node label garbage collector라는 GC 스크립트를 bash로 작성해두고, 주기적으로 동기화 작업을 수행하도록 kubernetes의 cron job으로 만들어두었다. Cron job에서 주기적으로 수행하는 동작은 다음과 같다.

  • Namespace 별로 DB에 사용자 지정 노드 목록을 조회하는 쿼리를 날린다.
  • 클러스터의 전체 node들의 label 정보를 불러온다.
  • DB가 정답지라고 생각하고, 누락된 label들을 node에 붙이거나 잘못 붙어있는 label들을 node에서 제거한다.

앞서 잠깐 언급했던 user_label을 정의하는 부분에서 namespace를 값으로 쓰는 이유가 이 node label GC script 때문이었다. Label은 {domain}/{user}={namespace} 형식을 사용했는데, 이때 label key가 겹치면 안되므로 domain을 namespace마다 고유하게 변경하거나 user 부분을 UUID로 사용하는 등의 처리가 필요하긴 하다.

Node label GC 스크립트의 내용을 요약해보면, 소프트웨어에서 정한 정보를 정답이라고 믿는 것이 핵심이다. 다시 말해서, 사용자의 행동에 의해 결정된 사항 이외에 인프라 레벨에서 변경된 사항들은 기본적으로 신경쓰지 않는 것이다. "Mismatch로 인한 동기화"의 기준을 명확히 정하기 위함이다. 적어도 사용자가 선택한 노드 중 일부가 인프라에서 정상 상태라면 사용자의 pod가 무한 Pending에 빠지는 일은 예방할 수 있다.

그런데, 실제 인프라 노드가 변경되거나 빠져버리는 경우는 어떨까? DB에 여전히 "쓰레기 data"가 남는 문제는 node label GC script로도 해결이 되지 않는다. 이는 앞서 언급했듯이 정답이 없는 문제이므로, 최소한 사용자 경험을 해치지 않기 위해 경고 메시지를 내보내도록 처리하였다. 예를 들어, DB에 저장된 정보가 모두 invalid해졌다고 가정해보자. 사용자는 요청한 pod들이 전부 Pending 상태인 것을 확인하게 될 것이다. 원인 파악을 위해 자신이 선택한 노드 목록을 조회할 때, 시스템에서는 실제 인프라의 노드 label 정보와 DB 정보를 비교하여 mismatch가 발견되는 경우 관리자에게 연락하라는 경고 메시지를 표시해주도록 구현했다.

Pod 강제 퇴출 기능

이 글의 코드 예제를 통해 구현한 기능에는 의도하지 않은 node에 pod가 배포되어 있는 경우 강제로 퇴출시키는 내용은 포함되어 있지 않다. 사용자가 사용할 노드 목록을 수정하는 경우, 새로 갱신된 노드 목록 외의 노드에 배포되어 있던 pod는 어떻게 처리해야 할까? 이 질문에도 사실 정답은 없다. 정책적으로 결정할 문제이다. 만약 즉각적으로 pod를 퇴출시킨다면, 일부 사용자의 불만이 생길 수 있다. 그렇다고 pod를 퇴출시키지 않는다면, pod가 종료되거나 재시작되는 경우가 거의 없는 경우 "노드 선택 기능" 자체가 유명무실하게 된다. 시간이 지남에 따라 의도하지 않은 node에 배포되어 있는 pod들이 많이 쌓이게 될 것이다.

노드에서 pod를 강제 퇴출시키는 기능은 이 글에서 다룬 node affinity 방식 자체만으로는 구현할 수 없다. Kubernetes의 taint와 toleration을 사용해야 설정만으로 즉각적인 pod 퇴출을 구현할 수 있다. 자세한 내용은 다른 글에서 소개하도록 하겠다. 이 글에서 자세히 설명하지는 않았지만, 코드 예제의 node affinity를 삽입하는 부분을 보면 requiredDuringSchedulingIgnoredDuringExecution을 사용하고 있는 것을 확인할 수 있다. 이것은 문자 그대로 pod가 처음 배포되기 전에는 반드시 지켜야 하지만, 이미 배포되어 실행 중인 pod에는 적용하지 않는다는 뜻이 담겨있다. 쉽게 말해서, 노드 선택 기능은 pod의 최초 배포 시점에만 적용되는 것이지, pod 퇴출과는 관련이 없다는 뜻이다.

정리해보면, 정답이 없는 pod 퇴출 문제에 대해 이 글에서 작성한 코드 예시들은 방관하는 입장을 취한 것이라고 볼 수 있다. 별도로 추가적인 작업을 해주기 전까지는 의도하지 않은 node에 pod들이 배포되어 있게 된다. 이는 사용자들이 사용중인 pod를 강제로 종료하는 것에 대한 리스크를 지고 싶지 않기 때문이다. 엉뚱한 node에 배포된 pod로 인해 인프라 자원 부족 문제 등이 발생하더라도 그때서야 후속 조치를 하는 것으로 충분하다고 판단했다.

그렇다면 node affinity 방식을 선택한 상황에서 후속 조치로써의 pod 퇴출은 어떻게 실행할 수 있을까? Deployment를 통해 pod를 배포했다면, 간단히 replicas를 0으로 만들었다가 다시 원래대로 복구하여 pod가 다시 배포되도록 만들면 된다. 물론 이 과정도 python의 kubernetes 패키지로 수행할 수 있다. 동작 확인은 kubectl 명령어로 대신하겠다.

def restart_deployment(user: str, namespace: str = "default") -> None:
  deployment = apps_v1_api.read_namespaced_deployment(name=user, namespace=namespace) 
  original_replicas = deployment.spec.replicas
  apps_v1_api.patch_namespaced_deployment_scale(
    name=user,
    namespace=namespace,
    body={"spec": {"replicas": 0}},
  )
  apps_v1_api.patch_namespaced_deployment_scale(
    name=user,
    namespace=namespace,
    body={"spec": {"replicas": original_replicas}},
  )
$ kubectl scale --replicas=0 deployment test-user
deployment.apps/test-user scaled
$ kubectl get pods -o wide
NAME                         READY   STATUS        RESTARTS        AGE    IP            NODE               NOMINATED NODE   READINESS GATES
test-user-5d86bbdfdf-6qj85   1/1     Terminating   11 (109s ago)   121m   10.42.0.177   node1   <none>           <none>
$ kubectl scale deployment test-user --replicas=1 
deployment.apps/test-user scaled
$ kubectl get pods -o wide
NAME                         READY   STATUS    RESTARTS   AGE   IP            NODE               NOMINATED NODE   READINESS GATES
test-user-5d86bbdfdf-rmsmc   1/1     Running   0          14s   10.42.0.178   node1   <none>           <none>

Comments

    More Posts

    Argo workflow에 kubernetes resource request와 limit을 설정하는 방법

    Kubernetes 환경에서 argo workflow를 통해 파이프라인을 실행할 때는 자원 할당에 대한 고민이 필요하다. 그런데, 일반적인 방식으로는 의도대로 자원 할당이 되지 않는다. Kubernetes 환경의 안정적인 운영을 위해 argo workflow에 자원 설정을 하는 방법을 알아보자.

    Python FastAPI로 파일 업로드 및 다운로드 가능한 web server 개발하기

    FastAPI를 활용하면 파일을 업로드하거나 다운로드할 수 있는 web server를 매우 간단하게 구현할 수 있다. 예제 코드와 함께 최대한 간단하게 파일 업로드 및 다운로드 API를 구현하고 테스트하는 방법을 알아보자.

    안전하게 무한 루프 탈출하기 - Handling SIGTERM in kubernetes with python

    프로그램의 유형에 따라 명확한 종료 시점 없이 반복적인 작업을 수행해야 하는 경우가 있다. 그런데, 영원한 것은 없지 않나! 언젠가는 종료를 시켜야 한다면, 어떻게 해야 안전하게 무한 루프를 빠져나올 수 있는지 알아보자.

    Font Size