Netflix Zuul

Netflixが開発したJavaベースのAPI Gateway。動的ルーティング、モニタリング、レジリエンス、セキュリティ機能を提供。Spring Cloudとの統合。

API GatewayJavaSpring CloudNetflix OSSマイクロサービス動的ルーティングフィルターRibbon

概要

Netflix Zuulは、Netflix社によって開発されたJavaベースのAPI Gatewayサービスです。動的ルーティング、モニタリング、レジリエンス、セキュリティ機能を提供し、マイクロサービスアーキテクチャにおける重要なコンポーネントとして設計されています。

Zuul 1.xは同期・ブロッキングI/Oアーキテクチャを使用していましたが、Zuul 2.xでは非同期・ノンブロッキングI/O(Nettyベース)を採用し、高いスループットと低レイテンシを実現しています。Spring Cloudとの密接な統合により、Spring Bootエコシステムでの標準的なAPI Gateway選択肢となっています。

主要な特徴

  • 動的ルーティング: 実行時でのルーティングルール変更
  • フィルターベースアーキテクチャ: プラガブルなフィルターシステム
  • Netflix OSS統合: Eureka、Ribbon、Hystrixとの統合
  • Spring Cloud統合: Spring Bootアプリケーションとしての簡単な統合
  • 負荷分散: Ribbonクライアントサイドロードバランシング

主要機能

コア機能

  • プロキシとルーティング: リクエストの転送と負荷分散
  • フィルターチェーン: リクエスト前後での処理カスタマイズ
  • レジリエンス: Hystrixによるサーキットブレーカー機能
  • サービスディスカバリ: Eurekaとの統合による自動サービス発見
  • レート制限: トラフィック制御とスロットリング

フィルタータイプ

  • PRE Filters: リクエストルーティング前の処理
  • ROUTING Filters: リクエストのルーティング処理
  • POST Filters: レスポンス返却前の処理
  • ERROR Filters: エラー処理とエラーレスポンス生成
  • STATIC Filters: 静的レスポンス返却

インストール・セットアップ

Spring Boot依存関係の追加

Maven設定

<!-- pom.xml -->
<properties>
    <spring-cloud.version>2022.0.4</spring-cloud.version>
</properties>

<dependencies>
    <!-- Spring Boot Starter Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- Spring Cloud Zuul -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-zuul</artifactId>
    </dependency>
    
    <!-- Eureka Client -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>
    
    <!-- Hystrix -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
    </dependency>
    
    <!-- Actuator -->
    <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>

Gradle設定

// build.gradle
plugins {
    id 'org.springframework.boot' version '2.7.14'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
    id 'java'
}

ext {
    set('springCloudVersion', "2021.0.8")
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.cloud:spring-cloud-starter-netflix-zuul'
    implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
    implementation 'org.springframework.cloud:spring-cloud-starter-netflix-hystrix'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
    }
}

基本的なアプリケーション設定

メインアプリケーションクラス

// ZuulGatewayApplication.java
package com.example.zuulgateway;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;

@SpringBootApplication
@EnableZuulProxy
@EnableDiscoveryClient
public class ZuulGatewayApplication {
    
    public static void main(String[] args) {
        SpringApplication.run(ZuulGatewayApplication.class, args);
    }
}

基本設定ファイル

# application.yml
server:
  port: 8080

spring:
  application:
    name: zuul-gateway

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/
    register-with-eureka: true
    fetch-registry: true
  instance:
    hostname: localhost
    prefer-ip-address: true

zuul:
  prefix: /api
  routes:
    user-service:
      path: /users/**
      service-id: user-service
      strip-prefix: false
    product-service:
      path: /products/**
      service-id: product-service
      strip-prefix: false

hystrix:
  command:
    default:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 10000

ribbon:
  ConnectTimeout: 3000
  ReadTimeout: 10000
  MaxAutoRetries: 1
  MaxAutoRetriesNextServer: 1

management:
  endpoints:
    web:
      exposure:
        include: health,info,routes,filters
  endpoint:
    health:
      show-details: always

Docker設定

# Dockerfile
FROM openjdk:11-jre-slim

VOLUME /tmp

EXPOSE 8080

ARG JAR_FILE=target/zuul-gateway-1.0.0.jar
COPY ${JAR_FILE} app.jar

ENTRYPOINT ["java", "-jar", "/app.jar"]

基本的な使い方

ルーティング設定

設定ベースルーティング

# application.yml
zuul:
  prefix: /api/v1
  routes:
    # サービス名による自動ルーティング
    user-service:
      path: /users/**
      service-id: user-service
      strip-prefix: false
      
    # URL指定による直接ルーティング
    legacy-service:
      path: /legacy/**
      url: http://legacy.example.com:8080
      strip-prefix: true
      
    # 正規表現によるパターンマッチング
    api-docs:
      path: /docs/**
      url: forward:/swagger-ui.html
      strip-prefix: true

  # ルートの無効化
  ignored-patterns:
    - /admin/**
    - /internal/**
    
  # サービス全体の無効化
  ignored-services:
    - sensitive-service
    
  # ヘッダーフィルタリング
  sensitive-headers: Cookie,Set-Cookie,Authorization

Javaによる動的ルーティング

// RouteConfiguration.java
@Configuration
public class RouteConfiguration {
    
    @Bean
    public PatternServiceRouteMapper serviceRouteMapper() {
        return new PatternServiceRouteMapper(
            "(?<name>^.+)-(?<version>v.+$)",
            "${version}/${name}"
        );
    }
}

// DynamicRouteLocator.java
@Component
public class DynamicRouteLocator extends SimpleRouteLocator {
    
    @Autowired
    private ZuulProperties properties;
    
    @Autowired
    private DiscoveryClient discoveryClient;
    
    public DynamicRouteLocator(String servletPath, ZuulProperties properties) {
        super(servletPath, properties);
        this.properties = properties;
    }
    
    @Override
    protected Map<String, ZuulRoute> locateRoutes() {
        LinkedHashMap<String, ZuulRoute> routesMap = new LinkedHashMap<>();
        
        // 設定ファイルからのルート追加
        routesMap.putAll(super.locateRoutes());
        
        // 動的ルート追加
        List<String> services = discoveryClient.getServices();
        for (String serviceId : services) {
            ZuulRoute route = new ZuulRoute();
            route.setId(serviceId);
            route.setPath("/" + serviceId + "/**");
            route.setServiceId(serviceId);
            route.setRetryable(true);
            route.setStripPrefix(true);
            routesMap.put(route.getPath(), route);
        }
        
        return routesMap;
    }
}

カスタムフィルターの実装

Pre Filter例

// AuthenticationFilter.java
@Component
public class AuthenticationFilter extends ZuulFilter {
    
    private static final Logger logger = LoggerFactory.getLogger(AuthenticationFilter.class);
    
    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }
    
    @Override
    public int filterOrder() {
        return FilterConstants.PRE_DECORATION_FILTER_ORDER - 1;
    }
    
    @Override
    public boolean shouldFilter() {
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        
        // 認証が必要なパスかチェック
        String path = request.getRequestURI();
        return !path.startsWith("/public/") && !path.startsWith("/health");
    }
    
    @Override
    public Object run() throws ZuulException {
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        
        String authHeader = request.getHeader("Authorization");
        
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            logger.warn("Missing or invalid Authorization header");
            context.setSendZuulResponse(false);
            context.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
            context.setResponseBody("Unauthorized: Missing or invalid token");
            return null;
        }
        
        try {
            String token = authHeader.substring(7);
            if (!validateToken(token)) {
                throw new ZuulException("Invalid token", HttpStatus.UNAUTHORIZED.value(), "UNAUTHORIZED");
            }
            
            // ユーザー情報をヘッダーに追加
            context.addZuulRequestHeader("X-User-ID", extractUserId(token));
            context.addZuulRequestHeader("X-User-Role", extractUserRole(token));
            
        } catch (Exception e) {
            logger.error("Authentication error", e);
            throw new ZuulException("Authentication failed", HttpStatus.UNAUTHORIZED.value(), "UNAUTHORIZED");
        }
        
        return null;
    }
    
    private boolean validateToken(String token) {
        // JWT トークンの検証ロジック
        return true; // 実装例
    }
    
    private String extractUserId(String token) {
        // トークンからユーザーID抽出
        return "user123"; // 実装例
    }
    
    private String extractUserRole(String token) {
        // トークンからロール抽出
        return "USER"; // 実装例
    }
}

Route Filter例

// LoadBalancingFilter.java
@Component
public class LoadBalancingFilter extends ZuulFilter {
    
    @Autowired
    private LoadBalancerClient loadBalancer;
    
    @Override
    public String filterType() {
        return FilterConstants.ROUTE_TYPE;
    }
    
    @Override
    public int filterOrder() {
        return FilterConstants.SIMPLE_HOST_ROUTING_FILTER_ORDER - 1;
    }
    
    @Override
    public boolean shouldFilter() {
        RequestContext context = RequestContext.getCurrentContext();
        return context.getRouteHost() == null && context.get(SERVICE_ID_KEY) != null;
    }
    
    @Override
    public Object run() throws ZuulException {
        RequestContext context = RequestContext.getCurrentContext();
        String serviceId = (String) context.get(SERVICE_ID_KEY);
        
        ServiceInstance instance = loadBalancer.choose(serviceId);
        if (instance == null) {
            throw new ZuulException("No available instances for service: " + serviceId, 
                                  HttpStatus.SERVICE_UNAVAILABLE.value(), 
                                  "SERVICE_UNAVAILABLE");
        }
        
        context.setRouteHost(instance.getUri().toURL());
        context.addOriginResponseHeader(SERVICE_HEADER, instance.getServiceId());
        
        return null;
    }
}

Post Filter例

// ResponseEnhancementFilter.java
@Component
public class ResponseEnhancementFilter extends ZuulFilter {
    
    @Override
    public String filterType() {
        return FilterConstants.POST_TYPE;
    }
    
    @Override
    public int filterOrder() {
        return FilterConstants.SEND_RESPONSE_FILTER_ORDER - 1;
    }
    
    @Override
    public boolean shouldFilter() {
        return true;
    }
    
    @Override
    public Object run() throws ZuulException {
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletResponse response = context.getResponse();
        
        // レスポンスヘッダーの追加
        response.addHeader("X-Powered-By", "Zuul Gateway");
        response.addHeader("X-Request-ID", UUID.randomUUID().toString());
        response.addHeader("X-Response-Time", String.valueOf(System.currentTimeMillis()));
        
        // CORS ヘッダーの追加
        response.addHeader("Access-Control-Allow-Origin", "*");
        response.addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
        response.addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
        
        return null;
    }
}

設定例

高度なルーティング設定

# application.yml
zuul:
  # グローバル設定
  prefix: /api
  strip-prefix: true
  add-proxy-headers: true
  add-host-header: true
  
  # ルート設定
  routes:
    # マイクロサービス別ルーティング
    user-service:
      path: /users/**
      service-id: user-service
      custom-sensitive-headers: true
      
    # バージョン管理
    product-v1:
      path: /v1/products/**
      service-id: product-service-v1
      strip-prefix: false
      
    product-v2:
      path: /v2/products/**
      service-id: product-service-v2
      strip-prefix: false
      
    # 外部サービス統合
    payment-gateway:
      path: /payments/**
      url: https://payment.external.com/api
      strip-prefix: true
      custom-sensitive-headers: true
      
    # WebSocket支援
    websocket-service:
      path: /ws/**
      service-id: websocket-service
      strip-prefix: false
      
  # パフォーマンス設定
  host:
    socket-timeout-millis: 10000
    connect-timeout-millis: 5000
    
  # セキュリティ設定
  sensitive-headers: Cookie,Set-Cookie
  ignored-headers: X-Internal-Secret
  
  # リトライ設定
  retryable: true

Ribbon負荷分散設定

# Ribbon設定
ribbon:
  # グローバル設定
  ConnectTimeout: 3000
  ReadTimeout: 10000
  MaxAutoRetries: 1
  MaxAutoRetriesNextServer: 2
  MaxTotalHttpConnections: 200
  MaxConnectionsPerHost: 50
  
# サービス別設定
user-service:
  ribbon:
    NFLoadBalancerRuleClassName: com.netflix.loadbalancer.WeightedResponseTimeRule
    NIWSServerListClassName: com.netflix.loadbalancer.ConfigurationBasedServerList
    listOfServers: user1.example.com:8080,user2.example.com:8080,user3.example.com:8080
    ConnectTimeout: 1000
    ReadTimeout: 3000

product-service:
  ribbon:
    NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RoundRobinRule
    NIWSServerListClassName: com.netflix.niws.loadbalancer.DiscoveryEnabledNIWSServerList
    DeploymentContextBasedVipAddresses: product-service

Hystrix設定

# Hystrix設定
hystrix:
  command:
    # デフォルト設定
    default:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 10000
        timeout:
          enabled: true
      circuitBreaker:
        enabled: true
        requestVolumeThreshold: 20
        sleepWindowInMilliseconds: 5000
        errorThresholdPercentage: 50
      metrics:
        rollingStats:
          timeInMilliseconds: 10000
          
    # サービス別設定
    user-service:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 5000
      circuitBreaker:
        requestVolumeThreshold: 10
        errorThresholdPercentage: 30
        
  threadpool:
    default:
      coreSize: 10
      maximumSize: 50
      allowMaximumSizeToDivergeFromCoreSize: true
      maxQueueSize: 100
      queueSizeRejectionThreshold: 80

認証・セキュリティ

JWT認証の実装

JWT認証フィルター

// JwtAuthenticationFilter.java
@Component
public class JwtAuthenticationFilter extends ZuulFilter {
    
    @Value("${jwt.secret}")
    private String jwtSecret;
    
    @Value("${jwt.issuer}")
    private String jwtIssuer;
    
    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }
    
    @Override
    public int filterOrder() {
        return 1;
    }
    
    @Override
    public boolean shouldFilter() {
        RequestContext context = RequestContext.getCurrentContext();
        String path = context.getRequest().getRequestURI();
        
        // 認証不要のパスを除外
        return !path.matches("/public/.*|/health|/info|/swagger-.*");
    }
    
    @Override
    public Object run() throws ZuulException {
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        
        try {
            String token = extractToken(request);
            Claims claims = validateJwtToken(token);
            
            // ユーザー情報をコンテキストに追加
            context.addZuulRequestHeader("X-User-ID", claims.getSubject());
            context.addZuulRequestHeader("X-User-Roles", (String) claims.get("roles"));
            context.addZuulRequestHeader("X-Tenant-ID", (String) claims.get("tenantId"));
            
        } catch (Exception e) {
            handleAuthenticationError(context, e);
        }
        
        return null;
    }
    
    private String extractToken(HttpServletRequest request) throws ZuulException {
        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            throw new ZuulException("Missing Authorization header", 401, "UNAUTHORIZED");
        }
        return authHeader.substring(7);
    }
    
    private Claims validateJwtToken(String token) throws ZuulException {
        try {
            return Jwts.parser()
                    .setSigningKey(jwtSecret.getBytes())
                    .parseClaimsJws(token)
                    .getBody();
        } catch (Exception e) {
            throw new ZuulException("Invalid JWT token", 401, "UNAUTHORIZED");
        }
    }
    
    private void handleAuthenticationError(RequestContext context, Exception e) {
        context.setSendZuulResponse(false);
        context.setResponseStatusCode(401);
        context.setResponseBody("{\"error\":\"Unauthorized\",\"message\":\"" + e.getMessage() + "\"}");
        context.getResponse().setContentType("application/json");
    }
}

OAuth 2.0統合

// OAuth2Configuration.java
@Configuration
@EnableResourceServer
public class OAuth2Configuration extends ResourceServerConfigurerAdapter {
    
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
            .antMatchers("/public/**", "/health", "/info").permitAll()
            .antMatchers(HttpMethod.GET, "/api/products/**").hasAuthority("SCOPE_read")
            .antMatchers(HttpMethod.POST, "/api/products/**").hasAuthority("SCOPE_write")
            .anyRequest().authenticated()
            .and()
            .cors()
            .and()
            .csrf().disable();
    }
    
    @Bean
    public RemoteTokenServices tokenService() {
        RemoteTokenServices tokenService = new RemoteTokenServices();
        tokenService.setCheckTokenEndpointUrl("http://auth-server:8080/oauth/check_token");
        tokenService.setClientId("zuul-gateway");
        tokenService.setClientSecret("gateway-secret");
        return tokenService;
    }
}

API Key認証

// ApiKeyAuthenticationFilter.java
@Component
public class ApiKeyAuthenticationFilter extends ZuulFilter {
    
    @Autowired
    private ApiKeyService apiKeyService;
    
    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }
    
    @Override
    public int filterOrder() {
        return 2;
    }
    
    @Override
    public boolean shouldFilter() {
        RequestContext context = RequestContext.getCurrentContext();
        String path = context.getRequest().getRequestURI();
        return path.startsWith("/api/public/");
    }
    
    @Override
    public Object run() throws ZuulException {
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        
        String apiKey = request.getHeader("X-API-Key");
        if (apiKey == null) {
            apiKey = request.getParameter("api_key");
        }
        
        if (apiKey == null || !apiKeyService.isValidApiKey(apiKey)) {
            context.setSendZuulResponse(false);
            context.setResponseStatusCode(403);
            context.setResponseBody("{\"error\":\"Invalid API Key\"}");
            context.getResponse().setContentType("application/json");
            return null;
        }
        
        // API Key情報をヘッダーに追加
        ApiKeyInfo keyInfo = apiKeyService.getApiKeyInfo(apiKey);
        context.addZuulRequestHeader("X-Client-ID", keyInfo.getClientId());
        context.addZuulRequestHeader("X-Rate-Limit", String.valueOf(keyInfo.getRateLimit()));
        
        return null;
    }
}

// ApiKeyService.java
@Service
public class ApiKeyService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    public boolean isValidApiKey(String apiKey) {
        return redisTemplate.hasKey("api_key:" + apiKey);
    }
    
    public ApiKeyInfo getApiKeyInfo(String apiKey) {
        return (ApiKeyInfo) redisTemplate.opsForValue().get("api_key:" + apiKey);
    }
}

レート制限・トラフィック管理

Redis基盤のレート制限

// RateLimitingFilter.java
@Component
public class RateLimitingFilter extends ZuulFilter {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Value("${rate.limit.requests.per.minute:100}")
    private int requestsPerMinute;
    
    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }
    
    @Override
    public int filterOrder() {
        return 3;
    }
    
    @Override
    public boolean shouldFilter() {
        return true;
    }
    
    @Override
    public Object run() throws ZuulException {
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        
        String clientId = getClientIdentifier(request);
        String rateLimitKey = "rate_limit:" + clientId + ":" + getCurrentMinute();
        
        try {
            Long currentCount = redisTemplate.opsForValue().increment(rateLimitKey);
            if (currentCount == 1) {
                redisTemplate.expire(rateLimitKey, 60, TimeUnit.SECONDS);
            }
            
            // レート制限チェック
            if (currentCount > requestsPerMinute) {
                handleRateLimitExceeded(context, currentCount);
                return null;
            }
            
            // レート制限情報をヘッダーに追加
            context.addZuulResponseHeader("X-RateLimit-Limit", String.valueOf(requestsPerMinute));
            context.addZuulResponseHeader("X-RateLimit-Remaining", 
                String.valueOf(Math.max(0, requestsPerMinute - currentCount)));
            context.addZuulResponseHeader("X-RateLimit-Reset", String.valueOf(getNextMinute()));
            
        } catch (Exception e) {
            // Redis接続エラー時はレート制限をスキップ
            logger.warn("Rate limiting error: " + e.getMessage());
        }
        
        return null;
    }
    
    private String getClientIdentifier(HttpServletRequest request) {
        // API Key、IP、ユーザーIDの順で識別
        String apiKey = request.getHeader("X-API-Key");
        if (apiKey != null) {
            return "api:" + apiKey;
        }
        
        String userId = request.getHeader("X-User-ID");
        if (userId != null) {
            return "user:" + userId;
        }
        
        return "ip:" + getClientIP(request);
    }
    
    private String getClientIP(HttpServletRequest request) {
        String xForwardedFor = request.getHeader("X-Forwarded-For");
        if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
            return xForwardedFor.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
    
    private void handleRateLimitExceeded(RequestContext context, Long currentCount) {
        context.setSendZuulResponse(false);
        context.setResponseStatusCode(429);
        context.setResponseBody("{\"error\":\"Rate limit exceeded\",\"count\":" + currentCount + "}");
        context.getResponse().setContentType("application/json");
        
        // レート制限ヘッダー追加
        context.addZuulResponseHeader("X-RateLimit-Limit", String.valueOf(requestsPerMinute));
        context.addZuulResponseHeader("X-RateLimit-Remaining", "0");
        context.addZuulResponseHeader("Retry-After", "60");
    }
}

Bucket4j実装

// Bucket4jRateLimitFilter.java
@Component
public class Bucket4jRateLimitFilter extends ZuulFilter {
    
    private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
    
    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }
    
    @Override
    public int filterOrder() {
        return 3;
    }
    
    @Override
    public boolean shouldFilter() {
        return true;
    }
    
    @Override
    public Object run() throws ZuulException {
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        
        String clientId = getClientIdentifier(request);
        Bucket bucket = getBucket(clientId);
        
        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
        
        if (probe.isConsumed()) {
            // リクエスト許可
            context.addZuulResponseHeader("X-RateLimit-Remaining", 
                String.valueOf(probe.getRemainingTokens()));
        } else {
            // レート制限適用
            context.setSendZuulResponse(false);
            context.setResponseStatusCode(429);
            context.setResponseBody("{\"error\":\"Rate limit exceeded\"}");
            context.addZuulResponseHeader("X-RateLimit-Retry-After-Seconds", 
                String.valueOf(TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill())));
        }
        
        return null;
    }
    
    private Bucket getBucket(String clientId) {
        return buckets.computeIfAbsent(clientId, this::newBucket);
    }
    
    private Bucket newBucket(String clientId) {
        // 基本レート: 100 requests/minute
        Bandwidth limit = Bandwidth.classic(100, Refill.intervally(100, Duration.ofMinutes(1)));
        return Bucket4j.builder().addLimit(limit).build();
    }
}

モニタリング・ログ

メトリクス収集

// MetricsFilter.java
@Component
public class MetricsFilter extends ZuulFilter {
    
    @Autowired
    private MeterRegistry meterRegistry;
    
    private final Timer.Sample sample = Timer.start(meterRegistry);
    
    @Override
    public String filterType() {
        return FilterConstants.POST_TYPE;
    }
    
    @Override
    public int filterOrder() {
        return FilterConstants.SEND_RESPONSE_FILTER_ORDER + 1;
    }
    
    @Override
    public boolean shouldFilter() {
        return true;
    }
    
    @Override
    public Object run() throws ZuulException {
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        HttpServletResponse response = context.getResponse();
        
        String method = request.getMethod();
        String uri = request.getRequestURI();
        String status = String.valueOf(response.getStatus());
        String serviceId = (String) context.get(FilterConstants.SERVICE_ID_KEY);
        
        // レスポンス時間記録
        sample.stop(Timer.builder("zuul.request.duration")
                .tag("method", method)
                .tag("uri", uri)
                .tag("status", status)
                .tag("service", serviceId != null ? serviceId : "unknown")
                .register(meterRegistry));
        
        // リクエスト数カウント
        Counter.builder("zuul.requests.total")
                .tag("method", method)
                .tag("status", status)
                .tag("service", serviceId != null ? serviceId : "unknown")
                .register(meterRegistry)
                .increment();
        
        return null;
    }
}

構造化ログ

// LoggingFilter.java
@Component
public class LoggingFilter extends ZuulFilter {
    
    private static final Logger logger = LoggerFactory.getLogger(LoggingFilter.class);
    private static final ObjectMapper objectMapper = new ObjectMapper();
    
    @Override
    public String filterType() {
        return FilterConstants.POST_TYPE;
    }
    
    @Override
    public int filterOrder() {
        return FilterConstants.SEND_RESPONSE_FILTER_ORDER + 2;
    }
    
    @Override
    public boolean shouldFilter() {
        return true;
    }
    
    @Override
    public Object run() throws ZuulException {
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        HttpServletResponse response = context.getResponse();
        
        try {
            Map<String, Object> logData = new HashMap<>();
            logData.put("timestamp", Instant.now().toString());
            logData.put("method", request.getMethod());
            logData.put("uri", request.getRequestURI());
            logData.put("query", request.getQueryString());
            logData.put("status", response.getStatus());
            logData.put("userAgent", request.getHeader("User-Agent"));
            logData.put("referer", request.getHeader("Referer"));
            logData.put("clientIP", getClientIP(request));
            logData.put("responseTime", System.currentTimeMillis() - (Long) context.get("startTime"));
            logData.put("requestId", UUID.randomUUID().toString());
            
            String serviceId = (String) context.get(FilterConstants.SERVICE_ID_KEY);
            if (serviceId != null) {
                logData.put("serviceId", serviceId);
            }
            
            logger.info(objectMapper.writeValueAsString(logData));
            
        } catch (Exception e) {
            logger.error("Logging error", e);
        }
        
        return null;
    }
}

ヘルスチェック設定

// ZuulHealthIndicator.java
@Component
public class ZuulHealthIndicator implements HealthIndicator {
    
    @Autowired
    private RouteLocator routeLocator;
    
    @Autowired
    private LoadBalancerClient loadBalancer;
    
    @Override
    public Health health() {
        Map<String, Object> details = new HashMap<>();
        
        try {
            // ルート情報収集
            List<Route> routes = routeLocator.getRoutes();
            details.put("routeCount", routes.size());
            
            // サービス状態確認
            Map<String, String> serviceStates = new HashMap<>();
            for (Route route : routes) {
                if (route.getId() != null) {
                    try {
                        ServiceInstance instance = loadBalancer.choose(route.getId());
                        serviceStates.put(route.getId(), instance != null ? "UP" : "DOWN");
                    } catch (Exception e) {
                        serviceStates.put(route.getId(), "ERROR");
                    }
                }
            }
            details.put("services", serviceStates);
            
            return Health.up().withDetails(details).build();
            
        } catch (Exception e) {
            return Health.down().withException(e).withDetails(details).build();
        }
    }
}

高度な機能

カスタムErrorFilter

// CustomErrorFilter.java
@Component
public class CustomErrorFilter extends ZuulFilter {
    
    private static final Logger logger = LoggerFactory.getLogger(CustomErrorFilter.class);
    
    @Override
    public String filterType() {
        return FilterConstants.ERROR_TYPE;
    }
    
    @Override
    public int filterOrder() {
        return FilterConstants.SEND_ERROR_FILTER_ORDER + 1;
    }
    
    @Override
    public boolean shouldFilter() {
        return RequestContext.getCurrentContext().containsKey("error.exception");
    }
    
    @Override
    public Object run() throws ZuulException {
        RequestContext context = RequestContext.getCurrentContext();
        Throwable throwable = (Throwable) context.get("error.exception");
        
        logger.error("Zuul error", throwable);
        
        try {
            ErrorResponse errorResponse = createErrorResponse(throwable);
            
            context.setResponseStatusCode(errorResponse.getStatus());
            context.setResponseBody(objectMapper.writeValueAsString(errorResponse));
            context.getResponse().setContentType("application/json");
            
        } catch (Exception e) {
            logger.error("Error filter exception", e);
        }
        
        return null;
    }
    
    private ErrorResponse createErrorResponse(Throwable throwable) {
        if (throwable instanceof ZuulException) {
            ZuulException zuulException = (ZuulException) throwable;
            return new ErrorResponse(
                zuulException.nStatusCode,
                zuulException.errorCause,
                zuulException.getMessage(),
                Instant.now().toString()
            );
        } else if (throwable instanceof HystrixRuntimeException) {
            return new ErrorResponse(
                503,
                "SERVICE_UNAVAILABLE",
                "Service temporarily unavailable",
                Instant.now().toString()
            );
        } else {
            return new ErrorResponse(
                500,
                "INTERNAL_SERVER_ERROR",
                "An unexpected error occurred",
                Instant.now().toString()
            );
        }
    }
}

リクエスト・レスポンス変換

// RequestTransformationFilter.java
@Component
public class RequestTransformationFilter extends ZuulFilter {
    
    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }
    
    @Override
    public int filterOrder() {
        return FilterConstants.SERVLET_DETECTION_FILTER_ORDER + 1;
    }
    
    @Override
    public boolean shouldFilter() {
        RequestContext context = RequestContext.getCurrentContext();
        return context.getRequest().getContentType() != null && 
               context.getRequest().getContentType().contains("application/json");
    }
    
    @Override
    public Object run() throws ZuulException {
        RequestContext context = RequestContext.getCurrentContext();
        
        try {
            // リクエストボディの読み取り
            InputStream inputStream = context.getRequest().getInputStream();
            String requestBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
            
            // JSONパース・変換
            ObjectMapper mapper = new ObjectMapper();
            JsonNode rootNode = mapper.readTree(requestBody);
            
            // 変換ロジック
            if (rootNode.isObject()) {
                ObjectNode objectNode = (ObjectNode) rootNode;
                
                // タイムスタンプ追加
                objectNode.put("timestamp", Instant.now().toString());
                
                // バージョン情報追加
                objectNode.put("apiVersion", "v1.0");
                
                // センシティブ情報のマスキング
                if (objectNode.has("password")) {
                    objectNode.put("password", "***MASKED***");
                }
            }
            
            // 変換後のボディを設定
            String modifiedBody = mapper.writeValueAsString(rootNode);
            context.setRequest(new HttpServletRequestWrapper(context.getRequest()) {
                @Override
                public ServletInputStream getInputStream() throws IOException {
                    return new ServletInputStreamWrapper(modifiedBody.getBytes(StandardCharsets.UTF_8));
                }
                
                @Override
                public int getContentLength() {
                    return modifiedBody.getBytes(StandardCharsets.UTF_8).length;
                }
            });
            
        } catch (Exception e) {
            throw new ZuulException("Request transformation error", 400, "BAD_REQUEST");
        }
        
        return null;
    }
}

WebSocket対応

// WebSocketConfiguration.java
@Configuration
@EnableWebSocket
public class WebSocketConfiguration implements WebSocketConfigurer {
    
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(new WebSocketProxyHandler(), "/ws/**")
                .setAllowedOrigins("*");
    }
}

// WebSocketProxyHandler.java
@Component
public class WebSocketProxyHandler extends TextWebSocketHandler {
    
    @Autowired
    private LoadBalancerClient loadBalancer;
    
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        String path = session.getUri().getPath();
        String serviceId = extractServiceId(path);
        
        ServiceInstance instance = loadBalancer.choose(serviceId);
        if (instance != null) {
            // バックエンドWebSocketサービスへの接続確立
            session.getAttributes().put("backendInstance", instance);
        }
    }
    
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        ServiceInstance instance = (ServiceInstance) session.getAttributes().get("backendInstance");
        if (instance != null) {
            // バックエンドサービスにメッセージ転送
            forwardToBackend(instance, message);
        }
    }
}

パフォーマンス最適化

接続プール設定

# application.yml
zuul:
  host:
    # 接続プール設定
    max-total-connections: 400
    max-per-route-connections: 50
    socket-timeout-millis: 10000
    connect-timeout-millis: 5000
    
  # リトライ設定
  retryable: true
  
ribbon:
  # Ribbon設定
  MaxTotalConnections: 400
  MaxConnectionsPerHost: 50
  PoolMinThreads: 1
  PoolMaxThreads: 200
  ConnectTimeout: 3000
  ReadTimeout: 10000
  
hystrix:
  command:
    default:
      execution:
        timeout:
          enabled: true
        isolation:
          thread:
            timeoutInMilliseconds: 15000
  threadpool:
    default:
      coreSize: 50
      maximumSize: 200
      allowMaximumSizeToDivergeFromCoreSize: true
      maxQueueSize: 100

JVM最適化

# JVM起動オプション
JAVA_OPTS="-Xms2g -Xmx4g \
           -XX:+UseG1GC \
           -XX:MaxGCPauseMillis=200 \
           -XX:+UnlockExperimentalVMOptions \
           -XX:+UseCGroupMemoryLimitForHeap \
           -Djava.security.egd=file:/dev/./urandom \
           -Dspring.profiles.active=production"

キャッシング実装

// CachingFilter.java
@Component
public class CachingFilter extends ZuulFilter {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }
    
    @Override
    public int filterOrder() {
        return FilterConstants.SERVLET_DETECTION_FILTER_ORDER - 1;
    }
    
    @Override
    public boolean shouldFilter() {
        RequestContext context = RequestContext.getCurrentContext();
        return "GET".equals(context.getRequest().getMethod()) && 
               isCacheableRoute(context.getRequest().getRequestURI());
    }
    
    @Override
    public Object run() throws ZuulException {
        RequestContext context = RequestContext.getCurrentContext();
        String cacheKey = generateCacheKey(context.getRequest());
        
        try {
            Object cachedResponse = redisTemplate.opsForValue().get(cacheKey);
            if (cachedResponse != null) {
                // キャッシュヒット
                CachedResponse response = (CachedResponse) cachedResponse;
                context.setSendZuulResponse(false);
                context.setResponseStatusCode(response.getStatus());
                context.setResponseBody(response.getBody());
                context.addZuulResponseHeader("X-Cache", "HIT");
                return null;
            }
            
            // キャッシュミス時はレスポンスをキャッシュするためのフラグ設定
            context.set("cache_key", cacheKey);
            
        } catch (Exception e) {
            logger.warn("Cache read error", e);
        }
        
        return null;
    }
    
    private String generateCacheKey(HttpServletRequest request) {
        StringBuilder keyBuilder = new StringBuilder();
        keyBuilder.append("zuul_cache:")
                  .append(request.getMethod())
                  .append(":")
                  .append(request.getRequestURI());
        
        if (request.getQueryString() != null) {
            keyBuilder.append("?").append(request.getQueryString());
        }
        
        return keyBuilder.toString();
    }
    
    private boolean isCacheableRoute(String path) {
        return path.startsWith("/api/public/") || 
               path.startsWith("/api/reference/");
    }
}

トラブルシューティング

ログ分析とデバッグ

ログレベル設定

# application.yml
logging:
  level:
    com.netflix.zuul: DEBUG
    org.springframework.cloud.netflix.zuul: DEBUG
    com.netflix.hystrix: DEBUG
    com.netflix.ribbon: DEBUG
    com.netflix.discovery: INFO
    ROOT: INFO
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
    file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
  file:
    name: /var/log/zuul-gateway.log
    max-size: 100MB
    max-history: 30

アクチュエーターエンドポイント活用

# ルート情報確認
curl http://localhost:8080/actuator/routes

# フィルター情報確認
curl http://localhost:8080/actuator/filters

# Hystrixメトリクス
curl http://localhost:8080/actuator/hystrix.stream

# ヘルスチェック
curl http://localhost:8080/actuator/health

# メトリクス確認
curl http://localhost:8080/actuator/metrics

よくある問題と解決法

Hystrixタイムアウト問題

# より長いタイムアウト設定
hystrix:
  command:
    default:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 30000

# または特定のサービス用
hystrix:
  command:
    slow-service:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 60000

Ribbonサーバーリスト問題

// カスタムServerListUpdater
@Configuration
public class RibbonConfiguration {
    
    @Bean
    public ServerListUpdater serverListUpdater() {
        return new PollingServerListUpdater() {
            @Override
            public void start(final UpdateAction updateAction) {
                if (updateAction == null) {
                    return;
                }
                
                Runnable wrapperRunnable = new Runnable() {
                    @Override
                    public void run() {
                        try {
                            updateAction.doUpdate();
                        } catch (Exception e) {
                            logger.warn("Failed to update server list", e);
                        }
                    }
                };
                
                scheduler.scheduleWithFixedDelay(wrapperRunnable, 0, 30, TimeUnit.SECONDS);
            }
        };
    }
}

メモリリーク対策

// RequestContextクリーンアップフィルター
@Component
public class RequestContextCleanupFilter extends ZuulFilter {
    
    @Override
    public String filterType() {
        return FilterConstants.POST_TYPE;
    }
    
    @Override
    public int filterOrder() {
        return FilterConstants.SEND_RESPONSE_FILTER_ORDER + 100;
    }
    
    @Override
    public boolean shouldFilter() {
        return true;
    }
    
    @Override
    public Object run() throws ZuulException {
        // RequestContext のクリーンアップ
        RequestContext.getCurrentContext().clear();
        return null;
    }
}

デバッグツールと監視

// ZuulDebugFilter.java
@Component
@ConditionalOnProperty(name = "zuul.debug.enabled", havingValue = "true")
public class ZuulDebugFilter extends ZuulFilter {
    
    @Override
    public String filterType() {
        return FilterConstants.POST_TYPE;
    }
    
    @Override
    public int filterOrder() {
        return 1000;
    }
    
    @Override
    public boolean shouldFilter() {
        return RequestContext.getCurrentContext().debugRequest();
    }
    
    @Override
    public Object run() throws ZuulException {
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        
        if (request.getParameter("debug") != null) {
            Map<String, Object> debugInfo = new HashMap<>();
            debugInfo.put("requestId", context.get("requestId"));
            debugInfo.put("routeHost", context.getRouteHost());
            debugInfo.put("serviceId", context.get(FilterConstants.SERVICE_ID_KEY));
            debugInfo.put("requestURI", request.getRequestURI());
            debugInfo.put("method", request.getMethod());
            debugInfo.put("headers", Collections.list(request.getHeaderNames()));
            
            context.addZuulResponseHeader("X-Zuul-Debug-Info", 
                Base64.getEncoder().encodeToString(
                    objectMapper.writeValueAsBytes(debugInfo)));
        }
        
        return null;
    }
}

参考リンク

公式ドキュメント

Netflix OSS関連

Spring Cloud関連

学習リソース

移行ガイド