일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- Entity
- Streams
- Spring Data JPA
- PAGING
- consumer
- mirror maker2
- K8s
- CodePipeline
- producer
- AWS
- JPA
- bean
- Kubernetes
- Kotlin
- topic생성
- offsetdatetime
- API
- Spring JPA
- transactionaleventlistener
- QueryDSL
- entity graph
- CI
- kafka
- git
- cd
- centos7
- spring
- ECS
- mysql
- spring kafka
- Today
- Total
Yebali
Spring Cloud Gateway 동적으로 Route 변경하기 (feat. spring cloud config) 본문
스텝페이에서 적용했던 Spring Cloud Config을 사용하여
재시작 없이 Spring Cloud Gateway의 Route을 동적으로 추가하도록 했던 경험 남기기.
Spring Cloud Gateway란?
먼저 Spring Cloud Gateway은 Spring을 기반으로 API Gatewa기능을 제공해 주는 프로젝트이다.
일반적으로 MSA환경에서는 서버를 구성할 때 Gateway을 앞단에 배치하여 클라이언트들의 요청을 받고
요청 경로에 따라 알맞은 서비스에게 그 요청을 전달하게 한다.
이때, 요청을 어느 서비스에 전달할 지에 대한 Predicate, 요청/응답에 적용할 Filter 등의 정보를 가지고 있는 것을 Route라고 한다.
Route을 등록하는 기존 방식
일반적으로 Gateway의 route정보는 아래처럼 Bean으로 직접 등록하거나 .yml 파일에 명시하는 경우가 많다.
아래 두 방식으로 route을 만들면 '/api/store/admin/**,/api/store/manager/**,/api/store/client/**'경로로 요청을 받았을 때,
'JwtTokenHeaderGatewayFilterFactory' Filter을 거쳐 'http://localhost:8085'으로 요청을 보낸다.
직접 Bean으로 등록
@Configuration
class StoreRouter(
private val jwtTokenHeaderGatewayFilterFactory: JwtTokenHeaderGatewayFilterFactory,
) {
@Bean
fun routeStore(builder: RouteLocatorBuilder) = builder.routes {
route {
// 1. 아래 경로로 요청이 들어오면
path(
"/api/store/admin/**",
"/api/store/manager/**",
"/api/store/client/**"
)
// 2. 아래 filter을 적용해서
filters {
filter(jwtTokenHeaderGatewayFilterFactory.apply(JwtTokenHeaderConfig()), 0)
}
// 3. 아래 uri로 보낸다.
uri("http://localhost:8085")
}
}
}
YML 파일에 명시하기
cloud:
gateway:
routes:
- id: store
uri: http://localhost:8085
predicates:
- Path=/api/store/admin/**,/api/store/manager/**,/api/store/client/**
filters:
- JwtTokenHeaderGatewayFilterFactory
위 방법을 사용하면 Gateway 프로젝트 코드 상에서 Route 정보를 확인하고 수정할 수 있지만
Route정보가 변경될 경우 Gateway 서버가 재배포되어야 하는 단점이 있다.
이 때 Spring Cloud Config을 사용하면 서버 재배포 없이 Rroute 정보를 동적으로 변경할 수 있다.
Spring Cloud Config란?
Spring Cloud Config은 외부에 있는 설정 정보를 서버나 클라이언트에 제공하는 시스템이다.
일반적으로 git에 설정 정보를 저장하기 때문에 손쉽게 관리할 수 있다.
Spring Cloud Config은 설정 정보를 저장소(git)로부터 읽어와 제공해 주는 'Server'와
Server로부터 설정 정보를 읽어와 적용하는 'Client'로 구분한다.
Spring Cloud Config 적용하기
전체 구조
전체적인 구조를 살펴보면
먼저 Gateway의 Route와 관련된 설정은 Github에 저장한다.
그리고 Spring Cloud Config Server가 Github에 저장한 설정을 가져올 수 있도록 한다.
그 후 해당 설정을 사용할 Spring Cloud Config Client(Gateway Application)은
Spring Cloud Config Server로부터 Route 관련 설정을 가져와 적용한다.
만약 Github에 저장된 Route 설정이 변경되면 Spring Cloud Config의 monitor 기능을 사용하여 Route을 변경한다.
해당 과정은 먼저 Spring Cloud Config Server의 '[POST] /monitor'을 호출한다.
요청을 받은 Spring Cloud Config Server은 Message Queue(Kafka, RabbitMQ 등..)에 설정을 업데이트하라는 메시지를 발행한다.
그러면 해당 Queue을 구독하고 있는 Spring Cloud Config Client들은 Server에서 설정을 다시 받아와 설정을 업데이트한다.
Spring Cloud Config Server 구축
1. 설정 저장소(git) 생성
git에 저장소를 만들고 설정을 .yml 파일로 생성해 주면 된다.
구분하기 쉽기 위해 'configs' 디랙토리를 만들고 하위에 'gwadmin', 'gwcustomer'등 Service별로 한번 더 분리했다.
그리고 develop, staging, product 환경별로 설정이 달라질 수 있기 때문에
브랜치도 develop, release, main으로 분리했다.
Route에 대한 정보는 json의 형태로 작성했기 때문에 문법적인 실수를 하기 쉽다.
그래서 아래의 Github Actions와 Json Schema을 통해 Route 정보 수정 PR이 생길 때마다 json을 검증하도록 했다.
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"uri": {
"type": "string"
},
"predicates": {
"type": "array",
"items": {
"type": "string"
}
},
"customFilters": {
"type": "array",
"items": {
"type": "string"
}
},
"customRateLimiters": {
"type": "array",
"items": {
"type": "string"
}
},
"methods": {
"type": "array",
"items": {
"type": "string",
"enum": ["POST", "PUT", "DELETE", "OPTIONS", "GET"]
},
"uniqueItems": true
},
"rewritePaths": {
"type": "array",
"items": {
"type": "object",
"properties": {
"from": {
"type": "string"
},
"to": {
"type": "string"
}
},
"required": ["from", "to"],
"additionalProperties": false
}
}
},
"required": ["id", "uri", "predicates"],
"additionalProperties": false
}
}
# Github Actions
name: Validate Routes Json
on:
push:
branches: [ feature/*, fix/*, hotfix/* ]
jobs:
validate-routes-json:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install yq and ajv-cli
run: |
sudo snap install yq
npm install -g ajv-cli
- name: Find and validate JSON in all YAML files
run: |
SCHEMA_FILE=configs/schema.json
find configs -type f \( -name "*.yaml" -or -name "*.yml" \) | while read file; do
echo "Processing $file..."
JSON_DATA=$(yq e '.steppay."routes-json"' "$file")
if [[ ! -z "$JSON_DATA" && "$JSON_DATA" != "null" ]]; then
echo "$JSON_DATA" > temp.json
ajv validate -s $SCHEMA_FILE -d temp.json
if [ $? -ne 0 ]; then
echo "JSON Schema validation failed for $file"
exit 1
fi
rm temp.json
else
echo "No 'steppay.routes-json' found in $file"
fi
done
2. 의존성 추가
Spring + Kotlin 환경에서 아래와 같은 의존성을 추가해 주었다.
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
// config server
implementation("org.springframework.cloud:spring-cloud-config-server") // spring cloud config server
implementation("org.springframework.cloud:spring-cloud-config-monitor") // monitor 기능
implementation("org.springframework.cloud:spring-cloud-starter-bus-amqp") // MQ을 사용하기 위한 설정정
// actuator
implementation("org.springframework.boot:spring-boot-starter-actuator")
// test
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
3. Config Server 설정 추가
application.yaml에는 Github에서 정보를 가져오기 위한 인증 정보 등을 추가한다.
Private/Public key은 'ssh-keygen -M PEM -t rsa -b' 명령을 통해 생성하고,
Private key: spring.cloud.config.server.git.private-key에 넣어주고
Public key: Github > 계정 Setting > SSH and GPG keys > New SSH key에 넣어주면 된다.
환경별 .yaml에는 각각의 브랜치에서 설정을 가져올 수 있도록 default-label(기본 브랜치)을 명시해 주었다.
server:
port: 8080
spring:
application:
name: cloud-config-server
cloud:
config:
monitor:
endpoint:
# 아래처럼 설정하면 monotor기능을 사용하기 위한 Path가 아래와 같이 변경된다.
# [POST] http://localhost:8080/api/public/monitor
path: /api/public
server:
git:
uri: git@github.com:yebali/service-config.git # Github 주소
search-paths: configs/** # Github Repository에서 설정 정보를 찾기 시작하는 위치
ignore-local-ssh-settings: true
private-key: |
-----BEGIN RSA PRIVATE KEY-----
.
.
.
vnyks8spxRSJACOXK0ejeydjdEEUG27rDNVb6045fSZaOJgOgvBBo/Cd+i4mCSXr
sRYp1YysyJ45BpBcG4t4dSL1LEcKaPw5WpjnDEMC6P0aMztfNF7Niy39PJf6nXP8
.
.
.
-----END RSA PRIVATE KEY-----
rabbitmq:
host: ${RABBIT_MQ_HOST:127.0.0.1}
port: ${RABBIT_MQ_PORT:5672}
username: ${RABBIT_MQ_USER:rabbit_user}
password: ${RABBIT_MQ_PASS:rabbit_password}
virtual-host: ${RABBIT_MQ_VIRTUAL_HOST:/local}
management:
endpoint:
health:
probes:
enabled: true
health:
livenessState:
enabled: true
readinessState:
enabled: true
--- prod ---
spring:
cloud:
config:
server:
default-label: main # branch name
--- stg ---
spring:
cloud:
config:
server:
default-label: release # branch name
--- dev ---
spring:
cloud:
config:
server:
default-label: develop # branch name
4. 설정 불러오기
그리고 서버를 실행시킨 뒤 '[GET] http://localhost:8080/<config 하위 서비스 이름>/<환경>'으로
요청을 보내면 저장된 설정들이 불러와지는 걸 볼 수 있다.
환경에는 뜬금없이 'yebali'가 들어가 있는데,
원래 '[GET] http://localhost:8080/gwpublic/dev'을 요청했다면 gwpublic-dev.yaml 파일의 설정을 반환하겠지만
지금 예제에서는 파일별로 환경을 나누지 않고 Branch별로 환경을 나누었기 때문에 의미 없는 PathVariable이다.
Spring Cloud Config Client 설정
1. 의존성 추가
dependencies {
.
.
// config client
implementation("org.springframework.cloud:spring-cloud-starter-config")
implementation("org.springframework.cloud:spring-cloud-config-client")
implementation("org.springframework.cloud:spring-cloud-starter-bus-amqp")
.
.
}
2. Config Client 설정 추가
application.yaml에 아래와 같이 Config Server의 주소를 적어준다.
'optional'을 붙여주면 Config Server에서 설정을 불러오지 못해도 Client서버가 실행될 수 있게 해 준다.
추가로 환경별로 application.yaml을 만들어 적용하면 브랜치(환경)별 다른 설정을 적용할 수 있다
spring:
config:
import: optional:configserver:http://localhost:8080 # config-server path
3. Config Server에서 받아온 설정을 @Bean으로 등록하기
아래는 json형태로 저장된 route정보를 객체로 역직렬화하는 코드이다.
.yaml 파일에 저장된 설정들은 보통 @ConfigurationProperties 어노테이션을 사용해 가져오는 경우가 많지만
실행 중인 서버에서 Spring Cloud Config를 통해 동적으로 변경할 수 있는 범위는 @Bean으로 제한되기 때문에
@ConfigurationProperties을 사용하지 않고 @Component을 사용했다.
또한, @RefreshScope 어노테이션을 사용하여 설정이 변경된 것이 감지되었을 때 해당 @Bean에 반영되도록 했다.
@Component
@RefreshScope
class StepRouteProperty(
@Value("\${steppay.routes-json}")
private val routesJson: String,
private val objectMapper: ObjectMapper
) {
val routes: List<RouteConfig>
get() = objectMapper.readValue(routesJson, object : TypeReference<List<RouteConfig>>() {})
data class RouteConfig(
val id: String,
val uri: String,
val predicates: List<String>,
val customFilters: List<String> = emptyList(),
val customRateLimiters: List<String> = emptyList(),
val methods: List<String> = listOf("GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"),
val rewritePaths: List<RewritePath> = emptyList()
)
data class RewritePath(
val from: String,
val to: String,
)
}
4. 설정을 Route로 등록하기
위에 @Bean으로 만든 'StepRouteProperty'을 route로 만드는 과정이다.
위와 동일한 이유로 @RefreshScope을 사용했다.
@Configuration
class RouteLocatorConfig(
private val stepRouteProperty: StepRouteProperty,
private val builder: RouteLocatorBuilder,
private val filterFactories: List<AbstractGatewayFilterFactory<*>>,
private val environment: Environment,
) {
@Bean
@RefreshScope
fun steppayRouter() = builder.routes {
stepRouteProperty.routes.forEach { config ->
route {
metadata("id", config.id)
path(*config.predicates.toTypedArray()) and method(*config.methods.toTypedArray())
filters {
applyCustomFilters(config.customFilters)
config.rewritePaths.forEach { rewritePath(it.from, it.to) }
}
uri(environment.getProperty(config.uri))
}
}
}
private fun GatewayFilterSpec.applyCustomFilters(customFilterNames: List<String>) {
findFilterFactories(customFilterNames).forEach {
when {
it is JwtTokenFilter -> filter(it.apply(JwtTokenFilterConfig()), 0)
else -> {}
}
}
}
private fun findFilterFactories(names: List<String>): List<AbstractGatewayFilterFactory<*>> {
return filterFactories.filter { it::class.simpleName in names }
}
}
주의할 점 하나는 Route에 적용할 CustomFilter을 찾을 때 'simpleName'으로 Filter를 찾아오는데
'simpleName'에서 'GatewayFilterFactory' 문자열은 제외된다.
무슨 이유인지는 모르겠지만 'RouteDefinitionRouteLocator'에서 Filter를 찾을 때 사용하는 'factory.name()'을 살펴보면
내부에서는 normalizeFilterFactoryName()을 사용하고 있고
내부에서는 'GateWayFilterFactory'라는 문자열을 지우고 있다.
Route 확인하기
위 과정을 모두 마치고 아래와 같이 Actuator 설정을 추가하고
management:
endpoints:
web:
exposure:
include: gateway
'[GET] http://<Gateway 주소>/actuator/gateway/routes'으로 조회하면 아래와 같이 Route가 설정된 것을 볼 수 있다.
Route 설정이 변경될 때 자동으로 반영하기
Github Repo에서 변경된 Route정보가 자동으로 Gateway에 자동으로 변경되기 위해서 Github Actions을 사용했다.
develop, release, main 브랜치에 머지되었을 때 Config Server 설정에 추가했던 monitor의 endpoint를 호출하여
설정이 자동으로 반영될 수 있게 했다.
name: Reload gateway routes
on:
push:
branches:
- develop
- release
- main
jobs:
validate-routes-json:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install yq and ajv-cli
run: |
sudo snap install yq
npm install -g ajv-cli
- name: Find and validate JSON in all YAML files
run: |
SCHEMA_FILE=configs/schema.json
find configs -type f \( -name "*.yaml" -or -name "*.yml" \) | while read file; do
echo "Processing $file..."
JSON_DATA=$(yq e '.steppay."routes-json"' "$file")
if [[ ! -z "$JSON_DATA" && "$JSON_DATA" != "null" ]]; then
echo "$JSON_DATA" > temp.json
ajv validate -s $SCHEMA_FILE -d temp.json
if [ $? -ne 0 ]; then
echo "JSON Schema validation failed for $file"
exit 1
fi
rm temp.json
else
echo "No 'steppay.routes-json' found in $file"
fi
done
refresh-routes:
needs: validate-routes-json
runs-on: ubuntu-latest
steps:
- name: reload develop gateway routes
if: github.ref == 'refs/heads/develop'
run: |
curl -X POST -H "Content-Type: application/json" -d '{"path": ["**"]}' https://api.develop.steppay.kr/api/public/monitor
- name: reload staging gateway routes
if: github.ref == 'refs/heads/release'
run: |
curl -X POST -H "Content-Type: application/json" -d '{"path": ["**"]}' https://api.staging.steppay.kr/api/public/monitor
- name: reload production gateway routes
if: github.ref == 'refs/heads/main'
run: |
curl -X POST -H "Content-Type: application/json" -d '{"path": ["**"]}' https://api.steppay.kr/api/public/monitor
'Spring' 카테고리의 다른 글
Spring event와 Transaction 그리고 @EventListerner, @TransactionalEventListener (0) | 2024.11.08 |
---|---|
Spring Security + JWT 발급 (1) | 2024.11.06 |
Spring Boot의 Auto Configuration (0) | 2023.10.26 |
WebSocket 구현하기 feat.Spring (0) | 2023.02.10 |
Spring Collection 조회 성능 비교 (0) | 2022.12.25 |