この記事はNikkei Advent Calendar 2021の6日目の記事です。
こんにちは、長期インターン生の林(Shinyaigeek)です。
fastly の提供する Compute@Edge という Edge Computing 基盤があります。 まだベータという形でですが、このランタイムで JavaScript の対応が始まったことをうけ、 Compute@Edge で何ができるのか、そのために何が必要なのか、というのを実際に Compute@Edge で Apollo Server を用いて GraphQL Server を建てることを通して考察します。
Compute@Edge とは
本題に入る前に Compute@Edge とはどのような技術なのか、何ができるのか、という説明から入りたいと思います。
Compute@Edge とは fastly が提供している Edge Computing 基盤です。開発者の書いたコードを、 WebAssembly として fastly のエッジサーバー上で実行することで、クライアントのデバイスから物理的に近い場所で実行できるようになります。
これは認証処理やA/Bテスト、Feature Flags のために HTTP Request/Response を加工する処理、あるいはリバースプロキシのような処理など、いわゆる middleware のようにクライアントと Origin Server の間でちょっとした処理を行うのに有用です。
またエッジサーバー上で完全な HTTP Response を生成して返却するような、通常のサーバーレスでやることと同じような使い方も可能です。
初期の Compute@Edge は Rust のみをサポートしており、「.rs
-> .wasm
-> エッジサーバーにデプロイ」まで fastly の提供する cli ツールで一気に行っていました。または WASI をサポートしている言語であれば、それを .wasm
へとコンパイルすればあとは開発者独自のビルドプロセスでビルド、デプロイすることも可能でした。
この Compute@Edge ですが、2021年夏、 ユーザーの要望を受け JavaScript の対応を始めました。 (本稿の執筆時点ではまだベータ対応です)
JavaScript がサポートされたことにより、npm により支えられる JavaScript の豊富なモジュールエコシステムにアクセスできる、JavaScript に慣れた人にとって書きやすいなどの旨味がありますが、その他にも Node.js サーバーでやっていることを Compute@Edge へと移植して、サーバーレスのような運用をする、といったこともできるようになります。 例えば tensorflow.js を動かすことで、学習済みのモデルをエッジで実行し、ユーザーのジオロケーション情報などからユーザーの次の遷移先を推定したり、本稿で紹介するような Apollo Server を動かすことで、 GraphQL Server を構築したり、React や Vue などの Server-Side Rendering を行ったりなども可能になるでしょう。
ここでこれからの考察のためにまず言葉の定義をさせてください。上述しているようなエッジサーバーをサーバーレスのような、Stateless な Function を実行するためのランタイムとして扱うこと、いわば Edge As An Origin とも呼べる使い方、エッジサーバーを指して「エッジネイティブ」とします。
本稿では、Compute@Edge のエッジネイティブなアプリケーションサーバー基盤としてのポテンシャルに着目して考察を重ねていきます。
新時代のサーバーレス
さて、Compute@Edge について簡単に説明したところで、次に Compute@Edge をエッジネイティブなサーバーとして使うことのメリットについても触れていきましょう。
Function を実行するためのラインタイム、という観点でサーバーレスとの比較が気になります。
まずサーバーレスの強みとして、プラットフォームが自動でスケーリングをしてくれるため、開発者がサーバーリソースの管理に人的リソースを割く必要がなくなることや、Function単位でサーバーレスに小さく切り出して実行することで、コードの変更による影響範囲を小さくできる、などが挙げられます。一方、非アクティブなコンテナで Function を実行しようとするとインスタンスの生成、起動に時間がかかってしまう、いわゆるコールドスタート問題があります。このサーバーレスの課題を Compute@Edge は解消しており、Compute@Edgeのランタイムでは他の製品の100倍以上も速い起動時間であると謳っているようです。[3]
"The power of serverless, 72 times over" で言及されている通り、既存のサーバーレスにあったスケーラビリティや技術の分散によるより優れた開発者体験に加えて、fastlyの持つよりクライアントのデバイスから物理的に近い距離にあるエッジサーバーで実行できるため、通信による遅延を抑えつつ、クライアントのデバイスのマシンスペックに依存せずに処理を行うことができます。また Compute@Edge はコールドスタートのための時間が短いためその点でもパフォーマンスのメリットを享受できることなどもエッジネイティブなサーバーを建てることの強みとして挙げられます。また Compute@Edge に限った話だと、WebAssembly を動かしているためそのセキュリティやポータビリティといった強みを活かしつつ、fastly の CDN サーバーで実行されるため、その CDN のキャッシュを扱え、外部との通信も圧縮などの最適化を受けることができる、といったメリットも享受できます。
またこれは JavaScript に限った話になりますが、Compute@Edge は JavaScript を実行するために Firefox の JavaScript Engine である SpiderMonkey の WebAssembly 実装を用いています。ビルド時にコードと SpiderMonkey の WebAssembly をまとめてしまって、それをデプロイ/実行するという形になっています。そしてビルド時に JavaScript のバイトコードの生成プロセスまでは済まされるため、JavaScript Runtime としてのパフォーマンスも高いです。
"The power of serverless, 72 times over" では Compute@Edge は
A new generation of serverless
と謳われており、従来のサーバーレスのコールドスタート、ロケーションの管理といった問題を解消した、次世代のコンピューティング基盤とも言えるでしょう。
Compute@Edge for JSのセットアップと概要
fastly の Document に従って fastly の CLI をインストールし、その上でこのテンプレートを元に構築してください。
なおローカルのマシンで Compute@Edge を動かすのにも、エッジサーバーへとデプロイしそこで Compute@Edge を動かすのにも fastly の service と token が必要になります。
コードを見てみると、
addEventListener('fetch', event => event.respondWith(handleRequest(event)));
fetch
イベントについてイベントリスナーを登録し、そのコールバックで handleRequest
を呼び出していることがわかります。こうすることでエッジサーバーに (Origin Server に対しての)HTTP Request が届くとそのコールバックが実行されます。この ServiceWorker ライクな API は、Origin Server とクライアントのデバイスとの HTTP通信でのやり取りの間にエッジサーバーがあってエッジで処理をしていると考えると、かなり自然なものと言えるでしょう。
次は handleRequest
の処理を見てみましょう。(一部抜粋)
async function handleRequest(event) {
let req = event.request;
if (!["HEAD", "GET"].includes(req.method)) {
return new Response("This method is not allowed", {
status: 405,
});
}
let url = new URL(req.url);
if (url.pathname == "/") {
return new Response(welcomePage, {
status: 200,
headers: new Headers({ "Content-Type": "text/html; charset=utf-8" }),
});
}
return new Response("The page you requested could not be found", {
status: 404,
});
}
エッジサーバーの元へと届いた HTTP Request を見て、 HEAD method か GET method かつ /
へのリクエストに対して、 エッジで制した HTML を Response として返し、 それ以外の HTTP Request に対しては404を返していることがわかります。これは Backend に Origin Server を置いていてもエッジサーバーで HTTP Request に対しての HTTP Response を生成して応答してしまう、エッジネイティブなサーバーの一例とも言えるでしょう。もちろん従来のVCLと同様に fastly で登録した backend へと fetch
を介す事によって HTTP Request を転送することもできます。
Apollo Serverを動かす
Compute@Edge 上で Apollo Server を動かすためには、上記の通り fetch イベントへのイベントリスナーのコールバックで、 HTTP Request と HTTP Response を処理し、さもエッジネイティブなアプリケーションであるかのように GraphQL Server として届いた HTTP Request に対して HTTP Response を生成して返せば良いということがわかります。
本稿では、データストアを RESTful API として抽象化した外部の API サーバーとやりとりして、それを隠蔽する BFF としての GraphQL Server を構築します。
まず npm のモジュールとして必要なものに、apollo-server
、apollo-server-core
、ついでに外部の RESTful API との通信も行いたいので apollo-datasource-rest
なども必要になります。
npm install apollo-server apollo-server-core apollo-datasource-rest
通常の Apollo Server を建てるときと同様に、 GraphQL のスキーマ定義、 resolver の定義、 data source を定義し、それを元に Apollo Server を初期化し、イベントリスナーのコールバックの中でサーバーを listen します。これは Compute@Edge は関係なく Apollo Server を建てるために必要なこととなります。
簡単な実装を載せていきます。
GraphQL Scheme 定義
const typeDefs = gql`
type Hello {
greet: String!
}
type Query {
greet(name: String!): Hello
}
`;
GraphQL datasource の定義
import { RESTDataSource } from "apollo-datasource-rest";
export class HelloAPI extends RESTDataSource {
constructor() {
super();
this.baseURL = "https://some-restful-api/";
}
async getGreet(name: string) {
return this.get(`people/${name}`);
}
}
const dataSources = () => ({
helloAPI: new HelloAPI(),
});
GraphQL resolvers の定義
const resolvers = {
Query: {
greet: async (
// * 型は適当
_: any,
{ name }: { name: string },
{ dataSources }: { dataSources: any }
) => {
return dataSources.helloAPI.getGreet(name);
},
},
};
その上で、Compute@Edge 用のハンドラーと Compute@Edge 用の Apollo Server を用意し、その中で GraphQL Query を実行し、そのレスポンスをエッジからのResponseとして返してあげれば Apollo Server が Compute@Edge 上で動作するようになります。
以下にサンプルコードを置きます。
Apollo Server
class ApolloServer extends ApolloServerBase {
async createGraphQLServerOptions(request: Request): Promise<GraphQLOptions> {
return super.graphQLServerOptions({ request });
}
public async listen() {
this.assertStarted("listen");
}
}
ハンドラー
export function graphqlComputeAtEdge(
options: GraphQLOptions | CloudflareOptionsFunction
) {
const graphqlHandler = async (req: Request): Promise<Response> => {
const url = new URL(req.url);
const query =
req.method === "POST"
? await req.json()
: {
query: url.searchParams.get("query"),
variables: url.searchParams.get("variables"),
operationName: url.searchParams.get("operationName"),
extensions: url.searchParams.get("extensions"),
};
return runHttpQuery([req], {
method: req.method,
options: options,
query,
request: req as any,
}).then(
({ graphqlResponse, responseInit }) =>
new Response(graphqlResponse, responseInit),
(error: HttpQueryError) => {
if ("HttpQueryError" !== error.name) throw error;
const res = new Response(error.message, {
status: error.statusCode,
headers: error.headers,
});
return res;
}
);
};
return graphqlHandler;
}
あとは、これをもとに Apollo Server を Compute@Edgeのハンドラーで動かします。
const server = new ApolloServer({
typeDefs,
resolvers,
dataSources,
introspection: true,
});
addEventListener("fetch", async (event: any) => {
await server.start()
await server.listen();
const res = await graphqlComputeAtEdge(() => {
return server.createGraphQLServerOptions(event.request);
})(event.request);
event.respondWith(res);
});
今回は実験用途のため、また Compute@Edge for JavaScript においてはコードの Top Level において addEventListener
で fetch
イベントに対してコールバックを設定しないといけないという制約上、 HTTP Request が届くたびに GraphQL Server を start()
、listen()
させていますが、実際のユースケースではこの実行コストは無視できないかもしれません。しかし Compute@Edge の改善により Top Level で fetch
イベントに対してコールバックを設定しないといけないという制約がなくなるなどすれば、こうした問題も解消されていくでしょう。
Universal JavaScript
こうすれば Compute@Edge で Apollo Server で動くのでしょうか、実は答えは 否 です。
実はこのように動かそうとしても、ランタイムでエラーが出てしまいます。というのも Compute@Edge for JavaScript は かなり純粋な JavaScript Runtime で、Web API や Node.js の API など、我々が JavaScript のコードを書くときにあると想定されるものがありません。普段よく JavaScript を書く人であれば、少し躓くポイントであるかもしれません。ブラウザの Runtime 向けの JavaScript コードを書くときに、 Node.js の API があるものとみなして、ブラウザ Runtime にはない filesystem や child_process を使おうとしてもランタイムでエラーが出るのは当然です。
一部の npm モジュールは typeof window
などでランタイムがブラウザのものか Node.js か判定した上で。それに応じてどちらのランタイムでも動くようなコードで書かれており、 それらはしばしば universal なモジュールと言われます。ですがこの Compute@Edge のランタイムでは使える API の種類やそのインターフェースが Node.js のものとも Web のものとも異なるため、Web API、Node.js APIに依存するモジュールがそのままだと動かないのはもちろんのこととして、Node.js、Web双方で動くように作られたモジュールも Compute@Edge 上にない API に依存しているときは動作せずにランタイムエラーが出てしまいます。
例えば Buffer や http モジュールについては polyfill を挟んであげる必要がありますし、必要に応じて偽の window.location
を用意してあげたりする必要があります。また最も大きな違いが fetch
で、fetch 自体は Compute@Edge のランタイムにも生えていますが、インターフェースを変えていて、第二引数である RequestInitObject
に対して backend
プロパティを付与してあげなければランタイムでエラーが出てしまいます。というのも Compute@Edge における fetch
はただ単に外部の任意のURLに対して HTTP Request を送れる、というものではなく、 fastly で定義した backend に対して HTTP Request を転送する、あるいは送るというものになります。なので backend
プロパティを介して どの backend に対して HTTP Request を送るのか、というのを明示しなくてはいけません。今回の場合、RESTful API を backend に置いた GraphQL サーバーを構築する事になるため、対象の RESTful API を backend として設定してあげた上で、backend: backend_a
という形で指定してあげる必要があります。
自分で fetch を使うときについては上記のことについて注意するだけで十分ですが、利用しているnpmモジュールでそのような配慮がなされているということはないため、そうしたnpmモジュールを利用するためには、 無いものを埋めるため、というよりはインターフェースの差異を埋めるため に polyfill を入れてあげる必要があります。簡単な polyfill の実装は以下のようになります。
const backends = {
"https://backend_hoge.com/": "hoge",
"https://backend_fuga.com/": "fuga",
"https://backend_bar.com/": "bar"
};
const __native_fetch = fetch;
fetch = (requestInfo, requestInit) => {
const requestUrl = typeof requestInfo === "string" ? requestInfo : requestInfo.url;
const [, backend] = Object.entries(backends).filter(([k]) => k.startsWith(requestUrl))[0];
__native_fetch(requestInfo, {
...requestInit,
backend,
});
};
このような HTTP Request 先のエンドポイントの URL からどの backend に HTTP Request を送るか決定した上で、それを RequestInitObject へと渡す処理を加えた fetch を実装してグローバルな fetch と置き換える必要があります。例えば webpack plugin を書くなどで対処できます。
こうした polyfill をあれこれ書いて、落とし穴を埋めてあげることでようやく多くの npm にあるモジュールを利用できるようになります。
universal な JavaScript のモジュールとなると、Node.js 上でも Web でも (あるいは WebWorker でも)動く、という認識が一般的だと思いますが、JavaScript の普及、そして Compute@Edge だけでなく Cloudflare Workers といったランタイムの増加に伴って、文字通りの真の universal を担保するハードルは高まるのかもしれませんね。今ある Edge Computing 基盤もプラットフォームによって API がかなりバラバラなため、例えば Remix や svelte kit は Server-Side Rendering するサーバーとして Cloudflare Workers で動かせるようになっているのですが、そのために plugin などでプラットフォーム固有の実装をしています。
各種プラットフォームが歩み寄り、ある一定の標準化がなされて共通のAPIになる、と言うのも非現実的な気がしますし、既存の Node.js サーバー向けの、あるいは Web 向けの npm モジュールを Compute@Edge で利用する際に、ランタイムそれ自体やモジュールの対応を待つ、というのも現状は非現実的であり、むしろ webpack plugin やバンドラーの機能を用いて既存のランタイムとの差異をアプリケーションの文脈に応じて埋めていく、というのが一般的になっていくのではないか、と感じています。こうした Compute@Edge のエコシステム上の辛さは同様にエコシステムの発展で解消されていくものであり、我々ユーザーレベルでもアプローチできていくことです。盛り上げていきたいですね💪
終わりに
最近 Edge Computing 基盤がどんどん開発されてきており、さらにそこで動かすことを前提としたライブラリやフレームワークも増えてきました。Edge Computing によってエンドユーザーの体験はさらに良くなりますし、できることも増えていくでしょう。その可能性とロマンを本稿で少しでも感じ取っていただければ幸いです。
以上です。明日は7日目、淵脇さんによる「日経のインターネットの歴史」です。お楽しみに!
関連文献
[1]: Fastly、Compute@Edge の JavaScript 対応を発表, https://prtimes.jp/main/html/rd/p/000000020.000037639.html (accessed 6 December 2021).
[2]: The power of serverless, 72 times over, https://www.fastly.com/blog/the-power-of-serverless-at-the-edge (accessed 6 December 2021).
[3]: The lifecycle and performance of a Lucet instance, https://www.fastly.com/blog/lucet-performance-and-lifecycle (accessed 6 December 2021).