c. Gateway
O gateway é a porta de entrada da API: toda requisição que passa pelo edge atravessa ele. Ele é construído com o Spring Cloud Gateway, que roda sobre Spring WebFlux e Netty — não bloqueante, então poucas threads fazem o proxy de milhares de requisições concorrentes — na porta 8080.
| Responsabilidade | Como |
| Roteamento | /auth/** → auth; /catalog/** → lb://catalog |
| Balanceamento de carga entre instâncias dos serviços | Spring Cloud LoadBalancer entre catalog-1..3, pulando as que não estão saudáveis |
| Autenticação | valida o JWT: assinatura (JWKS), exp, iss, aud |
| Autorização (grossa) | aberta / qualquer token / scope admin, por caminho e método |
| Propagação de identidade | X-User-Id, X-User-Email, X-User-Scope para os serviços |
| Timeouts e retries | conexão 1 s, resposta 5 s; um retry para GETs idempotentes |
1. Projeto
Crie services/gateway (mesmo Dockerfile com EXPOSE 8080, mesmo .dockerignore).
services/gateway/Dockerfile e .dockerignore (iguais aos do catalog, só muda o EXPOSE 8080)
| services/gateway/Dockerfile |
|---|
| # syntax=docker/dockerfile:1
# ---- build stage: compile with Maven inside a JDK image -------------------
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
COPY src ./src
# the cache mount keeps ~/.m2 between builds: dependencies are downloaded once
RUN --mount=type=cache,target=/root/.m2 mvn -B -q package -DskipTests
# ---- runtime stage: only a JRE and the jar --------------------------------
FROM eclipse-temurin:21-jre
WORKDIR /app
RUN useradd --system --uid 10001 app
COPY --from=build /src/target/app.jar app.jar
USER app
EXPOSE 8080
# use up to 75% of the container's memory limit for the heap
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
|
services/gateway/.dockerignoretarget/
.idea/
.vscode/
*.iml
| services/gateway/pom.xml |
|---|
| <?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>boxoffice</groupId>
<artifactId>gateway</artifactId>
<version>1.0.0</version>
<name>gateway</name>
<description>BoxOffice API gateway: routing, JWT validation, identity headers</description>
<properties>
<java.version>21</java.version>
<spring-cloud.version>2025.1.3</spring-cloud.version>
</properties>
<dependencies>
<!-- Spring Cloud Gateway on WebFlux (non-blocking, Netty) -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
</dependency>
<!-- client-side load balancing across catalog-1..3 (lb://catalog) -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<!-- WebClient.Builder: the load balancer's health checks use it (Boot 4 moved it to its own starter) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webclient</artifactId>
</dependency>
<!-- validates JWTs: signature (JWKS), expiry, issuer, audience -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<finalName>app</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
|
services/gateway/src/main/java/boxoffice/gateway/GatewayApplication.javapackage boxoffice.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}
2. Rotas e balanceamento de carga
| services/gateway/src/main/resources/application.yaml |
|---|
| server:
port: 8080
spring:
application:
name: gateway
cloud:
gateway:
server:
webflux:
httpclient:
connect-timeout: 1000 # ms to open a connection to a service
response-timeout: 5s # never wait forever for a service
default-filters:
# retry idempotent GETs once on another instance when a connection fails
- name: Retry
args:
retries: 1
methods: GET
series: SERVER_ERROR
exceptions: java.io.IOException, java.util.concurrent.TimeoutException
routes:
- id: auth
uri: ${AUTH_URL:http://auth:8081}
predicates:
- Path=/auth/**
- id: catalog
uri: lb://catalog # "lb": pick one instance of "catalog" per request
predicates:
- Path=/catalog/**
# the instances behind lb://catalog (a static list; Kubernetes replaces it in lab 10)
discovery:
client:
simple:
instances:
catalog:
- uri: http://catalog-1:8082
- uri: http://catalog-2:8082
- uri: http://catalog-3:8082
loadbalancer:
configurations: health-check # skip instances that are not ready
health-check:
interval: 5s
path:
default: /actuator/health/readiness # this instance, not db/redis that every replica shares
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: ${AUTH_JWKS_URI:http://auth:8081/auth/.well-known/jwks.json}
jwt:
issuer: ${JWT_ISSUER:boxoffice-auth}
audience: boxoffice
management:
endpoints:
web:
exposure:
include: health, info, metrics, gateway
endpoint:
health:
probes:
enabled: true
gateway:
access: read-only # GET /actuator/gateway/routes lists the routes
|
- Uma rota = id + predicados (quando ela casa) + URI (para onde vai) + filtros.
uri: lb://catalog pede ao Spring Cloud LoadBalancer uma instância do catalog a cada requisição (round robin). A lista de instâncias aqui é estática (discovery.client.simple); com o Kubernetes no lab 10, um Service assume esse papel. configurations: health-check faz o balanceador consultar o /actuator/health/readiness de cada instância a cada 5 s e pular as que não estão UP — um health check ativo, o que o Nginx open source não conseguia fazer no lab 3. Ele usa um WebClient para a sondagem, que no Spring Boot 4 fica em um starter próprio: é por isso que o pom tem spring-boot-starter-webclient. Até a primeira rodada de sondagens terminar (alguns segundos depois da inicialização), o balanceador não conhece nenhuma instância saudável e o gateway responde 503. Ele sonda a readiness, não o /actuator/health: a saúde completa inclui o db e o redis, que todas as réplicas compartilham, então uma única dependência parada marcaria as três como down de uma vez e o gateway responderia 503 para tudo — até para requisições que o cache, ou um fallback, poderiam atender. Retry reenvia uma vez um GET que falhou (para outra instância); nunca POSTs, que podem não ser idempotentes.
Dois load balancers?
Sim, de propósito: o edge balanceia entre réplicas do gateway (acrescente gateway-2 ao upstream do Nginx quando um gateway não bastar), e o gateway balanceia entre instâncias dos serviços. Isso espelha a nuvem: um load balancer L7 público na frente de uma frota de API gateways, que chamam os serviços por balanceamento de carga interno.
3. Segurança: quem pode chamar o quê
| services/gateway/src/main/java/boxoffice/gateway/SecurityConfig.java |
|---|
| package boxoffice.gateway;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
import org.springframework.security.web.server.SecurityWebFilterChain;
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
/** Which routes are open, which need a valid token, which need the admin scope. */
@Bean
SecurityWebFilterChain security(ServerHttpSecurity http) {
return http
.csrf(ServerHttpSecurity.CsrfSpec::disable) // stateless API, no cookies
.authorizeExchange(ex -> ex
// open: getting a token, and the public keys to check one
.pathMatchers("/auth/register", "/auth/login", "/auth/.well-known/**").permitAll()
// open: browsing the catalog (HEAD is a GET without the body)
.pathMatchers(HttpMethod.GET, "/catalog/**").permitAll()
.pathMatchers(HttpMethod.HEAD, "/catalog/**").permitAll()
// admin only: changing the catalog (scope claim "user admin")
.pathMatchers("/catalog/**").hasAuthority("SCOPE_admin")
// health, metrics, routes: the edge never forwards /actuator from outside
.pathMatchers("/actuator/**").permitAll()
// everything else (and every future route): any valid token
.anyExchange().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
.build();
}
/** Signature from the JWKS of auth, plus expiry, issuer and audience checks. */
@Bean
ReactiveJwtDecoder jwtDecoder(@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwksUri,
@Value("${jwt.issuer}") String issuer,
@Value("${jwt.audience}") String audience) {
var decoder = NimbusReactiveJwtDecoder.withJwkSetUri(jwksUri).build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer), // exp, nbf, iss
new JwtClaimValidator<List<String>>("aud", aud -> aud != null && aud.contains(audience))));
return decoder;
}
}
|
- As regras são avaliadas em ordem; a primeira que casa vence.
anyExchange().authenticated() é o padrão seguro: uma rota nova acrescentada no mês que vem fica protegida, a menos que alguém a abra explicitamente. oauth2ResourceServer().jwt() transforma o gateway em um resource server OAuth 2.0: ele lê Authorization: Bearer …, valida o token com o decoder abaixo e mapeia a claim scope para authorities (SCOPE_user, SCOPE_admin). - O decoder baixa o JWKS do
auth uma vez e o guarda em cache; só busca de novo quando chega um token com um kid desconhecido. É por isso que a validação é local e barata. - Um token ausente ou inválido recebe
401 Unauthorized (com um header WWW-Authenticate); um token válido sem o scope necessário recebe 403 Forbidden.
| services/gateway/src/main/java/boxoffice/gateway/UserHeadersFilter.java |
|---|
| package boxoffice.gateway;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* Tells the services who the user is: X-User-Id, X-User-Email, X-User-Scope.
* Headers with these names coming from the client are always removed first,
* otherwise anyone could claim to be anyone.
*/
@Component
public class UserHeadersFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
var cleaned = exchange.mutate()
.request(r -> r.headers(h -> {
h.remove("X-User-Id");
h.remove("X-User-Email");
h.remove("X-User-Scope");
}))
.build();
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.filter(JwtAuthenticationToken.class::isInstance)
.cast(JwtAuthenticationToken.class)
.map(auth -> cleaned.mutate()
.request(r -> r.headers(h -> {
var jwt = auth.getToken();
h.set("X-User-Id", jwt.getSubject());
h.set("X-User-Email", jwt.getClaimAsString("email"));
h.set("X-User-Scope", jwt.getClaimAsString("scope"));
}))
.build())
.defaultIfEmpty(cleaned) // anonymous request on an open route
.flatMap(chain::filter);
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
}
|
Um GlobalFilter roda em toda rota. Primeiro ele remove qualquer header X-User-* enviado pelo cliente e então, se a requisição traz um JWT validado, acrescenta a identidade tirada do token. Os serviços leem um header simples em vez de interpretar tokens — simples, mas só vale enquanto ninguém conseguir alcançar os serviços sem passar pelo gateway.
5. O novo edge
Agora o edge encaminha tudo para o gateway; o roteamento por caminho foi um salto para dentro. Ele também esconde o /actuator/ da internet.
| infra/nginx/nginx.conf |
|---|
| # BoxOffice edge (lab 4): everything goes to the gateway, which routes to the services
user nginx;
worker_processes auto;
events {
worker_connections 4096;
}
http {
# one line per request: who served it, and how long the upstream took
log_format edge '$remote_addr "$request" $status rt=$request_time '
'upstream=$upstream_addr ut=$upstream_response_time id=$request_id';
access_log /var/log/nginx/access.log edge;
# Docker's embedded DNS: lets nginx start even if a replica is down, and
# notice replicas that come back (the "resolve" flag below, nginx >= 1.27.3)
resolver 127.0.0.11 valid=10s ipv6=off;
upstream gateway {
zone gateway 64k; # shared memory, required by "resolve"
server gateway:8080 resolve max_fails=3 fail_timeout=10s;
# more gateway replicas go here: gateway-2:8080, ...
keepalive 64;
}
# rate limiting per API key: 20 req/s per key, bursts of up to 40.
# Requests WITHOUT an X-Api-Key header have an empty key and are not limited
# (in production you would also limit them per client IP).
limit_req_zone $http_x_api_key zone=per_key:10m rate=20r/s;
limit_req_status 429;
server {
listen 80;
# the edge's own liveness: does not touch any backend
location = /healthz {
access_log off;
return 200 "ok\n";
}
# internal endpoints are never public
location /actuator/ {
return 404;
}
location / {
limit_req zone=per_key burst=40 nodelay;
proxy_pass http://gateway;
proxy_http_version 1.1;
proxy_set_header Connection ""; # keep upstream connections alive
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Request-Id $request_id;
add_header X-Request-Id $request_id always;
# passive health checking: a failed attempt is retried on another replica
proxy_connect_timeout 1s;
proxy_read_timeout 5s;
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2;
}
}
}
|
6. Compose
| docker-compose.yaml |
|---|
| name: boxoffice
# settings shared by the three catalog replicas (a YAML anchor)
x-catalog: &catalog
build: ./services/catalog
image: boxoffice-catalog
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
DB_POOL_SIZE: ${DB_POOL_SIZE:-10}
depends_on:
db:
condition: service_healthy
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8082/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 40s
retries: 3
services:
db:
image: postgres:17
environment:
POSTGRES_DB: ${DB_NAME:-boxoffice}
POSTGRES_USER: ${DB_USER:-boxoffice}
POSTGRES_PASSWORD: ${DB_PASSWORD:-boxoffice}
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
# three identical replicas; no "ports:" — only the edge is reachable from outside
catalog-1:
<<: *catalog
hostname: catalog-1
catalog-2:
<<: *catalog
hostname: catalog-2
catalog-3:
<<: *catalog
hostname: catalog-3
auth:
build: ./services/auth
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-boxoffice}
DB_USER: ${DB_USER:-boxoffice}
DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
JWT_ISSUER: ${JWT_ISSUER:-boxoffice-auth}
JWT_TTL: ${JWT_TTL:-PT1H}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@boxoffice.dev}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-boxoffice-admin}
depends_on:
db:
condition: service_healthy
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 40s
retries: 3
gateway:
build: ./services/gateway
environment:
AUTH_URL: http://auth:8081
AUTH_JWKS_URI: http://auth:8081/auth/.well-known/jwks.json
JWT_ISSUER: ${JWT_ISSUER:-boxoffice-auth}
ports:
- "8080:8080" # published for experiments; clients use the edge (port 80)
depends_on:
- auth
- catalog-1
- catalog-2
- catalog-3
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
start_period: 40s
retries: 3
edge:
image: nginx:1.30-alpine
ports:
- "80:80"
volumes:
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- gateway
volumes:
db-data:
|
O gateway publica a porta 8080 só para que você possa medi-lo diretamente; em produção, só o edge ficaria exposto.
docker compose up --build -dcurl -s localhost:8080/actuator/gateway/routes | jq -r '.[].route_id'auth
catalogcurl -s -o /dev/null -w "%{http_code}\n" localhost/catalog/events/1200curl -s -o /dev/null -w "%{http_code}\n" localhost/actuator/health404
Armadilhas
401 em toda rota protegida logo depois da inicialização: o gateway busca o JWKS sob demanda; se o auth estava fora do ar naquele momento, tente de novo — e confira o AUTH_JWKS_URI. 401 depois de reiniciar o auth: os tokens antigos foram assinados com a chave antiga. Faça login de novo. - Diferença de relógio:
exp/iat são comparados com o relógio do gateway; os contêineres compartilham o relógio do host, mas servidores em produção precisam de NTP (aula 3). - CORS: um front-end no navegador em outra origem precisa de CORS configurado no gateway (
spring.cloud.gateway.server.webflux.globalcors) — desnecessário para o k6 ou o curl.
Volte à visão geral do lab para testar e medir.