前回の記事では、「HRMOSタレントマネジメント」内にあった複数の認証基盤を統合させるための大まかな流れを紹介しました。API Gatewayパターンの実装には複数の選択肢がありますが、我々はSpring WebFluxベースのフレームワークであるSpring Cloud Gatewayを使用しました。本記事では、Spring Cloud Gatewayを選択した背景、実際にSpring Cloud Gatewayを使用して得られた知見を紹介します。

何故Spring Cloud Gatewayを選択したのか

API Gatewayパターンの実装では、Amazon API Gateway等のマネージドサービス、Kong等のミドルウェアの使用、独自に実装するといった選択肢が挙げられます。その中で何故Spring Cloud Gatewayを使用して独自に実装したのか説明します。

「HRMOS」シリーズは製品が日々増えており、これらの変化にAPI Gatewayは順応が必要です。このような環境でマネージドサービスの使用は料金面での見通しが立てにくいため採用を見送りました。また、ミドルウェアの使用についても、将来増えるであろう要件に耐えうるか不明であったため採用を見送りました。

独自に実装する場合、フレームワークを使用するのか、使用するのであれば何を使用するのか検討が必要です。「HRMOS」ではKotlinを使用したSpring Bootで開発したサービスを運用しており、Springエコシステムに存在するAPI GatewayのフレームワークSpring Cloud Gatewayは既存の知識や知見を流用できると考え、使用候補に挙がりました。 Spring Cloud Gatewayで使用しているSpring WebFluxは、Netty等のノンブロッキングサーバー上で実行されているため、性能面での期待もできます。また、Spring Cloud Gatewayは機能の拡張も容易に行える機構になっているため、要件の本質的な実装に時間をかけることができると判断して採用しました。

Spring Cloud Gatewayを使用した実装例

ここでは、Spring Cloud Gatewayを使用した具体的な実装例について紹介します。

Spring Cloud Gatewayでは、多くの処理をapplication.yamlに定義して設定できます。下記はAPI GatewayへのリクエストのHostヘッダーがservice.exampleであった場合にhttp://service-backend.exampleへリクエストをプロキシする設定例です。

application.yamlの設定例:

1
2
3
4
5
6
7
8
spring:
  cloud:
    gateway:
      routes:
      - id: host_route
        uri: http://service-backend.example
        predicates:
        - Host=service.example

簡単な設定でAPI Gatewayへのリクエストをプロキシできることが分かると思います。また、アプリケーション内のコードで同様の設定が行えます。

Kotlinでの実装例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Configuration
class RouteConfiguration {
    @Bean
    fun routeLocator(builder: RouteLocatorBuilder): RouteLocator {
        val routes = builder.routes()
        routes.route {
            it.host("service.example").uri("http://service-backend.example")
        }
        return routes.build()
    }
}

複数Hostに対するプロキシも下記のように簡単に行なえます。

application.yamlの設定例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
spring:
  cloud:
    gateway:
      routes:
      - id: service_1
        uri: http://service1-backend.example
        predicates:
        - Host=service1.example
      - id: service_2
        uri: http://service2-backend.example
        predicates:
        - Host=service2.example

Spring Cloud GatewayはSpring WebfluxのWebFilterと、図1のFilterに該当するSpring Cloud Gateway内のGatewayFilter, GlobalFilterを使用してリクエストに対して独自の処理が行えます。

Spring Cloud Gatewayの仕組み
図1. Spring Cloud Gatewayの仕組み
画像は公式ドキュメントより引用1

GatewayFilterを用いて独自の処理を行いたい場合、Spring Cloud Gatewayで用意されているAbstractGatewayFilterFactoryで実現できます。
プロキシ時に、Cookieのセッション情報からIDトークンを生成しヘッダーに付与する例を下記に示します。

Kotlinでの実装例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@Component
class TokenTranslationFitlerFactory : AbstractGatewayFilterFactory<Any>() {
    override fun apply(config: Any) =
        GatewayFilter { exchange: ServerWebExchange, chain: GatewayFilterChain ->
            val sessionId = exchange.request.cookies.getFirst("LOGIN_SESSION")?.value ?: throw Exception()
            val userId = getUserId(sessionId)
            val token = genAuthToken(sessionId)

            val request = exchange.request.mutate().header("X-AUTH-TOKEN", token).build()
            chain.filter(exchange.mutate().request(request).build())
        }

    private fun getUserId(sessionId: String): String {
        return "TODO" // セッションストアに問い合わせて、有効なセッションであればユーザIDを返す
    }

    private fun genAuthToken(userId: String): String {
        return "YourToken"
    }
}

作成したTokenTranslationFitlerFactoryは次のようにプロキシの設定と紐づけできます。

application.yamlの設定例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
spring:
  cloud:
    gateway:
      routes:
      - id: host_route
        uri: http://service-backend.example
        predicates:
        - Host=service.example
        filters:
        - TokenTranslationFitlerFactory       

Kotlinでの実装例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Configuration
class RouteConfiguration {
	@Bean
	fun routeLocator(builder: RouteLocatorBuilder, tokenTranslationFitlerFactory: TokenTranslationFitlerFactory): RouteLocator {
		val routes = builder.routes()
		routes.route {
			it.host("service.example")
				.filters { f ->
					f.filter(tokenTranslationFitlerFactory.apply{})
				}
				.uri("http://service-backend.example")
		}
		return routes.build()
	}
}

このように、Spring Cloud Gatwayではプロキシや独自の処理を容易に実装できるため、採用を決定しました。

実際に使用して得られたSpring Cloud Gatewayの知見

ここからは、実際に使用して得られたSpring Cloud Gatewayの知見を紹介します。

1. Spring Cloud GatewayのCORS設定がAPI GatewayのすべてのHostに対して同一のパスにしか行えない

Spring Cloud GetewayではCORS処理をするための設定が用意されています。下記にapplication.yamlでの設定例を示します。

application.yamlの設定例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
spring:
  cloud:
    gateway:
      globalcors:
        cors-configurations:
          '[/service_1/**]':
            allowedOrigins: "https://service1.example"
            allowedMethods:
            - GET
          '[/service_2/**]':
            allowedOrigins: "https://service2.example"
            allowedMethods:
            - GET            

設定例に[/service_1/**]とあるように、API Gatewayのパス毎にCORS処理の設定が可能です。しかし我々は、リクエストのHostを元にしてAPI Gatewayから各サービスのプロキシを行なっていたため、Host毎にCORS処理の設定を行いたかったのですが、パス毎の設定しか行えませんでした。

この設定を行った場合、Spring Cloud Gatewayの内部ではベースになっているSpring Webluxのクラスに対して設定値を渡し、CORS処理を行っています。このSpring WebfluxのCORS処理は、外部からの差し替えが難しい構造になっており、Host毎にCORS処理の設定をすることは容易ではありませんでした。

具体的には、CORS処理の設定を行うとSpring Cloud Gatewayの内部でSpring Webluxのクラス(AbstractHandlerMapping)に設定値を渡し、CORS処理を行うクラスCorsProcessorAbstractHandlerMapping内でCORS処理を行います。そのため独自のCORS処理をしたい場合、AbstractHandlerMapping内のCorsProcessorを独自のCORS処理を実装したクラスへの差し替えが必要です。しかし、CorsProcessorAbstractHandlerMapping内で初期化されており、SpringのBeanといった機構で差し替えることができません。そのため我々は、AbstractHandlerMappingを管理しているDispatcherHandlerの処理をoverrideすることにより差し替えました。

Kotlinでの実装例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class CustomCorsProcessor : DefaultCorsProcessor() {
    override fun process(config: CorsConfiguration?, exchange: ServerWebExchange): Boolean {
      // 独自のCORS処理を行う
    }
}

@Configuration
class CorsCustomizeConfig {
    @Primary
    @Bean
    fun dispatchHandler(): DispatcherHandler {
        return object : DispatcherHandler() {
            override fun initStrategies(context: ApplicationContext) {
                super.initStrategies(context)
                val customCorsProcessor = CustomCorsProcessor()
                handlerMappings
                    ?.filterIsInstance<AbstractHandlerMapping>()
                    ?.forEach {
                        // CorsProcessorの差し替えを行う
                        it.corsProcessor = customCorsProcessor
                    }
            }
        }
    }
}

また、この問題に関連してSpring Cloud GatewayがCORSのプリフライトリクエストで使用するOPTIONSリクエストをプロキシできない問題Issue(#830), Issue(#2472)があります。Spring Cloud GatewayのCORS処理がSpring Webfluxに依存しているため、改善が難しいこともあり、現状(2022/12時点)で改善は進められていないので注意が必要です。

2. 不正なURLクエリのリクエストにプロキシを行わずにHTTP 400 Bad Requestを返却する

不正なURLクエリのリクエストに対してAPI Gatewayがプロキシを行わずにHTTP 400 Bad Requestを返却する事象が発生しました。また、INFO以上のログを出力するようにしている場合、ログには何も出力せずにHTTP 400 Bad Requestを返却していたため、プロキシを行わずにレスポンスしている事に気づくのが困難でした。

具体的な再現条件としては、?param1=true?param2=falseのような?が続いた不正なクエリを与えたhttp://localhost:8080/example?param1=true?param2=falseにリクエストがあると発生し、ログレベルをDEBUGにすることで下記の様なログが出力されます。

1
2
3
2022-08-05 10:00:00.000 DEBUG 98815 --- [ctor-http-nio-2] o.s.w.s.adapter.HttpWebHandlerAdapter    : Failed to apply forwarded headers to HTTP GET "/example?param1=true?param2=false"

java.lang.IllegalArgumentException: Invalid character '=' for QUERY_PARAM in "true?param2=false"

この処理はSpring WebのHttpWebHandlerAdapter内で行われており、この振る舞いを無効にするためには多くの処理の差し替えが必要です。そのため我々のチームでは、org.springframework.web.server.adapter.HttpWebHandlerAdapterのログレベルをDEBUGに変更することで発生を検知できるようにしました。

3. Connection has been closedの発生

reactor.netty.channel.AbortedException: Connection has been closedが、API Gatewayの開発中に発生しました。

「HRMOSタレントマネジメント」ではAPI Gatewayからプロキシ先の各サービスへAWSのALB(Application Load Balancer)を介して接続しており、ALBには一定時間(デフォルトは60秒)データが送受信されなかった場合にコネクションが切断されるアイドルタイムアウトが設定されています。一方で、Spring Cloud Gatewayは一度接続したコネクションを使い回すように設計されており、デフォルトではアイドルタイムアウトに近い値は設定されていません。

そのためALBは、一定時間経過後にAPI Gatewayとの接続をアイドルタイムアウトにより切断していましたが、API Gateway側ではそのコネクションを再利用しようとしてConnection has been closedが発生していました。

Spring Cloud Gatewayにも 一定時間データが送受信されなかった場合に接続を切断する設定spring.cloud.gateway.httpclient.pool.max-idle-timeが存在するため、この値をALBのアイドルタイムアウトより小さい値を設定することでこの問題を解決しました。

application.yamlの設定例:

1
2
3
4
5
6
spring:
  cloud:
    gateway:
      httpclient:
        pool:
          max-idle-time: 55s

他のタイムアウトの値も設定できるようになっているため、必要に応じて設定をしておくべきです。

4. 環境によって Filter のOrder が変化する

API GatewayがローカルとAWS環境では正常に動作するものの、GitHub Actions上でビルドしたローカル開発向けのDocker Imageから起動すると期待通りの振る舞いを行わない問題が発生しました。

Spring Cloud Gatewayを使用した実装例で紹介したように、Spring Cloud GatewayではFilterを用いて独自の処理を行うことができ、Filterの適用順はOrderを設定して変更できます。下記にGlobalFilterの適用順を設定する例を示します。

Kotlinでの実装例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Component
class SomeGlobalGatewayFilter : GlobalFilter, Ordered {
    override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
        // 独自の処理を行う
        return chain.filter(exchange)
    }

    // Filterの適用順であるOrderを設定する
    override fun getOrder(): Int {
        return  Ordered.LOWEST_PRECEDENCE
    }
}

Orderを設定していない場合や同じOrderの値が使用されている場合、Filterはjarファイル内のクラスの順に適用します。このクラスの順序は、jarをビルドする環境毎に変化する可能性があるため注意が必要です。
我々はGithub Actions上でAWS環境向けのDockerイメージとローカル開発向けのDockerイメージをビルドしていましたが、ローカル開発向けのDockerイメージはamd/arm向けのマルチプラットフォームイメージを作成しており、そのイメージのみjarファイル内のクラスの順序が変化してしまいました。

従って、順序が大切な処理は明示的にOrderを指定する必要があり、Orderが重複しないように注意が必要です。

まとめ

「HRMOSタレントマネジメント」では、Spring Cloud Gatewayを用いてAPI Getewayを構築しました。Spring Cloud Gatewayはプロキシや独自の処理を容易に実装できるようになっているため、要件の本質的な実装に時間をかけることができます。その反面、「実際に使用して得られたSpring Cloud Gatewayの知見」で紹介した1, 2のようなSpring Cloud GatewayがSpring Webfluxベースで作られているために発生する問題や、3, 4のようなSpring, Spring Cloud Gateway固有の問題にも注意が必要です。この記事が今後Spring Cloud Gatewayを用いてAPI Gatewayを構築される方の参考になれば幸いです。

石塚 崇寛
石塚 崇寛

HRMOS 事業部のソフトウェアエンジニア。2021年新卒入社。音楽フェスとライブに行くことが好きなことから、社内ではモッシュと呼ばれている。好きな音楽のジャンルはラウドロック。