コンテンツにスキップする

「実践 Node.js 入門」を読んだ

投稿時刻2023年7月8日 10:59

実践 Node.js 入門」を 2,023 年 07 月 08 日に読んだ。

目次

メモ

セマンティックバージョニング (semver) p78

npmのパッケージはセマンティックバージョニング (Semantic Versioning, semver) の仕様にしたがってバージョニングすることが推奨されています。
セマンティックバージョニングでは、 Major.Minor.Patch の規則で、 1.0.0 や 0.13.1 のように 3 つの数値をドットで区切る記法が利用されます。

major.minor.patch のバージョンを表しています。
それぞれの更新ルールは次のようになります。

名称 ルール 
Major 
バグ/機能追加を問わず下位互換性を損なうリリース時にインクリメントされ、 Minor と Patch を 0 にリセットする

Minor 
下位互換性のある機能追加のリリース時にインクリメントされ、 Patch を 0 にリセットする

Patch 
下位互換性のあるバグ修正のリリース時にインクリメントされる

すべてのモジュールがこのルールに従っているとは限りません。
しかし、このルールを知っておくことで、下位互換性のない破壊的な変更が加えられているかを知る、ある程度の目安となります。

また、 npm のパッケージ更新の挙動もこの semver を基準に動作します。
自分で npm モジュールを公開する時などは、ユーザーのためにもこのバージョニングに従ったリリースを心がけるとよいでしょう。

シグナルと Node.js p232

ExecStart でアプリケーションの起動コマンドを記述します。
ここを npm start としていない理由は、 Linux のシグナルのためです。
シグナルとは簡単に言うと、プロセスに対してこういう振る舞いをしてねとお願いを投げるものです。

たとえば近年デプロイ先として多く利用されるようになってきた Kubernetes では、
Pod (アプリケーションのプロセス)が終了する際に SIGTERM が送信され、一定時間後にもプロセスが終了していない場合は SIGKILL が送信されます。

Web サーバーをシャットダウンする時のことを考えてみましょう。
ユーザーがアクセスしている最中にサーバーが落ちた時に、データベースの更新などを含む処理が走っている場合、最悪のケースではデータに不整合が起きてしまう可能性があります。
そうならないためにもサーバーがシャットダウンする時は、新規 リクエストを停止し、終了処理を入れた方がよいでしょう。
これは Graceful Shutdown と呼ばれています。

このシャットダウンを行うためのトリガーが Kubernetes では SIGTERM です。
下記が Node.js で実装する簡易的な Graceful Shutdown のサンプルです。

Node.js では process というグローバルオブジェクトにシグナルのイベントハンドラーを設定できます。

const timeout = 30 * 1000; // 30秒のタイムアウトとする

process.on('SIGTERM', () => {
	// Graceful Shutdownの開始 
	// 新規リクエストの停止
	server.close (() => {
		// 接続中のコネクションがすべて終了したら実行される 
	});

	const timer = setTimeout(() => { 
		// タイムアウトによる強制終了
		process.exit(1); 
	}, timeout); 
	timer.unref();
});

npm の話に戻ると、 npm を通してアプリケーションを起動するとシグナルが送られた際に、まず npm がそれらのシグナルを受け取ります。
アプリケーションプロセスがシグナルによって処理を行う場合、 Node.js のプロセスで直接シグナルを受け取りたい場合があります。
このため、筆者は systemd 環境等で動作させるアプリケーションの起動は、直接 node コマンドから行うよう記述することが多いです。

ファイルディスクリプタと Node.js p234

Node.js アプリケーションを実行する際の重要な設定に、 systemd の LimitNOFILE があります。
LimitNOFILEはファイルディスクリプタ数を設定する項目です。

Node.js は何度か言及した通り、シングルプロセスシングルスレッドで多くのリクエストを処理する言語です。
そのため、ひとつのプロセスが扱うファイル数は多くなりがちです。

このため、ファイルディスクリプタの値が低いままでは、 Node.js の処理するトラフィック量が増えるにつれて Too many open files というエラーによってプロセスダウンが起きがちです。

たとえばシェルなどでユーザーのデフォルト値を確認してみると、筆者の環境では 1024 でした。
$ ulimit -n 
1024 

この数値では本番の運用に耐えるのは難しいでしょう。
筆者は本番環境で利用する際に、十分大きな値として 65535 (2 ^ 32 - 1) を主に利用してします。
本質的にはアプリケーションが想定する最大のリクエストでエラーが出ない数値を設定できれば問題ありません。

npm audit p345

npm パッケージのバージョンアップについて説明しました。
次はパッケージの脆弱性に関連する npm audit について触れます。

npm auditはアプリケーションが利用しているnpmパッケージの中に、脆弱性を含むバージョンがあるかを確認するコマンドです。
たとえば先ほど Express v3 をインストールしたアプリケーションで試してみると、いくつかのパッケージに脆弱性が見つかっていると表示されます。
次の例は部分的に抜粋した qs そジュールの結果です。

各モジュールの脆弱性のレベルや内容のリンクなどが表示されています。
直接アプリケーションが依存として記述していないパッケージであっても、パッケージの依存ツリーの中に存在していれば検出されます。

npm audit コマンドは、検出された脆弱性を修正する npm audit fix というコマンドもあります。
npm audit fix は semver から判断した互換性を崩さないバージョンで更新されます。
したがって、すべての脆弱性が解消されるわけではないことに注意してください。

$ npm audit fix 

互換性を考慮せず強制的に更新する npm audit fix --force というコマンドもありますが、
これはそれぞれのパッケージが動作保証をしていないバージョンまでモジュールを更新してしまいます。
更新してたまたま動作する場合もありますが、気軽に実行できるものではありません。

実際に運用を開始すると npm audit fix では脆弱性を修正しきれないケースに多く遭遇します。
すばやく対応することがよりよいのは間違いありませんが、すべての脆弱性がアプリケーションにおいて致命的というわけでもありません。

修正しきれない脆弱性は内容を把握し、そのアプリケーションにおいて問題を発生させるかを確認しましょう。
また、問題がある場合には別のモジュールに入れ替えられないか検討をしたり、時には OSS 自体にコミットすることで修正したりするという手段も必要になるでしょう。

筆者はそういった際には OSS 側に貢献できると、自分たちだけでなくそのモジュールを利用するすべてのユーザーにとって利益があるため、
できる限りOSS側にフィードバックできるとよいと考えています。

パフォーマンス計測ツールの導入 p359

次のステップではパフォーマンスの計測に入ります。
ここは各自使い慣れているツールを利用しましょう。
ab などさまざまなツールがありますが、筆者は vegeta という計測ツールを利用することが多いです。

ここでは vegeta を利用して計測方法の説明をします。
vegeta を実行すると、 次のように計測した結果が標準出力に表示されます。

duration オプションで継続時間を指定して、 rate オプションで req/s を調整します。
このあたりは筆者の感覚値ですが、 1 サーバーで rate=30 くらいは最低でも耐えられることを目標にパフォーマンスをチューニングすることが多いです。

耐えられるリクエスト数が増えれば増えるほどよいのは確かです。
しかし、パフォーマンスのためにトリッキーなコードが増えるより、
サーバーの数を増やして並べてしまったほうが運用コストは低くなることもあります。
見極めが重要です。

結果の読み解き方に話を移します。
Success はレスポンスがステータスコード 200 で返ってきた率で、ここは 100% を維持する必要があります。
Duration などの結果がすごくよくなったと思っていたら、全部エラーになって速かっただけということがあります。

Latencies はアクセス中の平均 50%, 95%, 99% の最大のレスポンスタイムが表示されています。
ここはシステムによっても目標とする数字は変わりますが、筆者はまずは 1 秒以内を目安としてチューニングを行っていることが多いです。

パフォーマンスチューニング p360

パフォーマンスを計測する準備ができました。
次はいよいよ改善、パフォーマンスチューニングのステップです。

筆者は重要度順に次の順番で見ています。
1. ファイルディスクリプタの確認 (6.13.3参照)
2. cluster 対応の確認 (6.15参照) 
3. アプリケーションコードの改善

パフォーマンスチューニングというとアプリケーションコードの改善にまず取り掛かかってしまいたいところです。
しかし、アプリケーションの改善の優先度は一番低く、最後の最後で手をつける部分になります。

表層を改善するより底となる部分からの改善の方が、より効果が出やすくコスト対効果は高くなります。
1,2 では Node.js の特性に合わせて設定が足りているかをまず確認します。
設定が十分であることを確認したら、 3 のアプリケーションコードの改善に着手します。

Node.js にはアプリケーションのプロファイリングを行う --prof という起動オプションがあります。
$ node-prof index.js 

prof オプションをつけて起動してからプロセスを終了すると isolate-xxxxx-xxxx-v8.log というファイルが吐き出されます。
このファイルの内容を人間が読み解くのは難易度が高いので、さらにこのファイルを Node.js で --prof-process を用いて処理します。
$ node --prof-process isolate-xxxxx-xxxx-v8.log > isolate.txt

こうして吐き出された isolate.txt ファイルの中身を確認します。
注目するべきは (summary) です。
ここは JavaScript や C++ レイヤーのコードがどれだけ CPU を専有しているかを表します。

上記の例では C++ レイヤーの処理が全体の 80% を占めていることが読み取れます。
つまり Node.js のコアコードの占める割合が多くあるということです。

逆に JavaScript の割合が大きければ、ユーザー(アプリケーション開発者)が書いた部分、
アプリケーションコードが多くを占めるということになります。

JavaScriptの割合が大きければ、それだけユーザーコードが CPU を専用しているので、 JavaScript のコードを改善していく余地があります。
また、コアコードの割合が大きいからといって改善できないわけではありません。
この部分はアプリケーションコードやライブラリがコアコードを大量に呼び出していれば比率が高くなります。
たとえば fs.readFileSync のような同期コードが呼ばれていると、その処理中にはほかの JavaScript コードは動くことができず、 C++ の total 時間が加算されます。

このため、たとえコアコードの total 時間が長くても、改善の余地があります。
GC はガベージコレクションが起きたことを表します。
ここが 1453 6.8% 168.8% GC のように、大量に発生していた場合は「頻繁な GC が起きている」 = 「メモリリークが起きてしまっている可能性がある」と読み解けます。

他にも Bottom up (heavy) profile の欄を見ると具体的にどういった関数が重い処理なのかを確認可能です。
flamegraph
多少読みやすくなったとはいえ、この文字列情報だけでは、具体的にどこがへビーなポイントなのか読み解くのは容易ではありません。
そこで flamegraph 利用して、よりアプリケーションに沿ったヘビーポイントの可視化を行います。
flamegraph は先にあげたコードパスの実行時間などをヒューマンフレンドリーに可視化してくれるツールです。
これを通すことでホットコードなどが可視化され、どの部分を直すと効果が高いのかを見極めやすくなります。

flamegraphを表示するためのモジュールはいくつかありますが、筆者は 0x というモジュールを愛用しています。
このモジュールを通してアプリケーションを起動します。
0x をグローバルにインストール (npm install -g 0x) した上で、 0x 経由で index.js (内容は後述のリスト 8.10 参照) node で実行します。
$ 0x -- mode index.js # ある程度負荷をかけたら Ctrl + C などで停止

起動した環境に vegeta で負荷をしばらくかけてから、プロセスを終了します。
すると 4123.0x/ のようなディレクトリが生成されます。

このディレクトリには先の prof オプションで吐き出された isolate-xxxxx-xxxx-v8.log や flamegraph.html というファイルが格納されています。
今回注目するのは flamegraph.html です。
これファイルをブラウザで開くと次の画像のようなページが見られます。

この結果は先ほど --prof-process で吐き出した結果をよりグラフィカルに表したようなものです。
濃い赤色になっているほどホットコード、つまり一番多く通るコードパスです。
濃い赤色の部分のパフォーマンスを向上させられると、全体に効果が出やすいわけです (誌面の都合で白黒です) 。
横軸は関数の実行時間を表し、呼び出された関数が積み上げられていきます。
ここで気にするべきは横幅です。
「縦に積み上がっていて横幅が短い」ということは「たくさん関数を呼び出しているが実行時間は短い」という意味になります。

このため、最初は横幅が長くて、ホットコードな部分を重点的に見ます。
先の flamegraph は次のサンプルコードの結果です。

flamegraph で赤くなっている部をみると -readFileHandle internal/fs/promises.js となっています。
つまり fs モジュールの readFile という処理が多く動いているということがわかります。

上記のサンプルコードでいうとリクエストのたびに、 fs.readFile が走ってしまうためホットコードとして現れています。
この場合はリクエストによって返すファイルは変わらないので、サーバー起動時にファイルを読み込むようにすれば先ほどのようなホットコードの表示にはならないでしょう。

実際にはこんな単純に見つけられることはあまりないですが、俯瞰して眺めるのに活用します。
同期コードがホットコードとして現れて大きく改善できることもあるので、
右上の検索ボックスから Sync と入れて検索をするのも有効です。

メモリリークの調査 p364

ここまで述べてきたのは、主に設計やアプリケーションの書き方によるパフォーマンス低下をみつける手法でした。
ほかにも Node.js の性能を劣化させる原因に、ガベージコレクション (GC) あります。
他言語と同様に、ガベージコレクションはランタイムを停止させてしまうため、性能上よくありません。
ガベージコレクションが大量に起きている、もしくはサーバーの監視で右肩上がりをしていたメモリ使用量が突然下がってまた上昇していく、といった特徴が出ていた場合、メモリリークしているコードが含まれている可能性があります。

基本的に Node.js のコアコードにメモリリークは存在しません。
メモリリークが起きてしまっているということはほぼ確実に「自分の書いたコード」か「利用しているモジュール」に原因があります。
実際にメモリリークが起きているかを確認するためには、やはり計測してみるしかありません。次のようなコードを差し込みます。 

global.gc は強制的にガベージコレクションを呼び出す関数です。
heap メモリの使用量を出力する前にガベージコレクションを強制的に呼び出したのにもかかわらず、
メモリ使用量が上がり続けていた場合、ガベージコレクションできないメモリを保持し続けている (メモリリークがある) 可能性があります。

global.gc を利用するには、 --expose-gc フラグ付きで起動する必要があります。
例では try-catch でこの関数を囲んでいます。
$ node --expose-gc index.js

実際にメモリリークが起きているサーバーで起動してみると、ヒープの使用量が右肩上がりになっている様子が出力されます。

もちろん普通のアプリケーションも時間経過とともにメモリ消費は増えるものです。
右肩上がりだからといって即座にメモリリークと判断するのは危険です。

先に述べたような特徴と合わせてメモリリークが起きているかどうかを判断しましょう。
また、これだけでは具体的に何がメモリリークを起こしているのかはわかりません。
次はメモリのヒープダンプの取得をします。

メモリのヒープダンプ p366

Node.js でメモリを直接調査するにはいくつか方法がありますが、 heapdump モジュールを使う方法が手軽です。
$ npm install heapdump 

少し古い記事ですが、下記のメモリリークの発見ガイドは非常に参考になるので必読です。
Node.js での JavaScript メモリリークを発見するための簡単ガイド | POSTD https://postd.cc/simple-guide-to-finding-a-javascript-memory-leak-in-node-js/

基本的にメモリリークの検出で行うことは、上記の記事にある「 3 点ヒープダンプ法」です。
3 点ヒープダンプは、名前の通り 3 回ヒープダンプをとり、 2 回目と 3 回目の実行結果から GC を逃れているオブジェクトを見つけ出して対策するという手法です。

1. 1回目のヒープダンプ取得。
ここが基準点となる。

2. 2回目のヒープダンプ取得。
ここでは基準点から 1 回以上 GC が働いていることが期待できる。 

3. 3回目のヒープダンプ取得。
ここでは、 2 回目のヒープダンプ取得時からさらに GC が働いているはずなので、 2 回以上の GC が期待できる。
これによって GC を複数回行っても回収されなかったもの (メモリリークの対象) が抜き出せる。
ヒープダンプを実際に取得する 
筆者は下記のようなコードを差し込み、 kill コマンドを送信して、 3 回ヒープダンプを取得します。

process オブジェクトの on 関数でプロセスに対するシグナルの受け取りが可能です。
ここでは、 SIGUSR2 にシグナルが来たとき、ヒープダンプを取得します。
適当なタイミングで、 kill -USR2 {{アプリケーションの pid }} を実行して、ヒープダンプを走らせます。
$ kill-USR2 {{アプリケーションの pid }}

heapdump モジュールを利用して heapdump.writeSnapshot() を実行すると heapdump-xxxx というファイルが生成されます。
次に Chrome の DevTools を開き Memory タブ内にある Profiles から生成されたファイルをロードしていきます。
3点ヒーブダンブ法とは、複数回 (最低 3 回) 取得したメモリのダンプを比較し、ガベージコレクションされないオブジェクトを探す方法です。
Summary の All objects となっているところから Objects allocated between heapdump-xxx and heapdump-yyy を選択するとそれぞれの比較ができます。
それぞれの計測間で、新しくメモリの上に確保されたもののガベージコレクションされずに残ってしまっているオブジェクトがメモリリークを起こしていると考えられます。

それぞれのポイントを比較するしくみ上、一定以上の長さと実際のアクセスを行った最低 3 点をみる必要があります。
筆者は、最初は 10 分間に 3 回以上の頻度として、取得することが多いです。
その結果からガベージコレクションが発生していなさそうであれば、さらに時間と間隔を増やして確認をしていきます。

SIGUSR2を使う理由 p368

今回、利用するシグナルに SIGUSR2 を割り当て、 SIGUSR1 を割り当てないのには理由があります。
SIGUSR1 は Linux 的にはユーザーが自由に使っていいシグナルになっていますが、Node.js では debugger を起動するシグナルとして利用されています。

'SIGUSR1' is reserved by Node.js to start the debugger.
It's possible to install a listener but doing so might interfere with the debugger. 
https://nodejs.org/api/process.html#process_signal_events