5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

SpringCloudGatewayでサービスを守る

Last updated at Posted at 2020-02-23

この記事の概要

  • サービス運用をしているとこんなことが起きる
    • 予期せぬ負荷でレスポンスが悪化する、システムが正常に稼働しなくなる
    • 異常のあるサーバーにアクセスし続けて事態をより悪化させる
  • このようなことからサービスを守るためにrate limitやcircuit breakerを導入したい
  • spring-cloud-gatewayでできるみたいだから試してみる!
    • (僕の開発現場ではspringbootを使用しているので、いろんなことがspringでできると嬉しいのです)
  • 開発環境はMac

spring-cloud-gatewayとは?

  • ドキュメント:https://spring.io/projects/spring-cloud-gateway
  • gatewayの名の通り、routingをしてくれます
  • それだけではなく、routeごとに以下のようなことができます
    • ヘッダーやパラメータを付与する
    • 認証をかける
    • 条件によってフィルタリングする(今回のメイン)

とりあえず簡単なサンプルを

build.gradle
plugins {
    id 'org.springframework.boot' version '2.2.4.RELEASE'
    id 'io.spring.dependency-management' version '1.0.9.RELEASE'
    id 'java'
    id "com.github.ben-manes.versions" version "0.28.0" // 最新バージョンを確認するためにいつもいれてます
}

group = 'rhirabay'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
targetCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

ext {
    set('springCloudVersion', "Hoxton.SR1")
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webflux' // 内部API用
    implementation 'org.springframework.cloud:spring-cloud-starter-gateway'

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
    }
}
application.yml
spring:
  cloud:
    gateway:
      routes:
        - id: sample
          uri: "http://localhost:8080"
          predicates:
            - Path=/sample/**
          filters:
            - PrefixPath=/api
  • id
    • 任意の識別子を記述します
    • 一意であればなんでも良いはずです
  • uri
    • routing先を記述します
    • pathは記述しても使われません
    • pathはすべてgatewayへのアクセス時のpathが使われます
  • predicates
    • routing条件を記述します
    • この例だとPathが/sampleで始まるものをroutingします
    • headerやcookie、query、時間指定とかもできます
  • filters
    • リクエストに付与する情報を付与したり、リクエストに制限をかけることができます
    • この例だとpathの先頭に/apiを付与します
    • 今回のrate limitやcircuit breakerの制御はここで行います
SampleController.java
@Slf4j
@RestController
@RequestMapping("/api")
public class SampleController {

    @RequestMapping("/**")
    public Mono<RequestSummary> api(ServerHttpRequest request) {
        var summary = RequestSummary.of(
                request.getURI().toString(),
                request.getMethodValue()
        );
        log.info("request summary. {}", summary);
        return Mono.just(summary);
    }

    @Value(staticConstructor = "of")
    public static class RequestSummary {
        private String uri;
        private String method;
    }
}
  • 裏側のAPI
  • どんなリクエストがきたのかログに出すして返すだけ

実行!

curl http://localhost:8080/sample
> {"uri":"http://localhost:8080/api/sample","method":"GET"}

いざ本題

Rate Limit ~予期せぬ負荷からサービスを守る~

  • ドキュメント
  • 例えば「秒間10リクエストを超えるアクセスがきた場合に後ろのAPIを叩かずにgatewayで返す」みたいなことができる
  • アクセス数の管理はここではredisで行う

実装

build.gradle
...
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive'

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}
...
  • org.springframework.boot:spring-boot-starter-data-redis-reactiveを追加
application.yml
spring:
  redis:
    host: localhost
    port: 6379
  cloud:
    gateway:
      routes:
        - id: ratelimit
          uri: "http://localhost:8080"
          predicates:
            - Path=/ratelimit/**
          filters:
            - PrefixPath=/api
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10
                redis-rate-limiter.burstCapacity: 10
  • redisに接続するので設定値を追加
    • localにredisを起動している場合は、defautlのままでイケるので記載不要ですが、念の為
  • filtersにRequestRateLimiterを追加
    • redis-rate-limiter.replenishRate:これを超えると429(Too Many Requests)を返す
    • redis-rate-limiter.burstCapacity:一時的にここまで叩いても良いという設定値
@Configuration
public class RateLimitAutoConfiguration {
    @Bean
    public KeyResolver simpleKeyResolver() {
        return exchange -> Mono.just("sample");
    }
}
  • KeyResolverによりrate limitをかける単位を決めることができる
  • もしユーザーごとにかけたければユーザー識別子を、叩き元のホストごとにかけたければホスト情報をMono.justの引数に設定する
  • ここでは固定の文字列を入れているのでroute全体で10rpsのrate limitがかかる

動作確認

locustで負荷をかけてみる
※locustについては記事の最後の補足をご覧ください

スクリーンショット 2020-02-23 9.35.47.png スクリーンショット 2020-02-23 9.36.38.png

カスタム

pathごとにrate limitをかける

KeyResolverでrate limitをかける単位を変更可能

@Configuration
public class RateLimitAutoConfiguration {
    @Bean
    KeyResolver userKeyResolver() {
        return exchange -> Mono.just(exchange.getRequest().getPath().toString());
    }
}
スクリーンショット 2020-02-23 0.22.14.png

Pathごとに10rpsだけ正常に受け付けていて、超えた分はエラーになっている!

HTTP StatusやResponse Body

https://github.com/spring-cloud/spring-cloud-gateway/blob/master/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactory.java
付け入る隙がなさそう。。。

Circuit Breaker ~異常のあるサーバーを切り離す~

実装

検証用のバックエンドAPI

...
    @RequestMapping("/randomSleep/**")
    public Mono<Integer> randomSleep() {
        var sleepMillis = 1;
        var id = i.getAndAdd(1) % 2;
        if (id == 0) {
            sleepMillis = 300;
        }

        log.info("id: {}, cnt: {}, sleep: {}", id, i.get(), sleepMillis);

        return Mono.just(sleepMillis)
                .delayElement(Duration.ofMillis(sleepMillis));
    }

    @RequestMapping("/fallback")
    @ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
    public void fallback() {
        log.warn("fallback");
        return;
    }
...
  • /randomSleep:1リクエストごとにsleep時間が「1ms→300ms→1ms→300ms→...」と切り替わるAPI
  • /fallback:フォールバック用のAPI。ログを出力するだけ

依存関係を追加

build.gradle
...
dependencies {
    ...
    implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-reactor-resilience4j'
    ...
}
...

gatewayの実装

application.yml
spring:
  cloud:
    gateway:
      routes:
        - id: circuitbreaker
          uri: "http://localhost:8080"
          predicates:
            - Path=/circuitbreaker/**
          filters:
            - PrefixPath=/api/randomSleep
            - name: CircuitBreaker
              args:
                name: myCircuitBreaker
                fallbackUri: forward:/api/fallback
  • fallbackUri:フォールバック先のURLを指定。ここでは/api/fallbackにフォワード
@Configuration
public class CircuitBreakerAutoConfiguration {

    @Bean
    public Customizer<ReactiveResilience4JCircuitBreakerFactory> defaultCustomizer() {
        return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
                .circuitBreakerConfig(CircuitBreakerConfig.ofDefaults())
                .timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofMillis(200)).build())
                .build());
    }
}
  • ほとんどデフォルト設定のまま
  • .timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofMillis(200)).build())これによって、レスポンスタイムが200msを超えるものはフォールバックされる

実行

log
id: 0, cnt: 1, sleep: 300
fallback
id: 1, cnt: 2, sleep: 1
id: 0, cnt: 3, sleep: 300
fallback
id: 1, cnt: 4, sleep: 1
id: 0, cnt: 5, sleep: 300
fallback
id: 1, cnt: 6, sleep: 1
id: 0, cnt: 7, sleep: 300

300msのsleepをするとフォールバックの方にアクセスが行ってる!

カスタマイズ

一定の条件ですべてフォールバックに流す

  • デフォルトだと50%がfailの状況が100リクエスト続いたらフォールバックに流れる
log
id: 1, cnt: 96, sleep: 1
id: 0, cnt: 97, sleep: 300
fallback
id: 1, cnt: 98, sleep: 1
id: 0, cnt: 99, sleep: 300
fallback
id: 1, cnt: 100, sleep: 1
id: 0, cnt: 101, sleep: 300
fallback
fallback
fallback
fallback
  • これを30%、10リクエストにしたかったらこんな感じ
@Configuration
public class CircuitBreakerAutoConfiguration {

    @Bean
    public Customizer<ReactiveResilience4JCircuitBreakerFactory> defaultCustomizer() {
        return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
                .circuitBreakerConfig(CircuitBreakerConfig.custom()
                        .failureRateThreshold(30) // エラー比率
                        .slidingWindowSize(10) // エラー継続リクエスト数
                        .build())
                .timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofMillis(200)).build())
                .build());
    }
}

リクエスト数ではなく、エラーが継続している秒数で制御することも可能!

    @Bean
    public Customizer<ReactiveResilience4JCircuitBreakerFactory> defaultCustomizer() {
        return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
                .circuitBreakerConfig(CircuitBreakerConfig.custom()
                        .failureRateThreshold(30) // エラー比率
                        .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.TIME_BASED)
                        .slidingWindowSize(10) // エラー継続秒数
                        .build())
                .timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofMillis(200)).build())
                .build());
    }

フォールバックからの復帰

デフォルトでは「60秒間間隔」で「10件」バックエンドのAPIを実行。
failureRateThresholdを下回るエラー比率でレスポンスが帰ってきたらCircuitBreakerはCLOSE。
これを「30秒間隔」で「5件」にしたかったらこんな感じ

    @Bean
    public Customizer<ReactiveResilience4JCircuitBreakerFactory> defaultCustomizer() {
        return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
                .circuitBreakerConfig(CircuitBreakerConfig.custom()
                        .failureRateThreshold(30) // エラー比率
                        .slidingWindowSize(10) // エラー継続件数
                        .waitDurationInOpenState(Duration.ofSeconds(30)) //  OPEN→HALF OPENまでの間隔
                        .permittedNumberOfCallsInHalfOpenState(5) // HALF OPEN時のリクエスト数
                        .build())
                .timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofMillis(200)).build())
                .build());
    }

circuitbreakerが開始するまではfallbackに流したくない


    @Bean
    public Customizer<ReactiveResilience4JCircuitBreakerFactory> defaultCustomizer() {
        return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
                .circuitBreakerConfig(CircuitBreakerConfig.custom()
                        .failureRateThreshold(30) // エラー比率
                        .slidingWindowSize(10) // エラー継続件数
                        .waitDurationInOpenState(Duration.ofSeconds(30)) //  OPEN→HALF OPENまでの間隔
                        .permittedNumberOfCallsInHalfOpenState(5) // HALF OPEN時のリクエスト数
                        // レスポンスタイムが200ms以上のリクエストが50%を超えるとOPEN状態に遷移
                        // それまでfallbackには流さない
                        .slowCallDurationThreshold(Duration.ofMillis(200))
                        .slowCallRateThreshold(50)
                        .build())
                .timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofSeconds(100)).build())
                .build());
    }
  • slowCallDurationThresholdslowCallRateThresholdを指定する

結論

  • サービスが食い切れるリクエスト以上のリクエストが来ないようにrate limitで流量を制御!
  • 障害でレスポンスが悪化したときのためにCircuitBreakerでフォールバック!

補足

Redis

環境構築

brew install redis
brew services start redis

Locust

インストール
こちらをご覧ください
https://qiita.com/rhirabay/items/bf9c6c2b75412668377b

定義ファイル

ratelimit.py
from locust import HttpLocust, TaskSet, task, between, constant

class UserBehavior(TaskSet):
    @task(1)
    def task1(self):
        self.client.get("/ratelimit/task1", verify=False)

    @task(1)
    def task2(self):
        self.client.get("/ratelimit/task2", verify=False)

class WebsiteUser(HttpLocust):
    task_set = UserBehavior
    wait_time = constant(1)
circuitbreaker.py
from locust import HttpLocust, TaskSet, task, between, constant

class UserBehavior(TaskSet):
    @task(1)
    def task2(self):
        self.client.get("/circuitbreaker", params={}, verify=False)

class WebsiteUser(HttpLocust):
    task_set = UserBehavior
    wait_time = constant(0.5)
5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?