Technote

by sizuhiko

Next.js を standalone ビルドしたアプリで New Relic エージェントを動かす

こちらの記事は Next.js で作ったアプリケーションを AppRunner にデプロイする の続編となります。 前編を読まないとわからない内容ではないですが、もし良ければ事前に確認してください。

前の記事で Next.js を standalone ビルドしたアプリを App Runner にデプロイするところまで書きました。

そのアプリでテスト環境のとき New Relic エージェントを入れたいということでやってみたんですが、ものすごいハマりどころが多かったので誰かの役に立てばと思い記事にします。

エージェントのインストールと起動方法

New Relic の Node.js エージェントは node-newrelic というリポジトリにある OSS ライブラリです。 利用するときは以下のように Node.js の –require module オプションを使って起動するようです。

$ node -r newrelic your-program.js

Next.js で利用する場合の例として Next.js example projects というのも用意されていますが、今回は standalone モードでビルドされてますので、 Custom Next.js servers というところのやり方と一緒で結局 --require module モードで起動することになります。

やってみる

まず npm install newrelic でパッケージを追加しておいて、 Next.js のリポジトリにある standalone ビルドの Dockerfile で起動スクリプトを以下のように変更します。

# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
ENV HOSTNAME="0.0.0.0"
CMD ["node", "-r", "newrelic", "server.js"]

はい、起動しません。

Error: Cannot find module ‘/app/nodemodules/lodash/lodash.js’. Please verify that the package.json has a valid “main” entry at Object. (/app/nodemodules/@newrelic/security-agent/lib/nr-security-agent/lib/core/commonUtils.js:20:16)

調べよう

newrelic/csec-node-agent という依存モジュールの package,json の中に lodash があるのを見つけました。

で、使っている場所はこの行なんですが、いやな使い方をしていますね。

const lodash = require('lodash')
// ここを
const isEmpty = require('lodash/isEmpty')

下のように記述してくれていれば良い(実際 isEmpty しか使ってない)のですけど… まぁとはいえ動きません。

まず何でかっていうと、そもそも Next.js は standalone モードでビルドされていて、コンテナサイズを小さくするために Next.js から依存関係にないパッケージは入りません。 つまり New Relic のエージェントを --require module モードで起動すると、依存しているパッケージがないので動かないという、まぁ至極その通りな感じであります。

で、いろいろネットの情報を調べていたら、サーバー起動する直前で npm i newrelic すると良いよというのを見つけました。 以下のソース

# Install the next.js plugin after it copies the standalone server and static bits to workdir
# I cannot figure out why If I just install `@newrelic/next` and add to project's package.json
# why it does not copy them over but it does not for some reason so we will add it after all the copying
# occurs
RUN npm i newrelic

USER nextjs

EXPOSE 3000

ENV PORT 3000

# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
ENV HOSTNAME="0.0.0.0"

CMD ["node", "-r", "newrelic", "server.js"]

なるほどねー、ってことでやってみました。

…. 動かない….

なんでかって言うと、僕らのアプリでは、依存関係で(厳密にいうと依存の依存に)すでに lodash があったんですね。つまりこの状況( /app/node_modules/lodash/ が存在する )で依存の依存で lodash 入れようと思ってもすでに入っているので、追加(上書き)では入りません。 まぁそうですよね。npm とはそういうものです。で、その lodash は standalone モードで使ってないモジュールは消されているので lodash.js という一等地の全部インポートファイルはありません。せめて require('lodash/isEmpty') なっていれば…(isEmptyはあった)。 とはいえ、他にも依存モジュールあったんで、lodash だけの問題とも言い切れませんね。

先人が通った道

Next.js に New Relic 導入し、docker コンテナの起動に失敗した話 に出会いました。 node_modules の下のファイルをコピーする….。まぁ動きそうな気はするけど、コピーはいやだなぁ….ということで違う方法を模索します。

過去には、Next.js 用のライブラリがあったようで、そのときの issue も見つけました。

でそのときの対応が node-newrelic リポジトリになるときのマイグレーション issue も見つけました。

ということで、何だか standalone モードへの対応は考慮されているようですが、前述のとおり Web アプリ側でも依存関係にあってエージェント側でも使っているみたいなケースでは失敗してしまうこともありえます。 僕がソースコードを見回った結果は lodash だけっぽかったので、そこだけ修正すれば大丈夫なのかもですけど。

グローバルインストールという脇道(ハック)

アプリケーションの下の node_modules だと入らないので、別のところでクリーンインストールすれば良いのでは?という結論に至りました。 とりあえずアプリと別のディレクトリを掘ってやってもよかったんですけど、面倒だったのでグローバルインストールを使います。

# Install dependencies only when needed
FROM base AS deps

# 長いので省略 一番下に追加
RUN npm i -g newrelic

マルチステージビルドの deps の最後でグローバルインストールします。 続いて standalone ビルドしたファイルをコピーした後で、グローバルインストールフォルダを丸っとコピーして持ってきます。 で、そこに NODE_PATH 環境変数を通すというハックを入れました。

# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

COPY --from=deps /usr/local/lib/node_modules /usr/local/lib/node_modules
ENV NODE_PATH=/usr/local/lib/node_modules

おそらく gist の例とかは newrelic が依存しているライブラリを Next.js のアプリが使っていない前提で書かれていると思われる。

結局 newrelic が依存しているものを Next が良い感じに standalone ビルドすると当然 newrelic なんぞ?みたいになって動かないからな。ライブラリ作者はそこまで気が回ってないのだろう

グローバルはハックな方法だと思うけど、正しいやり方をサポートは対応してくれるんかな….

ライブラリは OSS なんで issue 書いてよ、というのは、まぁそのとおり(まだやってません、すみません)。

やってみる(2回目)

動きません….. 😭

Cannot find module ‘@newrelic/native-metrics’\nRequire stack:\n- /usr/local/lib/nodemodules/newrelic/lib/sampler.js\n- /usr/local/lib/nodemodules/newrelic/lib/agent.js\n- /usr/local/lib/node_modules/newrelic/index.js\n- internal/preload

Cannot find module ??なんですって?

"optionalDependencies": {
    "@contrast/fn-inspect": "^4.2.0",
    "@newrelic/native-metrics": "^11.0.0",
    "@prisma/prisma-fmt-wasm": "^4.17.0-16.27eb2449f178cd9fe1a4b892d732cc4795f75085"
},

なんか optionalDependencies があるんですけど、 npm i newrelic だけじゃないの?ドキュメントに書いてあった?

調べよう(2回目)

ありましたよ、ドキュメント。 Node.jsのVM測定

New Relic Node.js エージェントの v2.0.0 以降、ネイティブモジュールはオプションの依存関係となり、自動的にインストールされるようになりました。

じゃぁインストールされんのかな?と思うじゃないですか。ダメです。

展開プラットフォームでネイティブ モジュールをコンパイルするには、 node-gypパッケージの手順に従います。ネイティブ Node.js モジュールをインストールするための前提条件は次のとおりです。 プラットフォーム 前提条件 Unix/Linux Python(v2.7推奨、v3.x.xは未サポート)、make、C/C++コンパイラ(GCCなど)

いやいや、 Python 必要なん? (node-gyp だから当然だけど)。 つまり自動インストールしようとした結果、インストールに必要なもの(今回でいうとgccとかPytnonとか)がインストールされてないので、自動インストール自体が失敗するということになります。

Node.jsエージェントのインストール という公式ドキュメントをみると

オプション:追加のNode.jsランタイムレベル統計情報を取得するため、@newrelic/native-metricsパッケージがインストールされていることを確認してください。

ってことで、まぁこれは必要なようです。

結局のところの deps は以下のようになりました。

# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
# RUN apk add --no-cache libc6-compat
RUN apk update && \
  apk upgrade && \
  apk add --no-cache libc6-compat make gcc g++ python3
WORKDIR /app

# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
  else echo "Lockfile not found." && exit 1; \
  fi

RUN npm i -g newrelic
RUN npm i -g @newrelic/native-metrics

やってみる(3回目)

動いたー🎉

まとめ

以下、苦労したところ

  • ドキュメントが分散していてわかりずらい(最初から github でなく公式 Docs みておいた方が良かったかも)
  • Next.js の standalone モードとは驚くほど相性が悪い

前者は改善の余地ありそうだけど、後者は何ともならなそうですね。 今回は別の場所で入れて NODE_PATH するというハックを使いましたが、結局エージェントとWebアプリで依存関係の競合が起きたときに困ると言うことには違いないです。たとえば違うバージョン使っていたら?とかそれが原因で動かなかったら?ということは今後容易に起きそうです。 希望としてはエージェントは1ファイルにパッキングしておいて欲しいということですね。というか require module で動くんだから自分が必要なパッケージは固めておいて欲しいですよね。Node.js のアプリだったらみんながみんなフルセットの node_modules を使っていないことがあるのは Node.js 使っている人ならわかりそうなのになぁという気分です。

ちゃんと時間が取れて、良い感じの英文が書けたら issue 出そうかな。それも OSS への貢献ですね。 この記事で同じような境遇になった人が、解決の助けになったら、と思います。