banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

一次構築多箇所デプロイ - Next.js ランタイム環境

私たちは一般的に、env を制御することで "Build once, deploy many" の哲学を実現します。しかし、Next.js では、環境変数は 2 種類に分かれます。一つはクライアント側で使用できる NEXT_PUBLIC_ で始まる環境変数、もう一つはサーバー側でのみ使用できる環境変数です。前者は Next.js のビルド時にクライアントコードに注入され、元のコードが置き換えられるため、env を制御しても一度のビルドで複数のデプロイを実現することはできません。一度異なる環境にデプロイする必要があり、env を変更する場合は再ビルドが必要です。

image

今日の記事では、Next.js の Runtime Env を使用して一度のビルドで複数のデプロイを実現する方法について探ります。

Next.js Runtime Env#

今日の主役は next-runtime-env というライブラリで、これを使うことで Next.js で Runtime Env を利用できます。これを通じて、一度のビルドで複数のデプロイを実現できます。

npm i next-runtime-env

クライアント側の環境変数の使用方法を変更します:

import { env } from 'next-runtime-env'

const API_URL = process.env.NEXT_PUBLIC_API_URL
const API_URL = env('NEXT_PUBLIC_API_URL') 

export const fetchJson = () => fetch(API_URL as string).then((r) => r.json())

次に、 app/layout.tsx に環境変数注入スクリプトを追加します。

import { PublicEnvScript } from 'next-runtime-env'

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en">
      <head>
        <PublicEnvScript />
      </head>
      <body className={inter.className}>{children}</body>
    </html>
  )
}

これで完了です。

では、試してみましょう。以下のようなページがあり、上記の API_URL の応答データを直接レンダリングします。

'use client'

export default function Home() {
  const [json, setJson] = useState(null)
  useEffect(() => {
    fetchJson().then((r) => setJson(r))
  }, [])
  return JSON.stringify(json)
}

今、 next build でプロジェクトをビルドし、その後 .envNEXT_PUBLIC_API_URL を変更し、 next start でプロジェクトを起動して、実際のリクエストのインターフェースが .env の変更に応じて変わるかどうかを観察します。

現在の NEXT_PUBLIC_API_URL=https://jsonplaceholder.typicode.com/todos/2 で、プロジェクトを起動すると、ブラウザがリクエストするのは https://jsonplaceholder.typicode.com/todos/2 です。

image

.envNEXT_PUBLIC_API_URLhttps://jsonplaceholder.typicode.com/todos/3 に変更し、プロジェクトを再起動すると、ブラウザがリクエストするのは https://jsonplaceholder.typicode.com/todos/3 です。

image

これで、一度のビルドで複数のデプロイを実現しました。env を変更するだけで済みます。

Runtime Env の詳細#

実際、 next-runtime-env の実装原理は非常にシンプルで、 <PublicEnvScript /> は実際には <head><script /> を注入しています。以下のように。

<script data-testid="env-script">window['__ENV'] = {"NEXT_PUBLIC_API_URL":"https://jsonplaceholder.typicode.com/todos/3"}</script>

<head /> 内のスクリプトはページの水合前に実行されるため、クライアント側で window['__ENV'] を通じて環境変数を取得できます。 next-runtime-env が提供する env() はこのように実現されています。この環境変数はサーバーサイドでは常に動的で、サーバーサイドの値は常に process.env[key] を通じて取得されます。

以下の簡略なコードは env() の実装を示しています。

export function env(key: string): string | undefined {
  if (isBrowser()) {
    if (!key.startsWith('NEXT_PUBLIC_')) {
      throw new Error(
        `Environment variable '${key}' is not public and cannot be accessed in the browser.`,
      );
    }

    return window['__ENV'][key];
  }

  return process.env[key];
}

環境変数に依存しない成果物の構築#

プロジェクトには一般的に大量の環境変数が存在し、一部の環境変数はクライアント側でのみ使用されます。プロジェクトのビルドプロセス中に、環境変数を正しく注入しないと、プロジェクトがビルドできなくなります。

例えば、一般的な API_URL 変数はリクエストインターフェースのアドレスであり、ビルド中に値がないと、プリレンダリング中のインターフェースリクエストエラーが発生し、ビルドが失敗します。例えば、ルートハンドラー内に以下のような関数があります。

import { NextResponse } from 'next/server'

import { fetchJson } from '../../../lib/api'

export const GET = async () => {
  await fetchJson()
  return NextResponse.json({})
}

API_URL が空の場合、 fetchJson はエラーを報告し、ビルドが失敗します。

 ✓ Collecting page data    
   Generating static pages (0/6)  [    ]
Error occurred prerendering page "/feed". Read more: https://nextjs.org/docs/messages/prerender-error

TypeError: Failed to parse URL from 

これは、Next.js がデフォルトでルートハンドラーをプリレンダリングするためで、プリレンダリング中に fetchJson が実行され、 API_URL が空であるため、リクエストが失敗します。

noStore() を使用するか、動的な方法を変更することでこの問題を解決できます。

import { unstable_noStore } from 'next/cache'
import { NextResponse } from 'next/server'

import { fetchJson } from '../../../lib/api'

export const dynamic = 'force-dynamic' // 方法 2

export const GET = async () => {
  unstable_noStore() // 方法 1
  await fetchJson()
  return NextResponse.json({})
}

他のページのビルド中に同様の問題が発生した場合も、この部分を変更すれば解決できます。

ビルド時に、環境変数を一切注入しなかった場合、ビルド後のサービスを起動する前に、必ず現在のディレクトリに .env ファイルを作成し、変数の値を正しく記入することを忘れないでください。そうしないと、プロジェクトが正常に動作しません。

Dockerfile を使用して環境変数に依存しないイメージを構築#

前のセクションを基に、全体のビルドプロセスをさらに封装し、Docker を使用してビルドを完了し、Docker Hub に公開することで、真の意味で一度のビルドで複数のデプロイを実現します。

Dockerfile ファイルを作成します。

FROM node:18-alpine AS base

RUN npm install -g --arch=x64 --platform=linux sharp

FROM base AS deps

RUN apk add --no-cache libc6-compat
RUN apk add --no-cache python3 make g++

WORKDIR /app

COPY . .

RUN npm install -g pnpm
RUN pnpm install

FROM base AS builder

RUN apk update && apk add --no-cache git

WORKDIR /app
COPY --from=deps /app/ .
RUN npm install -g pnpm

ENV NODE_ENV production
RUN pnpm build

FROM base AS runner
WORKDIR /app

ENV NODE_ENV production

# その他のdocker環境注入
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/.next/server ./.next/server

EXPOSE 2323

ENV PORT 2323
ENV NEXT_SHARP_PATH=/usr/local/lib/node_modules/sharp
CMD node server.js;

上記の dockerfile は公式バージョンを基に修正され、Shiroで実際に使用されています。

Next.js のスタンドアロンビルドには sharp 依存関係が含まれていないため、Docker ビルド中にまず sharp をグローバルにインストールし、その後 sharp のインストール位置の環境変数を注入しました。

このように構築された Docker イメージは環境変数に依存せず、スタンドアロンビルドにより Docker イメージの占有スペースが小さくなります。

Docker コンテナのパスマッピングを通じて、現在のディレクトリにある .env をコンテナ内部の /app/.env にマッピングするだけで済みます。

ここで、簡単な Docker コンポーズの例を書きます。

version: '3'

services:
  shiro:
    container_name: shiro
    image: innei/shiro:latest
    volumes:
      - ./.env:/app/.env # .envファイルをマッピング
    restart: always
    ports:
      - 2323:2323

これで完成です。今後、誰でも Docker pull を通じて構築されたイメージを取得し、ローカルの .env を変更することで、自分の環境のプロジェクトを実行できるようになります。

この記事は Mix Space によって xLog に同期更新されました。元のリンクは https://innei.in/posts/tech/nextjs-runtime-env-and-build-once-deploy-many

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。