이번 포스팅에서는 cdk8s를 활용해 Kubernetes에 Python Application을 배포하는 방법에 관해 다루려고 합니다.

우선 Kubernetes에 Python Application을 배포하기 위해서는

첫번째, wsgi(ex. uwsgi. gunicorn)와 web server(ex.nginx)를 구성해야하고

두번째, 위와 같은 요소들을 오브젝트화 하여 yaml파일형태로 만들어줘야 합니다.

보통 Kubernetes 클러스터 관리 플랫폼(ex. Rancher)에서 UI를 통해 배포할 수도 있지만 인프라의 일관성의 향상과 코드를 통한 버전관리에 그 이점이 있다고 볼 수 있습니다.

그리고 제 기준에서는 cdk8s를 통해 Chart구성을 하면서 Kubernetes 배포에 대해 이해를 하는데 도움이 많이 되었습니다. 혹시 Kubernetes에 웹 서버를 배포하시려는 분이 있으시다면 한 번 도전해보시는 것도 좋을 것 같습니다.

사용환경

  • Code Editor: vscode
  • Language: TypeScript

CDK8S 환경 세팅


cdk8s 프로젝트를 생성하기 위해서는 공식문서를 보며 기본적인 세팅을 하셔야 되는데요.

공식문서를 통해 cdk8s 설치진행하신 후에 프로젝트를 생성하고자 하는 경로에서 cdk8s init으로 프로젝트를 생성하시면 됩니다.

$ mkdir infra-test
$ cd infra-test
$ cdk8s init typescript-app

cdk8s init이 성공적으로 실행이 된다면 생성한 폴더에 cdk8s 프로젝트 세팅이 됩니다.

처음엔 빌드시에 함께 생성되는 js파일들이 있을텐데요.

저 같은 경우에는 build시 생성되는 불필요한 js파일을 삭제하도록 했습니다. clean 하는 부분을 build script에 삽입하고 Build하면 깔끔히 정리가 되어 보기에 훨씬 좋습니다:)

{
  "name": "k8s-deployment",
  "version": "1.0.0",
  "main": "main.js",
  "types": "main.ts",
  "license": "Apache-2.0",
  "private": true,
  "scripts": {
    "import": "cdk8s import",
    "synth": "cdk8s synth",
    "clean": "tsc --build --clean",
    "compile": "tsc",
    "watch": "tsc -w",
    "test": "jest",
    "build": "npm run compile && npm run synth && npm run clean",
    "format": "prettier --parser typescript --single-quote --write .",
    "upgrade": "npm i cdk8s@latest cdk8s-cli@latest",
    "upgrade:next": "npm i cdk8s@next cdk8s-cli@next"
  },
  "dependencies": {
    "cdk8s": "^1.0.0-beta.5",
    "cdk8s-plus": "^0.33.0",
    "cdk8s-plus-17": "^1.0.0-beta.5",
    "constructs": "^3.3.161"
  },
  "devDependencies": {
    "@types/jest": "^27.0.2",
    "@types/node": "^16.10.3",
    "cdk8s-cli": "^1.0.0-beta.67",
    "prettier": "2.2.1",
    "jest": "^26.6.3",
    "ts-jest": "^26.4.4",
    "typescript": "^4.0.5"
  }
}

build 명령어를 통해 정상적으로 작동하는지 한 번 더 확인해줍니다.

npm run build

Project Setting


본격적으로 차트 작성을 해보도록 합니다.

main.ts에서 바로 차트 작성을 해도 되지만 좀 더 보기 좋게 깔끔하게 정리해서 작성해보도록 하겠습니다.

웹어플리케이션 배포를 위한 폴더를 따로 만들어주고 실제 차트작성을 위한 파일(chart.ts)과 port정보나 namespace등 차트작성에 필요한 인자를 관리하기 위한 파일(index.ts)로 나눠서 작성하도록 합니다.

$ mkdir backend-app
$ cd backend-app
$ touch index.ts chart.ts

Cluster Object 구성


아래 그림과 같이 내부적인 구조가 만들어져야 하는데 여기서 가장 하단의 Pod은 Nginx, uwsgi으로 구성하고 NodePort Service와 연결해주도록 합니다. End user는 클러스터 외부에서 Nodeport를 통해 접속이 가능합니다.

출처: [Kubernetes] 6. 쿠버네티스 Service란? (NodePort, nginx 실습)
출처: DJANGO WITH KUBENETES I: LOCAL - GUNICORN AND NGINX

Deployment는 Nginx, uwsgi 컨테이너를 하나의 Pod으로 구성합니다.

본격적으로 차트 작성에 앞서 imports 폴더 아래 k8s.ts 파일을 열어보면 수많은 코드가 있는데 차트 작성에 필요한 리소스들이 정의되어 있는걸 볼 수가 있습니다. 다시 말해 리소스를 코드에서 사용할 수 있도록 정의되어 있으므로 필요한 리소스를 k8s에서 import해서 사용하면 되겠습니다.

이는 초기 프로젝트 세팅시에 cdk8s.yaml 파일이 생성되고 해당 설정에 맞춰 import된 것을 알 수 있습니다.

language: typescript
app: node main.js
imports:
  - k8s

Chart 작성


크게 Service, Deployment 정도로 구성해보기로 하겠습니다.

우선 구성에 필요한 부분은 import해줍니다.

import { Construct } from 'constructs';
import { Chart } from 'cdk8s';
import { Container, Quantity, ResourceRequirements, KubeDeployment, KubeService, IntOrString } from '../imports/k8s';

위에서 생성한 chart.ts, index.tschart.ts 에서는 직접적인 차트구성을 하고 index.ts에서는 chart.ts에서의 설정값들을 인자로 관리하고 넘겨주는 역할로 나누겠습니다. 아래와 같이 chart.ts에서는 index.ts에서 넘겨주는 인자들을 받아서 처리하도록 해줍니다.

이제chart.ts 에서 하나의 차트안에 필요 구성 오브젝트를 작성해보겠습니다. 여기서 하나의 Chart는 하나의 Kubernetes의 manifest(yaml파일)가 됩니다.

export interface BackendAppChartProps {
  readonly namespace: string; // Kubernetes namespace
  readonly image: string;  // Docker image URI
  readonly configName: string;  // Kubernetes config file name
  readonly containerPort: number;  // Container port
  readonly nodePort: number;  // Node port
};

export class BackendAppChart extends Chart {
  constructor(scope: Construct, id: string, props: BackendAppChartProps) {
      super(scope, id, props);
}

위에서 구성하기로 했던 Service, Deployment 중 Service먼저 작성해보겠습니다.

Nodeport 타입의 Service를 생성해주고

new KubeService(this, 'service', {
  spec: {
    type: 'NodePort',
    ports: [{
      name: props.containerPort + 'tcp' + props.nodePort,
      port: props.containerPort,
      targetPort: IntOrString.fromNumber(props.containerPort),
      protocol: 'TCP',
      nodePort: props.nodePort
    }],
    selector: label
  }
})

Selector에서 쓰일 label과 Node Affinity를 미리 정의해줍니다.

const label = {
      workloadName: `backend-app`
    };
      

// Node Affinity 설정
let affinity = {
  nodeAffinity: {
    requiredDuringSchedulingIgnoredDuringExecution: {
      nodeSelectorTerms: [
        {
          matchExpressions: [{
            key: "<Node Affinity Key>",
            operator: 'In',
            values: ["true"]
          }]
        }
      ]
    }
  }
}

nginx, uwsgi 각각의 Container를 정의한 뒤 Deployment의 containers로 정의해줍니다.

const backendAppContainer: Container = {
  image: props.image,
  name: `backend-app`,
  stdin: true,
  tty: true,
  command: ["nginx", "-c", "<nginx.conf file path>"],
  ports: [{name: `${props.containerPort}tcp${props.nodePort}`, containerPort: props.containerPort, protocol: 'TCP'}],
  envFrom: [
    {
      configMapRef: {
        name: props.configName,
        optional: false
      }
    }
  ],
  resources: backendAppResource
};

const uwsgiAppContainer: Container = {
  image: props.image,
  name: `uwsgi-app`,
  stdin: true,
  tty: true,
  command: ["uwsgi", "--ini", "<ini file path>"],
  envFrom: [
    {
      configMapRef: {
        name: props.configName,
        optional: false
      }
    }
  ],
  resources: uwsgiAppResource
};

new KubeDeployment(this, `backend-app`, {
  metadata: {
    namespace: props.namespace,
    name:`backend-app`
  },
  spec: {
    selector: {
      matchLabels: label
    },
    template: {
      metadata: { labels: {
        ...label,
        }
      },
      spec: {
        restartPolicy: "Always",
        affinity: affinity,
        containers: [
          backendAppContainer,
          uwsgiAppContainer
        ]
      }
    }
  }
})

그 뒤 index.ts 에서 위 정의된 props에 대한 정보를 넘겨주면 됩니다.

import { App } from 'cdk8s';
import { BackendAppChart } from './chart';

export const addBackendAppChart = (app: App) => {
  const namespace = "infra-test";

  new BackendAppChart(app, "backend-app", {
    namespace: namespace,
    configName: "infra-test-env",
    image: "<input your docker image>",
    containerPort: <input your container port>,
    nodePort: <input your node port>
  });
};

그리고 마지막으로 main.ts에서 위에서 작성한 차트를 synth해주기 위한 코드를 작성해주고

import { App } from 'cdk8s';
import { addBackendAppChart } from "./backend-app";

const app = new App();
addBackendAppChart(app);

app.synth();

변경 사항을 저장한 뒤 터미널에서 빌드해줍니다.

$ npm run build

성공적으로 빌드가 된다면 dist 폴더 아래 yaml파일이 생성되는데 이 파일을 배포해주면 됩니다.

gunicorn도 uwsgi와 크게 다르지 않습니다. uwsgi대신 gunicorn Container를 정의하고 아래와 같이 실행명령어만 수정해서 배포하면 완료입니다:)

const gunicornAppContainer: Container = {
  image: props.image,
  name: `gunicorn-app`,
  stdin: true,
  tty: true,
  command: ["gunicorn", "-c", "<gunicorn.conf.py file path>"],
  envFrom: [
    {
      configMapRef: {
        name: props.configName,
        optional: false
      }
    }
  ],
  resources: uwsgiAppResource
};

gunicorn.conf.py 파일을 직성하는 방법에 대해서는 공식문서를 참고하시면서 작성하시면 됩니다.

마치며


지금까지 cdk8s를 활용한 Chart작성 및 배포 파일 생성에 대해 알아봤는데요. Python Application 세팅이나 wsgi, nginx 설정에 대한 부분들도 다루기엔 다소 내용이 많을 것 같아 주제와 관련된 부분만 추려 작성해보았습니다. 추가적으로 사용하시는 인프라 환경에 따라 다소 설정하시는데에 차이가 있을 수 있으니 참고하시면 좋을 것 같습니다.

끝으로 해당 포스팅을 준비하면서 여러 시행 착오도 겪었었지만 쿠버네티스 오브젝트에 대한 이해나 인프라 네트워크에 대한 이해를 하는데 좋은 기회가 되었던 것 같습니다:)

감사합니다.