Merpay & Mercoin Tech Fest 2023 は、事業との関わりから技術への興味を深め、プロダクトやサービスを支えるエンジニアリングを知ることができるお祭りで、2023年8月22日(火)からの3日間、開催しました。セッションでは、事業を支える組織・技術・課題などへの試行錯誤やアプローチを紹介していきました。
この記事は、「メルコインにおけるシステム間のデータ分離を実現するための通信アーキテクチャ」の書き起こしです。
@pobo380:皆さん、こんにちは。このセッションでは、「メルコインにおけるシステム間のデータ分離を実現するための通信アーキテクチャ」についてお話しさせていただきたいと思います。Fintech ArchitectのKohei Nodaと申します。
最初に少し自己紹介をさせてください。改めて、名前はKohei Nodaといいます。経歴としては、2014年にMIXI入社した後、クライアントエンジニア・バックエンドエンジニア・エンジニアリングマネージャーなど、さまざまなポジションでゲーム開発を行ってきました。
その後、昨年の4月に転職してメルコインに入社し、Architectというポジションで仕事をしてきました。今年の1月からメルペイとメルコインとのアーキテクチャが、合流してFintech Architectという形になって、今はFintech Architectチームで仕事をしています。
早速ですが今日お話しすることをお伝えしたいと思います。一つ目が、メルコインにおける、システム間のデータ分離がどういうものかという話で、二つ目が、開発者体験を損なわずにデータ分離をどのように実現したかという話をしたいと思います。そして、最後にまとめをお話できたらなと思います。
まずメルコインのシステム間のデータ分離はどういうものなのかをご説明します。
メルコインにおけるデータ分離とは、メルコインのシステムとメルカリ/メルペイのシステム間で、それぞれが持つお客さまのデータを容易に紐付けられないようにすることです。
例えばメルコインでは暗号資産を取り扱う業務をしていますので、メルコイン側のシステムでは、お客さまの暗号資産情報を預かる一方、メルカリ/メルペイのシステムではお客さまの住所や氏名などの個人情報をお預かりしています。
もしこれらの情報に同時アクセスできる人がいると、その人はどこの誰がどれだけの暗号資産を持つのかという情報が得られることになります。この情報には価値があり、それを誰かに売る、何かに悪用するなどの可能性が考えられます。
そのため、メルコインのサービスを提供するにあたって、仮に内部の従業員だったとしても、メルコインとメルカリ/メルペイのシステムの間でデータの紐付けができない状態を担保したいという要求がありました。
データ分離を実現するための基本的な仕組みとして、そもそもシステムの分離があります。この図であるように、メルコインとメルカリ/メルペイのシステムはそういう分離されて作られていて、メルコインの従業員はメルコインのお客さまのデータ、メルカリ/メルペイの従業員はメルカリ/メルペイのお客さまデータにしかアクセスできないということが、システムの分類によって実現されています。
この図では、メルカリ/メルペイの従業員がメルコインのお客さまデータにアクセスできないことを示しています。逆もまた然りで、メルコインの従業員がメルカリ/メルペイのお客さまデータにアクセスできません。
ですが、このシステムの分類だけだと、ここでもしメルコインにインシデントが発生したとしてお客さまのデータが流出してしまったと仮定すると、メルカリ/メルペイのアクセス権しか持たない従業員だったとしても、メルカリ/メルペイのお客さまデータと、メルコインのお客さまデータを同時にアクセスして紐付けすることが可能になってしまいます。
さらに悪いケースとして、メルコインだけでなく、メルカリ/メルペイのお客さまデータも同時に流出したとすると、その両方のデータにアクセスできる人は、データの紐付けができ悪用できる状況になります。
このような問題を解決するために、UserIDの分離を行うことになりました。メルコインのシステムとメルカリ/メルペイのシステムで、同じお客さまに対して異なるIDを振り、それぞれのシステムで保存する仕組みです。
同じお客さまでも、異なるIDが振り分けられているので、もし両方のデータが流出したとしてもデータの紐付けが不可能になります。
このように、UserIDの分類をすることで、データの紐付けが流出したとしても、インシデントが起きたことを考えたとしても、紐付けができなくなります。
一方で、メルコインのシステムは、メルカリ/メルペイのシステムに依存し、実際メルコインのサービスとしてもメルカリアプリの中でビットコインの購入や売却ができるようになっているので、どうしてもメルカリ/メルペイのシステムと連携する必要があります。
しかし、メルコインのシステムとメルカリ/メルペイのシステムで異なるIDを使っているとどのお客さまがどのUserIDなのかがわからないので、通信をしても連携ができません。
そこで、通信の際には、お客さまを特定できるIDに変換してから、システム間の通信を行う必要があります。そのために、今回のメルコイン・メルカリ/メルペイのシステム間の連携では、このように内部用IDと外部用IDを用意しました。
単純に考えると、メルコインとメルカリ/メルペイのそれぞれにお客さまのUserIDがあり、交互に変換すれば良いんではないかと思われます。
しかし、例えばメルコインからメルカリ/メルペイに通信を送ることを考えたときに、メルコインの中で通信を送ろうとしたときにメルカリ/メルペイのIDに変換してから送るとすると、メルコインの中にメルカリ/メルペイのID変換を行う権限がないといけません。それをメルコインのサービスの中で付与すると、メルコイン側がメルカリ/メルペイのIDを知っている状況と変わりません。IDの変換権限を持ったサービスやそこにアクセスできる従業員は、実際にはメルコインのお客さまデータとメルカリ/メルペイのデータを紐付けることができるようになってしまいます。
そこで通信用に外部用IDを導入して、2種類のIDを使って変換する仕組みにしています。
それぞれIDを呼び分けているのですが、以降の説明では、メルコインの内部で使う通常のお客さまのIDをMercoin UserID、通信用のものをMercoin PPIDとします。同様に、メルカリ/メルペイのシステムの内部のIDはメルカリ/メルペイUserID、通信用のIDは、メルカリ/メルペイのPPIDとします。
PPIDの詳細については、IDPチームの方が書いたエンジニアリングブログを読んでみてください。
参考記事:Applying OAuth 2.0 and OIDC to first-party services
先ほどの説明で4種類のIDが登場しましたが、メルコインからメルカリへの通信のケースを考えると、このような変換ステップが必要になります。
一つ目はメルコイン側のサービスで、Mercoin UserIDをMercoin PPIDに変換してリクエストを送るというステップになります。次にメルカリ/メルペイのサービスが、リクエストを受け取ったら、リクエストに含まれるメルコインのPPIDをMercari UserIDに変換し、リクエストの処理を行います。
処理をしてレスポンスが生成されたら、レスポンスに含まれるMercari UserIDをメルコインに返すためMercoin PPIDに変換してレスポンスを返す必要があります。最後にレスポンスを受け取ったメルコイン側のサービスはMercoin PPIDをメルコイン内部のUserIDに変換して、レスを受け取ったレスポンスを処理するステップです。
UserIDの分離によってユーザーのデータプライバシーやデータを悪用される可能性を減らせる一方で、4種類のIDが存在するとID体系が複雑で、かつID変換を行った通信もすごく通信のステップ変換のステップも多くてややこしくなります。
今回のシステム連携ではさまざまなシステム連携が必要で、いくつものシステム間の通信を行う箇所がありました。これらのID変換を各マイクロサービスで実装するとするとコストがとても大きくなります。
そこで、システム間で異なるUserIDを用いることでユーザーのデータを守りたい。その一方で、開発者の体験も損なわないようにしたいという要求がありました。これらを同時に満たすアーキテクチャを作りたいという状況でした。
ここからは、そのような要求を満たすアーキテクチャをどう作ったかという話をしたいと思います。
目指した開発者体験を先にお話しすると、メルカリグループでは全面的にマイクロサービスを採用しています。そのため、システム間の通信メルカリ/メルペイとメルコインとのシステム間の通信もマイクロサービス同士の通信になります。
このマイクロサービス同士の通信が、内部マイクロサービスと通信するときと全く同じようにID変換を意識せずに、自分たちのUserIDだけ意識して開発すれば、外部の通信のときには自動的に外部IDに変換されて相手に到達するし、逆にAPIを呼び出される側になったとしても、自分のマイクロサービスに届くときには内部UserIDになって届いているという状況を目指しました。
それをどういうふうに実現したか。メルカリグループでは、基本的にはマイクロサービスの通信はProtocol BuffersというIDLで定義されています。そのため、マイクロサービス間でAPI通信を行うためのリクエストとレスポンスは全てProtobufメッセージとして定義されていることになります。
このメッセージの定義を利用して、通信時にメッセージに含まれているUserIDが、自動的に通信経路上で、開発者が意識することなく適切に変換されているをやることで開発者がID変換を意識する必要がない状態を作ることができました。
具体的なProtobufメッセージに含まれるIDの変換なんですけれども、Goの実装例も書いてあるんですが、イメージとしてはProtobufのあるメッセージとID変換の向きがUserIDからPPIDなのか、PPIDからUserIDなのかに従って、ID変換の呼び分けを行います。
メッセージの中に含まれるIDを全て変換して値を置き換えることが、メソッドを呼び出したときに行われるイメージです。
どのフィールドにあった値が含まれるかを知らなければならないのですが、どのフィールドに値が含まれるかという情報は、CustomOptionというProtocol Buffersの仕組みを使って、protoファイルの定義の中でアノテーションを行います。
これが、実際にプロファイルに対してアノテーションを行うときのイメージです。
例えばGetUserRequestというユーザー情報を取り出すリクエストがあったとして、引数にUserIDがあるときには、フィールドに対してID変換を有効にする、アノテーションをつけます。またレスポンスも同様に、レスポンスに含まれるUserIDにアノテーションをつけます。
そのためのCustomOptionの定義はこのような内容になります。
ここまでで説明したProtobufメッセージに含まれるIDの変換の機会を、通信経路上の二つの箇所で行いました。
一つは呼び出し側(Caller)でのID変換はgRPC Client Interceptorで、呼び出される側(Callee)でのID変換はGatewayというサービスを使って行いました。
通信経路全体としてはこのような形になります。
Caller側のID変換は、マイクロサービスに含まれるgRPC Client Interceptorで行います。そのinterceptorがIDを変換して、Gatewayを経由して、相手のマイクロサービスにリクエストを送ります。
そのGatewayでもう一度ID変換が行われて、Calleeのマイクロサービスに届きます。メルカリとメルコインのシステムを例に出していますが、逆向きの通信についても同様の経路です。
通信系の上の二つのID変換について説明する前に、図の中に出てきたID Providerについて説明します。
これはUserIDとPPIDのマッピングを持っているサービスで、メルカリ内のID ProviderのIDPのチームが管理しているサービスで、UserIDとPPIIDを相互に変換するAPIを提供しています。gRPC Interceptorでの変換とGatewayでの変換で、ID Providerと通信をして、IDマッピングを手に入れて変換することになります。
CallerのID変換ですが、これは先ほど説明したようにgRPC Client Interceptorで行います。呼び出し側のCaller側のマイクロサービスの中で変換を行います。
gRPC Client Interceptorは、gRPCのクライアントからAPIを呼び出すときに、いろいろな操作ができます。
そのできる操作の一つにリクエストレスポンスを変更することがあります。gRPC Client Interceptorの中で、メッセージに含まれているIDを取り出し、変換し、Gatewayに送信しています
これはAPI呼び出しのたびに何かする必要はなくて、gRPCのClientをセットアップするときにInterceptorを挟むという設定をしておけば、自動的にInterceptorが呼び出されます。
このinterceptorでは、リクエスト送信時にCallerのUserIDからCallerのPPIDに変換して、Gatawayから戻ってきたレスポンスに対してCallerのPPIDからCallerのユーザーに変換するという処理が行われます。
CalleeのID変換は、Gatewayで行われます。Gatewayはすでにメルカリ内で多く利用されているサービスで、外部のインターネット(例:お客さまからの使っているアプリ)からくるリクエストの入口となるサービスです。いわゆる、API Gatewayに近い実装です。これが一番手前にあり、内部のマイクロサービスにルーティングを行います。
Gatewayの中で、Callerから渡ってきたgRPCの呼び出しのメッセージに対してID変換を行います。リクエストを受信したときには、CallerのPPIDでくるので、CalleeのUserIDに変換して、マイクロサービスにルーティングを行い、メッセージを渡します。
自分たちのマイクロサービスからレスポンスが返ってきたら、そこにはCalleeのUserIDが含まれているので、それをCallerのPPIDに変換します。
gRPCにはServer Interceptorがあって、Client Interceptorと同じように、Callee側のマイクロサービスでもリクエストレスポンスの変更が可能です。なぜここで、Callee MSではなく、GatewayでID変換を行っているのかという話をすると、CallerのPPIDからCalleeのUserIDの変換は、各マイクロサービスではなく、限られたコンポーネントで行いたいという要求がありました。
呼び出される側は、いろいろなところから呼び出されるので、相手のPPIDが送られてきます。その際、受け手のマイクロサービスは、任意の相手のPPIDが送られてくる可能性があるので、このIDを自分のUserIDに変換するという権限を持つ必要があります。
これはかなり強い権限であり、相手のPPIDを自分のUserIDに変換できるということは、もし相手のPPIDとユーザーデータに自分がアクセスできる状態になったときに、自分の持っているデータとその相手のデータの紐付けが可能になるという権限になります。
なのでここは各マイクロサービスに権限を渡すのではなくてGatewayという限られたサービスだけに権限を渡して変換を行うという選択をしました。
もう一つの観点としてCalleeのマイクロサービスにServer Interceptorを導入しなきゃいけないという、手間もなくなるというメリットもありました。
ID変換と通信フローのおさらいです。このように、Caller側のマイクロサービスからgRPC Client Interceptorにメッセージがあって、ID変換が行われ、Callee側のGatewayにメッセージがわたり、そこでまたID変換流れ、Calleeのマイクロサービスは内部のUserIDだけでリクエストを処理する場合と、レスポンスを生成して、Gatewayに返し、ID変換が行われ、gRPC InterceptorでID変換行われ、また呼び出し元のCallerのマイクロサービスにレスポンスが返ってくることになります。
Gatewayと、gRPC Client Interceptorによる変換で、このような開発者体験が得られました。開発者がやらなければいけないこととしては、Caller側は、gRPC Client Interceptorを、導入するだけですね。一度だけセットアップすれば良いです。
また、通信に使うProtobufのメッセージに含まれる、UserID、そのフィールドに対してアノテーションを付与すること。この二つを行うだけで、開発者はID変換というのを意識することなく、UserIDだけ扱って、内部通信と同じようにシステムを超えた、API呼び出しが可能になりました。
ここまで通常の同期通信のケースを考えましたが、実際にはシステムをまたいで非同期通信をしたいケースもありました。
ここでいう非同期通信は、メッセージキューを使う通信です。また、メルカリグループでは、ほとんどのサービスで、Cloud Pub/Subを利用しています。Cloud Pub/Subを経由した通信でも、開発者がID変更を意識することなく開発できるようにしたい状況でした。
そこで、Cloud Pub/Subのトピックに加えて、Pub/Sub Pusherというサービスが出てきます。
Pub/Sub Pusher自体はID変換のためだけに存在しているものではなく、Cloud Pub/SubのトピックからPull SubscriptionでメッセージをPullして、それをgRPCリクエストに変換し、メッセージを受け取りたいSubscriberに対してgRPCリクエストを送る機能を持ったコンポーネントです。
これは元々Pull Subscriptionを実装するというテーマがあるので、他のマイクロサービスの通信と同じように、Cloud Pub/Subを使いたいという要望が要求があって、作られたものになります。これはサブスクライブするトピックと、gRPCリクエストを送る先をKubernetesのmanifestとして記述すると、カスタムリソースになっていてカスタムコントローラーとして実装されていて、裏で実際にその処理をしてくれるものです。
これに拡張を行って、今回のシステム連携のために、ID変換機能をまずこのPubSub Pusherに追加しました。PubSub Pusherの中でPublisher側のUserIDから、SubscriberのPPIDへ変換しています。
同期呼び出しの場合は、Publisher PPIDに変換する形でしたが、ここではSubscriberのPPIDに変換しています。
Pub/Sub Pusherが行うのは、通常は内部の通信なのでこれをGatewayに対してリクエストを送ることをしています。
あとはGatewayは同期的なAPI呼び出しと同じように、gRPCに変換されているので、リクエスト受信時とレスポンスの送信時に、同じ変換を行えばいいことになります。
これで非同期通信に関しても、開発者ID変換を意識しないという体験が得られました。開発者が行うことは、Pub/Sub Pusherの設定ファイルを記述して、ID変換とGatewayへのPushを有効にすることだけです。
最後に、まとめです。
改めてですが、メルコインではシステム間でUserIDを分離することで、データの紐付けを不可能にして、高いレベルでのデータプライバシー保護を実現することができました。
一方で、ID変換という複雑な作業は、通信経路上で透過的にID変換を行うアーキテクチャを導入することで、効率的な開発ができるようになりました。マイクロサービスの開発者は実装時にID変換を意識する必要がありません。
なぜこれを実現できたかというと、マイクロサービスといえどほとんどがGoで実装されていて、gRPCを使って、通信しているProtobufでメッセージが提起されている状況だったからこそだと思います。
最後に、残っている課題を紹介して終わろうかと思います。
一つは、メルカリの中では一部Goでない実装があり、その場合は今回のようなID変換に対応できないという課題があります。
もし解決しようとすると、よりマイクロサービスにInterceptして入れるのではなく、例えば通信経路上のプロキシなどを経由して、呼び出し側も変換することが考えられます。
不具合調査時のTraceabilityは、お客さまに問題が起きて、メルカリのユーザーデータと実際見る行為のお客さまのデータを紐付けて調査しなければならないとき、それができない仕組みになっているので、かなり調査がやりづらいという状態です。
最後の一つはUserID以外のデータの紐付けです。
例えばメルカリでの購入履歴をメルコインでも共有していて、同じデータをそれぞれのDBに保存し、それぞれにユニークIDが保存されていると、UserIDでなくても紐付けが可能になってしまっています。現状の解決策はないんですけど、これをどう防ぐかを考えています。
発表は以上です。ありがとうございました。