近些年來,傳統的 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 vm module 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 設定,但是同樣的是,它也是基於事件迴圈佇列所設定的超時。根本來說,它無法限制任務佇列中的死迴圈。

面對這個難題,考慮了很久,也導致這個專案拖了挺長一段時間的。摸索中想到了大概兩個方法能夠解決這個問題:

  1. 繼續使用 cluster 模組,cluster 模組沒有直接的 API 鉤子給我們方便的在主程序中實現計時的邏輯。我們可以考慮重寫任務分發演算法,在 Round Robin 演算法的的基礎上實現計時的邏輯。從而控制子程序,當子程序超時時,直接結束子程序的宣告週期。
  2. 第二個方法是,放棄使用 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 的介紹,到最後的上雲實踐,循序漸進。

本篇也大量參考其中。

把玩

FaaS