Lambda と CloudWatch Events を利用した定期ジョブを Serverless で管理する
Adventarを支える技術 Advent Calendar 2019 の13日目です。
今日は定期ジョブの実行について書きます。定期ジョブというのは cron とかで定期的にプログラムを実行するやつのことです。
実行するプログラム
Adventar では、定期ジョブは一つだけしかなくて、定期的に投稿された URL のメタデータをフェッチしてくる、というものです。
Adventar は自分の担当の日に URL を投稿すると、その URL のタイトルと画像を取ってきて表示する機能があります。普段は URL を投稿するリクエスト中でその処理をしていますが、これだと予約投稿のような機能を使いたいときに困ります。
例えば 12/10 に自分の番がくるけど、記事はその前に書いて hatena blog の予約投稿で 12/10 の 0 時に公開されるように設定しておく、Adventar にも前日までにその URL をセットしておく、というのはユースケースとして考えられます。なお、このとき Adventar はその日にならないと投稿した URL は投稿者以外からは見えないようになっています(ネタバレになると面白くないので)。しかし、URL が Adventar に登録された時点では hatena blog のほうもまだ公開されていない状態なので、タイトルや画像が取れません。
そこで、1時間に一回定期ジョブを実行して、その日に URL があるけどタイトルや画像が空のレコードに対して、その URL にアクセスしてメタデータを取ってくる、というジョブを動かしたいのです。
ジョブのプログラムは簡単で、こんな感じです。
CloudWatch Events と Lambda
今回は常駐するサーバーがないので cron を実行する環境がありません。いくつか方法はありますが、CloudWatch Events を使うことにしました。CloudWatch Events は cron のような書式で定期的に何かの処理をトリガーすることができるので、今回はこれを利用します。
CloudWatch Events のトリガーには様々なものが設定できますが、今回のケースは、Lambda か ECS tasks でプログラムを実行するのがよさそうです。
どちらでもよかったのですが、Lambda であれば Serverless フレームワークを使って管理することができそうだったので、今回は Lambda で実行することにしました。
追記: これを書いた後で諸事情により ECS に変更したので、現在は ECS で動いています。
Serverless を使った設定
特に難しいことはなくて、以下のような設定を書くだけです。
これで Serverless が Go のビルドからデプロイ、CloudWatch Events の設定まで全部やってくれます。便利。
注意しないといけないのは、時間が UTC でしか設定できないので、JST で 12/1 の 0 時から12/25 まで、という設定は書きのように少しトリッキーな記述が必要です。なお、これだと12/26にも少し実行されますが、実行されても害はないのでさぼってます。
events: - schedule: cron(0 15-23 30 11 ? *) - schedule: cron(0 * 1-25 12 ? *)
まとめ
今日は Serverless で定期ジョブを管理する方法について書きました。明日は Lambda を利用した画像のリサイズサーバーについて書きます。
ecs-cli を利用したAPI サーバーのデプロイ
Adventarを支える技術 Advent Calendar 2019 の12日目です。
今日は API サーバーのデプロイについて書きます。
API サーバーは、4日目の記事 でも書いたように、gRPC のサーバーをたてています。この API サーバーは、Amazon ECS, Fargate を利用してホストしています。
ECS のデプロイツールは色々とあって、仕事で使っているのは hako というツールです。今回は他のツールも使ってみたいというのもあって、AWS 公式が提供している ecs-cli を使ってみました。
https://github.com/aws/amazon-ecs-cli
ecs-cli の他の良い点としては、docker-compose.yml
を設定ファイルとして使えるというところじゃないかなと思っています。docker-compose.yml
であれば、ツール独自のシンタックスを覚えなくても良いのがいいですね。
ただ、当然のようにdocker-compose.yml
だけでは足りなくて、ECS 独自の設定も別途必要になります。それはecs-params.yml
という設定ファルに書くのですが、設定ファイルが分散してしまうのが微妙という見方もありそうです。
Adventar ではそれぞれ以下に設定があります。
ecs-cli のコマンド
compose create service
https://docs.aws.amazon.com/AmazonECS/latest/developerguide/cmd-ecs-cli-compose-service-create.html
サービスを作ります。実行するのは初回だけです。ALB と target group は別途 terraform で作っています。
$ ecs-cli compose --project-name adventar --cluster adventar --region ap-northeast-1 service create --create-log-groups --launch-type FARGATE --container-name envoy --container-port 80 --target-group-arn arn:aws:elasticloadbalancing:ap-northeast-1:287379415997:targetgroup/adventar-api/xxx
compose service up
https://docs.aws.amazon.com/AmazonECS/latest/developerguide/cmd-ecs-cli-compose-service-up.html
これが実質デプロイコマンドです。
docker-compose.yml と ecs-params.yml の設定を見てタスク定義を作り、新しいタスクをデプロイしてくれます。
$ ecs-cli compose --project-name adventar --cluster adventar service up
なお、ドキュメントには
a combination of the create and start commands
とあるので、start
でもいいのかと思いきや、start
だと新しいタスクのデプロイは行わません。また、
This command updates the desired count of the service to 1.
とあるので、desired count が 2 以上だった場合は 1 になるのかと思いきや、既存の desired count をそのまま引き継いでくれます。ドキュメントを読んだだけだとこのあたりの挙動はよくわかりませんでした。
compose service scale
https://docs.aws.amazon.com/AmazonECS/latest/developerguide/cmd-ecs-cli-compose-service-scale.html
タスク数(desired count)を変更します。
$ ecs-cli compose --project-name adventar --cluster adventar service scale 2
docker push
デプロイする際は、ECS のデプロイの前に docker push
する必要があるので、git の HEAD の revision で tag を打って dockdr push
してからデプロイしています。こんな感じです。
TAG=$(git rev-parse HEAD) cd $ROOT_DIR/grpc-server docker build -t hokaccha/adventar-grpc-server:${TAG} . docker push hokaccha/adventar-grpc-server:${TAG} cd $ROOT_DIR/envoy docker build -t hokaccha/adventar-envoy:${TAG} . docker push hokaccha/adventar-envoy:${TAG}
今回はコードもオープンですし、ECR は利用せずに Docker Hub の public なところに push しています。
実際のデプロイスクリプトはこんな感じです。
まとめ
API サーバーを ECS でデプロイする話しを書きました。明日は Lambda と CloudWatch Events を使った定期ジョブについて書きます。
フロントエンドのデプロイ
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 を実際のアプリケーションに組み込んだときに工夫した点や苦労した点について書きたいと思います。