Node.js v20/ライブラリアップデートの手順・ポイントについて

# はじめに

新しい職場のプロダクトに関して環境構築を行なっていると、ランタイムであるNode.jsがEOLを迎えているバージョンであったり、利用しているフレームワーク等のバージョンが古い状態のまま放置されていることに気がつきました。
そこでバージョンアップを提案し、ランタイムとライブラリのバージョンアップ対応などを行いました。今回はどういうステップでバージョンアップを行なったかなどの記録を記事に起こしたいと思います。

## 前提となるアーキテクチャ周り

アップデート前は以下のバージョンを利用していました。

- ランタイム
    - Node.js 16
- フレームワーク・ライブラリ
    - NestJS 9
    - firebase 9 

また、CIに `Github Actions`を利用し、インフラ周りは`Google Cloud`で構築しています。

## Node.jsのリリースサイクル周りの補足

Node.jsのリリースに関する情報を見るとリリーススケジュールについて以下のように決められています。
> Node.jsのメジャーバージョンは6か月間 Current ステータスとなり、ライブラリー開発者にサポートを追加する時間を与えます。6か月後、奇数番号のバージョン（9、11など）はサポートが終了し、偶数番号のバージョン（10、12など）が Active LTS ステータスに移行し、一般公開向けの準備が整います。LTS ステータスとは「長期間サポート」であり、通常は合計30か月間の重要なバグ修正が保証されます。プロダクションのアプリケーションでは Active LTS または Maintenance LTS ステータスのバージョンのみを利用してください。

より具体的には、以下のようなスケジュールとなっています。
![](https://raw.githubusercontent.com/nodejs/Release/main/schedule.svg?sanitize=true)


例えば、Node.js 18は2025-04-30にEOLを迎えるということです（16はとっくの昔にEOLを迎えています）。

参考：

https://nodejs.org/ja/about/previous-releases

## 実際にバージョンアップを行なう

大きなステップとしては、
1. Node.jsのアップデート内容の調査
1. Node.jsのバージョンを上げる
1. 各ライブラリのバージョンを上げる
1. リグレッションテスト
1. 定常的にアップデートする仕組みづくり

という流れになります。

Node.jsのバージョンアップを先に行うのは、Node.js 16が既にEOLを迎えているバージョンであり、サポート外であるためにライブラリのアップデートができないという事情によります。

### Node.jsのバージョンを上げる

Node.jsについてはリリースノートやテックブログなどを参考に、既存機能に影響のある破壊的変更はなさそうだなと判断しました。

以下の変更を行なっていきました。

#### ローカル環境

[volta](https://volta.sh/)を利用しているため、コマンド一つで切り替えることができます。

```sh
volta pin node@20
```

これによって、package.jsonのvolta.nodeの値が変わります。他の開発者もvoltaを利用しているため、あまり意識することなくNode.jsのバージョンを切り替えることができました。

#### CI環境のアップデート

Github Actionsを利用しているため、`.github/workflows/**.yml`のうち、Node.jsのバージョン指定を行っている場所を書き換えていきます。

```yml
uses: actions/setup-node@v4
with:
  node-version: '20.18.x'
```

#### GoogleCloud環境のアップデート

GoogleCloudのbuildpacksはpackage.jsonの`engines.node`の値でバージョンを指定できるため、こちらを書き換えます。voltaでpinしているバージョンと相違が起きないように注意します。

参考：

https://cloud.google.com/docs/buildpacks/nodejs?hl=ja

これで完了です。

### 各ライブラリのアップデート

多くのライブラリが最新バージョンではない状態でしたが全てを一気にアップデートする余力はなかったため、まずは優先して脆弱性のあるライブラリに関してアップデートを行うことにしました。具体的には以下の手順で行っていきます。

1. 脆弱性のあるパッケージの特定
1. そのライブラリのマイグレーションガイドの確認・リリースノート等の調査
1. バージョンアップ実施

#### 脆弱性のあるパッケージの特定

まずは、脆弱性のあるパッケージを特定します。
npm/yarn/pnpmには `audit` という脆弱性のあるパッケージを特定する機能があります。

```sh
npm audit
```

このコマンドを実行すると、例えば、以下のようなレポートが出力されます。

```console
# npm audit report

@grpc/grpc-js  <1.8.22
Severity: moderate
@grpc/grpc-js can allocate memory for incoming messages well above configured limits - https://github.com/advisories/GHSA-7v5v-9h63-cj86
fix available via `npm audit fix --force`
Will install firebase@11.4.0, which is a breaking change
node_modules/@grpc/grpc-js
  @firebase/firestore  1.3.1-0 - 1.3.1-canary.df1b588 || 1.3.2-0 - 1.3.3-canary.bc4a844 || 1.13.1-0 - 1.13.1-canary.ce3addba || 1.14.0-0 - 1.15.1 || 3.7.3-20221108184443 - 3.7.3-canary.fde5adf63 || 3.8.0-20221206221533 - 4.0.0
  Depends on vulnerable versions of @grpc/grpc-js
  node_modules/@firebase/firestore
    @firebase/firestore-compat  <=0.0.900-exp.520ca39d0 || 0.2.3-20221108184443 - 0.2.3-canary.fde5adf63 || 0.3.0-20221206221533 - 0.3.13 || 0.3.30-20240424141009 - 0.3.30-dataconnect-preview.f2ddc3d7b || >=0.4.0-20230301000120
    Depends on vulnerable versions of @firebase/firestore
    node_modules/@firebase/firestore-compat
      firebase  <=10.9.0-canary.ed84efe50
      Depends on vulnerable versions of @firebase/firestore
      Depends on vulnerable versions of @firebase/firestore-compat
      node_modules/firebase
```

上記であれば、 `@grpc/grpc-js` に脆弱性があり、利用している`firebase`がその脆弱性のあるパッケージを利用しているということ表しています。

##### audit fix

なお、auditにはpackage.jsonやロックファイルを更新する機能もあり、

```
npm audit fix
```

とすることでメジャーバージョンを上げない（破壊的変更のない）範囲で、package.jsonやロックファイルを更新してくれます。
また、`--force`を利用するとメジャーバージョンの引き上げも行ってくれます。
しかし、今回はこちらの機能は利用しませんでした（できませんでした）。
なぜなら、audit fixは脆弱性のあるライブラリを軸にバージョンを上げてくれるのですが、関連するライブラリをよしなに読み取ってバージョンを上げるという賢さはないためです。
例えば、NestJSであれば `@nestjs/common`、`@nestjs/config`、`@nestjs/core`など複数のパッケージを利用することでアプリケーションの体を成しますが、その全てが脆弱性を持っているわけではありませんでした。そのため、一方はv11なのに、一方はv9のままというちぐはぐなアップデートを行ってしまうため、問題がありました。

そこで、audit reportはあくまでバージョンアップ対象のライブラリの特定に利用し、以下の手順でバージョンアップを行なっていきました。

#### そのライブラリのマイグレーションガイドの確認・リリースノート等の調査

ということで、`audit fix`で一気にバージョンを上げて解決というわけではなく、地道に各ライブラリのマイグレーションガイドなどを参照しながらアップデートしていきます。

`<ライブラリ名> migration guide` など検索をすると、大手のライブラリであれば丁寧なマイグレーションガイドがでてきますのでそちらを確認していきます。

例えば、NestJS v11向けであれば以下のマイグレーションガイドがあります。

https://docs.nestjs.com/migration-guide

- [npm-check-updates](https://www.npmjs.com/package/npm-check-updates) 経由でアップデートを推奨するよ
- 内部で依存するexpressが5系になったよ
- Module関係のAPIが変わったよ

などの情報が記載されています。分量の多いライブラリはAIに抜粋させたりしながら読み進めていました。

#### バージョンアップ実施

ということで、NestJS関係のライブラリを一通りnpm-check-updatesで上げていきます。

```sh
ncu -u @nestjs/foo @nestjs/bar ...
```

起動すると、TypeScriptの型エラーがでてきました。 node_modules配下のモジュールの型エラーでした。こちらはTypeScriptのバージョンを上げることで解決しました[^1]。　次にTypeScriptのアップデートに伴ってプロダクトコード側にも型エラーがでたため、そちらを一部修正しました。


[^1]: ところで、nestjs v10のアナウンスにはCLIはTypeScript >= 4.8という記載があり、v11には特にTypeScriptのバージョンの記載はなかった記憶があります。アップデート前のプロダクトのTypeScriptのバージョンは4.9.5だったので問題ないのではと思ったのですが、型エラーがでていました。どこかで情報を見落としているのかな？


これでアプリの起動ができることを確認し、NestJSのアップデートは完了としました。

①マイグレーションガイド・リリースノートの確認→②アップデート作業（必要があればプロダクトコードなどを一部修正）という作業を脆弱性のあるライブラリに対して繰り返していきました。

### リグレッションテスト

こちらは新参者の私には難しい内容ですので、既存メンバーに代表的なユースケースやシナリオを整理・連携してもらう形で実施しました。

### 定常的にアップデートする仕組みづくり

今回、ランタイム・かなりたくさんのライブラリのアップデートを実施しました。本来であれば脆弱性のあるライブラリを放置すること自体が望ましくなく、定常的にアップデートする仕組み作りを進めています（ここは現在進行形）。

まずは、定番のdependabotを導入しました。
dependabotでできることなどは以下の記事が参考になりますのでご参照ください。

https://tech.visasq.com/dependabot-to-handle-vulnerabilities

今は実際に作成されるPRなどを確認されながら運用の改善について考えています。

例えば、個別のライブラリのバージョンを上げるのでアップデート漏れがあるという点を改善したいと考えています。
実際に、`@prisma/client`に対するPRがでているが、`prisma`についてはアップデートしてくれないということがありました。
レビューで動作確認していれば気づけますが、マイナー（パッチ）バージョンだから大丈夫だろうという風に緩くマージしてしまうと、思わぬバージョンの不整合を引き起こしてしまうなと思っています。
このあたりは、dependabotの[`grouped version update`](https://github.blog/changelog/2023-06-30-grouped-version-updates-for-dependabot-public-beta/)機能などで解決できそうなので試していきたいなと思います。