이번 목표는 아래와 같다.
- Gateway에서 로드밸런싱 수행하기
- 인증 기능 구현
- api 요청 로깅 (with kafka)
- 2편에서..
Gateway에서 로드 밸런싱하기
원하는 방식은
gateway로 들어온 요청 모두를 BE1,BE2 서버로 보내며, 로드 밸런싱 옵션 조절이 가능하고, 서버의 상태를 체크해서 서버가 응답하지 못하는 경우를 감지하여 요청을 다른 서버로 보내는 로드 밸랜서를 구현하는 것이다.
구현을 고려했던 방식을 먼저 소개하자면
1.LoadBalancedWebClient를 만들어 요청을 리턴하기
해당 방식은 LoadBalancerClient 어노테이션을 이용해서 WebClient가 로드밸런싱 설정을 포함하여 동작하게 만들어 로드밸런싱을 가능하게 한다.
WebClient에 로드밸런서를 넣어 동작하는 방식이기에 특정 uri에 대해서만 로드밸런서가 동작하게 할 수 있다는 장점이 있지만, 이번 프로젝트에서는 gateway에 들어오는 모든 요청에 대해서 로드밸런싱을 수행하고자 하므로 이러한 부분은 장점이 되지 못했다.
또한, 어노테이션을 통해 꽤 간단하게 설정이 가능하지만 그래도 설정 파일들을 직접 작성해야 하기에 귀찮다는 단점이 있었다.
2.LoadBalancingFunction을 WebClient 필터에 추가하기
ReactorLoadBalancerExchangeFilterFunction 을 만들어 WebClient의 필터에 추가하는 방식이다.
1번의 방법보단 간단하게 구현할 수 있을 것 같았지만 관련 자료가 찾기 힘들었고, WebClient에 물려있는 방식이기에 원하는 구현과는 방향이 달라 이 방식도 선택하지 않았다.
3.Eureka를 Discovery Server로써 사용하기
사실 가장 많은 자료가 있고 여러 사람들이 Eureka를 통해 로드밸런서를 사용하고 있었다.
Eureka는 동적으로 인스턴스들을 등록할 수 있고 주기적으로 등록된 서비스 인스턴스들의 상태를 확인하여 로드밸런싱을 수행한다.
사실 필요한 기능을 모두 갖춘 방법이라 이 방법을 선택하려 했지만, 나는 여러 호스트를 동적으로 추가하거나 삭제하는 작업을 수행할 일이 없기에(무료 인스턴스들만 쓰니..) 조금 더 간단한 방법을 찾아보았다.
4.정적으로 로드밸런싱할 서버를 명시하기
Eureka를 Discovery Client로 사용하지 않아도 스프링이 기본으로 제공해주는 Simple Discovery Client를 사용해서 로드밸런서를 구현할 수 있다.
해당 방식의 장점은 “간단함” 그 자체이고
단점은, 정적으로 인스턴스 서버들을 등록해야 한다는 점이다.
하지만, 이 단점은 나에겐 상관없는 부분이기에 이 방법으로 로드밸런서를 구현해보자…
gradle에
implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
implementation 'org.springframework.cloud:spring-cloud-starter-loadbalancer'
의존성을 추가해준 후 application.yml을 아래처럼
spring:
cloud:
gateway:
routes:
- uri: lb://hello-service # Load Balancer URI handled by ReactiveLoadBalancerClientFilter
predicates:
- Path=/hello
loadbalancer:
configurations: health-check # Required for enabling SDC with health checks
discovery:
client:
simple: # SimpleDiscoveryClient to configure statically services
instances:
hello-service:
- secure: false
port: 8090
host: localhost
serviceId: hello-service
instanceId: hello-service-1
- secure: false
port: 8091
host: localhost
serviceId: hello-service
instanceId: hello-service-2
설정해주면 끝이다.
정말 직관적이고 간단하게 로드밸런서를 등록할 수 있다.
단, 해당 방식에서 health check를 /actuator/healt로 체크를 하기에 인스턴스 서버에는 actuator를 추가해줘야 한다.
액츄에이터를 추가한 후 인스턴스 서버들을 쭉 올려주고 /hello 로 요청을 보내보면 인스턴스 서버에서 응답을 하는 것을 확인할 수 있다.
인증 기능 구현하기
서비스에서 인증이 필요한 부분의 경우 /open 로 따로 엔드포인트를 분류하고 그 외의 요청에 대해선 모두 인증을 필요로 하는 것이 목적이다.
또한, 추후 관리자 페이지를 업데이트 하는 시점에 따로 관리자 엔드포인트를 분류하고 403오류를 뱉어주는 필터도 추가할 예정이다.
API Gateway에선 받아온 인증 헤더의 JWT를 검사하고 유효하다면 api 요청 시 사용자의 username을 헤더에 추가로 담아 보내주게 할 것이고, 이미 jwt는 gateway에서 검사했으니 굳이 프록시된 서버로 넘겨줄 필요가 없기에 Authorization 헤더를 포함하지 않고 요청을 전송해보려 한다.
먼저 jwt 사용을 위해 의존성을 추가해주자
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.12.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.12.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.12.5'
그 다음, 설정 파일에 open 경로에 대한 예외 설정과 적용할 필터와 필터 생성 시 넘겨줄 인수를 작성해주자.
spring:
cloud:
gateway:
routes:
- id: open-service
uri: <http://localhost:8088>
predicates:
- Path=/open/**
- id: backend-route
# uri: lb://backend-service
uri: <http://localhost:8088>
predicates:
- Path=/**
filters:
- name: CustomAuthFilter
args:
headerName: Authorization
granted: Bearer
jwt:
secret: xx~~~~
/open 하위의 경로에 대해선 필터링 없이 요청을 보내게끔 하여 인증없이도 요청할 수 있게 하였고
그 외의 경로에 대해선 CustomAuthFilter를 적용하며 해당 필터 생성 시 args에 명시된 인수들을 전달하게 된다.
CustomAuthFilter
@Component
@Slf4j
public class CustomAuthFilter extends AbstractGatewayFilterFactory<CustomAuthFilter.Config> {
private final TokenProvider tokenProvider;
public CustomAuthFilter(TokenProvider tokenProvider){
super(Config.class);
this.tokenProvider = tokenProvider;
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
String authorizationHeader = exchange.getRequest().getHeaders().getFirst(config.headerName);
if (StringUtils.hasText(authorizationHeader) && authorizationHeader.startsWith(config.granted+" ")) {
String token = authorizationHeader.substring(7); // Bearer
try {
String username = tokenProvider.validateTokenAndGetUsername(token);
ServerHttpRequest request = exchange.getRequest();
request.mutate().header(CommonCode.HEADER_USERNAME.getContext(),username);
request.mutate().headers(httpHeaders -> httpHeaders.remove("Authorization"));
return chain.filter(exchange);
} catch (Exception e) {
log.error("Token validation error: {}, class : {}", e.getMessage(), e.getClass());
}
}
return unauthorizedResponse(exchange); // Token is not valid, respond with unauthorized
};
}
private Mono<Void> unauthorizedResponse(ServerWebExchange exchange) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
@Getter
@Setter
public static class Config{
private String headerName; // Authorization
private String granted; // Bearer
}
}
일반적인 jwt 기반 인증을 수행하는 filter이다. 다만, 처음 목적과 같이 인증에 성공했다면 사용자의 이름을 헤더에 추가해주고 jwt를 헤더에서 빼준다.
TokenProvider
@Component
@Slf4j
public class TokenProvider {
private final SecretKey key; // secret Key
public TokenProvider(@Value("${jwt.secret}") String secretKey
) {
byte[] secretByteKey = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(secretByteKey);
}
public String validateTokenAndGetUsername(String token) {
Jws<Claims> claimsJws = Jwts.parser().verifyWith(key).build().parseSignedClaims(token);
return claimsJws.getPayload().containsKey(CommonCode.HEADER_USERNAME.getContext()) ?
(String)claimsJws.getPayload().get(CommonCode.HEADER_USERNAME.getContext()) : "";
}
}
token을 privatekey로 인증하고 claim에서 username을 꺼내준다.
이제 테스트를 해보자
/open/123 (인증이 필요없는 요청)
/authTest (인증 필요 + 토큰 없음 ⇒ 실패)
/authTest (인증 필요 + 토큰 있음 ⇒ 성공)
인증 성공 후 요청을 넘겨 받은 서버에서 Request header를 쭉 뽑아보면
Authorization헤더는 빠지고 username 헤더는 포함되어 잘 넘어온 것을 확인할 수 있다.
application.yml 암호화
아무래도 퍼블릭 레포에 올라가는 상황에서 실제 서버 주소나 private key를 노출하기엔 위험하기에 먼저 작업을 수행해보려 한다.
workflow가 동작하는 순간에
microsoft/variable-substitution과 sed명령을 통해 application.yml의 빈 값을 github action에 등록된 값으로 채워 넣어주는게 목표다.
- name: Set yaml file
uses: microsoft/variable-substitution@v1
with:
files: ${{ env.RESOURCE_PATH }} // x/x/application.yml
env:
spring.datasource.url: ${{secrets.DB_URL}}
spring.datasource.username: ${{secrets.DB_USERNAME}}
spring.datasource.password: ${{secrets.DB_PASSWORD}}
jwt.secret: ${{secrets.JWT_SECRET}}
jwt.token-validity-in-seconds: ${{secrets.JWT_TOKEN_VALIDITY}}
이런 형식으로 설정 파일을 손쉽고 보기 편하게 채워넣을 수 있지만..
discovery:
client:
simple:
instances:
backend-service:
- secure: false
port: 80
host: address_be1
serviceId: backend-service
instanceId: backend-service-1
- secure: false
port: 80
host: address_be2
serviceId: backend-service
instanceId: backend-service-2
이렇게 명시적으로 구분되지 못하는 host 주소들의 경우 위의 방법으로는 값을 대체할 수 없기에 문자열 패턴을 기반으로 값을 바꿔넣어주는 방법이 필요하다고 생각했고 그 방법으로는 sed 명령어를 직접 수행시키는게 제일 적합해보였다.
sed 명령어는 대상 파일에 대해서 열단위로 특정 문자가 있는 지, 그리고 해당 문자가 있다면 수행할 작업을 수행할 지 등 파일 내 문자열의 패턴에 관한 작업을 수행해주는 명령어다.
내가 쓴 명령어를 예로 들면
sed "s/address_be1/abcd/g" >> application.yml
address_be1 이란 값을 가진 행을 찾고 해당 값을 abcd로 바꾼 후 파일에 저장한다는 뜻이다.
즉, sed “s/val 1/ val 2/g” 가 val1 → val2로 치환한다는 의미이고
파일을 그냥 붙이면 수정 후 바로 저장하는 형태가 아니기에 >>을 통해 수정 후 저장을 바로 해준다.
이제 넘어가서 본격적으로 kafka를 이용해서 게이트웨이 정보를 로깅해보자.
참고
https://spring.io/blog/2023/07/05/active-health-check-strategies-with-spring-cloud-gateway
https://spring.io/guides/gs/spring-cloud-loadbalancer
https://velog.io/@hongjunland/Spring-Cloud-Gateway를-통한-MSA-간의-라우팅-및-JWT-공통-인증
'Gyunpang' 카테고리의 다른 글
프로젝트 구조 중간점검 (0) | 2024.07.27 |
---|---|
8. Gateway 로깅 및 인증 기능 구현하기(2) (0) | 2024.07.23 |
(번외) docker container scale 조정 시 github action에서만 recreate된다.. (0) | 2024.04.13 |
6. 여러대의 인스턴스에 무중단 배포하기 (0) | 2024.04.06 |
5. Nginx + React 무중단 배포하기 (0) | 2024.03.26 |