私は最近 [npm パッケージマネージャー](https://www.npmjs.com/)を使用してパッケージをダウンロードしましたが、以前のバージョンのパッケージがすでにインストールされているのにもかかわらず、npm ではモジュールの更新をインストールする際、新しいバージョンを入手するためにターボール全体をダウンロードしなければならないことに気づきました。これは非常に非効率的です。
CDN 設定とサーバーレスクラウドコンピューティング機能のみを使用し、以前にキャッシュされた2つのファイルの差分を求めることは、エッジとサーバーレスコンピューティングサービスを利用して Web サイトの効率性とパフォーマンスを向上し、帯域コストを削減できる良い例です。この記事では、ソフトウェア、ドキュメント、保存したゲームなど、バージョン管理されダウンロード可能なアセットをホストするサイトで帯域の消費を大幅に削減するためのソリューションを提案します。
![diff at the edge diagram 1](//images.contentful.com/6pk8mg3yh2ee/3vd8iGLYk8Okuki4I8Q6O6/6d16ae671a7c4979deeb8aa679f4b373/diff2.jpg)
私が作成したオープンソースサービスの [Polyfill.io](https://polyfill.io/) を例として使用します。これは npm モジュールとして公開されています。最新バージョンは gzip 形式で11 MBに圧縮されており、非圧縮の場合は99 MBです。[bsdiff](http://www.daemonology.net/bsdiff/) を使用すると、1つ前のバージョンから最新バージョンへの変更箇所をまとめたパッチを生成できます。
```
$ bsdiff polyfill_io-3.16.0.tar polyfill_io-3.17.0.tar polyfill_io-3.16.0...3.17.0.patch
$ ls -lah
total 424
drwxr-xr-x 5 me staff 170B 18 Apr 15:55 .
drwxr-xr-x 14 me staff 476B 18 Apr 16:32 ..
-rw-r--r-- 1 me staff 209K 18 Apr 17:27 polyfill_io-3.16.0...3.17.0.patch
-rw-r--r-- 1 me staff 99M 18 Apr 15:54 polyfill_io-3.16.0.tar
-rw-r--r-- 1 me staff 97M 18 Apr 15:53 polyfill_io-3.17.0.tar
```
つまり、クライアントがすでにバージョン3.16.0を所有している場合、3.17.0へ更新するのに209 KBのみダウンロードされます。これは、__全体の11 MBのわずか1.8 %__です (11 MBは99 MBを gzip 形式で圧縮したものであり、これを使用しない場合はターボール全体をダウンロードする必要があります)。
しかし通常、npm などのモジュールホスティングサービスは静的なホスティング環境 (Amazon S3、Google Cloud Storage など) にモジュールを保存するため、この種の動的コンテンツの生成機能を追加するのは難しく、できたとしても制限が生じます。また、すべてのモジュールについてバージョンのペア間の差分を事前生成することは、コンピューティング/ストレージリソースをうまく活用しているとは言えません。
## この処理が CDN レベルで可能か
もちろんです。以下のようにして Fastly の CDN で可能です。
![diff at the edge diagram 2](//images.contentful.com/6pk8mg3yh2ee/TZiC5JSyisgSu0aS4kMQw/b61eac339b0a22f3d8155cf10cd0fcd0/diff1.jpg)
リクエストの特性に基づいてオリジンサービスを選択できる CDN を使用すると、「差分」のリクエストをパッチ生成サービスにルーティングできます。Fastly の CDN では、この処理を VCL (お客様が利用できる [Varnish Configuration Language](https://docs.fastly.com/guides/vcl/)) で行うことができます。まずは特殊なバックエンドを定義します。
```
backend be_diff_service {
.dynamic = true;
.port = "443";
.host = "<
>";
.ssl_sni_hostname = "<>";
.ssl_cert_hostname = "<>";
.ssl = true;
.probe = {
.timeout = 10s;
.interval = 10s;
.request = “GET /healthcheck HTTP/1.1”"Host: <>" "Connection: close" "User-Agent: Fastly healthcheck";
}
}
```
次に、パッチのリクエストに使用する特殊なシンタックスを決定し、このシンタックスを検出してリクエストを特殊なバックエンドにルーティングするコードを vcl_recv に追加します。
```
sub vcl_recv {
....
declare local var.diffUrlPrefixSTRING;
declare local var.diffUrlSuffixSTRING;
if (req.url ~ "^(/.*\/\-\/.*)\-(\d+\.\d+\.\d+)...(\d+\.\d+\.\d+)(\.tgz)\.patch"){
set var.diffUrlPrefix = if (req.http.Fastly-SSL, "https://", "http://") req.http.Host ".global.prod.fastly.net"re.group.1 "-";
set var.diffUrlSuffix = re.group.4;
set req.backend = be_diff_service;
set req.http.Host = "<>";
set req.http.Backend-Name = "diff";
set req.url = "/compareURLs?from="var.diffUrlPrefix re.group.2var.diffUrlSuffix "&to=" var.diffUrlPrefix re.group.3var.diffUrlSuffix;
}
....
}
```
npm のダウンロードでは /module-name/-/module-name-1.2.3.tgz などの URL を使用するため、差分リクエストとして /module-name/-/module-name-1.2.3...1.2.4.tgz.patch もサポートします。上記の VCL における正規表現では、このカテゴリーに分類されるリクエストを取得して以下の処理を行います。
1. 差分サービスを指すようにバックエンドを変更する
2. リクエスト内の正しいオリジンのドメインをサービスに送信するように Host ヘッダーを更新する
3. 差分生成サービスのシンタックスに合わせてパスを書き換える
(Fastly のエッジクラウドプラットフォームで独自の VCL の実行を開始する手順の詳細については [VCL の入門ガイド](https://docs.fastly.com/guides/vcl/guide-to-vcl)をご覧ください。)
これは非常に役立つ処理ですが、CDN キャッシュノード自体が差分を生成することはできません。これは、AWS Lambda や Google Cloud Functions などのサーバーレスコンピューティングサービスの優れた事例です。ここでは、Google Cloud Functions を使用して処理を行います。
GCF をまだセットアップしていない場合は、Google の[クイックスタートガイド](https://cloud.google.com/functions/docs/quickstart)を参照するとすぐに GCF をセットアップして実行できます。
必要なクラウド機能のソースを以下に示します。
```
const url = require('url');
const zlib = require('zlib');
const fetch = require('node-fetch');
const bsdiff = require('node-bsdiff').diff;
exports.compareURLs = function compareURLs (req, res) {
Promise.resolve()
.then(() => {
return Promise.all(['from','to'].map(param=> {
return fetch(req.query[param])
.then(resp => {
const name = url.parse(req.query[param]).pathname.replace(/^.*\/([^\/]+)\/?$/, '$1');
const isCompressed = Boolean(resp.headers.get('Content-Encoding')=== 'gzip' || name.match(/\.(tgz|gz|gzip)$/));
const respStream = isCompressed ?resp.body.pipe(zlib.createGunzip()): resp.body;
const bufs = [];
respStream.on('data', data => bufs.push(data));
return new Promise(resolve => {
respStream.on('finish', () => {
resolve(Buffer.concat(bufs));
});
});
})
;
}))
})
// Create patch and serve it
.then(([from, to]) => {
const patch = bsdiff(from, to);
res.status(200);
res.send(patch);
})
;
};
```
公開されている2つの npm モジュールを使用します。1つは、標準の WHATWG Fetch API を Node.js に実装する [node-fetch](https://www.npmjs.com/package/node-fetch) です (この記事を書いている時点ではノードでネイティブにサポートされていません)。もう1つは、[Colin Percival](https://twitter.com/cperciva) 氏が開発した優れた[バイナリ差分アルゴリズム](http://www.daemonology.net/bsdiff/)を実行する [node-bsdif](https://www.npmjs.com/package/node-bsdiff) です。
このコードにはエラーの処理や検証が含まれていません。また、適切な Cache-Control 情報を追加する (パッチをキャッシュできるのは、比較対象の2つのファイルのうちの短い方のキャッシュ期間です)、または入力ファイルにある [surrogate-key](https://docs.fastly.com/guides/purging/getting-started-with-surrogate-keys) ヘッダーをそのまま渡すことでパッチのレスポンスを改善できます。コメント付きの[より包括的なソリューションを GitHub にアップロード](https://github.com/fastly/diff-service)したのでご自由にご活用ください。
## テスト
新しいエンドポイントをテストするため、Fastly サービスを作成する differentnpm.com という架空のドメイン名を npm レジストリ用に新しく作成し、オリジンサーバーとして実際の npm レジストリを使用してセットアップしました。npm で最も人気のあるモジュールの1つ、 lodash 4.17.4 のターボール全体のダウンロードリクエストは、この新しいサービスが npm レジストリのように動作することを示しています。
$ curl "http://differentnpm.com.global.prod.fastly.net/lodash/-/lodash-4.17.4.tgz" -vs 1>/dev/null
< HTTP/1.1 200 OK
< Cache-Control: max-age=21600
< Content-Type: application/octet-stream
< Content-Length: 310669
< X-Served-By: cache-sjc3143-SJC, cache-sjc3628-SJC
< X-Cache: HIT, HIT
このリクエストは npm の実際のレジストリにルーティングされ、310 KBのファイルが生成されます (Content-Length ヘッダーを参照)。また、このファイルはよく使用されるファイルであり、ローカルの CDN キャッシュノードに配置されている可能性が高く、想定どおりこのリクエストはキャッシュヒット (HIT) です。
ただし、この新しいレジストリでは新しい差分 URL も透過的にサポートされます。
$ curl "http://differentnpm.com.global.prod.fastly.net/lodash/-/lodash-4.17.3...4.17.4.tgz.patch" -vs 1>/dev/null
< HTTP/1.1 200 OK
< Cache-Control: max-age=21600
< content-type: application/octet-stream
< Content-Length: 1207
< Connection: keep-alive
< X-Served-By: cache-sjc3132-SJC
< X-Cache: HIT
ここに示す lodash 4.17.3 と4.17.4 の差分のリクエストは__わずか1,207バイト (元のサイズの0.3 %)__ のパッチです。
bsdiff は bspatch とセットで使用するツールであり、古いファイルとパッチを指定して新しいファイルを生成できます。
```
$ ls -la
-rw-r--r-- 1 me staff 2254848 18 Apr 16:30 lodash-4.17.3.tar
-rw-r--r-- 1 me staff 1207 19 Apr 17:35 lodash-4.17.3...4.17.4.tgz.patch
$ bsdiff lodash-4.17.3.tarlodash-4.17.4.tar lodash-4.17.3...4.17.4.tgz.patch
$ tar tf lodash-4.17.4.tar
package/package.json
package/README.md
package/LICENSE
package/_baseToString.js
....
```
## セービング
この種の処理がどの程度役立つかを確認できるように、npm の[依存数の多いモジュール](https://www.npmjs.com/browse/depended)のリストを作成し、各モジュールについて以下のデータを収集しました。
- テスト期間中のダウンロード数 (2017年4月に使用)
- 最新バージョンのターボールのサイズ
- 最新バージョンのターボールと1つ前のバージョンのターボールの差分のサイズ
公開データから確認できないのは、ユーザーが以前のバージョンのファイルをローカルのキャッシュに保持している頻度です。その数値が5 %、15 %、50 %の場合の影響を確認してみましょう。
|
パッチサイズ |
毎月のデータセービング (GB)、キャッシュ比 |
モジュール |
ダウンロード数 (×1,000件) |
サイズ (バイト) |
毎月の転送量 (GB) |
絶対値 (バイト) |
相対値 (%) |
5 % |
15% |
50% |
lodash |
42,866 |
310,669 |
12,403 |
1,207 |
0.39% |
618 |
1,853 |
6,177 |
request |
24,756 |
56,636 |
1,306 |
3,248 |
5.73% |
62 |
185 |
615 |
async |
43,923 |
97,968 |
4,008 |
23,083 |
23.56% |
153 |
459 |
1,532 |
express |
11,577 |
52,372 |
565 |
602 |
1.15% |
28 |
84 |
279 |
chalk |
21,045 |
5,236 |
103 |
1,027 |
19.61% |
4 |
12 |
41 |
bluebird |
14,327 |
135,089 |
1,803 |
2,669 |
1.98% |
88 |
265 |
883 |
underscore |
12,229 |
34,172 |
389 |
6,879 |
20.13% |
16 |
47 |
155 |
commander |
26,118 |
13,425 |
327 |
1,309 |
9.75% |
15 |
44 |
147 |
debug |
45,226 |
16,144 |
680 |
588 |
3.64% |
33 |
98 |
328 |
moment |
9,219 |
497,477 |
4,271 |
891 |
0.18% |
213 |
640 |
2,132 |
合計 (上位10モジュール) |
251,286 |
|
25,853 |
|
1,229 |
3,687 |
12,290 |
|
相対的なセービング |
4.75% |
14.26% |
47.54% |
差分サイズは明確に異なっており、最もよく利用される npm モジュールであってもきわめて差分サイズが小さくなる傾向にあります。しかし、npm モジュールのリクエストの一部が差分である場合は、ほぼ同じ割合の帯域が排除されることがこのデータからわかります。
## その他の使用事例
差分によるメリットが得られるビジネスの形態はパッケージマネージャーだけではありません。Android では、バイナリ差分を使用して Google Play ストアからアプリを更新します。すでに所有しているアプリに対する更新データをユーザーに送信する必要があるシナリオでは、差分を使用することによって帯域の使用効率が大幅に向上します。