복사되었습니다.

Python으로 kubernetes 환경에 app 배포하기 - Kubernetes deployment with python

Cover Image for Python으로 kubernetes 환경에 app 배포하기 - Kubernetes deployment with python

Kubectl 명령어를 통해 kubernetes 환경에 다양한 구성요소들을 배포할 수 있지만, python으로 템플릿 코드를 짜두면 개발자들도 편리하게 배포를 자동화할 수 있다. Jinja2와 kubernetes 패키지를 통해 사용자 요청에 따라 kubernetes 환경에 pod들을 띄우는 기능을 만들어보자.

개발 환경 준비물

Python 패키지 설치하기

Python으로 kubernetes 환경에 app을 배포하는 방법은 다양하지만, 이 글에서는 내가 주로 사용하는 방식을 위주로 소개하도록 하겠다. 요약하자면, yaml 파일을 미리 특정 형식으로 저장해두고 이를 python으로 읽어서 kubernetes 패키지로 손쉽게 배포하는 방식이다. 이를 위해서는 python 환경에 세 가지 준비물이 필요하다. 다음 명령어를 통해 미리 설치해두어야 한다.

$ pip install Jinja2
$ pip install PyYaml
$ pip install kubernetes

잘 설치되었는지 확인하려면 python 터미널에서 다음 명령어를 실행해보면 된다. 에러 메시지 없이 잘 실행된다면 준비가 완료된 것이다.

>>> import jinja2
>>> import yaml
>>> import kubernetes

위 세 가지 패키지들의 용도를 요약하면 다음과 같다.

  • Jinja2: 풍부한 문법을 통해 코드 레벨에서 유연한 yaml 파일을 생성하기 위함
  • PyYaml: yaml 파일을 읽어서 python의 dict로 변환하기 위함
  • kubernetes: python 코드에서 kubernetes cluster와 상호작용하기 위함

Kubernetes config 불러오기

Python에서 kubernetes cluster에 접근하기 위해서는 load_config 메서드를 통해 연결 정보를 불러와야 한다. 이 메서드의 코드를 살펴보면, 우선 로컬 환경의 kubernetes config 파일을 조회해보고, 없을 경우 cluster 내부에서 사용되는 config를 조회해보는 식으로 진행된다. 해당 메서드 하나로 개발 환경과 운영 환경 모두에서 사용할 수 있다는 의미다.

로컬 개발 환경에서 테스트한다면, ~/.kube/config 경로에 파일이 잘 들어있는지 확인해보자. Kubernetes config 파일을 다른 경로에서 관리하고 있다면, 메서드를 호출할 때 config_file 인자에 해당 경로를 지정해주면 된다.

Python에서 kubernetes config를 잘 인식하고 있는지 확인하려면 다음 코드로 테스트해보면 된다. 만약, 아무런 응답이 없다면 kubernetes config가 제대로 설정되지 않아서 kubernetes cluster와 통신이 불가능한 상태를 의미한다. 정상적으로 통신이 가능한 상태라면 아래처럼 pod 목록이 출력되어야 한다.

from kubernetes import client, config

def test() -> None:
  config.load_config()
  core_v1_api = client.CoreV1Api()
  pods = core_v1_api.list_namespaced_pod(namespace="test")
  print(pods)
>>> test()
{'api_version': 'v1',
 'items': [],
 'kind': 'PodList',
 'metadata': {'_continue': None,
              'remaining_item_count': None,
              'resource_version': '966688',
              'self_link': None}}

패키지 사용법 기초

Jinja2 template 사용법

먼저 kubernetes에 배포할 deployment를 yaml 파일로 정의해두어야 한다. 설정 값들을 python 코드 레벨에서 자유롭게 변경할 수 있도록 jinja2 템플릿으로 파일을 만들어보겠다. 간단한 예제라면 jinja2 대신 python에서 기본적으로 제공하는 string.Template을 사용해서 간단하게 문자열 치환(substitute) 기능을 사용하는 것으로도 충분하다. Jinja2를 사용하는 이유는 풍부한 문법을 지원하기 때문이다. 조건문과 반복문을 활용해서 string.Template에 비해 더욱 세밀하고 자유도 높은 yaml 파일을 생성할 수 있다.

예를 들어, string.Template을 사용하면, 다음과 같이 빈 칸을 뚫어두고 사용자의 설정 값을 채워넣는 간단한 예제를 만들기 쉽다. Yaml 파일에 ${VARIABLE} 형식으로 빈칸을 뚫어두고, substitute 메서드에 keyword argument로 값을 넘겨주면 된다.

# manifest.yaml

values:
- hello: ${HELLO}
from string import Template
import yaml
from typing import Dict, Any

def test(filepath: str = "manifest.yaml") -> Dict[str, Any]:
  with open(filepath) as f:
    manifest_str = Template(f.read()).substitute(
      HELLO="world!",
    )
  return yaml.safe_load(manifest_str)
>>> test()
{'values': [{'hello': 'world!'}]}

Jinja2의 문법을 사용한다면, 다음과 같이 조건문과 반복문을 통해서 더욱 유연하게 yaml 파일을 생성할 수 있다. 빈칸은 {{ VARIABLE }} 형식으로 뚫어두면 되고, {%- if VARIABLE %} 형식으로 조건문을 주면 VARIABLE 값이 인자로 전달된 경우에만 제한적으로 yaml 파일의 내용이 생성되도록 만들 수 있다. 그 외에 값을 비교하는 조건문이나 반복문은 예제를 확인해보자.

# manifest.jinja2

values:
- hello: {{ HELLO }}
{%- if CUSTOM_VALUES %}
  {%- for custom_value in CUSTOM_VALUES %}
    {%- if custom_value.type == "A" %}
- {{ custom_value.key }}: {{ custom_value.value }}
    {%- elif custom_value.type == "B" %}
- key: {{ custom_value.key }}
  value: {{ custom_value.value }}
    {%- else %}
- type: unknown
    {%- endif %}
  {%- endfor %}
{%- endif %}
from jinja2 import Environment, FileSystemLoader

def test(dirpath: str = ".", filepath: str = "manifest.jinja2") -> Dict[str, Any]:
  loader = FileSystemLoader(dirpath)
  env = Environment(loader=loader)
  template = env.get_template(filepath)
  manifest_str = template.render(
    HELLO="world!",
    CUSTOM_VALUES=[
      {
        "type": "A",
        "key": "aaa",
        "value": "AAA",
      },
      {
        "type": "B",
        "key": "bbb",
        "value": "BBB",
      },
      {
        "type": "C",
      },
    ],
  )
  return yaml.safe_load(manifest_str)
>>> test()
{'values': [{'hello': 'world!'}, {'aaa': 'AAA'}, {'key': 'bbb', 'value': 'BBB'}, {'type': 'unknown'}]}

참고로, 조건문이나 반복문을 쓸 때 {%- %} 대신 {% %} 형식을 사용하면 yaml 파일을 생성했을 때 불필요한 공백(개행 문자)이 생기게 된다. 물론 yaml 패키지를 통해 python dict 형식으로 파싱할 때는 문제가 없지만, 되도록이면 {%- %} 형식을 사용하는 습관을 들이자.

Kubernetes 패키지로 deployment 띄우는 법

Yaml 파일이 준비되었다면, python의 kubernetes 패키지를 통해 쉽게 app을 배포할 수 있다. 우선 앞서 살펴본 template 방식을 사용하지 않고, 이미 완성되어 있는 간단한 yaml 파일로 테스트해보자. Docker image 중 sh를 사용할 수 있는 것들 중 아무거나 사용해서 sleep 300 명령어를 실행하는 간단한 app을 배포해보자.

# deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: test
spec:
  replicas: 1
  selector:
    matchLabels:
      app: test
  template:
    metadata:
      labels:
        app: test
    spec:
      containers:
      - name: test-container
        image: alpine
        ports:
        - containerPort: 8080
          name: http
          protocol: TCP
        command: ["/bin/sh", "-c"]
        args: ["sleep 300"]
def test(filepath: str = "deployment.yaml") -> None:
  config.load_config()
  apps_v1_api = client.AppsV1Api()
  with open(filepath) as f:
    manifest = yaml.safe_load(f.read())
  apps_v1_api.create_namespaced_deployment(
    namespace="default",
    body=manifest,
  )

PyYaml 패키지를 통해 yaml 파일을 읽어서 python의 dict 형식으로 변환한 뒤, kubernetes 패키지의 create_namespaced_deployment 메서드를 사용해서 default namespace에 간단하게 배포하였다. 위 코드를 실행하면 정상적으로 배포가 완료된 것을 확인할 수 있다.

$ kubectl get deployments
NAME   READY   UP-TO-DATE   AVAILABLE   AGE
test   1/1     1            1           89s
$ kubectl get pods
NAME                   READY   STATUS    RESTARTS      AGE
test-65b584ff6-m4tsn   1/1     Running   1 (68s ago)   6m11s

이렇게 deployment를 최초로 생성하는 것 외에도, read_namespaced_deployment, patch_namespaced_deployment, delete_namespaced_deployment 등의 메서드를 통해 배포된 app을 수정하거나 제거하는 등의 작업도 간단하게 수행할 수 있다. 자세한 내용은 kubernetes-client github를 참고하도록 하자.

Python으로 띄우는 deployment example

Jinja2로 highly configurable한 yaml 만들기

Jinja2 문법을 활용하면 kubernetes deployment에 필요한 다양한 값들을 yaml 파일에 동적으로 생성하기 쉽다. 참고용으로 가장 많이 사용하는 설정 값들을 위주로 예시를 하나 공유하도록 하겠다. 이 예시를 기반으로 상황에 맞게 수정해서 사용하면 편리할 것이다.

이름과 label같은 기본적인 metadata 외에도, 가용성을 위한 replica 수, cpu와 memory 등의 자원 할당 및 사용량 제한과 관련된 설정, volume mount와 관련된 설정, 그리고 port처럼 네트워크와 관련된 설정 등 최대한 다양한 것들을 담으려고 노력했다.

# deployment.jinja2

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ name }}
{%- if namespace %}
  namespace: {{ namespace }}
{%- endif %}
{%- if deployment_labels %}
  labels:
  {%- for deployment_label in deployment_labels %}
    {{ deployment_label.key }}: {{ deployment_label.value }}
  {%- endfor %}
{%- endif %}
spec:
  replicas: {{ replicas }}
  selector:
    matchLabels:
{%- for pod_label in pod_labels %}
      {{ pod_label.key }}: {{ pod_label.value }}
{%- endfor %}
  template:
    metadata:
      labels:
{%- for pod_label in pod_labels %}
        {{ pod_label.key }}: {{ pod_label.value }}
{%- endfor %}
    spec:
      containers:
      - name: {{ name }}
        image: {{ image }}
{%- if container_ports %}
        ports:
  {%- for container_port in container_ports %}
        - containerPort: {{ container_port.port }}
          name: {{ container_port.name }}
    {%- if container_port.protocol %}
          protocol: {{ container_port.protocol }}
    {%- endif %}
  {%- endfor %}
{%- endif %}
{%- if commands %}
        command:
  {%- for command in commands %}
        - "{{ command }}"
  {%- endfor %}
{%- endif %}
{%- if command_args %}
        args:
  {%- for command_arg in command_args %}
        - "{{ command_arg }}"
  {%- endfor %}
{%- endif %}
{%- if env_vars %}
        env:
  {%- for env_var in env_vars %}
        - name: {{ env_var.name }}
          value: "{{ env_var.value }}"
  {%- endfor %}
{%- endif %}
{%- if resources %}
        resources:
  {%- if "requests" in resources %}
          requests:
    {%- for request in resources.requests %}
            {{ request.resource_type }}: {{ request.quantity }}
    {%- endfor %}
  {%- endif %}
  {%- if "limits" in resources %}
          limits:
    {%- for limit in resources.limits %}
            {{ limit.resource_type }}: {{ limit.quantity }}
    {%- endfor %}
  {%- endif %}
{%- endif %}
{%- if volume_mounts %}
        volumeMounts:
  {%- for volume_mount in volume_mounts %}
        - mountPath: {{ volume_mount.mount_path }}
          name: {{ volume_mount.name }}
    {%- if "sub_path" in volume_mount and volume_mount.sub_path %}
          subPath: {{ volume_mount.sub_path }}
    {%- endif %}
    {%- if "read_only" in volume_mount %}
          readOnly: {{ "true" if volume_mount.read_only else "false" }}
    {%- endif %}
  {%- endfor %}
{%- endif %}
{%- if volumes %}
      volumes:
  {%- for volume in volumes %}
      - name: {{ volume.name }}
    {%- if volume.type == "pvc" %}
        persistentVolumeClaim:
          claimName: {{ volume.resource_name }}
    {%- elif volume.type == "nfs" %}
        nfs:
          path: {{ volume.path }}
          server: {{ volume.server }}
    {%- elif volume.type == "secret" %}
        secret:
          secretName: {{ volume.resource_name }}
    {%- elif volume.type == "config_map" %}
        configMap:
          name: {{ volume.resource_name }}
    {%- endif %}
  {%- endfor %}
{%- endif %}

Python으로 app 설정 및 배포

def test(dirpath: str = ".", filepath: str = "deployment.jinja2") -> None:
  config.load_config()
  apps_v1_api = client.AppsV1Api()
  loader = FileSystemLoader(dirpath)
  env = Environment(loader=loader)
  template = env.get_template(filepath)
  namespace = "default"
  manifest_str = template.render(
    name="test",
    namespace=namespace,
    image="alpine",
    replicas=2,
    deployment_labels=[
      {"key": "app", "value": "test-deployment"},
      {"key": "type", "value": "deployment"},
      {"key": "owner", "value": "admin"},
    ],
    pod_labels=[
      {"key": "app", "value": "test-pod"},
      {"key": "type", "value": "pod"},
    ],
    container_ports=[
      {"name": "http", "port": 8000, "protocol": "TCP"},
      {"name": "unknown", "port": 8001},
    ],
    commands=["/bin/sh", "-c"],
    command_args=["sleep 300"],
    env_vars=[
      {"name": "CUSTOM_ENV1", "value": 1234},
      {"name": "CUSTOM_ENV2", "value": "Hello, World!"},
    ],
    resources={
      "requests": [
        {"resource_type": "cpu", "quantity": "500m"},
        {"resource_type": "memory", "quantity": "50Mi"},
      ],
      "limits": [
        {"resource_type": "cpu", "quantity": "2"},
        {"resource_type": "memory", "quantity": "1Gi"},
      ],
    },
    volume_mounts=[
      {"name": "logdir", "mount_path": "/var/log", "read_only": True}
    ],
    volumes=[
      {"name": "logdir", "type": "nfs", "path": "/nfs/share", "server": "0.0.0.0"},
      {"name": "local-storage", "type": "pvc", "resource_name": "test-pvc"},
      {"name": "secret-storage", "type": "secret", "resource_name": "test-secret"},
      {"name": "cm-storage", "type": "config_map", "resource_name": "test-cm"},
    ],
  )
  print(manifest_str)
  manifest = yaml.safe_load(manifest_str)
  apps_v1_api.create_namespaced_deployment(
    namespace=namespace,
    body=manifest,
  )

Python 코드 실행 결과

>>> test()
apiVersion: apps/v1
kind: Deployment
metadata:
  name: test
  namespace: default
  labels:
    app: test-deployment
    type: deployment
    owner: admin
spec:
  replicas: 2
  selector:
    matchLabels:
      app: test-pod
      type: pod
  template:
    metadata:
      labels:
        app: test-pod
        type: pod
    spec:
      containers:
      - name: test
        image: alpine
        ports:
        - containerPort: 8000
          name: http
          protocol: TCP
        - containerPort: 8001
          name: unknown
        command:
        - "/bin/sh"
        - "-c"
        args:
        - "sleep 300"
        env:
        - name: CUSTOM_ENV1
          value: "1234"
        - name: CUSTOM_ENV2
          value: "Hello, World!"
        resources:
          requests:
            cpu: 500m
            memory: 50Mi
          limits:
            cpu: 2
            memory: 1Gi
        volumeMounts:
        - mountPath: /var/log
          name: logdir
          readOnly: true
      volumes:
      - name: logdir
        nfs:
          path: /nfs/share
          server: 0.0.0.0
      - name: local-storage
        persistentVolumeClaim:
          claimName: test-pvc
      - name: secret-storage
        secret:
          secretName: test-secret
      - name: cm-storage
        configMap:
          name: test-cm
$ kubectl get pods
NAME                   READY   STATUS    RESTARTS   AGE
test-cbc4bbf7b-ghcc7   0/1     Pending   0          12s
test-cbc4bbf7b-mh7hq   0/1     Pending   0          12s

Comments

    More Posts

    Domain entity와 ORM을 따로 구분해서 정의하는 방법 - Domain entity with imperative ORM

    Domain entity를 정의할 때 declarative 방식의 ORM을 사용하면 domain model에 DB 관련 정보가 노출되는 문제가 있다. Python sqlalchemy를 활용하여 imperative mapping style로 domain entity로부터 ORM을 분리해보자.

    도메인 entity와 ORM을 동시에 추구하면 안 되는 걸까? - Domain entity with declarative ORM

    ORM을 사용하는 환경에서 DDD를 따르는 코드를 작성하다보면, domain entity를 어떤식으로 정의해야 하는지 혼란스러울 때가 있다. Python sqlchemy를 통해 declarative mapping 방식으로 domain entity를 어떻게 구현하면 좋은지 알아보자.

    영속성 domain entity를 정의할 때 상속을 사용해도 괜찮을까? - Repository code smell

    객체지향 프로그래밍에서는 코드 중복 최소화 및 다형성을 위해 인터페이스나 추상 클래스를 정의하고 상속을 사용하는 경우가 많다. 그러나, 이는 도메인 주도 설계(DDD) 관점에서 항상 바람직한 것은 아니다. 영속성 domain entity를 정의할 때 상속을 사용하는 것이 적절한지, 그리고 어떤 것들을 고려해야 하는지에 대해 논의해보자.

    Font Size