Pular para conteúdo

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/.dockerignore
target/
.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.java
package 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.

4. Headers de identidade

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
catalog
curl -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.