切り分け方を間違えたマイクロサービスについてのおとぎ話


こんにちは、 @qsona です。本記事はMicroservices Advent Calendar 2日目の記事で、息切れしない程度に1日目の続きを書きます。(2時間遅刻><) 宜しければぜひ 1日目 もご覧下さい。

さて、ある巨大なシステムがあるとします。下はこのシステムの状態を、良い順に表したものです。

  1. 適切にマイクロサービスに切り分けられたサービス。
  2. 適切にモジュール化された、モノリシックのサービス。
  3. 密結合になっているモノリシックのサービス。
  4. 適切ではないマイクロサービス化をされたサービス。

1が2より良いというのは私のポジショントークだとして、何が言いたいかというと、3と4の比較で「マイクロサービスの切り分け方に失敗するくらいならモノリシックのほうがマシ」ということです。

では、適切ではない切り分け方というのは、例えばどういうものでしょうか? また、なぜそういうことが起きてしまうのか。そしてそうなったサービスはどうなる運命なのでしょうか。

切り分け方を間違えたマイクロサービスのおとぎ話

昔々あるところに、 パズル をやることで クエスト を進めていくようなゲームがありました。
「パズルを完了した時に、クエストを進める」というものです。

エンジニアは、 パズル というサービスと クエスト というサービスの2つに分けることにしました。

PuzzleサービスとQuestサービスが疎結合になり、それぞれが独自に開発運用できる状態になればめでたしめでたし。

切り分けに失敗した例

以下は切り分けに失敗した例です。何がダメなのでしょうか? なお、疑似コード的に書いていますが、メソッドのうち必要なものは、サービスインターフェイス (Web API) を通して提供されているものとします。

Puzzle サービス (WebAPIとして提供: Puzzle#finish)


class Puzzle {
  // パズル終了!!
  finish() {
    // パズル自体の終了処理...

    // このパズルから付与するポイントを計算する
    var point = this.calcQuestPoint();
    // 現在進行中のクエストを取得する
    var currentQuest = Quest.getCurrent();
    // クエストにポイントを付与する
    currentQuest.addPoint(point);
  }

  calcQuestPoint() {
    // パズルの進捗状況から計算する...
    return this.xxx + this.yyy * this.zzz;
  }
}

Quest サービス (WebAPIとして提供: Quest.getCurrent, Quest#addPoint)

class Quest {
  static getCurrent() {
    // いま進めているクエストを取得
    return new Quest();
  }

  addPoint(point) {
    this.point += point;
    // 一定ポイントたまったらクエスト完了とする
    if (point >= 100) { 
      this.complete();
    }
  }
}

切り分けに失敗したときの辛さ

上の切り分け方がダメな理由は明白で、
PuzzleサービスにQuestのロジックが入り込んでいる ことです。

今回のマイクロサービス化のゴールを再確認すると、PuzzleサービスとQuestサービスが独自に開発運用できる状態でした。上の切り分け方では、どうなるのでしょうか?

それでは、昔々ある日のQuestチームの状況を見てみましょう。(架空です)


Quest企画さん: いまはユーザ1人では同時に走るクエストは1種類だけど、これからは2種類のクエストを同時に走らせるようにしよう。
QuestエンジニアA: ええやん
Quest企画さん: 仕様はこうでこうでごにょごにょごにょ・・・・・・よろしく頼む〜

QuestエンジニアB: いまgetCurrentっていうAPI提供しちゃってますけども。どうします?
QuestエンジニアA: 全部のクエストを配列で返すAPIつくろ
QuestエンジニアB: いいですけど(せこせこ。PR。)

QuestエンジニアB: 誰がgetCurrentのAPI使ってるんだっけ。(せこせこ。調べる)
QuestエンジニアB: ああ、Puzzleだ。使ってるPuzzleサービスも直さないとですよ。
QuestエンジニアA: せやな。(せこせこ。PuzzleサービスにPR)

    // 現在進行中のクエストを取得する
-   var currentQuest = Quest.getCurrent();
+   var currentQuests = Quest.getCurrentQuests();
    // クエストにポイントを付与する
+   currentQuests.forEach(currentQuest => {
      currentQuest.addPoint(point);
+   });

PuzzleエンジニアC: このPRなんすか
Quest エンジニアA: クエストの仕様がこういう風にかわってごにょごにょごにょ・・・・・・
PuzzleエンジニアC: (あんまりクエストの仕様知るのに時間つかいたくないんだけどな)
PuzzleエンジニアC: はい、LGTM。リリースは3日後です。
Quest エンジニアA: いやー、今日ださなあかんねん
PuzzleエンジニアC: はい? (またQuestチームはこれか) しゃーないんでhotfixで出しますよ。
Quest エンジニアA: ええやん

PuzzleエンジニアD: これどういう順番でリリースするん?
PuzzleエンジニアC: Questが先でPuzzleが後です。getCurrentQuestsのAPIリリースされないとPuzzleリリースできないでしょう。
Quest エンジニアB: けどQuestリリースしたら新しいクエストすぐ公開されますよね。そこからPuzzleリリースするまでは新しいクエストにポイント入らないことに。。
PuzzleエンジニアD: (同時リリースできないんだから無理だろ・・・) まあなるべく間隔あけずにデプロイしてください。
Quest エンジニアA: なんとかなるやろ

(リリース後)
Quest企画さん: パズルクリアしたけど新しいクエスト進まなかったってユーザに言われてるけど
Quest エンジニアA: それ仕様や!


てな感じで、問題をあげると

  • Questの変更がQuestサービスに閉じていない。別サービスのPuzzleに修正を出している。
    • 別サービスにPR => お互いにコストが高い。
    • 繋ぎこみも大変。
  • リリース順の問題。単独リリースが理想だが、上のようにお互いに依存していると、上げる順番がないことすらある。

ということになります。

この先生きのこるのか

  1. 「これは切り分け方が良くない。」そう気付いたエンジニアは、一度マイクロサービスを捨てて1つのサービスへとマージを図りました。PuzzleとQuestのチームは1つになり、サービスも1つになりました。苦しいマージ作業を通してチームは団結し、パズル・クエストチームとして発展しましたとさ。(モノリシックエンド)

  2. なかなか成果がないQuestチーム、開発と価値検証を素早くイテレーションしたかったが、マイクロサービスが足を引っ張りなかなかうまく進めません。そうこうしているうちにQuestが事業のメインストリームではなくなり、Questサービスは緩やかな死を迎えましたとさ。(バッドエンド)

この問題は回避できるのか?

さて、程度はともあれ、マイクロサービスには上のような悩みはつきものだと思いますが、。

マイクロサービスでは、各サービスが何の責務を持つのかを考えるのがまず重要です。そして、今回はPuzzleとQuestがそれぞれ責務です。そうだとしたら、QuestのロジックはすべてQuestに寄っていなければいけません。

1日目に書いたように、イベント駆動を利用してPuzzleから一切のQuestのロジックを剥がすことができれば、どうでしょうか。

Puzzle サービス

class Puzzle {
  // パズル終了!!
  finish() {
    // パズル自体の終了処理...

    // マイクロサービス間でのイベント通知?
    Event.emit('finish puzzle', this);
  }
}

// Puzzleサービスには、Questに関する記述が一切ない

Quest サービス

class Quest {
  static getCurrent() {
    // いま進めているクエストを取得
    return new Quest();
  }
  static calcQuestPointByPuzzle(puzzle) {
    // パズルの進捗状況から計算する...
    return puzzle.xxx + puzzle.yyy * puzzle.zzz;
  }

  addPoint(point) {
    this.point += point;
    // 一定ポイントたまったらクエスト完了とする
    if (point >= 100) { 
      this.complete();
    }
  }
}

// パズル完了イベントを受け取る(マイクロサービス間のイベント受け取り?)
Event.on('finish puzzle', (puzzle) => {
  var currentQuest = Quest.getCurrent();
  // クエストポイントを付与する
  currentQuest.addPoint(this.calcQuestPoint());
});

責務の切り分けとしてはずっとよくなりました。

ただし、イベント通知については、サービス内のイベント通知であれば実装の問題ですが、マイクロサービス間でのイベント通知は、物理的な問題を考えなければなりません。

ここでは仮に物理的な問題が解決されたとしましょう。するとQuestチームの人はQuestサービスにだけにコミットできるようになり、めでたしめでたし。でしょうか?

実はさらにまだ、上の例から見えてくる、マイクロサービスでは顕著になる考慮すべき問題があります。これも3日目以降に書いていきたいと思います。めでたしめでたしは遠い。