マイクロサービスを数年間実践して見えた課題とその対策

Pocket

こんにちは。Synergy! 開発チームの松本です。

前回の記事で、マイクロサービスアーキテクチャスタイルが持つ 9 つの特徴について解説しました。今回はその流れで、当社がここ数年、マイクロサービスアーキテクチャスタイルを実践してきて直面した課題と、現時点でのその対策をご紹介します。

  • サービス間のコミュニケーションが失敗する
  • オンプレ環境はモノリス化しやすい
  • サービスをコンテキスト境界できれいに分割することが難しい
  • 特定のサービス強化にリソースを集中投下したいケースがある
  • アラート対応による割込みで集中力も開発時間もうばわれる
  • サービス間通信における結合度をいかに下げるべきか

Synergy! に関するマイクロサービスへの取り組みをご紹介した過去記事はこちらです。

サービス間のコミュニケーションが失敗する

マイクロサービスアーキテクチャでは、サービス間のコミュニケーションに失敗することがあります。モノリシックアーキテクチャであっても、コンポーネント同士のコミュニケーションに失敗することはありますが、マイクロサービスアーキテクチャの場合、コミュニケーションがリモートコールであることから、失敗する確率はより高まります。

経験的に、多くのケースが一時的な障害なので、リトライ機構を入れることでそのほとんどを自動でリカバリーできます。しかし、この仕組みを期待通りに機能させるためには考慮すべきポイントがいくつかあります。

主に、次の三点です。

  • リトライ条件
  • リトライ戦略
  • リトライによる重複実行

リトライ条件

サービス間のコミュニケーションに失敗したら、何でもかんでもリトライする、というわけにはいきません。何度リトライしても失敗することが明らかなエラーもあります。無駄なリトライは、システムに不要な負荷をかけるだけです。

RESTish な HTTP での API コールを例にとると、ステータスコードが 400 系のエラーはリトライ不要です。コール元であるクライアントのリクエスト自体に問題があるのですから、同じリクエストを何度送ったところで結果は変わりません。

一方で、500 系のエラーなら、リトライによってリカバリーできる可能性があります。特に、500 Internal Server Error や 503 Service Unavailable はリトライ対象とすべきでしょう。

このように、コール先となったサービスから返される応答ステータスを条件に、リトライの実行可否を決定することになります。従って、コールされる側となるサービスも、応答ステータスを適切に返せるように設計/実装することが求められます。

もうひとつ、リトライすべきケースがあります。それは、通信障害によるコミュニケーション失敗時です。

通信障害が発生した場合、相手先となるサービスから応答コードが返されません。connection refused といった通信例外が発生するでしょうから、それらを拾ってリトライを実行することになります。

リトライ戦略

リトライ間隔をどの程度にすべきか。何度、あるいはいつまでリトライを繰り返すべきか。

リトライ機構を実現する上で、これらの戦略を決めることは非常に難しい課題です。コミュニケーションの失敗原因となった問題が復旧するまでに、どの程度の時間がかかるのかを推測することが難しいからです。

後者については、運用を続けながら調整するしかありません。当社の場合、ローンチ当初はリトライを続ける時間の上限を短め(あるいはリトライの回数上限を少なめ)に設定しておきました。この状態で運用を続け、リトライを諦めたあとに問題が自然復旧していることが多くみられるようなら、上限を緩和していくという調整を行っています。

前者のリトライ間隔については、Exponential Backoff がおすすめです。簡単に言うと、リトライする度に、指数関数的にその間隔を長くしていくという戦略です。

等間隔のリトライでは、障害時間が長引いた時に無駄な通信を発生させることになり、システムに余計な負荷をかけることになります。Exponential Backoff なら、リトライを繰り返すたびにその間隔が広がっていくので、この問題を緩和できます。

しかし、リトライ間隔については、これだけではまだ不十分です。

あるサービスに対し、同時に多くのアクセスが行われたことが、コミュニケーション失敗の原因となることもあります。もし、そのアクセス元すべてが同じタイミングでリトライを繰り返したら、負荷による障害も繰り返し発生し続け、いつまでたってもリカバリーできません。

これを回避するには、リトライ間隔をランダムに揺らすことが有効です。この辺りの方針については、下記ブログ記事が面白いので、参考にしてみてはいかがでしょうか。

リトライによる重複実行

サービス間のコミュニケーションは、一見、失敗したように見えて、実は成功しているケースもあり得ます。

例えば HTTP での API コールにおいて、サーバー側の処理に成功したにもかかわらず、通信障害によってステータスコードがクライアントに届かなかったような場合です。この状態で、クライアントがリトライを実行すると、サーバー側で同じ処理を二度実行することになり、不整合が起こるかもしれません。

また、API のリモートコールは、さらにその先で他の API のリモートコールが行われるようなツリー構造を形成していることも見逃せません。失敗したリモートコールがリトライされると、そこから先に形成されているリモートコールツリーのうち、前回成功していたリモートコールも間接的にリトライすることになります。つまり、このケースでも同じ処理を二度実行する事態が起こり得ます。

この問題は、API の実装において処理の冪等性を保障することでカバーします。そのためには、リトライされた一連の API コールの一意性を識別するキーが、コールされた API 側の処理で必要になります。従って、API をコールする側からそのキーを渡せるように、API を設計する必要があります。

なお、サービス間でのリモートコールによるコミュニケーションを追跡/制御するなら、Zipkin のような分散トレーシングや、Hystrix のようなサーキットブレーカーの導入も検討すべきでしょう。こちらについては様々なブログ記事などで取り上げられていますので、ここでは割愛します。

オンプレ環境はモノリス化しやすい

サーバの構築はインフラチームに依頼する - オンプレ環境での開発ではとくに、このようなプロセスを運用する組織が多いのではないでしょうか。しかし、アプリケーション実行環境の構築が、開発チームの責務となっていない体制では、ソフトウェアシステムがモノリス化しやすくなるようです。

インフラチームの人的リソースが十分ならこういった問題は起こらないかもしれません。しかし、現実はそうあまくはないでしょう。複数の開発チームからサーバ構築依頼を同時に受けても、全てのサーバを同時に構築するには人手不足です。ここに開発チーム間での調整や、待ち行列が発生します。

開発チーム間での調整は独立したサービスリリースを阻み、待ち行列はリリースサイクルのスピード低下につながります。これらは開発チームにとっての大きなストレスとなります。

そうすると、開発チームは、新たなサーバの構築を回避しようとしだします。他のアプリケーションが動作する既存のサーバ上に、新しいアプリケーションを共存させたいと考えるようになるのです。

しかし、ひとつのサーバでいくつものアプリケーションを実行するのは、サーバリソース的に非効率です。だから、新しいアプリケーションを作るのではなく、既存のアプリケーション対して新たな機能をつぎ足すようになります。こうして小さなモノリスができあがり、それが次第に巨大化していきます。

もちろんこういったことが起きないよう、ルールで縛ることはできます。しかし、長期間、開発/運用し続けるようなソフトウェアシステムにおいて、多くのルールを全員に理解させ、徹底し続けることは困難です。ルールがなくても皆が期待する行動を自然と取れるような環境を提供したいところです。

そこで、次のような選択肢が考えられます。

  • 開発チームのフルスタック化
  • サーバ構築の自動化
  • サーバレス
  • コンテナ化

開発チームのフルスタック化

チームがクロスファンクショナルでフルスタックであればあるほど、チームの独立性は向上します。従って、開発チームがサーバ構築スキルを獲得すれば良いわけです。

実践方法としてまず思いつくのは、インフラエンジニアを開発チームに迎えることです。ここで新たな問題となりそうなのは、人的リソースに対する制約でしょう。

全てのチームにインフラエンジニアを参加させられるほど、人的リソースが潤沢であるとは限りません。そもそも、インフラエンジニアの人的リソースが不足していたからこそ、この問題が発生していたわけですから。

もしチームにインフラエンジニアを参加させられたとしても、メンバーとなったインフラエンジニアがチーム内の仕事だけを担うと、少々時間をもてあましてしまいます。だからといって、インフラエンジニアがチームの掛け持ちを行うと、チーム間での調整が再燃します。だったらもとの通り、インフラエンジニアだけでひとつのチームを組んで、次々とやってくる開発チームからのオーダーに応えた方が、まだ効率が良いでしょう。

次に考えられる実践方法は、アプリケーションエンジニアがサーバ構築技術を習得するというものです。もちろん、インフラエンジニアがアプリケーション開発技術を習得するというのも良いでしょう。

しかしここでも現実的な壁が立ちはだかります。アプリケーション開発/保守、インフラ構築/運用、どちらもスキルとして専門性が高く奥が深いので、両方のスキルを十分に兼ね備えたスーパーエンジニアは、なかなか現れてくれません。

この二つのいずれかの実践方法が上手く機能するケースもあるとは思いますが、残念ながら当社のケースでは上手くはまりませんでした。

サーバ構築の自動化

仮想サーバなら、Vagrant のようなソフトウェアを使って構築を自動化する方法があります。物理サーバでも、Ansible のような構成管理ソフトウェアでサーバのセットアップを自動化できます。

この戦略でもやはり、開発チームからインフラチームへの構築依頼は発生します。サーバ OS や多くのミドルウェア、ネットワークに関する知識が必要となるからです。しかし、インフラチームが手動でサーバを構築していたり、サーバ構築が完全にインフラチームの仕事として切り出されている状況と比較すると、インフラチームの負荷が小さくなるので、課題は少し改善します。

サーバレス

オンプレ環境で FaaS (Function as a Service) を実現する OSS がいくつかあります。これらを使ってサーバレス環境を実現するという方法もあります。

本格的な FaaS 環境の構築と運用は、それなりに大きな規模になるので、可能なら自前でオンプレに環境を整えるより、クラウド上のマネージドなサービスを使いたいところです。当社もクラウドに配置しているサービスの多くはサーバレスで動いています。

社内事情でクラウドを利用できないということなら、オンプレで環境を構築するしかありません。FaaS 実現のための OSS のいくつかは、Kubernetes を使ったオーケストレーションや、コンテナ技術を前提にしていることが多いようです。次のコンテナ化戦略との併用としても良いかもしれません。

コンテナ化

オーケストレーションも含めたコンテナ環境の整備と移行は大変な労力を必要とします。しかし、この戦略が現時点で最もバランスの良い解ではないかと考えています。

新しいアプリケーションを立ち上げるたびにコンテナを追加するのは開発チームの役割です。インフラチームは、コンテナ環境という、全ての開発チームが共通で利用する基盤の構築と運用を担います。こうすればそれぞれのエンジニアの技術的専門性も活かせ、開発チーム間の余分な調整も生み出さず、チームの独立性を保ったスピード感のあるプロダクト開発が実現できます。

できれば GKE (Google Kubernetes Engine) のようなマネージドなサービスを利用したいところですが、クラウドを利用できないケースもあるでしょう。しかし、KubernetesDocker の登場で、オンプレでのコンテナ技術を利用するハードルは下がっています。当社でもまさに今、オンプレおよびクラウドの両方で、Kubernetes を使ったコンテナ基盤の整備を進めており、いくつかのサービスがコンテナ上で動き出しています。

サービスをコンテキスト境界できれいに分割することが難しい

マイクロサービスアーキテクチャおいて、実践が難しいことのひとつが、ソフトウェアシステムを適切にサービス分割することです。

この課題は、フェーズによってふたつに分けられます。

  • 初期のサービス分割を適切に設計すること
  • ローンチ後、サービス分割を適切な粒度に保ち続けること

初期のサービス分割を適切に設計すること

ソフトウェアシステムの規模にもよりますが、初期のサービス分割設計は、可能な限り少人数で行うことをおすすめします。この役割を仮に「サービスコーディネーター」と呼ぶこととします。

ビジネスケイパビリティコンテキスト境界の抽出は、切り口によって様々に結果が変わってきます。複数人で完全に一貫性を持った分割基準を持てれば良いのですが、実際はそれぞれの経験や能力、知識、置かれている環境などによって、分割基準がまちまちになります。

だから、可能な限り少人数のサービスコーディネーターで、サービス分割と、サービス間の連携方法について設計し、全体としての一貫性維持に注力します。その結果として抽出されたサービスを、各チームに割り当てます。チームは、割り当てられたサービスを詳細に設計し、実装します。当社の場合、このような流れで初期の設計を進めました。

ローンチ後、サービス分割を適切な粒度に保ち続けること

初期のサービス分割設計と同様に難しいのが、ローンチ後に追加開発を繰り返す中で、サービスの粒度を常に良い状態に保ち続けることです。

ここでは次のように、課題を更に二つの観点に分類します。

  • 既存のサービスの統廃合
  • ビジネス要求によるまったく新しいサービスの追加

既存のサービスの統廃合

あるひとつの既存サービスを、複数のサービスに分割したい時があります。サービスが担うビジネスケイパビリティコンテキストが大きすぎた時です。あるいは、はじめは小さくても開発を繰り返す中でそれらが大きくなりすぎてしまった時などです。こういったケースにおいては、そのサービスを所有するチーム自身が分割案を構想し、実際に進めていくことになります。

逆に、チームをまたいだ複数のサービスを統合するケースもあります。これは、それらのサービスがビジネスケイパビリティコンテキストの一部を共有してしまうために発生します。このケースは、それら複数のサービスの機能開発を同時期に行う頻度が高いという点から検知され、統合プロジェクトが動きだします。

どちらのケースもチーム単体や、チーム間の協力で解決できそうです。基本方針としてはこのように、チームが主体的にサービス粒度を適切に保つという責務を遂行すべきです。

しかし、現実は理想の通りには進みません。

チームは日々、ビジネス要求に応えるべく、様々な機能開発に従事し、アラート対応に追われ、システムパフォーマンスの向上に時間を追われています。その中で、サービスの粒度を良い状態に保つというタスクは、優先順位がどうしても下がりがちになります。

優先順位を上げて対応できたとしても、プロダクト全体として一貫性のあるサービス分割設計を、チーム単体で行うことは困難です。結果、マイクロサービスアーキテクチャ全体としてのサービス分割設計は、少しずついびつになっていきます。

だから、初期のサービス分割設計を行ったサービスコーディネーターが、定期的にサービス分割設計を見直すというプロセスを組み込みます。ここで見直された設計は、チームのバックログに入ります。バックログとしてやるべきことが明示化されるので、チームは実施日を決めて、サービスの見直しに取り掛かることが可能になります。

ビジネス要求によるまったく新しいサービスの追加

新たなビジネス要求に応えるために、まったく新しいサービスが必要になることがあります。しかし、この「ビジネス要求から新たなサービスを抽出する」という設計は、いったい誰が行うのでしょうか。

新しいサービスの追加とならないまでも、要求分析の結果、いくつかの既存サービスに、機能追加を行うことになるかもしれません。これも、各サービスへの機能仕分けを適切に行う役割を担う人が必要となります。

マイクロサービスアーキテクチャは、複数のサービスが協力しあってビジネス要求に応えています。従って、ビジネス要求の単位と、サービスの単位は、完全には一致しません。このミスマッチが大きければ、ビジネス要求をもとにアーキテクチャを紐解く人が必要となります。この役割を担う人も、一貫性の側面から、初期のサービス分割設計を行ったサービスコーディネーターであることが望ましいと考えられます。

ただし、サービスコーディネーターの責務が大きすぎると、彼らが開発スケジュール上のボトルネックになり、チームの独立性をむしばむ原因となりかねません。可能な限り、チームの責務を大きくし、サービスコーディネーターの責務を小さくするようなプロセス設計を心がけることが重要です。

特定のサービス強化にリソースを集中投下したいケースがある

ビジネス要求により、特定のサービスを集中的に強化したいことがあります。しかし、サービスの開発と運用は少人数のチームで担っています(Two Pizza Team)。単独チームでは人的リソースが不足してしまうことがあります。

他のチームから人を借りてくるべきか、悩ましいところです。これをやりだすと、チームをまたいだ人的リソースの管理と調整がチーム間の結合度を高め、サービスの独立したリリースの阻害要因となります。これを繰り返すと、そもそもチームという単位が形骸化し、プロジェクト単位で編成されるアサインされた担当者の集まりになってしまいます。

ビジネスケイパビリティコンテキスト境界を無視して、いくつかのチームにサービスを切り出すというのも、サービス間の結合度を高め、チーム間の結合度を高めてしまいます。だからと言って、開発期間を延ばして単独の少人数チームだけで対応するのも、ビジネスチャンスを逃しかねません。

この課題は、まれに発生するビジネス要求の変化によるケースだけでなく、日常的なチーム間の稼働状況の格差としてもあらわれます。例えば SoE(System of Engagement) のような、恒常的に開発され続けるサービスを担当するチームは常に稼働状況が高くなりやすく、SoR(System of Records)のような、比較的変化の少ないサービスを担うチームは稼働状況が低くなりやすい、といったケースです。

そう考えると、要求に応じてプロジェクトチームを編成するのが臨機応変で良いようにも思えてきます。

プロジェクトチームではなくプロダクトチーム、そしてチームビルディング

マイクロサービスアーキテクチャスタイルでの開発は、基本的に、開発ごとに必要となる人数のエンジニアを集めるプロジェクト型のモデルではなく、プロダクト型のモデルです。スクラムチームのように、チームが自分たちのベロシティ(任意期間内に開発可能な規模)を理解した上で、バックログから優先順位の高いアイテムを順次、動くモノに変えていくようなスタイルに近いと言えます。プロジェクト型モデルは何をいつまでに開発するかが先にあって、それに合わせて都度、チームを組むやり方。後者であるプロダクト型モデルはチームが先にあって、チームの開発能力に合わせて何をいつまでに開発するかが決まるやり方です。

長期間、継続して繰り返し開発されるようなソフトウェアシステムは、設計や実装の一貫性を維持しなければ、コードのエントロピーが急激に増大します。特に、マイクロサービスアーキテクチャを採用するような大きなシステムでは、ひとりの人間が全てを把握することが難しく、設計や実装の一貫性を維持することが困難になります。だからこそ、ビジネスケイパビリティの視点でソフトウェアシステムを複数のサービスに分割し、そのそれぞれに担当チームを付けるのです。チームは、自分たちが担当するケイパビリティに対する専門性を高めることと、マイクロサービスに対する設計や実装の一貫性を維持することに注力します。

また、チームビルディングが進むと、チーム内での各メンバーの役割が自然に決まっていきます。イテレーションを強力に推進する人や、チームが抱える技術的課題を突破していく人、チーム内でこぼれ落ちた仕事を見つけ出して次々と片付けていく人など。こういった集まりが上手くかみ合って、チームの開発能力が高まっていきます。

だから、やはり「チーム」が単位なのです。では、チームのベロシティを超える仕事を、どのようにして対処すれば良いのでしょう。

チーム協働モデル

私たちもベストなソリューションを見出せていませんが、人的リソースが不足しているチームの開発に、他のチームがチームごと一時的に参加するというやり方が良いようです。ふたつ(やそれ以上)のチームが完全に融合するというより、チーム同士の協働です。開発が完了したらこの協働は終了です。開発したサービスは、もともと担当していたチームが運用を担います。

このチーム協働モデルで行われる調整は、それぞれのチームが担当する開発領域を決めることと、対象サービスのオーナーとなるチーム側の設計思想や実装方針を、ジョインした側のチームが理解することです。これらの一貫性は、サービスを所有するチームがレビューを行うというプロセスの導入で担保できます。どうしても、チーム間のクリティカルパスは出来てしまいますが、これは単独チームで開発を進めても発生することなので、そこまで大きな問題にはならないでしょう。人単位でのリソース調整やクリティカルパスの管理をするよりは、シンプルです。

アラート対応による割込みで集中力も開発時間もうばわれる

チームが開発と運用の両方を担うと、日々発砲されるアラートの対応に追われるようになります。新機能のリリース直後は特に、障害に至らないまでも、過検知も含めた細かな問題が検知されるたびにエンジニアは開発の手を止め、検知された問題の内容を確認し、切り分けすることになります。日々、機能強化を続けるプロダクトなら、システムが枯れないので、この呪縛からはなかなか抜け出せません。

このストレスから逃れるために、チームのモチベーションは、サービスの信頼性を上げようとする方向に向かいます。アラートが続けば集中力をうばわれ、生産性が低下します。24/365 のようなプロダクトだと、就寝中も、バカンス中も関係なくアラートが襲ってきます。精神的にも体力的にも疲弊してしまいます。

この課題に対しては、次のような地道な改善を重ねて負担を軽減します。

  • 一次対応だけでなく、なるべく早くその原因を潰してしまう。そのための保守工数を、チームの定常的な業務として確保しておく
  • 復旧作業をできるかぎり自動化する。自動化された復旧処理の実行トリガーは、自動でも手動でも構わないが、自動がより良い
  • 開発段階からある程度、障害を見越した設計と実装を心掛ける
    • 将来起こりうるすべての障害を見越して設計することは不可能なので、やり過ぎには注意
    • 問題発生時に状況が把握できるように、どこでどのようなログを出力するかが、最も重要
  • 夜中にアラート対応を行った場合は、対応者の翌日勤務を休みにするといった柔軟な勤務を可能にする

サービス間通信における結合度をいかに下げるべきか

サービス間通信を行うことで発生する、開発上の主な依存関係が二つあります。

ひとつはスケジュール上の依存関係。利用される側となる API の開発が進まないと、それを利用する側の開発が進まないという点。こういった関係は、スケジュール上でのクリティカルパスを生み出し、開発期間を長くします。

もうひとつは、API をリモートコールするためのコード上の依存関係。API 提供側が、SDK のようなクライアントライブラリを作成して配布すると、そこで利用されているライブラリのバージョンやプログラミング言語が依存関係や制約となってしまいます。だからといって、API 利用側となる複数のサービスがそれぞれでリモートコールを実装するのは、同じ目的を持つコードを重複して開発するという無駄が発生してしまいます。

ひとつめの課題はセオリーどおり、API の定義(インタフェース)を先に作成し、それを API 利用側となる開発チームに共有することで、スケジュール上の依存関係を緩和します。RESTish な API なら、OpenAPI Specification を使うのがおすすめです(gRPC でも OK)。これによって、ふたつめの課題も同時に解決できます。

OpenAPI Specification での API 定義をもとに、クライアントコードを自動生成できるので、API 利用側はこれを利用してふたつめの課題を解決できます。もちろん、クライアントコードの自動生成は、様々なプログラミング言語に対応しています。このクライアントコード自動生成は、機能面で少々もの足りなさもあるので、余力があるならオリジナルの自動生成ツールを作成して共有するのも良いでしょう。

また、OpenAPI Specification の定義を使い、サーバ側のコードも自動生成できます。これに少しの実装を加えれば、API のリモートコールを含めたテストのモックとして利用できます。

実は、このままではもうひとつの課題が残っています。API 内で定義されているモデルを、API 利用側がサービス内にそのまま組み込んでしまいやすいという点です。コンテキストが異なるサービス間でのモデルの共有は、ミスマッチをおこし、保守性や拡張性を貶める原因となります。

ここは、ドメイン駆動設計境界づけられたコンテキスト間での「変換マップ」が解決の糸口となります。API 内のモデルと API 利用側サービス内でのモデルを相互変換するロジックをリモートコール前後に組み込むのです。

このロジックは、API 利用側に DI (Dependency Injection) としてコード化するのがシンプルです。つまり、API 利用側が、サービス間の関係をインタフェースとして抽象化します。インタフェースで扱われるモデルは、API 利用側サービスが定義するモデルです。その実装として、モデル変換処理とリモートコール処理を注入します。こうすることで、シンプルなリモートコールなら、OpenAPI Specification が定義されるのを待つことすらなく、インタフェースを定義し、モック実装を注入してテストすることも可能になります。

書籍「エリック・エヴァンスのドメイン駆動設計」では、境界づけられたコンテキスト間の関係について、共有カーネル(Shared Kernel)や腐敗防止層(Anticorruption Layer)、別々の道(Separate Ways)を含む 7 つのパターンが定義されています。これらがサービスの分割や、サービス同士の関係を整理する上で、非常に参考になります。

最後に

今回もまた長い記事になってしまいました。

記事が長くなれば執筆時間も比例して長くなります。もっと速く執筆できればと思い、色々工夫はしているのですが、なかなか難しいものですね。

Pocket

Comments are closed, but you can leave a trackback: Trackback URL.