Express + Next.js をコンテナで動かしたい

皆さん、こんにちは。技術開発グループのn-ozawanです。
9月ですね。年を重ねると時間経つのが早く感じる現象を「ジャネーの法則」といいます。

本題です。
Express + Next.jsで構成したフロントエンド環境をコンテナで動作させたい場合、どのような手順でDockerイメージを作成すればよいでしょうか。今回は、Express + Next.jsで構成したフロントエンド環境をイメージ化するまでの手順をまとめたいと思います。

Express + Next.js プロジェクトの準備

まずはExprerss + Next.jsで構成されたプロジェクトを準備します。以下のコマンドでNext.jsを構築します。途中の質問は全てデフォルトのままでOKです。

npx create-next-app@latest express-server-app

次にExpressをインストールします。

cd express-server-app
npm i express
npm i -D ts-node @types/express

プロジェクトのルート直下にserver.tsを追加します。内容は以下の通りです。

import express, { json } from 'express';
import next from "next";

const port = parseInt(process.env.PORT || "3000", 10);
const dev = process.env.NODE_ENV !== "production";
const server = express();
const app = next({ dev });

async function startupServer() {
  await app.prepare();    

  const handle = app.getRequestHandler();
  server.get('*',(req, res) => handle(req, res));

  server.listen(port, () => {
    console.log(
      `> Server listening at http://localhost:${port} as ${dev ? "development" : process.env.NODE_ENV}`,
    );
  });    
}

startupServer();

7行目のnext({ dev });でNext.jsのサーバーを生成しています。引数にdevを渡していますが、ローカル環境で動作するときはdev: trueを、本番環境で動作するときはdev: falseを指定する必要があります。このdevの中身は5行目で判断しています。

10行目のapp.prepare();でNext.jsのサーバーを立ち上げます。13行目ではExpressサーバーが受信したリクエストを、Next.jsサーバーへ渡します。

tsconfig.server.jsonを追加します。内容の説明は省きます。

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "commonjs",
    "outDir": "dist",
    "lib": ["es2019"],
    "target": "es2019",
    "noEmit": false
  },
  "include": ["server.ts"]
}

あとは起動するコマンドを修正すればOKです。package.jsonのscriptsを修正します。

"dev": "ts-node -P ./tsconfig.server.json ./server.ts",

以下のコマンドでExpress + Next.jsの環境が動作します。

npm run dev

Dockerfileの作成

ローカル環境での動作が確認できましたので、本番環境用に、このプロジェクトのDockerイメージを作成します。Next.jsのDockerイメージを作成するDockerfileは公式より公開されています。このDockerfileを参考に構築します。

ビルド

Dockerfileを作成します。まずはpackage.jsonpackage-lock.jsonをイメージにコピーして、npm ciにより依存関係のパッケージをインストールします。

FROM node:18-alpine AS base

# Install dependencies only when needed
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

次にソースコードなどをイメージにコピーして、npm run buildによりビルドを実行します。

# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN npm run build

npm run buildは以下のように修正します。next.jsのビルドと、Expressのビルドを実行しています。

"build": "next build && tsc --project tsconfig.server.json",

Next.jsをビルドする際は、next.config.mjsoutput: "standalone"を追加しておきます。standaloneで出力すると、node_moduleを含む、Next.jsを実行するために必要なファイルがstandaloneというフォルダにまとめて出力してくれます。

const nextConfig = {
  output: "standalone",
};

Next.jsのファイルをイメージにコピーする

Next.jsをビルドすると、ビルド結果は.nextフォルダに出力されます。.nextに出力されたファイルをイメージにコピーします。

# Production image, copy all the files (express + next.js) and run express
FROM base AS runner
WORKDIR /app

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

Expressのファイルをイメージにコピーする

Expressをビルドすると、ビルド結果はdistフォルダに出力されます。distに出力されたファイルをイメージにコピーします。コピー後は、npm ci --omit devで実行に必要なパッケージをインストールします。

COPY --from=builder /app/dist ./

COPY --from=builder /app/package-lock.json ./
RUN npm ci --omit dev

サーバーを実行する

最後はnpm run startによりサーバーを実行します。

USER nextjs
EXPOSE 3000
ENV PORT=3000

# server.js is created by next build from the standalone output
ENV HOSTNAME="0.0.0.0"
CMD ["npm", "run", "start"]

npm run startの内容は以下のように修正します。

"start": "NODE_ENV=production node ./server.js",

イメージ化してコンテナを起動する

Dockerfileの編集は以上です。実際にイメージ化してコンテナを起動してみましょう。

docker build -t express-server-app .
docker run -p 3000:3000 -it express-server-app

おわりに

今回はExpress + Next.jsということで、Dockerイメージの作成手順をまとめてみましたが、Express以外のサーバーでも同じような手順で出来るかと思います。

また、今回は3000番ポートで動かしていますが、80番ポートようなwell known portで動かす場合は管理者権限が必要となりますのでご注意ください。USER nextjsでnextjsユーザーで起動するようにしていますので、おそらく80番ポートでの起動は出来ないです。その場合はUSER nextjsをコメントアウトしてください。

ではまた。

Recommendおすすめブログ