npmパッケージのバイナリコード配布パターン
X-tech推進本部 黒澤近年、処理の高速性などからGoやRustなどで書かれたバイナリコード(以下、バイナリ)を活用したnpmパッケージが注目されています。
JavaScriptは一種類のファイルを用意すれば、OSやCPUによらず実行できますが、バイナリはOSやCPUごとにファイルの形式や内容が異なります。そのため、インストール先の環境に合わせたバイナリの配布・インストール方法が必要となります。
配布・インストール方法にはいくつかのパターンがあり、注意すべき点も異なります。この記事では配布・インストール方法のパターンと注意点を整理します。
postinstall
postinstallは、package.jsonに指定できるライフサイクルスクリプトの1つで、パッケージのインストール後に実行するコマンドを指定できます。
{
"scripts": {
"postinstall": "node install.js"
}
}
指定したコマンドで、次のような処理を実行することで、バイナリの配布・インストールを行えます。
- インストール先環境のOSやCPUを判定
- 対応するバイナリをインターネットなどからダウンロード
- 所定の場所に格納
postinstallのデメリットはセキュリティのリスクが高いことです。postinstallは任意のコマンドを指定できるため、サイバー攻撃による悪用が続いています。pnpmはpostinstallの自動実行をブロックするようになりました。
記事執筆時点では、後述するoptionalDependenciesの利用が増えていますが、歴史あるnpmパッケージではpostinstallを引き続き採用しているものも見受けられます。
optionalDependenciesによるバイナリの配布・インストール方法としてある程度利用されるようになったのは、2020年から2021年ごろからと筆者は認識しています。例えば、@swc/coreがoptionalDependenciesに移行したのは2020年(Migrate to napi by kdy1 · Pull Request #1009 · swc-project/swc)、esbuildがoptionalDependenciesへ移行したのは2021年です(install using "optionalDependencies" by evanw · Pull Request #1621 · evanw/esbuild)。それ以前からバイナリの配布・インストール方法を整備していたnpmパッケージではpostinstallを引き続き採用している傾向にあります。
postinstallの実行は原則禁止しつつ、やむを得ない場合に特定パッケージを許可するといった対応が現実的と筆者は考えています。
optionalDependencies
OSやCPUごとのバイナリを別々のパッケージを作成して、親パッケージの依存パッケージとして指定し、インストール先環境に適したもののみをインストールさせる、という方法です。
npmパッケージにはパッケージが対応するOSやCPUを指定できます(osフィールドやcpuフィールド)。例えば、osフィールドにlinuxを指定したパッケージは、Linux環境ではインストールが成功しますが、ほかの環境では失敗します。
{
"os": [
"linux"
]
}
また、optionalDependenciesに指定した依存パッケージは個別パッケージのインストールに失敗しても、インストール処理全体は失敗しません。
OSやCPUごとのバイナリを含んだパッケージをoptionalDependenciesに指定することで、インストール先の環境にあったバイナリのパッケージのみがインストールされます。
{
"name": "@my-org/my-pkg",
"optionalDependencies": {
"@my-org/my-pkg-darwin-arm64": "...",
"@my-org/my-pkg-linux-x64": "...",
"@my-org/my-pkg-win32-x64": "..."
}
}
optionalDependenciesのメリットは、postinstallなどの、パッケージが指定したコマンドを実行せずにインストールできる点です。
GoやRust製のバイナリを活用しているパッケージにはこの方法を採用しているものが多くあります。いくつか例を挙げます(順不同)。
一方で、optionalDependenciesにもデメリットがいくつかあります。
package.jsonで表現できる条件のみが実現可能
Linuxでは利用しているlibcによって利用できるバイナリが異なる場合が多くありますが、npmがlibcフィールドに対応したのは2023年です(feat: support libc field checks by nlf · Pull Request #54 · npm/npm-install-checks)。yarnやpnpmは2022年にlibcフィールドに対応していました。
現状package.jsonで表現できる条件に課題があったとして、新たにフィールドが追加され、広く利用できるようになるには、今後も一定の時間がかかると考えています。
依存パッケージがインストールされなくても検知が難しい
optionalDependenciesのインストールに失敗しても、インストール処理全体は失敗になりません。そのため、依存パッケージのインストールがすべて失敗した場合(対応していないOSやCPUの環境にインストールしようとした場合)やインストールされるべきパッケージのインストールが予期せず失敗した場合の検知が難しいです。
また、npmやpnpmではインストール時にoptionalDependenciesのインストールをスキップする指定ができます。この場合もバイナリパッケージはインストールされません。
そこで、optionalDependenciesとpostinstallを組み合わせているパッケージもあります。
例えば、esbuildのpostinstallはバイナリパッケージのインストール有無をチェックし、インストールされていなければ警告を表示します(node-install.ts)。なお、バイナリパッケージがインストールされている場合は、バイナリを呼び出すJavaScriptをバイナリへのリンクに差し替えることで、実行時のオーバーヘッドを取り除く処理を行っています。
また、@swc/coreのpostintallもバイナリパッケージのインストール有無をチェックし、インストールされていなければWebAssembly版である@swc/wasmのインストールを試みます(postinstall.ts)。
ほとんどの場合、esbuildや@swc/coreはpostinstallを実行しなくてもパッケージを利用できます。セキュリティを優先して、postinstallの実行を許可しないことも現実的な選択肢と考えています。
実行時インストール
プログラム実行時にバイナリがインストールされているかチェックして、インストールされていなければ、インストールする、という方法です。
記事執筆時点では一般的ではありませんが、例はあります。
rolldownはWebContainers(ブラウザ上でNode.jsなどを実行できる開発環境)で実行していると判断したときに、バイナリ(WebAssembly)がインストールされていなければ、バイナリをインストールします(feat(node): auto fallback wasm binding on webcontainer by hi-ogawa · Pull Request #3922 · rolldown/rolldown)。
electronもpostinstallから実行時インストールへの移行が検討されています(rfc: lazy electron download, remove postinstall by MarshallOfSound · Pull Request #22 · electron/rfcs)。
この方法のメリットは、実行時に毎回確認するため、バイナリのインストール漏れが起きにくいことです。
デメリットは、インターネット(バイナリのダウンロード元)にアクセスできない環境で実行した場合、インストールに失敗することです。オフライン環境で実行する場合はもちろん、アクセス範囲を限定しているサーバーで実行する場合も失敗しえます。
まとめ
これまで紹介した3つの方法には、それぞれメリット・デメリットがあります。筆者なりに再度整理すると次のようになります。
postinstallは古くから使われている方法ですが、セキュリティリスクを踏まえて、pnpmでは自動実行がブロックされるに至りました。この方法を採用しているパッケージを積極的に採用する理由は薄いと考えています。
optionalDependenciesでは、パッケージが指定したコマンドを実行することなくバイナリをインストールできます。optionalDependenciesはインストール失敗を検知できないことがあるため、postinstallと組み合わせているパッケージもありますが、この手のpostinstallは許可しなくても良いと考えています。
実行時インストールは少ないですが、知識として持っておくと、手元の環境では動作していたのに別の環境で動作しない事象の調査などで役立つ可能性があります。