Next.jsアプリケーションで実行時に環境変数を設定する
今やReactを使ってアプリケーションを作る時に、一番最初に思い浮かぶのがNext.jsというほど、どこでも使われているフレームワークがNext.jsです。
私の会社でも例に漏れずNext.jsを使用してアプリケーションを開発していますが、デプロイ時に起こった問題が
Next.jsはビルド時に成果物に環境変数を埋め込む
ということです。
実際にビルドされた.nextを見てみると
このように、環境変数として設定している値が埋め込まれているのがわかります。
そして、実行時にはこの埋め込まれた環境変数がそのまま使われます。
これの何が問題になるかというと、ビルドしたファイルをそのまま複数の環境で運用したい時に、バックエンドの向き先やその他の環境変数を変えられない。ということです。
例えば弊社では、Azure App Serviceでアプリケーションをホストしていて、以下のようにCI/CDを組んでいます。
GitHubのブランチの更新をトリガーとして、Azure PipelinesでDockerコンテナをビルド、コンテナレジストリにpushしてそれを自動でAzure App Serviceのデプロイスロットにデプロイしているわけですね。
Azure App Serviceでは、上記のようなデプロイスロットの使用が推奨されています。
デプロイスロットは、本番環境に直接デプロイするわけではなく、ステージングにデプロイして動作を確認した後、スワップ(本番環境とステージングスロットを入れ替え)を行い、本番環境にデプロイできる機能です。
アプリケーションをもう一度ビルドする手間なく、動作確認したものをそのままデプロイできるため、ダウンタイムゼロで、かつ安全にデプロイすることができます。
さて、元の話に戻ります。先程、環境変数はビルド時に解決されると言いました。
しかし、デプロイスロットを使用する場合、ステージング向けにビルドされたものがそのまま本番環境にデプロイされることになります。
つまり、デプロイスロットをNext.jsで使用すると、環境別に環境変数を設定することができません。
デプロイスロットの使用を諦めて、環境別にパイプラインを作るか?とも思いましたが、Next.jsの都合でデプロイスロットの使用を諦めるのは、なんか嫌な感じがします。
今回のアプリケーションはモノレポですし、サーバーとのデプロイ戦略を合わせたい、というのもあります。
というわけで、この記事では、Next.jsアプリケーションの実行時に環境変数を設定する方法をご紹介します。
先に言っときます。かなりハックです。
前提条件
今回は以下の条件で開発を行い、動作を検証しています。
- Next.js 14.2.6
- Pages Router
- output: standalone
- Yarn 4.5.0
- hono(Node.jsで実行)とNext.jsのモノレポジトリ
達成したいこと
- できる限り現状のソースコードを変えたくない
- チーム開発なのでそれなりにルールや知識の共有に時間がかかる
- パフォーマンスをあんまり損ないたくない
失敗例
先に、試していく中で失敗した例を紹介します。
standaloneビルドを使わない
standaloneビルドを使わずに、next buildを行った場合でも、実行環境では環境変数を読み込めませんでした。
ビルドされた成果物を見てみると、しっかりと環境変数が埋め込まれてました(一敗)。
つまり、今回はstandaloneビルドを前提としていますが、例えstandaloneビルドを使用していない場合でも、環境変数はビルド時に解決されることになります。
Runtime Configを使う
Next.jsにはRuntime Configという設定項目があります。
module.exports = {
serverRuntimeConfig: {
// Will only be available on the server side
mySecret: 'secret',
secondSecret: process.env.SECOND_SECRET, // Pass through env variables
},
publicRuntimeConfig: {
// Will be available on both server and client
staticFolder: '/static',
},
}
next.config.jsでこのように設定すると
import getConfig from 'next/config'
import Image from 'next/image'
// Only holds serverRuntimeConfig and publicRuntimeConfig
const { serverRuntimeConfig, publicRuntimeConfig } = getConfig()
// Will only be available on the server-side
console.log(serverRuntimeConfig.mySecret)
// Will be available on both server-side and client-side
console.log(publicRuntimeConfig.staticFolder)
function MyImage() {
return (
<div>
<Image
src={`${publicRuntimeConfig.staticFolder}/logo.png`}
alt="logo"
layout="fill"
/>
</div>
)
}
export default MyImage
このようにnext/configを使って読み込むことができます。
ただし、これを使ってもだめでした。
よく見てみると
A page that relies on publicRuntimeConfig must use getInitialProps or getServerSideProps or your application must have a Custom App with getInitialProps to opt-out of Automatic Static Optimization. Runtime configuration won't be available to any page (or component in a page) without being server-side rendered.
サーバーレンダリングされないページだと意味ないよ。Automatic Static Optimizationもオプトアウトする必要があるよ。Automatic Static OptimizationをオプトアプトするためにページにgetInitialPropsを使ってね(ざっくり訳)と書いてあります。
このあたりあまり理解してないのですが、今回のアプリケーションではサーバーサイドレンダリングが必要になる場面がほぼなく、CSRとしてアプリケーションを実装しています。
今回の要件には合わないかつ、パフォーマンスも悪化しそうだったので断念しました。
追記:
改めて確認したところ、この機能はもう非推奨だそうです。
なので要件を満たせたとしても、使わないほうが良いと思います。
Route Segment Config force-dynamicを使う
Route Segment Configとは、ページ単位、レイアウト単位、ルートハンドラー単位でアプリケーションの挙動を変更できる設定です。
そこに
export const dynamic = 'force-dynamic'
と書くことで、ページ・レイアウト単位で動的レンダリングを強制し、リクエスト毎にサーバーでレンダリングを行わせることができます。
しかしこれはApp Router専用機能。今回のPages Routerでは使えません。
しかも、環境変数を実行時に書き換えるためだけにこれを使用すると、パフォーマンスにも影響が出そうです。
App Routerを利用している場合、上記discussionでもこの解決策が提示されているので、パフォーマンスとかに留意したうえで使うと、手っ取り早いと思います。
解決編
結局いろいろスマートに解決できないか模索しましたが、1番ハックな方法に落ち着きました。
それが、「実行時にファイルごと環境変数を書き換える」ことです。
今回のアプリケーションでは、Dockerイメージを使ってデプロイを行い、コンテナを起動してアプリケーションをホストしています。
Dockerでは、Entrypointを設定することができ、そこに書いているスクリプトは起動時に実行されます。
つまり、Entrypointに書き換えのスクリプトを設定しておいて、起動時にビルド時に埋め込まれた環境変数を上書きしてしまおう、ということです。
メリット
- 既存の実装を一切変更する必要がない
- パフォーマンスが落ちない
デメリット
- ハッキーな方法すぎる
- Next.jsの仕様が変わった場合に動かなくなる可能性がある
- これに起因する不具合が発生する可能性がある
- なお、現時点では不具合は起こってません。
手順
なお、すでにDockerでビルドしてホストする環境が整っていること、Next.jsでアプリケーションを構築済みであることが前提になります。
1. スクリプトを作る
以下のように書いています。
#!/bin/ash
if [ "$#" -eq 0 ]; then
echo "No arguments provided. Please provide the necessary arguments."
exit 1
fi
VARIABLES="NEXT_PUBLIC_BACKEND_URL" # その他設定している環境変数を指定する
echo "Checking Environment Variables..."
for VAR in $VARIABLES; do
eval VALUE=\$$VAR
if [ -z "$VALUE" ]; then
echo "$VAR is not set. Please set it and rerun the script."
exit 1
fi
done
echo "Replacing Environment Variables..."
find /app/public /app/.next -type f -name "*.js" |
while read file; do
for VAR in $VARIABLES; do
eval VALUE=\$$VAR
sed -i "s|TMP_$VAR|$VALUE|g" "$file"
done
done
echo "Replacing Environment Success, Starting Next.js..."
exec "$@"
今回の実行環境はAlpine Linuxなので、ash向けに記述しています。
bash向けに書く場合は適宜読み替えるか、下記の記事を参考に
まず、この部分では、entrypoint.shに対して、実行コマンド(node server.jsやyarn startなど)が渡されているかを確認しています。
if [ "$#" -eq 0 ]; then
echo "No arguments provided. Please provide the necessary arguments."
exit 1
fi
ここで、置き換え対象の環境変数を指定しています。ashの場合はスペース区切りで書く必要があります。
VARIABLES="NEXT_PUBLIC_BACKEND_URL NEXT_PUBLIC_HOGE_FUGA"
ここでは、VARIABLES
で指定した環境変数がちゃんと設定されているかを確認します。
echo "Checking Environment Variables..."
for VAR in $VARIABLES; do
eval VALUE=\$$VAR
if [ -z "$VALUE" ]; then
echo "$VAR is not set. Please set it and rerun the script."
exit 1
fi
done
そして、環境変数を埋め込みます。
今回は、ビルド時に環境変数を TMP_{変数名}
にしている前提で、TMP_が付いた環境変数を置き換えています。
今回は /app/public /app/.next を対象としています。ディレクトリ構造が違う場合は適宜読み替えてください。
echo "Replacing Environment Variables..."
find /app/public /app/.next -type f -name "*.js" |
while read file; do
for VAR in $VARIABLES; do
eval VALUE=\$$VAR
sed -i "s|TMP_$VAR|$VALUE|g" "$file"
done
done
最後に、引数として渡されたコマンドを実行します。
echo "Replacing Environment Success, Starting Next.js..."
exec "$@"
2. Dockerfileを作る
FROM node:20-alpine as builder
ARG NEXT_PUBLIC_BACKEND_URL
WORKDIR /app
COPY . .
RUN corepack enable && yarn set version stable
RUN yarn install --immutable
RUN yarn workspace server db:generate
RUN yarn workspace interface build
RUN yarn workspaces focus interface
FROM node:20-alpine as runner
WORKDIR /app
RUN corepack enable && yarn set version stable
COPY /app/apps/interface/.next/standalone/apps/interface /app/
COPY /app/apps/interface/.next/standalone/node_modules /app/node_modules
COPY /app/apps/interface/public/ /app/public
COPY /app/apps/interface/.next/static /app/.next/static
COPY /app/infra/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
EXPOSE 3000
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD ["node", "server.js"]
今回のアプリケーションでは、以下のようなモノレポになっています
適宜読み替えてください。
.
├ apps
│ ├ interface
│ │ ├ .next
│ │ ├ public
│ │ ├ src
│ │ │ └ pages
│ │ │ └ index.tsx
│ │ └ package.json
│ └ server
├ infra
│ ├ Dockerfile
│ └ entrypoint.sh
└ package.json
また、
RUN chmod +x /app/entrypoint.sh
にあるように、権限を正しく当ててあげないとエラーになるので注意しましょう。
3. ビルド時に環境変数を固定値に設定する
ビルドパイプラインなどの設定で、ビルド時に環境変数を TMP_NEXT_PUBLIC_BACKEND_URL
などのようにして設定します。
4. 実行時の設定したい環境変数を設定してあげる
実行時の環境変数に、本当に設定したい環境変数を設定します。
あとは正しく動作していることを確認するだけです。
Q. スワップしたときもentrypoint.shは実行されるの?
A. されました。
ちゃんとスワップ後に設定した環境変数が適用されていることを確認できました。
よかった。
おわりに
今回は、実行環境で環境変数を変更してNext.jsを起動する方法を紹介しました。
今後公式からもっと良い解決方法が出ればいいですが......Next.js自体がサーバーサイドレンダリングを推してるので、CSRでの環境変数周りは変わらなさそうな気がしますね。
なにかの参考になれば嬉しいです。お疲れ様でした。
おまけ:別の解決方法
他の方がやっている別の解決方法を調査の過程でいくつか見つけたので載せておきます。
検証はしてないので動作の保証はできません。
Route Segment Config force-dynamicを使う
Appでしか動かない。
Route Segment Config force-dynamicを使うで紹介した方法を実際にやった例
クライアントコンポーネントの親をサーバーサイドレンダリングして、Contextを使ってクライアントでその値を使う方法
1番シンプルでキレイな気がします。
ただし、CSRの部分では実装がめんどくさくなるという課題もあるようです。
getInitialPropsを設定して、publicRuntimeConfigを使う方法
多分PagesでもAppでも動く。でもAppならforce-dynamicを使うほうが良いと思う。
Runtime Configを使うで紹介した方法をちゃんとやった例
なんもしないけど読み込める?例
ここでなんか触れられている。多分force-dynamicを指定してるからだと思う。
参考
Discussion