実践マイクロサービス ─ コンポーネント分割やトラブル回避の考え方をLINEの導入事例に学ぶ
マイクロサービスとは、小さいサービス同士をつないで連携し、ひとつのサービスを構成する手法のことです。そのメリット・デメリットなどマイクロサービスの本質と、LINEでの導入事例から実運用とトラブル回避について、LINE Shopチームの佐藤春旗さんが解説します。
はじめまして、佐藤春旗です。
前職でソーシャルサービスやOSの開発を経て、2013年にLINEに入社。スタンプショップやLINE Payの開発に参加したのち、現在はスタンプショップなどを担当するLINE Shopのチームに所属し、マネージャーを務めています。
本稿では、マイクロサービスを軸に、2つのテーマを取り上げていきます。
1つ目は、マイクロサービスの概要解説です。あわせて、実際に運用して見えたメリット・デメリットを紹介しながら、マイクロサービスの本質を探っていきます。
もう1つのテーマは、「いかにマイクロサービス実運用を考えるか」です。LINEでの導入事例のほか、マイクロサービスで起こりやすいトラブルと解決方法を紹介していきますので、実際にマイクロサービスの導入を考える方の参考になれば幸いです。
- マイクロサービスの基本的な考え方
- マイクロサービスアーキテクチャのメリットとデメリット
- LINEでなぜマイクロサービスが導入されたのか
- 事例1. Shopのアーキテクチャ ~ ユーザー視点にそって入口を分割する
- 事例2. Channelサービス
- 分割の粒度をどう決めていくか?
- マイクロサービスアーキテクチャ運用の注意点 ~ ハマりやすい「落とし穴」とその解決策
- Armeriaを利用した簡潔なサンプルコード
マイクロサービスの基本的な考え方
そもそもマイクロサービスとはどういうものなのでしょうか? ここでは、個々のサービスとしてのマイクロサービスではなく、アーキテクチャとしてのマイクロサービスについてお話したいと思います。
マイクロサービスとは、Web APIなどのRPC(Remote Procedure Call、遠隔手続き呼び出し)を通じて、小さいサービス同士をつないで連携し、ひとつのサービスを構成する手法のことです。
マイクロサービスを知るには、対義語のモノリシック(monolithic)アプリケーションと比較すると、分かりやすいかもしれません。
モノリシックなサービスでは、1つのアプリケーションに対してすべての機能が開発されます。複数のアプリケーションを開発する場合でも、認証サービスやデータベース、アクセス許可などのコンポーネントもそれぞれのアプリケーションの中で開発が必要になります。
対してマイクロサービスでは、これらを共通コンポーネント化し、APIで呼び出すというアプローチをとることが可能です。
マイクロサービスアーキテクチャの基本的な思想は、
- サービスを構成する各要素を疎結合に構成して連携する
- 各要素に適した技術を用いる
- サービス間の会話はAPIなど決められた様式に従う
という考えのもと、大きな規模のサービスを分割して、小さなコンポーネントにする設計をしようというものです。
これらの考え方は、SOA(Service Oriented Architecture、サービス指向アーキテクチャ)などの形で、以前から存在していたと思います。規模の大きなサービスを作ろうとするときも、似たような考えで構築することが多いのではないでしょうか。
今から振り返ると、私がLINEに入社した2013年は、マイクロサービスアーキテクチャを構成するための技術が徐々に出そろってきた時期になります。
Apache Thriftや、Protocol BuffersとgRPCなど、IDL・シリアライズフォーマット・RPCフレームワークが存在し、またその後にはAWS Lambdaに代表されるサーバーレス・コンピューティングのコンセプトが生まれています。
そして、それ以前からも、ビジネスやロジックの複雑化と、トラフィックやユーザー数などの大規模化に伴うスケーラビリティの必要性に対応するため、これまでひとつのサーバーの中で完結していたものが、規模が大きくなってきて、分割せざるを得なくなりつつありました。
- 機能を分割して別のサーバーで構築した上で、ThriftやgRPCといったRPCによって機能を呼び出す
- 機能間のメッセージによる会話はJSONで機械的に誤解のないように規定しよう
といった具合です。
マイクロサービスアーキテクチャのメリットとデメリット
マイクロサービスの理想的な形を追求すると「機能ABの間で余計な依存性がない」「機能Aのトラブルで機能Cが影響を受けない」ということになりますが、実現するのはそう簡単ではありません。
障害発生時の影響を少なくし、分業して開発にあたることができる
マイクロサービスのメリットは2つ。サービスの障害発生時の影響を限定的にでき、かつ復旧も速やかになることと、開発担当者の責任範囲を分割できることです。
マイクロサービスアーキテクチャの本質は分業です。コンポーネントや1個のマイクロサービスごとに集中できれば、より深く開発、問題分析に取り組めます。
こうしたマイクロサービスのメリットを享受するには、「要素がきちんと分割できている」ことが重要です。それぞれの要素に適した技術を用いた上で、要素を小さなコンポーネントに分割し、疎結合で連携させるということが実現できて、はじめてメリットが見えてくると考えています。
適切な分割の粒度については、以下で説明する導入事例のセクションに譲ります。
見るべき範囲が狭まる反面、属人性が高まるデメリットも
細分化することで生じるデメリットもあります。
その1つが、属人性が高くなることです。障害時の影響が分断でき、責任の所在がはっきりする反面、自分の管轄外のコンポーネントやプロダクトに対して無関心になりがちです。
例えば、他のチームが管轄するコンポーネントの影響を受けて、自分の担当サービスに障害が発生したとします。その場合、実際に手を動かして修正できるのは自分ではなく、そのコンポーネントの担当者です。コミュニケーションがうまくいかないと、かえって復旧まで時間がかかってしまう場合もあるでしょう。
原因調査もあわせて担当者に依頼したときには、エラーの要因が分からないまま、サービスそのものは復旧している、という状態になることもあります。開発者の仕事はコードに触れることなのに、実際のコードを読んだり書いたりできないのはもどかしいところです。
問題対応を受ける側も気を使います。直すべき箇所の認識にズレがないか、そもそもどれくらい重要な問題なのかを共有できていないと、疑心暗鬼になりがちです。
このように、いち個人がサービス全体を俯瞰することが難しくなると、結果的に構造が複雑化してしまうことにも留意せねばなりません。いずれも先ほど挙げたメリットとは裏表の関係にあるため、必要悪と言えるかもしれません。
分断しないためのコミュニケーション
これらのデメリットは、マイクロサービスの本質とは表裏一体です。避けられない問題ではありますが、コミュニケーションを工夫することで、解決できるケースもあると思います。
- ThriftなどのIDLを用いてAPIを提示する
- テストコードをきちんと書く
などの方法で、誤解のないコミュニケーションができると考えています。
弊社でも、さまざまな国のエンジニアがサービス開発に携わっています。日本人同士が日本語を使っても誤解は起こるものなので、良いコミュニケーションが必要になってきます。良いプロダクトを作るには、適切な「エンジニアらしい」手法を使って正しくコミュニケーションすることが欠かせないと、日々痛感しています。
人間がひとりでできることには限界がありますし、他の開発者の担当範囲を全部知るのは不可能です。しかし、少しだけでも自分の担当コンポーネントの外に関心を寄せられれば、一緒にできることが増えそうだなと思うケースはあります。
LINEでなぜマイクロサービスが導入されたのか
ここからは弊社での導入実例と、運用で起こりがちなトラブルおよびその解決方法をつづっていきたいと思います。
私が入社した2013年時点、すでにLINEはメッセンジャーだけでなく、多数のサービスを展開していました。モノリシックに開発されたメッセージングサービスだけのときと状況が異なり、運用面での問題が噴出していたのです。
これは転換期での話ではありますが、2016年にはアプリ画面のテーマをカスタマイズできるLINEの着せかえショップで発生した障害の影響によって、メッセージングの送受信機能が影響を受けてしまいました。
社内でも、サービス開発に関わるメンバー・チームが拡大するにつれ、サービスそれぞれでリスクを取りつつ全体を安定的に運用する必要性が議論されるようになり、「マイクロサービス的な考え方でサービスをスケールしていった方がいい」という意見が挙がりました。
現在も道半ばではありますが、サービスの増加に伴って、重複している機能を適切に分割していこうというモチベーションになっていると考えています。
事例1. Shopのアーキテクチャ ~ ユーザー視点にそって入口を分割する
もう少し具体的な導入事例として、私が担当しているShopのサービスや、我々が利用しているChannel機能のアーキテクチャを挙げて説明していきたいと思います。
Shopのアーキテクチャでは、ざっくり分けると入口をShop-ProxyとLINE STOREの2つとし、裏側の共通コンポーネント部分としてShop-Serverを経由して、その裏にSearch FEとElasticsearchをおいています。
共通のロジックをShop-Serverにおきつつ、ユーザーの行動フローが異なる部分はきちんと分けておくことが、この構成でのポイントになります。
例えば、ユーザーにとっては「スタンプを買う」という同じ動作でも、LINEアプリからのスタンプ購入と、Webブラウザからスタンプを買うという2つの入口に分けることで、Shop-ProxyとLINE STOREどちらかの入口が障害になったとしても、もう一方は動き続けることができます。
マイクロサービスのメリット「拡張性の高さ」を生かす
例として挙げた「スタンプを買う」以外にも、送信などいくつかの機能をこのコンポーネントから呼び出すこともありますし、逆にShopの機能を別のコンポーネントから呼び出して使うこともあります。
一方で、Shop-Serverから後ろのコンポーネントは入口が異なっても共通になっています。こうすることで、新しい販売サービスを立ち上げようと思ったときも、Shop-Serverと会話ができるようなコンポーネントをProxyやShop-Serverと横並びにさせ、拡大することが可能です。
コンポーネントを挟み、使いたい機能を分かりやすく使う
障害時の代替手段を用意しサービスの安定性を高めるだけでなく、必要な機能を必要なサービスで表現し、その責任を担保することもまた重要です。
Shopアーキテクチャの例では、Search FE→Elasticsearchの部分がこれに該当します。
Elasticsearchは多機能で強力なオープンソース・ソフトウェアですが、ひとつのサービスですべての機能が必ずしも必要になるとは限りません。ごく一部の機能だけ使いたいというケースも多いため、フロントエンド(FE)コンポーネントとしてSearch FEを作り、実際に使いたい機能にアクセスしやすくしています。
自分たちが本当に必要最小限な部分をラップして使いやすい形で提供することは、MySQLなどのRDBMSでも同様のことが言えるでしょう。多機能な反面、使い方によってはチェーンソーとなり得るものをそのまま使うのではなく、特定の機能を呼び出すコンポーネントを作ってやるという考え方です。
事例2. Channelサービス
誰でも必要になる機能をひとまとめにする、というマイクロサービスの理念に沿った内部サービスが、Channelです。Channelは、LINEのさまざなまサービスを使う上で必要になる認証などの機能を一括して管理し、複数のマイクロサービスからさまざまな機能を呼び出すことに特化しています。
外部に提供しているMessaging APIもChannelの恩恵を受けています。外からは「Messaging API」という1つの仕組みに見えますが、内部のマイクロサービスへの導線をまとめてChannelが管理しているのです。
ユーザーにとって「LINEからメッセージを送る」という動作ひとつとっても、認証のためのAPI(Auth)と、実際にメッセージを送信するAPI(Messaging)は異なりますし、ユーザーのプロフィールを取得するAPI(Profile)などもあり、それぞれ要求される性能や必要なデータが違います。
APIを、Auth、Messaging、Profile、その他の機能、と細かく分割してChannelから呼び出す構成にすることで、一部のサーバーが落ちてもメッセージングそのものが影響を受けないという保証ができます。
分割の粒度をどう決めていくか?
導入の実例とあわせて、サービスを分割するメリットを述べてきましたが、マイクロサービスとはいえ、闇雲に小さくサービスを分割していけばいいというものでもありません。あくまで適切な単位での分割が必要になってきます。
前提になるのは、「いざ障害が発生したときに、止めて良いサービスとそうでないサービスを考えた上で設計する」ことです。「LINEのメッセージが止まると、一体誰がどのように困るのか」を開発するエンジニアが理解してないと、設計はできないということです。
弊社では、分割の最小単位を、ユーザーにとって動いてほしい単位の1つに1個マイクロサービスがある状態にすることを心掛けています。私の部署でいうと「LINEアプリでのスタンプショップ」「Webブラウザ向けのLINE STORE」などが1つの単位になっています。
とはいえ、ユーザーが捉える「単位」によっては、複数のマイクロサービスが存在することもあるでしょう。先ほど挙げた「スタンプを購入する機能」を例に取ると、アプリから見るスタンプショップと、Webブラウザから見るLINE STOREの2つが存在することになります。
この場合、片方のサービスに障害があっても、もう片方では使えることが理想です。共倒れを避けるには、ユーザーにとって見えているものに対して、1個ずつ対応できる(マイクロサービスがある)状態が自然なのではと考えています。
共有化したい部分はどこにあるのか? を明確にする
先に述べてきたことと相反する面もありますが、複数のサービスで共通化したい部分を1個のコンポーネントにまとめることも、分割におけるひとつの基準になっています。
例を挙げるとするなら、データベースやストレージにアクセスするために特殊なビジネスロジックを組んでいるといった場合は、1個のコンポーネントにまとめた方がメンテナンスもしやすくなるでしょう。
我々の事例では、LINEのスタンプショップと着せかえショップでこの基準を運用しています。「スタンプ」と「着せかえ」では、商品データやユーザーがお金を払って購入するまでのフローのように異なる部分もありますが、日本向け/アメリカやヨーロッパ向けのように管理上同等になっているルールもあります。
「このユーザーがこの商品を買えるか・買えないか」を判断するロジックは、どちらのショップサービスでも必要となるため、別々に実装するより、できる限り1個のコンポーネントにまとめて実装した方が、間違いが起こる可能性も減り、再利用性も高まります。
このように、「1個ずつ小さな単位でサービスを実装したい反面、共通化したい部分は1個のコンポーネントにまとめたい、というアンビバレンツな思いを持ちつつもバランスを取る」のが、実際のところだと思います。
本来エンジニアにとって良いのは、コードを少なくし、再利用性を高めることですし、これはマイクロサービスでも例外ではありません。前述のバランスに配慮しつつ、マイクロサービスとしてベストな状態を保てるぐらいの複雑さに収めるのが、一番良いのではないでしょうか。
もし目的を持たず、見通しもないまま分割や共有化の設計をしていくと、必要以上に細分化され、管理や運用も難しい状況に陥ってしまいます。
マイクロサービスアーキテクチャ運用の注意点 ~ ハマりやすい「落とし穴」とその解決策
ここでは、運用していく上で起こりがちな問題と、その解決策を紹介していきたいと思います。
Cascading Failure(ドミノ倒し)
マイクロサービスアーキテクチャの運用を考える上で、設計の時点で考慮しておく必要があるのが、Cascading Failureです。隣あった機能A・Bのうち一方に障害が起きると、もう一方にも障害が出て、いわゆるドミノ倒しのように複数の機能が動かなくなってしまう状態のことです。
これを防ぐには、機能Aが壊れたときにも機能Bが動き続けるような考慮が必要です。この考慮のことを、Circuit Breaker(遮断機)と呼んでいます。ある電気機器で漏電が発生したとしても他の機器は守る「ブレーカー」と同じように、サービスの本質的な部分を使えるようにし、障害が起こっている部分へのアクセスを一時的に切り離します。
例えば、LINEスタンプを購入できない障害が発生した場合、スタンプを送信する機能は使い続けたいが、新しいスタンプの販売登録やスタンプ画像の更新はいったんあきらめよう(つまり、そこにCircuit Breakerを入れることができる)、といった考え方です。
Circuit Breakerと同じように、多少データが古くても動いて問題がないものならデータをキャッシュし、欲しい応答が得られないときに代わりに応答するやり方もあります。
このようにサービス全体の品質を積極的に低下させることで、サービスの本当に大事な部分を守ることを、サービスのGraceful Degradation(グレースフル・デグラデーション)と呼びます。
マイクロサービスの文脈で語られることはあまりありませんが、ドミノ倒しを防ぐには「どの部分でドミノ倒しの連鎖を止めてサービスを守るか?」をあらかじめ考えておくことが必要です。
Cyclic Dependency:同期処理から非同期処理へ
次に紹介したいのが、Cyclic Dependency(循環依存)です。マイクロサービスが巡り巡って自分自身に依存するケースでは、より複雑な問題が発生します。
典型的なマルチスレッド問題と同様に、自分自身に依存するコンポーネントがあるシステムは、さまざまな理由でデッドロックを起こします。マイクロサービスにおいても、特に同期的な処理を行う場合に、Cyclic Dependencyの一箇所でリソース不足や問題が発生すると、全体の処理が止まってしまいます。
さらに、マイクロサービスアーキテクチャで大きなシステムを運用していると、Cyclic Dependencyがないことを保証するのが難しいこともあります。機能AがBに依存して、BがCに依存して、CがDに依存して、DがなぜかAに依存して……結果的にABCDのすべてが依存してしまうと、分離が難しくなり、障害はすべてを巻き込む状況になってしまいます。
少々強引な仮定ですが、コンポーネントの起動時や、定期的な処理にCyclic Dependencyがある場合、もしデータセンターの電源がすべて落ちるなど、全体の再起動が必要になったときに、ABCDどの機能から立ち上げればいいか分からない、といった最悪のケースも考えられます。さすがに、どうしようもなくなった事例を実際に目にしたことは私自身もありませんが、マイクロサービスの理論上、起こり得るのではないかと思います。
このような問題を避けるには、ビジネスロジックを工夫し、丁寧にシステムを設計する必要があります。依存性を解消するアプローチの一つは、同期的な依存を、非同期化することです。
同期処理は、機能Aから機能Bを呼び出したときに「応答が返ってくるまで待ち続ける」ことが問題です。結果どうなるかというと、機能Bが応答を返してくれないかぎり、機能Aは何もできない状態になり、他の機能CやDが機能Aを呼び出そうとしても応答せず、ABCDのすべてが稼働しなくなってしまいます。
呼び出し方法を、「機能Aが情報をキューに入れ、終わりにして次の処理に移る」というように非同期化することで、同期的な依存度を低くできます。LINE社内では、Apache Kafkaを導入することで、処理の非同期化を進めています。
解決テクニック1. Retry Strategy, Rate Limit & Throttling:流量制御
非同期処理の仕組みを導入しても、大量の呼び出しが発生した場合に、過負荷に陥らないような考慮が必要になります。過負荷に陥ると、リソース不足で各機能が稼働しなくなってしまうからです。
機能Aから機能Bを呼び出すときに、機能Bは処理負荷が大きいので、複数台のサーバーB-1・B-2・B-3に分散して稼働している例を考えます。B-1が何かしらの理由で不安定で一定時間レスポンスがない場合、機能BをB-2かB-3から呼び出そうという実装をすると思います。しかし、むやみに再呼び出しをしてしまっては、B-2とB-3も高負荷になって、不安定になり、サービス全体が止まってしまい、Cascading Failure(ドミノ倒し)が発生してしまいます。
それを防ぐには、そもそも過負荷に陥らせないため、呼び出しの流量を制御する必要があります。機能Aがあるユーザーから受け付けるリクエストは同時に3つまで、機能Bで10件の処理待ちが発生したらそれ以上は機能Aからの呼び出しを受け付けない、といった要領です。
解決テクニック2. Tracing:処理追跡ツールによる全体像の把握
マイクロサービスにある「全体像を把握しづらい」というデメリットに対して、効果的なのは「ボタンを押す」のようなアクションを起こしたあと、どのコンポーネントが呼び出されているかを可視化するためのツールを使うことです。
このように、分散されたサービス内のリクエストを可視化し、システムの挙動や性能を把握することを、分散トレーシング(技術)と呼びます。
弊社では、分散トレーシングシステムのZipkinや、OpenTracingといったツールを用いて、全体像の把握につとめています。
Armeriaを利用した簡潔なサンプルコード
最後に、弊社でオープンソースとして提供しているArmeriaの紹介をさせてください。Ameriaは、ThriftやgRPCを用いたAPIサーバーを実装するためのライブラリです。
マイクロサービスを実装するための機能を積極的にサポートしており、本記事の話題に関連する内容では、Circuit Breakerや依存するサービスの死活監視などの機能を持っています。まだ発展中ではありますが、ぜひ触ってみていただけると幸いです。
Armeriaのサンプルコードとして、公式レポジトリにも armeria-examples があります。
佐藤 春旗(さとう・はるき)singing_hacky haruki-sugarsun
編集:薄井千春(ZINE)