Lambda を使った画像のリサイズサーバー

Adventarを支える技術 Advent Calendar 2019 の14日目です。

今日は Lambda , API Gateway, CloudFront などを使って、画像のリサイズサーバーを作る話を書きます。

Adventar における画像のリサイズ

Adventar は記事を投稿した際に、記事の関連画像を取ってきて表示する機能があります。

f:id:hokaccha:20191214213045p:plain

これは OGP などのメタタグから画像の URL を取得していますが、取得した URL をそのまま使用して画像を取得すると、いくつか問題があります。

  • 毎回リクエストが飛ぶので行儀が悪い
  • 画像の URL がhttp(非 SSL) の場合に mixed contents になる
  • 画像サイズが大きいとパフォーマンスに影響する(OGP の画像は 1000px 超えで設定してるケースも多い)
    • Adventar でほしいのはせいぜい、100px〜200pxぐらい

これを解決するために、取得した画像をhttpsで配信し、適切なサイズにリサイズし、キャッシュするようなサーバーがあるとよさそうです。

画像のリサイズは FastlyImageFlux などのサービス、ngx_small_light などの OSS を使うなど、様々な方法がありますが、今回は自前で画像を持っているわけではなく、外部サイトの画像を取得して利用したいという少し違うユースケースというのもあり、Lambda と CloudFront で自作することにしました。

といっても全然難しい仕組みではなく、画像 URL を受け取ってリサイズして画像を返す Go のプログラムを Lambda で動かして、CloudFront でキャッシュしているだけです。Go のコードも100行ちょっとの簡単なものです。

https://github.com/adventar/adventar/blob/10b9b2386f76d1fd66c10c31d4dd0550f6d0527d/image-server/main.go

いくつか工夫していているとこがあるので説明します。

DB に依存させない

まず、一つ目が DB に依存せずに動作させるということです。画像の URL は、DB に保存されていますので、画像 URL を知るためには、カレンダーの投稿 ID を受け取って DB にアクセスし画像 URL を取ってくる、ということが必要になりますが、そのためだけに DB にアクセスするのはめんどくさいので、今回はリクエスト URL に直接画像の URL を埋め込んでいます。

https://img.adventar.org/?url=<画像URL>

のような感じです。Lambda でこの画像 URL を fetch して、適当なサイズにリサイズして返します。本当はサイズの指定もクエリでできるようにしたいのですが、とりあえず今は1サイズしかないので固定にしています。

https://github.com/adventar/adventar/blob/10b9b2386f76d1fd66c10c31d4dd0550f6d0527d/image-server/main.go#L84

ハードコードでだいぶひどい感じですが、かなりギリギリになって実装したので雑コードです(投稿が始まる12月までにあればいいので実装を一番最後に回した)。

ダイジェスト値による検証

ただ、これだと任意の URL のプロキシになってしまってよろしくないので、ダイジェスト値を設定して URL が改ざんされていないかを検証しています。OSS なのでコードを読めばわかりますが、単に画像 URL に SALT を混ぜて sha1 を取っているだけの簡単なものです。なので最終的な URL はこのような感じになっています。

https://img.adventar.org/img/<digest>?url=<画像URL>

実際の URL はこんな感じです。

https://img.adventar.org/img/c96546f133a9e2803dd7bb033681ffcf81146cb0?url=https%3A%2F%2Fcdn-ak.f.st-hatena.com%2Fimages%2Ffotolife%2Fh%2Fhokaccha%2F20191203%2F20191203213418.png

デプロイ

フロントエンドのデプロイバッチジョブのデプロイ にも利用した Serverless を利用しました。今回 Serverless 大活躍です。設定はこんな感じで何も難しくない。

https://github.com/adventar/adventar/blob/10b9b2386f76d1fd66c10c31d4dd0550f6d0527d/image-server/serverless.yml

デプロイスクリプト

$ serverless deploy

で終了。

https://github.com/adventar/adventar/blob/10b9b2386f76d1fd66c10c31d4dd0550f6d0527d/image-server/Makefile#L10

まとめ

Lambda を使った画像のリサイズサーバーについて書きました。明日は DB のスキーマ管理について書きます。

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 にアクセスしてメタデータを取ってくる、というジョブを動かしたいのです。

ジョブのプログラムは簡単で、こんな感じです。

https://github.com/adventar/adventar/blob/fa0714e49ea3aa60888532b60b924af0c12bbc80/batch/update_entry_site_info/main.go

CloudWatch Events と Lambda

今回は常駐するサーバーがないので cron を実行する環境がありません。いくつか方法はありますが、CloudWatch Events を使うことにしました。CloudWatch Events は cron のような書式で定期的に何かの処理をトリガーすることができるので、今回はこれを利用します。

CloudWatch Events のトリガーには様々なものが設定できますが、今回のケースは、Lambda か ECS tasks でプログラムを実行するのがよさそうです。

どちらでもよかったのですが、Lambda であれば Serverless フレームワークを使って管理することができそうだったので、今回は Lambda で実行することにしました。

追記: これを書いた後で諸事情により ECS に変更したので、現在は ECS で動いています。

Serverless を使った設定

特に難しいことはなくて、以下のような設定を書くだけです。

https://github.com/adventar/adventar/blob/fa0714e49ea3aa60888532b60b924af0c12bbc80/batch/serverless.yml

これで 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 しています。

実際のデプロイスクリプトはこんな感じです。

https://github.com/adventar/adventar/blob/fa0714e49ea3aa60888532b60b924af0c12bbc80/api-server/bin/deploy

まとめ

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"
}

https://github.com/adventar/adventar/blob/b491c3be64b8d35b3484b69e4f9ce8f6ecfb93eb/terraform/cloudfront.tf#L38-L42

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 にアップすることでしのぎました。なんとかこれで動いてます。

以下が苦肉の策のコードです。

https://github.com/adventar/adventar/blob/fa0714e49ea3aa60888532b60b924af0c12bbc80/frontend/bin/deploy#L11

Serverless フレームワークを使った API Gateway と Lambda のデプロイ

次に、SSR するための Lambda のデプロイですが、これには今回 Serverless Framework を使いました。API Gateway や Lambda のデプロイは、Serverless や AWS SAM などを使うと便利です。SAM でもよかったのですが、今回は知見のあった Serverless を採用しました。

Serverless でのデプロイはそんなに難しいことはしていなくて、以下のような設定ファイルを書くだけです。

https://github.com/adventar/adventar/blob/fa0714e49ea3aa60888532b60b924af0c12bbc80/frontend/serverless.yml

これで Nuxt.js のビルドを実行後に

$ serverless deploy

API Gateway と Lambda にデプロイできます。

また、サーバーのコードも量が少ないとはいえ TypeScript で管理したいのと、一部クライアントの TypeScript のコードに依存があるので webpack で変換して bundle することにしました。

https://github.com/adventar/adventar/blob/fa0714e49ea3aa60888532b60b924af0c12bbc80/frontend/webpack.config.server.js

本当は Lambda にアップロードする容量を削減のため、必要なコードだけ bundle して一枚の JS にてアップロードしたかったのですが、色々とうまくいかずに bundle するのは諦め、webpakc で node_modules の解決は無視して、node_moduels をアップロードすることにして対応しました。Lambda の容量の上限に引っかかるほど大きくはないので一旦これで妥協しています。

キャッシュのパージ

今回 S3 にアップするアセットは CloudFront でキャッシュしています。JS や CSS はダイジェスト値がついていてファイル名がユニークになるのでキャッシュのパージは必要ありませんが、index.htmlsw.js などはキャッシュのパージが必要です。また、本来は SSR した結果もキャッシュしたいと思っていたので、上記に書いたデプロイの処理をおこなったあとにキャッシュをパージする必要があります。CloudFront のキャッシュのパージは以下のようにします。

aws cloudfront create-invalidation --distribution-id xxx --paths '/*'

最終的な、デプロイスクリプトは以下ような感じです。

https://github.com/adventar/adventar/blob/fa0714e49ea3aa60888532b60b924af0c12bbc80/frontend/bin/deploy

まとめ

フロントエンドのデプロイについて書きました。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 でおこなっています。

f:id:hokaccha:20191210124537p:plain

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 などの動的コンテンツを同時に配信しているので、少し複雑になっていますが、コードは以下のような感じです。

https://github.com/adventar/adventar/blob/b491c3be64b8d35b3484b69e4f9ce8f6ecfb93eb/frontend/server.ts

これを 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 は基本的に JavaScriptAPI を呼び出してコンテンツをレンダリングするので、JavaScript を理解できないクライアントには正しいコンテンツを提供できません。典型的な例は検索エンジンクローラーや、OGP などでサイトのコンテンツを取得するケースです。

SEO については最近のクローラーJavaScript を理解してくれるので SSR 必須ではないと思います。OGP は、Adventar においてはカレンダーページのタイトルと概要テキストだけどうにかできればいいので、Edge Side(Lambda@Edge など)でどうにかして差し込むなどの方法があるかもしれません。

一方パフォーマンスについてはちゃんとやればかなり効果があると思っています。一般的な SPA をレンダリングする場合ユーザーがアクセスしてコンテンツをレンダリングするまでには

  1. HTML を取得する
  2. JavaScript を取得する
  3. JavaScript を実行して API コールする
  4. 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 にキャッシュすることにしました。キャッシュしているコードはこのへんです。

キャッシュした情報は plugin でリストアしています。

https://github.com/adventar/adventar/blob/f580de20510f9debe6356a5ad193c4532d8f6a0d/frontend/plugins/auth.ts

この処理は初期描画の直前に一回だけ実行されるので、描画と同時にログイン状態がわかります。機密情報をlocalStorageに保存するのはセキュリティ的なアレがありますが、今回保存するのは token そのものでなく、表示に必要なデータだけにしているのでそのあたりは安心です。

これでほぼよさそうに見えますが、キャッシュの状態が不整合になる可能性を考えてみましょう。

  1. 別タブでログアウトした/別のアカウントでログインした
  2. ログイン処理を終えてリダイレクトされて戻ってきた

1 のケースは問題にならなくて、firebase.auth().onAuthStateChanged()のイベントはユーザーのログイン状態が変わったら発火するイベントリスナーなのですが、別タブでログイン/ログアウトしたような場合でもイベントが発火します。なので、別タブでログイン/ログアウトしてもユーザーの状態は同期されます。

2のケースは、ログインプロバイダのログイン画面から戻ってきたときの話しです。ログイン画面から戻ってきた瞬間なので、キャッシュの状態は未ログイン状態のはずですが、実際にはプロバイダでのログインが成功していればログイン状態となるはずです。なので、キャッシュと実際の状態が不整合になるのは実はこの場合だけです。

このケースは、ログイン処理中であるというローディングを出すことで対応しました。最初は画面全体をローディングにしていましたが、状態が変わってから再描画するところはそんなに多くなく、ログイン処理中というのがわかればいいので、ヘッダのアイコンのところだけ局所的にローディングすることにしました。

f:id:hokaccha:20191205123241p:plain

ただ、プロバイダのログイン画面から戻ってきた、という状態を取る方法がなさそうだったので、リダイレクト前に sessionStorage に適当な値をセットして、次に初期化処理が走った場合はリダイレクトから帰ってきたとみなして、firebase.auth().onAuthStateChanged()が発火するまでローディングを出します。

唯一不自然になるパスは、ログインを押してログイン画面に遷移後、ログインを完了せずに戻ってくるパターンですが、まあそんなに致命的な問題でもないので許容しています。

アイコン URL が更新されない問題

Adventar ではユーザーアイコンに OAuth でとってきた画像を使っています。例えば Twitter でユーザーがアイコンを変えた場合には Adventar でも新しいアイコンになってほしいので、ログインの処理のときにアイコンをアップデートする処理をいれています。コードはこのあたりです。

https://github.com/adventar/adventar/blob/b73af145638e9f4d4da6655015786bdac54510eb/api-server/grpc-server/service/user.go#L47-L57

しかしこれではダメで、Firebase Authentication は最初にログインしたときの情報を Firebase Authentication 側にストアしてもっているらしく、ユーザーが Twitter 側でアイコンを更新しても、Firebase Authentication 側のデータは更新されないようです。

いくつか方法はありそうですが、一旦今はログアウト時に Firebase Authentication 側のユーザーを消すという方法を取っています。

https://github.com/adventar/adventar/blob/b73af145638e9f4d4da6655015786bdac54510eb/frontend/lib/Auth.ts#L56

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 が無効な可能性がある、というメッセージを表示することにしました。

https://github.com/adventar/adventar/blob/b73af145638e9f4d4da6655015786bdac54510eb/frontend/lib/Auth.ts#L88-L92

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"
  }
}

この namepicture がそうです。GoogleTwitter などのプロバイダに設定してある名前やアイコン画像が取れるのですが、GitHub の場合、name が含まれない場合がありました。

GitHub は名前の入力が必須ではないので、入力してないユーザーの場合はこのフィールドがない状態の token になります。名前は必ず存在する、という前提でコードを書いていたので、そのような状態のユーザーがログインしようとするとエラーになってログインできない状態になっていました。

空の場合は user_id (github.com/xxxxxx)の部分が取れればいいのですが、Firebase Authentication 経由だとその情報は取れなさそうだった(もちろん自前でアクセストークン使って GitHubAPI を自前で呼び出せばとれますが)ので諦めて"No Name"のような名前でユーザーを作ることで対応しました。

https://github.com/adventar/adventar/blob/b73af145638e9f4d4da6655015786bdac54510eb/api-server/grpc-server/util/auth.go#L47-L51

まとめ

Firebase Authentication でハマった点などをいくつか紹介しました。慣れてないので色々とハマりましたが、やはり自前で認証の仕組みを用意するのに比べると格段に楽でよかったです。

明日は Server Side Rendering のについて書きます。