ほりひログ

所属組織の製品 (Azure とか) に関連する内容が多めだけど、個人の見解であって、所属組織を代表する公式情報ではないです。

Azure Functions/App Serviceでリモートビルドする時は、node_modulesを入れるな!

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を合わせないとうまく動かない。

けど、今回遭遇したのはそれとは別の話。

少なくとも

  1. Linux環境のローカル側でnode_modules/.bin/COMMANDを作っている。
  2. node_modules をデプロイパッケージに含めている。
  3. 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.jsonbinフィールドで宣言されているもの。
例えばtypescriptモジュールのtscコマンドの場合は、

のように宣言されている。
github.com

ここでnode_modules/.bin配下に作られるコマンドはOSごとに作られる内容が変わる。
Linux環境でbinフィールドを持つパッケージをインストールすると、binフィールドで指定されたファイル*3へのシンボリックリンク(以下、symlinkと書く)を作るらしい*4

実際にLinux環境でnpm install typescriptを実行してtscをインストールしてみると、
と、node_modules/.bin/tscnode_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 tscnode_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からコピーに置き換わっている。

github.com

(余談)Windows環境からデプロイ

ちなみにローカル側がWindows環境だと、もう少しややこしく、node_modulesを含めてデプロイしても、Azure側がWindowsでもLinuxでもリモートビルドが成功してしまうケースがある。

なぜか。
Windows環境でのnpm installは、symlinkを作らずに代わりに実行可能なスクリプトたちを作るから。

tsc を例にすると、Windows環境でnpm install typescriptを実行すると、

  1. PowerShell 用の tsc.ps1
  2. コマンドプロンプト用の tsc.cmd
    あと
  3. Cygwin/MinGW/MSYSなどのエミュレーター向けの tsc コマンド(ShellScript)

この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で動かすのがいいと思う。

まとめ

結局のところ、以前からあるベストプラクティスに則る、が正解。


*1:アプリケーション設定で"SCM_DO_BUILD_DURING_DEPLOYMENT"を"true"とかにする

*2:あまり使い道のないは自覚している

*3:"tsc"の場合は、"./bin/tsc"

*4:https://docs.npmjs.com/cli/v10/configuring-npm/package-json#bin

*5:VSCode拡張機能やAzure Functions Core Toolsでのデプロイを想定

*6:postinstall で "ls -l node_modules/.bin/tsc" を実行して確認。