フロントエンドのデプロイ
Adventarを支える技術 Advent Calendar 2019 の11日目です。
今日はフロントエンドのデプロイについて書きます。フロントエンドの構成は昨日の記事で書いたとおり、一部を Lambda で SSR していて、静的コンテンツは S3 で配信しています。
なので、S3 と Lambda の両方にデプロイする必要があってやや面倒です。
S3 へのデプロイ
まず、静的コンテンツの S3 へのデプロイ自体は簡単で、nuxt build
で生成された成果物(デフォルトではdist
ディレクトリ)を S3 にアップロードするだけです。
$ nuxt build $ aws s3 sync ./dist/ s3://your-bucket/prefix
SSR しなければ、これだけで Nuxt.js のアプリケーションは配信できます。注意する点としては、SPA の場合クライアント側でルーティングするので、すべてのパスに対して index.html
を返す必要があるという点です。
CloudFront と S3 でやる場合は、ファイルが存在しない場合 CloudFront には S3 から 403 が返るので、これをハンドリングして index.html
を返します。terraform で書くとこんな感じです。
custom_error_response { error_code = "403" response_code = "200" response_page_path = "/index.html" }
SSR のためのビルド
今回、一部だけ SSR するという構成にしたのでめんどくさかった点がこれで、Nuxt.js は SPA の場合は spa mode でビルド、SSR の場合は universal mode でビルドする必要があります。
https://nuxtjs.org/api/configuration-mode/
なので、ビルドプロセスも両方でやる必要があるのですが、spa mode と universal mode でビルドした JS ファイルのダイジェスト値が微妙に食い違ったりしたということがありました。そうするとどうなるかという、spa mode でビルドして S3 にアップロードした中に、universal mode からで生成した HTML に含まれる JS のファイル名がなくて JS が 404 になって動かない、ということが発生しました。たぶんこんな使い方想定してないのでエッジケースだと思います。
Nuxt.js を直すほどの元気はなかったので、universal mode でビルドした結果も S3 にアップすることでしのぎました。なんとかこれで動いてます。
以下が苦肉の策のコードです。
Serverless フレームワークを使った API Gateway と Lambda のデプロイ
次に、SSR するための Lambda のデプロイですが、これには今回 Serverless Framework を使いました。API Gateway や Lambda のデプロイは、Serverless や AWS SAM などを使うと便利です。SAM でもよかったのですが、今回は知見のあった Serverless を採用しました。
Serverless でのデプロイはそんなに難しいことはしていなくて、以下のような設定ファイルを書くだけです。
これで Nuxt.js のビルドを実行後に
$ serverless deploy
で API Gateway と Lambda にデプロイできます。
また、サーバーのコードも量が少ないとはいえ TypeScript で管理したいのと、一部クライアントの TypeScript のコードに依存があるので webpack で変換して bundle することにしました。
本当は Lambda にアップロードする容量を削減のため、必要なコードだけ bundle して一枚の JS にてアップロードしたかったのですが、色々とうまくいかずに bundle するのは諦め、webpakc で node_modules の解決は無視して、node_moduels をアップロードすることにして対応しました。Lambda の容量の上限に引っかかるほど大きくはないので一旦これで妥協しています。
キャッシュのパージ
今回 S3 にアップするアセットは CloudFront でキャッシュしています。JS や CSS はダイジェスト値がついていてファイル名がユニークになるのでキャッシュのパージは必要ありませんが、index.html
や sw.js
などはキャッシュのパージが必要です。また、本来は SSR した結果もキャッシュしたいと思っていたので、上記に書いたデプロイの処理をおこなったあとにキャッシュをパージする必要があります。CloudFront のキャッシュのパージは以下のようにします。
aws cloudfront create-invalidation --distribution-id xxx --paths '/*'
最終的な、デプロイスクリプトは以下ような感じです。
まとめ
フロントエンドのデプロイについて書きました。Nuxt.js と Serverless のおかげで、そんなに複雑には見えませんが、ここまでたどり着くにはけっこうな時間を消費しました...。
明日はこの流れで API サーバーのデプロイについて書きます。
Server Side Rendering の技術概要
Adventarを支える技術 Advent Calendar 2019 の10日目です。
今日は Adventar の Server Side Rendering(以下 SSR)している技術構成について書きます。
インフラ構成
まず、先日の記事に書いたように、Adventar ではすべてのページを SSR しているのではなく、カレンダーの詳細ページだけを SSR しています。SSR するカレンダー詳細は API Gateway で受けて Lambda でレンダリングしており、そうでない静的なアセットは S3 で配信しています。その前段のルーティングは CloufFront でおこなっています。
S3 で捌いているのは、JS や CSS などの静的ファイルは当然ですが、例えばトップページなどの HTML も S3 が返します。ただしこの HTML は SSR されていない、JS と CSS のリンクだけしかない HTML で、クライアントサイドで API を呼び出してコンテンツをレンダリングします。
なぜ Lambda か
Lambda でも Cloud Functions でもよかったのですが、単に他のインフラ構成要素が AWS になったからで、Cloud Functions との技術的な比較はしていません。
常駐型のサーバーと比べて Lambda などのサーバーレス環境を利用するのは、管理コスト(費用、手間)が安く済むというのが大きいでしょう。うまく使えば安く済むし、サーバーを管理しなくていいというのは非常に大きいメリットです。
一方デメリットとしては、パフォーマンスなどが挙げられると思います。Lambda は遅くはないですが、コールドスタートなどのデメリットもあるし、常駐型のサーバーと比べるとパフォーマンスの最適化はしづらい側面があると思っています。これについては、先日発表された Provisioned Concurrency によって解決できるかもしれないですけどまだちゃんと試してません。
https://aws.amazon.com/jp/blogs/aws/new-provisioned-concurrency-for-lambda-functions/
今回は SSR した結果を CDN でキャッシュするという予定だったので、パフォーマンス、費用面ともに常駐サーバーよりは有利になる予定でした。前日にも書いたとおり、現状は一旦キャッシュの実装は見送って毎回 Lambda が呼ばれていますが、費用的にはサーバーを用意するよりは安く済みそうです。パフォーマンスは HTML のレスポンスタイムが200ms〜300ms ぐらいかかっているので、速くはないが、激遅でもないという感じです。
Lambda で Nuxt.js の SSR
Nuxt.js は
$ nuxt start
で SSR なサーバーを起動することができ、レールを外れずに使う場合はこれでいいのですが、今回は Lambda を使って SSR したいのでこれは使えません。
Nuxt.js はカスタムサーバーを利用する場合、nuxt.render
という express の middleware などと互換性のある API を提供しているのでこれを使います。
https://nuxtjs.org/api/nuxt-render/
また、Lambda で express などのサーバーを利用するための serverless-http というモジュールがあるので、これも利用します。
https://www.npmjs.com/package/serverless-http
これらを利用すると、以下のような感じで Nuxt.js を Lambda で利用できます。
import { Nuxt } from "nuxt"; import serverless from "serverless-http"; import express from "express"; import config from "~/nuxt.config"; const app = express(); const nuxt = new Nuxt(config); app.use(nuxt.render); module.exports.handler = serverless(app);
他にも RSS などの動的コンテンツを同時に配信しているので、少し複雑になっていますが、コードは以下のような感じです。
これを Lambda で実行すれば、クライアント側で同じコードで HTML が生成されて返ってきます。前述したように、API Gateway を通してカレンダーページだけでこれを実行しているわけです。
まとめ
Adventar における SSR の技術構成について書きました。明日は SSR を含めた、フロントエンドのデプロイについて書こうと思います。
Adventar における Server Side Rendering の導入
Adventarを支える技術 Advent Calendar 2019 の9日目です。
Adventar のフロントエンドは Nuxt.js で SPA な構成ですが、一部で Server Side Rendering(以下 SSR)をおこなっています。今日は SSR をする理由や Adventar での導入方法について書きます。
なぜ SSR するのか
SPA で SSR が必要な理由は主に
- マシンリーダビリティ(SEO とか OGP とか)
- パフォーマンス
の2つだと思っています。SPA は基本的に JavaScript で API を呼び出してコンテンツをレンダリングするので、JavaScript を理解できないクライアントには正しいコンテンツを提供できません。典型的な例は検索エンジンのクローラーや、OGP などでサイトのコンテンツを取得するケースです。
SEO については最近のクローラーが JavaScript を理解してくれるので SSR 必須ではないと思います。OGP は、Adventar においてはカレンダーページのタイトルと概要テキストだけどうにかできればいいので、Edge Side(Lambda@Edge など)でどうにかして差し込むなどの方法があるかもしれません。
一方パフォーマンスについてはちゃんとやればかなり効果があると思っています。一般的な SPA をレンダリングする場合ユーザーがアクセスしてコンテンツをレンダリングするまでには
- HTML を取得する
- JavaScript を取得する
- JavaScript を実行して API コールする
- API の結果を元に DOM を構築する
というフローがあります。SSR だと最初の HTML を受け取った時点でコンテンツ情報もできているので、ユーザーは即座にコンテンツを閲覧することができ、パフォーマンス的には優位です。
もちろん SPA が返す、JS と CSS 以外何もコンテンツがない HTML と比べると、SSR の HTML をつくる過程にはサーバー側で API コールなり、SQL の呼び出しがあり、それを元に HTML を構築する処理が走るので、SPA のような を作るよりも時間がかかります。とはいえ、ネットワーク的なレイテンシの少ない SSR のほうが基本的に速くなるほうが多いでしょう。しかし、サイトの特性によっては初回ロードの遅延がそこまで気にならない場合もあるでしょう。
一方で、SSR によるパフォーマンス的な最大のメリットは、キャッシュによるパフォーマンス最適化がやりやすいところにあると思っています。SSR した結果を CDN などでキャッシュして適切にパージができれば、パフォーマンスは超速になります。
ただし、このパージがけっこう大変で、当初は CDN でキャッシュするアーキテクチャを前提で設計していましたが、色々と考えた結果パージに思ったよりコスト(実装コスト、お金的なコスト両方で)がかかるので結局 CDN のキャッシュは今年はいったん諦めて、リクエスト毎に SSR しています。諦めた理由などについては後日詳しく書こうと思います。来年やれたらやりたいです。
このように、ちゃんとやればいくつかのメリットがある SSR ですが、正直実装コスト、運用コストが高すぎてメリットに見合うかは微妙なところだと思っています。実際、今回実装してみてそれを実感しました。
やはりブラウザとNode.jsという実行環境が全然違う環境で同一のコードを実行するというのは、理論上は可能でも色々と困難です。5日目の記事にもその一部を書きましたし、この他にも実装コスト、運用コストがかなり高いです。
今回 Adventar で SSR を導入するのに費用対効果はあまり考えてなくて、単なる技術的なチャレンジという意味合いが強いです。Lambda を使ったサーバーレスな環境での SSR というのを試したかったので導入しました。
どこに SSR を導入するか
今回、まず決めたのは SSR において認証情報を扱わない、ということです。今回は認証に Firebase Authentication を利用しているのもあり、認証をクライアントサイドで行っており、最初のリクエストにセッション情報をリクエストしていません。
Cookie に認証情報を乗せて頑張ればできないことはないのですが、API サーバーと SSR のサーバーで認証の機構を共有するなど、かなりの実装コストがかかりまし、SSR した結果を CDN でキャッシュする場合、レスポンスの HTML に認証された状態のものを乗せるのは複雑度が爆増するのが目に見てみます。
また、SSR した結果をキャッシュに乗せることを考えると、キャッシュのパージ戦略を考える必要があります。キャッシュのパージは対象のコンテンツとページが多くなるほど複雑になって制御が難しくなるので、できるだけ局所的にするのが良いと思っています。そのような理由から、今回 SSR するのはカレンダー詳細ページだけに限定することにしました。カレンダー詳細ページというは以下のような画面です。
https://adventar.org/calendars/3860
この画面は、ソーシャルメディアなどでもよく共有されるので、OGP を SSR で埋め込む必要もあるし、アクセスの8割はこの画面に最初に着弾するので、初期描画のパフォーマンスが最適化されることに大きい意味があるためです。
SSR 画面を限定したことで、すべて SSR するよりはトータルの実装コストは低くなったとは思いますが、最初にロードする画面が SSR 対象(カレンダー詳細画面)か、そうでないかによって微妙に挙動が変わって、どちらかでしか起きないバグが発生したり、デプロイのフローが複雑になったりと、それはそれで大変でした。
まとめ
今回は SSR のメリットや、Adventar における導入事例を紹介しました。明日はもう少し具体的な、Nuxt.js を Lambda で SSR する場合の技術的なトピックについて紹介したいと思います。
Firebase Authentication の苦労話
Adventarを支える技術 Advent Calendar 2019 の8日目です。
今日は Firebase Authentication で苦労した点や工夫した点について書きます。
ログインの判定が遅い問題
Firebase Authentication を使って、現在のセッションがログインしているかどうかを検出するには、firebase.auth().onAuthStateChanged
()
を呼び出します。
https://firebase.google.com/docs/auth/web/manage-users
firebase.auth().onAuthStateChanged(function(user) { if (user) { // User is signed in. } else { // No user is signed in. } });
しかし、この処理は呼び出してから初回のイベントが発火するのに数秒ぐらいレイテンシがあります。なので、愚直にこの機能を使うと、ページがロードされて、ログインしていれば数秒後にログインしている状態(自分のアイコンがヘッダに表示されたり、自分の投稿に編集ボタンが出たり)に切り替わる、ということになり、いい体験ではありません。
そこでログイン状態は localStorage
にキャッシュすることにしました。キャッシュしているコードはこのへんです。
- https://github.com/adventar/adventar/blob/f580de20510f9debe6356a5ad193c4532d8f6a0d/frontend/lib/Auth.ts#L118
- https://github.com/adventar/adventar/blob/f580de20510f9debe6356a5ad193c4532d8f6a0d/frontend/store/index.ts
キャッシュした情報は plugin でリストアしています。
この処理は初期描画の直前に一回だけ実行されるので、描画と同時にログイン状態がわかります。機密情報をlocalStorage
に保存するのはセキュリティ的なアレがありますが、今回保存するのは token そのものでなく、表示に必要なデータだけにしているのでそのあたりは安心です。
これでほぼよさそうに見えますが、キャッシュの状態が不整合になる可能性を考えてみましょう。
- 別タブでログアウトした/別のアカウントでログインした
- ログイン処理を終えてリダイレクトされて戻ってきた
1 のケースは問題にならなくて、firebase.auth().onAuthStateChanged()
のイベントはユーザーのログイン状態が変わったら発火するイベントリスナーなのですが、別タブでログイン/ログアウトしたような場合でもイベントが発火します。なので、別タブでログイン/ログアウトしてもユーザーの状態は同期されます。
2のケースは、ログインプロバイダのログイン画面から戻ってきたときの話しです。ログイン画面から戻ってきた瞬間なので、キャッシュの状態は未ログイン状態のはずですが、実際にはプロバイダでのログインが成功していればログイン状態となるはずです。なので、キャッシュと実際の状態が不整合になるのは実はこの場合だけです。
このケースは、ログイン処理中であるというローディングを出すことで対応しました。最初は画面全体をローディングにしていましたが、状態が変わってから再描画するところはそんなに多くなく、ログイン処理中というのがわかればいいので、ヘッダのアイコンのところだけ局所的にローディングすることにしました。
ただ、プロバイダのログイン画面から戻ってきた、という状態を取る方法がなさそうだったので、リダイレクト前に sessionStorage
に適当な値をセットして、次に初期化処理が走った場合はリダイレクトから帰ってきたとみなして、firebase.auth().onAuthStateChanged()
が発火するまでローディングを出します。
唯一不自然になるパスは、ログインを押してログイン画面に遷移後、ログインを完了せずに戻ってくるパターンですが、まあそんなに致命的な問題でもないので許容しています。
アイコン URL が更新されない問題
Adventar ではユーザーアイコンに OAuth でとってきた画像を使っています。例えば Twitter でユーザーがアイコンを変えた場合には Adventar でも新しいアイコンになってほしいので、ログインの処理のときにアイコンをアップデートする処理をいれています。コードはこのあたりです。
しかしこれではダメで、Firebase Authentication は最初にログインしたときの情報を Firebase Authentication 側にストアしてもっているらしく、ユーザーが Twitter 側でアイコンを更新しても、Firebase Authentication 側のデータは更新されないようです。
いくつか方法はありそうですが、一旦今はログアウト時に Firebase Authentication 側のユーザーを消すという方法を取っています。
export async function logoutWithFirebase() { const user = firebase.auth().currentUser; if (!user) return; try { await user.delete(); } catch (err) { console.error(err); } await firebase.auth().signOut(); }
Adventar 側では特に Firebase Authentication の User ID などは使ってないので、ログアウト時に消しても問題ないためです。ただ、ユーザーとしてはアイコンを更新するためにログアウトが必要、というのは非常にわかりにくいので、もう少しいい方法を考えたいところです。
Firebase Authentication でログイン時にプロバイダの API のアクセストークンが取れるので、それで API をこちらで叩いて最新のアイコンを取得して更新、という感じになるかなと思っています(が、けっこうめんどくさいので後回しになっている)。
third-patry cookie が無効な場合にログインできない問題
これはそのままなのですが、Firebase Authentication は third-patry cookie が無効な場合はログインが失敗するという問題があります。
https://github.com/firebase/firebase-js-sdk/issues/934
これはもう仕方ないので、ログイン時のエラーをハンドリングして、Cookie によるエラーの場合は third-party cookie が無効な可能性がある、というメッセージを表示することにしました。
const COOKIE_ERROR_MSG = "third-party cookie の設定が無効になってる可能性があります。ブラウザの設定をご確認ください。"; const msg = err.code === "auth/web-storage-unsupported" ? COOKIE_ERROR_MSG : err.message; alert(`ログインに失敗しました。\n${msg}`); console.error(err);
GitHub 認証の場合にユーザー名が空のケースがある問題
Firebase Authentication から取得できる JWT の中には名前やプロフィールアイコンのURLなどが以下のような形で格納されています。
{ "name": "Kazuhito Hokamura", "picture": "https://lh3.googleusercontent.com/a-/AAuE7mDtNB98Nu2WHMoeBUs1x3XrNqCrav4GnZRTbwMB8g", "user_id": "xxx", "sub": "xxx", "iat": 1234567890, "exp": 1234567890, "firebase": { "identities": { "google.com": [ "xxx" ] }, "sign_in_provider": "google.com" } }
この name
や picture
がそうです。Google や Twitter などのプロバイダに設定してある名前やアイコン画像が取れるのですが、GitHub の場合、name
が含まれない場合がありました。
GitHub は名前の入力が必須ではないので、入力してないユーザーの場合はこのフィールドがない状態の token になります。名前は必ず存在する、という前提でコードを書いていたので、そのような状態のユーザーがログインしようとするとエラーになってログインできない状態になっていました。
空の場合は user_id (github.com/xxx
の xxx
)の部分が取れればいいのですが、Firebase Authentication 経由だとその情報は取れなさそうだった(もちろん自前でアクセストークン使って GitHub の API を自前で呼び出せばとれますが)ので諦めて"No Name"のような名前でユーザーを作ることで対応しました。
まとめ
Firebase Authentication でハマった点などをいくつか紹介しました。慣れてないので色々とハマりましたが、やはり自前で認証の仕組みを用意するのに比べると格段に楽でよかったです。
明日は Server Side Rendering のについて書きます。
Firebase Authentication を利用した認証
Adventarを支える技術 Advent Calendar 2019 の7日目です。
今日は Firebase Authentication 認証について書きます。
選定理由
まず、なぜ今回 Firebase Authentication を利用することに決めたかを説明します。検討した選択肢としては
- Firebase Authentication
- Amazon Cognito
- Auth0
- 自前でがんばる
あたりです。このうち自前でがんばるのは大変なので最後の手段とし、可能な限りマネージドなサービスを検討しました。Rails 時代はセッションを Cookie ストアにしていたので、サーバー側でデータストアが不要だったりしてそんなに面倒ではなかったのですが、安全面を考えると Cookie ストアはやめたいと思っていたし、JWT にするにしても、結局安全面を考えると expire を短くしてリフレッシュトークンを使うことになりますが、リフレッシュトークンの管理をするのが面倒だったりします。なのでそのあたりをマネージドでできるのであればマネージドがいい。
マネージドサービスを検討するにあたり、考えないといけなかったのは過去のデータとの互換性です。ログインシステムを変更することで過去に作ったカレンダーや投稿を管理できなくなるのは避けたいところです。
Adventar ではこれまで、認証には omniauth という Gem を利用していました。omuniauth は色々な OAuth プロバイダを利用して認証システムを作ることができるライブラリです。Adventar では Google, Twitter, Facebook, GitHub をプロバイダとして利用していました。
なので、前提条件として、Google, Twitter, Facebook, GitHub の OAuth には絶対に対応している必要がありました。それを考えると Amazon Cognito は Twitter, GitHub のログインに対応していないので採用を見送りました。
Auth0 と Firebase Authentication はどちらも要件を満たしていましたが、Firebase Authentication はなんと無料で使えるということだったので、Firebase Authentication が今回のユースケースで使えるかを検証し、問題なさそうだったので今回利用を決めました。
Auth0 も Adventar ぐらいの規模だと無料か一番安いプラン($23)ぐらいで利用できそうではありましたが、個人開発にとって $23 はかなりでかいのです。
アーキテクチャ
Firebase Authentication は Firebase のその他の機能と連携すると、おそらくそんなに面倒なことはないのですが、今回は Firebase Authentication 以外に Firebase の機能は使わないので、Firebase Authentication を使った認証の仕組みを自前のサーバーに組み込む必要があります。
サーバー側が必要な情報は、ログインプロバイダと OAuth の uid 、名前とプロフィールアイコンの4つです。旧システムではログインプロバイダと OAuth の uid ユーザーの組み合わせを一意なユーザーの識別子として利用していたので、これがないとユーザーの識別ができません。名前とプロフィールアイコンは表示に使うだけなので、あれば嬉しいという感じです。これらを Firebase Authentication の API から取得してサーバーに送ることができればいいわけです。
Firebase Authentication ではまず最初に、クライアント側で認証の処理を行います。要点だけ説明していきますので、詳細はドキュメントを見てください。
https://firebase.google.com/docs/auth/web/firebaseui
まず、ユーザーが Adventar から、「Google でログイン」を押すと以下の処理が実行されます(API Key などは別途設定しています)。
firebase.auth().signInWithRedirect(new firebase.auth.GoogleAuthProvider());
これでユーザーは Firebase Authentication のサイトを経由して Google 認証の画面に飛びます。ユーザーが Google の認証情報を入力して成功すると Adventar に戻ってくるわけですが、このとき以下のようなコードで JWT のトークンを取得することができます。
firebase.auth().onAuthStateChanged(async user => { const token = await user.getIdToken(); });
実際のコードはこのあたりです。
この token
の実態は JWT で、前述したログインプロバイダ、uid、名前、プロフィールアイコンなどのほしかった情報が入っています。
なので、これをサーバー側に投げれば Adventar が持っているデータと突き合わせてユーザーを識別できるのですが、この token が改ざんされていないかを確認するため、JWT の検証が必要です。
https://firebase.google.com/docs/auth/admin/verify-id-tokens
今回はアプリケーションサーバーに Go を使っていますが、Firebase Authentication の Go SDK に JWT の検証機能があるのでこれを使います。
json := os.Getenv("FIREBASE_CREDENTIAL_JSON") if json == "" { return nil, fmt.Errorf("FIREBASE_CREDENTIAL_JSON is empty") } opt := option.WithCredentialsJSON([]byte(json)) app, err := firebase.NewApp(context.Background(), nil, opt) if err != nil { return nil, xerrors.Errorf("Failed to initialize firebase app: %w", err) } client, err := app.Auth(context.Background()) if err != nil { return nil, xerrors.Errorf("Failed to get auth client: %w", err) } token, err := client.VerifyIDToken(context.Background(), idToken) if err != nil { return nil, xerrors.Errorf("Failed to verify token: %w", err) }
こんな感じです。実際のコードは以下にあります。
検証が済んだらこの token に含まれるデータを信頼できるので、それを使ってユーザーの認証を行います。
まとめ
Firebase Authentication を選んだ理由や基本的な認証の仕組みについて解説しました。
明日は Firebase Authentication を実際のアプリケーションに組み込んだときに工夫した点や苦労した点について書きたいと思います。
envoy の gRPC proxy に関する便利機能
Adventarを支える技術 Advent Calendar 2019 の6日目です。
今日は envoy の gRPC に関する便利機能について紹介しようと思います。
gRPC-Web proxy
4日目の記事でも書きましたが、今回は gRPC-Web の proxy レイヤーとして envoy を利用しています。envoy で gRPC-Web の機能を有効するのは簡単で、HTTP filters に envoy.grpc_web
を書いて、ヘッダの設定をするだけです。
- https://github.com/adventar/adventar/blob/f580de20510f9debe6356a5ad193c4532d8f6a0d/api-server/envoy/envoy-prod.yaml#L44
- https://github.com/adventar/adventar/blob/f580de20510f9debe6356a5ad193c4532d8f6a0d/api-server/envoy/envoy-prod.yaml#L36-L38
これだけで gRPC-Web を受けて、upstream のクラスタに gRPC でリクエストするようになります。超簡単。他に書くことがありません。
gRPC-JSON transcoder
5日目の記事に書きましたが、Adventar では、gRPC と JSON API の両方を envoy によって実現しています。JSON API で受けて、upstream の gRPC に流すのは、envoy の gRPC-JSON transcoder という機能を使っています。
https://www.envoyproxy.io/docs/envoy/latest/api-v2/config/filter/http/transcoder/v2/transcoder.proto
これは grpc-gateway と同じようなものです。Protocol Buffers の定義から生成したスキーマを envoy が読んで、gRPC をバックエンドにした JSON API を提供します。例えば、次のような Protocol Buffers を定義します。
syntax = "proto3"; package adventar.v1; import "google/api/annotations.proto"; message GetCalendarRequest { int64 id = 1; } message Calendar { int64 id = 1; string title = 2; ... } service Adventar { rpc GetCalendar(GetCalendarRequest) returns (Calendar) { option (google.api.http) = { get : "/v1/calendars/" } } }
option
で指定しているのは、grpc-gateway でも使われているもので、 gRPC と JSON API のマッピングを定義するためのものです。
envoy 側の設定は簡単で、envoy.grpc_json_transcoder
を設定するだけです。
ここで指定ているproto.pb
は以下のようなコマンドで出力しています。
$ protoc \ --include_imports \ --include_source_info \ --descriptor_set_out=../api-server/envoy/proto.pb \ adventar/v1/*.proto
これで envoy と gRPC サーバーを起動すると、以下のようにアクセスすることが可能になります。
$ curl http://localhot:8080/v1/calendars?id=1 | jq { "id": 1, "title": "xxx", ... }
クライアント側が gRPC を使えなくても、普通の HTTP で通信できるので、様々な場面で便利な機能だと思います。
auto_mapping
gRPC-JSON transcoder は便利なのですが、Protocol Buffers の定義に
option (google.api.http) = { get : "/v1/calendars/" }
のようなアノテーションを書かないといけないのが面倒です。これを解消するのに、envoy v1.11.0 から auto_mapping という機能が追加されました。
https://github.com/envoyproxy/envoy/pull/6731
これはgoogle.api.http
の定義を書かなくても、POST /<package>.<service>/<method>
という URL でアクセス可能になるという機能です。
設定は簡単でauto_mapping: true
と書くだけです。
これで以下のようにアクセスできます。
$ curl -X POST -d '{"calendar_id":1}' https://localhost:8080/adventar.v1.Adventar/GetCalendar | jq { "id": 1, "title": "xxx", ... }
Adventar ではこの機能を使っているので、google.api.http
の定義は書いていません。
(余談)google.api.http を捨てられていない理由
google.api.http
の定義は書いていません、と言いつつ、実は一番下にgoogle.api.http
の記述があるのに気づいたと思いますが、これは gRPC サーバーの前に立てている AWS Application Load Balancer(ALB) のヘルスチェックが GET でしかできず、auto_mapping は POST にしか対応してないので、しかたなく書いています。
ちなみに ALB は今現在 HTTP/2 の pass through に対応していないので、普通は gRPC の前段に置くことはできなくて色々と面倒なのですが、今回は gRPC-Web と JSON API しか通しておらず、直接 ALB が gRPC をしゃべる必要がないので ALB が利用できて便利です。このあたりのインフラの概要については後日書く予定でいます。
明日は Firebase Authentication について書く予定です。
gRPC-Web と Server Side Rendering の苦労話
Adventarを支える技術 Advent Calendar 2019 の5日目です。
今日は gRPC-Web 導入にあたって最も苦労した Server Side Rendering(以降は SSR と書きます)の話を書きます。
前提条件
初日の記事 に書いたとおり、今回の Adventar のシステム変更の目的に、
- gRPC-Web を使う
- Serverless な仕組みで SSR をおこなう
というのを入れました。趣味プロダクトなので技術が目的になっています。なので、gRPC-Web をやめる、SSR をしない、という選択肢はそもそも取らない、という前提で読んでください。(どちらかをやめることができればどれほど楽だったか...)
また、ここでいう SSR とは Nuxt.js などで作った JavaScript のアプリケーションを同一コードでサーバー側でも動かして HTML をレンダリングする、という文脈のやつです。Universal JavaScript と言い換えてもよいです。
個人的には SPA の SSR は実装難易度や運用コストが増えるので、やらないで済むならやらないほうがいい派なんですが、今回は普段やらないようなこともやってみるというのも目的の一つにありました。
gRPC-Web が Node.js で利用できない問題
gRPC-Web の自動生成したクライアントは完全にブラウザ用になっていて、Node.js では利用できません。何が困るかというと、SSR する際にブラウザとサーバーで同一のコードが使えないので詰んでしまいます。
ブラウザ用の Polyfill などを入れてみたりしましたが、どうしても動かないので諦めました。正攻法は gRPC-Web のクライアント生成に手を入れて Node.js 用のトランスポートオプションをサポートすることだと思いますが、C++ (protoc の gRPC-Web プラグインの実装言語)が苦手すぎて今回は諦めました。
そこで今回は妥協案として、Node.js 向けに JSON API を提供することにしました。JSON API というのは普通に ContentType: application/json
で通信する API という意味です。つまりブラウザと API サーバー間は gRPC-Web で通信しますが、SSR する場合の Node.js と API サーバー間は JSON API で通信します。最悪な妥協策というのは理解しています。
envoy を使った JSON API Proxy
API を2種類用意するといっても、サーバーを別途を立てたわけではありません。gRPC-Web の proxy として利用している envoy に、JSON API を gRPC に変換する機能があるため、gRPC-Web と JSON API を同一のプロセスで処理できます。
envoy のこの機能については後日詳しく紹介しますが、少し設定を足すだけで済むので、サーバー側は特に手間はかかりません。2つの API があることで面倒なのはクライアント側だけということです。
クライアント側のコード
クライアント側の対応はサーバー側よりは面倒ですが、そこまで複雑ではありません。以下のように gRPC のクライアントと JSON API のクライアントを用意します。
そして以下のように ブラウザが実行する場合は gRPC、SSR の場合は JSON API を呼び出すだけです。
- gRPC の呼び出し
mounted
は SSR では呼び出されないのでクライアント側でしか実行されない
- JSON API の呼び出し
process.server
は SSR の場合だけtrue
になる
SSR は基本的に更新系のAPIを呼び出すことはないですし、今回は SSR するのをカレンダーの詳細ページ( https://adventar.org/calendars/3860 のようなページ)だけに留めたので、最終的にはこれぐらいのコード量でなんとかなりました。しかしここに行き着くまでにはだいぶ苦労しました...。
まとめ
仕事だったらおそらく頑張って Node.js のトランスポートオプションを実装するか、gRPC-Web を諦めて JSON API に寄せるか、SSR を諦めて別の方法を取る道をさぐったかもしれませんが、今回は前提条件にもあるように、gRPW-Web と SSR は妥協できなかったのでこのような方法を採用しました。来年時間が取れれば gRPC-Web に Node.js サポートパッチを書きたいです。
明日は envoy の便利機能について書く予定です。