티스토리 뷰

들어가기전에

기존 레거시(영어) 시스템의 배포 형태를 보면 전면에 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
링크
«   2025/05   »
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
글 보관함