一文了解 DataLeap 中的 Notebook
精選一、概述
Notebook 是一種支持 REPL 模式的開發(fā)環(huán)境。所謂「REPL」,即「讀取-求值-輸出」循環(huán):輸入一段代碼,立刻得到相應(yīng)的結(jié)果,并繼續(xù)等待下一次輸入。它通常使得探索性的開發(fā)和調(diào)試更加便捷。在 Notebook 環(huán)境,你可以交互式地在其中編寫你的代碼、運行代碼、查看輸出、可視化數(shù)據(jù)并查看結(jié)果,使用起來非常靈活。
在數(shù)據(jù)開發(fā)領(lǐng)域,Notebook 廣泛應(yīng)用于數(shù)據(jù)清理和轉(zhuǎn)換、數(shù)值模擬、統(tǒng)計建模、數(shù)據(jù)可視化、構(gòu)建和訓(xùn)練機器學(xué)習(xí)模型等方面。
但是顯然,做數(shù)據(jù)開發(fā),只有 Notebook 是不夠的。在火山引擎 DataLeap 數(shù)據(jù)研發(fā)平臺,我們提供了任務(wù)開發(fā)、發(fā)布調(diào)度、監(jiān)控運維等一系列能力。我們將 Notebook 作為一種任務(wù)類型,加入了數(shù)據(jù)研發(fā)平臺,使用戶既能擁有 Notebook 交互式的開發(fā)體驗,又能享受一站式大數(shù)據(jù)研發(fā)治理套件提供的便利。如果還不夠直觀的話,試想以下場景:
在交互式運行和可視化圖表的加持下,你很快就調(diào)試完成了一份 Notebook。簡單整理了下代碼,根據(jù)使用到的數(shù)據(jù)配置了上游任務(wù)依賴,上線了周期調(diào)度,并順手掛了報警。之后,基本上就不用管這個任務(wù)了:不需要每天手動檢查上游數(shù)據(jù)是否就緒;不需要每天來點擊運行,因為調(diào)度系統(tǒng)會自動幫你執(zhí)行這個 Notebook;執(zhí)行失敗了有報警,可以直接上平臺來處理;上游數(shù)據(jù)出錯了,可以請他們發(fā)起深度回溯,統(tǒng)一修數(shù)。
二、選型
2019 年末,在決定要支持 Notebook 任務(wù)的時候,我們調(diào)研了許多 Notebook 的實現(xiàn),包括 Jupyter、Polynote、Zeppelin、Deepnote 等。Jupyter Notebook 是 Notebook 的傳統(tǒng)實現(xiàn),它有著極其豐富的生態(tài)以及龐大的用戶群體,相信許多人都用過這個軟件。事實上,在字節(jié)跳動數(shù)據(jù)平臺發(fā)展早期,就有了在物理機集群上統(tǒng)一部署的 Jupyter(基于多用戶方案 JupyterHub),供內(nèi)部的用戶使用??紤]到用戶習(xí)慣和其強大的生態(tài),Jupyter 最終成為了我們的選擇。
Jupyter Notebook 是一個 Web 應(yīng)用。通常認(rèn)為其有兩個核心的概念:Notebook 和 Kernel。
Notebook 指的是代碼文件,一般在文件系統(tǒng)中存儲,后綴名為ipynb。Jupyter Notebook 后端提供了管理這些文件的能力,用戶可以通過 Jupyter Notebook 的頁面創(chuàng)建、打開、編輯、保存 Notebook。在 Notebook 中,用戶以一個一個 Cell 的形式編寫代碼,并按 Cell 運行代碼。Notebook 文件的具體內(nèi)容格式,可參考 The Notebook file format (https://nbformat.readthedocs.io/en/latest/format_description.html)。
Kernel 是 Notebook 中的代碼實際的運行環(huán)境,它是一個獨立的進程。每一次「運行」動作,產(chǎn)生的效果是單個 Cell 的代碼被運行。具體來講,「運行」就是把 Cell 內(nèi)的代碼片段,通過 Jupyter Notebook 后端以特定格式發(fā)送給 Kernel 進程,再從 Kernel 接受特定格式的返回,并反饋到頁面上。這里所說的「特定格式」,可參考 Messaging in Jupyter(https://jupyter-client.readthedocs.io/en/stable/messaging.html)。
在 DataLeap 數(shù)據(jù)研發(fā)平臺,開發(fā)過程圍繞的核心是任務(wù)。用戶可以在項目下的任務(wù)開發(fā)目錄創(chuàng)建子目錄和任務(wù),像 IDE 一樣通過目錄樹管理其任務(wù)。Notebook 也是一種任務(wù)類型,用戶可以啟動一個獨立的任務(wù) Kernel 環(huán)境,像開發(fā)其他普通任務(wù)一樣使用 Notebook。
三、技術(shù)路線
在 Jupyter 的生態(tài)下,除了 Notebook 本身,我們還注意到了很多其他組件。彼時,JupyterLab 正在逐漸取代傳統(tǒng)的 Jupyter Notebook 界面,成為新的標(biāo)準(zhǔn)。JupyterHub 使用廣泛,是多用戶 Notebook 的版本答案。脫胎于 Jupyter Kernel Gateway(JKG)的 Enterprise Gateway(EG),提供了我們需要的 Remote Kernel(上述的獨立任務(wù) Kernel 環(huán)境)能力。2020 上半年,我們基于上面的三大組件,進行二次開發(fā),在字節(jié)跳動數(shù)據(jù)研發(fā)平臺發(fā)布了 Notebook 任務(wù)類型。整體架構(gòu)預(yù)覽如圖。
JupyterLab
前端這一側(cè),我們選擇了基于更現(xiàn)代化的 JupyterLab (https://jupyterlab.readthedocs.io/en/stable/getting_started/overview.html) 進行改造。我們刨去了它的周邊視圖,只留下了中間的 Cell 編輯區(qū),嵌入了 DataLeap 數(shù)據(jù)研發(fā)的頁面中。為了和 DataLeap 的視覺風(fēng)格更契合,從 2020 下半年到 2021 年初,我們還針對性地改進了 JupyterLab 的 UI。這其中包括將整個 JupyterLab 使用的代碼編輯器從 CodeMirror 統(tǒng)一到 DataLeap 數(shù)據(jù)研發(fā)使用的 Monaco Editor,同時還接入了 DataLeap 提供的 Python & SQL 代碼智能補全功能。
額外地,我們還開發(fā)了定制的可視化 SDK,使得用戶在 Notebook 上計算得到的 Pandas Dataframe 可以接入 DataLeap 數(shù)據(jù)研發(fā)已經(jīng)提供的數(shù)據(jù)結(jié)果分析模塊,直接在 Notebook 內(nèi)部做一些簡單的數(shù)據(jù)探查。
JupyterHub
JupyterHub(https://jupyterhub.readthedocs.io/en/stable/) 提供了可擴展的認(rèn)證鑒權(quán)能力和環(huán)境創(chuàng)建能力。首先,由于用戶較多,因此為每個用戶提供單獨的 Notebook 實例不太現(xiàn)實。因此我們決定,按 DataLeap 項目來切分 Notebook 實例,同項目下的用戶共享一個實例(即一個項目實際上在 JupyterHub 是一個用戶)。這也與 DataLeap 的項目權(quán)限體系保持了一致。注意這里的「Notebook 實例」,在我們的配置下,是拉起一個運行 JupyterLab 的環(huán)境。另外,由于我們會使用 Remote Kernel,所以在這個環(huán)境內(nèi),并不提供 Kernel 運行的能力。
在認(rèn)證鑒權(quán)方面,我們讓 JupyterHub 請求我們業(yè)務(wù)后端提供的驗證接口,判斷登錄態(tài)的用戶是否具備請求的對應(yīng) DataLeap 項目的權(quán)限,以實現(xiàn)權(quán)限體系對接。
在環(huán)境創(chuàng)建方面,我們通過 OpenAPI 對接了字節(jié)跳動內(nèi)部的 PaaS 服務(wù),為每一個使用了 Notebook 任務(wù)的 DataLeap 項目分配一個 JupyterLab 實例,對應(yīng)一個 PaaS 服務(wù)。由于直接新建一個服務(wù)的流程較長,速度較慢,因此我們還額外做了池化,預(yù)先啟動一批服務(wù),當(dāng)有新項目的用戶登入時直接分配。
Enterprise Gateway
Jupyter Enterprise Gateway (https://jupyter-enterprise-gateway.readthedocs.io/en/latest/) 提供了在分布式集群(包括 YARN、Kubernetes 等)內(nèi)部啟動 Kernel 的能力,并成為了 Notebook 到集群內(nèi) Kernel 的代理。在原生的 Notebook 體系下,Kernel 是 Jupyter Notebook / JupyterLab 中的一個本地進程;對于啟用了 Gateway 功能的 Notebook 實例,所有 Kernel 相關(guān)的功能的請求,如獲取 Kernel 類型、啟動 Kernel、運行 Cell、中斷等,都會被代理到指定的 Gateway 上,再由 Gateway 代理到具體集群內(nèi)的 Kernel 里,形成了 Remote Kernel 的模式。
這樣帶來的好處是,Kernel 和 Notebook 分離,不會相互影響:例如某個 Kernel 運行占用物理內(nèi)存超限,不會導(dǎo)致其他同時運行的 Kernel 掛掉,即使他們都通過同一個 Notebook 實例來使用。
EG 本身提供的 Kernel 類型,和字節(jié)跳動內(nèi)部系統(tǒng)并不完全兼容,需要我們自行修改和添加。我們首先以 Spark Kernel 的形式對接了字節(jié)跳動內(nèi)部的 YARN 集群。Kernel 以 PySpark 的形式在 Cluster 模式的 Spark Driver 運行,并提供一個默認(rèn)的 Spark Session。用戶可以通過在 Driver 上的 Kernel,直接發(fā)起運行 Spark 相關(guān)代碼。同時,為了滿足 Spark 用戶的使用習(xí)慣,我們額外提供了在同一個 Kernel 內(nèi)交叉運行 SQL 和 Scala 代碼的能力。
2020 下半年,伴隨著云原生的浪潮,我們還接入了字節(jié)跳動云原生 K8s 集群,為用戶提供了 Python on K8s 的 Kernel。我們還擴展了很多自定義的能力,例如支持自定義鏡像,以及針對于 Spark Kernel 的自定義 Spark 參數(shù)。
穩(wěn)定性方面,在當(dāng)時的版本,EG 存在異步不夠徹底的問題,在 YARN 場景下,單個 EG 進程甚至只能跑起來十幾個 Kernel。我們發(fā)現(xiàn)了這一問題,并完成了各處所需的 async 邏輯改造,保證了服務(wù)的并發(fā)能力。另外,我們利用了字節(jié)跳動內(nèi)部的負(fù)載均衡(nginx 七層代理集群)能力,部署多個 EG 實例,并指定單個 JupyterLab 實例的流量總是打到同一個 EG 實例上,實現(xiàn)了基本的 HA。
四、架構(gòu)升級
當(dāng)使用 Notebook 的項目日漸增加時,我們發(fā)現(xiàn),運行中的 PaaS 服務(wù)實在太多了,之前的架構(gòu)造成了
部署麻煩。全量升級 JupyterLab 較為痛苦。盡管有升級腳本,但是通過 API 操作升級服務(wù),可能由于鏡像構(gòu)建失敗等原因,會造成卡單現(xiàn)象,因此每次全量升級后都是人工巡檢檢查升級狀態(tài),卡住的升級單人工點擊下一步。同時由于升級不同服務(wù)不會復(fù)用配置相同的鏡像,所以有多少服務(wù)就要構(gòu)建多少次鏡像,當(dāng)服務(wù)數(shù)量達到一定量級時,我們的批量升級請求可能把內(nèi)部鏡像構(gòu)建服務(wù)壓垮。
JupyterLab 需要不斷的根據(jù)用戶增長(項目增長)進行擴容,一旦預(yù)先啟動好的資源池不夠,就會存在新項目里有用戶打開 Notebook,需要經(jīng)歷整個 JupyterLab 服務(wù)創(chuàng)建、環(huán)境拉起的流程,速度較慢,影響體驗。而且,JupyterLab 數(shù)量巨大后,遇到 bad case 的幾率增高,有些問題不易復(fù)現(xiàn)、非常偶發(fā),重啟/遷移即可解決,但是在遇到的時候,用戶體驗受影響較大。
運維困難。當(dāng)用戶 JupyterLab 可能出現(xiàn)問題,為了找到對應(yīng)的 JupyterLab,我們需要先根據(jù)項目對應(yīng)到 JupyterHub user,然后根據(jù) user 找到 JupyterHub 記錄的服務(wù) id,再去 PaaS 平臺找服務(wù),進 webshell。
當(dāng)然,還有資源的浪費。雖然每個實例很小(1c1g),但是數(shù)量很多;有些項目并不總是在使用 Notebook,但 JupyterLab 依然運行。
穩(wěn)定性存在問題。一方面,JupyterHub 是一個單點,升級需要先起后停,掛了有風(fēng)險。另一方面,EG 入流量經(jīng)過特定負(fù)載均衡策略,本身是為了使 JupyterLab 固定往一個 EG 請求。在 EG 升級時,JupyterLab 請求的終端會隨之改變,極端情況下有可能造成 Kernel 啟動多次的情況。
基于簡化運維成本、降低架構(gòu)復(fù)雜性,以及提高用戶體驗的考慮,2021 上半年,我們對整體架構(gòu)進行了一次改良。在新的架構(gòu)中,我們主要做了以下改進,大致簡化為下圖:
- 移除 JupyterHub,將 JupyterLab 改為多實例無狀態(tài)常駐服務(wù),并實現(xiàn)對接 DataLeap 的多用戶鑒權(quán)。
- 改造原本落在 JupyterLab 本地的數(shù)據(jù)存儲,包括用戶自定義配置、Session 維護和代碼文件讀寫。
- EG 支持持久化 Kernel,將 Kernel 遠(yuǎn)程環(huán)境元信息持久化在遠(yuǎn)端存儲(MySQL)上,使其重啟時可以重連,且 JupyterLab 可以知道某個 Kernel 需要通過哪個 EG 連接。
鑒權(quán) & 安全
單用戶的 Jupyter Notebook / JupyterLab 的鑒權(quán)相對簡單(實際上 JupyterLab 直接復(fù)用了 Jupyter Notebook 的這套代碼)。例如,使用默認(rèn)命令啟動時,會自動生成一個 token,同時自動拉起瀏覽器。有了 token,就可以任意地訪問這個 Notebook。
事實上,JupyterHub 也是起到了維護 token 的作用。前端會發(fā)起一個獲取 token 的 API 請求,再拿著獲取的 token 請求通過 JupyterHub proxy 到真實的 Notebook 實例。而我們直接為 Jupyter Notebook 增加了 Auth 的功能,實現(xiàn)了在 JupyterLab 單實例上完成這套鑒權(quán)(此時,使用了 DataLeap 服務(wù)簽發(fā)的 Token)。
最后,由于所有用戶會共享同一組 JupyterLab,我們還需要禁止一些接口的調(diào)用,以保證系統(tǒng)的安全。最典型的接口包括關(guān)閉服務(wù)(Shutdown),以及修改配置等。后續(xù) Notebook 所需的配置,轉(zhuǎn)由前端保存在瀏覽器內(nèi)。
代碼 & Session 持久化
Jupyter Notebook 使用 File Manager(https://github.com/jupyter-server/jupyter_server/blob/main/jupyter_server/services/contents/filemanager.py) 管理 Contents 相關(guān)讀寫(對我們而言主要是 Notebook 代碼文件),原生行為是將代碼存儲在本地,多個服務(wù)實例之間無法共享同一份代碼,而且遷移時可能造成代碼丟失。
為了避免代碼丟失,我們的做法是,把代碼按項目分別存儲在 OSS 上并直接讀寫,同時解決了一些由于代碼文件元信息丟失,并發(fā)編輯導(dǎo)致的其他問題。例如,當(dāng)多個頁面訪問同一份代碼文件時,都會從 OSS 獲取最新的 code,當(dāng)用戶存儲時,前端會獲取最新的代碼文件,比較該文件的修改時間同前端存儲的是否一致,如果不同,則說明有其它頁面存儲過,會提示用戶選擇覆蓋或是恢復(fù)。
Notebook 使用 Session 管理用戶到 Kernel 的連接,例如前端通過 POST /session 接口啟動 Kernel,GET /session 查看當(dāng)前運行中的 Kernel。在 Session 處理方面,原生的 Notebook 使用了原生的 sqlite(in memory),見代碼(https://github.com/jupyter-server/jupyter_server/blob/main/jupyter_server/services/sessions/sessionmanager.py)。盡管我們并不明白這么做的意義何在(畢竟原生的 Notebook 重啟,一切都沒了),但我們順著這個原生的表結(jié)構(gòu)繼續(xù)前進,引入了 sqlalchemy 對接多種數(shù)據(jù)庫,將 Session 數(shù)據(jù)搬到了 MySQL。
另一方面,由于我們啟動的 Kernel,有一部分涉及 Spark on YARN,啟動速度并不理想,因此早期我們增加了功能,若某個 path 已有正在啟動的 Kernel,則等其啟動完畢而不是再啟動一個新的。這個功能原先使用內(nèi)存中的 set 實現(xiàn),現(xiàn)在也移植到了數(shù)據(jù)庫上,通過 sqlalchemy 來訪問。
Kernel 持久化 & 訪問
在 Remote Kernel 的場景下,一個 JupyterLab 需要知道它的某個 Kernel 具體在哪個 EG 上。在之前一個項目一個 JupyterLab 的狀態(tài)下,我們通過負(fù)載均衡簡單處理這個問題:即一個 Server 總是只訪問同一個 Gateway。然而當(dāng) JupyterLab 成為無狀態(tài)服務(wù)時,用戶并非固定只訪問一個 JupyterLab,也就不能保證總訪問用戶 Kernel 所在的 EG。
另一個情況是,當(dāng) JupyterLab 或 EG 重啟時,其上的 Kernel 都會關(guān)閉。當(dāng)我們升級相關(guān)服務(wù)時,總是需要通知用戶準(zhǔn)備重啟 Kernel。因此,為了實現(xiàn)升級對用戶無感,我們在 EG 這層開發(fā)了持久化 Kernel 的特性。
Kernel Gateway 在啟動 Kernel 時,記錄了關(guān)于 Kernel 的一些元信息,包括啟動參數(shù)、連接 Kernel 使用的 IP/Port 等。有了這些信息,當(dāng)一個 Kernel Gateway 重啟且 Remote Kernel 不關(guān)閉,就有辦法重新連接上。原本這些信息默認(rèn)在內(nèi)存 dict 中維護,開源倉庫中有一套存儲在本地文件的方案;基于這套方案,我們擴展了自研的存儲到 MySQL 的方案。
在多實例的場景下,每一個 EG 實例依然會接管的各自的一部分 Kernel,并記錄每個 Kernel 由誰接管(探活、Cull Idle、連接使用等)。在其關(guān)閉前,需要清除接管信息,以便下次啟動或其他實例啟動時撈起。
為了減少 client(正常是 JupyterLab) 任意訪問 EG 的情況,一方面我們沿用了負(fù)載均衡的策略,另一方面 JupyterLab 在請求 Kernel 相關(guān)操作前,會先請求 EG 一次,由 EG 決定 JupyterLab 具體請求哪一個 EG IP/Port。
當(dāng) EG 服務(wù)本身重啟或者升級時,會在進程退出之前去清除接管信息。當(dāng)頁面繼續(xù)訪問時,JupyterLab 服務(wù)將會隨機分發(fā)相應(yīng)請求,由其它的 EG 服務(wù)繼續(xù)接管。
收益
架構(gòu)升級簡化后,整套 Notebook 服務(wù)的穩(wěn)定性獲得了極大的提升。由于實現(xiàn)了用戶無感知的升級,不僅提升了用戶的使用體驗,運維的成本也同時降低了。
部署的成本也極大地降低,包括算力、人力的節(jié)省。由于剝離了內(nèi)部依賴,我們得以將這套架構(gòu)部署在各種公有云、私有化場景。
五、調(diào)度方案
在前面,我們重點關(guān)注了怎么將 Jupyter 這套應(yīng)用嵌入到 DataLeap 數(shù)據(jù)研發(fā)中。這只覆蓋了我們 Notebook 任務(wù)的頁面調(diào)試功能。實際上,同時作為一個調(diào)度系統(tǒng),我們還需要關(guān)心怎么調(diào)度一個 Notebook 任務(wù)。
首先,是和所有其他任務(wù)類型相同的部分:當(dāng) Notebook 任務(wù)所配置的上游依賴任務(wù)全部運行完畢,開始拉起本次 Notebook 任務(wù)的運行。我們會根據(jù)任務(wù)的版本創(chuàng)建一個任務(wù)的快照,我們稱之為任務(wù)實例,并將其提交到我們的執(zhí)行器中。
對于 Notebook 任務(wù),在實例運行前,我們會根據(jù) Notebook 任務(wù)對應(yīng)的版本,從 OSS 拷貝一份 Notebook 代碼文件,用于執(zhí)行。在具體的執(zhí)行流程中,我們使用了 Jupyter 生態(tài)中的 nbconvert (https://nbconvert.readthedocs.io/en/latest/) 來實現(xiàn)在沒有 Jupyter 應(yīng)用的前提下在后臺運行這份 Notebook 文件,并將運行后得到的結(jié)果 Notebook 文件傳回 OSS。nbconvert 的工作原理比較簡單,且復(fù)用了 Jupyter 底層的代碼,具體如下:
- 根據(jù)指定的 Kernel Manager 或 Notebook 文件里的 Kernel 類型創(chuàng)建對應(yīng)的 Kernel Manager(https://github.com/jupyter/jupyter_client/blob/main/jupyter_client/manager.py);
- Kernel Manger 創(chuàng)建 Kernel Client,并啟動一個 Kernel;
- 遍歷 Notebook 文件里的 Cell,調(diào)用 Kernel Client 執(zhí)行 Cell 里的代碼;
- 獲取輸出結(jié)果,按照 nbformat 指定的 schema 填入 NotebookNode,并保存。
下圖是調(diào)度執(zhí)行 Notebook 的 Kernel 運行流程和通過調(diào)試走 EG 的 Remote Kernel 運行流程對比??梢钥闯?,它們的鏈路并沒有本質(zhì)上的區(qū)別,只不過是在調(diào)度執(zhí)行時,不需要交互式的 Kernel 通信,以及 EG 的這些 Kernel Launcher 使用了 embed_kernel 在同進程內(nèi)啟動 Kernel 而已。走到最底層,它們都是使用了 ipykernel 的(其他語言 kernel 同理)。
六、未來工作
Notebook 任務(wù)已成為字節(jié)跳動內(nèi)部使用較為高頻的任務(wù)類型。在火山引擎,我們也可以購買 DataLeap,即一站式大數(shù)據(jù)研發(fā)治理套件,開通交互式分析的版本,使用到 DataLeap 的 Notebook 任務(wù)。
有的時候,我們發(fā)現(xiàn),我們有比 Jupyter 社區(qū)快半步的地方:比如基于 asyncio 異步優(yōu)化的 EG;比如給 Notebook 增加 Auth 能力。但社區(qū)的發(fā)展也很快:比如社區(qū)將 Jupyter 后端相關(guān)的代碼實現(xiàn),統(tǒng)一收斂到了jupyter_server;比如 EG 作者提出的 Kernel Provider 方案,令jupyter_server可以直接支持 Remote Kernel。
因此我們并未就此止步。目前,這套 Notebook 服務(wù)和 DataLeap 數(shù)據(jù)研發(fā)的其他前后端服務(wù),仍存在著割裂。未來,我們希望精簡架構(gòu),實現(xiàn)徹底的整合,使 Notebook 并非以嵌入的形式融合在 DataLeap 的產(chǎn)品中,而是使其原生就在 DataLeap 數(shù)據(jù)研發(fā)中被支持,帶來更好的性能,同時又保留所有 Jupyter 生態(tài)帶來的強大功能。另一方面,隨著 DataLeap 數(shù)據(jù)研發(fā)平臺對流式數(shù)據(jù)開發(fā)的支持,我們也希望借助 Notebook 實現(xiàn)用戶對流式數(shù)據(jù)的探索、調(diào)試、可視化等功能的需求。相信不久的將來,Notebook 能夠?qū)崿F(xiàn)流批一體化,來服務(wù)更加廣泛的用戶群體。