STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

Goで作られたシステムをRuby on Railsに移植しています 〜GraphQL編〜

Goで作られたシステムをRuby on Railsに移植しています 〜GraphQL編〜

STORES でエンジニアをしている片桐です。

先日、「Goで作られたシステムをRuby on Railsに移植しています」という記事を投稿させていただきました。

product.st.inc

product.st.inc

ベース部分の実装について別ブログで紹介したいと書かせていただきましたが、今回はその中から「GraphQLの移行基盤としてのGraphQL Stitchingの導入」について紹介させていただきます。

GraphQL Stitching の導入

GraphQL Stitchingの実現には、すでに弊社内で採用実績のある graphql-stitching gemを利用します。

導入後、graphql-stitching gemのREADME.mdのQuick Startを参考に、graphql-rubyで作成されるgraphql_controllerのschema実行部分を書き換えてみます。

app/controllers/graphql_controller.rb

 class GraphqlController < ApplicationController
   def execute
     # (略)

-    result = Schema.execute(query, variables:, context:, operation_name:)
+    client = ::GraphQL::Stitching::Client.new(locations: {
+      other: {
+        # 移植元システムの提供するGraphQLのschema。移植先システムのリポジトリにあらかじめ含めておく。
+        schema: ::GraphQL::Schema.from_definition(::Rails.root.join("app/models/other_system/schema.graphql")),
+        executable: ::GraphQL::Stitching::HttpExecutable.new(url: ::Rails.configuration.x.other_graphql_endpoint),
+      },
+      main: {
+        schema: ::Schema,
+      },
+    })
+    result = client.execute(query, variables:, context:, operation_name:)
     render json: result
   rescue StandardError => e
     raise e unless Rails.env.development?
     handle_error_in_development(e)
   end

   # (略)
 end

Production Readyな状態にする

上記の変更で一応動作はするようになるのですが、実際に本番で利用するためにはさらに追加で設定すべき部分があります。また、今回の移植特有の設定も追加で必要です。この記事では今回の移植において行なった設定や変更をご紹介します。

本番環境と開発環境でStitchingClientの生成方法を切り替える

graphql-stitchingのドキュメントを読むと、本番環境ではschemaのマージ(composition)はせず、事前にマージ済みのschemaからStichingClientを生成すべきと記載されています。

github.com

このドキュメントを素直に実装すると、以下のようになります。

lib/tasks/dump_schema.rake

マージ済みのschemaを生成するrake task

task dump_schema: :environment do
  client = GraphQL::Stitching::Client.new(locations: {
    other: {
      schema: GraphQL::Schema.from_definition(Rails.root.join("app/models/other_system/schema.graphql")),
      executable: GraphQL::Stitching::HttpExecutable.new(url: Rails.configuration.x.other_graphql_endpoint),
    },
    main: {
      schema: Schema,
    },
  })
  Rails.root.join("app/models/other_system/schema.graphql").write(client.supergraph.to_definition)
end

app/controllers/graphql_controller.rb

 class GraphqlController < ApplicationController
   def execute
     # (略)

-    client = GraphQL::Stitching::Client.new(locations: {
-      other: {
-        schema: ::GraphQL::Schema.from_definition(::Rails.root.join("app/models/other_system/schema.graphql")),
-        executable: ::GraphQL::Stitching::HttpExecutable.new(url: ::Rails.configuration.x.other_graphql_endpoint),
-      },
-      main: {
-        schema: ::Schema,
-      },
-    })
+    client = if ::Rails.env.development?
+      ::GraphQL::Stitching::Client.new(locations: {
+        other: {
+          schema: ::GraphQL::Schema.from_definition(::Rails.root.join("app/models/other_system/schema.graphql")),
+          executable: ::GraphQL::Stitching::HttpExecutable.new(url: ::Rails.configuration.x.other_graphql_endpoint),
+        },
+        main: {
+          schema: ::Schema,
+        },
+      })
+    else
+      ::GraphQL::Stitching::Client.from_definition(::GraphQL::Schema.from_definition("app/graphql/schema.graphql"), executables: {
+        other: ::GraphQL::Stitching::HttpExecutable.new(url: ::Rails.configuration.x.other_graphql_endpoint),
+        main: ::Schema,
+      })
+    end
     result = client.execute(query, variables:, context:, operation_name:)
     render json: result
   rescue StandardError => e
     raise e unless Rails.env.development?
     handle_error_in_development(e)
   end

   # (略)
 end

しかし、さすがにこれは好ましくありません。同じような定義が何度も重複してしまっています。そこでGraphQL::Stitching::Clientを継承したクラスを定義してそこにまとめることにしました。

まずは、マージされたschemaの配置場所を定数として定義します。

※ なぜ「GraphQL::Stitching::Clientを継承したクラス」のクラスメソッドとして定義しないのか? と思うかもしれませんが、後ほど説明するので一旦気にしないでください。

app/graphql/supergraph_path.rb

SupergraphPath = ::Pathname.new(__dir__).join("schema.graphql")

次に、GraphQL::Stitching::Clientを継承したクラスを定義してみます。

app/graphql/stitching_client.rb

class StitchingClient < ::GraphQL::Stitching::Client
  SUPERGRAPH_SCHEMA = ::GraphQL::Schema.from_definition(::SupergraphPath.to_s)
  private_constant :SUPERGRAPH_SCHEMA

  class << self
    def load_schema_from_locations? = ::Rails.env.development?

    def locations
      {
        other: {
          schema: ::GraphQL::Schema.from_definition(::Rails.root.join("app/models/other_system/schema.graphql").to_s),
        },
        main: {
          schema: ::Schema
        }
      }
    end

    def composer_options = {}

    def build_supergraph = ::GraphQL::Stitching::Composer.new(**composer_options).perform(locations)
  end

  def initialize(executables:)
    if self.class.load_schema_from_locations?
      locations = self.class.locations.to_h do |name, definition|
        [name, {**definition, executable: executables.fetch(name)}]
      end
      super(locations:, composer_options: self.class.composer_options)
    else
      supergraph = ::GraphQL::Stitching::Supergraph.from_definition(SUPERGRAPH_SCHEMA, executables:)
      super(supergraph:)
    end
  end
end

これで、controller側からは、以下のように使用できます。

app/controllers/graphql_controller.rb

 class GraphqlController < ApplicationController
   def execute
     # (略)

-    client = if ::Rails.env.development?
-      ::GraphQL::Stitching::Client.new(locations: {
-        other: {
-          schema: ::GraphQL::Schema.from_definition(::Rails.root.join("app/models/other_system/schema.graphql")),
-          executable: ::GraphQL::Stitching::HttpExecutable.new(url: ::Rails.configuration.x.other_graphql_endpoint),
-        },
-        main: {
-          schema: ::Schema,
-        },
-      })
-    else
-      ::GraphQL::Stitching::Client.from_definition(::GraphQL::Schema.from_definition("app/graphql/schema.graphql"), executables: {
-        other: ::GraphQL::Stitching::HttpExecutable.new(url: ::Rails.configuration.x.other_graphql_endpoint),
-        main: ::Schema,
-      })
-    end
+    client = ::StitchingClient.new(executables: {
+      other: ::GraphQL::Stitching::HttpExecutable.new(url: ::Rails.configuration.x.other_graphql_endpoint),
+      main: ::Schema
+    })
     result = client.execute(query, variables:, context:, operation_name:)
     render json: result
   rescue StandardError => e
     raise e unless Rails.env.development?
     handle_error_in_development(e)
   end

   # (略)
 end

rake taskも以下のようにかけます。

lib/tasks/dump_schema.rake

 task dump_schema: :environment do
-  client = GraphQL::Stitching::Client.new(locations: {
-    other: {
-      schema: GraphQL::Schema.from_definition(Rails.root.join("app/models/other_system/schema.graphql")),
-      executable: GraphQL::Stitching::HttpExecutable.new(url: Rails.configuration.x.other_graphql_endpoint),
-    },
-    main: {
-      schema: Schema,
-    },
-  })
-  Rails.root.join("app/models/other_system/schema.graphql").write(client.supergraph.to_definition)
+  Rails.root.join("app/models/other_system/schema.graphql").write(StitchingClient.build_supergraph.to_definition)
 end

ちなみにrake taskについてはgraphql(GraphQL Ruby)の方がGraphQL::RakeTaskというschemaをファイルに書き出すためのtask群を用意してくれています。ですので、統合されたスキーマを書き出す処理もこれに乗せるのが良いでしょう。

lib/tasks/graphql.rake

require "graphql/rake_task"
require_relative "../../app/graphql/supergraph_path"

GraphQL::RakeTask.new(
  directory: SupergraphPath.dirname,
  schema_name: "Schema",
  load_schema: ->(_) { StitchingClient.build_supergraph.schema }
)

ちなみにこのrake taskが読み込まれるタイミングではまだRailsは初期化されていません。そのため、クラスのオートロードは効きませんし、Railsの機能に依存した部分を定義に含んだファイルは読み込みエラーになってしまいます1。ここに掲載したStitchingClientはRailsの機能には依存していないので、単にrequire_relativeで読み込むだけで問題ありません。しかし、このrake task定義のためだけにこれらの制約が課されるのは好ましくありません。そのため、このtaskの定義のタイミングで必要なSupergraphPathだけを個別のファイルに切り出し、それだけをrequire_relativeで読み込むようにしました。

部分的なエラーの取り扱い

GraphQLは、仕様上「部分的なエラー」というものをサポートしています。レスポンスでerrorsにエラーを返しつつ、エラーになっていない部分をdataに返すことができます。そのため、GraphQL Stitchingも「stitching先でエラーが発生した場合やそもそもstitching先に接続できなかった場合、このサーバーで解決できた部分と発生したエラーをマージして反す」という挙動になっています。

しかし、移植元システムは「部分的なエラー」という状態をサポートしていません。移植元システムは弊社でGraphQLを採用し始めた初期に開発されたサーバーであり、当時は社内のGraphQLに対する知見が不足していました。部分的なエラーを標準的なerrorsフィールドだけで扱おうとすると難易度が高い点は当時から各所で指摘されており、解決策もデファクトと言えるものはなく、複数の方法が提案されていました。そのため、当時は部分的なエラーのサポートは見送り、すべて成功/すべてエラーのどちらかにする方針で実装を進めました。

ちなみに後発のサーバーでは、その後の知見の蓄積に伴い部分的なエラーをサポートするようになっています。この辺りについては以前にこのブログで紹介しているの興味があればぜひご一読ください。

product.st.inc

話を戻します。

弊社全体としては部分的なエラーをサポート方針になってきていますが、これを今回の移植に適用すると明確にAPIの挙動が変わってしまいます。ただでさえ一定リスクのある移行作業を行っている中、APIの挙動をこのタイミングで合わせて変更するのはさすがに避けるべきです。そのため、以下のような場合にはGraphQL全体をエラーにする必要があります。

  • stitching先でエラーが発生した場合。
  • そもそもstitching先に接続できなかった場合。

「そもそもstitching先に接続できなかった場合」はいわゆる通信エラーなのでhttp 500など2の、HTTPレイヤーのエラーとしてしまえば問題ありません。

一方、stitching先でエラーが発生した場合は、そのエラーをレスポンスとして返す必要があります。デフォルトでは、このとき移植先サーバー側で解決できた結果も一緒に返ってしまいますが、これをstitching先のエラーだけを返すようにする必要があります。

これを実現するために、GraphQL::Stitching::HttpExecutableを継承したクラスを定義してそこで挙動をカスタマイズしていきます。

まずが、課題となっていた2パターンでエラーを発生させるようにしました。

app/graphql/custom_stitching_http_executable.rb

class CustomStitchingHttpExecutable < ::GraphQL::Stitching::HttpExecutable
  class HTTPError < ::StandardError
    attr_reader :response

    def initialize(response)
      super("code: #{response.code} body: #{response.body}")
      @response = response
    end
  end

  class RemoteGraphQLError < ::StandardError
    attr_reader :remote_errors

    def initialize(remote_errors)
      super("Remote GraphQL returns errors: #{remote_errors.to_json}")
      @remote_errors = remote_errors
    end
  end

  def call(...)
    super.tap do |response_json|
      # GraphQL Stitchingは通信先がエラーを返した場合、bongoで成功した部分と失敗した部分をマージして、
      # 「部分的に成功、部分的に失敗」という形のレスポンスを返す
      # しかし、majaではそのような部分的なエラーは存在しなかったので、
      # それに合わせて一部でもエラーが起きたら全体をエラーとして落とす
      raise RemoteGraphQLError, response_json["errors"] if response_json["errors"].present?
    end
  end

  def send(...)
    super.tap do |response|
      # GraphQL StitchingはOK以外のresponsenの場合でもGraphQLレスポンスとして解釈してマージしようとする
      # しかし、OK以外のstatus code以外は想定外なので、例外を出して処理を止める
      raise HTTPError, response unless response.is_a?(::Net::HTTPSuccess)
    end
  end
end

しかし、これだけですとcontroller側GraphQL::Stitching::Client#executeでは例外が発生しません。GraphQL Stitchingでは、デフォルトではエラーが発生しても例外を発生させないようになっているためです。

この挙動に対する対応は、今回は部分的なエラーをサポートしないこともあり特に難しいことはありません。エラー発生時に例外を発生させるように設定するだけです。

app/graphql/stitching_client.rb

 class StitchingClient < ::GraphQL::Stitching::Client
   # (略)
 
   def initialize(executables:)
     if self.class.load_schema_from_locations?
       locations = self.class.locations.to_h do |name, definition|
         [name, {**definition, executable: executables.fetch(name)}]
       end
       super(locations:, composer_options: self.class.composer_options)
     else
       supergraph = ::GraphQL::Stitching::Supergraph.from_definition(SUPERGRAPH_SCHEMA, executables:)
       super(supergraph:)
     end
+
+    on_error { |_request, error| raise error }
   end
end

最後に、controller側に「stitching先でエラーが発生した場合」の処理を追加します。今回の変更では、この場合に「CustomStitchingHttpExecutable::RemoteGraphQLError」を発生させるようにしているので、このエラーをハンドリングします。

app/controllers/graphql_controller.rb

 class GraphqlController < ApplicationController
   def execute
     # (略)

     result = client.execute(query, variables:, context:, operation_name:)
     render json: result
+  rescue ::CustomStitchingHttpExecutable::RemoteGraphQLError => e
+    render json: {errors: e.remote_errors}
   rescue StandardError => e
     # (略)
   end

   # (略)
 end

ログ出力

internal server error相当のエラー

実際に本番環境でシステムを運用していく上で、ログは非常に重要です。GraphQL Rubyでは、resolverでエラーが発生した場合はそのままcontrollerからその例外が投げられ500エラーになります。そのため、既存の仕組みによりログ出力やエラートラッカーへの通知などが行われます。

一方GraphQL Stitchingでは、上のセクションでも軽く触れましたがデフォルトではエラーが発生してもGraphQL::Stitching::Client#executeは例外を出しません。例外をハンドリングするには、#on_errorにエラーハンドラーを設定する必要があります。

今回はすでにon_errorを追加しているので特に追加の対応は不要です。

これで GraphQL Ruby と同様にcontrollerから例外が投げられるようになっており、既存の仕組みでエラーが処理されるようになります。

bad request相当のエラー

bad request相当のエラーは、基本的にクライアント側のリクエストに問題があり、サーバー側には非が無いもの(= 準正常系)になります。しかしながら、ユーザー視点で見ればこれもエラーであり、原因や解決策についてお問い合わせをいただく場合があります。また、実装の不具合により本来有効なリクエストを誤って不正と判断してしまっていることも往々にしてあります。このような場合に、エラーが発生したという事実がログに残っていないと問題の調査・検知が困難になってしまいます。

今回は部分的なエラーをサポートしないこともあり、シンプルに「レスポンスのerrorsに値が存在するとき」に追加のログを出すようにしました。

app/controllers/graphql_controller.rb

 class GraphqlController < ApplicationController
   def execute
     # (略)

     result = client.execute(query, variables:, context:, operation_name:)
+
+    if json["errors"].present?
+      logger.warn "client error occurred on #{request.path}: #{json.fetch("errors")}"
+    end
+
     render json: result
   rescue ::CustomStitchingHttpExecutable::RemoteGraphQLError => e
+    logger.warn "client error occurred on #{request.path}: #{e.remote_errors}"
     render json: {errors: e.remote_errors}
   rescue StandardError => e
     # (略)
   end

   # (略)
 end

GraphQL stitching により生成されるquery

GraphQL Stitching は、要求されたqueryにを基にstitching先に投げるべきqueryを生成します。このとき、要求されたfieldがどのサーバーで解決されるかは下記のように複雑に入んでいる場合があります。この場合、stitching先に投げられるqueryがどのようになるかは自明ではありません。もし問題が発生した場合の調査を考えると、どのようなqueryがstitching先に投げられたかもログに出ていると便利です。

query($itemId: UUID!) {
  item(itemId: $itemId) { # ここはstitching先で解決される
    label
    compexValue # ここはこのサーバーで解決される
    price
    employee { # ここはこのサーバーで解決される
      employeeId
      displayName
      shop { # ここはstitching先で解決される
        shopId
        displayName
      }
    }
  }
}

そこで、stitching先にリクエストを投げる処理に以下のようにログを追加しました。

app/graphql/custom_stitching_http_executable.rb

 class CustomStitchingHttpExecutable < ::GraphQL::Stitching::HttpExecutable
   # (略)


-  def send(...)
+  def send(request, document, variables)
+    ::Rails.logger.info("GraphQL Stitching request. url: #{@url}, query: #{document}")
+
    # (略)
  end
end

ちなみにリクエストパラメータは variables に分離されるので、query文字列(上記のコードではdocument引数に入っています)にセンシティブな情報が含まれる心配はありません。もしより情報を充実させるために variablesHTTP Header の情報を出力させる場合は、ActiveSupport::ParameterFilterなどを用いてセンシティブな情報がログに出力されないようにしましょう。

HTTP Headerをproxyする

HTTPのリクエストにおいては、HTTP Headerも重要な情報です。特に認証などの部分に大きく関わってきています。今回のようにあるサーバーの裏に別のサーバーがある構成の場合、認証は一番クライアントに近い部分でのみ行い、内部の通信は内部用の認証を用いたり、internalな通信を用いて認証を不要にするなどの方法を取ることが多いです。しかし、今回裏側にいるサーバー(= 移植元のサーバー)は元々クライアントから直接リクエストを受け付けていたサーバーなので、すでに認証の仕組みが入っています。

今回の目的は移植期間中に双方のGraphQLをシームレスにマージして提供することであり、この構成は一時的なものです。また、双方のGraphQL APIは共に同じ認証の形式を採用しています。そのため、移植元サーバーとのやり取りに専用の認証機構などは導入せず、シンプルに移植先サーバーへのリクエスト時の認証情報を、そのまま移植元サーバーへproxyすることにしました。

双方のサーバーの認証は、HTTPのAuthorization headerによるBearer token認証です。現状の挙動を可能な限り踏襲することを考えると、そのほかのHTTP Headerも含めて丸ごとproxyするのが良いかなと考えました。

早速実装に取り掛かりたいのですが、皆さんはRuby on Railsでrequest headerをすべて取得するにはどうすれば良いかご存じでしょうか?request.headersを思い浮かべる方が多いのかなと思うのですが、実はこれでは不適切です。なぜかというと、このメソッドが返すのはRack Environmentというもので、request headerの一覧ではないからです。このHashは、request header以外の情報も多く含んでいる上に、request headerのキー名もリクエスト時の値とは異なるものに変換されてしまっています。そのため、以下のようなロジックでhttp headerを抜き出して変換する必要があります。

requested_headers = request
  .headers
  .filter { |k, _| k.starts_with?("HTTP_") }
  .to_h
  .transform_keys { it.sub("HTTP_", "").titleize.tr(" ", "-") }

また、HTTP headerの中にはHostというリクエスト先のhost名を設定するHeaderがあります。これはさすがに移植先へのリクエスト時の値では不適切なので、引き継がないようにしました。

はまりどころ

これでstitching先と通信できるかと思ったのですが、なぜかエラーが発生してしまいました。確認してみると移植元サーバーからのレスポンスがbinary形式となっていました。1つ1つHTTP headerを消したり戻したりしながらデバッグを進めたところ、これは Accept-Encoding ヘッダーが原因で発生していることが判明しました。Accept-Encoding: gzip, deflate, br, zstdのような値が設定されていたために、gzipで圧縮されたデータ = binaryデータが返ってきてしまっていました。

冷静に考え直すと、Accept系のヘッダーは「clientと、clientと直接通信するサーバー(移植先サーバー)間の通信に関する指定」であって、移植先サーバーとstitching先のサーバーとの通信には必ずしもそのまま適用すべきものではありません。そのため、この問題に関しては「Acceptで始まるキーのHTTP headerを除外する」ことで対処しました。

他にも除外すべきHTTP headerはあるのかもしれないですが、この設定で問題なく動くことが確認できたため、今回の移植ではこの設定でproxyさせることにしました。

補足: Net::HTTPにおけるgzip圧縮の扱いについて

ここまでの内容を見ると、rubyのNet::HTTPがgzip圧縮をサポートしていないかのように見えてしまいますが、実はNet::HTTPは普通にgzip圧縮をサポートしています。では何が問題だったのかというと、「Accept-Encodingを明示的に指定してしまっていた」ことです。

Net::HTTPは、デフォルトでAccept-Encoding headerが設定され、@decode_contentというフラグにtrueが設定されるようになっています。

https://github.com/ruby/net-http/blob/d8fd39c589279b1aaec85a7c8de9b3e199c72efe/lib/net/http/generic_request.rb#44-L47

しかし、Accept-Encodingが外部から指定された場合は、@decode_contenttrueにならないようになっています。

今回の実装は「Accept-Encodingが外部から指定された場合」に当てはまってしまっており、Net::HTTPのgzipのdecode処理が無効になってしまっていたのでした。

keep-aliveを利用してHTTP connectionを使い回す

今回の用途では、特に移植が進んでいない時期には非常に高い頻度でstitchingリクエストが実行されます。その度にHTTP connectionを確立する処理を行うのは非効率です。これを改善するための仕組みとして、HTTPにはkeep-aliveという「一度はったconnectionを複数のリクエストで使い回す仕組み」があります。rubyでは、net-http-persistentというgemでkeep-aliveを簡単に実現可能なので、これを導入します。

変更を入れるファイルはCustomStitchingHttpExecutableです。

app/graphql/custom_stitching_http_executable.rb

 class CustomStitchingHttpExecutable < ::GraphQL::Stitching::HttpExecutable
   # (略)

+  CONNECTION = ::Net::HTTP::Persistent.new
+  private_constant :CONNECTION
+
+  class << self
+    def shutdown = CONNECTION.shutdown
+  end

   # (略)

   def send(request, document, variables)
     # (略)

+    parsed_url = ::URI.parse(@url)
+
+    net_http_request = ::Net::HTTP::Post.new(parsed_url.path, @headers).tap do |req|
+      req.body = {query: document, variables:}.to_json
+    end
+
-    super.tap do |response|
+    CONNECTION.request(parsed_url, net_http_request).tap do |response|
       # GraphQL StitchingはOK以外のresponsenの場合でもGraphQLレスポンスとして解釈してマージしようとする
       # しかし、OK以外のstatus code以外は想定外なので、例外を出して処理を止める
       raise HTTPError, response unless response.is_a?(::Net::HTTPSuccess)
     end
   end
 end

ここで.shutdownというメソッドを定義していますが、これがどこから呼ばれるのかと疑問に思った方もいるでしょう。このメソッドは、Rackサーバーであるpitchforkbefore_forkで呼ばれます。

config/pitchform.rb

 before_fork do |server|
   defined?(ActiveRecord::Base) and ActiveRecord::Base.connection.disconnect!
+ 
+  CustomStitchingHttpExecutable::HttpExecutable.shutdown
 end

pitchforkは「reforking」特殊な戦略を採用しており、pitchforkで動くアプリケーションは「fork安全」である必要があります3。「fork」という処理においては、fork時点のメモリ状態がそのままfork先にコピーされます4。そのため、fork時点で各種コネクションやファイルディスクリプタなどが開かれていた場合、fork元とfork先が同じものをさしている状態となってしまいます。これらのリソースは複数のプロセスに同じものが割り当てられることを想定していないので、この状態は深刻な不具合につながります。通常のfork serverであれば、これらのリソースが開かれる前にforkが行われるので問題ありません。しかしpitchforkでは、サーバーの起動後、一定数のリクエストを受け付けた後にforkを行う「reforking」が発生します。そのため、fork前に開かれているコネクションやファイルディスクリプタをcloseし、fork安全な状態にする必要があるのです。

この辺りの詳細は、pitchforkの作者自身が解説されています。この辺りの実装を行う少し前に、ちょうどこの解説を読んでいたため、before_forkでのconnectionのclose(.shutdown)が必要なことに思い至ることができました。

この解説は非常に面白い内容となっているので、ぜひ読んでみることをお勧めします。

byroot.github.io

日本語訳

techracho.bpsinc.jp

接続先の切り替え

ここまで紹介させていただいたものを中心に、その他細かい調整をGraphQL stitchingの設定を経て、移植先のサーバーは移植元と同等のサーバーとして振る舞える状態になりました。最後に、移植元サーバーを参照していた各プロダクト群のリクエスト先を移植先サーバーに切り替えていきます。

ここについては特段目あたらしい部分はなく、シンプルにリクエスト数や影響数の少ないところから順次リクエスト先のURLを切り替えていきました。この段階では特段大きなトラブルもなく無事すべてのリクエストの切り替えが完了しました。

まとめ

3記事にわたってサービス統合の詳細についてご紹介させていただきました。統合の方針を考えていた時点でも「想定できていない実装レベルのハマりどころはあるだろう」とは考えていましたが、実際その通りで、現実に進めていくには中々気合がいる作業だなと実感しました。とはいえ「なんとか実現できるだろう」とは考えていましたし、実際形にできたので私としては嬉しい限りです。また、今回の実装を経て得られた知見はもちろん、作業の中で出てくる課題を1つ1つ解決して形にしていくのはエンジニアとしての地力が鍛えられるなと感じました。

今回の取り組みを見て、「面白そう」とか「自分もやってみたい」と思われた方はいらっしゃいますでしょうか? 弊社にはこのような仕様から模索していくような仕事がまだまだたくさん転がっています。もし興味を持っていただけた方がいましたら、ぜひ採用サイトも覗いてみていただけると嬉しいです。

jobs.st.inc


  1. load_schema に渡しているlambdaはtask実行時に評価されるので、Railsに依存した処理を書いて問題ありません。
  2. 502 Bad Gateway 等のエラーコードが適切かもしれませんが、stitchingをしているのは移行期のみであり、複数システム構成であることをあまり意識したくないので今回はシンプルに 500 Internal Server Error としました。
  3. 現時点では移植先サーバーのreforkingはまだ有効化されていないので、この設定は無しでも動きます。ただし、将来的にreforkingを有効化する際のハマりどころになってしまうので、現時点でこの設定を有効にしています。
  4. fork時のメモリのコピーはCopy on Write戦略に基づき行われるので、実際にはこのタイミングでコピーが行われるわけではありません。