從 0 到 1,打造新一代開源函式計算平臺

KubeSphere發表於2021-12-08

本文是根據霍秉傑在 2021 稀土開發者大會分享的內容整理而來。

作者:霍秉傑,雲原生 FaaS 專案 OpenFunction Founder; FluentBit Operator 的發起人;他還是幾個可觀測性開源專案的發起人,如 Kube-Events、Notification Manager 等;熱愛雲原生和開源技術,是 Prometheus Operator, Thanos, Loki, Falco 的貢獻者。

無伺服器計算,即通常所說的 Serverless,已經成為當前雲原生領域炙手可熱的名詞,是繼 IaaS,PaaS 之後雲端計算髮展的下一波浪潮。Serverless 強調的是一種架構思想和服務模型,讓開發者無需關心基礎設施(伺服器等),而是專注到應用程式業務邏輯上。加州大學伯克利分校在論文 A Berkeley View on Serverless Computing 中給出了兩個關於 Serverless 的核心觀點:

  • 有服務的計算並不會消失,但隨著 Serverless 的成熟,有服務計算的重要性會逐漸降低。
  • Serverless 最終會成為雲時代的計算正規化,它能夠在很大程度上替代有服務的計算模式,並給 Client-Server 時代劃上句號。

那麼什麼是 Serverless 呢?

Serverless 介紹

關於什麼是 Serverless,加州大學伯克利分校在之前提到的論文中也給出了明確定義:Serverless computing = FaaS + BaaS。雲服務按抽象程度從底層到上層傳統的分類是硬體、雲平臺基本元件、PaaS、應用,但 PaaS 層的理想狀態是具備 Serverless 的能力,因此這裡我們將 PaaS 層替換成了 Serverless,即下圖中的黃色部分。

Serverless 包含兩個組成部分 BaaSFaaS,其中物件儲存、關係型資料庫以及 MQ 等雲上基礎支撐服務屬於 BaaS(後端即服務),這些都是每個雲都必備的基礎服務,FaaS(函式即服務)才是 Serverless 的核心。

現有開源 Serverless 平臺分析

KubeSphere 社群從 2020 年下半年開始對 Serverless 領域進行深度調研。經過一段時間的調研後,我們發現:

  • 現有開源 FaaS 專案絕大多數啟動較早,大部分都在 Knative 出現前就已經存在了;
  • Knative 是一個非常傑出的 Serverless 平臺,但是 Knative Serving 僅僅能執行應用,不能執行函式,還不能稱之為 FaaS 平臺;
  • Knative Eventing 也是非常優秀的事件管理框架,但是設計有些過於複雜,使用者用起來有一定門檻;
  • OpenFaaS 是比較流行的 FaaS 專案,但是技術棧有點老舊,依賴於 Prometheus 和 Alertmanager 進行 Autoscaling,在雲原生領域並非最專業和敏捷的做法;
  • 近年來雲原生 Serverless 相關領域陸續湧現出了很多優秀的開源專案如 KEDADaprCloud Native Buildpacks(CNB)TektonShipwright 等,為建立新一代開源 FaaS 平臺打下了基礎。

綜上所述,我們調研的結論就是:現有開源 Serverless 或 FaaS 平臺並不能滿足構建現代雲原生 FaaS 平臺的要求,而云原生 Serverless 領域的最新進展卻為構建新一代 FaaS 平臺提供了可能。

新一代 FaaS 平臺框架設計

如果我們要重新設計一個更加現代的 FaaS 平臺,它的架構應該是什麼樣子呢?理想中的 FaaS 框架應該按照函式生命週期分成幾個重要的部分:函式框架 (Functions framework)、函式構建 (Build)、函式服務 (Serving) 和事件驅動框架 (Events Framework)。

作為 FaaS,首先得有一個 Function Spec 來定義函式該怎麼寫,有了函式之後,還要轉換成應用,這個轉換的過程就是靠函式框架來完成;如果應用想在雲原生環境中執行,就得構建容器映象,構建流程依賴函式構建來完成;構建完映象後,應用就可以部署到函式服務的執行時中;部署到執行時之後,這個函式就可以被外界訪問了。

下面我們將重點闡述函式框架、函式構建和函式服務這幾個部分的架構設計。

函式框架 (Functions framework)

為了降低開發過程中學習函式規範的成本,我們需要增加一種機制來實現從函式程式碼到可執行的應用之間的轉換。這個機制需要製作一個通用的 main 函式來實現,這個函式用於處理通過 serving url 函式進來的請求。主函式中具體包含了很多步驟,其中一個步驟用於關聯使用者提交的程式碼,其餘的用於做一些普通的工作(如處理上下文、處理事件源、處理異常、處理埠等等)。

在函式構建的過程中,構建器會使用主函式模板渲染使用者程式碼,在此基礎上生成應用容器映象中的 main 函式。我們直接來看個例子,假設有這樣一個函式。

package hello

import (
    "fmt"
    "net/http"
)

func HelloWorld(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, "Hello, World!\n")
}

經函式框架轉換後會生成如下的應用程式碼:

package main

import (
    "context"
    "errors"
    "fmt"
    "github.com/OpenFunction/functions-framework-go/functionframeworks"
    ofctx "github.com/OpenFunction/functions-framework-go/openfunction-context"
    cloudevents "github.com/cloudevents/sdk-go/v2"
    "log"
    "main.go/userfunction"
    "net/http"
)

func register(fn interface{}) error {
    ctx := context.Background()
    if fnHTTP, ok := fn.(func(http.ResponseWriter, *http.Request)); ok {
        if err := functionframeworks.RegisterHTTPFunction(ctx, fnHTTP); err != nil {
            return fmt.Errorf("Function failed to register: %v\n", err)
        }
    } else if fnCloudEvent, ok := fn.(func(context.Context, cloudevents.Event) error); ok {
        if err := functionframeworks.RegisterCloudEventFunction(ctx, fnCloudEvent); err != nil {
            return fmt.Errorf("Function failed to register: %v\n", err)
        }
    } else if fnOpenFunction, ok := fn.(func(*ofctx.OpenFunctionContext, []byte) ofctx.RetValue); ok {
        if err := functionframeworks.RegisterOpenFunction(ctx, fnOpenFunction); err != nil {
            return fmt.Errorf("Function failed to register: %v\n", err)
        }
    } else {
        err := errors.New("unrecognized function")
        return fmt.Errorf("Function failed to register: %v\n", err)
    }
    return nil
}

func main() {
    if err := register(userfunction.HelloWorld); err != nil {
        log.Fatalf("Failed to register: %v\n", err)
    }

    if err := functionframeworks.Start(); err != nil {
        log.Fatalf("Failed to start: %v\n", err)
    }
}

其中高亮的部分就是前面使用者自己寫的函式。在啟動應用之前,先對該函式進行註冊,可以註冊 HTTP 類的函式,也可以註冊 cloudevents 和 OpenFunction 函式。註冊完成後,就會呼叫 functionframeworks.Start 啟動應用。

函式構建 (Build)

有了應用之後,我們還要把應用構建成容器映象。目前 Kubernetes 已經廢棄了 dockershim,不再把 Docker 作為預設的容器執行時,這樣就無法在 Kubernetes 叢集中以 Docker in Docker 的方式構建容器映象。還有沒有其他方式來構建映象?如何管理構建流水線?

Tekton 是一個優秀的流水線工具,原來是 Knative 的一個子專案,後來捐給了 CD 基金會 (Continuous Delivery Foundation)。Tekton 的流水線邏輯其實很簡單,可以分為三個步驟:獲取程式碼,構建映象,推送映象。每一個步驟在 Tekton 中都是一個 Task,所有的 Task 串聯成一個流水線。

作容器映象有多種選擇,比如 Kaniko、Buildah、BuildKit 以及 Cloud Native Buildpacks(CNB)。其中前三者均依賴 Dockerfile 去製作容器映象,而 Cloud Native Buildpacks(CNB)是雲原生領域最新湧現出來的新技術,它是由 Pivotal 和 Heroku 發起的,不依賴於 Dockerfile,而是能自動檢測要 build 的程式碼,並生成符合 OCI 標準的容器映象。這是一個非常驚豔的技術,目前已經被 Google Cloud、IBM Cloud、Heroku、Pivotal 等公司採用,比如 Google Cloud 上面的很多映象都是通過 Cloud Native Buildpacks(CNB)構建出來的。

面對這麼多可供選擇的映象構建工具,如何在函式構建的過程中讓使用者自由選擇和切換映象構建的工具?這就需要用到另外一個專案 Shipwright,這是由 Red Hat 和 IBM 開源的專案,專門用來在 Kubernetes 叢集中構建容器映象,目前也捐給了 CD 基金會。使用 Shipwright,你就可以在上述四種映象構建工具之間進行靈活切換,因為它提供了一個統一的 API 介面,將不同的構建方法都封裝在這個 API 介面中。

我們可以通過一個示例來理解 Shipwright 的工作原理。首先需要一個自定義資源 Build 的配置清單:

apiVersion: shipwright.io/v1alpha1
kind: Build
metadata:
  name: buildpack-nodejs-build
spec:
  source:
    url: https://github.com/shipwright-io/sample-nodejs
    contextDir: source-build
  strategy:
    name: buildpacks-v3
    kind: ClusterBuildStrategy
  output:
    image: docker.io/${REGISTRY_ORG}/sample-nodejs:latest
    credentials:
      name: push-secret

這個配置清單分為 3 個部分:

  • source 表示去哪獲取原始碼;
  • output 表示原始碼構建的映象要推送到哪個映象倉庫;
  • strategy 指定了構建映象的工具。

其中 strategy 是由自定義資源 ClusterBuildStrategy 來配置的,比如使用 buildpacks 來構建映象,ClusterBuildStrategy 的內容如下:

這裡分為兩個步驟,一個是準備環境,一個是構建並推送映象。每一步都是 Tekton 的一個 Task,由 Tekton 流水線來管理。

可以看到,Shipwright 的意義在於將映象構建的能力進行了抽象,使用者可以使用統一的 API 來構建映象,通過編寫不同的 strategy 就可以切換不同的映象構建工具。

函式服務 (Serving)

函式服務 (Serving) 指的是如何執行函式/應用,以及賦予函式/應用基於事件驅動或流量驅動的自動伸縮的能力 (Autoscaling)。CNCF Serverless 白皮書定義了函式服務的四種呼叫型別:

我們可以對其進行精簡一下,主要分為兩種型別:

  • 同步函式:客戶端必須發起一個 HTTP 請求,然後必須等到函式執行完成並獲取函式執行結果後才返回。
  • 非同步函式:發起請求之後直接返回,無需等待函式執行結束,具體的結果通過 Callback 或者 MQ 通知等事件來通知呼叫者,即事件驅動 (Event Driven)。

同步函式和非同步函式分別都有不同的執行時來實現:

  • 同步函式方面,Knative Serving 是一個非常優秀的同步函式執行時,具備了強大的自動伸縮能力。除了 Knative Serving 之外,還可以選擇基於 KEDA http-add-on 配合 Kubernetes 原生的 Deployment 來實現同步函式執行時。這種組合方法可以擺脫對 Knative Serving 依賴。
  • 非同步函式方面,可以結合 KEDADapr 來實現。KEDA 可以根據事件源的監控指標來自動伸縮 Deployment 的副本數量;Dapr 提供了函式訪問 MQ 等中介軟體的能力。

Knative 和 KEDA 在自動伸縮方面的能力不盡相同,下面我們將展開分析。

Knative 自動伸縮

Knative Serving 有 3 個主要元件:Autoscaler、Serverless 和 Activator。Autoscaler 會獲取工作負載的 Metric(比如併發量),如果現在的併發量是 0,就會將 Deployment 的副本數收縮為 0。但副本數縮為 0 之後函式就無法呼叫了,所以 Knative 在副本數縮為 0 之前會把函式的呼叫入口指向 Activator

當有新的流量進入時,會先進入 Activator,Activator 接收到流量後會通知 Autoscaler,然後 Autoscaler 將 Deployment 的副本數擴充套件到 1,最後 Activator 會將流量轉發到實際的 Pod 中,從而實現服務呼叫。這個過程也叫冷啟動

由此可知,Knative 只能依賴 Restful HTTP 的流量指標進行自動伸縮,但現實場景中還有很多其他指標可以作為自動伸縮的依據,比如 Kafka 消費的訊息積壓,如果訊息積壓數量過多,就需要更多的副本來處理訊息。要想根據更多型別的指標來自動伸縮,我們可以通過 KEDA 來實現。

KEDA 自動伸縮

KEDA 需要和 Kubernetes 的 HPA 相互配合來達到更高階的自動伸縮的能力,HPA 只能實現從 1 到 N 之間的自動伸縮,而 KEDA 可以實現從 0 到 1 之間的自動伸縮,將 KEDA 和 HPA 結合就可以實現從 0 到 N 的自動伸縮。

KEDA 可以根據很多型別的指標來進行自動伸縮,這些指標可以分為這麼幾類:

  • 雲服務的基礎指標,比如 AWS 和 Azure 的相關指標;
  • Linux 系統相關指標,比如 CPU、記憶體;
  • 開源元件特定協議的指標,比如 Kafka、MySQL、Redis、Prometheus。

例如要根據 Kafka 的指標進行自動伸縮,就需要這樣一個配置清單:

apiVersion: keda.k8s.io/v1alpha1
kind: ScaledObject
metadata:
  name: kafka-scaledobject
  namespace: default
  labels:
    deploymentName: kafka-consumer-deployment # Required Name of the deployment we want to scale.
spec:
  scaleTargetRef:
    deploymentName: kafka-consumer-deployment # Required Name of the deployment we want to scale.
  pollingInterval: 15
  minReplicaCount: 0
  maxReplicaCount: 10 
  cooldownPeriod: 30
  triggers:
  - type: kafka
    metadata:
      topic: logs
      bootstrapServers: kafka-logs-receiver-kafka-brokers.default.svc.cluster.local
      consumerGroup: log-handler
      lagThreshold: "10"

副本伸縮的範圍在 0~10 之間,每 15 秒檢查一次 Metrics,進行一次擴容之後需要等待 30 秒再決定是否進行伸縮。

同時還定義了一個觸發器,即 Kafka 伺服器的 “logs” topic。訊息堆積閾值為 10,即當訊息數量超過 10 時,logs-handler 的例項數量就會增加。如果沒有訊息堆積,就會將例項數量減為 0。

這種基於元件特有協議的指標進行自動伸縮的方式比基於 HTTP 的流量指標進行伸縮的方式更加合理,也更加靈活。

雖然 KEDA 不支援基於 HTTP 流量指標進行自動伸縮,但可以藉助 KEDA 的 http-add-on 來實現,該外掛目前還是 Beta 狀態,我們會持續關注該專案,等到它足夠成熟之後就可以作為同步函式的執行時來替代 Knative Serving。

Dapr

現在的應用基本上都是分散式的,每個應用的能力都不盡相同,為了將不同應用的通用能力給抽象出來,微軟開發了一個分散式應用執行時,即 Dapr (Distributed Application Runtime)。Dapr 將應用的通用能力抽象成了元件,不同的元件負責不同的功能,例如服務之間的呼叫、狀態管理、針對輸入輸出的資源繫結、可觀測性等等。這些分散式元件都使用同一種 API 暴露給各個程式語言進行呼叫。

函式計算也是分散式應用的一種,會用到各種各樣的程式語言,以 Kafka 為例,如果函式想要和 Kafka 通訊,Go 語言就得使用 Go SDK,Java 語言得用 Java SDK,等等。你用幾種語言去訪問 Kafka,就得寫幾種不同的實現,非常麻煩。

再假設除了 Kafka 之外還要訪問很多不同的 MQ 元件,那就會更麻煩,用 5 種語言對接 10 個 MQ(Message Queue) 就需要 50 種實現。使用了 Dapr 之後,10 個 MQ 會被抽象成一種方式,即 HTTP/GRPC 對接,這樣就只需 5 種實現,大大減輕了開發分散式應用的工作量。

由此可見,Dapr 非常適合應用於函式計算平臺。

新一代開源函式計算平臺 OpenFunction

結合上面討論的所有技術,就誕生了 OpenFunction 這樣一個開源專案,它的架構如圖所示。

主要包含 4 個元件:

  • Function : 將函式轉換為應用;
  • Build : 通過 Shipwright 選擇不同的映象構建工具,最終將應用構建為容器映象;
  • Serving : 通過 Serving CRD 將應用部署到不同的執行時中,可以選擇同步執行時或非同步執行時。同步執行時可以通過 Knative Serving 或者 KEDA-HTTP 來支援,非同步執行時通過 Dapr+KEDA 來支援。
  • Events : 對於事件驅動型函式來說,需要提供事件管理的能力。由於 Knative 事件管理過於複雜,所以我們研發了一個新型事件管理驅動叫 OpenFunction Events

    OpenFunction Events 借鑑了 Argo Events 的部分設計,並引入了 Dapr。整體架構分為 3 個部分:

    • EventSource : 用於對接多種多樣的事件源,通過非同步函式來實現,可以根據事件源的指標自動伸縮,使事件的消費更加具有彈性。
    • EventBus : EventBus 利用 Dapr 的能力解耦了 EventBus 與底層具體 Message Broker 的繫結,你可以對接各種各樣的 MQ。EventSource 消費事件之後有兩種處理方式,一種是直接呼叫同步函式,然後等待同步函式返回結果;另一種方式是將其寫入 EventBus,EventBus 接收到事件後會直接觸發一個非同步函式。
    • Trigger : Trigger 會通過各種表示式對 EventBus 裡面的各種事件進行篩選,篩選完成後會寫入 EventBus,觸發另外一個非同步函式。

關於 OpenFunction 的實際使用案例可以參考這篇文章:以 Serverless 的方式用 OpenFunction 非同步函式實現日誌告警(點選下方圖片跳轉閱讀)。

OpenFunction Roadmap

OpenFunction 的第一個版本於今年 5 月份釋出,從 v0.2.0 開始支援非同步函式,v0.3.1 開始新增了 OpenFunction Events,並支援了 Shipwright,v0.4.0 新增了 CLI。

後續我們還會引入視覺化介面,支援更多的 EventSource,支援對邊緣負載的處理能力,通過 WebAssembly 作為更加輕量的執行時,結合 Rust 函式來加速冷啟動速度。

加入 OpenFunction 社群

期待感興趣的開發者加入 OpenFunction 社群。可以提出任何你對 OpenFunction 的疑問、設計提案與合作提議。

您也可以加入我們的微信交流群,加群管理員微信:cloud-native-yang,備註進 OpenFunction 交流群。

您可以在這裡找到 OpenFunction 的一些典型使用案例:

相關文章