🪐

モジュールのimportが原因でCloud Run上のNode.jsアプリケーションの起動が遅いの時の対処方法

2025/01/14に公開

こんにちは、バックエンドエンジニアの飯沼です。私たちが開発している旅行アプリ『NEWT(ニュート)』のAPIはNode.jsで動かしています。モノリシックな構成で約2年半ほど機能拡張を続けた結果、いつの間にかAPIの起動速度は30秒近くまで遅くなってしまいました。ここまで遅くなるとセールなどのイベントでトラフィックが急増した際に、APIインスタンスのスケールアウトが間に合わずエラーを返す割合が高くなってきます。

この記事では、起動速度が遅い問題を解決するために行ったことを、後学のために過程も含めて共有します。今回の内容は業務委託で協力してくださった與那城さんによる取り組みです。(ありがとうございます!!)

なお、今回の改善対象のアプリケーションの実行環境や規模は以下の通りです。

  • 実行環境:Cloud Run (Second Generation / 2024年12月頃)
  • Node.jsバージョン:v18系
  • アプリケーション:
    • ソースファイル数:≥ 3,000
      • 合計ファイルサイズ:43MB ※ TypeScriptからJavaScriptにトランスパイルした状態
    • 依存ライブラリ数:50件 ※ devDependenciesは除く
      • 合計ファイルサイズ:487MB
  • 改善前の起動速度:99パーセンタイル 30秒

📝 先に結論

Cloud Run環境でNode.jsアプリケーションの起動が遅かった理由は、モジュールimportにともなうnode_modules配下の大量のファイル読み込みが遅いから、ということでした。解決策は3つ考えて、2つ試しました。

  • (A) モジュールのimportを遅延させる
    • 大幅なアプリケーションの書き換えが発生するため今回はやりませんでした。
  • (B) バンドラを使う:30秒 → 10秒(効果最大)
    • node_modulesとアプリケーションのソースファイルをバンドラで1ファイルにまとめる方法です。
    • Cloud Traceなど、一部動作しないライブラリがあったため検証途中で断念しました。
  • (C) ソースファイルをインメモリファイルシステムに配置してから起動する:30秒 → 20秒(工数最小)

当初 (B) で進めて10秒まで改善できましたが、Cloud Traceがバンドルされたアプリケーションをサポートしていないことに気が付き、(C) に切り替えて20秒まで短縮しました。

改善前後の起動時間の変化です。もともと起動時ヘルスチェックの間隔が20秒と無駄に長く設定していたことが原因で99パーセンタイルで48秒掛かっていました。ヘルスチェックの間隔を3秒に短縮することで-18秒、さらに今回の対策で-10秒短縮しました。

📈 まずは計測から

ローカルのMacBookでは2, 3秒で起動することができ、数十秒かかる状況が再現ができないため、Cloud Run環境で計測する必要がありました。このときに利用したのが Node.jsのInspector API です。

説明のため、以下のような express のアプリケーションがあったとします。

// example-server.mjs
import express from "express";

const app = express();

app.get("/", (req, res) => {
  res.send("Hello World!");
});

app.listen(3000, () => {
  console.log("Server is running on http://localhost:3000");
});

これをinspectorのsessionでwrapして、アプリケーションコードの実行よりも先にプロファイルを開始して、Port Listenのタイミングを起動完了とみなしてプロファイルを終了します。また、結果を /_profile から取得できるようエンドポイントを追加しています。

import inspector from "inspector";

const session = new inspector.Session();
session.connect();

session.post("Profiler.enable", () => {
  // プロファイルを開始
  session.post("Profiler.start", async () => {
    await import("express").then(({ default: express }) => {
      let startupProfile;

      const app = express();
      app.get("/", (req, res) => {
        res.send("Hello World!");
      });

      // プロファイル結果を取得するエンドポイント
      app.get("/_profile", async (req, res) => {
        res.json(startupProfile);
      });

      app.listen(3000, () => {
        // PortをListen(≒ アプリケーションが起動)したらプロファイルを停止
        session.post("Profiler.stop", async (err, { profile }) => {
          if (!err) {
            startupProfile = profile;
          }
        });

        console.log("Server is running on http://localhost:3000");
      });
    });
  });
});

プロファイル結果のJSONファイルはChromeの開発者ツールのPerfomanceタブから読み込むことで可視化できます。こちらのスクリーンショットは上記のサンプルアプリケーションのプロファイル結果ですが、実際のアプリケーションではmoduleのrequire起因のファイル読み込み (fs.read) が大部分を占めていることがわかりました。

💡 方法1:バンドラで1ファイルにまとめる(30秒→10秒)

ファイル読み込みがボトルネックであれば読み込む量を減らせば速くなるだろうと、まず試したのがバンドラでアプリケーションのソースファイルとnode_modulesを1ファイルにまとめる方法です。TypeScriptにも対応している ncc を利用しました。

具体的な変更点としては、Dockerfile内のビルドコマンドをtscからnccに切り替えて、起動時にバンドル後のjsファイルを指定するよう変更しました。他にもアプリケーションの書き換えが必要な箇所がありました。

# Before: tscでトランスパイル
RUN npx tsc
# ...
CMD [ "node", "dist/src/index.js" ]
# After: nccでトランスパイル&バンドル
RUN npx ncc build --external @google-cloud/tasks --source-map --no-source-map-register -t ./src/index.ts
# ...
CMD [ "node", "dist/index.js" ] # バンドル後のjsファイルに変更

ポイント:

  • バンドルされることをサポートしていないライブラリは --external で指定することで、これまで通り node_modules から読み込みます。
  • アプリケーションのソースファイルのパスを指定してライブラリが読んでくれるようなものは修正が必要です。例えば、TypeORMはEntitiyファイルのパスを "src/migration/*.js" のようにワイルドカードで指定すると読み込んでくれますが、バンドラで1ファイルにまとめるのでパス指定ではなく、手動でEntityをimportする必要があります。私たちはimport漏れがないようにすべてのEntityをリストアップするスクリプトを準備しました。
  • 「結論」セクションにも書きましたが、Cloud Trace Agentはバンドルされたアプリケーションのトレースには対応していません。Cloud Traceは require-in-the-middle でNode.jsのrequireをhookして、shimmer でモジュールの挙動を書き換えることでトレース用のspanを設定しているようです。
    似たようなライブラリとして opentelemetry-js がありますが、こちらでも同様のライブラリを利用しているため、バンドルされたアプリケーションはサポートできないのではないかと推測しています。
    アプリケーションからライブラリのrequireをhookできなくなることが問題であれば、アプリケーション側でshimmerを使ってspan付きの処理に書き換えれば使えるようになるかもしれません。また時間が取れたら試してみようと思います。

💡 方法2:Cloud Runのインメモリファイルシステムから起動する(30秒→20秒)

バンドラを使った方法はパフォーマンスモニタリングで重要な役割を担っているCloud Traceが動作しなくなるため、採用することはできませんでした。代わりに試したのがインメモリファイルシステムを利用する方法です。

前提となるのが、Cloud Runのコンテナ内のファイルシステムは書き込み可能で、インメモリファイルシステムである、ということです。つまり、コンテナ内ならどこでも良いですがコンテナ起動後に /app などにソースファイルを書き込むと、メモリに書き込まれるので高速な読み込みが期待できます。具体的にはDockerfileを以下のように変更しました。アプリケーションの変更は不要のため改修コストが少なく済むのが良いところです。

# ...
# distにはトランスパイル済みのjsファイルが格納されている
RUN tar cf app.tar dist node_modules package.json
# ...
# ソースファイルをインメモリファイルシステムに書き込んでから起動
WORKDIR /app
CMD [ "bash", "-c", "tar xf app.tar && node dist/index.js" ]

結果、ファイルの展開に5秒、起動に15秒、合計20秒程度で起動できるようになりました。ただし、副作用としてファイルの容量分(私たちのケースでは500MB程度)メモリ使用量が増えてしまいました。そこで試したのが次に紹介する方法です。

起動時に必要なファイルのみをインメモリファイルシステムに展開する

Node.jsには一度読み込んだモジュールをキャッシュする仕組みがあり、キャッシュされた情報は require.cache からアクセスできます。これを利用することで、インメモリに展開するファイル数を絞ることができます。具体的には以下のような流れです。

  • コンテナイメージのビルド時にAPIを起動、起動時点の require.cache からキャッシュされたファイルパス(= 起動時に読まれるファイル群)を記録し、起動時に読み込まれるファイルのみをtarにまとめる
  • 起動時にtarを展開することで起動に必要なファイルを上書き(= インメモリに配置)

ファイル数を絞った分展開に掛かる時間とメモリ消費量を抑えることができます。私たちのケースではファイルの展開に掛かる時間が5秒→2~3秒に短縮され、インメモリファイルシステムの使用量を500MB→50MB程度に抑えることができました。アプリケーションコードとDockerfileは以下のようになります。※ 起動時間には多少のばらつきがあり99パーセンタイルで確認したところ20秒程度で落ち着いています。

// index.js
import fs from "fs";
import express from "express";

const app = express();

// 起動時に必要なファイル名の書き込み先
// 指定した場合はPort Listenのタイミングで出力して、プロセスを終了する
const STARTUP_REQUIRED_FILES_OUTPUT_PATH = process.env.STARTUP_REQUIRED_FILES_OUTPUT_PATH || "";

app.listen(3000, () => {
  if (STARTUP_REQUIRED_FILES_OUTPUT_PATH) {
    const filenames = Object.values(require.cache)
      .map((entry) => entry?.filename)
      .filter(Boolean);
    fs.writeFileSync(requiredFilesOnStartUpOutputPath, filenames.join("\n"));
    process.exit(0);
  }

  console.log("Server is running on http://localhost:3000");
});
# ...
FROM deps as archive
COPY --from=deps /work/package.json package.json
COPY --from=deps /work/node_modules node_modules
# distはJavaScriptにトランスパイル済みのアプリケーションコード
COPY --from=build /work/dist dist
WORKDIR /work
# 一度起動することで起動時に必要なファイルを特定し、tarにまとめる
RUN env STARTUP_REQUIRED_FILES_OUTPUT_PATH=required_files_on_startup.txt \
  node ./dist/index.js \
  && sed -E 's,^/work/,,' required_files_on_startup.txt > required_files_on_startup_relative.txt \
  && echo >> required_files_on_startup_relative.txt \
  && find node_modules -type f -name package.json >> required_files_on_startup_relative.txt \
  && tar -T required_files_on_startup_relative.txt -cf required_files_on_startup.tar

FROM node AS app
WORKDIR /app
# アプリケーションの動作に必要なファイル + 起動に必要なファイルをtarにまとめたもの
COPY --link --from=deps /work/package.json package.json
COPY --link --from=deps /work/node_modules node_modules
COPY --link --from=build /work/dist dist
COPY --link --from=archive /work/required_files_on_startup.tar required_files_on_startup.tar
# 起動時に必要なファイルを上書きすることでインメモリファイルシステムに配置
CMD [ "bash", "-c", "tar xf required_files_on_startup.tar && node dist/index.js" ]

🤔 余談:Cloud Runで大量のファイルアクセスが遅いのはなぜ?

不思議なことにCloud Runの環境で上記 tar ファイルのサイズを du コマンドで確認すると7MB程度しかありません。同じイメージをローカル(MacBook)にpullして同じように du で確認すると55MBあるのです。これは推測ですが、Cloud Runで利用しているコンテナランタイム上ではファイルが圧縮された状態になっており、大量のファイルを開くと展開のためのオーバヘッドが大きくパフォーマンスが出ない、ということではないでしょうか?

以上、Cloud Run上のNode.jsアプリケーション起動高速化のための取り組みの紹介でした。目を通していただいてありがとうございます。少しでも参考になる箇所があれば幸いです。


最後にちょっとだけ宣伝させてください 🙏

令和トラベルでは定期的にTech LT会などの勉強会やイベントを開催しています。1/30(木)は 19:30〜 「カスタマーファーストを実現するプロダクトマネジメントの舞台裏」というテーマでLT会を開催しますので、ぜひご参加ください。
https://reiwatravel.connpass.com/event/339388/

また、令和トラベルでは、一緒に働く仲間を募集しています。興味を持っていただいた方がいれば、ぜひご連絡をお待ちしております!
https://www.reiwatravel.co.jp/engineers

令和トラベル Tech Blog

Discussion