이 연재글은 Spring Cloud 알아보기의 3번째 글입니다.

monolithic 아키텍쳐로 이루어진 시스템은 자원을 동적으로 할당하거나 해제하는 일이 빈번한 환경에서 유연하게 대처하기가 어렵습니다. 예를 들면 로드 밸런싱을 하거나 장애 복구 등이 빈번한 환경을 구축하려면 가용한 서비스 인스턴스에 대한 정보(host, port)를 누군가가 관리하고, 해당 정보를 동적으로 제공할 수 있어야 합니다. 그런데 monolithic 시스템은 처음부터 그러한 점을 고려하여 설계된 시스템이 아니기 때문에 위와 같은 기능을 도입하는데 비용이 많이 들고 효율적이지 못합니다. 클라우드에서는 위와 같은 서비스를 제공하는 시스템을 처음부터 고려하고 설계되었으며 통칭하여 Service Discovery라고 부릅니다. Service Discovery는 자신이 가지고 있는 호스트들의 정보를 클라이언트에게 전달하거나 정보를 업데이트 해주는 역할을 합니다. 클라이언트는 서비스가 가동되는 순간 Discovery서버에 자신의 네트워크 및 서버 정보를 전달하고 주기적으로 통신하여 정보를 갱신합니다. 연결된 클라이언트에서는 Discovery 서버를 통하여 동적으로 변하는 리소스에 대한 정보를 알수있습니다. 또한 다른 서버의 리소스에 접근할 때 변경 가능한 ip나 hostname이 아닌 애플리케이션 이름으로 접근 가능하게 해주므로 동적 리소스를 관리하는데 큰 장점을 가지게 됩니다. 

Eureka 서비스 구축

다음과 같은 설정의 Spring boot Eureka 프로젝트를 만듭니다.
spring-cloud-starter-netflix-eureka-server 라이브러리를 포함시킵니다.

신규 프로젝트를 초기 구성하는데 어려움이 있다면 Spring initializr를 통해 간편하게 프로젝트를 생성할 수 있습니다. 다음 포스팅을 참고해 주세요.
>> Spring initializr로 Spring 프로젝트 생성하기

build.gradle

plugins {
    id 'org.springframework.boot' version '2.1.5.RELEASE'
    id 'java'
}

apply plugin: 'io.spring.dependency-management'

group = 'com.spring'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

repositories {
    mavenCentral()
}

ext {
    set('springCloudVersion', "Greenwich.SR1")
}

dependencies {
    implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-server'
}

dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
    }
}

Application 설정

@EnableEurekaServer를 선언하여 Eureka 서버를 활성화합니다.

@EnableEurekaServer
@SpringBootApplication
public class EurekaApplication {
    public static void main(String[] args) {
        SpringApplication.run(EurekaApplication.class, args);
    }
}

bootstrap 설정

Config서버를 eureka에 클라이언트로 등록할것이므로 Config서버 설정은 하지 않습니다.
registerWithEureka: eureka 자신을 eureka 서버에 등록 할지 여부(eureka자신도 client가 될 수 있음)
fetchRegistry: client 서비스가 eureka 서버로 부터 서비스 리스트 정보를 local에 cache 할지 여부
추가 설정이 없기 때문에 bootstrap-local.yml과 boostrap-alpha.yml은 안만들어도 되지만 향후에 설정이 추가될수 있으므로 일단 profile 정보만 넣어서 만듭니다.

# bootstrap.yml
spring:
  application:
    name: eureka-discovery
  profiles:
    active: local
eureka:
  client:
    registerWithEureka: true
    fetchRegistry: false
# bootstrap-local.yml
spring:
  profiles: local
# bootstrap-alpha.yml
spring:
  profiles: alpha

eureka 환경정보를 Config서버에서 가져오도록 설정은 할수 있으나 그렇게하지 않은 이유는 Config서버를 eureka 클라이언트로 등록하는 설정을 추가할경우 서로간에 디펜던시가 생기기 때문입니다. 한쪽서버가 띄워져 있지 않은 상태에서 바라보는 서버가 실행되면 오류가 발생합니다. 실습에서는 Config서버를 eureka에 클라이언트로 등록해 사용할 것이므로 eureka의 설정은 내부설정을 따르도록 세팅합니다.

Eureka 대시보드 확인

Eureka 서버(port 8761) Application을 실행합니다. 아래 주소로 접근하면 Eureka 대시보드를 확인할수 있으며, 현재는 자기 자신만 등록되어 있는걸 확인할 수 있습니다.

http://localhost:8761/

Eureka Discovery에 클라이언트 서비스 등록

Eureka에 가용한 클라이언트 인스턴스들을 등록해 보겠습니다. 이전 장에서 생성한 zuul gateway, member-service, contents-service에 eureka client설정을 추가합니다.

클라이언트 서비스에 eureka 라이브러리 추가

gateway, member, contents 서비스에 다음 라이브러리를 추가합니다.

build.gradle

implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'

클라이언트 서비스 설정 파일에 eureka 정보 추가

server-configs 파일( member-service-local.yml, conents-service-local.yml, zuul-gateway-local.yml )에 eureka 접속정보를 추가합니다. alpha환경은 github에 내용을 추가하고 커밋합니다
preferIpAddress : 서비스간 통신 시 hostname 보다 ip 를 우선 사용 하도록 설정.

eureka:
  client:
    serviceUrl:
      defaultZone: ${EUREKA_URI:http://localhost:8761/eureka}
  instance:
    preferIpAddress: true

Application설정 추가

Application설정에 @EnableDiscoveryClient를 추가합니다. 설정을 추가하고 서버를 리스타트하면 클라이언트 정보를 Eureka 서버로 전송하게 됩니다.

// member-service
@EnableDiscoveryClient
@SpringBootApplication
public class MemberServiceApplication {
    .................
}
// contents-service
@EnableDiscoveryClient
@SpringBootApplication
public class ContentsServiceApplication {
    .................
}
// zuul-gateway
@EnableDiscoveryClient
@EnableZuulProxy
@SpringBootApplication
public class GatewayApplication {
    .................
}

Eureka Discovery 대시보드 확인

Config 서버, Eureka 서버가 실행되어 있는 상황에서 gateway, member, contents 서비스를 실행합니다. eureka 대시보드에 들어가면 서버스의 인스턴스들이 추가로 등록된것을 확인할 수 있습니다.

Eureka Discovery의 활용(1)

이번 장 처음에서 설명한 “다른 서버의 리소스에 접근할 때 변경 가능한 ip나 hostname이 아닌 애플리케이션 이름으로 접근 가능하게 해주므로”에 대한 실습을 해보겠습니다. 현재 Configuration서버에 설정되어있는 zuul-gateway-{profile}.yml의 routing설정은 다음과 같이 고정된 호스트와 port로 설정이 되어있습니다. 이 설정을 eureka를 이용하면 serviceId로 교체할수 있습니다.

# 변경 전 zuul-gateway-local.yml
zuul:
  routes:
    member:
      stripPrefix: false
      path: /v1/member/**
      url: http://localhost:8080
    pay:
      stripPrefix: false
      path: /v1/pay/**
      url: http://localhost:8081
    else:
      stripPrefix: false
      path: /v1/**
      url: http://localhost:8081

Gateway config 수정

아래와 같이 url을 serviceId로 변경 합니다. serviceId는 eureka 대시보드의 application name입니다.

# 변경 후 zuul-gateway-local.yml
zuul:
  routes:
    member:
      stripPrefix: false
      path: /v1/member/**
      serviceId: member-service
    pay:
      stripPrefix: false
      path: /v1/pay/**
      serviceId: contents-service
    else:
      stripPrefix: false
      path: /v1/**
      serviceId: contents-service

Gateway config 변경 내용 실시간 반영

gateway의 refresh endpoint를 호출하여 실시간으로 config를 반영합니다. 내용을 보면 seviceId로 설정이 교체되었음을 확인할 수 있습니다.

$ curl -XPOST http://localhost:9100/actuator/refresh
["zuul.routes.member.url","zuul.routes.pay.serviceId","zuul.routes.else.url","zuul.routes.member.serviceId","zuul.routes.pay.url","zuul.routes.else.serviceId"]

Gateway를 통한 서비스 호출

다음과 같이 memeber, contents 서비스를 호출해 봅니다. 실시간으로 설정이 적용되었고 serviceId로 각각의 서비스에 문제없이 접근할 수 있음을 확인할수 있습니다.

Gateway에서는 더 이상 클라이언트 서비스의 도메인이나 IP 등의 상세한 정보는 알 필요 없이 serviceId만으로도 인스턴스를 사용할 수 있게 되었습니다.

$ curl localhost:9100/v1/member/detail
Member Detail - Port 8080 - Hello Spring MemberService Local Env!!!!!!
$ curl localhost:9100/v1/pay/detail
Pay Detail - Port 8081 - Hello Spring ContentsService Local Env

Eureka Discovery의 활용(2)

여기까지 해봤다면 모든 서비스에 설정되어있는 Configuration서버의 접속 정보도 Eureka를 이용하여 serviceId로 교체할수 있는게 아닌가? 하는 생각이 드실겁니다. 실습해 보겠습니다. config서버도 eureka 클라이언트로 등록하기 위해 아래 내용을 진행합니다.

build.gradle

config서버에 eureka-client라이브러리를 추가합니다.

implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'

bootstrap.yml

application.name 및 eureka 서버 접속 정보를 추가합니다.

server:
  port: 9000
spring:
  profiles:
    active: native,local
  application:
    name: config-service
eureka:
  client:
    registryFetchIntervalSeconds: 5
    enabled: true
    serviceUrl:
      defaultZone: ${EUREKA_URI:http://localhost:8761/eureka}
  instance:
    preferIpAddress: true

Application 설정

@EnableDiscoveryClient를 명시하여 eureka client로 등록합니다.

@EnableDiscoveryClient
@EnableConfigServer
@SpringBootApplication
public class ConfigurationApplication {
    public static void main(String[] args) {
        SpringApplication.run(ConfigurationApplication.class, args);
    }
}

eureka 클라이언트 등록확인

configuration서버를 재시작하고 eureka 대시보드를 다시 확인해보면 config-server가 등록된것을 확인할 수 있습니다.

config서버를 사용하고 있는 client 서비스 설정 변경

contents-service를 타겟으로 하여 설정을 변경해 보겠습니다.

bootstrap-local.yml 수정

기존에는 config.uri가 명시되어 있었지만 삭제하고 eureka의 config-server의 serviceId로 접근하도록 설정 합니다.

spring:
  profiles: local
  cloud:
    config:
      #uri: http://localhost:9000
      fail-fast: true
      discovery:
        service-id: config-service
        enabled: true

테스트

contents-service를 재시작하고 url을 호출해 보면 정상적으로 호출 됨을 확인할 수 있습니다. 실습에서는 contents-service만 적용했지만, zuul-gateway, member-service도 동일하게 적용하면 serviceId로 config 서버에 접근 할수 있습니다. eureka discovery를 적용함으로써 cloud에서의 동적 자원의 관리가 어떤 것인지 실제로 체감 할 수 있게 되었습니다.

$ curl localhost:9100/v1/pay/detail
Pay Detail - Port 8081 - Hello Spring ContentsService Local Env

Eureka Discovery의 활용(3) – 동적 서비스 추가/삭제

이번에는 서비스를 동적으로 추가/삭제해보도록 하겠습니다. 현재 프로젝트에는 member-service와 contents-service두개가 zuul-gateway를 통해 서비스 중입니다.

service당 1개의 인스턴스만으로 서비스되고 있는 상황에서 예상치 못하게 접속자가 많아져서 트래픽을 처리할 서버를 늘려야 할때 어떻게 해야 할까요?

클라우드가 아닌 시스템에서 상황이 이러했다면 아마도 추가 인스턴스를 투입하기 위해 여러가지 시스템 작업이 필요했을 것입니다. 그 후에는 L4에 적용하는 과정도 필요했을 것이구요. 예상치 못한 상황에서 기존 시스템은 즉각적으로 대응하기가 어렵다는 사실을 알수 있습니다. 하지만 현재 실습중인 클라우드 환경에서는 다음과 같이 새로운 인스턴스를 하나 더 띄우기만 하면 작업이 끝납니다. 간단한 플로우는 다음과 같습니다.(틀릴수도 있습니다..)

대상 서비스에 추가로 인스턴스를 띄움 – eureka에 인스턴스가 자동 등록 – Gateway서버에서 서비스의 인스턴스 목록을 갱신하고 인스턴스가 여러대일경우 로드 밸런싱하여 서비스 제공(Ribbon)

$ java -jar -Dserver.port=8090 member-0.0.1-SNAPSHOT.jar

eureka 대시보드에 member-service의 인스턴스가 하나더 추가된것을 확인 할 수 있습니다.

zuul gateway를 통해 member-service를 여러번 호출하면 다음과 같이 2개의 서비스가 번갈아가며 실행되는것을 확인 할 수 있습니다.

$ curl localhost:9100/v1/member/detail
Member Detail - Port 8090 - Hello Spring MemberService Local Env
$ curl localhost:9100/v1/member/detail
Member Detail - Port 8080 - Hello Spring MemberService Local Env
$ curl localhost:9100/v1/member/detail
Member Detail - Port 8090 - Hello Spring MemberService Local Env
$ curl localhost:9100/v1/member/detail
Member Detail - Port 8080 - Hello Spring MemberService Local Env

인스턴스의 물리서버에 문제가 생겼다던지, 트래픽이 줄어들어 인스턴스의 개수를 줄여야 할땐 어떻게 해야 할까요?

서비스의 인스턴스를 종료 – eureka의 클라이언트 목록 자동 갱신 – zuul gateway의 Ribbon(Client측 로드밸런서)에서 인스턴스가 자동으로 빠지고 서비스 제공

위와 같은 점이 의미하는 점은 무엇일까요?
어떤 서비스를 운영함에 있어 추가 처리량이 필요한 경우 리소스를 쉽게 증설하고, 필요없거나 문제가 있을때는 리소스를 빠르게 축소할 수 있는 높은 가용성을 지니게 되었다는 것입니다. 방법이 비교적 쉬우니 시스템 인력이 필요치 않을수도 있구요. 다만 트래픽이 적고 몇대 안되는 인스턴스를 운영하는 서비스의 경우엔 이러한 클라우드 시스템을 도입하는것이 배보다 배꼽이 클수도 있겠습니다. 하지만 예측 불가능한 트래픽을 가진 높은 유동성을 필요로 하는 서비스를 운영하고 있다면 도입해볼 만한 충분한 가치가 있을것으로 판단됩니다.

추가) 우아한 종료

eureka는 관리중인 서비스가 종료 되었을때 동적으로 감지하여 eureka 레지스트리에서 삭제하는데 이때 즉각적으로 삭제가 수행되려면 client 서버들이 정상적으로 종료되었다는 신호를 보내야 합니다. 이렇게 하지 않으면 eureka의 health-check interval 동안은 종료된 서버가 삭제되지 않아 클라이언트가 오류 결과를 볼 수 있습니다. spring에서는 actuator를 통해 서버를 종료하는 방법을 제공합니다.

actuator 활성화

client서버(실습에서는 member-service, contents-service가 해당)의 build.gradle에 다음 내용을 추가합니다.

  implementation 'org.springframework.boot:spring-boot-starter-actuator'

config서버에서 관리하는 member-service, contents-service .yml 환경 파일에 actuator 설정을 추가합니다. 아래는 member-service의 환경파일로 management부터가 actuator 설정 내용입니다. 아래 내용을 설정하고 클라이언트 서버를 재시작하면 shutdown 가능한 endpoint가 활성화 됩니다.

spring:
  profiles: local
  message: Hello Spring MemberService Local Server!!!!!
eureka:
  client:
    registryFetchIntervalSeconds: 5
    serviceUrl:
      defaultZone: ${EUREKA_URI:http://localhost:8761/eureka}
  instance:
    preferIpAddress: true
management:
  endpoint:
    shutdown:
      enabled: true
  endpoints:
    web:
      exposure:
        include: shutdown,info

서비스 종료

actuator를 설정하고 나면 shutdown endpoint를 호출하여 서비스를 종료할 수 있습니다.

예) meber-service 종료

$ curl -XPOST http://localhost:8080/actuator/shutdown
{"message":"Shutting down, bye..."}

실습에서 사용한 코드는 아래 GitHub에서 확인하실 수 있습니다.

https://github.com/codej99/SpringCloudMsa/tree/feature/service-discovery

연재글 이동[이전글] Spring Cloud MSA(2) – Gateway(Routing & Filter) Server by Netflix zuul