小心 Serverless 陷阱

技術樂觀主義陷阱

技術具有商品屬性,這是常常被我們忽略的一個事實。且不談壟斷之後帶來的商業利益,一方面技術依賴市場的認可來彰顯它的價值,另一方面技術還需要依靠大衆的反饋才得以完善自己,所以龐大的用戶羣體是它繁榮的基石,它需要儘可能的爲人所知。無論你是想吸引更多的項目和開發者加入某個社區中,還是想讓某個框架擺脫默默無聞乃至脫穎而出,過程都務必依賴於大量的運營活動,其中不少也要倚靠背後大廠的資源投入。從近乎壽終正寢的 Silverlight 到近些年大火的 Flutter,無不遵循着類似的模式。

既然是面向大衆的商品,商家必然會以利益相關者的姿態爲其辯護和吶喊,這無可厚非。但在此影響之下,當技術人員對某項技術進行調研或者在被動接收來自行業內的更新時,得到的信息會不知不覺的向積極側偏移,這對技術人員來說未必是好事情。因爲我們很難分辨感官裏的哪一些是事實,哪一些是觀點,哪一些是有條件成立,更重要的是還有哪一些是它沒有告訴你的。

Serverless 就是其中一個例子。

這篇文章不是對 serverless 的批評。Serverless 是雲原生架構(Cloud Native )下水到渠成的必然產物,從 IaaS(Infrastructure as a Service) 到 Paas(Platform as a Service) 甚至再到 Saas (Software as a Service),我們看到的是運維能力不斷外包的遷移過程,這有助於塑造精銳團隊專注於交付業務價值以及靈活應對市場變化——爲什麼我們要千篇一律的寫登陸註冊模塊?如何才能將代碼的維護成本降至最低?Serverless 便是在這些前提下誕生的。但 Serverless 只是其中一種解決方案(a solution),而非唯一的解決方案(the solution),更重要的是這篇文章會讓你意識到它絕非是方案中的理想首選。

例如在每一篇介紹 serverless 的文章中,都一定會提到因爲冷啓動緣故導致 serverless 函數具有較慢的首次響應時間問題,但它們能夠提供的信息通常到此便戛然而止了,這無法給我們帶來任何幫助,我們也不會對它產生任何的警惕。如果我繼續告訴你不同供應商的延遲各不相同,我所在項目中 Azure Serverless 的第一次啓動延遲可以長達 6 秒,那麼我相信此時你會更慎重的看待這條信息,並開始降低對於它作爲 web server 的預期。

本文想強調的另一點是,雖然 serverless 看似是近幾年才誕生的 “新” 技術,但它背後遵循最佳實踐依然是 “舊” 世界下人們早已達成的共識;在實際將它應用到現有產品的過程中,你需要關心內容與前 serverless 時代也並無二致。例如在 OWASP 整理出的有關 Serverless 排名前十的安全問題中,我不認爲有哪一則是 serverless 架構 “獨享” 的。Serverless 與傳統服務相比的優勢之一可能是前人的寶貴經驗被固化到了平臺和產品形態之中,用以確保你不必再走彎路。

考慮到通識性,本文主要使用 Azure 和 AWS 旗下的 serverless 服務對問題進行說明。

被輕視的供應商鎖定(vendor lock-in)

供應商的三道鎖

供應商鎖定在雲原生架構下是無法避免的問題,如果你選擇 Azure 作爲你的雲服務提供商,那麼你大概率會順帶選擇 Azure Blob Storage 而不是 AWS S3 作爲你的存儲服務,因爲來自於同一個供應商下的服務契合度更高,維護起來更容易。同樣考慮到成本和風險,自此之後更換服務的可能性也幾乎爲零。

好在編程語言和編程框架依然通用無界,加之容器化技術早已成熟,在開發常規業務代碼的方面供應商並沒有給我們造成太大的困惱。此時的供應商只充當配角 ,無論你是選擇 AWS EventBridge 還是 Azure Event Grid,背後 Event-Driven 的決策不會發生改變,核心的業務代碼不會受到影響,用 ExpressJS 寫出的代碼在不同服務商之間仍可複用。這種模式最明顯的特點是業務人員可以專心開發業務代碼,它們不用關心公司購買的是哪家提供商的產品。雖然聽起來有些反模式,但代碼與環境的適配可以全權交給運維人員去處理的這條路是可行的。

而 serverless 模式恰恰相反,它的崛起像是一道命題作文,在概念先行的前提下不同的供應商根據自己現存基礎設施優先推出自己的解決方案。對於這一點有意思的是,如果你現在去看市面上講解 serverless 的技術圖書,書中談及的概念和代碼實施方案一定是圍繞某個單一平臺編寫的。

Serverless 中有一個很重要的概念正是這方面的體現:trigger.

顧名思義,trigger 是 function(本文的 function 泛指廣義各個雲平臺上 serverless 的實現代碼,同時代指 Azure Function 和 AWS Lambda)的觸發器,由它來負責啓動 function。例如對於一個響應前端請求的 function 而言,http 請求就是它的 trigger。

但在 serverless 生態中,http 是最不重要的。你不妨回想一下我們最經典的 serverless 用例,離線創建略縮圖:在該流程中需要有 function 響應處理略縮圖的消息,在存儲之後需要有 function 將數據更新進數據庫中。其中的消息服務和存儲服務就是 function 的 trigger。

此時不難發現當你開始編寫 function 時,你需要確認你的雲供應商提供這類服務的具體產品是什麼,消息服務在 Azure 中可以是 Azure Service Bus,但是到了 AWS 則變成了 Message Queuing Service。不同服務提供的 API 和模型不盡相同,同時代碼與服務集成的方式也是量身定做的,這是第一層鎖。

其次爲了在 function 代碼中訪問這類服務,裸寫的代碼是不被允許的,因爲你需要在訪問服務時用指定的方式傳遞 API Key,通常解決這個問題的辦法是直接集成供應商提供的 client SDK,比如 @azure/service-bus 或是 AWS SDK。事實上從接收到請求的那一刻起,代碼差異就已經註定了,雖然 Azure 和 AWS 都同意以 event handler 函數的形式來響應 trigger 的請求,但兩者的函數簽名差異明顯,你能取得的函數所在的上下文也各有千秋。這是第二層鎖。

這兩者看上去似乎把硬件和軟件層面都覆蓋到了,最重要的 “隱形鎖” 卻無形中被忽略了——那就是供應商的意志,即它們希望你以什麼樣的方式去設計和編寫 function。

以 API 架構爲例,Azure 提供的服務比如 Azure Serverless 或者是 App Service 可以是相互獨立的,哪怕你只購買其中的一項服務,你也可以單獨爲其配置 API Management, Identity 等屬性。服務被允許對外暴露 HTTP 端口。在其官網給出的架構模式中,移動端設備可以直接訪問 Azure Serverless 服務。而在 AWS 中,服務的職責更爲垂直,而非 Azure 般全能。HTTP 端點大多要被託管在 API Gateway 上,它爲你提供了豐富的功能,比如權限驗證、日誌監控、緩存等等。同樣在 AWS 官網給出的後端架構模式中,移動設備的請求必須要經過 API Gateway。在 Azure Serverless 中每一個 serverless 項目都有屬於自己的配置文件 host.json,如果我們想要限制 function 處理的最大請求數,你只需要修改該文件的配置項即可:

{
    "extensions": {
        "http": {
            "routePrefix": "api",
            "maxConcurrentRequests": 100,
            "customHeaders": {
                "X-Content-Type-Options": "nosniff"
            }
        }
    }
}

上面代碼中的 maxConcurrentRequests 就能用來控制併發請求數。

而在 AWS 中,對於同步的 HTTP 端請求,官方建議你可以通過 API Gateway 限流功能(throtting)和設定 AWS WAF 規則來實現。

**這層鎖的危害在於你必須從一開始就在供應商的框架內來設計自己的解決方案。**在 AWS 中你當然可以不選擇 API Gateway 的 Lambda authorizer 功能作爲 function 權限校驗的解決方案,但我不確定其他路會讓你繞多遠。

即使你沒有接觸過 Lambda authorizer 也沒有關係,我後面會有詳細的講解。在後面的章節我們也會看到,在抱怨它的同時我們不得不承認它背後遵循的依然是業內的最佳實踐,我們看似無路可選,但實際上我們唯一能走的恰恰是前任留下的捷徑。

解 “鎖”

好消息是在這一層可見的危機面前我們依然有能夠緩和的餘地。

2019 年 Thoughtworks 剛好發佈了一篇關於如何避免 serverless 供應商鎖定的文章 Mitigating serverless lock-in fears,文章從硬件到軟件層面都給出了很多減少遷移成本的建議。但在我看來其中最爲實用的一則是:爲程序設計一組好的架構。

雖然低入門門檻是 serverless 不爭的賣點之一,但是它的天花板依然可以達到傳統技術棧程序相同的高度,一脈相承的優秀設計可給予後期維護上的便利。

例如一個對外發送郵件的用例首先採用 Azure Serverless Function 編寫,我們在 httpTrigger 入口函數中可以直接引用 Azure SendGrid SDK 執行發送服務

import * as SendGrid from "@sendgrid/mail";
SendGrid.setApiKey(process.env["SENDGRID_API_KEY"] as string);

const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise<void> {

const email = {
  to: 'test@example.com', // Change to your recipient
  from: 'test@example.com', // Change to your verified sender
  subject: 'Sending with SendGrid is Fun',
  text: 'and easy to do anywhere, even with Node.js',
  html: '<strong>and easy to do anywhere, even with Node.js</strong>',
}

await SendGrid.send(email);
}

之後如果想將它遷移至 AWS Lambda 的話,發送郵件部分需要完全替換爲調用 AWS 的 SES 服務:

import { SendEmailCommand }  from "@aws-sdk/client-ses";
import { sesClient } from "./libs/sesClient.js";

// Set the parameters
const params = {
  Destination: {},
  Message: {},
};

const data = await sesClient.send(new SendEmailCommand(params));

但事實上我們並不關心誰在爲我們提供郵件發送服務,無論是 SendGrid 或者 SES 功能上並無差別。所以在設計這個程序時,我們完全可以提取一個公共的 email client,讓 httpTrigger 入口函數調用 client 即可:

import emailClient from "./email-client";

const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise<void> {

// ...
await emailClient.send(email);
}

那麼在遷移的過程中,入口函數幾乎無需改動,更改只發生在 client 中,我們也只需對 client 重新測試驗證即可。如果你使用的是 C#,我們甚至可以將 EmailClient 抽象爲一個接口注入後使用。說白了我們又回到了分離關注點,甚至可以說是六邊形架構的老路。

針對接口編程還有一個優勢——便於我們進行組件測試。

我們可以把上面的流程擴展一下,再被 trigger 之後首先需要從 KeyVault 中獲取用於使用 SendGrid 的 API_KEY,在發送完畢 SendGrid 之後再使用 Application Insights 記錄日誌,流程如下圖所示你可能有興趣對虛線框內整套功能進行 E2E(端到端)測試,這並非無法實現,但是難且代價極大。它的難首先體現在 E2E 本身的測試性質上,如果你對測試金字塔還有印象的話,處於金字塔頂端的 E2E 測試無論是運行成本還是維護成本都是最高的;其次由於 serverless 第三方提供服務的差異性,你很難在每個人的本地搭建出一套線下穩定的測試環境來,由此產生的不確定和對線上環境的依賴有悖於我們對於測試能夠快速反饋和重複執行的期望。

所以我建議在 serverless 中從代碼中抽象出服務層(Service Layer),優先針對服務層進行測試。服務層是應用的邊界和對業務邏輯和用例的封裝,即使發生技術棧遷移它也應該是最不被影響的功能,它應該作爲測試中的一個風險點。而服務層打交道的對象不再是具體的供應商服務而是抽象的接口,這也便於我們在針對服務層的測試中對依賴進行 mock,優化測試流程。

Serverless 裏的舊酒

身份驗證

無論你使用什麼樣的技術棧,微服務、Serverless、Low-Code 等等,認證(Authentication)和授權(Authorization)始終是你無法逃避的問題。但不同技術棧下解決授權問題的模式並無不同。在這裏先統一一下語言,以下用 “驗證” 同時代指 “認證” 和“授權”。

以微服務架構爲例,服務於接口背後的每一組微服務不可能都擁有獨立的驗證機制。如果你執意這麼做的話需要解決不僅限於以下的問題:

所以通常我們會在系統的邊界(Edge Layer)進行驗證。我們對邊界外的一切調用保持懷疑態度,對邊界內的服務無條件信任,這被公認爲業內的最佳實踐。

而這種邊界在現代企業架構中的化身就是 API Gateway。值得強調的是身份驗證只是 API Gateway 承擔的其中一項職責,實際上 API Gateway 能做的遠不止於此。

AWS Lambda 的官方驗證機制亦是如此:在上圖中最左側的 client 的請求必須經過 API Gateway 的驗證之後纔可以繼續訪問後續的 Lambda 或者是 EC2 服務。

在回答了 “在哪裏驗證” 這個問題之後,借上面的流程我們要繼續回答第二個問題:如何驗證。

鑑於上面論述的每個服務 / 函數都不應該各自實現一遍驗證功能,AWS API Gateway 爲我們準備了驗證機制 custom authorizer (也可以稱之爲 lambda authorizer,因爲 authorizer 由 lambda 函數實現),它的工作原理如下:

從上述流程中不難看出驗證通過與否決定自 authorizer 的代碼實現。但無論你是利用 JWT 還是 SAML 進行驗證,背後遵循的依然是傳統 OAuth 的經典流程。我不想對 OAuth 着過多筆墨,下面的流程圖也許能喚起你的不少回憶在上述 AWS 的身份驗證流程中,當 client 在向 AWS Lambda 發送請求時,我們首先需要向 Authorization Server 驗證身份之後才允許將資源返回給 client。不難看出 authorizer 是流程圖中步驟 6 的體現

我要對潛在的 “錯誤” 做一個解釋:你可能會認爲 OAuth 並不適用於 AWS API Gateway 這類情況,因爲 OAuth 本質上是針對 “授權” 操作設計的,即決定你能夠訪問哪些資源;而 API Gateway 的例子像是 “認證” 場景,即你是否是合法用戶。

你對於 OAuth 的理解是對的,藉此我們不妨繼續對 OAuth 進行一次深入說明:OAuth 實質上是一則委託協議,它開放了軟件程序以用戶的姿態訪問第三方資源的一種可能。OAuth 中的 client 不是指擁有這些資源的用戶,而是被用戶委託的應用程序,比如再網易相冊需要訪問用戶的谷歌網盤裏的照片的場景中,client 其實代指的是網易相冊。正因爲它只關心授權資源,所以它可以不用關心誰以及用什麼方式授權的這些資源。殘酷點說網易相冊只關心它是否能夠獲取到允許它調用 Google API 拿到文件信息 token 而已。

回到 API Gateway 的例子中,API 所代表的資源通常是公用的,自然作爲資源擁有方的 AWS 無需關心背後的 client 是誰,也無權限制用戶可能授權給多少個應用,它只關心請求裏的驗證憑據。從這個角度上說,lambda 的驗證工作與 OAuth 不謀而合。

如果對 OAuth 再做一次抽象的話,我們可以將它稱之爲 “基於 token 的身份驗證機制(token-based authentication)”。和傳統的用戶名密碼授權驗證方式相比會帶來以下優勢:

例如 Azure Serverless 就支持存粹基於 token 的驗證方式,在你將 HttpTrigger 的 authLevel 參數設置爲 function 之後,需要從 UI 上獲取 Function Key 值並將其放入名爲 x-function-key 的 http header 中才得以讓請求抵達 function,否則你將得到 401 返回結果可以看出在解決 serverless 場景下的身份驗證問題時,我們仰仗的依然是前人留下的寶貴財富。

部署 Serverless

最後簡短的提一下 Serverless 的部署問題

靈活和輕量是 serverless 主打的賣點之一,超級便捷的部署方式便是這一系列特性的最佳體現。例如 AWS 支持通過上傳 zip 文件部署 Lambda;Firebase 支持通過 CLI 部署 Cloud Function;而 Azure Serverless 則爲 VSCode 開發了 Azure Functions 插件,允許你在 IDE 中開發過程中一鍵部署 Azure Function。

如果你對這些手段的改進僅僅理解爲免去了繁瑣的步驟便於我們可以更快速的將代碼部署到生產環境的話,那麼我建議你還是不要使用這些手段爲妙。因爲在軟件交付的過程中純手工的部署行爲是一類反模式行爲:這種一步到位的手工部署意味着你必須用手工測試的方式驗證功能是否正常,同時未經試運行環境的檢測而直接部署到生產環境的話,會導致我們無法驗證在開發環境中產生的假設在生產環境中是依然成立的,甚至在發生問題之後沒有配套機制保證我們的代碼回滾到上一個穩定版本。我們可以引用《持續交付》一書中的話對理想中的持續交付進行歸納:軟件發佈能夠(也應該)成爲一個低風險、頻繁、廉價、迅速且可預見的過程。

所以 serverless 的交付環節依然需要被管理,例如配置管理、編譯、自動化測試、灰度發佈等等過程對 serverless 仍然適用。那爲什麼 serverless 服務商不繼續邁出一步爲我們提供更豐富的交付解決方案呢?這個問題的答案既是肯定的也是否定的。

肯定回答的理由是,現有平臺工具早已支持我們達成此類目標。例如 Azure DevOps 平臺支持我們爲 serverless 應用創建 pipeline 以及管理每一次構建後的 artifact;Azure Serverless 也支持灰度發佈(Deployment slots),你可以選擇首先將構建後的代碼首先發布到 staging 環境上,待驗證無誤後一鍵將現存的 production 代碼替換。

而否定答案也同樣成立的理由是因爲所有這些配套設施都並非只爲 serverless 精心定製,幾乎所有 Azure 提供的服務都能通過 Azure DevOps 平臺進行部署,所有服務在 Azure DevOps 上被一視同仁對待。

所以不難看出 Azure DevOps 出售的並不是預置好的標準化流程,而是支持定製化的公共能力。它們之所以止步於此不是因爲代編碼能力有限,而是因爲無法在代碼層面做進一步抽象。

如果你對編碼稍有經驗的話,你應該明白在軟件開發中最困難的不是編碼環節,而是在於前期的程序設計,以及將各類模式恰如其分的融入其中。然而你也應該理解,哪怕是耳熟能詳的 MVC 模式,在不同編程語言下的含義也不盡相同,甚至在同一種編程語言下實現也可以不同(例如 Angular 的雙向綁定模式之於 Backbone.js 的事件機制)。我們無法用一成不變的代碼精準對其定義。

持續交付知識也具有類似的性質,大部分時候我們需要有的放矢的爲項目設計交付流程。例如爲團隊選擇恰當的 Git 工作流,判斷是否有必要爲項目添加冒煙測試等等。這些都是無法通過代碼計算出來的,這部分工作往往也是最難的,因爲你需要對項目進行評估以及團隊溝通之後才能將方案確定下來。正是因爲 DevOps 環節裏存在太多不確定因素,項目之間千差萬別,平臺能做的也只能是在衆多因素之間尋找最大公約數(所有的項目都需要部署,都需要灰度發佈,都需要環境變量管理),又或者將把所有可能性打包作爲公共能力提供給你(比如 Azure DevOps 平臺)

尾聲

因爲篇幅的關係我們只能談論到這裏,希望你能從上述的文字中勾勒出一個有關 serverless 更清晰的輪廓。我們很難說服自己使用 serverless。

關於雲原生社區

雲原生社區是國內最大的獨立第三方雲原生終端用戶和泛開發者社區,由 CNCF 大使、開源意見領袖共同發起成立於 2020 年 5 月 12 日,提供雲原生專業資訊,促進雲原生產業發展。雲原生社區基於成員興趣創建了多個 SIG(特別興趣小組),如 KubernetesIstioEnvoyDaprOAM邊緣計算機器學習可觀察性穩定性安全等。點擊瞭解我們

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/5OOhnVEql6CKV5IwhGgQ6w