Netflix Zuul
Netflixが開発したJavaベースのAPI Gateway。動的ルーティング、モニタリング、レジリエンス、セキュリティ機能を提供。Spring Cloudとの統合。
概要
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関連
- Eureka - Service Discovery
- Ribbon - Client Side Load Balancer
- Hystrix - Circuit Breaker
- Archaius - Configuration Management
Spring Cloud関連
学習リソース
- Spring Cloud Netflix Tutorial
- Microservices with Spring Cloud
- Building Microservices Book
- Zuul Best Practices