こんにちは。Inflabでフロントエンド開発者をしているGOHSUEです。
Rallitは、InflabがInflearnとともに開発している採用サービスです。Rallit B2CはNext.jsベースで、Dockerイメージをデプロイしてサービスを運用しています。これまで、Rallitのデプロイ用Dockerイメージはかなり大きなサイズでした(macOS環境で約4.67GB)。
今回、Next.jsがサポートしているstandaloneオプションを適用したことで、Dockerイメージのサイズを最小化し、デプロイ時間を短縮できました。その経験を簡単に紹介します。
Rallit は、「Inflearnが学習によって人を成長させるサービスを提供しているのだから、成長した人が能力を発揮できる環境を作ることが、私たちのサービスの完成形ではないか?」という考えから生まれたサービスです。採用だけでなく、自分のキャリアを管理できる場を目指しています。ぜひ注目していただけるとうれしいです 🙌
1. Standalone
1-1. Standaloneの意味
Standaloneは 「独立型」 または 「独立したもの」 という意味です。Next.jsでは、Webアプリケーションの実行に必要な最小限のコードだけを抽出するという意味で使われています。公式ドキュメントによると、このオプションを追加すると、ビルド成果物として /standalone フォルダーが生成されます。
module.exports = {
output: 'standalone',
};
このオプションを使うと、デプロイ環境でWebアプリを実行するのに不要なコードがビルド成果物から除外されます。
1-2. 期待される効果
Dockerイメージのサイズを減らし、デプロイ時間を短縮できます。
Rallitは現在、JenkinsのCI/CDパイプラインを使ってAmazon ECS ClusterにDockerイメージをデプロイしています。CDパイプラインでは、生成したDockerイメージをビルドしたあと、ECR(Amazon Elastic Container Registry)にpushします。そのため、Dockerイメージのサイズが小さくなれば、ECRへのpush & pullにかかる時間が減り、デプロイ時間が短縮されます。
Amazon Elastic Container Registry(ECR) は、AWSが提供するDocker Container Registryサービスです。Docker Container RegistryはDocker Hubと同じ役割を果たします。つまり、デプロイ用のDockerイメージを保存・管理するサービスです。
2. ローカルでデプロイ用Dockerを実行する
ローカルでデプロイ用のDockerイメージを実行すると、standaloneが正しく適用されたかどうかを、JenkinsやECRを経由せずに簡単に確認できます。Dockerfile があれば、以下のコマンドで簡単にデプロイ用のDockerを立ち上げられます。なお、以下は next build を実行してビルド成果物を生成したあとに実行する必要があります。
Docker build . --target {特定のビルドステージ} -t {イメージ名:タグ}
Docker run -p {ホストのポート}:{コンテナのポート} {イメージ名:タグ}
3. Standaloneの適用
3-1. 基本
Next.jsの公式ドキュメントでは、次のようにオプションを適用できると説明されています。
module.exports = {
output: 'standalone',
};
このオプションを追加すると、next build時に次のようなフォルダーを確認できます。
⚠️ モノレポ + Next.js v12 の場合
モノレポ内のNext.js v12で上記のオプションだけを追加した場合、次のようなビルド成果物が得られます。
ビルド対象のrootパスと実際のrootパスがずれているため、目的のパッケージ配下のライブラリを取得できません。その結果、必要なライブラリが見つからないという Not Found エラーを吐いて、ビルドが止まってしまいます🫠
これは以下のオプションで解決できます(公式ドキュメント)。
module.exports = {
experimental: {
// this includes files from the monorepo base two directories up
outputFileTracingRoot: path.join(__dirname, '../../'),
},
};
このオプションを追加すると、配下のパッケージを取得できるようになります。
このオプションはNext.js v12で有効です。v13では、このオプションがなくてもモノレポで問題なく動作します。
3-2. 実行
standaloneで抽出したサーバーは、次のように実行できます。next startと同様の機能です。
node standalone/server.js
4. ビルド前にロードしておくべきライブラリがある場合
server.jsを実行する際に、事前にロードしておくべきモジュールがある場合があります。Rallitでは、Datadogのtracerである dd-trace、pino-logger、そして next-logger を事前に読み込む必要がありました。
これは Dockerfile で別途設定します。server.jsを実行する前に、Node.jsの -r オプションで preload.js を実行するように設定します。dd-trace についての補足は6-3で説明します。
COPY --chown=node:node packages/b2c/next-logger.config.js ./packages/b2c
COPY --chown=node:node packages/b2c/scripts/preload.js ./packages/b2c
ENV LOG_LEVEL debug
ENV DD_LOGS_INJECTION true
...
ENV NODE_OPTIONS "-r /workspace/packages/b2c/preload.js"
ENTRYPOINT ["node", "packages/b2c/server.js"]
[参考] DockerfileのCOPY
COPY --chown=node:node packages/b2c/next-logger.config.js ./packages/b2c
このコマンドは、以降のDockerfileのコードに頻繁に登場するので、簡単に説明します。
COPY
ファイルやディレクトリを、現在のビルドコンテキストからDockerイメージの内部にコピーします。--chown=node:node:
このオプションは、コピーしたファイルまたはディレクトリの所有者とグループを設定します。
ここでは、nodeユーザーとnodeグループを所有者に設定しています。packages/b2c/next-logger.config.js
現在のDockerビルドコンテキストにおけるsourceファイルのパスです。(コピー元)./packages/b2c
コピー先となるDockerイメージ内部のパスです。(コピー先)つまり、現在のビルドコンテキストにある
packages/b2c/next-logger.config.jsファイルをDockerイメージ内の./packages/b2cディレクトリにコピーし、そのファイルの所有者とグループをnodeに設定します。事前ロード関連のコードを書くにあたっては、同じチームのLucasさんに大いに助けていただきました。ありがとうございます 🙏
5. /static、/publicフォルダーを直接コピーする
公式ドキュメントによると、/static フォルダーと /public フォルダーはstandalone内には含まれません。以下のとおり、/static フォルダーと /public フォルダーはCDNで管理することが推奨されているためです。
staticおよびpublicのパスを、別途オブジェクトストレージやCDNで配信せず、Next.jsサーバーから配信する場合は、直接コピーする必要があります。
COPY --chown=node:node packages/b2c/.next/standalone ./
COPY --chown=node:node packages/b2c/public ./packages/b2c/public
COPY --chown=node:node packages/b2c/.next/static ./packages/b2c/.next/static
6. 環境変数の管理
6-1. 消えた環境変数
standaloneを正しく適用できていれば、環境変数もきちんと適用されるはずです。しかしRallitでは、環境変数が適用されませんでした。
フロントエンド内部で使っている fetcher が、API responseを正常に受け取れないという問題が起きました。Invalid Url エラーが出ており、endpointのhostnameが消えていたため、以下のコードを確認しました。fetcher は環境変数からendpointを取得していました。
fetcherはNext.jsだけでなくCRA環境でも使っている値なので、REACT_APP prefixを付けて使っています。
しかし、Docker内に環境変数がセットされていなかったため、endpointを正しく取得できていませんでした。
6-2. 環境変数を強制的にDockerにコピーできるのか?
結論から言うと、答えは「いいえ」です。環境変数がないことで起きる問題を解決するために、2つの方法を試しました。
-
環境変数ファイルをDockerfileでコピーする
Rallitでは、env-cmdを経由して環境変数を読み込んでいます。そこで同じ方法を使い、Dockerfileで環境変数ファイルをCOPYし、ENTRYPOINTにenv-cmdを追加しました。RUN npm install -g env-cmd COPY --chown=node:node packages/b2c/env/.env.development ./packages/b2c/env ENTRYPOINT ["env-cmd", "-f", "/workspace/packages/b2c/env/.env.development", "node", "packages/b2c/server.js"]しかし、パスを解決できませんでした。Docker内でenvファイル自体が見つからず、
DockerfileのENVコマンドで渡した値だけが適用されます。 -
Dockerfileで環境変数をセットする(非推奨)
上で読み込めなかったコード生成用の環境変数を、同じように追加しました。ENV REACT_APP_CODEGEN_API_HOST http://sample.api.rallit.comこれでfetcherは正常に動くようになりました。しかしこの方法では、すべての環境変数を
Dockerfileに直接追加しなければなりません。解決はできるものの、正しいやり方ではないと判断し、根本的な原因を解決することにしました。
6-3. 根本的な問題の解決策は?
正しく設定されていれば、buildしたときに必ず .env .env.productionが /standalone の中に含まれます。
しかしRallitでは、.env ファイルが /standalone の中に含まれていませんでした。.env ファイルがrootパスではなく /env の中にあったためです。
/env フォルダーの中にある場合、build時にenvファイルが見つかりません。 そのため、/standalone フォルダー内に環境変数が存在しなかったのです。
なぜRallitでは
/envフォルダーの中で環境変数を管理しているのですか?Rallitでは、一部の
envファイルをgitで管理しています。ローカルでは
.env.local、開発環境では.env.development、本番環境では.env.productionというように使い分けています。公式ドキュメントによると、build時には必ず.envと.env.productionだけが読み込まれます。つまり、ビルド時に.env.developmentの値は適用されません。また、
.env.localは優先順位が最も高く、ほかのファイルの値を上書きします。そのため、開発環境で実行するときに、.env.developmentの代わりに.env.localが適用されてしまうという問題があります。こうした問題を回避するために、
env-cmdを使いました。詳しくは公式ドキュメントをご参照ください。
| ファイル | ビルド | 実行 | 優先順位 |
|---|---|---|---|
| .env | 🟢 | 🟢 | 3️⃣ |
| .env.local | ❌ | 🟢 | 1️⃣ |
| .env.development | ❌ | 🟢 | 2️⃣ |
| .env.production | 🟢 | 🟢 | 2️⃣ |
既存の /env パスを維持したまま、この問題を解決することにしました。rootパスに .env ファイルさえあればコピーされるからです。そこで、rootパスに .env ファイルを生成するシェルスクリプトを作成しました。そして、デプロイ環境に応じてコピーするファイルを切り替えるように設定しました。
# B2C standalone build script
# standaloneに環境変数をセットするために、/envフォルダー内の環境変数を最上位に抽出するスクリプト
# developmentの場合: /env/.env.development -> .env COPY
# productionの場合: /env/.env.production -> .env COPY
#!/bin/bash
ORIGINAL_DIRECTORY="./env"
NEW_DIRECTORY="./"
NEW_FILE=".env"
clean_env() {
rm -rf .env
echo "🚀 .envの削除が完了しました。"
}
copy_env() {
local original_file="$ORIGINAL_DIRECTORY/$1"
if [ -e "$original_file" ]; then
cat "$original_file" > "$NEW_DIRECTORY/$NEW_FILE"
echo "$original_file を $NEW_FILE にコピーしました。"
echo "🚀 $2 環境の.envのコピーが完了しました。"
else
echo "$original_file ファイルが見つかりません。"
exit 1
fi
}
case "$1" in
prod)
copy_env ".env.production" "prod"
;;
dev)
copy_env ".env.development" "dev"
;;
clean)
clean_env
;;
*)
echo "Usage: sh set-env.sh [prod|dev|clean]"
exit 1
;;
esac
exit 0
{
...
"scripts": {
...
"build:env": "sh ./set-env.sh",
"build:development": "pnpm build:env dev && next build && pnpm build:env clean",
"build:production": "pnpm build:env prod && next build && pnpm build:env clean"
}
}
[参考] Docker内のnode_modulesを削除する
standaloneの利点は、アプリケーションの実行に必要な最小限のファイルだけを独立したフォルダーとして生成する点です。つまり、node_modulesをすべてイメージに含めなくても実行できます。ただ、RallitのDockerfileでは、rootのnode_modulesを削除するとdd-traceがNext.jsを見つけられなくなるため、rootのnode_modulesを強制的にコピーして使っていました。
この問題については、Next.js v13がdd-traceをサポートしなくなったため、dd-traceを使わずにnode_modulesを削除する方向で対応しました。修正 (2023.11)
このエラーは、dd-traceだけをimportしたときに、dd-trace内部の依存関係であるinit.jsが見つからずに発生していました。standaloneは、アプリケーションで使用されていない依存関係はコピーしません。 そこで、dd-trace/initをimportするようにしました。
_document.tsxファイルでdd-trace/initをimportするように修正すると、dd-traceの依存関係内部のinit.jsファイルまで、正しくstandaloneの成果物に含まれます。これにより、rootのnode_modulesを削除した状態でdd-traceのAPMを利用できるようになりました。より詳しい内容は、今後ブログで公開する予定です。同じチームのYoongdyさんが詳しく調べてくださいました。ありがとうございます 🙏
7. イメージサイズの比較
7-1. No registry stageの比較(Mac、node_modules削除前: 1.48GB)
4.37GB -> 625MB(約3.75GB減少)
7-2. Registry stageの比較(Mac、pnpm削除前/後)
Inflabのほかのサービスパッケージでも、共通してRegistry stageを使っています。Registry stageには、デフォルトでpnpmがインストールされています。しかし今回standaloneを適用したことで、pnpmを使う必要がなくなりました。以下は、pnpmを削除したレジストリを読み込んだ場合の差です。
920MB -> 635MB(約285MB減少)
7-3. Registry stageの比較(Linux (ECR)、pnpm削除前/後)
実際にはDockerイメージをECRにアップロードして使っているので、ECRにアップロードされたDockerイメージの容量も比較してみました。
689MB → 404MB(約285MB減少)
7-4. 最終比較
ECRにアップロードされたDockerイメージは圧縮後の容量なので、Macでの値とはサイズが異なります。
| stage | before | after (standalone + no pnpm) |
|---|---|---|
| Mac | 4.67 GB | 625 MB |
| Linux + ECR | 2.46 GB | 404 MB |
8. 結果
デプロイ時間が従来の3分38秒から1分7秒になり、約320%の性能改善(2分30秒短縮) を実現できました。
9. おわりに
Dockerの勉強会を一緒に行ったRallitフロントエンドチームのRaviさんとRokさん、standaloneオプションについて全体的な方向性を示してくださったLucasさん、そしてインフラ環境について多くのアドバイスとアイデアをくださったJakeさんのおかげで、今回の作業を無事に終えることができました。
そのほか、執筆に協力してくださったチームの皆さんに感謝します。🙇️
※この記事は2023年9月18日に執筆されたものです。











