運用 Next.js 的 Output File Tracing,減少 50% docker image size

hong-jen kao
Dcard Tech Blog
Published in
13 min readMay 8, 2023

大家好,我是 Dcard Frontend 的 cades。

自從 Next.js 12 釋出,能大幅減少 build output size 的 Output File Tracing 這個新功能就引起我的興趣,並嘗試將其運用在在公司的專案中。這篇文章將會介紹 Output File Tracing 誕生的緣起、欲解決的問題、運作的原理,以及我們在 Dcard 啓用它的過程中遇到的問題與解法。

Vercel 對「縮小能獨立佈署的軟體單元體積」的嘗試

serverless

Next.js 8 開始,為了支援佈署到 serverless 環境,推出將每個頁面打包成完全獨立(complete standalone)的單一檔案的功能,讓每個 page 對應到一個 lambda。在 next.config.js 中,此功能可透過 target: "serverless" 啓用。

serverless 的佈署環境在服務提供商之間存在差異,例如 Google Cloud Function 支援 package.json、佈署前幫你跑 npm install;而 AWS lambda 不允許,但可接受上傳一包 zip 檔。此外,兩者都接受 docker image 作為執行單元。

Node File Trace

隨著 Next.js 12 釋出target: "serverless"這項功能被 output file tracing 取代。官方表示target: "serverless" 是一種基於 webpack ,將所有 dependency 打包成單一檔案的做法,而他們很快發現這個做法「並不理想」,轉而開發了 @vercel/nft 這個套件,並將其運用在 Next.js 中,作為下一代的優化方案。

補充:這裡的 nft 是 node file trace 的縮寫,與虛擬貨幣無關

@vercel/nft 的 README 開頭寫道:

Used to determine exactly which files (including node_modules) are necessary for the application runtime.

This is similar to @vercel/ncc except there is no bundling performed and therefore no reliance on webpack. This achieves the same tree-shaking benefits without moving any assets or binaries.

nft 選擇了一條非常單純的道路:從入口檔案開始,分析 require、import、fs,一路「找出實際用到的檔案」,僅此而已。

可以將其視為「只做到檔案層級的 tree-shaking」,可想而知壓縮率肯定不如打包的方案。但正因為不涉及打包,因此從根本上避開諸多相容性問題。

Output File Tracing

而 Next 12 的 Output File Tracing 以 @vercel/nft給出的這份檔案清單為基礎,做了幾件事情:

  1. 將清單內的檔案維持原本的目錄結構,複製一份到 .next/standalone 目錄下
  2. 產生一個能夠直接被 node執行的 server.js,以取代原本啓動 server 的 next start 指令(也正因如此, .bin/next 將不會包含在 .next/standalone目錄中)

產出結果的使用方式也很單純:

  1. .next/standalone這個目錄複製一份到你的執行環境
  2. 執行 node server.js

這裡要注意,沒有引用到的檔案(例如 public/.next/static)不會被自動複製,需要手動複製到 .standalone下。

雖說原理很單純,但 nft 在減少檔案大小的表現亮眼,可以節省約 60% — 80% 。

關於打包

@vercel/nft 在避開打包的這條路上取得成績的同時,Vercel 仍在持續探索打包方案的可行性,例如打包成單一 JS 檔的 @vercel/ncc、編譯成單一執行檔的 @vercel/pkg。但對於 ESM 支援不佳,目前尚不到實用的程度。

在 Dcard 導入 Output File Tracing

為方便說明,以下將 Output File Tracing 簡稱為 OFT

一般來說,在一個單純的 Next.js 專案中,只要按照官方文件設定即可啓用 OFT。而 Dcard Frontend 在導入的過程中遇到了幾個需要克服的問題,在此分享。

OFT + monorepo

假設我們有一個簡單的 monorepo,結構如下:

my-monorepo-project
├── apps
│ └── my-app
└── packages
└── my-package

OFT 預設只會 trace 專案當前目錄下的檔案(如上面的 my-apps 目錄),而在 monorepo 的專案架構下,通常會依賴存取其他放在 packages/ 下的共用套件。為了將上層目錄納入 nft 的處理範圍,我們需要依照官方文件提供的解法,在 next.config.js 中增加以下設定即可:

// apps/my-app/next.config.js
module.exports = {
experimental: {
// this includes files from the monorepo base two directories up
outputFileTracingRoot: path.join(__dirname, '../../'),
},
}

然而當我們把 build 完的 .next/standalone 取出,會發現沒有 server.js 供我們執行,原因是 OFT 的產出維持了往上兩層的目錄結構,build 完的檔案主要放在 .next/standalone/apps/my-app 底下。此時如果我們嘗試直接執行:

node .next/standalone/apps/my-app/server.js

打開瀏覽器,可以看到網站是成功跑起來了,但功能似乎不太正常。打開 DevTool,會發現 build 出來的 js chunk 並沒有正確被載入:

此時我們需要動手整理一下目錄結構:

# 建立 dist 目錄. 把會用到的檔案都複製到這裡
mkdir dist

# monorepo 中此次 build 完的 app 目錄
cp -a ./.next/standalone/apps/my-app/. dist/

# OFT 過濾過的 node_modules 目錄
cp -a ./.next/standalone/node_modules dist/

# 沒有被 OFT 涵蓋的檔案
cp -a ./.next/static dist/.next/
cp -a ./public dist/

# 別忘了 package.json :)
cp -a ./package.json dist/

# 注意:./.next/standalone/packages/my-package/ 並不需要複製
# 它們已經連同 apps/my-app 的檔案被 next 編譯好,一起放到 ./.next/standalone/apps/my-app/.next/server/chunks 底下了

再嘗試執行一次:

node dist/server.js

恢復正常了!

OFT + custom server (and next.config.js)

Dcard 在 2019 年開始採用 Next.js,為了讓前端也能自行處理一些 server side 的需求而採用了 custom server(時至今日,已經有 middlewareAPI routes 等選擇了)。

前面提到 OFT 會產生一個代替 next startserver.js,這是 OFT 預設的入口。撰寫自己的 custom server 意味著我們決定捨棄這個預設的 server,然而 custom server 並不包含在 OFT 的處理範圍內,因此我們必須想辦法把 custom server (與它的 dependencies)也納入產出。

我們嘗試過使用 webpack、rollup、ncc、pkg 等打包方案,webpack 曾在其中某幾個 dependency 較單純的 site 成功打包,然而終究無法處理所有的 site 的所有 dependency,最終都以失敗收場。某種程度上印證了 Vercel 放棄 target: "serverless" 的理由。後來才想通,採用與 OFT 底層相同的 @vercel/nft 應該可行。

實際使用 @verce/nft ,會發現意外的單純。

const { nodeFileTrace } = require('@vercel/nft');
const files = ['./src/main.js', './src/second.js'];
const { fileList } = await nodeFileTrace(files);

只要提供「需要被 trace 的入口檔案列表」,nft 在完成 tracing 後將回傳「實際用到的檔案列表」,我們只需將這些檔案維持原本的目錄結構一一複製即可。在此提供我們交付 nft 處理的檔案:

  • TypeScript 編譯完的 custom server 入口檔案
  • next.config.js (如果它有 import 其他套件,那麼也會需要被 nft 處理)
  • custom start script (Dcard 有開發一套 start script,用來處理跨 site 共用的 setup/teardown 工作)

此外,前面有提到 OFT 會把 packages/ 底下的檔案一起編譯並放到 ./.next/standalone/apps/my-app/.next/server/chunks 底下。然而這是 OFT 才有的功能,nft 只負責「找出檔案」,因此 monorepo packages 的路徑轉譯工作,在這裡必須由我們在搬移的過程中自己完成。

我們在這裡的做法是把 packages/my-package 複製一份到 node_modules/my-package

解決上述問題後,終於成功啓用 OFT 了。在納入 custom server 、custom start script 以及 docker image base 的情況下,各 site 的 docker image 的 size 最終減少了 50% 左右。

擴大應用範圍

既然 @vercel/nft 可以處理 custom server,意味即便不是 Next.js 專案,一般的 node.js server 專案也能運用 @vercel/nft 優化空間。Dcard 目前也正在進行這個方向的嘗試。

一些雷區

關於 serverRuntimeConfig 與 publicRuntimeConfig

官方文件提到OFT 會在 build time 決定 serverRuntimeConfig 與 publicRuntimeConfig,並將其 serialize 成 JSON 寫入 server.js

Note: next.config.js is read during next build and serialized into the server.js output file. If the legacy serverRuntimeConfig or publicRuntimeConfig options are being used, the values will be specific to values at build time.

Next.js 官方目前將它們標示為 legacy,明確表示不支援 OFT,建議使用環境變數代替。然而,即便改用環境變數,它們依然會在 build time 被決定 (inline)。你必須在 build image 前設定好環境變數,而 build 出來的 code 將不再具備於 runtime 讀取環境變數的能力。

因此,如果你依賴 runtime 環境變數(例如在 production 和 staging 共用同一份 docker image,透過環境變數來調整設定),請務必注意這裡的行為將不符你的預期。

關於這個問題,我們已知的解法有兩種。

解法一:使用 custom server,由於捨棄 OFT 提供的 server.js,剛好避開了這個問題,得以持續使用 runtime 環境變數。

解法二:官方文件提到 dynamic lookup 環境變數不會被 inline,可參考以下寫法:

// This will NOT be inlined, because it uses a variable
const varName = 'NEXT_PUBLIC_ANALYTICS_ID'
setupAnalyticsService(process.env[varName])

// This will NOT be inlined, because it uses a variable
const env = process.env
setupAnalyticsService(env.NEXT_PUBLIC_ANALYTICS_ID)

然而這個方法我們尚未實測過,不確定是否在 server side 與 client side 都能順利運作,僅在此提出,供參考。

關於 CI cache

如果你的 CI 環境會 cache .next 目錄,那麼很可能會遇到「明明開了 OFT 設定在 CI 上卻 build 不出來」的奇怪現象,這是因為「未啓用 OFT 時留下的 .next/next-server.js.nft.json cache 被 next 拿來用」的緣故。啓用前後的 cache 是互不相容的,你可以嘗試清除 CI 上的 cache,或者使用不同的 cache key 隔離,確保兩種版本的 cache 不會互相污染彼此。

關於 build time 環境與 runtime 環境

如果你的 build time 環境與 runtime 一致(例如在 Dockerfile 中 install & build),那麼這段可以略過。如果不一致(例如在 CI install & build、copy 到 docker 執行),可能會遇到幾個問題。

next image resize 失效

如果你有使用 next/image,那麼你可能會發現 next image 的 resize 功能失效,一律提供原始大小的圖片。這是因為 next/image 要求安裝 sharp,而它會下載對應當前執行環境的 binary。這裡需要在實際執行的環境重新安裝正確版本的 sharp。

部分套件的 binary 檔案找不到

有些預編譯的 npm 套件會佈署不同 oscpu 的版本,讓 npm install 當下偵測環境來決定要安裝的版本,因此我們只會安裝適合 build time 環境的版本。如果使用 yarn,這個問題可以運用 yarnrc 的 supportedArchitectures 做到同時安裝不同環境的版本。然而 runtime 環境用不到的 build time 環境 binary 其實也很佔空間,其影響程度可能嚴重到完全抵消 OFT 帶來的好處,因此這裡也會需要在搬移的時候自行過濾掉。

結語

謝謝你讀到這裡,如果你也希望透過 OFT 來節省儲存空間用量,希望這篇文章有幫助到你。如果你遇到其他我們沒遇到的問題,也歡迎與我們分享你的經驗。

--

--