開源 Serverless 框架 Laf 效能優化實踐

2023-12-14 12:00:18

介紹

Laf 是一個完全開源的 Serverless 框架,Laf 的 Node.js 執行時容器 (以下簡稱為 Runtime) 是 Laf 的函數執行環境,依託於 Express.js 框架。採用容器程序常駐的方式,每一個應用對應於一個或多個容器 (彈性伸縮下),底層使用了 Node.js 的 vm 模組,使用 MongoDB 的 watch() 方法來監聽函數變更事件,以實現函數釋出和設定釋出。

Node.js vm 模組

Node.js 的 vm 模組是一個提供虛擬機器器功能的模組,用於在 Node.js 環境中建立一個獨立的 JavaScript 執行環境。它允許在應用程式中執行和控制一段 JavaScript 程式碼,同時提供了一些安全性隔離性

這個模組包括一些可用於建立隔離的執行環境的函數,使得程式碼能夠在獨立的上下文中執行,防止對主應用程式的影響。這在某些情況下可以提供更高的安全性,例如在沙盒環境中執行使用者提供的程式碼,或者實現一些動態載入執行程式碼的需求。

原文連結:https://forum.laf.run/d/1146

為什麼要優化

目前 Laf 的函數執行時存在以下問題:

  1. 頻繁使用 Node.js vm 模組重複建立 vm,vm 建立執行的過程中,CPU 消耗很高。在以下對 runtime 的 CPU 火焰圖分析可見,在函數執行過程中,有兩部分 CPU 執行時間較長,分別是輸出函數請求紀錄檔vm 建立執行過程

  1. 有時候遇到複雜的函數巢狀參照的時候,會導致迴圈參照,記憶體遲遲無法回收,造成記憶體漏失,導致 OOM Killed。
  2. 交由 runtime 自己通過 HTTP 呼叫的形式,非同步請求持久化函數紀錄檔,效能損耗大,QPS 直接減半
  3. 函數引擎這塊的邏輯越來越複雜和臃腫,維護難度很大,急需重構。

如何優化

在前面的分析中,我們知道,當前造成效能瓶頸的原因主要有兩點:

  1. 為了實現隔離,vm 模組重複建立,CPU 消耗高,特別是當函數參照達到一定規模時。另一方面,複雜的參照下,甚至會發生記憶體難以回收造成記憶體漏失的問題。
  2. 頻繁列印函數請求紀錄檔,依賴單執行緒的 Node.js 通過非同步請求處理 console.log 等紀錄檔,導致實際業務請求吞吐量下降。

因此,我們採用以下優化思路:

  1. 紀錄檔方面:使用標準輸出的形式輸出紀錄檔,交由 K8s 自己採集紀錄檔,而不由 runtime 自己處理。

  2. 函數引擎方面:第一次函數呼叫時,構建並快取函數模組,下次呼叫直接取出使用,不需要重複編譯,這塊更改需要確保以下因素:

    1. 保證這個快取的函數模組是無狀態,即 y = f(x),輸入相同的 x,則必然輸出確定的 y。
    2. 函數釋出時,要及時清除快取的函數模組。

優化前後架構對比分析

  • 優化前:

  • 優化後:

優化步驟

  1. 改造紀錄檔方案為容器紀錄檔標準輸出,交由 K8s 收集,完全去除紀錄檔的有狀態依賴。
  2. 重構函數引擎,建立函數模組,每一個函數模組的匯出都是一個 JS 物件,無論是程式碼還是參照的第三方包,都被視作為一個 Module,在程式碼中只會存在一份,等同於原生的 require / export:
    1. 簡化程式碼,儘可能複用,保留核心邏輯;
    2. 去除函數模組中的有狀態部分;
    3. 在函數執行、函數引入處建立函數模組快取
  3. 針對偵錯模式,每次函數執行時重新構建函數模組,主動收集執行紀錄檔。

核心函數呼叫邏輯

const vm = require('vm')

// 函數列表
const functionList = {
    a: "const b = require('b'); const func = () => b(); module.exports = func",
    b: "module.exports = () => 'hello world'"
}

// 函數模組快取
const functionModuleCache = new Map()

// 構建函數模組
const buildFunctionModule = (name) => {
    // 自定義 require 邏輯,用來載入函數
    const customRequire = (specifier) => {
        if (functionModuleCache.has(specifier)) {
            return functionModuleCache.get(specifier)
        }
        if(functionList[specifier]) {
            return buildFunctionModule(specifier)
        }
        return require(specifier)
    }
    
    // 全域性上下文
    const ctx = {
        __require: customRequire,
        module: {
            exports: {},
        }
    }

    // 重新定義 require
    const wrapCode = code => {
        return `
        const require = (name) => {
            return __require(name)
        }

        ${code}
        module.exports;
        `
    }
    
    // 構建模組
    const script = new vm.Script(wrapCode(functionList[name]))
    const mod = script.runInNewContext(ctx)
    // 快取構建結果
    functionModuleCache.set(name, mod)
    return mod
}

// 簡單寫一個入口函數
const main = () => {
    const func = buildFunctionModule('a')
    const res = func()
    console.log(res)
}

main()

優化效果

壓測

下面以 Laf 應用最低設定 0.1c 128m 為例進行壓測。

  1. 常規 HTTP 請求:

    資料量 測試結果 QPS
    10 並行請求 1000 次 110
    100 並行請求 1000 次 122
  2. WebSocket 連線

    每秒建立 100 個 websocket 連線,當建立 1 萬個 websocket 連線時,資源佔用情況如下:

真實案例

某個跑在 laf 上的應用,日活數十萬,原來需要 4 個 G 的記憶體,優化後,記憶體降至 512 MB 以下,CPU 只需要不到 1 核

附加彩蛋

除此之外,我們還做了不少額外的工作:

  1. 紀錄檔支援根據不同 Level,以不同的顏色輸出。
  2. 通過重定向自定義依賴安裝路徑,現在支援安裝和內建依賴版本不同的依賴包。
  3. 攔截器現在支援類似 koa 洋蔥圈結構的前攔截和後攔截的寫法,詳情檢視 Laf 檔案。
  4. ...

總結

通過優化 Laf 執行時,我們在將每個應用的成本降低至原來的 1/10 的同時,還大大提高了效能和穩定性,成功把 Laf 的價格打了下來 ~