Spring Cloud - Zuul API gateway & Proxy !(Netflix Zuul)

2021. 4. 15. 02:32 Spring Cloud/Spring Cloud

Netflix Zuul 이란 무엇인가?

마이크로서비스 아키텍쳐(MSA)에서 Netflix Zuul은 간단히 API gateway 또는 API Service,Edge Service로 정의된다.

그래서 하는 일이 무엇이냐? 마이크로서비스 아키텍쳐에서 여러 클라이언트 요청을 적절한 서비스로 프록시하거나 라우팅하기 위한 서비스이다.

 

 

 

위의 이미지에서 보이듯, 모든 마이크로서비스의 종단점은 숨기고 모든 요청을 최앞단에서 Zuul이 받아 

적절한 서비스로 분기를 시키게된다. 모든 마이크로서비스의 종단점을 숨겨야하는 이유가 무엇인가?

 

1) 클라이언트는 일부 마이크로서비스만 필요로한다.

2) 클라이언트별로 적용돼야 할 정책이 있다면 그 정책을 여러 곳에서 분산해 두는 것보단 한곳에 두고 적용하는 것이

더욱안전하다.(크로스오리진 접근정책이 바로 이런 방식의 대표적인 예임) 또한 서비스 단에서 사용자별 분기처리 로직은

구현하기 까다롭다.

3)대역폭이 제한돼 있는 환경에서 데이터 집계가 필요하다면 다수의 클라이언트의 요청이 집중되지 않게 중간에 게이트웨이를

두는것이 좋다.

 

Netflix Zuul 설계목적?

 

 

우선 Zuul은 JVM-based router and Server-side load Balancer이다. Zuul을 사용함으로써 서버사이드에서

동적 라우팅, 모니터링, 회복 탄력성, 보안 기능을 지원한다(Filter를 통한 구현)

또한 Zuul은 다른 기업용 API 게이트웨이 제품과는 달리 개발자가 특정한 요구 사항에 알맞게 설정하고 프로그래밍할 수 있게

개발자에게 완전한 통제권을 준다.

 

 

Zuul 프록시는 내부적으로 서비스 탐색을 위해 Eureka(유레카) 서버를 사용하고, 서비스 인스턴스 사이의 부하 분산을 위해 Ribbon(리본)을 사용한다.

위에서도 이야기 했던 것처럼 Zuul은 API계층에서 서비스의 기능을 재정의해서 뒤에 있는 서비스의 동작을 바꿀수 있다.

 

만약 이전 포스팅에서 Eureka에 대해 읽어 보았다면, 위의 그림만 보아도 Zuul이 어떤식으로

동작하는지 이해가 될것이다.

 

▶︎▶︎▶︎

[Spring Cloud/Spring Cloud] - Spring Cloud - Eureka를 이용한 마이크로서비스 동적등록&탐색&부하분산처리

 

 

그렇다면 Zuul은 어떠한 요구사항일때 쓸모가 있을까?

 

많은 요구사항이 있지만, 아래와 같은 요구사항일때 특히 더 쓸모가 있다.

 

1) 인증이나 보안을 모든 마이크로서비스 종단점에 각각 적용하는 대신 게이트웨이 한곳에 적용한다. 게이트웨이는

요청을 적절한 서비스에 전달하기 전에 보안 정책 적용, 토큰 처리 등을 수행할 수 있다. 또한 특정 블랙리스트(IP차단) 사용자를

거부할 수 있는 비즈니스 정책 적용이 가능하다.

2) 모니터링, 데이터 집계 등을 마이크로서비스 단에서 처리하는 것이아니라, Zuul에서 처리해 외부로 데이터를

내보낼때 사용할 수 있다.

3)부하 슈레딩(shredding),부하 스로틀링(throttling)이 필요한 상황에서도 유용하다.

4)세밀한 제어를 필요로 하는 부하 분산 처리에 유용하다(Zuul+Eureka+Ribbon)

 

 

예제 프로젝트의 구성은 Spring Cloud Config, Eureka, Zuul로 구성되어 있습니다.

▶︎▶︎▶︎

[Spring Cloud/Spring Cloud] - Spring Cloud - Spring Cloud Config(스프링 클라우드 컨피그)

▶︎▶︎▶︎

[Spring Cloud/Spring Cloud] - Spring Cloud - Eureka를 이용한 마이크로서비스 동적등록&탐색&부하분산처리

 

 

 

 

예제프로젝트는 위의 이미지의 구성입니다. 하지만 편의상 Zuul은 하나의 인스턴스만 그리고 2개의 마이크로서비스 인스턴스만

띄울 예정입니다. 그리고 마이크로서비스 인스턴스들은 또한 편의상 Spring Cloud Config를 이용하지 않았습니다.

만약 모든 구성을 스프링클라우드 컨피그로 가신다면 다른 유레카나 주울과 같은 컨피그 구성으로 가시면 됩니다.

그리고 이전 포스팅에서 유레카 서버를 독립설치형이 아닌 클러스터링된 구성으로 진행 할 것입니다.

 

#유레카 서버 - 1
spring.application.name=eureka-server1
eureka.client.serviceUrl.defaultZone=http://localhost:8889/eureka/,http://localhost:8899/eureka/
#eureka.instance.hostname=localhost
eureka.client.registerWithEureka=true
eureka.client.fetchRegistry=true
 
#유레카 서버 - 2
spring.application.name=eureka-server2
eureka.client.serviceUrl.defaultZone=http://localhost:8889/eureka/,http://localhost:8899/eureka/
eureka.client.registerWithEureka=true
eureka.client.fetchRegistry=true
 
#주울 
server.port=8060
zuul.routes.search-apigateway.serviceId=eurekaclient
zuul.routes.search-apigateway.path=/api/**
eureka.client.serviceUrl.defaultZone=http://localhost:8889/eureka/,http://localhost:8899/eureka/

 

위는 깃저장소에 있는 spring cloud config 설정파일입니다.(편의상 하나의 파일로 작성함. 실제로는 따로 파일을 나눠야함)

조금 설명할 점이 있다면, 유레카 독립모드와 클러스터 모드의 차이점입니다. 독립모드는 자신을 유레카서버에 등록하지 않고, 

캐시한 서비스 목록을 패치하지 않습니다. 하지만 클러스터모드에서는 유레카서버들이 서로 통신해야하기 때문에 자신을 서비스로

등록하고, 자신들의 서버목록들을 빠르게 통신하기 위해 캐시합니다. 그리고 defaultZone에 모든 유레카서버의 경로를 ","구분으로

나열합니다.(사실 서로 크로스해서 상대방의 주소만 써도됨. 하지만 나중에 유레카 서버가 많고 서로 하나씩 크로스됬다는 구성에서

만약 하나의 유레카서버가 죽어서 유레카서버끼리의 통신이 단절될 가능성도 있음. 그래서 모든 유레카서버 목록을 나열해서

통신하도록 하는 것이 좋음.)

그리고 주울도 하나의 유레카클라이언트이며 주울 프록시이다. 그래서 defaultZone에 유레카서버들을 나열한다. 그리고 프록시 설정이

한가지 방법만 있는 것은 아닌데 이 설정파일에는 /api/**로 들어오는 요청을 모두 eurekaclient로 보내라는 설정이다.

 

#서비스 - 1, application.properties
server.port=8090
spring.application.name=eurekaclient
eureka.client.serviceUrl.defaultZone=http://localhost:8889/eureka/,http://localhost:8899/eureka/
eureka.client.healthcheck.enabled=true
 
#서비스 - 2, application.properties
server.port=8080
spring.application.name=eurekaclient
eureka.client.serviceUrl.defaultZone=http://localhost:8889/eureka/,http://localhost:8899/eureka/
eureka.client.healthcheck.enabled=true
 
#서비스 1,2 호출하는 클라이언트, application.properties
server.port=8070
eureka.client.serviceUrl.defaultZone=http://localhost:8889/eureka/,http://localhost:8899/eureka/
spring.application.name=eureka-call-client
 
 
#config server, bootstrap.properties
server.port=8888
spring.cloud.config.server.git.uri=https://github.com/yeoseong/spring-cloud-configserver.git
management.security.enabled=false
management.endpoint.env.enabled=true
 
#eureka server - 1, bootstrap.properties
spring.application.name=eureka
spring.profiles.active=server1
server.port=8889
spring.cloud.config.uri=http://localhost:8888
management.security.enabled=false
 
#eureka server - 2, bootstrap.properties
spring.application.name=eureka
spring.profiles.active=server2
server.port=8899
spring.cloud.config.uri=http://localhost:8888
management.security.enabled=false
 
#zuul , bootstrap.properties
spring.application.name=zuulapi
spring.cloud.config.uri=http://localhost:8888
spring.profiles.active=dev
management.security.enabled=false
 
zuul.routes.eurekaclient=/api3/**
 
 
애플리케이션들의 application.properties,bootstrap.properties 입니다.(스프링클라우드컨피그 사용여부에 따라 다름)

 

나머지 설정들은 이전 포스팅에서 보고 왔다면 모두 이해할수 있다. 마지막 하나만 설명하자면, zuul의 설정이다. 이미 깃에 있는 설정파일에서 하나의

프록시 룰을 정해줬다. 하지만 그 방법말고도 다른방법이 있다.

zuul.routes.serviceId(eureka)=/path/**로도 라우팅 규칙을 정해줄 수 있다.

 

이제는 소스 설명이다. 유레카 및 컨피그, 마이크로서비스 클라이언트 소스는 이전과 동일하기 때문에 따로 작성하지 않는다.

▶︎▶︎▶︎

[Spring Cloud/Spring Cloud] - Spring Cloud - Spring Cloud Config(스프링 클라우드 컨피그)

▶︎▶︎▶︎

[Spring Cloud/Spring Cloud] - Spring Cloud - Eureka를 이용한 마이크로서비스 동적등록&탐색&부하분산처리

 

@EnableZuulProxy
@EnableDiscoveryClient
@SpringBootApplication
public class ZuulApiApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(ZuulApiApplication.class, args);
    }
    
    @Bean
    @LoadBalanced
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
    
    @Bean
    public ZuulFilter zuulFilter() {
        return new ZuulCustomFilter();
    }
    
    @RestController
    class ZuulController{
        
        @Autowired
        RestTemplate restTemplate;
        
        @GetMapping("/api2")
        public String zuulProxy() {
            System.out.println("ZuulController.zuulProxy() :::: /api2");
            return restTemplate.getForObject("http://eurekaclient/eureka/client", String.class);
        }
        
    }
}

위의 소스를 설명하면, @EnableZuulProxy로 이 애플리케이션이 주울 프록시임을 명시한다. 그리고 주울도 하나의 유레카 클라이언트임으로

@EnableDiscoveryClient로 명시해준다. 그리고 주울의 특징중 하나는 스프링 기반으로 만들어진 API임으로 개발자가 자신이 커스터마이징해서

사용할 수 있다는 점이다. @RestController로 직접 주울의 엔드포인트를 정의해서 원하는 서비스로 보낼수 있다. 더 세밀한 무엇인가가

필요하다면 이렇게 컨트롤러를 만들어서 커스터마이징해도 좋을 듯싶다. 그리고 주울도 위에서 말했듯이 하나의 유레카 클라이언트고

내부적으로 리본을 사용해 로드벨런싱 한다고 했으니, 컨트롤러에서 라우팅할때 @LoadBalanced된 RestTemplate을 이용해야

로드밸런싱이 된다.(지금까지 총 3가지 라우팅 룰을 다뤘다.)

 

public class ZuulCustomFilter extends ZuulFilter{
    
    private static Logger logger = LoggerFactory.getLogger(ZuulCustomFilter.class);
    
    /**
     * Criteria - 필터 실행 여부를 결정
     */
    @Override
    public boolean shouldFilter() {
        // TODO Auto-generated method stub
        return true;
    }
    
    /**
     * Action - Criteria 만족 시에 실행할 비즈니스 로직
     */
    @Override
    public Object run() throws ZuulException {
        // TODO Auto-generated method stub
        
        logger.info("ZuulCustomFilter :::: {}","pre filter");
        
        return null;
    }
    
    /**
     * Type - pre,route,post
     */
    @Override
    public String filterType() {
        // TODO Auto-generated method stub
        return "pre";
    }
    
    /**
     * Order - 필터 실행 순서를 결정, 숫자가 낮을 수록 우선순위가 높아짐.
     */
    @Override
    public int filterOrder() {
        // TODO Auto-generated method stub
        return 0;
    }
    
}

 

또한 주울은 필터를 정의해서 필요한 요청,응답에 대한 전/후처리가 가능합니다.

 

Pre Filter

주로 backend에 보내줄 정보를 RequestContext에 담는 역할

Payco의 AccessToken으로 email을 넘겨주는 경우

public class QueryParamPreFilter extends ZuulFilter {
  @Override
  public int filterOrder() {
    return PRE_DECORATION_FILTER_ORDER - 1; // run before PreDecoration
  }

  @Override
  public String filterType() {
    return PRE_TYPE;
  }

  @Override
  public boolean shouldFilter() {
    RequestContext context = RequestContext.getCurrentContext();
    return "member-api".equals(context.get(SERVICE_ID_KEY));
  }
    
  @Override
  public Object run() {
    RequestContext context = RequestContext.getCurrentContext();
    HttpServletRequest request = context.getRequest();
    String email = paycoTokenToEmail(request);
    context.addZuulRequestHeader("X-PAYCO-EMAIL", email);
    return null;
  }
}

email은 소중한 개인 정보입니다. 다루실 때 주의하시기 바랍니다.

Route Filter

pre filter 이후에 실행되며, 다른 서비스로 보낼 요청을 작성한다

이 필터는 주로 request, response를 client가 요구하는 모델로 변환하는 작업을 수행한다

아래의 예제는 Servlet Request를 OkHttp3 Request로 변환하고, 요청을 실행하고,

OkHttp3 Response를 Servlet Response로 변환하는 작업을 수행한다

public class OkHttpRoutingFilter extends ZuulFilter {
    @Autowired
    private ProxyRequestHelper helper;

    @Override
    public String filterType() {
        return ROUTE_TYPE;
    }

    @Override
    public int filterOrder() {
        return SIMPLE_HOST_ROUTING_FILTER_ORDER - 1;
    }

    @Override
    public boolean shouldFilter() {
        return RequestContext.getCurrentContext().getRouteHost() != null
               && RequestContext.getCurrentContext().sendZuulResponse();
    }

    @Override
    public Object run() {
        OkHttpClient httpClient = new OkHttpClient.Builder()
                // customize
                .build();

        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();

        String method = request.getMethod();

        String uri = this.helper.buildZuulRequestURI(request);

        Headers.Builder headers = new Headers.Builder();
        Enumeration<String> headerNames = request.getHeaderNames();
        while (headerNames.hasMoreElements()) {
            String name = headerNames.nextElement();
            Enumeration<String> values = request.getHeaders(name);

            while (values.hasMoreElements()) {
                String value = values.nextElement();
                headers.add(name, value);
            }
        }

        InputStream inputStream = request.getInputStream();

        RequestBody requestBody = null;
        if (inputStream != null && HttpMethod.permitsRequestBody(method)) {
            MediaType mediaType = null;
            if (headers.get("Content-Type") != null) {
                mediaType = MediaType.parse(headers.get("Content-Type"));
            }
            requestBody = RequestBody.create(mediaType, StreamUtils.copyToByteArray(inputStream));
        }

        Request.Builder builder = new Request.Builder()
                .headers(headers.build())
                .url(uri)
                .method(method, requestBody);

        Response response = httpClient.newCall(builder.build()).execute();

        LinkedMultiValueMap<String, String> responseHeaders = new LinkedMultiValueMap<>();

        for (Map.Entry<String, List<String>> entry : response.headers().toMultimap().entrySet()) {
            responseHeaders.put(entry.getKey(), entry.getValue());
        }

        this.helper.setResponse(response.code(), response.body().byteStream(),
                                responseHeaders);
        context.setRouteHost(null); // prevent SimpleHostRoutingFilter from running
        return null;
    }
}

 

Post Filter

Response를 생성하는 작업을 처리한다

아래 예제는 X-Sample 헤더에 임의의 UUID를 넣는 소스이다

public class AddResponseHeaderFilter extends ZuulFilter {
	@Override
	public String filterType() {
		return POST_TYPE;
	}

	@Override
	public int filterOrder() {
		return SEND_RESPONSE_FILTER_ORDER - 1;
	}

	@Override
	public boolean shouldFilter() {
		return true;
	}

	@Override
	public Object run() {
		RequestContext context = RequestContext.getCurrentContext();
    	HttpServletResponse servletResponse = context.getResponse();
		servletResponse.addHeader("X-Sample", UUID.randomUUID().toString());
		return null;
	}
}

▶︎▶︎▶︎참고



출처: https://coding-start.tistory.com/123?category=790355 [코딩스타트]