しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

Azure App Service に pnpm を利用した Node.js アプリケーションをデプロイする

最近は 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 を使う必要があります。