🛠️

Next.jsアプリケーションで実行時に環境変数を設定する

2024/10/21に公開

今や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という設定項目があります。

https://nextjs.org/docs/pages/api-reference/next-config-js/runtime-configuration

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を使う

https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config

Route Segment Configとは、ページ単位、レイアウト単位、ルートハンドラー単位でアプリケーションの挙動を変更できる設定です。

そこに

export const dynamic = 'force-dynamic'

と書くことで、ページ・レイアウト単位で動的レンダリングを強制し、リクエスト毎にサーバーでレンダリングを行わせることができます。

しかしこれはApp Router専用機能。今回のPages Routerでは使えません。
しかも、環境変数を実行時に書き換えるためだけにこれを使用すると、パフォーマンスにも影響が出そうです。

https://github.com/vercel/next.js/discussions/44628#discussioncomment-7002890

App Routerを利用している場合、上記discussionでもこの解決策が提示されているので、パフォーマンスとかに留意したうえで使うと、手っ取り早いと思います。

解決編

結局いろいろスマートに解決できないか模索しましたが、1番ハックな方法に落ち着きました。
それが、「実行時にファイルごと環境変数を書き換える」ことです。

今回のアプリケーションでは、Dockerイメージを使ってデプロイを行い、コンテナを起動してアプリケーションをホストしています。

Dockerでは、Entrypointを設定することができ、そこに書いているスクリプトは起動時に実行されます。

つまり、Entrypointに書き換えのスクリプトを設定しておいて、起動時にビルド時に埋め込まれた環境変数を上書きしてしまおう、ということです。

メリット

  • 既存の実装を一切変更する必要がない
  • パフォーマンスが落ちない

デメリット

  • ハッキーな方法すぎる
  • Next.jsの仕様が変わった場合に動かなくなる可能性がある
  • これに起因する不具合が発生する可能性がある
    • なお、現時点では不具合は起こってません。

手順

なお、すでにDockerでビルドしてホストする環境が整っていること、Next.jsでアプリケーションを構築済みであることが前提になります。

1. スクリプトを作る

以下のように書いています。

entrypoint.sh
#!/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向けに書く場合は適宜読み替えるか、下記の記事を参考に

https://phase.dev/blog/nextjs-public-runtime-variables/

まず、この部分では、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を作る

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 --from=builder /app/apps/interface/.next/standalone/apps/interface /app/
COPY --from=builder /app/apps/interface/.next/standalone/node_modules /app/node_modules
COPY --from=builder /app/apps/interface/public/ /app/public
COPY --from=builder /app/apps/interface/.next/static /app/.next/static
COPY --from=builder /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でしか動かない。

https://blog.manaten.net/entry/runtime-env-for-client

Route Segment Config force-dynamicを使うで紹介した方法を実際にやった例

クライアントコンポーネントの親をサーバーサイドレンダリングして、Contextを使ってクライアントでその値を使う方法

1番シンプルでキレイな気がします。

ただし、CSRの部分では実装がめんどくさくなるという課題もあるようです。

getInitialPropsを設定して、publicRuntimeConfigを使う方法

多分PagesでもAppでも動く。でもAppならforce-dynamicを使うほうが良いと思う。

https://teamraft.com/2022/06/27/nextjs-in-docker/

Runtime Configを使うで紹介した方法をちゃんとやった例

なんもしないけど読み込める?例

https://zenn.dev/link/comments/44f15b2ceaf49b

ここでなんか触れられている。多分force-dynamicを指定してるからだと思う。

参考

https://teamraft.com/2022/06/27/nextjs-in-docker/
https://zenn.dev/link/comments/44f15b2ceaf49b
https://blog.manaten.net/entry/runtime-env-for-client
https://github.com/vercel/next.js/discussions/16995
https://www.saltycrane.com/blog/2021/04/buildtime-vs-runtime-environment-variables-nextjs-docker/
https://phase.dev/blog/nextjs-public-runtime-variables/
https://dev.to/itsrennyman/manage-nextpublic-environment-variables-at-runtime-with-docker-53dl

Discussion