Nest CommanderをSingle Executable Applicationsとして実行してみた

ogp

この記事は enechain Advent Calendar 2023 の 3 日目の記事です。 enechainのeScanチームのソフトウェアエンジニアをしている西村が担当します。

私たちのチームは、モダンなバックエンド開発において、効率性とパフォーマンスを最大化するためにNestJSを採用しています。 今回はNestJS Commanderを利用し、Single Executable Applications(以降、SEAと呼びます)を作成する方法に焦点を当ててみたいと思います。

Node.jsのバージョン20では、新しい機能として「Single Executable Applications」が導入されました。 SEAの利点は多岐にわたります。一つの実行可能ファイルに全てを統合することで、デプロイメントが簡素化され、環境間での互換性の問題を最小限に抑えることができます。さらに、依存関係の管理が容易になり、アプリケーションの起動時間が短縮されるため、より迅速な開発とテストが可能になります。

本記事では、実際にNestJS Commanderで作られた簡単なアプリケーションをSEA化する手順を解説します。 また、実際のプロダクトをSEA化にあたり解決しなければならない課題点も見つかっているため、それについて解説したいと思います。

概要

NestJS Commanderについて

NestJS Commanderは、Node.jsでコマンドラインアプリケーションを簡単に作成できるライブラリであり、NestJSと組み合わせることで、コマンドラインからの入力を処理し、複雑なタスクを実行する強力なアプリケーションを構築できます。 これにより、開発者はコマンドラインツールの柔軟性を活かしつつ、NestJSの厳格なアーキテクチャと組み合わせることができます。

実際の使用例:Argo WorkflowsのノードでのCommanderの使用

私たちeScanチームでは、Kubernetes上のジョブ管理を行うワークフローエンジン「Argo Workflows」を活用しています。 Argo Workflowsの各ノードで、Node.jsによるNestJS Commanderを使用しています。この組み合わせにより、ワークフローの各ステップで必要なコマンドラインベースの操作を効率的に実行できます。 例えば、特定のデータ処理タスクやAPI呼び出しを、コマンドライン引数を通じて動的に制御することが可能です。 このアプローチにより、ワークフローの柔軟性と拡張性が大幅に向上しました。

argo

実際のプロダクトでは上図のワークフローが実行されており、各ノードはNestJS Commanderが使用されています。

問題点:Argo WorkflowsにおけるCommanderの起動遅延

私たちのチームでは、Argo Workflowsの各ノードでNestJSとCommanderを使用していますが、一つの問題に直面しています。 各ノードのPodでCommanderを起動する際に、起動が遅いという問題が生じており、結果として、各ノードの立ち上げにかかるオーバーヘッドが大きく、全体の効率性に影響を与えています。 例えば、問題の一つの要因として、NestJSの依存関係注入(DI)に時間がかかっている。という点が挙げられます。 結果として、各ノードの立ち上げにかかるオーバーヘッドが大きく、全体の効率性に影響を与えています。

解決策:スタートアップスナップショット

新たに導入されたSEAの機能、特に「スタートアップスナップショット」を利用することで、この問題を解決できる可能性があります。 スタートアップスナップショットを使用すれば、DIを完了した状態のアプリケーションを事前に準備し、その状態をスナップショットとして保存できます。 これにより、アプリケーションは既にDIが完了した状態で配布され、各ノードでの起動時間が大幅に短縮されることが期待されます。 結果として、各ノードの立ち上げオーバーヘッドが削減され、全体のワークフローがよりスムーズに実行される可能性が高まります。

NestJS CommanderをSingle Executable Applicationsにする

この記事で最終的に動くコードは、こちらのリポジトリ にあります。

セットアップ

今回は、SEAを使うためv20以上のNode.jsを使用します。NestJSとCommanderのインストールも行っていきます。

$ nodenv version
20.9.0 (set by /Users/nishimura/.ghq/src/github.com/ashigirl96/nest-commander/.node-version)
$ pnpm -v
8.11.0
$ pnpm add @nestjs/common @nestjs/core nest-commander
# typeScript と バンドラ の セットアップ
$ pnpm add -D esbuild typescript ts-node
# tsconfig.json作成
$ npx tsc --init

Commander(アプリケーション)の実装

次に、コマンドを作成します。

@Command({ name: 'basic', description: 'A parameter parse' })
export class BasicCommand extends CommandRunner {
  constructor() {
    super()
  }

  async run(
    passedParam: string[],
    options?: BasicCommandOptions,
  ): Promise<void> {
    this.runWithString(passedParam, options.string);
  }

  @Option({
    flags: '-s, --string [string]',
    description: 'A string return',
  })
  parseString(val: string): string {
    return val;
  }
}

続いて、モジュールを作成します。

@Module({
  imports: [],
  providers: [BasicCommand],
})
export class AppModule {}

最後に、NestJSのアプリケーションを作成します。

async function bootstrap() {
  await CommandFactory.run(AppModule);
}

void bootstrap();

Node.jsのSEAを作成するためには、依存関係含めた単一のCommonJsファイルにバンドル必要があります。今回は、esbuildを使ってバンドルします。

await esbuild.build({
  entryPoints: ['./src/main.ts'],
  platform: 'node',
  bundle: true,
  minify: true,
  outfile: './dist/main.js',
})

実際、コマンドを実行すると、以下のようになります。

> node ./dist/main.js basic -s SEA "Hello World"
[Nest] 96830  - 2023/12/01 19:10:06     LOG [NestFactory] Starting Nest application...
[Nest] 96830  - 2023/12/01 19:10:06     LOG [edt] y8r dependencies initialized +5ms
[Nest] 96830  - 2023/12/01 19:10:06     LOG [edt] UYe dependencies initialized +0ms
[Nest] 96830  - 2023/12/01 19:10:06     LOG [edt] E8r dependencies initialized +0ms
[Nest] 96830  - 2023/12/01 19:10:06     LOG [edt] uFr dependencies initialized +0ms
{ param: [ 'Hello World' ], string: 'SEA' }

Single Executable Applicationsの作成

# 単一実行可能アプリケーションに注入できるBlobを構築するための設定ファイルを作成する
> cat <<EOF > sea-config.json
{
  "main": "dist/main.js",
  "output": "sea-prep.blob"
}
EOF

# Blobの生成
node --experimental-sea-config sea-config.json

# nodenvから注入されるnodeを持ってくる
cp $(nodenv which node) command

# バイナリの電子署名を削除する
codesign --remove-signature command

# postjectを使ってバイナリに Blob を挿入する
npx postject command NODE_SEA_BLOB sea-prep.blob \
    --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \
    --macho-segment-name NODE_SEA

# 再び署名する
codesign --sign - command

これで、SEAが作成できました。実際に実行してみましょう。

 ./command basic -s SEA "Hello World"
(node:5571) ExperimentalWarning: Single executable application is an experimental feature and might change at any time
(Use `command --trace-warnings ...` to show where the warning was created)
[Nest] 5571  - 2023/12/01 19:26:49     LOG [NestFactory] Starting Nest application...
[Nest] 5571  - 2023/12/01 19:26:49     LOG [edt] y8r dependencies initialized +8ms
[Nest] 5571  - 2023/12/01 19:26:49     LOG [edt] UYe dependencies initialized +0ms
[Nest] 5571  - 2023/12/01 19:26:49     LOG [edt] E8r dependencies initialized +0ms
[Nest] 5571  - 2023/12/01 19:26:49     LOG [edt] uFr dependencies initialized +0ms
{ param: [ 'Hello World' ], string: 'SEA' }

動きました🎉

実運用に耐えうるか

NestJSとSEAを組み合わせて使用する際の実運用における検討点をまとめます。

  1. コードの分離

現状、Argo Workflowsの各ノードで実行される各コードが実行可能なコマンドの単位では分離されていません。 そのため、これらを単一の実行可能ファイルにする場合、実行可能な単位で分離するようにリファクタリングする必要があります。

  1. 現状のStartup Snapshotの利用と限界

Startup Snapshotの利用の利点は、特定の変数の状態を保存し、それを再利用することにあります。 しかし、NestJSで依存性注入(DI)された状態を変数として保持するのは現状難しそうです。 これは、DIされたオブジェクトの状態を正確にスナップショットとして保存し、再利用することが技術的に複雑といえます。 したがって、現段階ではStartup Snapshotの機能がNestJSとの相性が必ずしも良いとは言えない状況です。

まとめ

本記事では、私たちeScanチームがNestJSとCommanderを組み合わせて使用している例を紹介しました。 この組み合わせは、ワークフローの効率化と自動化において非常に有効であることがわかりますが、同時にいくつかの課題にも直面しています。

Argo Workflowsにおいて各ノードの起動時のオーバーヘッドは、システムのパフォーマンスに影響を及ぼしています。 Single Executable Applications(SEA)のスタートアップスナップショット機能を使うことで、アプリケーションの起動時間を大幅に短縮し、オーバーヘッドを削減することが期待されます。

しかし、実運用を考慮すると、現段階ではこのアプローチにはいくつかの問題が存在します。 特に、それぞれのノード上で動くファイルを単一の実行可能ファイルにするためには、適切なコード分離などのリファクタリングが必要です。 また、NestJSでDIされた状態を効果的にスナップショットとして保存し、再利用することは現段階では技術的に複雑そうです。

このような課題を考慮すると、現段階では、私たちのシステムにSEAを完全に統合することは難しいかもしれません。 あくまでも今回のSEA化は一つの手段として考えましたが、これからも様々な手段をもってシステムの効率とパフォーマンスを向上させるための取り組みを、続けていくつもりです。

enechainでは、事業拡大のために共に技術力で道を切り拓いていく仲間を募集しています。

herp.careers