cnpm rapid 極速模式實現(xiàn)原理
?????♀? 編者按:本文是螞蟻集團 Node.js 工程師天玎在 NodeParty 上的分享內(nèi)容,介紹了 cnpm rapid 模式的實現(xiàn)原理,以及如何通過集成 cnpm rapid 模式帶來的 npmfs 來加速 npm 依賴安裝,歡迎查閱~

背景
我們在半年前的 SEE Conf ?中分享了《一種秒級安裝 npm 的方式》,現(xiàn)在我們正式將 cnpm rapid 開源了。接下來我將深入介紹 cnpm rapid 模式的實現(xiàn)原理,以及如何通過集成 cnpm rapid 模式帶來的 npmfs 來加速 npm 依賴安裝。
目錄
本次分享分為三部分,分別是 “cnpm rapid 模式剖析”、“企業(yè)如何集成 rapid 模式加速 CI/CD”、“如何參與 cnpm rapid 開源貢獻”。

cnpm rapid 模式剖析
首先,先來看下 cnpm rapid 模式對比其他 npm 安裝器的性能。

我們使用以下基準測試環(huán)境來進行測試。

測試的結果如下,對比性能最慢的 npm,我們的安裝速度提升了 10 倍,即使對比最快的 pnpm 和 常規(guī)模式的 cnpm,我們也能有 3 倍的安裝速度提升。

理解我們?nèi)绾巫鲂阅軆?yōu)化,可以從問題入手。我們來看下 npm 有哪些性能瓶頸。
我們知道一次依賴安裝主要分為下面幾個步驟:
依賴樹生成;
依賴包下載;
依賴包解壓縮到 node_modules 目錄;
其他腳本執(zhí)行,文件權限變更等操作。

我們分別來看下前面最核心的三個流程,有哪些性能瓶頸。還是以基準測試舉例,安裝一個內(nèi)網(wǎng) @alipay/smallfish 包:
在生成依賴樹的過程中,我們需要 2211 次 registry 請求,獲取所有依賴的版本信息;
緊接著,我們根據(jù)生成的依賴樹去下載 2211 個依賴安裝包;
下載完依賴包之后,我們需要將依賴包解壓到項目 node_modules 目錄。

首先看,我們?nèi)绾蝺?yōu)化網(wǎng)絡請求。npm 生成依賴樹是通過遞歸請求 registry 信息。

我們內(nèi)部提供了服務端生成依賴樹服務,服務里面通過內(nèi)存緩存,分布式緩存,將內(nèi)部常用的依賴元信息進行緩存,省去了傳統(tǒng)客戶端生成依賴樹時,都需要去 DB 查詢的性能損失。這樣對比客戶端依賴樹生成,服務端依賴樹生成只需要一次 http 請求,服務端通過兩級緩存大幅度提高了依賴樹生成的速度。

再來看,我們?nèi)绾蝺?yōu)化磁盤 IO。一個 npm 包從下載到寫入磁盤是以下流程。以@alipay/smallfish 為例,會解壓縮 smallfish.tgz 包,展開 6 個文本文件,將文件寫入 node_modules。這個過程還包含目錄的創(chuàng)建。

那么,我們來看寫入流程。當我們將依賴包從 OSS 下載并寫到磁盤時,會使用 Node.js 的 API fs.writeFile,而 fs.writeFile 是 write syscall 的 Node.js 封裝。我們每次調(diào)用 fs.writeFile 對應底層一次或多次的 write syscall,這個次數(shù)取決于文件大小和單次 write syscall 寫入數(shù)據(jù)量。如果單次 write syscall 寫入量沒有寫滿,就會浪費一次系統(tǒng)調(diào)用,尤其在小文件寫入時。

于是同樣寫入 100MB 的一個大文件,和寫入 102400 個 1KB 的小文件,后者性能會變得非常差。

我們知道 npm 包分發(fā)是通過 tar.gz 文件格式將多個文件歸檔成一個文件進行的,而經(jīng)過統(tǒng)計,npm 包大小中位數(shù)在 16KB。解壓縮后會膨脹出數(shù)量眾多的小文件。于是大量寫入小文件,IO 性能就會急劇下降。

既然寫入一個大文件的速度比寫入同等大小的多個小文件的速度快,結合 npm 包是通過 tar.gz 文件格式進行分發(fā)。那么,我們是不是可以直接把 npm 包的 tar.gz 文件寫入磁盤這樣我們的寫入性能是不是就快很多。那么問題就變成,不解壓展開文件。

還是以 @alipay/smallfish 為例,我們的優(yōu)化磁盤 IO 的手段就變成直接將 2211 個 tar.gz 包寫入磁盤。當然實際操作中,我們會將 tar.gz 包解壓縮成 tar 進行存儲,因為我們需要讀取 tar.gz 里面的 entry 進而獲取真正的 npm 包產(chǎn)物。

進一步,我們知道 tar 是可以無限在文件末尾增加新的 entry。

既然我們已經(jīng)有 2211 個 tar 包,那是不是可以在寫入磁盤的時候,把 2211 個 tar 包拼接成 1 個 tar 包,這樣就只需要處理 1 個 tar 包的寫入,IO 性能進一步大幅提升。理論如此,但是實際上,我們的寫入流是伴隨著下載流同步進行的,前面提到單個 npm tgz 的中位數(shù)是 16KB,寫成一個 tar 包當然會大幅提高 IO 性能,但是我們的網(wǎng)絡帶寬就沒辦法占滿,經(jīng)過測試,我們選擇了 40 個線程做并發(fā)的下載和寫入,這樣我們就需要 40 個 tar 包來存儲所有的依賴。

現(xiàn)在問題來到我們有 40 個 tar ?包,里面包含所有 smallfish 項目的依賴文件。但是 tar 不展開,我們是沒辦法構建標準的 node_modules 文件目錄的,項目也就跑不起來。

本質(zhì)上,無論 tar 還是 node_modules 都是通過文件系統(tǒng)來管理的,上層 Node.js 讀寫文件,也是通過調(diào)用標準的文件系統(tǒng) API 來實現(xiàn)的,例如前面提到的 write syscall,那么我們是否可以構建一個不一樣的文件系統(tǒng),底層讀 tar,上層構造出 node_modules。事實上這是另外一個非常通用的文件系統(tǒng)技術,F(xiàn)USE,也即用戶態(tài)文件系統(tǒng)。區(qū)別于普通的文件系統(tǒng),F(xiàn)USE 需要上層的 hello 程序來托管 /tmp/fuse 掛載點,通過 libfuse 庫跟 libc 來交互,libc 再調(diào)用內(nèi)核 FUSE 實現(xiàn),來進行文件的讀寫。

我們需要一個能通過 FUSE 構造用戶態(tài)文件系統(tǒng)的項目即上圖中的 hello 程序,這里我們采用了螞蟻集團和阿里云共同開源的項目 Nydus,這個項目主要是給云原生時代,鏡像加速做文件系統(tǒng)使用,但對我們來說,只要能構造文件系統(tǒng)就足夠。

于是,我們的項目架構就變成底層是 tar buckets 來托管原始的 npm 包文件,通過 nydus 構造出文件系統(tǒng)來保證 node_modules 是原生的文件系統(tǒng),具備完整的文件系統(tǒng)操作。

但是這里還有一個問題,我們現(xiàn)在只通過 nydus 給用戶提供了一個 node_modules 文件目錄。那用戶如果需要 debug,修改 node_modules 中的文件,或者依賴安裝時,會進行 preinstall/install/postinstall 等操作,顯然作為全局的 tar buckets,我們既不能隨意改動里面的文件數(shù)據(jù),否則另外一個項目就可能拿到的不是預期內(nèi)的文件,同時 tar 的成本會很高,改一個 entry,就意味著 nydus 構造出來的文件系統(tǒng)需要重新構建,因為讀取數(shù)據(jù)的元信息變了。這就意味著 nydus 構造的文件系統(tǒng)一定是只讀的。那么我們怎么實現(xiàn)寫呢?
這就要引入另外一個技術,overlay。這個技術是可以將兩個文件系統(tǒng)合并成一個文件系統(tǒng)。如下圖,overlay 分為三層目錄:lower、upper 和 merged。三者的合并邏輯是,在 upper dir 進行的,針對 lower dir 同名文件的操作,都會被覆蓋掉,最終體現(xiàn)在 merged dir。舉個例子:
File1 在 lower 和 upper dir 都存在,那么最終 merged dir 里面的 File1 會是 upper dir 的文件;
File2 ?在 upper dir 被刪除,即使 lower dir 仍存在著個文件,最終 merged dir File2 也會被刪除;
如果 upper dir 沒有 File3 ?或者 lower dir 沒有 File4,那么 merged dir 會直接使用這個文件。
從上面這個例子可以得到一個啟發(fā):既然 nydus 構造的文件系統(tǒng)是只讀的,那么我們只要再構造一個可寫的文件系統(tǒng),然后通過 overlay 合并,我們就能得到一個可讀可寫的 node_modules 目錄。這里為了簡單我們可以直接使用 tmpfs 構造一個 upper dir,nydus 基于 tar buckets 構造的目錄為 lower dir。這樣我們就完美的構造出了一個可讀可寫的 node_modules 目錄。
回過頭,我們來看下社區(qū)現(xiàn)有安裝器存在的一些體驗問題。
npm 慢;
cnpm 解決了速度問題,但是增加的軟鏈部分破壞了社區(qū)生態(tài);
yarn 在上層代理了模塊查找,更是大幅度的破壞了社區(qū)生態(tài),需要對社區(qū)內(nèi)的項目,尤其是構建框架進行定制才行;
pnpm 則是目前來講,更加成功的項目,但是通過硬鏈全局緩存,導致模塊修改影響全局項目,體驗并不好。
而 cnpm rapid,通過潛到更底層的文件系統(tǒng),在下載速度更快的同時,將上層的不兼容文件一一規(guī)避,帶來默認良好的社區(qū)兼容性。
集成 rapid 模式加速 CI/CD
那么如何通過集成 cnpm rapid 模式來加速我們的 CI/CD 服務呢,下面第二部分,我將給大家分享下,怎么在 CI/CD 流中集成 cnpm rapid ?模式。
回過頭,我們看 cnpm rapid 模式的安裝流程就核心的三部分:
服務端生成依賴樹;
客戶端基于依賴樹去高速下載包,并合并成 tar 寫到磁盤;
然后基于 nydus 和 overlay 我們構造了一個可讀可寫的文件系統(tǒng)。
那么我們的改造流程也涉及這幾部分,依賴樹生成,高性能的下載器,鏡像改造。
首先看依賴樹生成服務,我們知道 npm 是通過 @npmcli/arborist 來生成依賴樹的,那么為了得到同樣的依賴樹,我們也可以使用這個包來進行依賴樹生成。區(qū)別是,我們將依賴樹生成服務放到服務端,通過內(nèi)存緩存和分布式緩存來提高依賴元數(shù)據(jù)的緩存命中率,進而提高依賴樹的生成速度。
再來看安裝器改造,簡單做可以直接集成 npminstall,這里包含完整的依賴安裝流程。
如果你有內(nèi)部定制的安裝器,可以集成 npmfs。我們看 npminstall 是如何集成 npmfs 的。一個 rapid 模式的安裝過程如下:
首先將 packge.json 發(fā)到服務端,生成依賴樹;
然后調(diào)用經(jīng)過寫入優(yōu)化的下載器,將依賴下載并寫入磁盤;
然后調(diào)用 nydus 和 overlay 來構造 node_modules。
但是注意,我們在構造 node_modules時,還應該執(zhí)行 npm 標準里面的 preinstall/install/postinstall 腳本。
那么結合 node_modules 的構造過程,我們知道 lower dir 是只讀,upper dir 是可寫。按照 npm install 腳本的定義,overlay 構造 node_modules 之前,我們就應該執(zhí)行 preinstall,node_modules 構造之后,我們按照順序執(zhí)行 install 和 postinstall 即可。但是 overlay 的 merged dir 實際上是一個掛載點,掛載的時候,會直接改變這個文件的類型,那么如果簡單的將 preinstall 的結果寫到 node_modules,在構造 node_modules 的時候,這些文件就會被刪除掉。所以我們執(zhí)行 preinstall 就需要在 upper dir 進行。
這樣,我們得到的 node_modules 目錄文件的邏輯就變成
具體的代碼實現(xiàn)如下
再看鏡像改造,我們構造 node_modules 使用了 Linux 的 FUSE 和 overlay 技術,好在現(xiàn)在主流的 Linux 發(fā)行版都集成了兩種技術。我們唯一要做的就是在 docker 容器中開啟 fuse 設備。

這樣我們就獲得了如下的 CI/CD 流

如何參與 cnpm rapid 開源貢獻
講完 ci/cd 流程如何集成 cnpm rapid,我們其實有一點沒有提到,就是個人開發(fā)者怎么使用 cnpm rapid。這里因為 macOS 跟 Linux 的差異,以及 ci/cd 和本地研發(fā)的習慣差異,個人開發(fā)者使用 cnpm rapid 模式還有一些體驗問題。那么我們希望社區(qū)可以一塊參與共建,讓 cnpm rapid 成為性能最高,體驗最好的 npm 依賴安裝器。
那么,假如我們參與共建,肯定是需要了解下項目結構的。因為項目中用到了不少底層的技術,像 FUSE 和 overlay,我們的技術棧也分為 Node.js 和 rust。

項目地址如下,歡迎參與開源貢獻???????????????
cnpm:
https://github.com/cnpm/cnpm
npminstall:
https://github.com/cnpm/npminstall
未來計劃
對于 cnpm rapid 模式,我們的未來計劃主要分為以下三塊。

macOS 體驗主要是在進程?;?,上層文件系統(tǒng)的穩(wěn)定以及保持常規(guī)的研發(fā)體驗一致。

講到這里,不知道大家有沒有一個感受,那就是 npm 社區(qū)確實很繁榮,畢竟現(xiàn)在開源已經(jīng)有四款 npm 依賴安裝器。但同時這也意味著分裂,標準的不統(tǒng)一,我們有不同的依賴樹鎖文件,有不同的安裝前置后置行為,甚至有不同的穩(wěn)定性治理方案。

針對安裝器不同,Node.js 官方推出了 corepack,里面包含了 npm、pnpm、yarn。

回過頭,我們來看 JS 的標準組織,tc39 給我們帶來了 ECMAScript 標準。這樣瀏覽器廠商不至于各自為戰(zhàn),不會靠著市場份額,加塞私貨,破壞 Web 生態(tài)的兼容性。

我們認為包管理到了需要類似 tc39 的組織和類似 ECMA-262 標準的時機了。

假如我們能把包管理這部分,通過類似 tc39 的組織一樣進行標準化,我們會在標準,用戶體驗和社區(qū)生態(tài)上更進一步。
統(tǒng)一的標準
一致的用戶體驗
安全的社區(qū)生

歡迎參與開源貢獻~???????????????
cnpm:
https://github.com/cnpm/cnpm
npminstall:
https://github.com/cnpm/npminstall