Dagger で Node.js アプリをビルドする

CI/CD のパイプラインを定義するためのツール Dagger を使って Node.js アプリのビルドを試してみました。

  • Dagger v0.2.7

今回使用したソースは こちら

sample1. echo の実施

まずは以下の処理を Dagger で実施してみます。

  • (1) ローカルの input.txt ファイルの内容を取得
  • (2) alpine イメージでコンテナを実行し、(1) に _echo を付けた文字列を echo して /tmp/output.txt へ出力
  • (3) コンテナの /tmp/output.txt の内容を取得し、ローカルの output.txt ファイルへ出力

Dagger プロジェクト作成

下記コマンドを実行して Dagger の実行に必要なファイルを作成しておきます。

$ dagger project init
$ dagger project update

project init で cue.mod が作成され、project update で cue.mod/pkg 内へ dagger.iouniverse.dagger.io のコアモジュールがインストールされます。

パイプライン定義

Dagger では CUE で CI/CD パイプラインを定義する事になっており、dagger.#Plan & { パイプラインの内容 } という風に記述します。※

CUE は一部 Go 言語風なので紛らわしいのですが、パワーアップした JSON のようなものだと捉えておくと理解し易いかもしれません。

 ※ 個人的に、dagger.#Plan の内容(cue.mod/pkg/dagger.io/dagger/plan.cue 参照)に対して
    { ・・・ } の内容をマージしているのだと解釈しています

パイプラインの処理は dagger.#Plan の actions で定義する事になっており、アクション名(下記の sample)は任意の名称を付けられるようです。(dagger do 時にアクション名を指定)

actions の処理はコンテナ内で実行する事になるので、ローカルのファイルや環境変数をコンテナとやりとりするために client が用意されています。

前述の (1) ~ (3) を定義すると以下のようになりました。

sample.cue
package sample1

import (
    "dagger.io/dagger"
    "dagger.io/dagger/core"
)

dagger.#Plan & {
    actions: {
        sample: {
            // (2) alpine イメージの pull
            _alpine: core.#Pull & {
                source: "alpine:3"
            }
            // (1) ローカルの input.txt ファイルの内容
            msg: client.filesystem."input.txt".read.contents
            // (2) コンテナで echo を実行し /tmp/output.txt へ出力 
            echo: core.#Exec & {
                input: _alpine.output
                args: [
                    "sh", "-c",
                    "echo -n \(msg)_echo > /tmp/output.txt",
                ]
                always: true
            }
            // (3) コンテナの /tmp/output.txt の内容を取得
            result: core.#ReadFile & {
                input: echo.output
                path: "/tmp/output.txt"
            }
        }
    }
    client: {
        filesystem: {
            // (1) ローカルの input.txt ファイルの内容を取得(ファイルの内容を取得するために string を設定)
            "input.txt": read: contents: string
            // (3) ローカルの output.txt ファイルへ出力
            "output.txt": write: contents: actions.sample.result.contents
        }
    }
}

コンテナの実行に core.#Exec、コンテナで出力したファイルの内容を取得するために core.#ReadFile を使っています。

client.filesystem.<パス>.read.contents の値を string とする事で ※、パスで指定したローカルファイルの内容を文字列として参照できます。

 ※ dagger.#FS とするとファイルシステムを参照できる

なお、前処理の output を次の処理の input に繋げていく事でパイプライン(処理の順序)を実現しているようです。

実行

それでは、実行してみます。

input.txt の内容を以下のようにしました。

input.txt
sample1

dagger do で任意のアクションを実行します。

実行例
$ dagger do sample
[✔] actions.sample                        ・・・
[✔] client.filesystem."input.txt".read    ・・・
[✔] actions.sample.echo                   ・・・
[✔] actions.sample.result                 ・・・
[✔] client.filesystem."output.txt".write  ・・・

正常に終了し、ローカルファイル output.txt が作成されたので内容を確認すると以下のようになりました。

output.txt の内容確認
$ cat output.txt
sample1_echo

sample2. Node.js アプリのビルド

それでは、本題の Node.js アプリのビルドです。

Node.js アプリ

ここでは、以下の TypeScript ファイルを esbuild を使って Node.js 用にビルドする事にします。

src/app.ts
import * as E from 'fp-ts/Either'

console.log(E.right('sample2'))

今回、esbuild による処理は package.json の scripts で定義しました。

package.json
{
  "scripts": {
    "build": "npx esbuild ./src/app.ts --bundle --platform=node --outfile=build/bundle.js"
  },
  "devDependencies": {
    "esbuild": "^0.14.37"
  },
  "dependencies": {
    "fp-ts": "^2.11.10"
  }
}

パイプライン定義

ここでは、以下の処理を実施します。

  • (1) ローカルの package.jsonsrc ディレクトリをコンテナの /app へコピー
  • (2) npm install を実行
  • (3) npm run build を実行
  • (4) コンテナの /app/build の内容をローカルの _build へ出力

sample1 ではファイル単位でローカル側とやりとりしましたが、こちらはディレクトリ単位で実施するようにしています。

client.filesystem.".".read.contentsdagger.#FS とする事でローカルのカレントディレクトリを参照し、include を使って package.jsonsrc 以外を除外しています。

ローカルのディレクトリとファイルをコンテナ環境へコピーするために core.#Copy、コンテナの /app/build の内容を参照するために core.#Subdir を使っています。

node_build.cue
package sample2

import (
    "dagger.io/dagger"
    "dagger.io/dagger/core"
)

dagger.#Plan & {
    actions: {
        build: {
            _node: core.#Pull & {
                source: "node:18-alpine"
            }
            // (1) ローカルの package.json と src ディレクトリをコンテナの /app へコピー
            src: core.#Copy & {
                input: _node.output
                contents: client.filesystem.".".read.contents
                dest: "/app"
            }
            // (2) npm install を実行(/app をカレントディレクトリに指定)
            deps: core.#Exec & {
                input: src.output
                workdir: "/app"
                args: ["npm", "install"]
                always: true
            }
            // (3) npm run build を実行(/app をカレントディレクトリに指定)
            runBuild: core.#Exec & {
                input: deps.output
                workdir: "/app"
                args: ["npm", "run", "build"]
                always: true
            }
            // (4) /app/build の内容を参照
            result: core.#Subdir & {
                input: runBuild.output
                path: "/app/build"
            }
        }
    }
    client: {
        filesystem: {
            // (1) ローカルの package.json と src を参照
            ".": read: {
                contents: dagger.#FS
                include: ["package.json", "src"]
            }
            // (4) ローカルの _build へ出力
            "_build": write: contents: actions.build.result.output
        }
    }
}

実行

dagger do で実行します。

実行例
$ dagger do build
・・・
[✔] actions.build                     ・・・
[✔] client.filesystem.".".read        ・・・
[✔] actions.build.src                 ・・・
[✔] actions.build.deps                ・・・
[✔] actions.build.runBuild            ・・・
[✔] actions.build.result              ・・・
[✔] client.filesystem."_build".write  ・・・

これにより、ローカルへ下記のファイルが出力されました。

_build/bundle.js
var __create = Object.create;
・・・
// src/app.ts
var E = __toESM(require_Either());
console.log(E.right("sample2"));

問題なく実行できました。

_build/bundle.js の動作確認例
$ cd _build
$ node bundle.js
{ _tag: 'Right', right: 'sample2' }