昨天有小夥伴問express 專案該如何部署。於是整理了這篇文章,主要講述如何部署一個基於nodejs 開發的服務端程序,供有需要的朋友們參考。
文章包含幾個部分:
(process)是電腦作業系統分配和調度任務的基本單位。開啟任務管理器,可以看到其實在電腦的後台運行著非常多的程序,每個程序都是一個進程。
現代瀏覽器基本上都是多進程架構的,以Chrome 瀏覽器為例,打開“更多工具” - “任務管理器”,就能看到當前瀏覽器的進程信息,其中一個頁面就是一個進程,除此之外,還有網路進程,GPU 進程等。
多進程的架構,得以確保應用更穩定的運作。還是以瀏覽器為例,如果所有的程式都運行在一個進程中,如果網路故障,或是頁面渲染出錯問題,都會導致整個瀏覽器的崩潰。透過多進程的架構,即使網路進程崩潰了,它不會影響到已有頁面的展示,最壞也就是暫時無法連接網路。
線程(thread)是作業系統能夠進行運算調度的最小單位。它被包含在進程之中,是進程中的實際運作單位。舉一個例子,一個程序好比是一家公司,下設多個部門,就是若干個進程;每個部門的通力合作使得公司正常運行,而線程就是員工,是具體幹活的人。
我們都知道JavaScript 是一門單執行緒語言。這麼設計是因為早期JS 主要用來寫腳本程序,負責實現頁面的互動效果。如果設計成多執行緒語言,一是沒有必要,二是多個執行緒共同操作一個dom 節點,那麼瀏覽器該聽誰的?當然隨著技術的發展,現在的JS 也支援了多線程,不過僅用來處理一些和dom 操作無關的邏輯。
單一進程帶來一個嚴重的問題,一個運行中的node.js 程序,一旦主執行緒掛掉,那麼這個進程也就掛掉了,整個應用程式也就隨之掛掉。再者,現代電腦大都是多核心CPU,四核心八線程,八核心十六線程,都是很常見的設備了。而node.js 作為一個單一進程的程序,白白浪費掉了多核心CPU 的效能。
針對這種情況,我們需要一個合適的多進程模型,將一個單一進程的node.js 程式變成多進程的架構。
Node.js 實作多進程架構有兩種常用方案,都是使用原生模組,分別是child_process
模組和cluster
模組。
child_process
是node.js 的內建模組,看名字也能猜到它負責的是和子程序有關的事。
我們不再細說該模組的具體用法,實際上它大概只有六、七個方法,還是非常容易理解的。我們使用其中的一個fork
方法來示範如何實現多進程以及多進程之間的通訊。
先看下準備好的演示案例的目錄結構:
我們使用http
模組建立了一個http server,當有/sum
請求進來時,會透過child_process
模組建立一個子進程,並通知子進程執行計算的邏輯,同時父進程也要監聽子進程發來的訊息:
/ / child_process.js const http = require('http') const { fork } = require('child_process') const server = http.createServer((req, res) => { if (req.url == '/sum') { // fork 方法接收一個模組路徑,然後開啟一個子進程,將模組在子進程中運行// childProcess 表示創建的子進程let childProcess = fork('./sum.js') // 傳送訊息給子進程childProcess.send('子進程開始計算') // 父行程中監聽子程序的訊息childProcess.on('message', (data) => { res.end(data + '') }) // 監聽子程序的關閉事件childProcess.on('close', () => { // 子程序正常退出和報錯掛掉,都會走到這裡console.log('子程序關閉') childProcess.kill() }) // 監聽子程序的錯誤事件childProcess.on('error', () => { console.log('子程序報錯') childProcess.kill() }) } if (req.url == '/hello') { res.end('hello') } // 模擬父行程錯誤if (req.url == '/error') { throw new Error('父程序出錯') res.end('hello') } }) server.listen(3000, () => { console.log('Server is running on 3000') })
sum.js
用來模擬子行程要執行的任務。子進程監聽父進程發送的訊息,處理計算任務,然後將結果傳送給父進程:
// sum.js function getSum() { let sum = 0 for (let i = 0; i < 10000 * 1000 * 100; i++) { sum += 1 } return sum } // process 是node.js 中一個全域對象,表示目前程序。在這裡也就是子進程。 // 監聽主程序發送的訊息process.on('message', (data) => { console.log('主程序的訊息:', data) const result = getSum() // 將計算結果傳送給父行程process.send(result) })
開啟終端,執行指令node 1.child_process
:
造訪瀏覽器:
接著來模擬子程序報錯的情況:
// sum.js function getSum() { // .... } // 子程序執行5s後,模擬行程掛掉setTimeout(() => { throw new Error('報錯誤') }, 1000 * 5) process.on('message', (data) => { // ... })
再次存取瀏覽器,5秒後觀察控制台:
子程序已經掛掉了,然後再訪問另一個url : /hello
,
可見,父進程仍能正確處理請求,說明子進程報錯,並不會影響父進程的運作。
接著我們來模擬父進程報錯的場景,註解掉sum.js
模組的類比報錯,然後重啟服務,瀏覽器存取/error
:
發現父進程掛掉後,整個node.js 程式自動退出了,服務完全崩潰,沒有挽回的餘地。
可見,透過child_process
的fork
方法實作node.js 的多進程架構並不複雜。進程間的通訊主要透過send
和on
方法,從這個命名上也能知道,其底層應該是一個發布訂閱模式。
但是它存在一個嚴重的問題,雖然子進程不影響父進程,但是一旦父進程出錯掛掉,所有的子進程會被」一鍋端掉“ 。所以,這個方案適用於將一些複雜耗時的運算,fork 出一個單獨的子程序去做。更準確的來說,這種用法是用來取代多執行緒的實現,而非多進程。
使用child_process
模組實現多重進程,似乎不堪大用。所以一般比較推薦使用cluster
模組來實作node.js 的多進程模型。
cluster
,集群的意思,這個名詞相信大家都不陌生。打個比方,以前公司只有一個前台,有時候太忙就沒辦法及時接待訪客。現在公司分配了4個前台,即使有三個都在忙,也還有一個能接待新來的訪客。集群大致上是這個意思,對於同一件事,合理的分配給不同的人去乾,以此來保證這件事能做到最好。
cluster
模組的使用也比較簡單。如果目前進程是主進程,則根據CPU 的核數建立合適數量的子進程,同時監聽子進程的exit
事件,有子進程退出,就重新fork 新的子進程。如果不是子進程,則進行實際業務的處理。
const http = require('http') const cluster = require('cluster') const cpus = require('os').cpus() if (cluster.isMaster) { // 程式啟動時先走到這裡,根據CPU 的核數,建立出多個子程序for (let i = 0; i < cpus.length; i++) { // 建立出一個子程序cluster.fork() } // 當任何一個子程序掛掉後,cluster 模組會發出'exit'事件。此時透過再次呼叫fork 來重啟進程。 cluster.on('exit', () => { cluster.fork() }) } else { // fork 方法執行建立子程序,同時會再次執行模組,此時邏輯就會走到這裡const server = http.createServer((req, res) => { console.log(process.pid) res.end('ok') }) server.listen(3000, () => { console.log('Server is running on 3000', 'pid: ' + process.pid) }) }
啟動服務:
可以看到, cluster
模組創造出了非常多的子進程,好像是每個子進程都運行著同一個web服務。
需要注意的是,此時並非是這些子進程共同監聽同一個連接埠。連接埠的監聽仍然是由createServer 方法所建立的server 去負責,將請求轉送給各個子程序。
我們寫一個請求腳本,來請求上面的服務,看下效果。
// request.js const http = require('http') for (let i = 0; i < 1000; i++) { http.get('http://localhost:3000') }
http 模組不僅可以建立http server,還能用來傳送http 請求。 Axios支援瀏覽器和伺服器環境,在伺服器端就是使用http 模組發送http 請求。
使用node
指令執行該文件,再看下原來的控制台:
列印出了具體處理請求的不同子進程的進程ID。
這就是透過cluster
模組實現的nodd.js 的多進程架構。
當然,我們在部署node.js 專案時不會這麼乾巴巴的寫和使用cluster
模組。有一個非常好用的工具,叫做PM2 ,它是一個基於cluster 模組實現的進程管理工具。在後面的章節會介紹它的基本用法。
到此為止,我們花了一部分篇幅介紹node.js 中多進程的知識,其實只是想要交代下為什麼需要使用pm2 來管理node.js 應用。本文由於篇幅有限,再加上描述不夠精確/詳盡,僅做簡單介紹。如果是第一次接觸這塊內容的朋友,可能沒有太明白,也不打緊,後面會再出一篇更細節的文章。
本文已經準備了一個使用express 開發的範例程序,點此存取。
它主要實現了一個介面服務,當訪問/api/users
時,使用mockjs
模擬了10條用戶數據,並返回一個用戶列表。同時會開啟一個定時器,來模擬錯誤的情況:
const express = require('express') const Mock = require('mockjs') const app = express() app.get("/api/users", (req, res) => { const userList = Mock.mock({ 'userList|10': [{ 'id|+1': 1, '名': '@cname', 'email': '@email' }] }) setTimeout(()=> { throw new Error('伺服器故障') }, 5000) res.status(200) res.json(userList) }) app.listen(3000, () => { console.log("服務啟動: 3000") })
本地測試一下,在終端機中執行指令:
node server.js
開啟瀏覽器,存取使用者清單介面:
五秒鐘後,伺服器會掛掉:
後面我們使用pm2 來管理應用程式後,就可以解決這個問題。
通常完成一個vue/react 專案後,我們都會先執行打包,然後再進行發布。其實前端專案要進行打包,主要是因為程式最終的運行環境是瀏覽器,而瀏覽器有各種相容性問題和效能問題,例如:
.vue
, .jsx
, .ts
文件,需要編譯而使用express.js 或koa.js 開發的項目,並不存在這些問題。並且, Node.js 採用CommonJS 模組化規範,有快取的機制;同時,只有當模組在使用到時,才會被導入。如果進行打包,打包成一個文件,其實就浪費了這個優勢。所以針對node.js 項目,不需要打包。
本文以CentOS 系統為例進行示範。
為了方便切換node 的版本,我們使用nvm 來管理node。
Nvm(Node Version Manager) ,就是Node.js 的版本管理工具。透過它,可以讓node 在多個版本之間進行任意切換,避免了需要切換版本時重複的下載和安裝的操作。
Nvm的官方倉庫是github.com/nvm-sh/nvm。因為它的安裝腳本存放在githubusercontent
站點上,所以經常訪問不了。所以我在gitee 上新建了它的鏡像倉庫,這樣就能從gitee 上存取到它的安裝腳本了。
透過curl
指令下載安裝腳本,並使用bash
執行腳本,會自動完成nvm 的安裝工作:
# curl -o- https://gitee.com/hsyq/nvm/raw/master/install.sh | bash
當安裝完成之後,我們再開啟一個新的窗口,來使用nvm :
[root@ecs-221238 ~]# nvm -v0.39.1
可以正常列印版本號,說明nvm 已經安裝成功了。
現在就可以使用nvm 來安裝和管理node 了。
檢視可用的node 版本:
# nvm ls-remote
安裝node:
# nvm install 18.0.0
查看已安裝的node 版本:
[root@ecs-221238 ~]# nvm list -> v18.0.0 default -> 18.0.0 (-> v18.0.0) iojs -> N/A (default) unstable -> N/A (default) node -> stable (-> v18.0.0) (default) stable -> 18.0 (-> v18.0.0) (default)
選擇一個版本來使用:
# nvm use 18.0.0
需要注意的一點,在Windows 上使用nvm 時,需要使用管理員權限執行nvm 指令。在CentOS 上,我預設使用root 使用者登入的,因而沒有出現問題。大家在使用時遇到了未知錯誤,可以搜尋解決方案,或是嘗試下是否是權限導致的問題。
安裝node 的時候,會自動安裝npm。查看node 和npm 的版本號:
[root@ecs-221238 ~]# node -v v18.0.0 [root@ecs-221238 ~]# npm -v 8.6.0
預設的npm 映像來源是官方位址:
[root@ecs-221238 ~]# npm config get registry https://registry.npmjs.org/
切換為國內淘寶的鏡像來源:
[root@ecs-221238 ~]# npm config set registry https://registry.npmmirror.com
到此為止,伺服器就已經安裝好node環境和配置好npm 了。
方法有很多,或從Github / GitLab / Gitee 倉庫下載到伺服器中,或是本地透過ftp 工具上傳。步驟很簡單,不再示範。
示範項目放到了/www
目錄下:
一般雲端伺服器僅開放了22 連接埠用於遠端登入。而常用的80,443等端口並未開放。另外,我們準備好的express 專案運行在3000埠上。所以需要先到雲端伺服器的控制台中,找到安全群組,新增幾個規則,開放80和3000連接埠。
在開發階段,我們可以使用nodemon
來做即時監聽和自動重啟,提高開發效率。在生產環境,就需要祭出大殺器—PM2了。
先全域安裝pm2:
# npm i -g pm2
執行pm2 -v
指令查看是否安裝成功:
[root@ecs-221238 ~]# pm2 -v5.2.0
切換到專案目錄,先把依賴裝上:
cd /www/express-demo npm install
然後使用pm2
指令來啟動應用程式。
pm2 start app.js -i max // 或pm2 start server.js -i 2
PM2 管理應用程式有fork 和cluster 兩種模式。啟動應用程式時,透過使用-i 參數來指定實例的個數,會自動開啟cluster 模式。此時就具備了負載平衡的能力。
-i :instance,實例的個數。可以寫具體的數字,也可以配置成max,
PM2
會自動檢查可用的CPU的數量,然後盡可能啟動進程。
此時應用就啟動好了。 PM2 會以守護程序的形式管理應用,這個表格展示了應用運行的一些信息,例如運行狀態,CPU使用率,內存使用率等。
在本機的瀏覽器中存取介面:
Cluster 模式是一個多進程多實例的模型,請求進來後會指派給其中一個行程處理。正如前面我們看過的cluster
模組的用法一樣,由於pm2 的守護,即使某個程序掛掉了,也會立刻重啟該進程。
回到伺服器終端,執行pm2 logs
指令,查看下pm2 的日誌:
可見,id 為1的應用實例掛掉了,pm2 會立刻重啟這個實例。請注意,這裡的id 是應用實例的id,並非進程id。
到這裡,一個express 專案的簡單部署就完成了。透過使用pm2 工具,基本能確保我們的專案可以穩定可靠的運作。
這裡整理了一些pm2 工具常用的指令,可供查詢參考。
# Fork模式pm2 start app.js --name app # 設定應用程式的名字為app # Cluster模式# 使用負載平衡啟動4個程序pm2 start app.js -i 4 # 將使用負載平衡啟動4個進程,取決於可用的CPU pm2 start app.js -i 0 # 等同於上面指令的作用pm2 start app.js -i max # 給app 擴展額外的3個進程pm2 scale app +3 # 將app 擴充或縮小到2個進程pm2 scale app 2 # 查看應用程式狀態# 展示所有行程的狀態pm2 list # 用原始JSON 格式列印所有進程清單pm2 jlist # 用美化的JSON 列印所有進程清單pm2 prettylist # 展示特定進程的所有資訊pm2 describe 0 # 使用儀表板監控所有進程pm2 monit # 日誌管理# 即時展示所有應用程式的日誌pm2 logs # 即時展示app 應用的日誌pm2 logs app # 使用json格式即時展示日誌,不輸出舊日誌,只輸出新產生的日誌pm2 logs --json # 應用程式管理# 停止所有行程pm2 stop all # 重啟所有行程pm2 restart all # 停止指定id的進程pm2 stop 0 # 重啟指定id的程序pm2 restart 0 # 刪除id為0進程pm2 delete 0 # 刪除所有的流程pm2 delete all
每個指令都可以親自嘗試一下,看看效果。
這裡特別展示下monit
指令,它可以在終端機中啟動一個面板,即時展示應用程式的運作狀態,透過上下箭頭可以切換pm2 管理的所有應用程式:
PM2 的功能十分強大,遠不止上面的這幾個指令。在真實的專案部署中,可能還需要設定日誌文件,watch 模式,環境變數等等。如果每次都手敲指令是十分繁瑣的,所以pm2 提供了設定檔來管理和部署應用程式。
可以透過以下命令來產生一份設定檔:
[root@ecs-221238 express-demo]# pm2 init simple File /www/express-demo/ecosystem.config.js generated
會產生一個ecosystem.config.js
檔案:
module.exports = { apps : [{ name : "app1", script : "./app.js" }] }
也可以自己建立一個設定文件,例如app.config.js
:
const path = require('path') module.exports = { // 一份設定檔可以同時管理多個node.js 應用程式// apps 是一個數組,每一個都是一個應用程式的設定apps: [{ // 應用程式名稱name: "express-demo", // 應用入口檔script: "./server.js", // 啟動應用的模式, 有兩種:cluster和fork,預設是fork exec_mode: 'cluster', // 建立應用實例的數量instances: 'max', // 開啟監聽,當檔案變更後自動重新啟動套用watch: true, // 忽略掉一些目錄檔案的變化。 // 由於把日誌目錄放到了專案路徑下,一定要將其忽略,否則應用啟動產生日誌,pm2 監聽到變化就會重啟,重啟又產生日誌,就會進入死循環ignore_watch: [ "node_modules", "logs" ], // 錯誤日誌存放路徑err_file: path.resolve(__dirname, 'logs/error.log'), // 印出日誌存放路徑out_file: path.resolve(__dirname, 'logs/out.log'), // 設定日誌檔案中每個日誌前面的日期格式log_date_format: "YYYY-MM-DD HH:mm:ss", }] }
讓pm2 使用設定檔來管理node 應用:
pm2 start app.config.js
現在pm2 管理的應用,會將日誌放到專案目錄下(預設放到pm2 的安裝目錄下),並且能監聽檔案的變化,自動重啟服務。