Spring Cloud Gateway + Eureka

nayoung
19 min readMar 25, 2024

--

외부에 노출되는 여러 엔드포인트를 중앙 집중식으로 관리하기 위해 Gateway를 사용하고 있다. By presenting a simplified and consistent API to clients, it hides the complexities of the underlying microservices.

API Gateway에 다음과 같은 기능을 구현했다.

위와 같이 운영하면 마이크로서비스는 서로의 주소를 몰라도 Gateway가 알아서 처리해 마이크로서비스의 중재자 역할을 한다. 이를 통해 마이크로서비스는 클라이언트와 독립적으로 확장할 수 있다.

https://www.youtube.com/watch?v=Zs3jVelp0L8

App 요청 전용, Web 요청 전용, mTLS 인증서 검증을 위한, 사내 요청 전용 등 목적에 맞게 게이트웨이를 사용할 수 있다.

https://www.youtube.com/watch?v=Zs3jVelp0L8

Before transmitting the request to the targeted service, the gateway will do some work like authenticating, authorization, payload validation, analyzing or some transformations on the header or the payload.

또한, 여러 마이크로서비스에서 사용하는 공통 데이터를 Gateway에서 생성할 수 있다. 예를 들어 여러 서비스에서 유저 정보가 필요할 때마다 유저 API를 호출하면 그만큼 비용이 발생한다.

유저 식별키와 함께 API를 요청하면 Gateway에서 유저 정보를 담은 passport(토근)을 생성해 여러 서비스로 전파한다. 유저 정보가 필요한 서비스는 Passport로 유저 정보를 사용해, 유저 API를 호출하는 비용을 막을 수 있다. 더 자세한 내용은 ‘토스는 Gateway 이렇게 씁니다’를 참고하면 된다.

Gateway를 사용하면 다음과 같은 문제도 생각해봐야 한다.

  • API Gateway를 개발, 배포 및 관리하는 비용 발생
  • network hop(gateway)이 추가되므로 응답 시간 증가할 수 있음
  • eureka에 등록된 모든 서비스를 대상으로 요청을 처리할 서비스를 찾기 때문에 라우팅 대상 서버가 많아지면 서비스를 찾는 비용이 클 것이라 예상
  • 이중화되지 않은 eureka가 중단되면 cache에 없는 요청 주소는 처리할 수 없음

Spring cloud gateway

gateway 관련 설정은 application.yml 또는 Java 코드로 작성하면 된다.

라우팅하는 여러 방법은 https://docs.spring.io/spring-cloud-gateway/reference/spring-cloud-gateway/request-predicates-factories.html 에서 확인할 수 있다. 요청 PATH를 기준으로 라우팅하는 방법은 다음과 같다.

application.yml

server:
port: 8089

spring:
cloud:
gateway:
routes:
- id: user-service
uri: http://localhost:8081 // eureka를 사용하지 않는 경우
predicates:
- Path=/user-api/**

- id: item-service
uri: lb://ITEM-SERVICE // eureka를 사용하는 경우
predicates:
- Path=/item-api/**
  • id: 목적지 uri와 필터를 식별하기 위한 ID
  • uri: 목적지 uri
  • predicates: HTTP 요청이 조건에 부합하는지 검사. 그렇지 않을 경우 404 상태 응답

Java 코드

@Configuration
public class RouterConfig {

// 단순 라우팅을 위한
@Bean
public RouteLocator routeLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route(r -> r.path("/item-api/**")
.filters(f -> f.rewritePath("/item-api/(?<segment>.*)", "/${segment}"))
.uri("lb://ITEM-SERVICE"))
.route(r -> r.path("/order-api/**")
.filters(f -> f.rewritePath("/order-api/(?<segment>.*)", "/${segment}"))
.uri("lb://ORDER-SERVICE"))
.build();
}

http://localhost:8089/item-api/… 형식으로 요청하면 gateway가 받는다. gateway는 모든 조건이 일치하는 서비스를 찾고 WebHandler가 실행된다.

pre-필터가 처리된 후 서비스로 요청을 보내고, post-필터를 처리해 클라이언트에게 응답한다. 위 코드는 http://localhost:8089/item-api/… 로 들어온 uri에서 /item-api 부분을 지우는 filter 작업을 진행하는데 그 이유는 Filter 챕터(아래)에 작성했다.

RouteLocator

package org.springframework.cloud.gateway.route;

public interface RouteLocator {

Flux<Route> getRoutes();

/**
* Gets routes whose {@link Route#getId()} matches with any of the ids passed by
* parameters. If an ID cannot be found, it will not return a route for that ID.
*/
default Flux<Route> getRoutesByMetadata(Map<String, Object> metadata) {
return getRoutes().filter(route -> matchMetadata(route.getMetadata(), metadata));
}

static boolean matchMetadata(Map<String, Object> toCheck, Map<String, Object> expectedMetadata) {
if (CollectionUtils.isEmpty(expectedMetadata)) {
return true;
}
else {
return toCheck != null
&& expectedMetadata.entrySet().stream().allMatch(keyValue -> toCheck.containsKey(keyValue.getKey())
&& toCheck.get(keyValue.getKey()).equals(keyValue.getValue()));
}
}

}

등록된 모든 서비스를 대상으로 요청을 처리할 서비스를 찾으며, 모든 조건에 부합되는 서비스를 찾으면 더 이상 탐색하지 않는 방식인 것 같다. 라우팅 대상 서버가 많아지면 비용이 클 것이라 예상된다.

[ctor-http-nio-2] o.s.c.g.h.p.PathRoutePredicateFactory    : 
Pattern "[/item-api/**]" does not match against value "/order-api/orders/create"

[ctor-http-nio-2] o.s.c.g.h.p.PathRoutePredicateFactory :
Pattern "/order-api/**" matches against value "/order-api/orders/create"

[ctor-http-nio-2] o.s.c.g.h.RoutePredicateHandlerMapping :
Route matched: order-service

Filter

요청에 맞는 서비스를 찾았다면 pre-필터, post-필터로 요청 정보를 구성할 수 있다. 여러 필터가 존재하는데 request path를 rewrite 하기 위해 RewritePath GatewayFilter를 사용했다.

// application.yml

- id: item-service
uri: lb://ITEM-SERVICE
predicates:
- Path=/item-api/**
filters:
- RewritePath=/item-api/?(?<segment>.*), /$\{segment}
@Configuration
public class RouterConfig {

@Bean
public RouteLocator routeLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route(r -> r.path("/item-api/**")
.filters(f -> f.rewritePath("/item-api/(?<segment>.*)", "/${segment}"))
.uri("lb://ITEM-SERVICE"))
.build();
}

요청을 처리하는 서비스를 찾기 위해 http://localhost:8089/item-api/… 형식으로 API를 호출하면 실제 서비스로 [GET /item-api/….] 요청한다.

그러므로 해당 서비스의 모든 컨트롤러에 @RequestMapping(“/item-api”) 를 추가해야 하는데 /item-api 경로는 요청을 처리할 서비스를 구분하기 위해 사용한 것이므로 서비스 코드에 추가하고 싶지 않았다.

RewritePath GatewayFilter를 사용해 /item-api 부분을 제거했고, 마이크로서비스의 컨트롤러에 @RequestMapping(“/item-api”)를 추가하지 않아도 된다.

더 많은 필터는 https://docs.spring.io/spring-cloud-gateway/reference/spring-cloud-gateway/gatewayfilter-factories.html에서 확인하면 된다.

Gateway에서 추가 작업

API Composition 같은 추가 작업이 필요한 API를 제공하려면 다음과 같이 작성한다.

@Bean
public RouterFunction<ServerResponse> myPageRoutes(MyPageHandler myPageHandler) {
return RouterFunctions.route()
.GET("/my-page-details/{accountId}/{cursorOrderId}",
RequestPredicates.accept(MediaType.APPLICATION_JSON),
myPageHandler::getMyPageDetails)
.build();
}

마이페이지를 위해 여러 마이크로서비스의 데이터 조합이 필요해 API Composition을 적용했다. 즉, 타겟 서비스 호출 전/후 추가 작업이 필요하므로 http://localhost:8089/my-page-details/…. 을 호출하면 getMyPageDetails 메서드를 호출하도록 설정했다.

API Composition에 대한 자세한 내용은 여기에서 확인할 수 있다.

Resilience4j CircuitBreaker로 요청 제어

오류 상태인 타겟 서비스로 요청을 보내는 것은 비용 낭비다. 정상 응답을 받지 못한다는 것을 알고 있음에도 API를 호출하는 것은 결국 네트워크 비용이 발생하기 때문이다.

이를 위해 CircuitBreaker를 설정해 정상 응답을 받지 못할 것으로 판단되는 경우, 타겟 서비스 앞단인 Gateway에서 클라이언트 요청을 차단(또는 적절한 응답 리턴)해 낭비되는 네트워크 호출 비용을 막을 수 있다. 해당 내용은 여기에서 자세히 확인할 수 있다.

Spring Cloud Netflix Eureka

MSA 환경에서 여러 마이크로서비스가 서로 통신하기 위해 주소가 필요한데 이를 Service Discovery가 담당한다. 동적으로 변화는 서비스의 주소를 등록해 관리한다.

서비스의 포트 번호가 고정되어 있다면 게이트웨이에 uri를 고정하면 되지만, 랜덤 포트를 사용하면 uri를 고정할 수 없어 Service Discovery를 통해 동적으로 변하는 서비스의 주소를 관리한다. eureka를 Service Discovery로 제공하고 있다.

eureka:
instance:
instanceId: ${spring.application.name}:${spring.application.instance_id:${server.port}}
client:
eureka-server-port: 8761
register-with-eureka: true // eureka 서비스로 등록
fetch-registry: true
disable-delta: true
service-url:
defaultZone: http://{localhost or 컨테이너 이름}
:${eureka.client.eureka-server-port}/eureka

여러 서비스와 gateway까지 eureka client로 설정한다. service-url의 host 부분은 로컬에서 테스트하면 localhost를, 도커에서 테스트하면 컨테이너명을 작성하면 된다.

이전에는 해당 서비스를 eureka client로 설정하기 위해 @EnableEurekaClient를 추가했었는데, 현재는 register-with-eureka: true 설정만 추가해도 eureka client로 등록된다.

fetch registry

fetch-registry=true 설정(default)으로 registry에 있는 모든 정보가 로컬로 캐싱된다. eureka가 중단되어도 캐싱된 정보가 있다면 요청할 수 있다.

[freshExecutor-0] org.apache.hc.client5.http.headers       
: http-outgoing-0 >> GET /eureka/apps/delta HTTP/1.1

[freshExecutor-0] com.netflix.discovery.DiscoveryClient
: Added instance IP주소:api-gateway:8089 to the existing apps in region null

[freshExecutor-0] com.netflix.discovery.DiscoveryClient
: Added instance IP주소:item-service:0 to the existing apps in region null

[freshExecutor-0] com.netflix.discovery.DiscoveryClient
: Added instance IP주소:user-service:0 to the existing apps in region null


// health-check
// eureka.instance.lease-renewal-interval-in-seconds 설정 시간마다 (default 30s)
[tbeatExecutor-0] org.apache.hc.client5.http.headers
: http-outgoing-1 >> PUT /eureka/apps/ITEM-SERVICE/IP주소:item-service:0?status=UP&lastDirtyTimestamp=1711531272400 HTTP/1.1

eureka.client.registry-fetch-interval-seconds 설정 시간(default 30s)마다 registry에 있는 모든 정보를 가져온다.

eureka.client.disable-delta: true

// disable-delta=false (default)
[freshExecutor-0] org.apache.hc.client5.http.headers
: http-outgoing-0 >> GET /eureka/apps/delta HTTP/1.1

// disable-delta=true
[freshExecutor-0] org.apache.hc.client5.http.headers
: http-outgoing-0 >> GET /eureka/apps/ HTTP/1.1

변경된 값만 가져오려면 disable-delta=true 설정하면 되고 GET 경로도 다르다.

Eureka SPOF

게이트웨이는 Eureka로부터 서비스 정보를 가져와 로컬에 캐시한다. Eureka가 잠깐 중단되어도 캐싱된 정보를 사용해 요청을 처리한다.

단일 구성인 Eureka 서버가 중단되면 게이트웨이는 더 이상 변경된 정보를 가지고 올 수 없으며 이는 서비스 간 통신이 불가능한 상황으로 이어질 수 있다.

이런 상황에 대비하는 방법은 Eureka 서버를 복제하거나 Zone Failover 등 여러 방법으로 해결할 수 있는데 자세한 내용은 https://11st-tech.github.io/2022/12/30/eureka-disaster-recovery-1/ 를 참고하면 된다.

instance Id

eureka client로 등록되면 그 결과가 다음과 같이 출력된다.

만약 eureka instanceId를 설정하지 않으면 IP주소:spring.applicaion.name:포트 번호 형식으로 등록된다.

랜덤 포트를 사용하면 IP주소:spring.applicaion.name:0 으로 등록되는데, 같은 서비스의 여러 인스턴스를 실행해도 1개의 상태만 출력되므로 ${spring.application.name}:${spring.application.instance_id:${server.port}} 형식으로 설정해 모든 인스턴스가 출력되도록 한다.

- id: user-service
uri: http://localhost:8081 // eureka를 사용하지 않는 경우
predicates:
- Path=/user-api/**

- id: item-service
uri: lb://ITEM-SERVICE // eureka를 사용하는 경우
predicates:
- Path=/item-api/**

eureka에 서비스가 등록되었다면 uri를 lb://{eureka에 등록된 서비스명} 으로 설정한다. 여기서 lb는 load balancing이라고 한다.

관련 이슈

Could not find org.springframework.cloud:spring-cloud-starter-netflix-eureka-client

Execution failed for task ':compileJava'.
> Could not resolve all files for configuration ':compileClasspath'.
> Could not find org.springframework.cloud:spring-cloud-starter-netflix-eureka-client:.
// build.gradle 추가
ext {
set('springCloudVersion', "2023.0.0")
}

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

MacOSDnsServerAddressStreamProvider

ERROR 3552 --- [api-gateway] [ctor-http-nio-3] i.n.r.d.DnsServerAddressStreamProviders  
: Unable to load io.netty.resolver.dns.macos.MacOSDnsServerAddressStreamProvider, fallback to system defaults.
This may result in incorrect DNS resolutions on MacOS.
Check whether you have a dependency on 'io.netty:netty-resolver-dns-native-macos'. Use DEBUG level to see the full stack
: java.lang.UnsatisfiedLinkError
: failed to load the required native library
// dependency 추가
implementation 'io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64'

--

--