Closed31

Next.js のサーバが起動するまでを追う (v11.1.2)

Masayuki IzumiMasayuki Izumi

前提

Next.js でサーバを起動する方法は大きく3つ(まだあるっけ?)

これらが起動するサーバはすべて next/server/next で定義されている NextServer class に行き着く。

next start

next dev

Custom server

  • もっともシンプルな例:
    • import next from "next";
    • const app = next({ dev: process.env.NODE_ENV !== 'production' });
    • app.prepare().then(() => { /* HTTP server つくってハンドラ登録して起動して待ち受け */ });
  • import next from "next";
    • cat packages/next/package.json | jq .main => ./dist/server/next.js
    • next startstartServer 内で呼ばれているものと同じ
Masayuki IzumiMasayuki Izumi

NextServer

import next from "next"

const app = next({ dev: process.env.NODE_ENV !== "production" });  // これ

https://github.com/vercel/next.js/blob/v11.1.2/packages/next/server/next.ts#L29

Masayuki IzumiMasayuki Izumi

主要 API は3つ

  • コンストラクタ
  • getRequestHandler
  • prepare

内部的にはいずれも getServer() が返すオブジェクトに移譲されている
https://github.com/vercel/next.js/blob/v11.1.2/packages/next/server/next.ts#L130-L140

2回目以降は serverPromise にメモ化されたインスタンスを使い回す。

      setTimeout(getServerImpl, 10)
      this.serverPromise = this.loadConfig().then(async (conf) => {
        this.server = await this.createServer({
          ...this.options,
          conf,
        })
        if (this.preparedAssetPrefix) {
          this.server.setAssetPrefix(this.preparedAssetPrefix)
        }
        return this.server
      })

とりあえず以下3つを見ていけば良さそう

  • getServerImpl
  • this.loadConfig
  • this.createServer
Masayuki IzumiMasayuki Izumi

setTimeout(getServerImpl, 10) は単純で、ServerImpl というファイルローカルな変数を初期化している

// https://github.com/vercel/next.js/blob/v11.1.2/packages/next/server/next.ts#L21-L27

let ServerImpl: typeof Server


const getServerImpl = async () => {
  if (ServerImpl === undefined)
    ServerImpl = (await import('./next-server')).default
  return ServerImpl
}

setTimeout で呼んで返り値を捨てているのは、loadConfig と並行に実行することで起動を早くするためっぽい
Promise.all じゃないのは、dev: true 時は不要になるからかな(しらんけど)
https://github.com/vercel/next.js/pull/24129

Masayuki IzumiMasayuki Izumi

DevServerServer を extend している
それぞれ prepare()getRequestHandler() をみてみる

prepare()

getRequestHandler()

Masayuki IzumiMasayuki Izumi
Masayuki IzumiMasayuki Izumi

this.loadConfig

NextServer のなんらかのメソッドを叩くと呼ばれる。
https://github.com/vercel/next.js/blob/v11.1.2/packages/next/server/next.ts#L119-L126

実体は next/server/config にある loadConfig

やってることはだいたい予想通りで next.config.js を require してるだけなんだけど、その手前にいくつかポイントがある

Masayuki IzumiMasayuki Izumi
`loadConfig` の前半を抜粋
// https://github.com/vercel/next.js/blob/v11.1.2/packages/next/server/config.ts#L451-L541

export default async function loadConfig(
  phase: string,
  dir: string,
  customConfig?: object | null
): Promise<NextConfigComplete> {
  await loadEnvConfig(dir, phase === PHASE_DEVELOPMENT_SERVER, Log)
  await loadWebpackHook(phase, dir)


  if (customConfig) {
    return assignDefaults({
      configOrigin: 'server',
      ...customConfig,
    }) as NextConfigComplete
  }


  const path = await findUp(CONFIG_FILE, { cwd: dir })


  // If config file was found
  if (path?.length) {
    let userConfigModule: any

Masayuki IzumiMasayuki Izumi

いくつか重要な挙動があった

  • next({ /* ... */ })conf を渡した場合、next.config.js は読まれない
  • next.config.js 以外の設定ファイルは許可されていない
    • .ts もだめだし、next.config/index.js でも多分ダメ

とくに前者の挙動は割と罠っぽい

Masayuki IzumiMasayuki Izumi

webpack とか、そういう devDependencies 的な外部モジュールが dependencies に含まれずバンドルされてるのはおもしろポイント
外部モジュールの不意の breaking や相性問題とかに振り回されないようにするためかな?

Masayuki IzumiMasayuki Izumi

startDevelopmentServer

next dev コマンドからの起動シーケンスでのみ実行される関数
next({ dev: true }) でも通らない

Masayuki IzumiMasayuki Izumi

Log

custom server にして自前のインフラに出すときとか、stdout / stderror 制御したくなる場合もあるかも(ないかも)

Masayuki IzumiMasayuki Izumi

graceful shutdown

してるのかな?
next start は本番で使ったらダメ って言ってる記事もあるので、ちょっと気になる
https://zenn.dev/umireon/articles/2e6add9aa34dbb

Masayuki IzumiMasayuki Izumi

参照した記事では「Express を使用していない(http は実装が低レベルすぎる)からダメ」と書いているが、http 使ってても諸々ちゃんとやってくれていればそれでいいので、使ったらダメな理由としては弱すぎる。

Masayuki IzumiMasayuki Izumi

v10.0.4 で導入されたよ!と主張している記事
https://mzqvis6akmakplpmcjx3.hatenablog.com/entry/2021/01/14/103134

でもこの記事で参照されているコードは SIGTERM されたらすぐ process.exit(0) してるだけにみえる。
要するに終了コードを正常に差し替えているだけ。

Graceful shutdown は「アクティブなコネクションが残っている場合はそれを捌き切ってから終了する」くらいのイメージだったので、自分の認識とは違う

Go とかはそんなかんじ
https://pkg.go.dev/net/http#Server.Shutdown

Masayuki IzumiMasayuki Izumi

雑に grep したかんじ、ライブラリ内でシグナル監視してる実装はない(まあこれは普通か)
next コマンド中にはある

`rg process.on`
:) % rg process.on
examples/with-electron-typescript/electron-src/preload.ts
15:process.once('loaded', () => {

packages/next/compiled/amphtml-validator/index.js
1:module.exports=(()=>{var e={306:(e,t,n)=>{"use strict";const r=n(584);const o=n(747);const i=n(605);const s=n(211);const u=n(622);const a=n(654);const [... 1 more match]

packages/next/compiled/conf/index.js
1:module.exports=(()=>{var f={601:f=>{"use strict";f.exports=JSON.parse('{"$schema":"http://json-schema.org/draft-07/schema#","$id":"https://raw.githubu [... 1 more match]

packages/next/compiled/@vercel/nft/index.js
1:module.exports=(()=>{var __webpack_modules__={3380:e=>{"use strict";e.exports=JSON.parse('{"0.1.14":{"node_abi":null,"v8":"1.3"},"0.1.15":{"node_abi": [... 2 more matches]

packages/next/compiled/webpack/index.js
56:process.on('message', function (data) {

packages/next/compiled/webpack/bundle4.js
48720:    process.on('exit', function() {
97260:					this.processContext(parser, expr, param);
97304:					this.processContext(parser, expr, param);
97331:	processContext(parser, expr, param) {
97985:					this.processContext(parser, expr, p);
98026:					this.processContext(parser, expr, p);
98060:	processContext(parser, expr, param) {
98635:		const processContext = (expr, param) => {
98718:					processContext(expr, param);
103386:						processContext(expr, option, weak);
103396:					processContext(expr, param, weak);
103414:		const processContext = (expr, param, weak) => {

packages/next/compiled/webpack/bundle5.js
29823:    process.on('exit', function() {
40951:			const processConnection = (connection, stateInfo) => {
40971:					processConnection(connection, state === true ? "T" : "O");
40992:					processConnection(connection, stateInfo);
59134:		const processContextHashSnapshot = (path, hash) => {
59166:				processContextHashSnapshot(path, hash);
59174:					processContextHashSnapshot(path, tsh);
59183:							processContextHashSnapshot(path, tsh.hash);
59190:								processContextHashSnapshot(path, tsh.hash);
78525:	const processConnectQueue = () => {
78927:			processConnectQueue();
86690:					this.processContext(parser, expr, param);
86730:					this.processContext(parser, expr, param);
86764:	processContext(parser, expr, param) {
87476:					this.processContext(parser, expr, p);
87513:					this.processContext(parser, expr, p);
87550:	processContext(parser, expr, param) {
132209:			// Find the maximum group and process only this one
139897:					// Process one dependency

packages/next/compiled/ora/index.js
1:module.exports=(()=>{var e={250:(e,t,r)=>{var s=r(820);e.exports=function(e,t){e=e||{};Object.keys(t).forEach(function(r){if(typeof e[r]==="undefined" [... 1 more match]

packages/next/server/dev/next-dev-server.ts
336:    process.on('unhandledRejection', (reason) => {
341:    process.on('uncaughtException', (err) => {

packages/next/bin/next.ts
97:process.on('SIGTERM', () => process.exit(0))
98:process.on('SIGINT', () => process.exit(0))

bench/capture-trace.js
54:  process.on('SIGINT', () => {
Masayuki IzumiMasayuki Izumi

これは前述の記事にあったとおりで、終了コードを変えてるだけで HTTP の接続には関知しない

// https://github.com/vercel/next.js/blob/v11.1.2/packages/next/bin/next.ts#L96-L98

// Make sure commands gracefully respect termination signals (e.g. from Docker)
process.on('SIGTERM', () => process.exit(0))
process.on('SIGINT', () => process.exit(0))
Masayuki IzumiMasayuki Izumi

next server の HTTP server の実体はさっき読んだとおりで、単に http.createServer で作ったやつ

// https://github.com/vercel/next.js/blob/v11.1.2/packages/next/server/lib/start-server.ts#L13-L19

  const srv = http.createServer(app.getRequestHandler())
  await new Promise<void>((resolve, reject) => {
    // This code catches EADDRINUSE error if the port is already in use
    srv.on('error', reject)
    srv.on('listening', () => resolve())
    srv.listen(port, hostname)
  })
Masayuki IzumiMasayuki Izumi

雑に実験。以下のコマンドでサーバを起動し、curl を投げて返ってくる前にサーバを落とす。

node -e 'require("http").createServer((_, resp) => { setTimeout(() => { resp.write("ok"); resp.end() }, 5000) }).listen(3000)'

ダメです。

:) % curl http://localhost:3000
curl: (52) Empty reply from server
Masayuki IzumiMasayuki Izumi

なので、「Next.js サーバをセルフホストするときは next start を使ったらダメ」という主張自体は真である。

Masayuki IzumiMasayuki Izumi

じゃあ自分で Express 使えば大丈夫!かというとそんなことはなさそうで、Graceful shutdown とかは自前で実装する必要はある。
そりゃそうか(単なるライブラリにシグナル捕まえられても困りそう)。
https://expressjs.com/en/advanced/healthcheck-graceful-shutdown.html

一応実験。

node -e 'app=require("express")(); app.get("/", (_, resp) => { setTimeout(() => { resp.send("ok") }, 5000)}); app.listen(3000)'

ダメです。

:) % curl http://localhost:3000
curl: (52) Empty reply from server
Masayuki IzumiMasayuki Izumi

graceful shutdow の話は、Blue-Green Deployment だとそこまで問題にならないのか
Rolling deploy を採用してると困る

coffeecupcoffeecup

cli 周りが分からなかったので、参考になりました!ありがとうございます!

V14 の getRequestHandler 周りの状況を調べたら、
V11 では getRequestHandler() で router.execute の1つの処理でレンダリングを行っているっぽいのが、V14 だと router serverrender-server の2つに分けれて、ルーティング(マニフェストを読み込んで、ダイナミックルーティングなどの処理を行う)とレンダリング(コンポーネントをサーバーサイドレンダリングさせて、HTML に変換して、フロントに渡す)を行っているみたいですね。
ルーティングはここら辺で行っていて、
レンダリングは、page router は分かりやすいんですが、app-router は分かりづらいですね。

このスクラップは2022/03/19にクローズされました