Making Your Application More Resilient
September 2, 2020 · View on GitHub
Table of Contents
- Scaling Your Application
- Load Balancing Your Upstream Traffic
- Respond Fast
- Rerouting Failing Traffic - Retry
- Rerouting Failing Traffic - Circuit Breaker or Fallback
- Rate Limiting Incoming Traffic
Scaling Your Application
Because we do not want to have downtime we will scale our shop to two instances. If one of them goes down we can still see the items we sell and, even more important, we can still accept orders!
$ kubectl scale deploy shop --replicas=2
Only after we see all the pods of our shop application in the Running state and the container for every pod is ready to accept traffic we can safely continue with the next step.
Load Balancing Your Upstream Traffic
Because we are using the Kubernetes service as load balancer we do not have any possibility to adapt the way our traffic is being routed.
This is where our fourth Spring Cloud project comes into play: Spring Cloud LoadBalancer.
Using this project we have the ability and flexibility to load balance our requests from inside our gateway instance using code.
An example of a custom load balancer could be one which is based on the health of the upstream or target instance.
If the instance we want to send our request to is not healthy or down we temporarily ignore this one so we can send our requests to instances which can accept our requests.
Configuring Our Shop Application
The health actuator endpoint uses a collection of HealthIndicators where each of them indicate the health status, eg. your connection to a database or message broker.
For simulation purposes we will create our own HealthIndicator which will put our shop instance out of order.
Add a new MaintenanceHealthIndicator.java file to our shop application:
package com.example.demo.shop;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.boot.actuate.health.Status;
import org.springframework.stereotype.Component;
@Component
public class MaintenanceHealthIndicator implements HealthIndicator {
private Health health = Health.up().build();
public void switchMode() {
this.health = this.health.getStatus() == Status.OUT_OF_SERVICE ? Health.up().build() : Health.outOfService().build();
}
@Override
public Health health() {
return this.health;
}
}
And a new MaintenanceController.java file:
package com.example.demo.shop;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MaintenanceController {
public static final Logger logger = LoggerFactory.getLogger(MaintenanceController.class);
private final MaintenanceHealthIndicator indicator;
public MaintenanceController(MaintenanceHealthIndicator indicator) {
this.indicator = indicator;
}
@PostMapping(value = "/maintenance")
public ResponseEntity<Void> switchMode() {
this.indicator.switchMode();
logger.info("Switched to {}", this.indicator.health().getStatus());
return ResponseEntity.ok().build();
}
}
To get our new application on Kubernetes we need to create a new image, push the image to our registry and restart our gateway:
$ cd ~/demo/shop
$ ./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=localhost:5000/apps/shop
$ docker push localhost:5000/apps/shop
$ kubectl delete $(kubectl get pod --selector app=shop -o name)
Adding New Dependencies to Our Gateway
For this to work we need to add two new dependency:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-kubernetes</artifactId>
</dependency>
At the core of Spring Cloud there is a discovery client abstraction.
Because we added the spring-cloud-starter-kubernetes dependency to our application we now have a specific Kubernetes-aware client that can discover all of our running instances.
This makes it much easier for us if and when we need to use another service registry system.
In the Spring Cloud Commons module there is also a CompositeDiscoveryClient implementation which allows you to connect to multiple service registry systems like Cloud Foundry, Hashicorp’s Consul or Apache Zookeeper.
Configuring Security Inside Kubernetes
In case your Kubernetes cluster has more fine-grained role-based access control you need to make sure your application has the correct permissions to access the Kubernetes API.
In order for the service discovery to work we need to have the get, list and watch permissions for the pods, services and endpoints resources.
For this workshop we will create a Role named "namespace-reader" and give the default ServiceAccount the correct permissions.
$ kubectl create role namespace-reader --verb=get,list,watch --resource=pods,services,endpoints
$ kubectl create rolebinding default-account-namespace-reader --role=namespace-reader --serviceaccount=default:default
NOTE: We suggest to create a specific
Role, orClusterRolein case you want to discover applications across namespaces, and a specificServiceAccountfor your application.
Configuring Our Load Balancer
At the beginning of the load balancing section we mentioned a load balancer that can temporarily ignore application instances which are not healthy and thus unavailable.
To use both the service discovery and the health checks we will need to configure a custom ServiceInstanceListSupplier.
A ServiceInstanceListSupplier will be used by the load balancer to retrieve the list of available application instances and later on choose one from the list it received.
Update GatewayApplication.java:
package com.example.demo.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient;
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
@LoadBalancerClient(name = "my-shop", configuration = GatewayApplication.LoadBalancerConfiguration.class)
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
// snip
public static final class LoadBalancerConfiguration {
@Bean
public ServiceInstanceListSupplier discoveryClientWithHealthChecksServiceInstanceListSupplier(
ConfigurableApplicationContext context) {
return ServiceInstanceListSupplier.builder()
.withDiscoveryClient()
.withHealthChecks()
.build(context);
}
}
}
NOTE: By default the
/actuator/healthpath is used to determine the health of the running instance. Setspring.cloud.loadbalancer.health-check.path.defaultto change the default path which will be polled. To configure the path for a specific load balancer setspring.cloud.loadbalancer.health-check.path.[SERVICE_ID]. As an example, we should set thespring.cloud.loadbalancer.health-check.path.my-shopproperty.
Adapting Our Route
To let our gateway know we want to use client side load balancing we need to adjust our route.
Instead of using http://my-shop.default.svc.cluster.local as the URI we only need to use the lb scheme and specify the name of the application, or when connecting to Kubernetes the name of the Kubernetes Service.
In your application.yaml file or Java config switch out http://my-shop.default.svc.cluster.local with lb://my-shop.
NOTE: By default the load balancer client filter uses a blocking ribbon LoadBalancerClient under the hood. We suggest you use the non-blocking filter instead. You can switch to the non-blocking filter by setting the value of
spring.cloud.loadbalancer.ribbon.enabledtofalse.
Deploying Our New Gateway
To verify that our new configuration is working we need to create a new image, push the image to our registry and restart our gateway:
$ cd ~/demo/gateway
$ ./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=localhost:5000/apps/gateway
$ docker push localhost:5000/apps/gateway
$ kubectl delete $(kubectl get pod --selector app=gateway -o name)
$ pkill kubectl -9
$ kubectl port-forward services/my-gateway 8080:80 --address 0.0.0.0 > /dev/null 2>&1 &
If everything went well we should still see our list of items when executing:
$ http GET localhost:8080/catalog/items
Testing Our Route to an Unhealthy Instance
Next we need to get one instance into maintenance mode:
$ kubectl port-forward $(kubectl get pod --selector app=shop -o name | head -n 1) 8081:8080 > /dev/null 2>&1 &
$ PID=$!
$ http GET localhost:8081/actuator/health
$ http POST localhost:8081/maintenance
$ http GET localhost:8081/actuator/health
$ kill -9 $PID
If we check the logs of the instance we just put out of order you should see the following line:
$ kubectl logs -f $(kubectl get pod --selector app=shop -o name | head -n 1)
com.example.demo.MaintenanceController : Switched to OUT_OF_SERVICE
If we open a new tab and browse to <public DNS>:8080/catalog/items we should not see any more logging being written to our console.
Press Control+C to stop following the logging.
NOTE: It might be possible that you're still seeing some new logging. The load balancer will check the health status of the instances every 25 seconds by default.
Respond Fast
Now that we made sure we only send our requests to healthy instances we are a little bit more relieved. But what if our instance is healthy and the requests are taking a long time? We do not want to let our customer wait a long time so we want to respond, or fail, fast.
Configuring Our Shop Application
To simulate our misbehaving shop application we will first make some changes.
Add a SimulationProperties.java:
package com.example.demo.shop;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "simulation")
public class SimulationProperties {
private long delay = 0;
public long getDelay() {
return delay;
}
public void setDelay(long delay) {
this.delay = delay;
}
}
Update ShopApplication.java:
package com.example.demo.shop;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@SpringBootApplication
@EnableConfigurationProperties({ SimulationProperties.class })
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
Update CatalogController.java:
package com.example.demo.shop.catalog;
import com.example.demo.shop.SimulationProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/catalog")
public class CatalogController {
public static final Logger logger = LoggerFactory.getLogger(CatalogController.class);
private final SimulationProperties simulationProperties;
public CatalogController(SimulationProperties simulationProperties) {
this.simulationProperties = simulationProperties;
}
@GetMapping(value = "/items", produces = { "application/json" })
public ResponseEntity<String> retrieveAllItems() throws InterruptedException {
Thread.sleep(this.simulationProperties.getDelay());
logger.info("Received request for catalog items");
return ResponseEntity.ok().body("[{ \"id\": \"6b76148d-0fda-4ebf-8966-d91bfaeb0236\", \"img\": \"https://images.unsplash.com/photo-1590688178590-bb8370b70528\", \"name\": \"Breakfast with homemade bread\", \"price\": 16 }, { \"id\": \"52d59380-79da-49d5-9d09-9716e20ccbc4\", \"img\": \"https://images.unsplash.com/photo-1592894869086-f828b161e90a\", \"name\": \"Brisket\", \"price\": 24 }, { \"id\": \"a7be01f8-b76e-4384-bf1d-e69d7bdbe4b4\", \"img\": \"https://images.unsplash.com/photo-1544025162-d76694265947\", \"name\": \"Pork Ribs\", \"price\": 20 }]");
}
}
Update BaseTestClass.java:
package com.example.demo.shop;
import org.junit.jupiter.api.BeforeEach;
import io.restassured.module.mockmvc.RestAssuredMockMvc;
import com.example.demo.shop.catalog.CatalogController;
import com.example.demo.shop.orders.OrdersController;
public class BaseTestClass {
@BeforeEach
public void setup() {
RestAssuredMockMvc.standaloneSetup(new CatalogController(new SimulationProperties()), new OrdersController());
}
}
And add the following lines to application.properties:
management.endpoints.web.exposure.include=env,health,info,refresh
management.endpoint.env.post.enabled=true
To get our changes on Kubernetes we need to create a new image, push the image to our registry and restart our gateway:
$ cd ~/demo/shop
$ ./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=localhost:5000/apps/shop
$ docker push localhost:5000/apps/shop
$ kubectl delete $(kubectl get pod --selector app=shop -o name)
Testing Our Delay
Next we need to set the delay for one of our instances:
$ kubectl port-forward $(kubectl get pod --selector app=shop -o name | head -n 1) 8081:8080 > /dev/null 2>&1 &
$ PID=$!
$ http -v POST localhost:8081/actuator/env name=simulation.delay value:=120000
$ http -v POST localhost:8081/actuator/refresh
$ http -v GET localhost:8081/catalog/items --timeout 5
$ kill -9 $PID
We should see the following message, confirming our delay is correctly set:
http: error: Request timed out (5.0s).
Adapting Our Route
To configure our timeouts we need to adjust our route to our shop application in our gateway.
Adjust GatewayApplication.java:
package com.example.demo.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient;
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import static org.springframework.cloud.gateway.support.RouteMetadataUtils.CONNECT_TIMEOUT_ATTR;
import static org.springframework.cloud.gateway.support.RouteMetadataUtils.RESPONSE_TIMEOUT_ATTR;
@SpringBootApplication
@LoadBalancerClient(name = "my-shop", configuration = GatewayApplication.LoadBalancerConfiguration.class)
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
return builder.routes()
.route("catalog_route",
r -> r.path("/catalog/{*segment}")
.uri("lb://my-shop")
.metadata(RESPONSE_TIMEOUT_ATTR, 200)
.metadata(CONNECT_TIMEOUT_ATTR, 200)
)
.route("orders_route",
r -> r.path("/orders/{*segment}")
.uri("lb://my-shop")
)
.build();
}
public static final class LoadBalancerConfiguration {
@Bean
public ServiceInstanceListSupplier discoveryClientWithHealthChecksServiceInstanceListSupplier(
ConfigurableApplicationContext context) {
return ServiceInstanceListSupplier.builder()
.withDiscoveryClient()
.withHealthChecks()
.build(context);
}
}
}
Or update application.yaml:
spring:
cloud:
gateway:
routes:
- id: catalog_route
uri: lb://my-shop
predicates:
- Path=/catalog/{*segment}
metadata:
response-timeout: 200
connect-timeout: 200
- id: orders_route
uri: lb://my-shop
predicates:
- Path=/orders/{*segment}
Deploying Our New Gateway
To verify that our new configuration is working we need to create a new image, push the image to our registry and restart our gateway:
$ cd ~/demo/gateway
$ ./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=localhost:5000/apps/gateway
$ docker push localhost:5000/apps/gateway
$ kubectl delete $(kubectl get pod --selector app=gateway -o name)
$ pkill kubectl -9
$ kubectl port-forward services/my-gateway 8080:80 --address 0.0.0.0 > /dev/null 2>&1 &
Testing Our Route
Now if we want to get our list we should see some weird behaviour happening:
Execute until you get a 504 Gateway Timeout error:
$ http GET localhost:8080/catalog/items
Rerouting Failing Traffic - Retry
We are now able to quickly send a response to our customer. But what now? We know our other instance is just working as expected. Could we just send our request again to that other instance?
Adapting Our Route
To configure a retry for a failing request we need to adjust our route to our shop application in our gateway.
NOTE: Keep in mind that, ideally, you only want to retry idempotent requests. An HTTP method is idempotent if an identical request can be made once or several times in a row with the same effect while leaving the server in the same state. In other words, an idempotent method should not have any side-effects (except for keeping statistics).
Adjust GatewayApplication.java:
package com.example.demo.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient;
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import static org.springframework.cloud.gateway.support.RouteMetadataUtils.CONNECT_TIMEOUT_ATTR;
import static org.springframework.cloud.gateway.support.RouteMetadataUtils.RESPONSE_TIMEOUT_ATTR;
@SpringBootApplication
@LoadBalancerClient(name = "my-shop", configuration = GatewayApplication.LoadBalancerConfiguration.class)
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
return builder.routes()
.route("catalog_route",
r -> r.path("/catalog/{*segment}")
.filters(f ->
f.retry(c ->
c.setRetries(2)
.setMethods(HttpMethod.HEAD, HttpMethod.GET, HttpMethod.PUT, HttpMethod.DELETE)
)
)
.uri("lb://my-shop")
.metadata(RESPONSE_TIMEOUT_ATTR, 200)
.metadata(CONNECT_TIMEOUT_ATTR, 200)
)
.route("orders_route",
r -> r.path("/orders/{*segment}")
.uri("lb://my-shop")
)
.build();
}
public static final class LoadBalancerConfiguration {
@Bean
public ServiceInstanceListSupplier discoveryClientWithHealthChecksServiceInstanceListSupplier(
ConfigurableApplicationContext context) {
return ServiceInstanceListSupplier.builder()
.withDiscoveryClient()
.withHealthChecks()
.build(context);
}
}
}
Or update application.yaml:
spring:
cloud:
gateway:
routes:
- id: catalog_route
uri: lb://my-shop
predicates:
- Path=/catalog/{*segment}
filters:
- name: Retry
args:
retries: 2
methods: HEAD,GET,PUT,DELETE
metadata:
response-timeout: 200
connect-timeout: 200
- id: orders_route
uri: lb://my-shop
predicates:
- Path=/orders/{*segment}
Deploying Our New Gateway
To verify that our new configuration is working we need to create a new image, push the image to our registry and restart our gateway:
$ cd ~/demo/gateway
$ ./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=localhost:5000/apps/gateway
$ docker push localhost:5000/apps/gateway
$ kubectl delete $(kubectl get pod --selector app=gateway -o name)
$ pkill kubectl -9
$ kubectl port-forward services/my-gateway 8080:80 --address 0.0.0.0 > /dev/null 2>&1 &
Testing Our Route
If everything went well we should not see any errors and our list of items being returned in a couple of milliseconds. To verify this, execute:
$ seq 10 | xargs -I INDEX http GET localhost:8080/catalog/items
Rerouting Failing Traffic - Circuit Breaker or Fallback
Now that we've rerouted our request to our healthy instance we are back in business.
But what would happen if none of them could handle the request?
For example, we just deployed a second shop application with its own Kubernetes Service and updated our route in our gateway but we see there's something wrong with this new deployment.
Configuring Our Shop Application
To simulate our misbehaving shop application we will need to make some changes again.
Update SimulationProperties.java:
package com.example.demo.shop;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "simulation")
public class SimulationProperties {
private long delay = 0;
private boolean simulateErrors = false;
public long getDelay() {
return delay;
}
public void setDelay(long delay) {
this.delay = delay;
}
public boolean getSimulateErrors() {
return simulateErrors;
}
public void setSimulateErrors(boolean simulateErrors) {
this.simulateErrors = simulateErrors;
}
public boolean simulateErrors() {
return simulateErrors;
}
}
And adjust CatalogController.java:
package com.example.demo.shop.catalog;
import com.example.demo.shop.SimulationProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/catalog")
public class CatalogController {
public static final Logger logger = LoggerFactory.getLogger(CatalogController.class);
private final SimulationProperties simulationProperties;
public CatalogController(SimulationProperties simulationProperties) {
this.simulationProperties = simulationProperties;
}
@GetMapping(value = "/items", produces = { "application/json" })
public ResponseEntity<String> retrieveAllItems() throws InterruptedException {
if (this.simulationProperties.simulateErrors()) {
throw new RuntimeException("Oops!");
}
Thread.sleep(this.simulationProperties.getDelay());
logger.info("Received request for catalog items");
return ResponseEntity.ok().body("[{ \"id\": \"6b76148d-0fda-4ebf-8966-d91bfaeb0236\", \"img\": \"https://images.unsplash.com/photo-1590688178590-bb8370b70528\", \"name\": \"Breakfast with homemade bread\", \"price\": 16 }, { \"id\": \"52d59380-79da-49d5-9d09-9716e20ccbc4\", \"img\": \"https://images.unsplash.com/photo-1592894869086-f828b161e90a\", \"name\": \"Brisket\", \"price\": 24 }, { \"id\": \"a7be01f8-b76e-4384-bf1d-e69d7bdbe4b4\", \"img\": \"https://images.unsplash.com/photo-1544025162-d76694265947\", \"name\": \"Pork Ribs\", \"price\": 20 }]");
}
}
Deploying Our New Shop
To make sure we have a second version of our application we will also change the name of our image. Execute the following commands to deploy the new shop:
$ cd ~/demo/shop/
$ ./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=localhost:5000/apps/shop-v2
$ docker push localhost:5000/apps/shop-v2
$ kubectl create deployment shop-v2 --image localhost:5000/apps/shop-v2
$ kubectl patch deployment shop-v2 --type json -p='[{"op": "add", "path": "/spec/template/spec/containers/0/env", "value":[{"name":"SIMULATION_SIMULATE_ERRORS", "value": "true"}]}, {"op": "add", "path": "/spec/template/spec/containers/0/ports", "value":[{"containerPort":8080}]}]'
$ kubectl expose deployment shop-v2 --name my-shop-v2 --port 80 --target-port 8080
Testing Our Failing Shop
To quickly check if the new version is indeed failing as intended execute:
$ kubectl port-forward $(kubectl get pod --selector app=shop-v2 -o name | head -n 1) 8081:8080 > /dev/null 2>&1 &
$ PID=$!
$ http GET localhost:8081/catalog/items
$ kill -9 $PID
Adding Missing Dependencies
To make use of the circuit breaker pattern we need to add a new dependency.
Open your pom.xml file and add the following snippet to your dependencies:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
</dependency>
Adding Our New Load Balancer
Because we need to use a new service we also need to define this in our gateway. To make our lives easier we are going to reuse the same configuration.
Update GatewayApplication.java:
package com.example.demo.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient;
import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClients;
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import static org.springframework.cloud.gateway.support.RouteMetadataUtils.CONNECT_TIMEOUT_ATTR;
import static org.springframework.cloud.gateway.support.RouteMetadataUtils.RESPONSE_TIMEOUT_ATTR;
@SpringBootApplication
@LoadBalancerClients(value = {
@LoadBalancerClient(name = "my-shop"),
@LoadBalancerClient(name = "my-shop-v2")
}, defaultConfiguration = GatewayApplication.LoadBalancerConfiguration.class)
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
// snip
public static final class LoadBalancerConfiguration {
@Bean
public ServiceInstanceListSupplier discoveryClientWithHealthChecksServiceInstanceListSupplier(
ConfigurableApplicationContext context) {
return ServiceInstanceListSupplier.builder()
.withDiscoveryClient()
.withHealthChecks()
.build(context);
}
}
}
Adapting Our Route
Next we will send our requests to our new version but we will keep the previous configuration as a fallback. For this to work we need to do a couple of things:
- Add a
/catalog_items_fallbackendpoint to the gateway which will make the request tolb://my-shop/catalog/items. - Add the new route
catalog_items_routewhich sends traffic tolb://my-shop-v2when a request matchesGET /catalog/items. - Add a circuit breaker to
catalog_items_routewhich forwards traffic to/catalog_items_fallback.
Add CatalogFallbackController.java:
package com.example.demo.gateway;
import java.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClient.Builder;
import reactor.core.publisher.Mono;
@RestController
public class CatalogFallbackController {
private static final Logger logger = LoggerFactory.getLogger(CatalogFallbackController.class);
private final WebClient.Builder webClientBuilder;
public CatalogFallbackController(Builder webClientBuilder) {
this.webClientBuilder = webClientBuilder;
}
@GetMapping("/catalog_items_fallback")
public Mono<ResponseEntity<String>> getCatalogItemsFromV1() {
logger.info("Retrieving catalog items from v1");
return webClientBuilder.build().get().uri("http://my-shop/catalog/items").exchange()
.timeout(Duration.ofMillis(200)).retry(2) // Just like in our route configuration, we want to have a fast response !
.flatMap(cr -> cr.toEntity(String.class))
.switchIfEmpty(Mono.just(ResponseEntity.notFound().build()));
}
}
Adjust GatewayApplication.java:
package com.example.demo.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient;
import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClients;
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import static org.springframework.cloud.gateway.support.RouteMetadataUtils.CONNECT_TIMEOUT_ATTR;
import static org.springframework.cloud.gateway.support.RouteMetadataUtils.RESPONSE_TIMEOUT_ATTR;
import java.util.Set;
@SpringBootApplication
@LoadBalancerClients(value = {
@LoadBalancerClient(name = "my-shop"),
@LoadBalancerClient(name = "my-shop-v2")
}, defaultConfiguration = GatewayApplication.LoadBalancerConfiguration.class)
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
return builder.routes()
.route("catalog_items_route",
r -> r.path("/catalog/items")
.and().method(HttpMethod.GET)
.filters(f ->
f.circuitBreaker(c ->
c.setFallbackUri("forward:/catalog_items_fallback")
.setStatusCodes(Set.of("500"))
)
)
.uri("lb://my-shop-v2")
)
.route("catalog_route",
r -> r.path("/catalog/{*segment}")
.filters(f ->
f.retry(c ->
c.setRetries(2)
.setMethods(HttpMethod.HEAD, HttpMethod.GET, HttpMethod.PUT, HttpMethod.DELETE)
)
)
.uri("lb://my-shop")
.metadata(RESPONSE_TIMEOUT_ATTR, 200)
.metadata(CONNECT_TIMEOUT_ATTR, 200)
)
.route("orders_route",
r -> r.path("/orders/{*segment}")
.uri("lb://my-shop")
)
.build();
}
public static final class LoadBalancerConfiguration {
@Bean
public ServiceInstanceListSupplier discoveryClientWithHealthChecksServiceInstanceListSupplier(
ConfigurableApplicationContext context) {
return ServiceInstanceListSupplier.builder()
.withDiscoveryClient()
.withHealthChecks()
.build(context);
}
}
}
Or update application.yaml:
spring:
cloud:
gateway:
routes:
- id: catalog_items_route
uri: lb://my-shop-v2
predicates:
- Path=/catalog/items
- Method=GET
filters:
- name: CircuitBreaker
args:
fallbackUri: forward:/catalog_items_fallback
statusCodes:
- 500
- id: catalog_route
uri: lb://my-shop
predicates:
- Path=/catalog/{*segment}
filters:
- name: Retry
args:
retries: 2
methods: HEAD,GET,PUT,DELETE
metadata:
response-timeout: 200
connect-timeout: 200
- id: orders_route
uri: lb://my-shop
predicates:
- Path=/orders/{*segment}
Deploying Our New Gateway
To verify that our new configuration is working we need to create a new image, push the image to our registry and restart our gateway:
$ cd ~/demo/gateway
$ ./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=localhost:5000/apps/gateway
$ docker push localhost:5000/apps/gateway
$ kubectl delete $(kubectl get pod --selector app=gateway -o name)
$ pkill kubectl -9
$ kubectl port-forward services/my-gateway 8080:80 --address 0.0.0.0 > /dev/null 2>&1 &
Testing Our Route
If we check the logs of the gateway instance, open a new tab and browse to <public DNS>:8080/catalog/items you should see the following line:
$ kubectl logs -f $(kubectl get pod --selector app=gateway -o name | head -n 1)
c.e.d.gateway.CatalogFallbackController : Retrieving catalog items from v1
Press Control+C to stop following the logging.
Rate Limiting Incoming Traffic
Suppose this is the first version of our API and that it doesn't have authentication or authorization. Along comes this hacker who wants to hurt us by sending a huge amount of requests to our application. One of the first things we can do is to add a rate limiter to our API gateway so that the requests of the hacker are not being sent to our application anymore.
Adding Missing Dependencies
To keep track of all the used requests we will make use of Redis as our backing store.
This requires the use of the spring-boot-starter-data-redis-reactive Spring Boot starter.
Open your pom.xml file and add the following snippet to your dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
Configuring Our Redis Connection
Next we need to let our application know where our Redis instance is located.
If you followed the steps as outlined in Setting up the environment you can use redis.default.svc.cluster.local as host.
spring:
redis:
host: redis.default.svc.cluster.local
password: Opstree@1234
Configuring Our Route
The Redis implementation is based off of work done at Stripe. The algorithm used is the Token Bucket Algorithm.
We will set up our rate limiter using a custom KeyResolver implementation and three properties: redis-rate-limiter.replenishRate, redis-rate-limiter.burstCapacity and redis-rate-limiter.requestedTokens.
-
The
KeyResolverinterface is used to group requests. The default implementation ofKeyResolveris thePrincipalNameKeyResolver, which retrieves thePrincipalfrom theServerWebExchangeand callsPrincipal.getName(). -
The
redis-rate-limiter.replenishRateproperty is how many requests per second you want a user to be allowed to do, without any dropped requests. This is the rate at which the token bucket is filled. -
The
redis-rate-limiter.burstCapacityproperty is the maximum number of requests a user is allowed to do in a single second. This is the number of tokens the token bucket can hold. Setting this value to zero blocks all requests. -
The
redis-rate-limiter.requestedTokensproperty is how many tokens a request costs. This is the number of tokens taken from the bucket for each request and defaults to 1.
In this example we will allow 1 request per second for every user.
While investigating the requests we saw our hacker is using a specific HTTP header called X-HACK-TOOL.
Since we have no authentication yet we will use this header to group our requests.
First we need to add our custom KeyResolver in GatewayApplication.java:
package com.example.demo.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient;
import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClients;
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import reactor.core.publisher.Mono;
@SpringBootApplication
@LoadBalancerClients(value = {
@LoadBalancerClient(name = "my-shop"),
@LoadBalancerClient(name = "my-shop-v2")
}, defaultConfiguration = GatewayApplication.LoadBalancerConfiguration.class)
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
// snip
@Bean
public KeyResolver userKeyResolver() {
return exchange -> Mono.justOrEmpty(exchange.getRequest().getHeaders().getFirst("X-HACK-TOOL"));
}
public static final class LoadBalancerConfiguration {
@Bean
public ServiceInstanceListSupplier discoveryClientWithHealthChecksServiceInstanceListSupplier(
ConfigurableApplicationContext context) {
return ServiceInstanceListSupplier.builder()
.withDiscoveryClient()
.withHealthChecks()
.build(context);
}
}
}
By default, if the KeyResolver does not find a key, requests are denied.
You can either adjust your custom KeyResolver to provide a default value which will group all requests together as if they were executed by the same person.
Or you can adjust this behavior by setting the spring.cloud.gateway.filter.request-rate-limiter.deny-empty-key (true or false) and spring.cloud.gateway.filter.request-rate-limiter.empty-key-status-code properties.
In this example we don't want to deny traffic which does not have this header.
Add spring.cloud.gateway.filter.request-rate-limiter.deny-empty-key=false to your configuration before continuing.
After we've configured our custom KeyResolver and our property we need to add a filter to our route.
Adjust GatewayApplication.java:
package com.example.demo.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiter;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient;
import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClients;
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import reactor.core.publisher.Mono;
import static org.springframework.cloud.gateway.support.RouteMetadataUtils.CONNECT_TIMEOUT_ATTR;
import static org.springframework.cloud.gateway.support.RouteMetadataUtils.RESPONSE_TIMEOUT_ATTR;
import java.util.Set;
@SpringBootApplication
@LoadBalancerClients(value = {
@LoadBalancerClient(name = "my-shop"),
@LoadBalancerClient(name = "my-shop-v2")
}, defaultConfiguration = GatewayApplication.LoadBalancerConfiguration.class)
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
return builder.routes()
.route("catalog_items_route",
r -> r.path("/catalog/items")
.and().method(HttpMethod.GET)
.filters(f ->
f.requestRateLimiter()
.rateLimiter(RedisRateLimiter.class,
rl -> rl.setReplenishRate(1).setBurstCapacity(60).setRequestedTokens(1)
)
.and()
.circuitBreaker(c ->
c.setFallbackUri("forward:/catalog_items_fallback")
.setStatusCodes(Set.of("500"))
)
)
.uri("lb://my-shop-v2")
)
.route("catalog_route",
r -> r.path("/catalog/{*segment}")
.filters(f ->
f.requestRateLimiter()
.rateLimiter(RedisRateLimiter.class,
rl -> rl.setReplenishRate(1).setBurstCapacity(60).setRequestedTokens(1)
)
.and()
.retry(c ->
c.setRetries(2)
.setMethods(HttpMethod.HEAD, HttpMethod.GET, HttpMethod.PUT, HttpMethod.DELETE)
)
)
.uri("lb://my-shop")
.metadata(RESPONSE_TIMEOUT_ATTR, 200)
.metadata(CONNECT_TIMEOUT_ATTR, 200)
)
.route("orders_route",
r -> r.path("/orders/{*segment}")
.uri("lb://my-shop")
)
.build();
}
@Bean
public KeyResolver userKeyResolver() {
return exchange -> Mono.justOrEmpty(exchange.getRequest().getHeaders().getFirst("X-HACK-TOOL"));
}
public static final class LoadBalancerConfiguration {
@Bean
public ServiceInstanceListSupplier discoveryClientWithHealthChecksServiceInstanceListSupplier(
ConfigurableApplicationContext context) {
return ServiceInstanceListSupplier.builder()
.withDiscoveryClient()
.withHealthChecks()
.build(context);
}
}
}
Or update application.yaml:
spring:
cloud:
gateway:
routes:
- id: catalog_items_route
uri: lb://my-shop-v2
predicates:
- Path=/catalog/items
- Method=GET
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 1
redis-rate-limiter.burstCapacity: 60
redis-rate-limiter.requestedTokens: 1
key-resolver: "#{@userKeyResolver}"
- name: CircuitBreaker
args:
fallbackUri: forward:/catalog_items_fallback
statusCodes:
- 500
- id: catalog_route
uri: lb://my-shop
predicates:
- Path=/catalog/{*segment}
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 1
redis-rate-limiter.burstCapacity: 60
redis-rate-limiter.requestedTokens: 1
key-resolver: "#{@userKeyResolver}"
- name: Retry
args:
retries: 2
methods: HEAD,GET,PUT,DELETE
metadata:
response-timeout: 200
connect-timeout: 200
- id: orders_route
uri: lb://my-shop
predicates:
- Path=/orders/{*segment}
NOTE: You can also define a rate limiter as a bean that implements the
RateLimiterinterface. In configuration you can reference the bean by name using SpEL in therate-limiterproperty, eg.rate-limiter: "#{@myRateLimiter}"wheremyRateLimiteris the name of the bean. You can then remove theredis-rate-limiter.replenishRate,redis-rate-limiter.burstCapacityandredis-rate-limiter.requestedTokensproperties.
Testing Our Route
To verify that our new configuration is working we need to create a new image, push the image to our registry and restart our gateway:
$ ./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=localhost:5000/apps/gateway
$ docker push localhost:5000/apps/gateway
$ kubectl delete $(kubectl get pod --selector app=gateway -o name)
$ pkill kubectl -9
$ kubectl port-forward services/my-gateway 8080:80 --address 0.0.0.0 > /dev/null 2>&1 &
We can now simulate a flood of HTTP request by executing:
$ curl https://raw.githubusercontent.com/TYsewyn/Getting-started-with-Spring-Cloud/master/hack.sh -o ~/demo/hack.sh && chmod a+x ~/demo/hack.sh
$ sh ~/demo/hack.sh 2 localhost:8080/catalog/items
NOTE: If you see that the requests are still succeeding, check the
X-RateLimit-Remainingheader. If the value of this header is -1 there is a problem with the configuration of your rate limiter. The easiest way to troubleshoot is to enable debug logging usinglogging.level.root: DEBUG.
While this script is running in our VM we should still be able to access our API from our browser.