Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/modules/ROOT/nav.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
*** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/requestsize-factory.adoc[]
*** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/setrequesthostheader-factory.adoc[]
*** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/tokenrelay-factory.adoc[]
*** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/websessionsticky-factory.adoc[]
*** xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/default-filters.adoc[]
** xref:spring-cloud-gateway-server-webflux/global-filters.adoc[]
** xref:spring-cloud-gateway-server-webflux/httpheadersfilters.adoc[]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
[[websessionsticky-factory]]
= `WebSessionSticky` `GatewayFilter` Factory

The `WebSessionSticky` filter enables per-route, server-side sticky load balancing using
the gateway's own `WebSession` as the affinity store. When this filter is applied to a
route, every client session is pinned to a specific backend instance for the duration of
that session. If the pinned instance is deregistered or fails a health check, the
gateway transparently selects a new instance and updates the session affinity — the
client sees no error.

This filter is the per-route, composable alternative to enabling sticky routing via the
`sticky://` URI scheme in the
xref:spring-cloud-gateway-server-webflux/global-filters.adoc#web-session-sticky-load-balancer-filter[WebSessionStickyLoadBalancerFilter].
Use this factory when you want `lb://` to remain the URI scheme and need to mix sticky
and non-sticky routes to the same service.

NOTE: This filter requires `WebSessionStickyLoadBalancerFilter` to be enabled:
`spring.cloud.gateway.global-filter.web-session-sticky-load-balancer.enabled=true`

The following listing shows how to add the `WebSessionSticky` `GatewayFilter`:

.application.yaml
[source,yaml]
----
spring:
cloud:
gateway:
server:
webflux:
global-filter:
web-session-sticky-load-balancer:
enabled: true
routes:
- id: legacy-service-route
uri: lb://legacy-service
predicates:
- Path=/legacy/**
filters:
- WebSessionSticky
- id: stateless-route
uri: lb://legacy-service
predicates:
- Path=/api/**
----

In this example, requests matching `/legacy/**` are pinned to a single instance of
`legacy-service` for each client session. Requests matching `/api/**` use standard
round-robin load balancing.

NOTE: Session affinity is stored server-side in the gateway `WebSession`. Unlike the
cookie-based `RequestBasedStickySessionServiceInstanceListSupplier`, clients do not need
to store or echo any instance-ID cookie. This is particularly useful for legacy backends
that cannot achieve distributed session state.

NOTE: If you run multiple gateway instances, ensure that the session store is shared
(e.g. Spring Session backed by Redis) so that a client reaching a different gateway
replica retains its affinity.
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,94 @@ However, if `GATEWAY_SCHEME_PREFIX_ATTR` is specified for the route in the Gatew

TIP: Gateway supports all the LoadBalancer features. You can read more about them in the https://docs.spring.io/spring-cloud-commons/docs/current/reference/html/#spring-cloud-loadbalancer[Spring Cloud Commons documentation].

[[web-session-sticky-load-balancer-filter]]
== `WebSessionStickyLoadBalancerFilter`

The `WebSessionStickyLoadBalancerFilter` provides server-side, WebSession-based sticky routing as an opt-in complement to the standard `ReactiveLoadBalancerClientFilter`.
A route opts in by using `sticky://` as its URI scheme instead of `lb://`.

Unlike the cookie-based approach provided by `LoadBalancerServiceInstanceCookieFilter`, this filter stores the `serviceId -> instanceId` affinity map server-side in the gateway's own `WebSession`.
Clients do not need to send or manage any cookie.

When a request arrives on a sticky route, the filter:

1. Looks up an existing `serviceId -> instanceId` affinity in the session.
2. If a pinned instance is found *and* that instance is still registered, routes directly to it.
3. If no affinity exists, or the pinned instance has been deregistered (e.g. after a health-check failure), selects a new instance via the configured delegate balancer (round-robin by default) and records the new affinity in the session.

This means a failed instance is transparently replaced — the client sees no error and subsequent requests are pinned to the new instance.

=== Enabling the filter

The filter is disabled by default.
Enable it with:

.application.yml
[source,yaml]
----
spring:
cloud:
gateway:
server:
webflux:
global-filter:
web-session-sticky-load-balancer-filter:
enabled: true
----

=== Route configuration

Use `sticky://` as the URI scheme on any route that should use session affinity:

.application.yml
[source,yaml]
----
spring:
cloud:
gateway:
server:
webflux:
routes:
- id: sticky-api-route
uri: sticky://my-service
predicates:
- Path=/api/**
- id: regular-route
uri: lb://my-service
predicates:
- Path=/public/**
----

In the example above, `/api/**` requests are pinned to a single instance of `my-service` for the life of the session, while `/public/**` requests are distributed across instances using the normal round-robin balancer.

NOTE: The `WebSessionStickyLoadBalancerFilter` handles only `sticky://` URIs.
Routes using the standard `lb://` scheme continue to be handled by `ReactiveLoadBalancerClientFilter` and are unaffected.

NOTE: Session affinity depends on the gateway maintaining a `WebSession` for each client.
Ensure that session persistence (e.g. Spring Session with Redis) is configured if you run multiple gateway instances; otherwise, a client that reaches a different gateway replica will lose its affinity until a new instance is selected.

=== Comparison with cookie-based sticky sessions

|===
| Feature | `LoadBalancerServiceInstanceCookieFilter` | `WebSessionStickyLoadBalancerFilter`

| Affinity storage
| Client-side HTTP cookie (`sc-lb-instance-id`)
| Server-side `WebSession` attribute

| Per-route opt-in
| No — configured per service via `LoadBalancerProperties`
| Yes — use `sticky://` on individual routes

| Client transparency
| Client must echo the cookie on each request
| Fully transparent; no client change required

| Graceful re-pin on instance failure
| No — cookie retains stale instance id
| Yes — stale affinity is replaced automatically
|===

[[routetorequesturl-filter]]
== `RouteToRequestUrl` Filter

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer;
import org.springframework.cloud.gateway.config.conditional.ConditionalOnEnabledFilter;
import org.springframework.cloud.gateway.config.conditional.ConditionalOnEnabledGlobalFilter;
import org.springframework.cloud.gateway.filter.LoadBalancerServiceInstanceCookieFilter;
import org.springframework.cloud.gateway.filter.ReactiveLoadBalancerClientFilter;
import org.springframework.cloud.gateway.filter.WebSessionStickyLoadBalancerFilter;
import org.springframework.cloud.gateway.filter.factory.WebSessionStickyGatewayFilterFactory;
import org.springframework.cloud.loadbalancer.config.LoadBalancerAutoConfiguration;
import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory;
import org.springframework.context.annotation.Bean;
Expand All @@ -36,6 +39,7 @@
*
* @author Spencer Gibb
* @author Olga Maciaszek-Sharma
* @author Beteab Gebru
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ ReactiveLoadBalancer.class, LoadBalancerAutoConfiguration.class, DispatcherHandler.class })
Expand All @@ -61,4 +65,21 @@ public LoadBalancerServiceInstanceCookieFilter loadBalancerServiceInstanceCookie
return new LoadBalancerServiceInstanceCookieFilter(loadBalancerClientFactory);
}

@Bean
@ConditionalOnBean(LoadBalancerClientFactory.class)
@ConditionalOnMissingBean(WebSessionStickyLoadBalancerFilter.class)
@ConditionalOnEnabledGlobalFilter(WebSessionStickyLoadBalancerFilter.class)
public WebSessionStickyLoadBalancerFilter webSessionStickyLoadBalancerFilter(
LoadBalancerClientFactory clientFactory) {
return new WebSessionStickyLoadBalancerFilter(clientFactory);
}

@Bean
@ConditionalOnBean(LoadBalancerClientFactory.class)
@ConditionalOnMissingBean(WebSessionStickyGatewayFilterFactory.class)
@ConditionalOnEnabledFilter(WebSessionStickyGatewayFilterFactory.class)
public WebSessionStickyGatewayFilterFactory webSessionStickyGatewayFilterFactory() {
return new WebSessionStickyGatewayFilterFactory();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/*
* Copyright 2013-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.springframework.cloud.gateway.filter;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.core.publisher.Mono;

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.DefaultResponse;
import org.springframework.cloud.client.loadbalancer.EmptyResponse;
import org.springframework.cloud.client.loadbalancer.Request;
import org.springframework.cloud.client.loadbalancer.Response;
import org.springframework.cloud.loadbalancer.core.NoopServiceInstanceListSupplier;
import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer;
import org.springframework.cloud.loadbalancer.core.RoundRobinLoadBalancer;
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.web.server.ServerWebExchange;

/**
* A {@link ReactorServiceInstanceLoadBalancer} that implements server-side,
* WebSession-based sticky routing. When the exchange attribute
* {@link WebSessionStickyLoadBalancerFilter#IS_STICKY_ATTRIBUTE} is {@code true}, the
* balancer reads a {@code serviceId -> instanceId} affinity map from the current
* {@link org.springframework.web.server.WebSession} and re-uses the previously selected
* instance if it is still available. If no affinity is recorded, or the recorded instance
* has been deregistered, a fresh instance is selected via the delegate (round-robin by
* default) and the new affinity is written back to the session.
*
* <p>
* When the {@code IS_STICKY_ATTRIBUTE} is absent or {@code false}, or when the request
* context is not a {@link ServerWebExchange} (e.g. an internal {@code WebClient} call),
* this balancer transparently delegates to the round-robin balancer so that non-sticky
* routes are unaffected.
*
* @author Beteab Gebru
* @since 5.0.4
* @see WebSessionStickyLoadBalancerFilter
*/
public class WebSessionStickyLoadBalancer implements ReactorServiceInstanceLoadBalancer {

/**
* Logger for this class.
*/
private static final Log LOG = LogFactory.getLog(WebSessionStickyLoadBalancer.class);

/**
* Session attribute key under which the {@code serviceId -> instanceId} affinity map
* is stored.
*/
public static final String STICKY_MAP_SESSION_ATTR = "spring.cloud.gateway.sticky.service-map";

private final String serviceId;

private final ReactorServiceInstanceLoadBalancer delegate;

private final ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider;

/**
* Creates a {@code WebSessionStickyLoadBalancer} backed by a
* {@link RoundRobinLoadBalancer} delegate.
* @param supplierProvider provider of available service instances
* @param serviceId the service identifier used to key session affinity
*/
public WebSessionStickyLoadBalancer(ObjectProvider<ServiceInstanceListSupplier> supplierProvider,
String serviceId) {
this(supplierProvider, serviceId, new RoundRobinLoadBalancer(supplierProvider, serviceId));
}

/**
* Creates a {@code WebSessionStickyLoadBalancer} with a custom delegate.
* @param supplierProvider provider of available service instances
* @param serviceId the service identifier used to key session affinity
* @param delegate the fallback balancer used when stickiness does not apply
*/
public WebSessionStickyLoadBalancer(ObjectProvider<ServiceInstanceListSupplier> supplierProvider, String serviceId,
ReactorServiceInstanceLoadBalancer delegate) {
this.serviceId = serviceId;
this.serviceInstanceListSupplierProvider = supplierProvider;
this.delegate = delegate;
}

@Override
@SuppressWarnings("rawtypes")
public Mono<Response<ServiceInstance>> choose(final Request request) {
ServiceInstanceListSupplier supplier = serviceInstanceListSupplierProvider
.getIfAvailable(NoopServiceInstanceListSupplier::new);
Object context = request.getContext();

// If the context is not a ServerWebExchange we have no session to pin
// against — this happens for internal WebClient calls that originate
// within the gateway itself rather than from a routed inbound request.
if (!(context instanceof ServerWebExchange)) {
return delegate.choose(request);
}

ServerWebExchange exchange = (ServerWebExchange) context;
return supplier.get().next().flatMap(instances -> getInstanceResponse(instances, exchange, request));
}

@SuppressWarnings("rawtypes")
private Mono<Response<ServiceInstance>> getInstanceResponse(final List<ServiceInstance> instances,
final ServerWebExchange exchange, final Request request) {
if (instances.isEmpty()) {
LOG.warn("No servers available for service: " + this.serviceId);
return Mono.just(new EmptyResponse());
}

Boolean isSticky = exchange.getAttributeOrDefault(WebSessionStickyLoadBalancerFilter.IS_STICKY_ATTRIBUTE,
Boolean.FALSE);
if (!isSticky) {
return delegate.choose(request);
}

return serviceInstanceFromSession(exchange, instances).flatMap(instance -> {
Response<ServiceInstance> response = new DefaultResponse(instance);
return writeSessionAffinity(exchange, response);
})
.switchIfEmpty(Mono
.defer(() -> delegate.choose(request).flatMap(response -> writeSessionAffinity(exchange, response))));
}

private Mono<ServiceInstance> serviceInstanceFromSession(final ServerWebExchange exchange,
final List<ServiceInstance> instances) {
return exchange.getSession().flatMap(session -> {
@SuppressWarnings("unchecked")
Map<String, String> stickyMap = (Map<String, String>) session.getAttribute(STICKY_MAP_SESSION_ATTR);
if (stickyMap == null || !stickyMap.containsKey(serviceId)) {
LOG.debug("No existing session affinity for service '" + serviceId + "', selecting new instance");
return Mono.empty();
}
String pinnedInstanceId = stickyMap.get(serviceId);
Optional<ServiceInstance> match = instances.stream()
.filter(i -> Objects.equals(i.getInstanceId(), pinnedInstanceId))
.findFirst();
if (!match.isPresent()) {
LOG.debug("Pinned instance '" + pinnedInstanceId + "' for service '" + serviceId
+ "' is no longer available, selecting new instance");
return Mono.empty();
}
LOG.debug("Reusing pinned instance '" + pinnedInstanceId + "' for service '" + serviceId + "'");
return Mono.just(match.get());
});
}

@SuppressWarnings("unchecked")
private Mono<Response<ServiceInstance>> writeSessionAffinity(final ServerWebExchange exchange,
final Response<ServiceInstance> response) {
if (!response.hasServer()) {
return Mono.just(response);
}
return exchange.getSession().map(session -> {
Map<String, String> stickyMap = (Map<String, String>) session.getAttribute(STICKY_MAP_SESSION_ATTR);
if (stickyMap == null) {
stickyMap = new HashMap<>();
session.getAttributes().put(STICKY_MAP_SESSION_ATTR, stickyMap);
}
stickyMap.put(serviceId, response.getServer().getInstanceId());
return response;
});
}

}
Loading