:Node 環(huán)境、PM2 與 Nginx 反向代理避坑指南)
先講個我自己的經(jīng)歷。幾年前我第一次部署一個基于 Nuxt 的 SSR 項目本以為和部署普通 Node 服務(wù)差不多結(jié)果本地跑得好好的一到服務(wù)器就各種 502、白屏、靜態(tài)資源 404。后來一步步排查才發(fā)現(xiàn)問題出在 Node 版本、構(gòu)建產(chǎn)物、進程守護和反向代理的整條鏈路上。從那以后我總結(jié)出一套 nuxt 項目部署的完整流程照著做基本不會再踩坑。這篇文章就把這套經(jīng)驗完整寫出來從構(gòu)建選型、環(huán)境準備、PM2 配置到 Nginx 反向代理再到常見的 502、內(nèi)存溢出、環(huán)境變量丟失等問題的排查思路全部一次講清。不管你是剛接觸 Nuxt 的新手還是已經(jīng)部署過幾次但總在細節(jié)上翻車的老手這篇都能幫你省下幾個小時。1. 部署前的整體思路拆解Nuxt 項目到底特殊在哪1.1 為什么不能像普通前端項目一樣直接扔進 Nginx很多朋友部署 Vue 項目習(xí)慣了npm run build出來一個dist目錄丟到 Nginx 的html目錄里就完事了。但 Nuxt 項目不行或者說要看你怎么用。Nuxt 默認是 SSR服務(wù)端渲染模式也就是說請求到達服務(wù)器后Nginx 要把請求轉(zhuǎn)發(fā)給一個 Node.js 進程由這個進程實時渲染出 HTML 再返回給瀏覽器。所以你的服務(wù)器上必須有一個常駐的 Node 進程在跑dist目錄只是構(gòu)建產(chǎn)物的一部分不是全部。你還需要node_modules里的運行時依賴、.nuxt目錄里的服務(wù)端代碼以及一套進程管理機制保證它不掛掉。如果你用的是nuxt generate做純靜態(tài)化部署那確實可以像普通前端項目一樣扔進 Nginx但前提是你的項目里沒有動態(tài)數(shù)據(jù)、沒有用戶登錄態(tài)、沒有服務(wù)端接口代理否則靜態(tài)化之后這些功能會直接失效。1.2 三種部署模式的選型SSR、SPA、靜態(tài)化Nuxt 3 里可以通過配置來決定最終部署形態(tài)我的經(jīng)驗是先想明白這一點再動手不然部署的時候會非常被動。部署模式配置方式適用場景服務(wù)器要求SSR默認開啟無需額外配置需要 SEO、動態(tài)數(shù)據(jù)、用戶交互復(fù)雜的站點必須 Node 環(huán)境 進程守護SPAssr: false后臺管理系統(tǒng)、內(nèi)部工具不追求首屏 SEO構(gòu)建后純靜態(tài)資源可扔 CDN靜態(tài)化nuxt generate/prerender內(nèi)容型站點、博客、文檔站內(nèi)容基本固定任意靜態(tài)服務(wù)器即可我見過不少人把 SSR 項目硬生生改成 SPA 部署只因為服務(wù)器上不會配 Node 進程結(jié)果 SEO 全廢了。反過來也有把純靜態(tài)博客用 SSR 模式部署的白白占著內(nèi)存。所以第一步就是確認你的場景到底需要哪種模式。1.3 本篇文章采用的方案清單我下面要講的這套流程默認你用的是 SSR 模式這也是 Nuxt 部署里最典型、最容易出問題的一種。涉及到的核心工具有Node.jsLTS 版本Nuxt 3 要求 Node 18 及以上npm 或 pnpm包管理器二選一PM2Node 進程守護工具負責(zé)讓服務(wù)常駐并開機自啟Nginx反向代理負責(zé)把公網(wǎng)流量轉(zhuǎn)發(fā)給本地 Node 端口Git代碼傳輸部署時把代碼推到服務(wù)器這套方案不需要 Docker因為對于中小型項目來說裸機 PM2 Nginx 是最省事、最容易排查問題的組合。Docker 有它的優(yōu)勢但如果你對容器不熟出了問題反而更難定位。2. 服務(wù)器環(huán)境準備與項目構(gòu)建配置2.1 Node.js 版本怎么選這一條我必須放在最前面說因為版本不對后面全白搭。Nuxt 3 官方要求 Node 18 或更高版本但我實測下來 Node 18 和 Node 20 在內(nèi)存占用和構(gòu)建速度上有明顯差異Node 20 的構(gòu)建更快內(nèi)存峰值稍微低一點。Node 22 目前也穩(wěn)定了不過社區(qū)里有些依賴還沒完全適配建議還是用 Node 20 LTS 最穩(wěn)妥。如果你服務(wù)器上已經(jīng)裝了舊版 Node比如 16 或者 14直接用node -v檢查一下不符合要求就先升級。升級方式我個人推薦用 nvm 管理curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash nvm install 20 nvm use 20 node -v裝好之后把默認版本指過去或者寫到.nvmrc文件里這樣以后項目成員切版本也方便。2.2 本地構(gòu)建還是服務(wù)器構(gòu)建這個問題很多人糾結(jié)我的建議是直接在服務(wù)器上構(gòu)建不要本地構(gòu)建完再傳上去。為什么因為 Nuxt 的構(gòu)建產(chǎn)物里有部分內(nèi)容是和操作系統(tǒng)、Node 版本相關(guān)的原生模塊比如某些加密庫、圖片處理庫你在本地 Mac 上構(gòu)建出來的.nuxt產(chǎn)物傳到 Linux 服務(wù)器上很可能跑不起來。就算跑起來了也可能因為 glibc 版本不一致出現(xiàn)奇奇怪怪的段錯誤。正確流程是本地把代碼寫好提交到 Git 倉庫服務(wù)器上git clone或者git pull拉最新代碼服務(wù)器上安裝依賴服務(wù)器上執(zhí)行npm run build服務(wù)器上用 PM2 啟動這樣保證構(gòu)建環(huán)境和運行環(huán)境完全一致省掉大量排查時間。2.3 構(gòu)建命令和產(chǎn)物結(jié)構(gòu)Nuxt 3 的構(gòu)建命令是npm run build執(zhí)行完會出現(xiàn)兩個關(guān)鍵目錄.nuxtNuxt 內(nèi)部生成的服務(wù)端和客戶端代碼運行時需要.outputNuxt 3 的服務(wù)器入口打包產(chǎn)物包含server/index.mjs和public/靜態(tài)資源目錄.output/server/index.mjs就是你要用 node 啟動的文件。PM2 配置里指向它就行。這里有個點要注意如果你在服務(wù)器上執(zhí)行npm run build時遇到內(nèi)存不足多半是 Node 的默認堆內(nèi)存不夠用后面我會專門講怎么調(diào)。2.4 依賴安裝npm 還是 pnpm我個人強烈推薦用 pnpm原因很簡單安裝速度快、磁盤占用小、node_modules 結(jié)構(gòu)清晰。但有兩點要注意用 pnpm 安裝依賴后啟動時如果提示找不到某個包先檢查是不是pnpm版本和項目的package.json里packageManager字段沖突如果你部署的服務(wù)器內(nèi)存特別小比如 1Gnpm install比pnpm install更容易因為并發(fā)過高導(dǎo)致 OOM反而 pnpm 更穩(wěn)安裝命令pnpm install --frozen-lockfile--frozen-lockfile會嚴格按照pnpm-lock.yaml安裝避免因為依賴版本漂移導(dǎo)致本地能跑、線上炸掉的經(jīng)典問題。3. PM2 進程守護與 Nginx 反向代理配置實戰(zhàn)3.1 PM2 配置文件不要用命令行參數(shù)硬啟動很多人部署 Node 服務(wù)喜歡pm2 start npm -- run start這種臨時命令對于生產(chǎn)環(huán)境我非常不建議。因為你的項目可能有多個環(huán)境測試、預(yù)發(fā)、生產(chǎn)每個環(huán)境的環(huán)境變量、端口、日志路徑都不一樣。把這些寫死在命令行里換環(huán)境的時候就得改命令容易出錯。正確做法是寫一個ecosystem.config.cjs文件放到項目根目錄module.exports { apps: [ { name: nuxt-app, exec_mode: fork, instances: 1, script: ./.output/server/index.mjs, env: { NODE_ENV: production, PORT: 3000, NITRO_PORT: 3000, NUXT_PUBLIC_API_BASE: https://api.example.com, }, max_memory_restart: 500M, log_date_format: YYYY-MM-DD HH:mm:ss, error_file: ./logs/err.log, out_file: ./logs/out.log, merge_logs: true, }, ], }幾個參數(shù)我解釋一下exec_mode: forkNuxt 3 的index.mjs本身不是 cluster 模式用 fork 最穩(wěn)instances: 1除非你的服務(wù)器是多核且壓力很大否則單實例足夠了實例多了反而互相搶內(nèi)存max_memory_restart: 500M內(nèi)存超過 500M 自動重啟這是防止內(nèi)存泄漏把服務(wù)器拖死的保命符NUXT_PUBLIC_API_BASE這是 Nuxt 3 運行時公共環(huán)境變量的寫法后面會細說啟動命令pm2 start ecosystem.config.cjs pm2 save pm2 startuppm2 save是保存當前進程列表pm2 startup會生成一個開機自啟腳本這樣服務(wù)器重啟后 PM2 會自動拉起你的應(yīng)用不用每次手動去pm2 start。3.2 環(huán)境變量在 Nuxt 3 里的正確注入姿勢Nuxt 3 的環(huán)境變量和 Vue 2 時期完全不同這里非常容易踩坑。Nuxt 3 里的環(huán)境變量分兩類運行時環(huán)境變量代碼里通過useRuntimeConfig()讀取的變量名稱以NUXT_為前綴啟動時從進程環(huán)境變量里注入構(gòu)建時環(huán)境變量打包時就被替換進代碼里的變量比如NUXT_PUBLIC_開頭的配置實戰(zhàn)經(jīng)驗是凡是后端服務(wù)地址、內(nèi)部密鑰這些不能暴露給瀏覽器的用NUXT_前綴只存在于 PM2 的env配置里凡是要暴露給客戶端瀏覽器使用的配置用NUXT_PUBLIC_前綴因為它會打進客戶端 bundle舉個小例子你的nuxt.config.ts里有runtimeConfig: { apiSecret: process.env.NUXT_API_SECRET, public: { apiBase: process.env.NUXT_PUBLIC_API_BASE || /api, }, }部署的時候PM2 的env里寫NUXT_API_SECRETxxxx NUXT_PUBLIC_API_BASEhttps://api.example.com然后在頁面里const config useRuntimeConfig() console.log(config.apiSecret) // 只在服務(wù)端有值 console.log(config.public.apiBase) // 客戶端也能拿到這里有個坑如果你在.env文件里寫環(huán)境變量然后本地npm run build這些變量會被編譯進代碼。但你部署時是在服務(wù)器上重新構(gòu)建的所以服務(wù)器上必須有一份對應(yīng)的.env或者通過 PM2 注入。我個人習(xí)慣是生產(chǎn)環(huán)境不用.env文件全部通過 PM2 的env注入這樣git status永遠是干凈的不怕密鑰被誤提交。3.3 Nginx 反向代理配置詳解PM2 把服務(wù)跑起來后默認監(jiān)聽3000端口。你不能讓用戶直接訪問 3000 端口原因有兩個裸端口看起來不專業(yè)而且用戶記不住Nginx 做反向代理的同時可以順便把 HTTPS、靜態(tài)資源緩存、Gzip 壓縮這些事一起干了這是我最常用的 Nginx 配置server { listen 80; server_name your-domain.com; # 將 http 請求重定向到 https return 301 https://$host$request_uri; } server { listen 443 ssl http2; server_name your-domain.com; ssl_certificate /etc/nginx/ssl/your-domain.com.pem; ssl_certificate_key /etc/nginx/ssl/your-domain.com.key; # 請求體大小限制如果項目里有文件上傳這個值要調(diào)大 client_max_body_size 20m; location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } # 靜態(tài)資源直接走 Nginx不經(jīng)過 Node location /_nuxt/ { alias /var/www/nuxt-app/.output/public/_nuxt/; expires 30d; add_header Cache-Control public, immutable; } }這里有兩個細節(jié)特別說明。第一個是proxy_set_header Upgrade和Connection upgrade這一組配置是給 WebSocket 用的。如果項目里有用到 WebSocket比如聊天室、實時通知少了這兩行Socket 連接會握手失敗。第二個是靜態(tài)資源緩存。Nuxt 構(gòu)建出來的_nuxt目錄下的文件都帶 hash 后綴文件名變了內(nèi)容才變所以可以放心設(shè)置expires 30d和immutable。這樣一來用戶第二次訪問頁面的時候絕大多數(shù)靜態(tài)資源直接命中瀏覽器緩存根本不請求服務(wù)器大幅降低 Node 進程壓力。3.4 部署腳本一條命令搞定整個流程每次手動登錄服務(wù)器敲那么多命令太累了我建議寫一個簡單的部署腳本放在項目deploy.sh里#!/bin/bash set -e echo 拉取最新代碼... git pull origin main echo 安裝依賴... pnpm install --frozen-lockfile echo 構(gòu)建項目... npm run build echo 重啟 PM2 進程... pm2 reload ecosystem.config.cjs --update-env echo 部署完成pm2 reload和pm2 restart的區(qū)別在于 reload 是零停機重載它先啟動新進程接管流量再殺掉舊進程。對于 SSR 應(yīng)用來說reload 能避免用戶請求剛好撞上重啟窗口導(dǎo)致的白屏。--update-env這個參數(shù)很重要它會強制 PM2 更新環(huán)境變量否則你改了ecosystem.config.cjs里的env配置不帶上這個參數(shù)的話舊的進程里還是老環(huán)境變量。4. 部署過程中的典型問題與排查實錄4.1 502 Bad Gateway最常見也最迷惑上線后一訪問就 502這是 SSR 部署最經(jīng)典的問題。502 的根源是 Nginx 無法連接到你配置的上游服務(wù)也就是 PM2 里管理的 Node 進程。排查路徑我固定走這三步先看 PM2 進程活著沒有pm2 list如果 status 顯示errored或者stopped說明進程掛了再看進程日志pm2 logs nuxt-app --lines 100一般日志里會明確報錯原因如果 PM2 顯示進程活著但還是 502直接在服務(wù)器上curl http://127.0.0.1:3000如果 curl 不通說明應(yīng)用監(jiān)聽失敗如果能通就是 Nginx 配置的上游地址不對我碰到過最隱蔽的一種情況是PM2 進程是活的端口也能通但用戶訪問還是 502。查了半天發(fā)現(xiàn) Nginx 的proxy_pass寫的是http://localhost:3000而服務(wù)器上的localhost解析到了::1IPv6但 Node 只監(jiān)聽了 IPv4 的127.0.0.1。改成proxy_pass http://127.0.0.1:3000;就立刻好了。這個坑非常經(jīng)典建議大家從一開始就寫127.0.0.1而不是localhost。4.2 構(gòu)建時內(nèi)存溢出Javascript heap out of memory無服務(wù)器或者 1G 小內(nèi)存服務(wù)器上構(gòu)建 Nuxt 項目很容易看到這個錯誤FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory這就是 Node 默認堆內(nèi)存上限不夠用了。默認上限大概是 2G但小服務(wù)器物理內(nèi)存可能只有 1G系統(tǒng)會先崩。解決辦法是構(gòu)建時顯式調(diào)大堆內(nèi)存我一般這樣寫NODE_OPTIONS--max-old-space-size1024 npm run build如果你的服務(wù)器有 2G 內(nèi)存可以給到 1536 或 2048。這個值不是越大越好因為--max-old-space-size只是堆上限給你進程的物理內(nèi)存配額就那么大設(shè)置超過實際可用物理內(nèi)存反而會觸發(fā)系統(tǒng) OOM Killer進程被強制殺掉。另外一個關(guān)聯(lián)問題是如果你的服務(wù)器內(nèi)存確實太小比如 512M老實說不太建議跑 Nuxt SSR構(gòu)建和運行都會很難受。要么升級配置要么把項目改成靜態(tài)化部署。4.3 環(huán)境變量在構(gòu)建后失真這個是 Nuxt 3 特有的坑我反復(fù)遇到?,F(xiàn)象是這樣的本地npm run dev的時候一切正常登錄、接口都能通。到了服務(wù)器上頁面能打開但所有接口請求都指向了localhost或者 API 地址不對。原因就是環(huán)境變量沒有正確注入到構(gòu)建過程。Nuxt 3 的runtimeConfig有一個特性從環(huán)境變量映射到 runtimeConfig 時構(gòu)建時和運行時的優(yōu)先級不一樣。比如你在nuxt.config.ts里寫了runtimeConfig: { public: { apiBase: process.env.NUXT_PUBLIC_API_BASE || /api } }這里process.env.NUXT_PUBLIC_API_BASE是在構(gòu)建時讀取的。如果你構(gòu)建時這個環(huán)境變量不存在構(gòu)建產(chǎn)物里寫死的就是默認值/api。運行時你再往 PM2 里注入NUXT_PUBLIC_API_BASE已經(jīng)晚了因為構(gòu)建產(chǎn)物里的代碼已經(jīng)把/api當成默認值編譯進去了運行時注入的變量不一定能覆蓋已經(jīng)內(nèi)聯(lián)的默認值。正確的做法是保證服務(wù)器構(gòu)建時的環(huán)境變量和運行時一致或者在nuxt.config.ts的runtimeConfig里不要寫process.env引用而是只寫占位空字符串讓運行時通過useRuntimeConfig()讀取時動態(tài)填入我個人傾向于第一種方案因為更直觀在構(gòu)建命令前把環(huán)境變量帶上NUXT_PUBLIC_API_BASEhttps://api.example.com npm run build這樣可以確保構(gòu)建產(chǎn)物里內(nèi)聯(lián)的就是線上正確的地址。4.4 靜態(tài)資源 404 與緩存策略問題部署后 HTML 能打開但頁面里的 JS、CSS 全部 404最常見的原因有這幾個Nginx 的/_nuxt/路徑 alias 寫錯了.output/public/_nuxt/這個目錄路徑?jīng)]對上。我一般先ls /var/www/nuxt-app/.output/public/_nuxt/看看目錄到底長什么樣再去改 Nginx 配置。Nuxt 的 baseURL 配置變了如果你在nuxt.config.ts里設(shè)置了app.baseURL: /some-prefix/那所有靜態(tài)資源的路徑都會帶上這個前綴Nginx 的 alias 也要對應(yīng)改。緩存策略過激導(dǎo)致 HTML 被緩存有的朋友給整個站點設(shè)置了expires 30d結(jié)果頁面 HTML 也被緩存了用戶那邊不管你怎么發(fā)布新版本永遠看到的還是舊頁面。我前面的配置里只對/_nuxt/目錄做了長期緩存根路徑location /沒有設(shè)置expires這樣 HTML 每次都是新鮮的。4.5 端口被占用和權(quán)限問題如果 PM2 啟動失敗日志里報EADDRINUSE: address already in use :::3000說明 3000 端口被別的進程占了。排查方式lsof -i :3000 kill -9 PID如果端口是 80 或者 443那大概率是之前的 Nginx 還沒起來或者有別的 Web 服務(wù)占著同樣用lsof查。關(guān)于權(quán)限我多說一句不要為了省事直接用 root 跑 PM2 或 Node 進程。生產(chǎn)環(huán)境更合理的做法是用一個單獨的 deploy 用戶把項目的屬主改為這個用戶然后在 Nginx 的proxy_pass里照常轉(zhuǎn)發(fā)。這樣萬一應(yīng)用被攻破攻擊者拿到的是低權(quán)限用戶的 shell不是 root。這個習(xí)慣越早建立越好。5. 部署后的驗證與運維小技巧5.1 上線后的三項驗證部署完成別急著關(guān)終端按下面三步快速驗證第一驗證首頁能正常返回 HTML。用 curl 看首頁響應(yīng)curl -I https://your-domain.com檢查響應(yīng)頭里有沒有server: nginx以及 HTTP 狀態(tài)碼是否 200。如果你看到響應(yīng)頭里帶著x-powered-by: Nuxt就不用太意外這是正常的。第二驗證靜態(tài)資源命中緩存。再 curl 一次靜態(tài)文件看響應(yīng)頭里有沒有Cache-Control: public, immutable。第三驗證 SSR 是否真的生效。在瀏覽器里打開頁面右鍵查看源代碼確認頁面 HTML 里有沒有實際渲染出的內(nèi)容。如果源代碼里只有一個app空殼說明你的站雖然部署成功了但實際跑的是 SPA 模式SEO 會受影響。5.2 日志管理PM2 日志切割PM2 默認把所有日志寫到同一個文件里時間長了會非常大影響性能。我習(xí)慣加一個pm2-logrotate插件pm2 install pm2-logrotate pm2 set pm2-logrotate:max_size 10M pm2 set pm2-logrotate:retain 7 pm2 set pm2-logrotate:compress true這樣每個日志文件超過 10M 就自動切割保留最近 7 份老日志壓縮存起來。運維久了你會發(fā)現(xiàn)日志輪轉(zhuǎn)配置得好排查問題會非常舒服。5.3 更新發(fā)布時的優(yōu)雅切換前面部署腳本里用的pm2 reload是零停機的但我補充一個細節(jié)如果你的項目有數(shù)據(jù)庫遷移這類操作不能把遷移代碼和應(yīng)用發(fā)布混在一次部署里做。否則新代碼起來的時候老的數(shù)據(jù)庫結(jié)構(gòu)還沒遷移請求一到就報錯。我的經(jīng)驗是涉及數(shù)據(jù)庫變更的發(fā)布先手動執(zhí)行遷移再用pm2 reload重啟應(yīng)用。這樣應(yīng)用起來的時候數(shù)據(jù)庫已經(jīng)是最新結(jié)構(gòu)不會出現(xiàn)中間狀態(tài)。5.4 多環(huán)境部署的推薦做法一個成熟的項目一般至少有三個環(huán)境開發(fā)、測試、生產(chǎn)。每個環(huán)境的 API 地址、密鑰都不同。我最推薦的做法是給每個環(huán)境寫一份獨立的 PM2 配置比如ecosystem.config.test.cjs和ecosystem.config.prod.cjs然后在部署腳本里按環(huán)境參數(shù)加載。這一步做在前面你會省下非常多來回改環(huán)境變量的時間。比如說你要發(fā)測試環(huán)境pm2 start ecosystem.config.test.cjs --env test pm2 save這樣測試環(huán)境自動會讀測試的配置生產(chǎn)環(huán)境讀生產(chǎn)的配置互不干擾。運維的人切換環(huán)境的時候也不會因為忘記改環(huán)境變量導(dǎo)致線上事故。6. 我踩過幾次坑之后的個人體會Nuxt 項目部署這事說復(fù)雜不算特別復(fù)雜但每一步都有隱藏的細節(jié)。如果非要說哪些地方最容易讓人翻車我的排序是環(huán)境變量注入、Node 版本不匹配、Nginx 的 location 和 alias 配置、PM2 的 env 更新時機。這四個坑我都在生產(chǎn)環(huán)境里真實踩過每個都能讓線上服務(wù)在“看似正?!钡臓顟B(tài)里悄悄出問題。所以我最后再重復(fù)三個實用習(xí)慣服務(wù)器上構(gòu)建不要在本地構(gòu)建完再傳產(chǎn)物環(huán)境變量全部通過 PM2 注入并保證構(gòu)建時環(huán)境和運行時環(huán)境一致用ecosystem.config.cjs管理啟動配置不要用命令行裸啟動把這三條刻在腦子里你的 Nuxt 項目部署大概率一次就能跑通。真要是遇到玄學(xué)問題先看 PM2 日志再看 Nginx 錯誤日志/var/log/nginx/error.log大多數(shù)問題都在日志里寫得明明白白。