Azure Functions/App Service(以下、「Azure側」と書く。長いので。)へのデプロイで、デプロイパッケージのサイズを減らすために、リモートビルド時にnode_modulesをデプロイしない、というのは割とわかりやすい理由。
一方で、リモートビルド自体が失敗する、というパターンがあって、今回はこのパターンにハマった。
そもそもそうするものなんだろうから改めて書くほどのことではないけど、せっかくハマった原因を調べたので記録と戒めに。
ざっくりいうと
- Azure側にアプリをデプロイする時にリモートビルドを使う*1と、デプロイ後にAzure上
npm run build
などが実行される。 - この時、node_modulesも一緒にデプロイしていると、それらが悪さしてデプロイが失敗するケースがある。
というわけで、リモートビルドを使うなら、node_modulesは極力デプロイしちゃいけない(結論)。
リモートビルドは、プライベートなパッケージを使う時は.npm
をいじる、などなど割と工夫が必要で、ビルド済みのパッケージをデプロイして Run from Packageした方がトラブルは少ない印象。
とはいっても、失敗する原因が気になったので、調べてみた*2。
node_modules起因でリモートビルドが失敗するパターン
当然node_modulesの中にOS依存のバイナリとかを使っている時に、ローカル側とAzure側でOSを合わせないとうまく動かない。
けど、今回遭遇したのはそれとは別の話。
少なくとも
- Linux環境のローカル側で
node_modules/.bin/COMMAND
を作っている。 node_modules
をデプロイパッケージに含めている。- npm コマンドの
postinstall
/build
/build:azure
などで、1. で作ったnode_modules/.bin/
配下のコマンドを実行している
の組み合わせでリモートビルドがコケる。
そしてこのパターンで出てくるエラーは大抵、
Error: Cannot find module '../lib/tsc.js'
みたいに、コマンド内から別ファイルへの参照に失敗している。
なぜデプロイが失敗するのか
Azure側でのリモートビルドでは、その初期段階でnpm install
が改めて実行される。
けど、ここでインストールしようとする依存モジュールがデプロイパッケージ内のnode_modules
配下にあると、そのモジュールのインストールをスキップして、デプロイパッケージに含まれているモジュール、つまりデプロイ前にローカル側でインストールしたモジュールを使おうとする。
この「デプロイ前にローカル側でインストールしたモジュール」が問題の原因。
npm がインストールするコマンド
「デプロイ前にローカル側でインストールしたモジュール」はどういうものなのか。
この node_modules/.bin
にインストールされるコマンドは、パッケージ側のpackage.jsonのbin
フィールドで宣言されているもの。
例えばtypescriptモジュールのtsc
コマンドの場合は、
のように宣言されている。
github.com
ここでnode_modules/.bin
配下に作られるコマンドはOSごとに作られる内容が変わる。
Linux環境でbin
フィールドを持つパッケージをインストールすると、bin
フィールドで指定されたファイル*3へのシンボリックリンク(以下、symlinkと書く)を作るらしい*4。
実際にLinux環境でnpm install typescript
を実行してtsc
をインストールしてみると、
と、node_modules/.bin/tsc
はnode_modules/typescript/bin/tsc
へのsymlinkになっていることがわかる。
リンク先の node_modules/typescript/bin/tscの中身は、
#!/usr/bin/env node require('../lib/tsc.js')
と、単に別のjsファイルを読み込んでいる。
なので、関連ファイルの位置関係はこんな感じ。
node_modules ├── .bin │ └── tsc ---- (a) └── typescript ├── bin │ └── tsc ---- (b) └── lib └── tsc.js ---- (c)
(a)がnpx tsc
で実行されるsymlinkファイルで、リンク先は(b)。
(b)はコマンドの実体で、相対パスで(c)を参照。
(c)はコマンド処理の本体。
途中、(b)から(c)へ相対パスでの参照があるけど、(a)が(b)のsymlinkであれば、(a)を実行しても(b)から(c)への参照は相対パスでも問題なし。
結果、npx tsc
やnode_modules/.bin/tsc
などで(a)を実行してもエラーは出ない。
ローカル環境ならね。
symlinkをデプロイするとどうなる?
じゃあこれらのnode_modules/.bin
配下のsymlink達をデプロイパッケージに含めてAzure側にデプロイする*5とどうなるか。
デプロイされたsymlinkはこうなる*6。
symlinkではなく、何らかのファイルそのものになっている。
じゃあその中身は、というと、
リンク先だったファイル(b)を丸々コピーしたものになっている。
うまいこと参照を解決するよう更新してるかといったら、そこまで優しくはなかった。
つまり、
- Azure側にデプロイした時に、(b)へのsymlinkだった(a)は、(b)の単なるコピーに変わった
- なので、(a)が直接(c)を参照する構成に変わった
- でも(a)に書かれている(c)への相対パス
../lib/tsc.js
だと、(a)があるnode_modules/.bin
から(c)へ辿れない
結果、
Error: Cannot find module '../lib/tsc.js'
になった、と見て間違いなさそう。
デプロイ時にsymlinkがリンク先のコピーに置き換える処理は、VSCodeのAzure拡張の場合はここらへんでやってるっぽいので、正確には、デプロイパッケージを作った段階で、symlinkからコピーに置き換わっている。
(余談)Windows環境からデプロイ
ちなみにローカル側がWindows環境だと、もう少しややこしく、node_modulesを含めてデプロイしても、Azure側がWindowsでもLinuxでもリモートビルドが成功してしまうケースがある。
なぜか。
Windows環境でのnpm install
は、symlinkを作らずに代わりに実行可能なスクリプトたちを作るから。
tsc を例にすると、Windows環境でnpm install typescript
を実行すると、
この3つがnode_modules/.bin
の中にできる。
node_modules ├── .bin │ ├── tsc.ps1 ---- (1) │ ├── tsc.cmd ---- (2) : └── tsc ---- (3)
このうち(3)が割と柔軟で、tsc
のようにOSに依存しないファイルだけで構成されていれば、CygwinだけじゃなくWSLや通常のLinuxでも動いてしまう。
なのでAzure側のOSがWindowsだと(2)が、Linuxだと(3)が動き、結果どっちのOSを使っていようと tsc
が実行できてしまい、問題に気付きにくい。
ややこしい。
ということは、Windows環境での開発が最強なのでは?
とは当然ならなくて。
繰り返しだけど、OS依存のバイナリを使うパッケージがあると、ローカル側とAzure側でOSが違えば当然動かない。
なので、「node_moduleをデプロイするな」という結論は変わらない。
「どうしてもnode_modulesを含めたい!」
社内だけで公開しているパッケージなど、Azure側からインストールできないパッケージがある。
どうしてもデプロイパッケージにnode_modules入れないといけない。
そんな人は、リモートビルドは諦めよう。
「依存パッケージのインストール」->「ビルド済みパッケージ作成」->「Azureへのデプロイ」まで一気にやっちゃうCI環境を構築して、Azure側はRun from Packageで動かすのがいいと思う。
まとめ
結局のところ、以前からあるベストプラクティスに則る、が正解。