SpringCloudGatewayでサービスを守る

# この記事の概要

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

# spring-cloud-gatewayとは？

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

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

```gradle: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}"
    }
}
```

```yaml: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の制御はここで行います

```Java: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
- どんなリクエストがきたのかログに出すして返すだけ

実行！

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

いざ本題

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

- [ドキュメント](https://cloud.spring.io/spring-cloud-static/spring-cloud-gateway/2.2.1.RELEASE/reference/html/#the-requestratelimiter-gatewayfilter-factory)
- 例えば「秒間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`を追加

```yaml: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`：一時的にここまで叩いても良いという設定値

```Java
@Configuration
public class RateLimitAutoConfiguration {
    @Bean
    public KeyResolver simpleKeyResolver() {
        return exchange -> Mono.just("sample");
    }
}
```

- `KeyResolver`によりrate limitをかける単位を決めることができる
- もしユーザーごとにかけたければユーザー識別子を、叩き元のホストごとにかけたければホスト情報を`Mono.just`の引数に設定する
- ここでは固定の文字列を入れているのでroute全体で10rpsのrate limitがかかる

## 動作確認

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

<img width="1419" alt="スクリーンショット 2020-02-23 9.35.47.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/361020/8c438423-ede3-aeeb-7414-99f1d70d1d10.png">
<img width="1057" alt="スクリーンショット 2020-02-23 9.36.38.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/361020/92787634-f906-ca29-6f01-65a4e57b7093.png">



## カスタム

### pathごとにrate limitをかける

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

```Java
@Configuration
public class RateLimitAutoConfiguration {
    @Bean
    KeyResolver userKeyResolver() {
        return exchange -> Mono.just(exchange.getRequest().getPath().toString());
    }
}
```

<img width="1422" alt="スクリーンショット 2020-02-23 0.22.14.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/361020/a24cfb6d-be8d-ee76-9f4a-726fcf861c82.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

```Java
...
    @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：１リクエストごとにsleep時間が「1ms→300ms→1ms→300ms→...」と切り替わるAPI
- /fallback：フォールバック用のAPI。ログを出力するだけ

### 依存関係を追加

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

### gatewayの実装

```yaml: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`にフォワード

```Java
@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を超えるものはフォールバックされる

## 実行

```text: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リクエスト続いたらフォールバックに流れる

```text: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リクエストにしたかったらこんな感じ

```Java
@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());
    }
}
```

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

```Java
    @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件」にしたかったらこんな感じ

```Java
    @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に流したくない

```Java

    @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());
    }
```

- `slowCallDurationThreshold`と`slowCallRateThreshold`を指定する

# 結論

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



# 補足

## Redis

環境構築

```bash
brew install redis
brew services start redis
```

## Locust

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

定義ファイル

```python: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)
```

```python: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)
```