Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

CDK for Kubernetes(CDK8s) 사용하기

Kubernetes를 사용하게 되면 매니페스트를 YAML로 작성해야 한다. 직접 YAML을 작성할 수도 있지만, 배포를 위해 컨테이너 이미지 태그도 업데이트해야 하고 상황에 따라 다른 매니페스트를 만들 수 있어야 관리하기 편하므로 보통 Helm, Kustomize, Cue등을 사용해서 YAML을 생성하곤 한다. 이러한 툴도 각자의 특성이 있지만 많은 상황에서 문제없이 사용할 수 있다.

한편 AWS에서 (개인적으로 작성하기가 몹시 어렵다고 생각하는) CloudFormation을 코드로 작성할 수 있게 하려고 CDK(Cloud Development Kit)를 만들어서 2019년에 공개했다. CloudFormation이 JSON이나 YAML로 작성했던데 반해 CDK는 TypeScript, Python, Java, .NET 등의 프로그래밍 언어로 작성할 수 있다. 프로그래밍 언어로 원하는 스택을 구성하면 CloudFormation 설정 파일을 만들 수 있다. 제약이 있는 템플릿 언어가 아니라 프로그래밍 언어이기 때문에 프로그래밍 언어의 장점을 그대로 이용할 수 있다.

CDK의 반응이 괜찮아서인지 작년 AWS는 CDK를 Kuberenetes에 도입한 CDK for Kubernetes(CDK8s)를 릴리스했다.

cdk8s 홈페이지


CDK for Kubernetes

CDK8s도 AWS에서 만들었지만, 지금은 CNCF의 샌드박스 프로젝트로 기부되었기 때문에 CNCF에서 관리된다. 그래서 저장소도 AWS가 아니라 cdk8s-team 아래에서 관리되고 있다.

앞에서 얘기한 대로 CDK8s는 CDK의 개념을 Kubernetes에 적용한 것이다. 현재 TypeScript, Python, Java를 지원하고 있는데(CDK8s는 TypeScript로 작성되었다.) 이러한 언어로 Kubernetes 매니페스트를 작성하면 YAML로 만들어 준다. CDK8s가 하는 것은 딱 여기까지다. 이렇게 만들어진 YAML을 어떻게 관리하고 적용할 것인가는 각자의 몫이지만 원하는 YAML을 만들었으니 상황에 따라 할 수 있는 방법은 많다.

현재 최신 버번은 v1.0.0-beta.10이다. 작년 말부터 베타버전을 릴리스하고 있으니 곧 정식 1.0.0이 나올 것 같다. 기능이 꽤 있는데 사용법을 먼저 살펴보자. 현재 베타 버전이니까 v1.0.0이 릴리스 되어도 기능이 크게 달라지진 않을 것이다.

TypeScript로 CDK8s 시작하기

여러 언어를 지원하지만 여기서는 TypeScript로 사용해 보려고 한다. 처음 사용하기 쉽게 cdk8s-cli를 제공하고 있다. npm으로 전역 설치를 할 수 있지만 전역 설치를 좋아하지 않아서 npx를 이용해서 프로젝트를 초기화한다. 예시를 위해 hello-cdk8s라는 디렉터리를 만들고 이 안에서 npx cdk8s-cli init typescript-app로 초기화를 실행한다. 여기서 typescript-app는 TypeScript 템플릿을 사용하겠다는 의미이고 다른 언어를 사용하려면 python-app이나 java-app을 지정해야 한다.

$ mkdir hello-cdk8s

$ cd hello-cdk8s

$ npx cdk8s-cli init typescript-app
Initializing a project from the typescript-app template

added 3 packages, and audited 11 packages in 3s
added 673 packages, and audited 684 packages in 14s

63 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

> hello-cdk8s@1.0.0 import
> cdk8s import

k8s

> hello-cdk8s@1.0.0 compile
> tsc


> hello-cdk8s@1.0.0 test
> jest "-u"

 PASS  ./main.test.ts
  Placeholder
    ✓ Empty (2 ms)

 › 1 snapshot written.
Snapshot Summary
 › 1 snapshot written from 1 test suite.

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   1 written, 1 total
Time:        2.63 s
Ran all test suites.

> hello-cdk8s@1.0.0 synth
> cdk8s synth

dist/hello-cdk8s.k8s.yaml
========================================================================================================

 Your cdk8s typescript project is ready!

   cat help         Print this message

  Compile:
   npm run compile     Compile typescript code to javascript (or "yarn watch")
   npm run watch       Watch for changes and compile typescript in the background
   npm run build       Compile + synth

  Synthesize:
   npm run synth       Synthesize k8s manifests from charts to dist/ (ready for 'kubectl apply -f')

 Deploy:
   kubectl apply -f dist/*.k8s.yaml

 Upgrades:
   npm run import        Import/update k8s apis (you should check-in this directory)
   npm run upgrade       Upgrade cdk8s modules to latest version
   npm run upgrade:next  Upgrade cdk8s modules to latest "@next" version (last commit)

========================================================================================================

단순히 템플릿 코드만 받는 게 아니라 npm 모듈도 설치하고 테스트도 실행하고 CDK8s에서 필요한 임포트와 TypeScript 컴파일까지 해준다.(오히려 너무 다 해줘서 어디까지가 템플릿인지 헷갈릴 수도 있다고 생각한다)

생성된 기본 파일은 아래와 같다. 파일이 많아 보이지만 ts 파일이 컴파일되어서 그렇고 기본 파일은 많지 않다.(node_modules는 제외했다.)

├── __snapshots__
│   └── main.test.ts.snap
├── cdk8s.yaml
├── dist
│   └── hello-cdk8s.k8s.yaml
├── help
├── imports
│   ├── k8s.d.ts
│   ├── k8s.js
│   └── k8s.ts
├── jest.config.js
├── main.d.ts
├── main.js
├── main.test.d.ts
├── main.test.js
├── main.test.ts
├── main.ts
├── package-lock.json
├── package.json
└── tsconfig.json


cdk8s import

임포트는 CDK8s를 사용할 때 알아야 할 중요한 부분 중 하나이다.

다음과 같은 Kubernetes 서비스 YAML을 생각해 보자. Service라는 kind를 설정하면서 API 버전도 있고 이에 따라 사용할 수 있는 필드가 정해져 있는데 이는 Kubernetes쪽에서 정의하고 Kubernetes 버전마다 지원하는 게 다르기도 하다.

apiVersion: v1
kind: Service
metadata:
  name: my-service
spec:
  selector:
    app: MyApp
  ports:
    - protocol: TCP
      port: 80
      targetPort: 9376

이러한 규약을 CDK8s가 모두 알고 있을 수는 없고(알고 있으면 아마 Kubernetes의 버전이 바뀔 때마다 CDK8s로 릴리스가 필요할 것이다.) Kubernetes에는 수많은 CRD(Custom Resource Definition)이 있으므로 이런 리소스의 필드는 미리 알 수도 없다.

import 기능은 Kubernetes 리소스의 정의를 코드에서 사용할 수 있도록 코드로 변환해서 가져오는 과정이다.

앞에서 생성된 파일에 이미 imports 폴더가 있었는데 이는 초기화 중에 임포트를 해서 생긴 것이다. imports 폴더를 지우고 다음 명령어를 실행해보자.

$ ./node_modules/.bin/cdk8s import --language typescript k8s
k8s

언어를 typescript로 지정해서 k8s를 임포트하면 imports/k8s.ts 파일이 생긴다.(아직 컴파일하지 않아서 .js 파일은 없다) 이 파일을 열어보면 많은 코드가 있지만, 앞에서 본 서비스를 보면 아래처럼 KubeService라는 이름으로 리소스가 정의되어 있다. 이를 가져와서 코드로 Kubernetes 서비스를 정의하면 (TypeScript이므로) 타입도 검사하고 잘못된 필드도 검사할 수 있게 된다.

export class KubeService extends ApiObject {
  public static readonly GVK: GroupVersionKind = {
    apiVersion: 'v1',
    kind: 'Service',
  }

  public static manifest(props: KubeServiceProps = {}): any {
    return {
      ...KubeService.GVK,
      ...props,
    };
  }

  public constructor(scope: Construct, id: string, props: KubeServiceProps = {}) {
    super(scope, id, KubeService.manifest(props));
  }
}

위에서 임포트할 때 cdk8s import --language typescript k8s 명령어를 사용했는데 이해를 위해서 필요한 옵션을 다 지정한 것이고 프로젝트에는 cdk8s.yaml이라는 설정 파일이 있다.

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

여기서 언어와 임포트할 Kubernetes API 객체나 CRD가 정의되어 있으므로 cdk8s import만 실행하면 인자를 주지 않아도 똑같이 k8s를 TypeScript로 임포트한다. 물론 이 명령어는 package.json에 스크립트로 지정되어 있으므로 npm run import를 실행해도 똑같다.

"scripts": {
  "import": "cdk8s import",
}

특정 버전을 사용하고 싶으면 k8s@1.18.0처럼 지정할 수 있다.

CRD의 경우는 CRD의 YAML을 지정해주어야 한다. 예를 들어 Knative라고 릴리스serving-crds.yaml 파일을 제공하고 있다. cdk8s.yaml 파일에 이 YAML 파일을 추가하면 임포트할 수 있다.

language: typescript
app: node main.js
imports:
  - k8s
  - https://github.com/knative/serving/releases/download/v0.21.0/serving-crds.yaml

임포트를 실행하면 Knative도 같이 임포트 되는 것을 볼 수 있다.

$ npm run import

> hello-cdk8s@1.0.0 import
> cdk8s import

k8s
autoscaling.internal.knative.dev
  autoscaling.internal.knative.dev/metric
  autoscaling.internal.knative.dev/podautoscaler
caching.internal.knative.dev
  caching.internal.knative.dev/image
networking.internal.knative.dev
  networking.internal.knative.dev/certificate
  networking.internal.knative.dev/ingress
  networking.internal.knative.dev/serverlessservice
serving.knative.dev
  serving.knative.dev/configuration
  serving.knative.dev/revision
  serving.knative.dev/route
  serving.knative.dev/service

Kubernetes YAML 생성

템플릿으로 생성된 main.ts 파일을 보면 다음과 같이 작성되어 있다.

import { Construct } from 'constructs';
import { App, Chart, ChartProps } from 'cdk8s';

export class MyChart extends Chart {
  constructor(scope: Construct, id: string, props: ChartProps = { }) {
    super(scope, id, props);

    // define resources here

  }
}

const app = new App();
new MyChart(app, 'hello-cdk8s');
app.synth();

여기서 Construct라는 것을 쓰고 있는데 Construct는 CDK8s의 기본 빌딩 블록이다. Kubernetes의 많은 리소스를 Construct로 추상화해서 조합하거나 공유해서 사용할 수 있다. 여기서 Chart(Heml의 영향인가?)도 Construct를 상속받았으므로 MyChart 클래스도 Construct 블록이라고 할 수 있다. 그리고 Chart 하나가 하나의 Kubernetes manifest 즉, 하나의 YAML 파일이 된다.

synth

MyChart를 앱에 추가하고 hello-cdk8s라는 이름을 주고 synth()를 실행했다. 이 함수가 YAML 파일을 생성한다.

이제 node main.js로 이 파일을 실행했다. (JS 파일을 지정했으므로 파일을 수정했다면 npm run compile로 TypeScript를 컴파일해 주어야 한다.) dist/hello-cdk8s.k8s.yaml 파일이 생성된 것을 볼 수 있다. 물론 아무것도 정의하지 않았으므로 파일은 빈 파일이다.

cdk8s.yaml 파일을 다시 보면 app 부분에 방금 실행한 node main.js 명령어가 지정된 것을 볼 수 있다.

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

이 설정이 있으므로 CLI를 이용해서 cdk8s synth를 실행하면 app에 지정한 명령어를 대신 실행해 준다. 이는 npm 스크립트로도 지정되어 있으므로 보통은 npm run synth를 실행하면 된다. 직접 node로 했을 때와는 달리 CLI를 이용하면 생성된 파일 목록을 출력해 준다.

$ npm run synth

> hello-cdk8s@1.0.0 synth
> cdk8s synth

dist/hello-cdk8s.k8s.yaml


Kubernetes 리소스 정의

빈 YAML을 생성하는 건 의미가 없으니까 Kubernetes manifest를 작성해 보자.

import { Construct } from 'constructs';
import { App, Chart, ChartProps } from 'cdk8s';
import { KubeDeployment, KubeService } from './imports/k8s';

export class MyChart extends Chart {
  constructor(scope: Construct, id: string, props: ChartProps = { }) {
    super(scope, id, props);

    new KubeDeployment(this, 'my-deployment', {
      metadata: {
        name: 'my-nginx'
      },
      spec: {
        selector: {
          matchLabels: {
            run: 'my-nginx'
          }
        },
        replicas: 2,
        template: {
          metadata: {
            labels: {
              run: 'my-nginx'
            }
          },
          spec: {
            containers: [
              {
                name: 'my-nginx',
                image: 'nginx',
                ports: [
                  { containerPort: 80 }
                ]
              }
            ]
          }
        }
      }
    });

    new KubeService(this, 'my-service', {
      metadata: {
        name: 'my-nginx',
        labels: {
          run: 'my-nginx'
        }
      },
      spec: {
        ports: [
          {
            port: 80,
            protocol: 'TCP'
          }
        ],
        selector: {
          run: 'my-nginx'
        }
      }
    });
  }
}

const app = new App();
new MyChart(app, 'hello-cdk8s');
app.synth();

앞에서 본 템플릿 파일에 간단한 Deployment와 Service를 정의했다. 많은 리소스가 있지만 사용 방법은 비슷하고 결국 클래스를 정의해서 상황에 따라 어떻게 조합하느냐에 달려 있으므로 여기서는 간단한 사용법만 살펴보겠다.

  • import { KubeDeployment, KubeService } from './imports/k8s'; : 임포트한 k8s 에서 사용할 KubeDeploymentKubeService를 가져왔다.
  • new KubeDeployment(this, 'my-deployment', {} : 리소스를 정의하면서 첫 인자는 스코프니까 this를 넘기면 되고 두 번째 인자는 ID이므로 원하는 값을 지정하면 되지만 같은 Chart 내에서 다른 ID와 겹치면 안 된다. 세 번째 인자로 정의할 인자를 넘기는데 실제 타입은 KubeDeploymentProps이다.

KubeService도 사용 방법이 같으므로 설명은 생략한다. 결국 각 필드를 JSON 리터럴로 정의하기 때문에 변수 등을 사용하는 것 외에 YAML로 작성하는 것과 무슨 차이가 있을 수 있나 생각할 수도 있지만, TypeScript로 각 필드가 정의되어 있기 때문에 자동완성도 되고 지정된 타입과 다른 타입을 입력하면 실행 전에도 오류가 나기 때문에 오타로 인한 실수를 줄일 수 있다.

replicas에 문자열 2를 입력하니 숫자타입이라고 나온 오류

TypeScript 코드를 수정했으므로 컴파일한 수 synth 명령어로 YAML을 생성하자.

$ npm run compile && npm run synth

> hello-cdk8s@1.0.0 compile
> tsc


> hello-cdk8s@1.0.0 synth
> cdk8s synth

dist/hello-cdk8s.k8s.yaml

이제 dist/hello-cdk8s.k8s.yaml을 확인하면 아래처럼 선언한 manifest가 출력된 것을 볼 수 있다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-nginx
spec:
  replicas: 2
  selector:
    matchLabels:
      run: my-nginx
  template:
    metadata:
      labels:
        run: my-nginx
    spec:
      containers:
        - image: nginx
          name: my-nginx
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  labels:
    run: my-nginx
  name: my-nginx
spec:
  ports:
    - port: 80
      protocol: TCP
  selector:
    run: my-nginx


테스트 작성

템플릿으로 프로젝트를 초기화할 때 main.test.ts 테스트 파일도 생성되었다. 프로그램이므로 테스트를 원하는 대로 작성할 수 있지만, 기본으로 Jest 테스트 프레임워크스냅숏 테스트로 설정되어 있다. 초기화 때 생성된 __snapshots__ 아래 스냅숏 파일과 synth로 나온 출력을 비교해서 테스트를 수행하는데 템플릿의 테스트 파일은 아래와 같다.

import {MyChart} from './main';
import {Testing} from 'cdk8s';

describe('Placeholder', () => {
  test('Empty', () => {
    const app = Testing.app();
    const chart = new MyChart(app, 'test-chart');
    const results = Testing.synth(chart)
    expect(results).toMatchSnapshot();
  });
});

npm test로 테스트를 실행하면 되는데 코드를 수정했으므로 당연히 테스트가 깨진다. 여기서 테스트 결과가 달라진 것은 의도된 상황이므로 스냅숏을 업데이트해줘야 한다. npm testjest 명령어를 실행하므로 --updateSnapshot을 추가하기 위해 --로 추가했다.

$ npm test -- --updateSnapshot

> hello-cdk8s@1.0.0 test
> jest "--updateSnapshot"

 PASS  ./main.test.ts
  Placeholder
    ✓ Empty (3 ms)

 › 1 snapshot updated.
Snapshot Summary
 › 1 snapshot updated from 1 test suite.

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   1 updated, 1 total
Time:        1.043 s
Ran all test suites.

__snapshots__/main.test.ts.snap 파일이 업데이트되었으므로 이후 테스트는 성공한다.

$ npm test

> hello-cdk8s@1.0.0 test
> jest

 PASS  ./main.test.ts
  Placeholder
    ✓ Empty (4 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   1 passed, 1 total
Time:        0.902 s, estimated 1 s
Ran all test suites.




어떻게 추상화해서 사용할 것인가는 다른 얘기지만 사용법은 어렵지 않아서 금방 지원할 수 있다. 내가 테스트 할 때는 일부 CRD에서 필드가 제대로 지정되지 않는 이슈가 있었는데 이런 부분도 우회해서 임의로 필드를 지정할 수 있었다. Helm 같은 도구도 편하게 Kubernetes YAML을 작성할 수 있고 추가로 제공하는 기능도 많이 있지만 프로그래밍 언어로 사용할 수 있다는 점은 꽤 매력적으로 느껴졌다. 개인적으로 가장 유용한 분분은 다른 프로그램에 내장시켜서 YAML을 생성하는 기능을 구현해야 할 때일 것 같다.

2021/03/18 03:12 2021/03/18 03:12