Outsider's Dev Story

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

CI/CD 파이프라인 엔진 Dagger의 Node.js SDK

지난 CI/CD 파이프라인 엔진 Dagger에서 Dagger에서 살펴봤는데 여기서 설명했던 Dagger CUE SDK는 현시점을 기준으로는 초기 Dagger CLI가 CUE SDK로 바뀐 거로 봐도 무방할 것 같다. 앞의 글에서도 얘기했듯이 CUE SDK도 0.3 버전에 맞춰서 바뀔 것이다.

v0.3부터는 Dagger 엔진을 두고 여러 언어의 SDK를 지원하는 방식을 취하고 있고 현재 Go SDK, Node.js SDK, Python SDK를 공개했고 GraphQL API도 공개한 상태이다. 저장소에서는 어떤 언어의 SDK를 지원하면 좋을지 설문을 받았다.

Dagger Node.js SDK

사용자 삽입 이미지

이전 글에서 보았듯이 Node.js SDK로 Dagger 엔진과 통신하게 된다. 사용 방법을 알기 위해 공식 문서를 따라해 보았다.

Node.js SDk 설치

Npm에 dagger가 등록되어 있다. 아직 많이 사용되지 않아서 검색으로는 잘 나오지 않고 현재 버전은 0.3.0이고 Node.js 16 이상을 지원한다. 일반적인 Node.js 패키지처럼 npm 혹은 yarn으로 설치할 수 있다.

$ npm install @dagger.io/dagger


Node.js SDK 간단한 파이프라인 사용해 보기

테스트해보려면 Node.js 프로젝트가 필요하므로 일단 npm init으로 초기화했다. 프로젝트는 그리 중요하지 않으니 기본값으로 초기화했다.

npm init -y
Wrote to /Users/outsider/dagger-nodejs-demo/package.json:

{
  "name": "dagger-nodejs-demo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

Dagger SDK를 설치하고 Dagger가 ES Modules만 지원하므로 프로젝트 타입을 module로 바꿔준다.

$ npm install -D @dagger.io/dagger
$ npm pkg set type=module

공식 문서에 나온 대로 Dagger를 사용하는 build.js를 생성해 보자.

// build.js
import { connect } from "@dagger.io/dagger"

// initialize Dagger client
connect(async (client) => {
  // get Node image
  // get Node version
  const node = client.container().from("node:16").withExec(["node", "-v"])

  // execute
  const version = await node.stdout()

  // print output
  console.log("Hello from Dagger and Node " + version)
})

위 예제에서 사용한 코드를 살펴보자.

  • connect() 함수로 Dagger 클라이언트를 만든다. 이 클라이언트를 이용해서 Dagger 엔진에 명령어를 보낼 수 있다.
  • 생성된 클라이언트에서 container().from() 새로운 컨테이너를 초기화한다. 여기서는 node:16 컨테이너 이미지를 사용했다. 이 함수는 OCI 호환 컨테이너 이미지인 Container를 반환한다.
  • Container.withExec() 함수로 컨테이너 안에서 실행할 명령어를 정의하고 명령어가 실행된 결과와 함께 Container 를 반환한다.
  • Container.stdout()로 출력 스트림을 가져올 수 있다.

이를 실행하면 컨테이너가 실행되고 받아온 Node.js 버전이 제대로 출력된 것을 볼 수 있다.

$ node build.js
Hello from Dagger and Node v16.19.0


React 앱 빌드하기

이제 기본적인 사용 방법은 알았으니 React 앱을 빌드해 보자. Create React App을 이용해서 데모로 사용할 프로젝트를 초기화하자. 앞에서 얘기한 대로 Dagger가 ES Modules만 지원하기 때문에 편의상 TypeScript를 사용했다.

$ npx create-react-app dagger-nodejs-react-demo --template typescript

Success! Created nodejs-react-app at /Users/outsider/dagger-nodejs-react-demo
Inside that directory, you can run several commands:

  npm start
    Starts the development server.

  npm run build
    Bundles the app into static files for production.

  npm test
    Starts the test runner.

  npm run eject
    Removes this tool and copies build dependencies, configuration files
    and scripts into the app directory. If you do this, you can’t go back!

We suggest that you begin by typing:

  cd nodejs-react-app
  npm start

Happy hacking!

이렇게 생성된 프로젝트의 디렉터리 구조는 다음과 같이 생겼다.

├── README.md
├── node_modules/
├── package-lock.json
├── package.json
├── public/
├── src/
└── tsconfig.json

Dagger SDK를 설치하고 프로젝트의 타입을 module로 설정했다.

$ npm install -D @dagger.io/dagger
$ npm pkg set type=module

TypeScript 파일을 실행하기 위해서 ts-node를 설치한다.

$ npm install -D ts-node

이제 Dagger로 앱을 테스트하고 빌드하기 위한 build.ts 파일을 생성한다.

// build.ts
import Client, { connect } from "@dagger.io/dagger"

// initialize Dagger client
connect(async (client: Client) => {
  // get reference to the local project
  const source = client.host().directory(".", { exclude: ["node_modules/"] })

  // get Node image
  const node = client.container().from("node:16")

  // mount cloned repository into Node image
  const runner = client
    .container({ id: node })
    .withMountedDirectory("/src", source)
    .withWorkdir("/src")
    .withExec(["npm", "install"])

  // run tests
  await runner.withExec(["npm", "test", "--", "--watchAll=false"]).exitCode()

  // build application
  // write the build output to the host
  await runner
    .withExec(["npm", "run", "build"])
    .directory("build/")
    .export("./build")
})

코드를 살펴보자.

  • connect(async (client) => {})로 Dagger 클라이언트를 생성한다.
  • client.host().directory(".", { exclude: ["node_modules/"] })는 호스트 머신(현재 머신)의 현재 디렉터리에 대한 참조를 가져온다. exclude 옵션으로 node_modules를 지정해서 호스트 머신의 node_modules는 제외한다.
  • client.container().from("node:16")으로 컨테이너를 초기화한다.
  • Container.withMountedDirectory("/src", source)로 호스트 머신의 현재 디렉터리를 /src로 마운트한다.
  • Container.withWorkdir()로 컨테이너의 워킹 디렉터리를 설정한다.
  • Container.withExec(["npm", "install"])로 컨테이너에서 npm install을 실행한다.
  • 새로 받은 Container 객체가 담긴 runner를 이용해서 runner.withExec()npm test를 실행한다.
  • Container.exitCode()는 명령어를 실행하고 종료 코드를 가져온다. 당연히 성공적으로 테스트가 실행되면 종료코드는 0이 된다.
  • Container.directory("build/")build/ 디렉터리에 대한 참조를 얻어서 Directory 객체를 반환한다.
  • Directory.export("./build")로 컨테이너의 빌드 폴더를 호스트 머신의 build 디렉터리에 작성한다.

이제 Dagger를 실행하기 위해 build.ts 파일을 실행해 보자.

$ node --loader ts-node/esm ./build.ts

앞에서 의존성을 설치한 뒤 테스트를 실행하고 빌드한 결과를 호스트 머신의 build 디렉터리에 복사하도록 했으므로 이를 실행하고 나면 로컬에 build 디렉터리가 생성된다. 이 디렉터리는 다음과 같이 생성된다.

├── build
│   ├── asset-manifest.json
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   ├── robots.txt
│   └── static

이는 Dagger 파이프라인에서 실행된 빌드 결과가 로컬에 잘 복사되었다.

지금까지는 정상적으로 실행되는 결과만 봤는데 0.3 버전이라고는 해도 0.3부터 SDK 방식으로 변경되기 시작했기 때문에 이제 첫 버전이나 마찬가지다. 그래서 간단히 테스트해본 느낌으로도 실제로 프로젝트에서 사용하기에는 아직 무리가 있다. Dagger에서 많이 고민했겠지만, 언어별로 파이프라인을 만든다는 것도 장점일 수도 있지만 단점일 수도 있다고 생각한다. 보통 CI/CD에서 많이 사용하는 YAML의 경우 프로젝트와 관련 없이 파이프라인을 공유해서 사용할 수도 있지만 언어별 SDK를 쓰는 경우에는 프로젝트의 언어에 맞는 파일로 변환하거나 해당 언어에 맞는 의존성을 설치해야 한다는 문제가 있다고 생각한다.

대신 SDK를 이용하면 프로그래밍할 수 있기 때문에 다른 프로그램과 통합한다거나 원하는 대로 조작할 때 얻을 수 있는 장점도 있다고 생각한다. Dagger에서 가장 큰 장점으로 생각하는 부분은 CI에서 실행하는 파이프라인을 로컬에서도 똑같이 사용할 수 있다는 부분이다. 로컬에서는 잘 동작하지만, CI에서만 오류 나는 경우를 꽤 많이 겪게 되는데 이럴 때 로컬에서도 똑같이 실행할 수 있다는 부분은 꽤 큰 장점이라고 생각된다. 물론 로컬에서 환경이 잘 설정되어 있지만 컨테이너 띄워서 매번 파이프라인을 실행할 때의 불편함이 있기 때문에 로컬에서도 Dagger를 주로 사용하진 않을 것 같다.

앞에서 말한 대로 아직 SDK의 첫 버전이기 때문에 부족한 부분이 많다. 대표적으로 일부러 데모 프로젝트의 테스트를 실패하게 만들어서 실행하면 다음과 같이 GraphQLRequestError 오류가 발생한다. GraphQLRequestError는 클라이언트가 Dagger 엔진과 GraphQL과 통신을 통해 받은 에러인데 아직은 도움이 안 되는 너무 날것의 에러다.

$ node --loader ts-node/esm ./build.ts
file:///Users/outsider/dagger-nodejs-react-demo/node_modules/@dagger.io/dagger/dist/api/utils.js:5
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
                                                                ^
GraphQLRequestError: Error message
    at file:///Users/outsider/dagger-nodejs-react-demo/node_modules/@dagger.io/dagger/dist/api/utils.js:119:23
    at Generator.throw (<anonymous>)
    at rejected (file:///Users/outsider/dagger-nodejs-react-demo/node_modules/@dagger.io/dagger/dist/api/utils.js:5:65)
    at processTicksAndRejections (node:internal/process/task_queues:96:5) {
  cause: ClientError: process "docker-entrypoint.sh npm test -- --watchAll=false" did not complete successfully: exit code: 1: {"response":{"data":{"container":{"withMountedDirectory":{"withWorkdir":{"withExec":{"withExec":{"exitCode":null}}}}}},"errors":[{"message":"process \"docker-entrypoint.sh npm test -- --watchAll=false\" did not complete successfully: exit code: 1","locations":[{"line":13,"column":9}],"path":["container","withMountedDirectory","withWorkdir","withExec","withExec","exitCode"]}],"status":200,"headers":{}},"request":{"query":"\n        {\n        container (id: \"eyJmcyI6e..중략...eCJ9fQ==\") {\n      \n        withMountedDirectory (path: \"/src\",source: \"eyJsbGIiO...중략...V4In19\") {\n      \n        withWorkdir (path: \"/src\") {\n      \n        withExec (args: [\"npm\",\"install\"]) {\n      \n        withExec (args: [\"npm\",\"test\",\"--\",\"--watchAll=false\"]) {\n      \n        exitCode  }}}}}\n      }\n      "}}
      at /Users/outsider/dagger-nodejs-react-demo/node_modules/graphql-request/src/index.ts:498:11
      at step (/Users/outsider/dagger-nodejs-react-demo/node_modules/graphql-request/dist/index.js:67:23)
      at Object.next (/Users/outsider/dagger-nodejs-react-demo/node_modules/graphql-request/dist/index.js:48:53)
      at fulfilled (/Users/outsider/dagger-nodejs-react-demo/node_modules/graphql-request/dist/index.js:39:58)
      at processTicksAndRejections (node:internal/process/task_queues:96:5) {
    response: {
      data: [Object],
      errors: [Array],
      status: 200,
      headers: [Headers]
    },
    request: {
      query: '\n' +
        '        {\n' +
        '        container (id: "eyJmcyI6e..중략...eCJ9fQ==") {\n' +
        '      \n' +
        '        withMountedDirectory (path: "/src",source: "eyJsbGIiO...중략...V4In19") {\n' +
        '      \n' +
        '        withWorkdir (path: "/src") {\n' +
        '      \n' +
        '        withExec (args: ["npm","install"]) {\n' +
        '      \n' +
        '        withExec (args: ["npm","test","--","--watchAll=false"]) {\n' +
        '      \n' +
        '        exitCode  }}}}}\n' +
        '      }\n' +
        '      ',
      variables: undefined
    }
  },
  code: 'D100',
  requestContext: {
    query: '\n' +
      '        {\n' +
      '        container (id: "eyJmcyI6e..중략...eCJ9fQ==") {\n' +
      '      \n' +
      '        withMountedDirectory (path: "/src",source: "eyJsbGIiO...중략...V4In19") {\n' +
      '      \n' +
      '        withWorkdir (path: "/src") {\n' +
      '      \n' +
      '        withExec (args: ["npm","install"]) {\n' +
      '      \n' +
      '        withExec (args: ["npm","test","--","--watchAll=false"]) {\n' +
      '      \n' +
      '        exitCode  }}}}}\n' +
      '      }\n' +
      '      ',
    variables: undefined
  },
  response: {
    data: { container: [Object] },
    errors: [ [Object] ],
    status: 200,
    headers: Headers { [Symbol(map)]: [Object: null prototype] }
  }
}

파이프라인 실행이라고 하면 사용자가 알고 싶은 것은 어느 단계에서 오류가 발생했고 어떤 오류가 발생했는지 일 텐데 이 오류에서는 그 내용을 전혀 알기가 어렵다. 물론 프로그램으로 SDK를 제어하므로 코드 자체에 오류가 있을 수 있지만 명령어 실행 중 오류가 발생하는 것이 더 많을 텐데 그 내용을 알기 어렵다. 물론 Container.stdout()Container.stderr()가 있지만 예외가 발생해서 코드상 예외로 빠지므로 컨테이너에서 발생한 오류 메시지를 알기가 어렵다.(메시지만 보면 쉽게 수정할 수 있을텐데...) 실제 방법이 있는지는 몰라도 아직은 디버깅할 때 사용 방법을 파악하지 못했다.

2023/01/09 02:06 2023/01/09 02:06