티스토리 뷰
들어가기전에
기존 레거시(영어) 시스템의 배포 형태를 보면 전면에 WEB서버를 두고 WAS만 배포하는 형태 많은데 이런 구조로는 WAS가 늘어날 때마다 WEB서버의 설정을 바꿔야 하는 등 auto scalable 한 시스템 구조를 가질 수 없었다.
serivce state, pool, traffic 등 전체 컨테이너를 관리할 수 있는 쿠버네티스를 도입하면 좋겠지만 현재 조직 방향이 기술 스택을 늘리는 것에 집중할 수 없고 비즈니스 성과를 내야 하는 스타트업이거나 조직 구성이나, 높은 러닝 커브 등 상황에 맞지 않으면 선뜻 도입하기 어려운 게 현실이다.
물론 전체 시스템을 갈아 없는 것도 답은 아니다. 그럼 어떻게 해야 할까?
최대한 레거시 시스템을 유지한 채 auto scalable 한 시스템을 만들고 CI/CD 와 연계해 유연한 배포 환경까지 만들어보고 싶은 고민에서 시작됐다.
시스템 구성도
Java/SpringBoot 프레임워크를 운영 중인 입장에서 자동적으로 확장한 서비스의 state를 관리하고 트래픽을 보낼 수 있는 프레임워크로 SpringCloudGateway와 Eureka를 선택했다.
Eureka와 SGC는 SpringCloud에서 많이 사용하는 Service Discovery 용으로 서비스의 상태를 관리함으로써 상태에 따라 동적으로 트래픽을 라우팅 할 수 있는 프레임워크이다.
위 두 가지만 조합해 사용하여도 간단하게나마 auto scalable 한 구조를 갖출 수 있었다.
이런 구조에서 서비스 배포는 어떻게 해야 할까?
마찬가지로 Eureka를 사용하여 WAS를 종료하지 않아도 상태 값만 변경해서 서비스를 제거할 수 있었다.
좀 더 나아가 새 버전 서비스 그룹과 이전 버전의 서비스 그룹을 나눌 수 있다면 즉시 롤백할 수 있는 구성이 가능하다.
CI/CD 전략을 어떻게 가져갈지에 따라 롤링(Rolling) 배포, 카나리(Canary) 배포, blue-green 배포 모두 가능해 보인다.
서비스를 그룹핑하고 auto scalable 한 서비스를 감시하여 자동적으로 등록/제거할 수 있도록 Zookeeper를 선택했다.
필자는 배포 전략 중 하나로 blue-green 배포를 선택했으며 가상의 시나리오를 세우고 아키텍처를 구상했다.(로컬 환경에서 진행했음을 참고 바란다.)
blue-green 배포 전략은 현재 운영 중인 서버를 Blue, 신규 배포용 서버를 Green으로 정한 후 트래픽을 Blue에서 Green으로 점진적으로 전환하는 것이다. 또한 Blue 환경을 제거하지 않고 언제든 롤백할 수 있도록 대기시킨다. 더 자세한 설명은 구글링 해보길 바란다
서비스 변경 흐름도
CI/CD 또는 관리자를 통해 active_color 변경 이벤트가 발생하면 Zookeeper Client 서버가 감시하여 Standby 서버들은 서비스 가능하도록 Eureka서버에 등록(UP) 지시, Active 서버들은 서비스 해지(OUT_OF_SERVICE)를 지시한다.
blue-green 배포 전략은 무중단으로 이루어져야 하기 때문에 반드시 순차적으로 진행한다.
개발 환경
전체 소스코드는 아래 github에 배포했으니 참고 바란다.
https://github.com/swimming-lab/blue-green-deployment
GitHub - swimming-lab/blue-green-deployment: Zookeeper를 이용한 Blue/Green 배포 시스템 구축
Zookeeper를 이용한 Blue/Green 배포 시스템 구축. Contribute to swimming-lab/blue-green-deployment development by creating an account on GitHub.
github.com
1) Gateway : SpringBoot, Spring Cloud Gateway(https://spring.io/projects/spring-cloud-gateway)
MSA 환경에서 Dynamic Routing을 위해 사용한다.
이번 주제 범위에서 벗어나 간단한 Routing 만 할 수 있도록 했다.(Hello World 수준)
설정값만 기재하고 나머지 구현체는 이미 많은 블로그 글이 많기에 생략하겠다.
application.yml
server:
port: 8088
spring:
application:
name: gateway
cloud:
gateway:
routes:
- id: service-client
uri: lb://service-client
predicates:
- Path=/**
eureka:
instance:
hostname: localhost
instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}}
client:
eureka-server-port: 8761
serviceUrl:
defaultZone: http://${eureka.instance.hostname}:${eureka.client.eureka-server-port}/eureka/
registry-fetch-interval-seconds: 10
disable-delta: true
management:
endpoints:
web:
exposure:
include: "gateway"
2) Service Discovery : SpringBoot, Eureka Server(https://spring.io/projects/spring-cloud-netflix)
서버들의 서비스 상태를 관리하기 위한 Service Discovery로 사용한다.
Gateway와 마찬가지로 설정값만 기재 후 생략하겠다.
application.yml
spring:
freemarker:
template-loader-path: classpath:/templates/
prefer-file-system-access: false
application:
name: service-discovery
server:
port: 8761
eureka:
instance:
hostname: localhost
client:
registerWithEureka: false
fetchRegistry: false
serviceUrl:
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
server:
response-cache-update-interval-ms: 1000
3) Service : SpringBoot, Eureka Client, Zookeeper
비즈니스 로직을 수행할 어플리케이션 서버들이며 DiscoveryClient로 Eureka를 사용하여 Eureka에 서비스 상태를 등록하고,
어플리케이션(SpringBoot) 구동 시 Zookeeper Server에 Runtime-Color 노드에 등록한다.
Zookeeper Client로부터 Active / Standby 상태를 변환 요청을 받아 Eureka Server로 서비스 상태 값을 변경하여한다.(UP/OUT_OF_SERVICE)
핵심 로직만 발취해 기재했으니 전체 소스코드를 참고하길 바란다.
application.yml
server:
port: 0
runtime-color: blue
eureka:
instance:
hostname: localhost
instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}}
initial-status: out_of_service
client:
eureka-server-port: 8761
serviceUrl:
defaultZone: http://${eureka.instance.hostname}:${eureka.client.eureka-server-port}/eureka/
enabled: true
spring:
application:
name: service-client
zookeeper:
server:
port: 2181
timeout: 60000
hostname: localhost:${zookeeper.server.port}
node:
leader: /leader
active-color: /active_color
blue: /blue
green: /green
ZookeeperHandler.java
// Zookeeper 등록
private void serviceRegister() {
try {
logger.info("runtime-color={}", runtimeColor);
if (runtimeColor.equals(ActiveColor.blue.name())) {
runtimeColorNode = blueNode;
} else {
runtimeColorNode = greenNode;
}
zooKeeper.create(
new StringBuilder(runtimeColorNode).append("/").append(eurekaHandler.getInstanceId()).toString(),
new StringBuilder("127.0.0.1:").append(port.get()).toString().getBytes(StandardCharsets.UTF_8),
ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL);
} catch (Exception e) {
e.printStackTrace();
}
}
EurekaHandler.java
// 서비스 등록 or 해지 요청 수신 Controller
@PostMapping(SET_STATUS_API_API)
public ResponseEntity restApiSetStatus(
@RequestParam(required = true) String instanceId,
@RequestParam(required = true) String status) {
logger.info("REST API {} [instanceId={}, status={}]", SET_STATUS_API_API, instanceId, status);
if (!getInstanceId().equals(instanceId)) {
logger.error("eureka status 변경 실패, 이유: instanceId 다름 > {} != {}", getInstanceId(), instanceId);
return new ResponseEntity(HttpStatus.BAD_REQUEST);
}
try {
setStatus(InstanceInfo.InstanceStatus.toEnum(status));
} catch (Exception e) {
return new ResponseEntity(HttpStatus.BAD_REQUEST);
}
return new ResponseEntity(HttpStatus.OK);
}
...
// 서비스 등록 or 해지
private int setStatus(InstanceInfo.InstanceStatus instanceStatus) {
InstanceInfo instanceInfo = eurekaClient.getApplicationInfoManager().getInfo();
int responseStatusCode = eurekaHttpClient.statusUpdate(instanceInfo.getAppName(), instanceInfo.getInstanceId(), instanceStatus, instanceInfo).getStatusCode();
if (responseStatusCode == 200) {
logger.info("Eureka status 변경 성공. [timestamp={}, responseStatusCode={}, status={}]", System.currentTimeMillis(), responseStatusCode, instanceStatus.toString());
} else {
logger.error("Eureka status 변경 실패. [eurekaHttpResponse.getStatusCode()={}]", responseStatusCode);
}
return responseStatusCode;
}
4) Zookeeper Client : SpringBoot, Zookeeper(https://zookeeper.apache.org/doc/r3.6.3/javaExample.html)
Zookeeper Server의 /active_color를 감시하며 변경 시 Service에게 등록/해지 지시를 내린다.
/blue, /green를 감시하며 새로운 Service 가 등록되어 active_color와 맞으면 서비스 등록 지시를 내린다.
Blue/Green 배포 전략의 기본인 무중단을 유지하기 위해 한 대씩 순차적으로 등록/해지한다.
leader 선출을 통해 가용성을 높인다.(소스코드에는 포함되어 잇지만, 이번 로컬 환경에는 단일 서버로 구성했다.)
application.yml
server:
port: 0
eureka:
instance:
hostname: localhost
instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}}
client:
eureka-server-port: 8761
serviceUrl:
defaultZone: http://${eureka.instance.hostname}:${eureka.client.eureka-server-port}/eureka/
spring:
application:
name: zookeeper-client
zookeeper:
server:
port: 2181
timeout: 60000
hostname: localhost:${zookeeper.server.port}
node:
leader: /leader
active-color: /active_color
blue: /blue
green: /green
ZookeeperHandler.java
// 리더 선출
private void leaderSelect() {
try {
logger.info("리더 경합 시작");
zooKeeper.create(
leaderNode,
new StringBuilder("127.0.0.1:").append(port.get()).toString().getBytes(StandardCharsets.UTF_8),
ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL);
String currentActiveColor = new String(zooKeeper.getData(activeColorNode, false, null));
setActiveColorNode(currentActiveColor);
zookeeperMonitor.watchNode(activeColorNode);
zookeeperMonitor.watchChildren(activeNode);
isLeader = true;
logger.info("리더 경합 완료 > 리더로 선정됨");
} catch (Exception e) {
isLeader = false;
} finally {
if (!isLeader) {
followerSelect();
}
}
}
...
// active_color 변경 이벤트 트리거
@Override
public synchronized void existsListener(byte[] data) {
if (isLeader) {
try {
if (data != null) {
activeColor = new String(data);
logger.info("active_color 변경");
logger.info("activeColor={}", activeColor);
List<String> activeWorkers = zooKeeper.getChildren(activeNode, false);
List<String> standbyWorkers = zooKeeper.getChildren(standbyNode, false);
logger.info("activeWorkers={}", activeWorkers);
logger.info("standbyWorkers={}", standbyWorkers);
for (String instanceId : activeWorkers) {
String instanceDomain = new String(zooKeeper.getData(
new StringBuilder(activeNode).append("/").append(instanceId).toString(),
false,
null));
serviceHandler.changeService(instanceId, instanceDomain, EurekaStatus.OUT_OF_SERVICE.name());
}
for (String instanceId : standbyWorkers) {
String instanceDomain = new String(zooKeeper.getData(
new StringBuilder(standbyNode).append("/").append(instanceId).toString(),
false,
null));
serviceHandler.changeService(instanceId, instanceDomain, EurekaStatus.UP.name());
}
String currentActiveColor = new String(zooKeeper.getData(activeColorNode, false, null));
setActiveColorNode(currentActiveColor);
zookeeperMonitor.watchChildren(activeNode);
}
} catch (Exception e) {
e.printStackTrace();
}
} else {
if (isInit) {
isInit = false;
} else {
logger.info("리더 사라짐");
leaderSelect();
}
}
}
...
// /blue, /green 자식 노드 변경 이벤트 트리거
@Override
public synchronized void getChildrenListener(String path, List<String> workers) {
try {
String currentActiveColor = new String(zooKeeper.getData(activeColorNode, false, null));
if (path.substring(1).equals(currentActiveColor)) {
logger.info("active node 추가");
logger.info("activeColor={}", activeColor);
logger.info("workers={}", workers);
for (String instanceId : workers) {
String instanceDomain = new String(zooKeeper.getData(
new StringBuilder(activeNode).append("/").append(instanceId).toString(),
false,
null));
serviceHandler.changeService(instanceId, instanceDomain, EurekaStatus.UP.name());
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
ZookeeperMonitor.java
해당 로직은 아래 주소에서 DataMonitor.java 샘플 코드를 참고해서 작성했다.
(https://zookeeper.apache.org/doc/r3.6.3/javaExample.html)
ServiceHandler.java
// 서비스 등록 or 해지 요청
public int changeService(String instanceId, String instanceDomain, String instanceStatus) {
RestTemplate restTemplate = new RestTemplate();
MultiValueMap<String, String> data = new LinkedMultiValueMap<String, String>();
data.add("instanceId", instanceId);
data.add("status", instanceStatus);
ResponseEntity<String> response = restTemplate.postForEntity(
new StringBuilder(SCHEME).append(instanceDomain).append(SET_STATUS_API_API).toString(),
data,
String.class);
logger.info("REST API RESPONSE [response.getStatusCode()={}]", response.getStatusCode().toString());
if (!response.getStatusCode().is2xxSuccessful()) {
return response.getStatusCode().value();
}
return HttpStatus.OK.value();
}
5) Zookeeper Server
https://zookeeper.apache.org/releases.html 에서 다운받아 실행할 수 있다.
wget https://dlcdn.apache.org/zookeeper/zookeeper-3.6.3/apache-zookeeper-3.6.3-bin.tar.gz
tar zxf apache-zookeeper-3.6.3-bin.tar.gz
./apache-zookeeper-3.6.3-bin/bin/zkServer.sh start
Node는 아래와 같이 구성되어 있다.
- /active_color : 서비스 중인 Color(blue/gree)를 가리키고, CI/CD 에서 Color를 변경한다.(Set)
Zookeeper Client에서 감시한다.(Watch)
- /leader : Zookeeper Client 중 한대를 leader를 선출하며 ephemeral Type으로 등록한다.(Create)
- /blue, /green : Service 서버들이 배포되면 자신의 Color와 같은 Node의 하위 경로에 Service를 등록한다.(Create)
6) CI / CD : 각자의 환경에 맞는 서비스 배포 환경(필자는 로컬에서 진행하므로 cmd로 직접 배포함)
jar 패키징 혹은 jar 실행 시 active_color를 지정(필자는 jar 실행 시 properties 값으로 지정함)
# Service 배포
java -jar ${workspace}/build/libs/service-client-0.0.1-SNAPSHOT.jar --server.runtime-color=${blue or green}
# Zookeeper /active_color 변경
./apache-zookeeper-3.6.3-bin/bin/zkCli.sh
set /active_color ${blue or green}
구현 화면
1) active_color = blue
blue 서비스가 active로 서비스 중
get /active_color
> blue
2) active_color = green
green 서비스가 active로 변경
set /active_color green
get /active_color
> green
결론
의도한 대로 blue-green 그룹의 상태 값을 변경하여 서비스가 즉시 바뀌는 것을 확인했다.
비록 ServiceDiscovery, Zookeeper 등 관리해야 할 서비스가 늘어나지만 불가피하게 레거시를 유지해야 하는 상황일 때는 괜찮은 구성으로 보인다.
그러나 쿠버네티스를 사용한다면 이 모든 게 필요하지 않다.(이것이 쿠버네티스를 사용하는 이유일까?)
더 나은 시스템을 위해 고민을 더 해봐야겠다.
'개발' 카테고리의 다른 글
gRPC spring-boot-starter (0) | 2021.06.08 |
---|---|
gRPC 알아보기 (0) | 2021.06.07 |
파이썬으로 슬랙(Slack) 알람 보내기 (0) | 2021.05.26 |
파이썬 셀레늄(selenium)으로 웹사이트 크롤링하기 (0) | 2021.05.26 |
- Total
- Today
- Yesterday
- HTTP/2
- AWS
- cloudfront
- spring-boot-starter
- KUBECTL
- Kubernetes
- Spring
- spring cloud
- 인그레스
- 쿠버네티스
- loadbalancer
- Java
- eks
- 슬랙
- springboot
- S3
- Selenium
- Ingress
- 슬랙알람
- 배포 전략
- gateway
- ALB
- slack
- python
- CURL
- Proto
- gRPC
- 파이썬
- protobuf
- k8s
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |