マイクロサービスにおけるユーザ認証とサービス間認証をサーバレスに実装してみよう
こんにちは。metroly incの大城です。
前回の続きを書いてみようと思います。ちなみに前回の記事はこちら。
見出しに色々とビッグワードを並べてしまいましたが、今回は最近取り組んでいる、サーバレス環境を使った弊社サービスのマイクロサービス化の過程を共有していきたいと思います。
まず、なぜこんな事に時間を費やしているのか背景を説明します。
モチベーション1: ソフトウェアのコードベースがそれなりに大きくなってきてしまって今見ると意味不明なコードが増えてきてしまいました。もちろん書いた時は、きれいに書こうと心がけているのですが、コードの絶対量が増えてくるとどうしても可読性が落ちてしまいます。特に夜中にゾーンに入って書いたコードはこの傾向が多いです。
⇒ 今後も継続して機能追加をしていくにあたって役割ごとにサービスをきれいに分けたいと考えるようになります。
モチベーション2: Kubernetesのクラスタを一人で管理するのは結構大変でした。アプリケーションのソースコードも書きながらインフラの構成をYAMLで定義していくのは、正直結構しんどいです。Ingressの設定、ディスクの指定だとか、レプリカをStatefulSetで作ったり、ヘルスチェックやら、オートスケールの設定とか・・・複雑だしめんどくさいです。
⇒ いい感じにサーバレスサービスを使って楽出来ないかと考えるようになります。
ということで、色々と調べて、完璧の理想形ではないですが、今回ご紹介する形で実装することになりました。誰かの参考になれば幸いです。
やりたい事
以下のようなサービスに分割していきたいと考えます。
ユーザーからはシンプルに Identity Service と Proxy だけが見えるようにします。Identity Serviceは認証をする際に利用し、認証が終わったらProxy越しで各種サービスにアクセスします。Proxyは以下の役割を果たします。
• リクエストの終端
• リクエストを適切なサービスに振り分け
• ユーザーの認証
使ったサービス
Firebase Authentication
Cloud Run (Managed)
Cloud Endpoints
を使いました。はい、GCP 大好きです。
ちなみにサーバレス好きな方にCloud Runめちゃくちゃオススメです。ステートレスなWebアプリケーションや簡単なETLに向いています。トラフィックがなくなれば潔くコンテナが0台に減少され料金がかからなくなり、トラフィックがその後来ると数秒で起動し、バーストに強いのが特徴です。さらにPaaSでありがちな言語やフレームワークの固定がありません。コンテナ化出来ればなんでもOKです。もう最強です。
Cloud EndpointsはAPIの管理のサービスです。Swaggerを使ってAPIの仕様を定義、公開したり、APIを保護したり、呼び出し元を認証したり、それぞれのバックエンドのパフォーマンスを監視することが出来ます。今回はこれを使ってProxyを構築します。
ユーザー体験
こんな感じに仕上がっています。裏の仕組みはめちゃくちゃ変わっていますが、ユーザー体験は前と大して変わりません。
アーキテクチャ
最終的には以下の様なアーキテクチャを採用しました。
小さく切り出したサービスをCloud Runのサービスとしてデプロイしていきます。エンドユーザーに見せる Identity Service と Proxy だけは Public にしてそれ以外は全て Private にします。エンドユーザーが Private サービスを利用する時はあくまでも Proxy経由でアクセスをさせます。
ちなみに Cloud Runは privateにしたとしてもインターネット経由でサービングされます。ただしprivateにした場合はJWTをヘッダーに入れておかないとリクエストがソースコードに到達する前に401が返って来る仕組みです。サービス単位でIAMを設定出来ますが、許可したユーザの鍵で署名をすればOKです。これはめちゃくちゃ便利で、ソースコードを開発する際はセキュリティ要件をあまり考慮する必要がありません。
ということで、サービスを何個も作るのですが、サービス毎にどのサービスを呼べるようにするかは事前に定義しておく事はマイクロサービスアーキテクチャでアプリを運営する際のセキュリティ上とても重要です。
ユーザーの認証
Proxyでのユーザー認証についてです。裏が全てCloud Runなのでこのページを参考にしました。
新しいサービスなのでベータなのは仕方ないですね。(2020/08/10時点)
Cloud Runの場合はEnvoyベースのProxyを自分でビルドして独立したCloud Runのサービスとしてデプロイしないといけないようです。(Proxyを同じPodにデプロイしたかったのですが、まだ出来ないようですが、ここは今後に期待しましょう。)
ユーザー認証は前回ご紹介したFirebase Authenticationで生成したID Tokenを使って実現をしたいと思います。
Cloud Endpointsを設定する際に Swaggerの仕様書をYAMLで書きますが、ここにFirebase Authenticationを受け付けることを書き加えておきます。
swagger: '2.0'
securityDefinitions:
firebase:
authorizationUrl: ''
flow: implicit
type: oauth2
x-google-issuer: 'https://securetoken.google.com/PROJECT_ID'
x-google-jwks_uri: >-
https://www.googleapis.com/service_accounts/v1/metadata/x509/securetoken@system.gserviceaccount.com
x-google-audiences: PROJECT_ID
スクリプトを参考にしながらCloud Endpointsを設定しCloud RunにProxyのサービスをデプロイしたら完了です。
これでProxy経由でバックエンドにアクセスされる場合は、ID Tokenが自動的に付与されてバックエンド転送されます。
これによりバックエンドの認証はFirebase AuthenticationとCloud Endpointsがいい感じに対応してくれます。バックエンドに届くのはすでに認証済みのリクエストのみになります。すでに認証済みなので、そのリクエストを信頼してレスポンスを返してあげればよいわけです。ますます自前で認証を構築する必要は今後なくなりそうです。とてもDeveloper Experienceとしてはいいです。
サービス間の認証
一方でサービス間の認証は正直ちょっと面倒でした。Proxyがサイドカーとしてデプロイされていないのが問題だと思います。
さて、マイクロサービスアーキテクチャで開発をしているとサービス間で通信をさせたいことが結構発生します。
例えば Identity Serviceでユーザーの認証が終わった際にこのユーザーが有料ユーザーなのか、無料ユーザーなのかが知りたいです。これがわかるとID Tokenを発行する時にそのユーザーの権限含めておくことが出来ます。
しかしユーザー購読ステータスは License Serviceに問い合わせる必要があります。つまりIdentity ServiceからLicense Serviceにサーバー間で通信を許可してあげる必要があります。
HTTPSのトラフィックはこの様なフローになります。IDをソースコードの中で取得しなくてはならなくなるので、ワンステップ増えてちょっとめんどくさいです。
以下はサンプルコードです。
const {GoogleAuth} = require('google-auth-library');
const auth = new GoogleAuth();
const axios = require('axios');
const client = await auth.getIdTokenClient(process.env.LICENSE_CONSUMER_URL)
const headers = await client.getRequestHeaders()
const email = // get user email
if (!email) throw 'user email is not defined'
const url = `${process.env.LICENSE_CONSUMER_URL}/api/v1/subscriptions?email=${email}`
const result = await axios({
url: url,
headers: headers,
})
良かった点
マイクロサービスに切り替える事によってソースコードのレポジトリは増えましたが、一つ一つのコード量は減りました。よって、Separation of Concernsは強制的に進みそれぞれのサービスで何をやっているのかが大分わかりやすくなりました。エンジニアが増えた時に更にリターンが得られると思います。
言語、フレームワーク、バージョンを自由に使える。マイクロサービスになったおかげで自分の今使いたい言語やフレームワークを自由に選べるようになりました。これはかなり嬉しいです。過去のコードは残しつつ、新しいコードはゼロベースで最適な技術を選定することが出来ます。(とはいえあまりめちゃくちゃになると良くないので、適切な新陳代謝に抑えましょう。)
料金が段違いに安くなりました。明言は避けますが、数分の1レベルに安くなりました。これまでGKEでホストしていたのですが、アクセスがあろうがなかろうがたくさんのVMがコンテナを実行させながら待機する必要があり無駄といえば無駄でした。Cloud Runは0台から数秒で立ち上がるので、待機中の無駄がありません。一度立ち上がったらその後はサクサク動くので十分許容範囲じゃないかと思います。
良くなかった点
Cloud Runのサービス間の認証は課題ありだと思います。ソースコードで書いていなくても、アウトバウンドのHTTPリクエストはインターセプトして認証ヘッダーを入れてくれるようになってほしいと思います。現状Cloud Runに依存した形のソースコードになってしまうので、イケていません。
レイテンシーが思ったよりもペナルティが大きい。Proxyを置くことでレイテンシーのペナルティがあることは当然だと思いますが、数msぐらいであったら良かったのですが、80から100msぐらい足されています。まぁギリギリ許容範囲ですが、もう少し頑張って欲しいです。
調べてみるとカスタムドメインの既知の問題のようでした。東京と北バージニアだけ遅くなるらしいです。早くどうにかしてくれ。。。
Requests to Cloud Run (fully managed) services using custom domains can have a very high latency from some locations. This issue is more pronounced for Cloud Run (fully managed) services in `asia-northeast1` and `us-east4`. If you observe this issue, you can achieve greater performance with Cloud Load Balancing using a serverless NEG.
まとめ
サーバレスにマイクロサービスの認証関連の課題をどのように弊社で解決しているかご紹介致しましたが、いかがだったでしょうか?
まだエンジニアが一人しかいない会社なので、なるべく運用が楽なものを選びながら開発を進めています。
マイクロサービス化を進めていく作業は部屋の掃除に似ています。機能は変わらないのですが、整理が進み、見通しが良くなり、次にやりたいことを実現するためにやらなければならないことが特定しやすくなったと感じます。
GKEで運用する場合よりは今回はサーバレス製品を使うことによって、より簡単に実現が出来たのかなと思います。もちろん出来ることは限られていると思います。
もし参考になったと思った方はスキとフォローをしてもらえると、とても励みになりますので、よろしくおねがいします。
次回はFirebase Authenticationを使って実現する認可の仕組みについて書いてみようと思います。
最後に
弊社では一緒に製品を開発しながら成長できるエンジニアを大募集しています。フルスタックエンジニアになって自分で思うままのプロダクトを作ってみたいと思っている方、まずはカジュアルに話しましょう!