最近は Node.js のアプリケーションを Azure App Service にデプロイする機会がかなり多いので、大体のユースケースには対応できると思っていたのですが、少し前に pnpm を使っているアプリケーションのデプロイが上手くいかないケースに遭遇したのでまとめておきます。
そもそも pnpm について詳しくはなかったのですが、確かにインストールは高速かつディスク容量をあまり食わないので良い感じだと思いました。モノリポだと特に輝く感じですね。
詳細は後述しますが、pnpm はシンボリックリンクを多用しているため Web App へのデプロイ時には工夫が必要でした。Web App には pnpm がデフォルトで入っていないという点も考慮する必要があります。
今回は検証用に以下のようなモノリポの構成で、フロントエンドとバックエンドのプロジェクトをそれぞれ用意しました。シンプルですが良くある構成だと思います。
フロントエンドは SPA なので Static Web App にデプロイする想定で、バックエンドは Web App にデプロイするように作っていきます。フロントエンドは SPA なので特に何も考える必要が無いですが、バックエンドは pnpm でインストールした node_modules
を正しく持っていく必要があります。
Web App に Node.js のアプリケーションをデプロイする方法としては、Docker Image を利用する方法と Run From Package を利用する方法の 2 種類があるので、それぞれで試していきます。
Docker Image を利用したデプロイ
Docker Image をビルドする方法は pnpm の公式ドキュメントに載っているので難しいことはないのですが、モノリポのサンプルではビルドした結果が含まれないなど問題があったのでカスタマイズします。
今回の構成で Docker Image が必要なのはバックエンドだけなので、その部分に特化して Dockerfile を書いています。本来なら pnpm の特徴を生かすためにフロントエンドのビルドもマルチステージで行うべきです。
基本的な流れは公式のサンプルと同じですが、ビルド結果をコピーしている点が大きく異なっています。
FROM node:20-slim AS base WORKDIR /app FROM base AS build ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable COPY . /app RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile RUN pnpm run -r build RUN pnpm deploy --filter=api --prod /prod/api FROM base AS api COPY --from=build /prod/api/node_modules /app/node_modules COPY --from=build /app/apps/api/dist /app/dist COPY --from=build /app/apps/api/package.json /app/package.json EXPOSE 3000 CMD [ "npm", "run", "start:prod" ]
最終成果物となる Docker Image には pnpm をあえて含めずに npm 経由で実行するようにしています。そして node_modules
については pnpm deploy
コマンドを使って開発用の依存関係を除いたデプロイ用に最適化したものを出力しています。
この Docker Image を Web App にデプロイするとバックエンドが正しく動作することが確認できます。
Run From Package を利用したデプロイ
App Service では Docker Image を使わない場合は Run From Package を使うことが強く推奨されているので、こちらの方法でも検証を行っておきます。
ビルド時に pnpm deploy
を実行しても node_modules
以下は .pnpm
というディレクトリの実体を参照するようになっています。pnpm を使っている場合はシンボリックリンクを維持したままデプロイする必要があるので、これを理解していないと失敗します。
検証用に作成した GitHub Actions のワークフロー定義は以下のようになりました。基本的な流れは Docker Image の作成と同じですが成果物を zip にする点が異なっています。
Docker Image を利用したケースでは pnpm deploy
の実行で済みましたが、pnpm がシンボリックリンクを多用しているので、単純に Run From Package 向けに zip を作るだけだと正しく動作しませんでした。これは zip コマンドで圧縮する際に -y
オプションを付けてシンボリックリンク維持することで回避できます。
name: Deploy apps on: push: branches: [ master ] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v3 with: version: 8.15.6 - name: Use Node.js v20 uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Build apps run: | pnpm install --frozen-lockfile pnpm -r build - name: Create api package run: | pnpm --filter api --prod deploy prod/api mkdir -p output/api && cp -r apps/api/dist prod/api/node_modules prod/api/package.json output/api cd output/api && zip -ryq ../../api.zip . - name: Deploy web run: | npm i -g @azure/static-web-apps-cli swa deploy ./apps/web/dist --env production --deployment-token ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }} - name: Deploy api uses: azure/webapps-deploy@v3 with: app-name: *** publish-profile: ${{ secrets.AZURE_WEB_APP_PUBLISH_PROFILE }} package: api.zip
このワークフロー定義で作成された zip ファイルは .pnpm
へのシンボリックリンクが維持されているので、Run From Package でデプロイすると正しく node_modules
以下のファイルを参照出来るようになります。
実際に Run From Package デプロイされた状態の wwwroot 以下を確認すると、node_modules
以下のディレクトリが .pnpm
以下にリンクされていることが確認出来ます。
Node.js アプリケーションを Run From Package でデプロイした場合は、Azure Portal から Startup Command を指定する必要があるので、今回は Docker Image と同じく pnpm ではなく npm を使って npm run start:prod
を実行するように設定します。
ここまでの設定で pnpm を使ったアプリケーションが Web App でも正しく動作するようになります。
ちなみに App Service は Run From Package ではなく zip デプロイも利用可能ですが、現状は内部でのデプロイに使われている KuduSync がシンボリックリンクに対応していないため正しく動作しません。
従って pnpm を使ったアプリケーションや将来的に npm でシンボリックリンクが使われるようになった際には、今回のように Docker Image か Run From Package を使う必要があります。