Categories
程式開發

GrowingIO 微服務 SaaS 與私有部署運行實踐


GrowingIO 作為國內領先的數據運營解決方案供應商,為上千家企業提供基於用戶行為的增長解決方案。

為滿足企業的不同側重與需求,GrowingIO 支持私有化和 SaaS 兩種部署方式。兩種部署方式的內容架構與交互設計不同,但相同的是產品底層邏輯,以及數據中心、用戶庫、產品分析、智能運營等產品線。

這些產品線在研發環境中,被設計為多個相互獨立的微服務。為避免研發資源浪費,GrowingIO 需要確保同一批微服務可在不同部署模式的環境中均正常運行。

SaaS 與私有部署環境的差別

要解決這個問題,我們需要找到 SaaS 與私有部署之間的差別。根據我們的經驗,總結了以下幾個主要的差別:

雲生態的差別:SaaS 產品部署在公有云環境裡,能使用標準的接口管理資源(實例),也可以非常方便地使用公有云的產品生態去架構服務的高可用。在私有部署的環境裡面,資源的訪問方式,雲平台的產品形態都不太一樣。數據隔離級別:SaaS 產品需要有非常完備且嚴格的多租戶數據隔離方案。在私有部署的環境裡面,數據隔離的級別會因為客戶的需求有細微的區別。用戶訪問量:SaaS 需要滿足幾千家客戶業務需求,要支撐這些訪問的可靠性,需要有一個可靠的集群。在私有部署的環境裡,服務只需要滿足 1 家客戶的業務需求,客戶對資源有一定的預算。運維方式不同:SaaS 產品有公司專業的 SRE 負責,他們有非常豐富的故障處理經驗,每個服務也有非常固定的上線流程。在私有環境裡,客戶可能不太熟悉服務的架構,服務升級會相對困難,處理故障不會那麼快速。

針對上面的這些主要問題,我們在服務端分成 3 個方向去解決:

減少服務個數減少中間件依賴插件化個性需求

通過使用這 3 個方面的改進,我們能帶來的好處是:降低了服務對最小資源的限制,高可用方案的複雜度,運維的困難性以及滿足部分個性化的部署需求。

接下來,聊一聊 GrowingIO 具體是如何做到的:

1,減少服務個數 – 合併微服務獨立進程

減少服務個數最簡單直接的方式是,將原本在 SaaS 上獨立運行的同構微服務,在私有部署的環境中讓它們合併在一個進程上運行。這種既能獨立又能合併運行的能力,在 Java 領域中已經有成熟的技術:

OSGi – Java 動態模型系統,使用標準化原語,將應用程序以組件的方式自由組合運行。 Servlet 容器技術 – 應用程序使用標準的 Servlet 接口,將自身以 web jar 的方式部署到像 Tomcat, Jetty 等 Servlet 容器中運行。

使用上面任何一種方案,對於現有的系統都存在這幾個問題。首先,需要將現有的服務改造以實現 OSGi 或 Servlet 標準。另外,對於不能合併在一起運行的服務,希望能繼續使用 gRPC 的方式進行服務間的通訊,需要找到一種方式能讓它們支持發布 gRPC 服務。

因此,我們需要設計一個模塊化系統,既能達成讓服務一起運行的目標,又只花少量的時間改造。為了讓效果更有成效,對這個模塊化系統做了幾點要求:

使用相同HTTP 框架的服務(我們使用的框架有:akka-http, undertow, netty)共同使用一個HTTP 端口所有服務共同使用一個gRPC 端口運行在同一個JVM 進程上的服務間通訊由gRPC 遠程調用切換成堆棧調用不同JVM 之間的服務調用方式保持不變

如下圖所示:

GrowingIO 微服務 SaaS 與私有部署運行實踐 1

要實現這個模塊化系統,我們需要想清楚一個問題:服務的本質是什麼?在我看來,服務的本質不是一個 HTTP 或 gRPC 接口,服務的本質是真正的業務處理邏輯。 HTTP 或 gRPC 只是對外通訊的一個手段而已。

當清楚服務的本質就是業務邏輯時,模塊化的定義就變得非常清晰。然後,我們構建這個模塊化的運行環境就能使得它們能在一起運行。要構建這個模塊化運行環境不必要知道模塊裡具體實現了什麼邏輯,只需要知道這個模塊初始化需要什麼,依賴了哪些服務,暴露的服務有哪些。根據這些具體的需求,能很清楚地用代碼表示:

trait BoxerModule {
// 打开调试模式
def debug(): Unit

def injector: Injector

// 模块是否暴露 HTTP 服务,以及使用的 HTTP 框架
def httpEngine: Option[ServerEngine]

// 模块暴露的 gRPC 服务集合
def grpcServices: Set[BindableService]

// 模块的后台服务集合
def workers: Seq[ServiceBuilder]

// 模块的启动服务集合
def bootstraps: Seq[ServiceBuilder]

// 注入上下文信息
def aware(context: ModuleContext): Unit

}

trait ServiceBuilder {

def build(injector: Injector): Service

}

// 模块上下文信息
trait ModuleContext {

// gRPC 进程内通道
def grpcChannel: ManagedChannel

// 判断某个 gRPC 服务是否可以使用进程内调用
def isInProcessServiceStub(stubClass: Class[_]): Boolean

}

在上面的代碼中,我們主要描述了以下幾個信息:

模塊的上下文信息模塊暴露的服務集合

接下來,要解決的是將多個服務(也就是 ModuleContext)合併在一起運行。在這之前,我們還需要解決幾個問題:

用什麼樣的方式將多個服務合併在一起?在同一個 JVM 內,怎麼保證模塊之間的既能相互調用,又能保持相互獨立?

GrowingIO 微服務 SaaS 與私有部署運行實踐 2

如上圖,Server A 與 Server B 都有 GlobalConfig 對象,它們的類路徑一致,包含的字段屬性也一致,但是它們字段屬性的值卻不一樣。如果將這兩個服務作為依賴的方式引入到同一個項目裡面運行,這種方式雖然簡單但是沒有解決模塊之間的隔離型問題。會發生 A 會覆蓋 B 或者 B 覆蓋 A 的問題。

那如何做到讓它們保持相互獨立呢?這時候,我們可以使用 JVM 的 ClassLoader 機制。在 JVM 中,兩個非父子關係的 ClassLoader 加載到的內容是相互隔離的。所以,如果我們將不同的模塊使用不同的 ClassLoader 加載就能讓它們保持相互獨立了。如下圖:

GrowingIO 微服務 SaaS 與私有部署運行實踐 3

上圖中的 ClassLoader 構造,可以使用下面的代碼實現:

val classLoaderA = new URLClassLoader(serverA.libs, parent)
val classLoaderB = new URLClassLoader(serverB.libs, parent)

模塊被加載成 ClassLoader 了,初始化模塊就變得簡單許多了。參考 Java 9 模塊化的設計,我們為每個微服務加入了模塊描述文件,如下圖:我們在 growing-insight 服務裡面加入了 BoxerModuleInfo 文件描述模塊內容。

GrowingIO 微服務 SaaS 與私有部署運行實踐 4

上圖中的 SimpleBoxerModule 是默認的模塊內容描述,如果使用者用標準的模式開發服務,那麼他只需要創建一個 BoxerModuleInfo 文件並繼承 SimpleBoxerModule 就能完成模塊註冊。是使用方式及其簡單。

class SimpleBoxerModule extends BoxerModule {

private[this] var moduleContext: ModuleContext = _
private[this] lazy val applicationContext = new ApplicationContextLoader(moduleContext).load(GlobalConfig.Server)

override def debug(): Unit = {}

override def injector: Injector = applicationContext.injector

override def httpEngine: Option[ServerEngine] = applicationContext.httpEngineOpt

override def grpcServices: Set[BindableService] = applicationContext.grpcServices

override def hooks: Seq[ApplicationHook] = applicationContext.hooks

override def workers: Seq[ServiceBuilder] = applicationContext.workers

override def bootstraps: Seq[ServiceBuilder] = applicationContext.bootstraps

override def aware(context: ModuleContext): Unit = moduleContext = context
}

根據模塊內容描述文件,用下面代碼的方式能將 ClassLoader 初始化成一個一個服務模塊:

val loader = new ModuleAppClassLoader(urls, parent)
val boxerModuleInfoClass = loader.loadClass("BoxerModuleInfo")
val module = boxerModuleInfoClass.newInstance().asInstanceOf[BoxerModule]

得到了 Module 對象,就能依據它暴露的服務合併在一起發布一個新的服務列表了。接下來,要解決的是如何讓原本通過遠程調用的 gRPC 服務轉換成進程內堆棧調用。這裡就不得不提在 BoxerModule 定義中的 ModuleContext 了。通過使用它的 isInProcessServiceStub 方法判斷某一個 gRPC 是否可以使用進程內調用,如果可以進程內調用,使用 grpcChannel 初始化 gRPC Stub 就能完成進程內調用了。

以上,是整個模塊化系統設計與實現的核心邏輯。不過還能在 ClassLoader 的架構上更進一步,達到上文中提到的減少資源使用的目標。

服務與服務(即模塊與模塊)之間,除了業務邏輯以及需要隔離的類之外,剩下的框架部分絕大部分是一致的,框架部分的類沒有隔離的必要性。如下圖,將不需要隔離性的框架類通過 BoxerSuiteClassLoader 管理可以提高內存空間利用率,因為 JVM 類加載器有雙親委派機制,ClassLoader 會優先從父 ClassLoader 加載類信息。

GrowingIO 微服務 SaaS 與私有部署運行實踐 5

2,減少中間件依賴 – 去掉中心化服務註冊中心

在 GrowingIO 的 SaaS 環境中,我們使用 Consul +

Dryad(https://github.com/growingio/dryad” 我們開源的一個服務註冊發現與配置管理組件) 實現服務的註冊與發現。為了降低私有部署的環境複雜性,去掉Consul 集群是一個有效的方式。因此選擇了類似於nginx 中upstream 的方式實現服務集群的高可用。私有部署的用戶訪問流量比起SaaS 上具有較強的可預測性,不需要經常性地做動態擴容。所以,使用這種方式並不會帶來特別高的維護成本。

GrowingIO 微服務 SaaS 與私有部署運行實踐 6

如上圖所示,Server A 在本地維護了 Server B 與 Server C 的地址。在使用的過程中根據 Server Name 可以找到服務具體的地址。這一功能,已經在 Dryad 裡開源出來了。使用的過程如下代碼:

定義服務具體的地址

dryad {
cluster {
direct = true // 为 true 则直接使用本地的集群配置,为 false 则通过 consul 的方式注册与发现服务
events = [ // events 是服务的名称
{
address = "10.0.0.1"
port = 8080
},
{
address = "10.0.0.2"
port = 8080
}
]
}
}

使用 io.growing.dryad.cluster.Cluster 的 roundRobin 接口獲取服務信息

val server = cluster.roundRobin(Schema.GRPC, "events")

3,個性化需求 – 插件化

GrowingIO 服務的 SaaS 版本與私有部署版本使用的是同一個代碼分支,在面對私有部署版本中的個性化需求時,使用的是 “插件化” 的思想解決實現不一致的問題。

即使用標準化的接口允許多個不同的實現滿足個性化需求。例如,在 SaaS 版本中為了滿足多租戶數據隔離的需求,有一個專門的服務去管理各個用戶的數據作用域。在私有版本中,我們保留了支持多租戶數據隔離的這個功能,但是簡化了它的實現邏輯。

如下圖,GrowingIO 使用接口 + 多個實現的方式滿足個性化需求:

|- service // 接口定义
| - impl // SaaS 上的标准实现
| - extension // 私有部署上的个性化实现

在服務啟動時,通過配置可以決定需要加載哪種實現,這個過程是自動完成的,不需要在開發過程中乾預。

extension {
enabled = true // 是否按照拓展的方式加载实现
key = "extension" // 加载的个性化需求地址(默认是 extension,如果有多个客户的实现不一样,修改 key 的值即可)
}

總結

本文,我們討論了 GrowingIO 是如何讓微服務支持 SaaS 與私有部署 2 種不同的環境的。要去解決它們之間的兼容問題,就需要找到它們之間的差別。很多時候,這 2 個環境的許多部分都是相反的,我們要在架構設計中自頂向下去包容這種矛盾,不能哪裡有問題就去解決哪裡,這樣只會讓程序越來越複雜。