基於 Verdaccio 的 NPM 私服優化小記

背景介紹

Verdaccio 是一個輕量級 npm 私有倉庫的開源解決方案,如下簡稱 npm 私服。javascript

近期觀察發現,有些項目依賴了名爲 npm 的 npm 包,每次項目部署時都會向私服 /npm 發起請求記錄,並在監控曲線上呈明顯的高耗時,這引發了咱們的關注。html

緣由

Verdaccio 對公共(外網)npm 包的中轉存在不小的性能損耗。java

其中一個問題,經過私服下載未經緩存的公共 npm 包,Verdaccio 都要等上游鏡像的響應完整結束以後,纔開始響應私服用戶的請求。這致使 Verdaccio 的總體速度比直接用上游慢了一截。nginx

至於會慢多少呢,要提到另外一個 npm 機制:一個依賴 package 下載以前,要先到鏡像地址的 /:package/:version? 接口獲取完整的包信息,以後纔會下載所需的版本。而一個模塊歷史發佈過的版本越多,信息量越大。尤爲是 npm 自身這個包,訪問一下 registry.npmjs.org/npm 便知。git

Verdaccio 慢就慢在獲取包信息這一步,它必須等待上游接口響應完成,才能作相關 JSON 解析和邏輯處理。所以不只僅是慢的問題了,還有內存和 CPU 的大量消耗。github

然而這一步對於 Verdaccio 又很重要,由於它的對於此接口的緩存策略基於文件,只有拿到完整的 JSON 返回值才能將其記錄到文件中。只是默認僅 2 分鐘的緩存時間,讓這一步操做的性價比打了折扣。web

思路

文件緩存的時效要視狀況延長,緩存邏輯也應當改良。redis

從上面看,私服接口性能優化空間還很大,哪怕只是將幾個體積較大的「罪魁禍首」 npm 包單獨優化,也能緩解私服的壓力。typescript

首先想到的是讓 Verdaccio 沒必要等待上游所有返回就開始響應私服用戶。其次是現有的緩存機制對部分低頻率高開銷的 package 請求形同虛設,小機器又經不起緩存擴充的資源消耗,網絡帶寬卻是相對不缺,下降計算成本、純網絡代理轉發是一個可行的方向。數據庫

Verdaccio 會對下載的 npm 包信息作解析和記錄,但其實咱們並不關心那些只屬於上游的包,只但願它能承擔好轉發工做,甚至全部公共依賴都不通過私服處理。

退一步講,就是要弱化在私服中對這些公共依賴的處理,減小解析過程 —— 能夠用 stream 或 buffer 完成請求轉發。

嘗試

遺憾的是 Verdaccio 自身的接口難以複用,只好直接在其基礎上增長路由(中間件)。

const _ = require('lodash');
const createError = require('http-errors');
const request = require('request');
const URL = require('url');

const Middleware = require('../../web/middleware');
const Utils = require('../../../lib/utils');

module.exports = function(route, auth, storage, config) {
  const can = Middleware.allow(auth);

  // 優化特定依賴的獲取,以 `npm` 舉例
  route.get('/npm', (req, res) => {
    // 拼接鏡像地址
    const upLinkUrl = _.get(config, 'uplinks.npmjs.url', 'https://registry.npm.taobao.org');
    const packageUrl = URL.resolve(upLinkUrl, req.originalUrl);

    // 利用 Verdaccio 定義的 res.report_error 來採集錯誤
    const npmRes = request(packageUrl)
      .on('error', res.report_error);

    // 直接將上游結果轉發,快速響應請求
    req.pipe(npmRes).pipe(res);
  });

  route.get('/:package/:version?', can('access'), function(req, res, next) {
    // ...
  });
  // ...
};
複製代碼

stream 轉發減輕了服務的內存壓力(節省上百 MB 的臨時緩衝),並減小這部分接口 50% 以上的 TTFB 響應時間,不過整體響應時間卻由於 stream 有所延長。

下降機器負載的目標達成了,進程的處理效率卻大大下降了。雖然能夠節省資源開銷,又不衝破原有架構,但從結果上看,甚至不是合格的優化。

Cluster?

回看 Verdaccio 官方文檔,它明確表示不支持(PM2)Cluster 模式。不過,其雲存儲方案是支持多進程多節點部署的,官方只提供了 google cloud、aws s3 storage 的插件。

因而能夠得出結論,只要擁有本身的雲存儲服務,就能使用或設計一套新的存儲插件,進而支持多進程架構。此方案必定可行,只是成本較高一些。咱們先換個思路,儘可能用簡單的辦法解決問題。

代理分流

隨着用戶(請求)數量的上升,服務響應速度和效率其實才是最要緊的問題,單節省資源終究不能改善這一點。所以決定繼續實施分流工程,這一次,要引入新的進程節點。

俗話說得好,沒有一箇中間層解決不了的問題,而在 Verdaccio 的場景下,這種作法又是至關地迅速和高效。

原理

npm 安裝機制

若是不瞭解 npm 官方客戶端的安裝機制,稍後能夠閱讀阮一峯的博客 npm 模塊安裝機制簡介,少部分知識已經不適用於當前版本了,不過最重要的是能理解 npm 下載流程。

其中咱們須要知道,npm 包下載前,客戶端會向上遊服務器查詢包信息,以及獲取壓縮包的下載地址 url,並將此 url 存放在 package-lock.json 文件中。之後每次執行下載,都會優先使用 package-lock.json 中的地址。

npm 下載最長請求路徑

爲了方便理解 Verdaccio 所處的位置,我來繪製一下 npm 包下載時從客戶端到 Verdaccio 再到上游的最長請求路徑簡圖,並忽略中間的安全驗證環節,以下所示。

verdaccio-fetch-path.png

接口轉發

有了代理層,就能夠忽略 Verdaccio 內部的各類邏輯,不受技術棧的約束,編寫少許的代碼,便能完成主要接口的分流。

首要的接口是 /:package/:version? ,釋放私服最大的查詢壓力,緣由能夠看這裏的解釋

次要的接口是 /:package/-/:filename ,也就是實際的下載接口。而且其中還涉及另外一個極爲有利的優化。

儘管 Verdaccio 是轉發上游的資源,它也會將下載 url 變動爲本身的服務域名。所以不論依賴是否私有,記錄到 package-lock.json 中的地址都是 Verdaccio 的地址。

但通過代理層的分流,此後通過更新的 package-lock.json 將保留原汁原味的下載地址,此後下載壓縮包的請求不再會發到私服。

綜上所述,咱們能夠將私服超過 99.99% 的流量轉移到代理或上游服務。

條件

接下來,咱們來肯定分流口徑,天然是判斷一個 package 是不是私服私有,所以須要 Verdaccio 提供接口,獲取私有包的列表。

Verdaccio 有一個 /-/verdaccio/packages 接口用來獲取全部私有包的信息,但這個包主要用於 Web 頁面,包含大量咱們不須要的信息,甚至簡單一點,只要提供私有 npm 包的包名就能知足篩選條件。

所以,能夠改良 /-/verdaccio/packages,例如新增一個專門獲取包名列表的接口,並增長內存緩存。

Verdaccio 版本不一樣時,作法也有很大差別,相信這裏的處理不是問題,只要認真閱讀上述接口就能獲取思路了。

PS:仍是補充一點代碼吧,早期版本 Verdaccio 只須要這樣改:

/** * Get name list of all visible package * @route /-/verdaccio/names */
route.get('/names', async function(req, res, next) {
  // 此處 cache 做爲緩存,在有新的私有 npm 包發佈時刷新便可
  let names = cache.get('packageNames');
  if (!names) {
    try {
      names = await storage.localStorage.localList.get();
    } catch(err) {
      return next(err);
    }
    cache.set('packageNames', names);
  }
  next(names);
})
複製代碼

最新的 names 要使用回調的方式取值,僞代碼:

const names = await new Promise((resolve, reject) =>
  storage.localStorage.storagePlugin.get((err, list) =>
    err ? reject(err): resolve(list)))
複製代碼

實現方式

客戶端

客戶端也能承擔分流的任務,即像 cnpm 同樣包裝一層本身的 npm cli 工具,但分流的邏輯要簡單許多,只需檢查要安裝的包是否屬於私有,而後分爲兩批安裝。

缺陷是推行難度和速度都不理想,因而這裏只是順便提一下。

服務端

到這一步,技術選型已經無所謂了,天然能夠 nginx + lua,簡單一點就繼續使用 Node.js 實現。

因爲其餘緣由,我用 express 作了實現,貼一點轉發邏輯,你們就自由發揮吧。

const request = require('request');
const rp = require('request-promise-native');

const publicRegistry = 'http://registry.npm.taobao.org';
const privateRegistry = 'http://npm.private.com';

const sec = 1000;
const min = 60 * sec;

const privateListCache = [];

/** * 檢查並更新私服包名列表的緩存 * 緩存能夠基於 redis 或內存,注意控制好更新節奏 */
async function checkPrivateCache() {}

/** * npm package 請求分流 * @route /:packages/:version? 版本檢查 * @route /:packages/-/:filename 下載 */
async function packages(req, res, next) {
  console.log(req.url)
  await checkPrivateCache();
  // 請求默認轉發至 taobao
  let baseUrl = publicRegistry;
  if (privateListCache.length && privateListCache.includes(req.params.package)) {
    // 轉發私服的請求
    baseUrl = privateRegistry;
  }

  const options = {
    uri: baseUrl + req.url,
    timeout: 2 * min
  };
  try {
    request(options).on('error', next).pipe(res)
  } catch(err) {
    next(err);
  }
}

/** * 其餘請求原樣轉發私服 * @route /* */
function all(req, res, next) {
  // 清除 headers 的 host
  const headers = Object.assign({}, req.headers, { host: undefined })
  const options = {
    uri: privateRegistry + req.url,
    method: req.method,
    timeout: 2 * min,
    headers
  }
  try {
    req.pipe(request(options).on('error', next)).pipe(res);
  } catch (err) {
    next(err)
  }
}
複製代碼

結果

在一樣的測試條件下,私服的 /:package/:version? 接口平均響應耗時從 4s 降至 400 ms,能夠明顯感受到速度的提高,而且能夠經過不斷擴展代理層優化處理效率。做爲輕量級的私服解決方案,生命週期獲得了很好的延長。

單機 Cluster

接下來,咱們討論如何解決 Verdaccio 默認的本地存儲方案不支持 Cluster 的問題。

標題爲何叫單機 Cluster 呢?

由於多機 Cluster 已經沒法使用默認的本地存儲,必須配合一套新的存儲方案,而官方只提供了 AWS 和 Google Cloud 的支持。這在國內已是一道門檻,所以大機率是要用上其餘雲存儲服務的,這意味着必須作一個 Verdaccio 插件實現必備的 add、search、remove、get 功能。

糟糕的是,假若本身的雲存儲不支持查詢功能,還得基於數據庫再造一套輪子,甚至再加一套解決讀寫衝突的輪子。

一句話來講,Verdaccio 是輕量級好手,不適合也沒必要要承載過重的裝備。重度使用的場景下,與其從頭定製的存儲體系,不如直接上其餘體積更大、功能完備的系統。

話說回來,做爲嘗試,我仍是基於 Redis 實現了它的單機 Cluster。雖然修改的 Verdaccio 版本較舊,但其新版 V4 的架構並無太大變化,思路仍是一致的。

思路

Verdaccio 默認沒法使用 PM2 Cluster 啓動,有兩大阻礙。

其一,緩存同步。它使用進程級別的內存緩存,沒有實現進程間通信,多進程之間緩存信息不能同步。

其二,寫鎖。本地存儲將內容持久化到本機磁盤,只有進程級別的「鎖」,多進程容易出現寫文件衝突。

這兩個問題處理起來其實很是簡單,特別是引入 Redis 以後。

針對第一點,內存緩存能夠遷移到 Redis,可是其中有大致積的 JSON 信息,不適合存在 Redis,能夠用 Redis 作消息中心,管理各進程的緩存狀態。

針對第二點,私服自己屬於簡單的業務場景,Redis 鎖徹底能夠勝任。

實現

本應該是 Show Code 環節,可念在筆者改的版本不存在普適性,索性改爲修改要點的簡單羅列吧。

  • 重寫 local storage,本地存儲依賴一個叫 .sinopia-db.json.verdaccio-db.json 的文件,其中保存全部私服的包。這個文件的內容適合使用 Redis 的 set 結構進行替換。
  • 查找並替換全部 fs.writeFile,加鎖處理。在鎖的實現上,新手須要多看官方文檔,大部分博客的實現都是錯誤的,好比忽略瞭解鎖步驟的原子化操做。
  • 向上回溯修改的鏈路。

優化補充

想來這多是專題的最後一期,因而把不太相關的幾個小問題也堆到下面吧。

只關心 Cluster 改造的看官可跳過此節,直接看末尾總結。

異步風格

因爲手上的 Verdaccio 版本較老,總體仍是 callback 風格,讓改造多了一點工做量。我使用的 Redis 客戶端爲 ioredis,注意把涉及到的調用鏈路都改造爲 async/await。

發佈訂閱

另外一個坑點是我拿到的 Redis 實際上是 Codis 集羣,這套方案的一個缺點是沒法使用 Redis 弱弱的發佈訂閱功能,也就不能直接拿來訂閱更新內存緩存的消息。只好另闢蹊徑,將 Redis 做爲「緩存中心」,進程取緩存前先查詢標誌位,若是標誌位存在,表明內存緩存須要更新。以進程號等信息作 key 前綴表示區分。

const os = require('os');

class CacheCenter {
  constructor(prefixKey = 'updated') {
    this.data = new Map();
    // 利用 redis 緩存標誌位,爲空時表示緩存須要更新
    this.prefix = prefixKey;
    // 用 pm2 進程號區分緩存狀態
    this.ip = getIPAddress();
    this.id = `${this.ip}:${process.env.NODE_APP_INSTANCE || 0}`;
  }

  async get(key) {
    const isCached = this.data.has(key);
    if (isCached) {
      const isCacheLatest = await redis.hget(this._key(key), this.id);
      if (isCacheLatest) {
        return this.data.get(key);
      }
    }
    return undefined;
  }

  async set(key, value) {
    this.data.set(key, value);
    await redis.hset(this._key(key), this.id, Date.now());
    redis.expire(this._key(key), 7 * 24 * 60 * 60);
  }

  async del(key) {
    redis.del(this._key(key));
  }

  has(key) {
    return this.data.has(key);
  }

  _key(key) {
    return `${this.prefix}:${key}`;
  }
}

function getIPAddress() {
  const interfaces = os.networkInterfaces();
  for (const iface of Object.values(interfaces)) {
    for (const alias of iface) {
      if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal) {
        return alias.address;
      }
    }
  }
  return '127.0.0.1';
}

module.exports = new CacheCenter();
複製代碼

頁面搜索優化

順便一提,Verdaccio web 頁面的 /search 接口性能極差,實現也存在諸多問題。此處值得加一層內存緩存,等到新包發佈時刷新。

早期 Verdaccio 不支持使用 name 搜索名爲 @scope/name 的包,可增長一條 name 專用的索引字段促成改進。根源是依賴的 lunr 引擎版本太低(0.7.0),但最新 lunr 的表現依然不太理想。

class Search {
  /** * Constructor. */
  constructor() {
    this.index = lunr(function() {
      this.field('name', {boost: 10});
      this.field('unscoped', {boost: 8});
      this.field('description', {boost: 4});
      this.field('author', {boost: 6});
      this.field('readme');
    });
  }

  /** * Add a new element to index * @param {*} pkg the package */
  add(pkg) {
    this.index.add({
      id: pkg.name,
      name: pkg.name,
      unscoped: getUnscopedName(pkg.name),
      description: pkg.description,
      author: pkg._npmUser ? pkg._npmUser.name : '???',
    });
  }
  // ...
}

/** * 截取包名中不帶 scope 的部分 * 參照命名規範 @scope/name,直接截取/後的字符串 */
function getUnscopedName(name) {
  return name.split('/')[1];
}
複製代碼

總結

若是隻是想必定程度上提升處理高併發的性能,能夠採起代理分流,代理服務能幫你分擔 99% 以上的壓力。

若是想進一步提高性能、實現應用的平滑重啓,本文單機 Cluster 並配合 pm2 reload 的作法值得一試。

而一但想開啓多節點集羣的能力,幾乎超出了輕量級私服的理念,試着更換其餘更重量級的選手吧。

相關文章
相關標籤/搜索