💪

【ApolloFederation】Schemaマージ時のコンフリクトエラーを回避できるか試してみた

2023/06/22に公開

はじめに

こんにちは、スペースマーケットでバックエンドエンジニアをしています。
今回は弊社で利用しているApolloFederationについての記事です。

弊社では複数のマイクロサービスを取りまとめて単一のGraphQLスキーマを提供するGatewayとしてApolloFederationを利用しています。(弊社のApolloFederationを利用したSchemaマージに関してはこちらで詳しく解説されています)

加えて、弊社では技術刷新の取り組みとしてRubyOnRailsからNestJSへのGraphQL実装移行も進めております。

今回はこのGraphQL移行実装のリリースをする際に障壁となる課題が存在したため、これをどうにかアプリケーションレベル(ApolloFederation)で回避することができないか実装・検証してみました。

現状課題

まずは移行実装のリリースにおける課題について説明します。

例えば、ServiceA(Rails)にhogeQueryを実装していたとします。
これを移行先となるServiceB(NestJS)にも同様のhogeQueryを実装しました。

各サービスで生成されるSchema以下の通りです。

ServiceAの既存実装のSchema

type Query {
  hoge: String!
  ...
}

ServiceBの移行実装後のSchema

type Query {
  hoge: String!
  ...
}

ここで、ServiceBの移行実装PRレビューを貰った勢いで、ServiceBをリリースするとどうなるでしょうか?答えはApolloFederationでSchemaのコンフリクトエラーが発生して起動に失敗します。

Schemaコンフリクトのイメージ

エラー内容は以下です。Query.hogeの定義は1度にしてと言われてます。

Error: A valid schema couldn't be composed. The following composition errors were found:
        Field "Query.hoge" can only be defined once.".

Gatewayは全てのAPIリクエストの受け口となっているため、このエラーが発生すると全てのAPIリクエストが失敗してしまいます。

逆に、このコンフリクトを避けるために、先にServiceAのhogeQueryを削除した移行実装をリリースする場合はどうでしょうか?こちらも答えは単純ですが、クライアントサイドからhogeQueryを参照することができなくなってしまいます。

このように一瞬でも特定のSchemaが一時的に重複する状態、もしくは一時的に存在しない状態になるとサービスのダウンタイム発生に繋がります。これが移行実装のリリースを難しくさせている根本的な原因です。

弊社ではこのエラーを回避するために、tmp環境(一時的に用意した既存のミラー環境)を作成し、一旦そちらに全てのAPIリクエストを流してから、アクセスが来なくなった既存の環境で各サービスの移行実装をリリースし、正常にGatewayが機能するようになったことを確認してからAPIリクエストを戻すリリース手法をとっていました。所謂カナリアリリース的な手法(?)です。

しかし、実装のリプレイスにしてはtmp環境の作成等作業コストが高いと感じており、どうにかしてコードの変更だけ(アプリケーションレベル)でこの移行実装をリリースできないかと考えるに至りました。

実装

それでは早速ですが、今回の趣旨に入ります。
私は上記の課題の根本的な原因となるスキーマコンフリクトをApolloFederationだけで回避できないか考えてみました。

色々試してみた結果、以下の実装でスキーマのコンフリクトを回避することができました。

const gateway = new ApolloGateway({
  serviceList: [
    { name: 'ServiceA', url: `${process.env.SERVICE_A}/graphql` },
    { name: 'ServiceB', url: `${process.env.SERVICE_B}/graphql` },
  ],
  buildService({ url, name }) {
    return new RemoteGraphQLDataSource<GatewayContext>({
      url,
      willSendRequest({ request, context }) {
        // 省略
      },
      didReceiveResponse({ response }) {
        if (name === 'ServiceB') {
          // ServiceBからのレスポンスのみ
          if (response.data?._service) {
            // スキーマの取得時のみ
            // 該当のQueryを削除
            response.data._service.sdl = response.data!._service.sdl.replace(
              /hoge: String!/g,
              '',
            );
          }
        }
        return response;
      },
    });
  },
});

buildServicedidReceiveResponse内で、各サービスからのSDL(Schema Definition Language)が渡ってくるので、ServiceBから提供されるSchema中のhogeQueryのみを文字列置換によって削除します。少々強引さが否めないですが、これでServiceAとServiceBから同じSchemaが提供されたとしてもコンフリクトエラーを回避することができるようになります。

ちなみに下記の条件分岐を設定しているのは、Schema取得時だけでなく、APIリクエストを受けた際にもこちらのdidReceiveResponseが実行されるのですが、その場合はresponse.data内に_serviceが含まれず、エラーになってしまうためです。

if (response.data?._service) {}

_serviceはApolloGatewayが各サービスから提供されるSchemaを取得するときにのみ含まれるようになっているようですね。

検証

では実際にこの実装でhogeQueryの呼び出しに問題がないか検証してみます。

前提条件としては以下の通りです。

  • ServiceAは既存の実装のまま
    • hogeクエリで文字列hoge from ServiceAを返す
  • ServiceBは移行実装を行なった状態
    • hogeクエリで文字列hoge from ServiceBを返す
  • GatewayはServiceBからのhogeQueryを削除するように実装(上述)

こちらの条件下で、Gatewayが正常に起動したことを確認してからhogeQueryを呼び出してみます。

以下のレスポンスが返ってきました。

{
  "data": {
    "hoge": "hoge from ServiceA"
  }
}

ServiceAで実装されたhogeQueryが呼び出されていることが確認できました。

続いて先ほどの前提条件の3つ目を以下のように変更してServiceAからのレスポンスを消し込んでみます。

  • GatewayはServiceAからのhogeQueryを削除するように実装(変更は以下)
- if (name === 'ServiceB') {
+ if (name === 'ServiceA') {

再度hogeQueryを呼び出してみると、以下のレスポンスが返ってきました。

{
  "data": {
    "hoge": "hoge from ServiceB"
  }
}

今度はServiceAからではなく、ServiceBで実装されたhogeQueryが呼び出されていることが確認できました。

まとめ

いかがでしたでしょうか?
無理矢理感は否めないのですが、tmp環境の作成といった作業を行うことなくGatewayのコードに変更を加えるだけ(アプレケーションレベル)で移行実装のリリースができるようになるのではないかと思います✨

よりスマートにSchemaマージ時の挙動を変えることができないかApolloFederationのドキュメント等も調べてはみたのですが、現状はこのように取得した生のSchemaを力技で改変するしかないようです。

もし、より良い方法があればコメントで教えていただけると嬉しいです。

最後に

弊社では、GraphQL や NestJS を利用した技術刷新に積極的に取り組んでおります。これらの技術を用いた開発に興味がある方は、ぜひ弊社の採用ページをご覧ください。

https://www.wantedly.com/projects/1113570
https://www.wantedly.com/projects/1113544
https://www.wantedly.com/projects/1061116
https://spacemarket.co.jp/recruit/engineer/

スペースマーケット Engineer Blog

Discussion