近些年來,傳統的 IaaS、PaaS 已經無法滿足人們對資源調度的需求了。各大雲廠商相繼開始推出自家的 Serverless 服務。Serverless 顧名思義,它是“無服務器”服務器。不過並不是本質上的不需要服務器,而是面向開發者(客戶)無需關心底層服務器資源的調度。只需要利用本身業務代碼即可完成服務的運行。
Serverless 是近些年的一個發展趨勢,它的發展離不開 FaaS 與 BaaS。這里不是着重討論 Serverless 架構的,而是嘗試利用 Node.js 來實現一個最簡易的 FaaS 平台。順便還能對 JavaScript 語言本身做進一步更深的研究。
Serverless 平台是基於函數作為運行單位的,在不同的函數被調用時,為了確保各個函數的安全性,同時避免它們之間的互相干擾,平台需要具有良好的隔離性。這種隔離技術通常被稱之為“沙箱”(Sandbox)。在 FaaS 服務器中,最普遍的隔離應該式基於 Docker 技術實現的容器級別隔離。它不同於傳統虛擬機的完整虛擬化操作系統,而且也實現了安全性以及對系統資源的隔離。
但在這我們嘗試實現一個最簡易的 FaaS 服務,不需要利用上 Docker。基於進程的隔離會更加的輕便、靈活,雖然與容器的隔離性有一定差距。
環境搭建
這里利用 TypeScript 來對 JavaScript 做更嚴格的類型檢查,並使用 ESlint + Prettier 等工具規范代碼。
初始化環境:
yarn --init
添加一些開發必要工具:
yarn add typescript ts-node nodemon -D
以及對代碼的規范:
yarn add eslint prettier eslint-plugin-prettier eslint-config-prettier @typescript-eslint/parser @typescript-eslint/eslint-plugin -D
當然不能忘了 Node 本身的 TypeScript lib。
yarn add @types/node -D
基礎能力
在 Nodejs多進程 | 🍭Defectink 一篇中,我們大概的探討了進程的使用。這里也是類似。在進程創建時,操作系統將給該進程分配對應的虛擬地址,再將虛擬地址映射到真正的物理地址上。因此,進程無法感知真實的物理地址,只能訪問自身的虛擬地址。這樣一來,就可以防止兩個進程互相修改數據。
所以,我們基於進程的隔離,就是讓不同的函數運行再不同的進程中,從而保障各個函數的安全性和隔離性。具體的流程是:我們的主進程(master)來監聽函數的調用請求,當請求被觸發時,再啟動子進程(child)執行函數,並將執行后的結果通過進程間的通信發送給主進程,最終返回到客戶端中。
基於進程隔離
chlid_process是 Node.js 中創建子進程的一個函數,它有多個方法,包括 exec、execFile 和 fork。實際上底層都是通過 spawn 來實現的。這里我們使用 fork 來創建子進程,創建完成后,fork 會在子進程與主進程之間建立一個通信管道,來實現進程間的通信(IPC,Inter-Process Communication)。
其函數簽名為:child_process.fork(modulePath[, args][, options])。
這里利用child.process.fork創建一個子進程,並利用child.on來監聽 IPC 消息。
// master.ts
import child_process from 'child_process';
const child = child_process.fork('./dist/child.js');
// Use child.on listen a message
child.on('message', (message: string) => {
console.log('MASTER get message:', message);
});
在 Node.js 中,process 對象是一個內置模塊。在每個進程啟動后,它都可以獲取當前進程信息以及對當前進程進行一些操作。例如,發送一條消息給主進程。
子進程則利用 process 模塊來和主進程進行通信
// child.ts
import process from 'process';
process.send?.('this is a message from child process');
執行這段方法后,master 就會創建一個子進程,並接收到其發來的消息。
$ node master.js
MASTER get message: this is a message from child process

到此,我們就實現了主進程與子進程之間的互相通信。但是需要執行的函數通常來自於外部,所以我們需要從外部手動加載代碼,再將代碼放到子進程中執行,之后將執行完的結果再發送回主進程,最終返回給調用者。
我們可以再創建一個func.js來保存用戶的代碼片段,同時在主進程中讀取這段代碼,發送給子進程。而子進程中需要動態執行代碼的能力。什么方式能在 JavaScript 中動態的執行一段代碼呢?
Devil waiting outside your floor
沒錯,這里要用到萬惡的 evil。在 JavaScript 中動態的加載代碼,eval 函數是最簡單方便,同時也是最危險和性能最低下的方式。以至於現代瀏覽器都不願意讓我們使用
console.log(eval('2 + 2'))
// VM122:1 Uncaught EvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: "script-src 'self' blob: filesystem:".
執行來自用戶的函數與普通函數略有一點區別,它與普通的函數不同,它需要利用 IPC 來返回值,而普通函數則之間 return 即可。我們不應該向用戶暴露過度的內部細節,所以,用戶的函數可以讓他長這樣:
// func.js
(event, context) => {
return { message: 'it works!', status: 'ok ' };
};
eval 函數不僅可以執行一行代碼片段,它還可以執行一個函數。在拿到用戶的匿名函數后,我們可以將其包裝成一個立即執行函數(IIFE)的字符串,然后交給 eval 函數進行執行。
const fn = `() => (2 + 2)`;
const fnIIFE = `(${fn})()`;
console.log(eval(fnIIFE));
不用擔心,evil 會離我們而去的。
這里我們使用主進程讀取用戶函數,並使用 IPC 發送給子進程;子進程利用 eval 函數來執行,隨后再利用 IPC 將其結果返回給主進程。
// master.ts
import child_process from 'child_process';
import fs from 'fs';
const child = child_process.fork('./dist/child.js');
// Use child.on listen a message
child.on('message', (message: unknown) => {
console.log('Function result:', message);
});
// Read the function from user
const fn = fs.readFileSync('./src/func.js', { encoding: 'utf-8' });
// Sent to child process
child.send({
action: 'run',
fn,
});
// child.ts
import process from 'process';
type fnData = {
action: 'run';
fn: () => unknown;
};
// Listen function form master process
process.on('message', (data: fnData) => {
// Convert user function to IIFE
const fnIIFE = `(${data.fn})()`;
const result = eval(fnIIFE);
// Sent result to master process
process.send?.({ result });
process.exit();
});

Devil crawling along your floor
前面我們利用 eval 函數獲得了執行動態代碼的能力,但與 Devil 做交易是需要付出代價的。很明顯,我們付出了不小的安全性以及性能的代價。
甚至於用戶代碼能夠直接修改 process,導致子進程無法退出等問題:
(event, context) => {
process.exit = () => {
console.log('process NOT exit!');
};
return { message: 'function is running.', status: 'ok' };
};
eval 函數能夠訪問全局變量的原因在於,它們由同一個執行其上下文創建。如果能讓函數代碼在單獨的上下文中執行,那么就應該能夠避免污染全局變量了。
所以我們得換一個 Devil 做交易。在 Node.js 內置模塊中,由一個名為 vm 的模塊。從名字就可以得出,它是一個用於創建基於上下文的沙箱機制,可以創建一個與當前進程無關的上下文環境。
具體方式是,將沙箱內需要使用的外部變量通過vm.createContext(sandbox)包裝,這樣我們就能得到一個 contextify 化的 sandbox 對象,讓函數片段在新的上下文中訪問。然后,可執行對象的代碼片段。在此處執行的代碼的上下文與當前進程的上下文是互相隔離的,在其中對全局變量的任何修改,都不會反映到進程中。提高了函數運行環境的安全性。
const vm = require('vm');
const x = 1;
const context = { x: 2 };
vm.createContext(context); // Contextify the object.
const code = 'x += 40; var y = 17;';
// `x` and `y` are global variables in the context.
// Initially, x has the value 2 because that is the value of context.x.
vm.runInContext(code, context);
在我們的 FaaS 中,我們無須在外層訪問新的上下文對象,只需要執行一段函數即可。因此可以通過vm.runInNewContext(code)方法來快速創建一個無參數的新上下文,更快速創建新的 sandbox。
我們只需要替換到 eval 函數即可:
// child.ts
import process from 'process';
import vm from 'vm';
type fnData = {
action: 'run';
fn: () => unknown;
};
// Listen function form master process
process.on('message', (data: fnData) => {
// Convert user function to IIFE
const fnIIFE = `(${data.fn})()`;
const result = vm.runInNewContext(fnIIFE);
// Sent result to master process
process.send?.({ result });
process.exit();
});
現在,我們實現了將函數隔離在沙箱中執行,流程如圖:

但 vm 真的安全到可以隨意執行來自用戶的不信任代碼嗎?雖然相對於 eval 函數來,它隔離了上下文,提供了更加封閉的環境,但它也不是絕對安全的。
根據 JavaScript 對象的實現機制,所有對象都是有原型鏈的(類似Object.crate(null)除外)。因此 vm 創建的上下文中的 this 就指向是當前的 Context 對象。而 Context 對象是通過主進程創建的,其構造函數指向主進程的 Object。這樣一來,通過原型鏈,用戶代碼就可以順着原型鏈“爬”出沙箱:
import vm from 'vm';
(event, context) => {
vm.runInNewContext('this.constructor.constructor("return process")().exit()');
return { message: 'function is running.', status: 'ok' };
};
這種情況就會導致非信任的代碼調用主程序的process.exit方法,從而讓整個程序退出。
也許我們可以切斷上下文的原型鏈,利用Object.create(null)來為沙箱創建一個上下文。與任何 Devil 做交易都是需要付出代價的:
The
vmmodule is not a security mechanism. Do not use it to run untrusted code.
Devil lying by your side
好在開源社區有人嘗試解決這個問題,其中一個方案就是 vm2 模塊。vm2 模塊是利用 Proxy 特性來對內部變量進行封裝的。這使得隔離的沙箱環境可以運行不受信任的代碼。
當然,我們需要手動添加一下依賴:
yarn add vm2
另一個值得慶幸的是,代碼改動也很小。我們只需要對child.ts簡單修改即可:
import process from 'process';
import { VM } from 'vm2';
type fnData = {
action: 'run';
fn: () => unknown;
};
// Listen function form master process
process.on('message', (data: fnData) => {
// Convert user function to IIFE
const fnIIFE = `(${data.fn})()`;
const result = new VM().run(fnIIFE);
// Sent result to master process
process.send?.({ result });
process.exit();
});
HTTP服務
在實現了動態執行代碼片段的能力后,為了讓函數能夠對外提供服務,我們還需要添加一個 HTTP API。這個 API 使得用戶可以根據不同的請求路徑來動態的執行對應的代碼,並將其結果返回給客戶端。
這里 HTTP 服務器選用的是 Koa。
yarn add koa
當然還要有其類型
yarn add @types/koa -D
為了響應 HTTP 請求並運行我們的函數,我們需要進一步的將運行子進行的方法封裝為一個異步函數,並在接收到子進程的消息后,直接 resolve 給 Koa。
將前面的子進程的創建、監聽以及讀取文件都封裝進一個函數:
// master.ts
import child_process from 'child_process';
import fs from 'fs/promises';
import Koa from 'koa';
const app = new Koa();
app.use(async (ctx) => {
ctx.response.body = await run();
});
const run = async () => {
const child = child_process.fork('./dist/child.js');
// Read the function from user
const fn = await fs.readFile('./src/func.js', { encoding: 'utf-8' });
// Sent to child process
child.send({
action: 'run',
fn,
});
return new Promise((resolve) => {
// Use child.on listen a message
child.on('message', resolve);
});
};
app.listen(3000);
現在我們的流程如下:

這樣還不夠,到目前為止,用戶還只是請求的根路徑,而我們響應的也只是同一個函數。因此我們還需要一個路由機制來支持不同的函數觸發。
使用ctx.request.path就能獲取到每次 GET 請求后的路徑,所以這里也不用大費周章的去划分路由,直接把路徑作為函數名,讀取文件,執行即可。所以這里的改造就簡單多了:
// master.ts
app.use(async (ctx) => {
ctx.response.body = await run(ctx.request.path);
});
const run = async (path: string) => {
const child = child_process.fork('./dist/child.js');
// Read the function from user
const fn = await fs.readFile(`./src/func/${path}.js`, { encoding: 'utf-8' });
// Sent to child process
child.send({
action: 'run',
fn,
});
return new Promise((resolve) => {
// Use child.on listen a message
child.on('message', resolve);
});
};
至此,我們就實現了一個最簡單的進程隔離 FaaS 方案,並提供了動態加載函數文件且執行的能力。
但這還不是全部,還有很多方面的問題值得去優化。
進階優化
FaaS 並不只是簡單的擁有動態的執行函數的能力就可以了,面對我們的還有大量的待處理問題。
進程管理
上述的方案看上去已經很理想了,利用子進程和沙箱防止污染主進程。但還有個主要的問題,用戶的每一個請求都會創建一個新的子進程,並在執行完后再銷毀。對系統來說,創建和銷毀進程是一個不小的開銷,且請求過多時,過多的進程也可能導致系統崩潰。
所以最佳的辦法是通過進程池來復用進程。如下圖,進程池是一種可以復用進程的概念,通過事先初始化並維護一批進程,讓這批進程運行相同的代碼,等待着執行被分配的任務。執行完成后不會退出,而是繼續等待新的任務。在調度時,通常還會通過某種算法來實現多個進程之間任務分配的負載均衡。

早在 Node.js v0.8 中就引入了 cluster 模塊。cluster 是對child_process模塊的一層封裝。通過它,我們可以創建共享服務器同一端口的子進程。
這時候我們就需要對master.ts進行大改造了。首先需要將child_process更換為 cluster 來管理進程,我們根創建CPU 超線程數量一半的子進程。這是為了留下多余的超線程給系統已經 Node 的事件循環來工作。順便在每個子進程中監聽對應的 HTTP 端口來啟動 HTTP 服務。
// master.ts
import cluster from 'cluster';
import os from 'os';
const num = os.cpus().length;
const CPUs = num > 2 ? num / 2 : num;
if (cluster.isMaster) {
for (let i = 0; i < CPUs; i++) {
cluster.fork();
}
} else {
const app = new Koa();
app.use(async (ctx) => {
ctx.response.body = await run(ctx.request.path);
});
app.listen(3000);
}
這里看上去有點匪夷所思,我們都知道,在操作系統中,是不允許多個進程監聽同一個端口的。我們的多個子進程看上去監聽的都是同一個端口!
實際上,在 Node.js 的 net 模塊中,當當前進程是 cluster 的子進程時,存在一個特殊的處理。
簡單來說就是,當調用 listen 方法監聽端口后,它會判斷是否處於 cluster 的子進程下。如果是子進程,則會向主進程發送消息,告訴主進程需要監聽的端口。當主進程收到消息后,會判斷指定端口是否已經被監聽,如果沒有,則通過端口綁定實現監聽。隨后,再將子進程加入一個 worker 隊列,表明該子進程可以處理來自該端口的請求。
這樣一來,實際上監聽的端口的依然是主進程,然后將請求分發給 worker 隊列中子進程。分發算法采用了 Round Robin 算法,即輪流處理制。我們可以通過環境變量NODE_CLUSTER_SCHED_POLICY或通過配置cluster.schedulingPolicy來指定其他的負載均衡算法。
總的來說,雖然我們的代碼看上去是由子進程來多次監聽端口,但實際上是由我們的主進程來進行監聽。然后就指定的任務分發給子進程進行處理。
回到我們的邏輯上,由於可以直接在當前代碼中判斷和創建進程,我們也就不再需要child.ts了。子進程也可以直接在作用域中執行 run 函數了。
所以我們將master.ts完整的改造一下,最終我們就實現了基於 cluster 的多進程管理方案:
import cluster from 'cluster';
import os from 'os';
import fs from 'fs/promises';
import Koa from 'koa';
import { VM } from 'vm2';
const num = os.cpus().length;
const CPUs = num > 1 ? Math.floor(num / 2) : num;
const run = async (path: string) => {
try {
// Read the function from user
const fn = await fs.readFile(`./src/func/${path}.js`, {
encoding: 'utf-8',
});
// Use arrow function to handle semicolon
const fnIIFE = `const func = ${fn}`;
return new VM().run(`${fnIIFE} func()`);
} catch (e) {
console.log(e);
return 'Not Found Function';
}
};
if (cluster.isMaster) {
for (let i = 0; i < CPUs; i++) {
cluster.fork();
}
} else {
const app = new Koa();
app.use(async (ctx) => {
ctx.response.body = await run(ctx.request.path);
});
app.listen(3000);
}
限制函數執行時間
上述,我們利用多進程方案來提高整體的安全性。但是,目前還沒有考慮死循環的情況。當用戶編寫了一個這樣的函數時:
const loop = (event, context) => {
while (1) {}
return { message: 'this is function2!!!', status: 'ok ' };
};
我們的進程會一直為其計算下去,無法正常退出,導致資源被占用。所以我們理想的情況下就是在沙箱外限制沒個函數的執行時長,當超過限定時間時,之間結束該函數。
好在,vm 模塊賦予了我們這一強大的功能:
vm.runInNewContext({
'loop()',
{ loop, console },
{ timeout: 5000 }
})
通過 timeout 參數,我們為函數的執行時間限制在 5000ms 內。當死循環的函數執行超 5s 后,隨后會得到一個函數執行超時的錯誤信息。
由於 vm2 也是基於 vm 進行封裝的,因此我們可以在 vm2 中使用和 vm 相同的能力。只需要小小的改動就可以實現限制函數執行時長能力:
return new VM({ timeout: 5000 }).run(`${fnIIFE} func()`);
看上去不錯!但 Devil 不會就這么輕易放過我們的。JavaScript 本身是單線程的語言,它通過出色的異步循環來解決同步阻塞的問題。異步能解決很多問題,但同時也能帶來問題。事件循環機制目前管理着兩個任務隊列:事件循環隊列(或者叫宏任務)與任務隊列(常見的微任務)。
我們可以把每次的事件循環隊列內的每次任務執行看作一個 tick,而任務隊列就是掛在每個 tick 之后運行的。也就是說微任務只要一直在運行,或者一直在添加,那么就永遠進入不到下一次 tick 了。這和同步下死循環問題一樣!
事件循環通常包含:setTimout、setInterval和 I/O 操作等,而任務隊列通常為:process.nextTick、Promise、MutationObserver 等。
VM2 也有類似 VM 的 timeout 設置,但是同樣的是,它也是基於事件循環隊列所設置的超時。根本來說,它無法限制任務隊列中的死循環。
面對這個難題,考慮了很久,也導致這個項目拖了挺長一段時間的。摸索中想到了大概兩個方法能夠解決這個問題:
- 繼續使用 cluster 模塊,cluster 模塊沒有直接的 API 鈎子給我們方便的在主進程中實現計時的邏輯。我們可以考慮重寫任務分發算法,在 Round Robin 算法的的基礎上實現計時的邏輯。從而控制子進程,當子進程超時時,直接結束子進程的聲明周期。
- 第二個方法是,放棄使用 cluster 模塊,由我們親自來管理進程的分發已經生命周期,從而達到對子進程設置執行超時時間的限制。
這兩個方法都不是什么簡單省事的方法,好在我們有優秀的開源社區。正當我被子進程卡主時,得知了一個名為 Houfeng/safeify: 📦 Safe sandbox that can be used to execute untrusted code. (github.com) 的項目。它屬於第二種解決辦法,對child_process的手動管理,從而實現對子進程的完全控制,且設置超時時間。
雖然上述寫的 cluster 模塊的代碼需要重構,並且我們也不需要 cluster 模塊了。利用 safeify 就可以進行對子進程的管理了。
所以這里對 Koa 的主進程寫法就是最常見的方式,將控制和執行函數的邏輯抽離為一個 middleware,交由路由進行匹配:
import Koa from 'koa';
import runFaaS from './middleware/faas';
import logger from 'koa-logger';
import OPTION from './option';
import router from './routers';
import bodyParser from 'koa-bodyparser';
import cors from './middleware/CORS';
const app = new Koa();
app.use(logger());
app.use(bodyParser());
app.use(cors);
// 先注冊路由
app.use(router.routes());
app.use(router.allowedMethods());
// 路由未匹配到的則運行函數
app.use(runFaaS);
console.log(`⚡[Server]: running at http://${OPTION.host}:${OPTION.port} !`);
export default app.listen(OPTION.port);
總結
我的簡易 FaaS 基本上到這里就告一段落了,對 Devil 的最后針扎就是限制函數的異步執行時間。實際上還有一些可以優化的點。例如對函數執行資源的限制,即便我們對函數的執行時間有了限制,但在函數死循環的幾秒鍾,它還是占有了我們 100% 的 CPU。如果多個進程的函數都會占滿 CPU 的執行,那么到最后服務器的資源可能會被消耗殆盡。
針對這個情況也有解決辦法:在 Linux 系統上可以使用 CGroup 來對 CPU 和系統其他資源進程限制。其實 safeify 中也有了對 CGroup 的實現,但我最終沒有采用作用這個方案,因為在 Docker 環境中,資源本身已經有了一定的限制,而且 Container 中大部分系統文件都是 readonly 的,CGroup 也不好設置。
還有一個優化的地方就是可以給函數上下文提供一些內置的可以函數,模仿添加 BaaS 的實現,添加一個常用的服務。不過最終這個小功能也沒有實現,因為(懶)這本來就是一個對 FaaS 的簡單模擬,越是復雜安全性的問題也會隨着增加。
推薦
無利益相關推薦:
目前市面上大部分對於 Serverless 的書籍都是研究其架構的,對於面向前端的 Serverless 書籍不是很常見。而《前端 Serverless:面向全棧的無服務器架構實戰》就是這樣一本針對我們前端工程師的書籍,從 Serverless 的介紹,到最后的上雲實踐,循序漸進。
本篇也大量參考其中。

