マイクロサービスを増やしていくと、認証・CORS・レートリミット・アクセスログといった横断的な処理が各サービスにコピペで散らばっていきますよね。サービスごとに微妙に実装が違ってバグの温床になったりもします。
こうした共通処理は、各サービスの前段に置く「APIゲートウェイ」に集約するのが定石です。今回はSpring Cloud Gatewayを使って、ルーティングから認証・レートリミット・回復性までを一箇所にまとめたゲートウェイを、動くコードで組み立てていきましょう。
Spring Cloud Gatewayの3つの基本概念
Spring Cloud Gatewayは、受け取ったリクエストを条件に応じて下流サービスへ振り分けるリバースプロキシです。設定を理解するうえで押さえたいのは次の3つだけです。
- Route(ルート) は「この条件に合ったら、この宛先へ流す」という1つのルールです。
- Predicate(述語) はマッチ条件で、パス・ホスト・HTTPメソッド・ヘッダなどで判定します。
- Filter(フィルター) はリクエストやレスポンスの加工で、ヘッダ付与やパス書き換え、認証などを差し込みます。
リクエストは「Predicateでルートを選ぶ → Filterチェーンを通る → 下流へ転送 → 応答が再びFilterを通って返る」という流れで処理されます。この順番を頭に入れておくと設定がぐっと読みやすくなります。
WebFlux前提であることに注意
最初にひとつ大事な前提です。Spring Cloud Gatewayはリアクティブスタック(Netty + WebFlux)の上で動きます。つまり spring-boot-starter-web(Tomcat + Spring MVC)を同じプロジェクトに入れると、サーバスタックが競合して起動に失敗します。
ゲートウェイは基本的に単独のアプリとして立て、MVC用のstarterは入れないと覚えておきましょう。リアクティブが不慣れな方は Spring WebFluxでリアクティブプログラミングを始める も合わせて読むと理解が早いです。
バージョンはSpring BootとSpring Cloudの対応関係が決まっているので、Spring Cloudの世代を勝手に選ばず、BOMで揃えるのが安全です。
依存の追加
MavenならSpring CloudのBOMを dependencyManagement で読み込み、starterを追加します。ここではSpring Cloud 2025.0.0(Northfields)を前提にします。
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2025.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
</dependency>
</dependencies>
2025.0.0でスターター名が整理され、リアクティブ版は spring-cloud-starter-gateway-server-webflux になりました。旧来の spring-cloud-starter-gateway もエイリアスとして残っていますが、非推奨警告がログに出るので新しい名前を使いましょう(次の世代の2025.1.0では旧名は削除されます)。これだけでNettyベースのゲートウェイが起動できます。
application.ymlでルートを定義する
まずは宣言的な設定から。/api/users/** をユーザサービスへ、/api/orders/** を注文サービスへ振り分ける例です。プロパティ接頭辞は spring.cloud.gateway.server.webflux.routes を使います。
spring:
cloud:
gateway:
server:
webflux:
routes:
- id: user-service
uri: http://localhost:8081
predicates:
- Path=/api/users/**
- Method=GET,POST
filters:
- AddRequestHeader=X-Gateway, scg
- id: order-service
uri: http://localhost:8082
predicates:
- Path=/api/orders/**
- Header=X-Api-Version, v1
uri は転送先です。http:// で直接指定するほか、サービスディスカバリと組み合わせるなら lb://user-service のように書いてロードバランス経由にもできます。predicates は複数書くとAND条件になります。
なお、旧接頭辞の spring.cloud.gateway.routes も2025.0.0では非推奨扱いで動きますが、警告が出ます。spring-boot-properties-migrator を一時的に入れると、古い接頭辞を洗い出せます。
Java DSLで動的に組み立てる
条件分岐や環境ごとの出し分けが必要なら、RouteLocator をBeanとして定義するJava DSLが便利です。ymlと併用もできます。
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
return builder.routes()
.route("user-service", r -> r
.path("/api/users/**")
.filters(f -> f.stripPrefix(2))
.uri("http://localhost:8081"))
.route("order-service", r -> r
.path("/api/service-b/**")
.filters(f -> f.rewritePath("/api/service-b/(?<seg>.*)", "/${seg}"))
.uri("http://localhost:8082"))
.build();
}
パスの差異を吸収する
外部に公開するパスと、下流サービスが実際に受けるパスはたいてい違います。ここを埋めるのが StripPrefix と RewritePath です。
StripPrefix=2 は先頭2セグメントを削ります。/api/users/123 は下流に /123 として届きます。より柔軟に変えたいときは RewritePath の正規表現を使います。上のDSL例では /api/service-b/foo を /foo に書き換えています。/api/{サービス名}/** のようなプレフィックス設計にしておくと、ルートの見通しがよくなりますよ。
GatewayFilterとGlobalFilterの違い
フィルターには2種類あります。特定ルートだけに適用する GatewayFilter と、全ルートに一律で適用する GlobalFilter です。認証やアクセスログのように「すべてのリクエストに効かせたい」処理はGlobalFilterで書きます。
どちらも chain.filter(exchange) を呼ぶ前が pre処理 、返ってきたあと(then の中)が post処理 です。複数フィルターの順序は Ordered を実装して getOrder() の値で制御します。値が小さいほど先に走ります。
GlobalFilterでトークン検証を横断実装する
全リクエストに共通の認証チェックを差し込んでみましょう。Authorization ヘッダを取り出し、検証に失敗したら401を返してチェーンを止めます。
@Component
public class AuthFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = exchange.getRequest()
.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (token == null || !isValid(token)) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
// 検証済みのユーザ情報を下流へ伝播する
ServerHttpRequest mutated = exchange.getRequest().mutate()
.header("X-User-Id", extractUserId(token))
.build();
return chain.filter(exchange.mutate().request(mutated).build());
}
@Override
public int getOrder() {
return -100; // レートリミット等より前に走らせる
}
}
ここではトークン検証の骨格だけを示しています。OAuth2/OIDCの認可サーバ構築そのものは本記事の範囲外なので、ゲートウェイ側は「検証して結果を下流へ渡す」役割に絞るのがすっきりします。
アクセスログを集約する
ロギングもGlobalFilterで一元化できます。post処理でステータスと所要時間を記録します。ここで注意したいのが、post処理の時点で getStatusCode() が null を返しうる点です。ログ処理が本流のリクエストを壊さないよう、null安全に取り出しておきましょう。
@Component
public class AccessLogFilter implements GlobalFilter {
private static final Logger log = LoggerFactory.getLogger(AccessLogFilter.class);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
long start = System.currentTimeMillis();
String path = exchange.getRequest().getURI().getPath();
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
long took = System.currentTimeMillis() - start;
HttpStatusCode code = exchange.getResponse().getStatusCode();
int status = code != null ? code.value() : -1;
log.info("{} status={} {}ms", path, status, took);
}));
}
}
ログにトレースIDを乗せて追跡したくなったら、Micrometer TracingとZipkinで分散トレーシングを実現する と組み合わせるとサービスをまたいだ流れが見えるようになります。
RequestRateLimiterでレートリミットをかける
レートリミットもゲートウェイ層でまとめられます。Spring Cloud GatewayはRedisを使ったトークンバケットの RequestRateLimiter を標準で持っています。まず spring-boot-starter-data-redis-reactive を追加し、フィルターを設定します。
spring:
cloud:
gateway:
server:
webflux:
routes:
- id: user-service
uri: http://localhost:8081
predicates:
- Path=/api/users/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10
redis-rate-limiter.burstCapacity: 20
redis-rate-limiter.requestedTokens: 1
key-resolver: "#{@ipKeyResolver}"
replenishRate が毎秒補充されるトークン数(=定常の許容レート)、burstCapacity がバケットの上限(=瞬間的に許すバースト)です。制限単位は KeyResolver で決めます。IP単位ならこう書きます。
@Bean
public KeyResolver ipKeyResolver() {
return exchange -> {
InetSocketAddress addr = exchange.getRequest().getRemoteAddress();
String key = addr != null ? addr.getAddress().getHostAddress() : "unknown";
return Mono.just(key);
};
}
getRemoteAddress() はプロキシやロードバランサ配下だと null になったり、実クライアントではなく手前のLBのアドレスを返したりします。本番では X-Forwarded-For ヘッダから実IPを取る実装に寄せるか、ForwardedHeaderTransformer の有効化を検討しましょう。X-User-Id やAPIキーのヘッダを返すようにすれば、ユーザ単位・キー単位の制限にもすぐ切り替えられます。
なお、単一アプリ内でJavaコードとして細かく制御したい場合は Bucket4jでアプリ内レートリミットを実装する が向いています。ゲートウェイ層とアプリ層で使い分けるとよいでしょう。
Resilience4jでフォールバックする
下流サービスが落ちたときに、ゲートウェイがタイムアウトを待ち続けて共倒れするのは避けたいですよね。spring-cloud-starter-circuitbreaker-reactor-resilience4j を追加すると、CircuitBreakerフィルターが使えます。
filters:
- name: CircuitBreaker
args:
name: userCb
fallbackUri: forward:/fallback/users
下流が異常なときは fallbackUri に指定した内部エンドポイントへ流れ、そこで代替レスポンス(キャッシュや縮退メッセージ)を返せます。サーキットブレーカーやリトライの挙動そのものを詰めたいときは Resilience4jでサーキットブレーカーを実装する を参照してください。下流を宣言的HTTPクライアントで呼ぶなら OpenFeignで宣言的HTTPクライアントを作る も相性がよいです。
フィルター内でブロッキングしない
最後にリアクティブランタイムならではの注意点です。ゲートウェイのフィルターはイベントループ上で動くため、JDBCの同期クエリや同期HTTP呼び出しのようなブロッキング処理をそのまま書くと、少ないスレッドが詰まってスループットが一気に落ちます。
原則として、フィルター内で Mono チェーンを .block() してはいけません。どうしても同期処理が必要なら boundedElastic スケジューラへ退避します。
return Mono.fromCallable(() -> legacyBlockingCall())
.subscribeOn(Schedulers.boundedElastic())
.flatMap(result -> chain.filter(exchange));
トークン検証も、外部へ問い合わせる場合は同期クライアントではなく WebClient などノンブロッキングな手段を使うのが基本です。
まとめ
Spring Cloud Gatewayを使って、ルーティング・パスリライト・認証・アクセスログ・レートリミット・回復性を一箇所に集約したAPIゲートウェイを組み立ててきました。各マイクロサービスは本来のビジネスロジックに集中でき、横断的な関心事はゲートウェイに寄せられます。
ポイントを振り返ると、WebFlux前提でMVC starterと同居させないこと、2025.0.0では新しいスターター名と server.webflux 接頭辞を使うこと、そしてフィルター内ではブロッキングを避けることです。ここから先は回復性やトレーシングの各記事と組み合わせて、運用に耐えるゲートウェイへ育てていきましょう。