기본 콘텐츠로 건너뛰기

Spring Cloud Config

Spring Cloud Config는 분산된 환경에서 서버간 환경설정을 공유하는 방법을 제공하고 있다. 유사한 솔루션들이 있지만 Git과 연동해서 사용할 수 있고, 서버 재시작 없이 설정을 적용하는 방법을 제공하고 있다. 기본적인 컨셉과 사용법을 정리해 둔다.

목차
1. Spring Cloud Config Server
  1. 요청 URL 형식과 응답포멧 
    2. Plain text 지원과 Placeholder 
  3. Spring Cloud Config Server 설정 
4. 동작확인 
2. Spring Cloud Config Client
1. Spring Cloud Config Client 설정 
2.환경변수 확인 
3. 설정변경과 Context Refresh 
3. 여러서버에 설정 동기화 하기
1. spring-cloud-starter-bus-redis 
2. Spring Cloud Bus 설정 
3. 설정파일 수정과 설정파일 동기화 
4. 결론
5. 예제파일
6. 참고


1. Spring Cloud Config Server

"Spring Cloud Config Server"는 설정을 읽어 갈 수 있는 몇 가지 endpoint를 제공하고 있다. Spring framework만 사용한다면 "Spring Cloud Config Server"와 "Spring Cloud Config Client"간 통신 방법을 알 필요가 없겠지만, Node.js같은 이기종 플랫폼에서 사용하려면 요청 URL 형식과 응답 포맷을 알아 둘 필요가 있다. 그리고 yml이나 properties가 아닌 설정파일도 읽을 수 있어서 요청 방법과 응답 형식에 대해서도 알아본다. 


  > 부가적으로 암복호화 기능도 있다. 예를 들어 설정파일에 패스워드를 넣어둘 때 Config Server에서 암호화해두고 클라이언트에서 복호화해서 사용할 수 있는데, spring security와 JCE가 필요하고 "Spring Cloud Config" 기능을 이해하는 수준에서는 필요하지 않아서 제외했다.


1.1 요청 URL 형식과 응답포멧

서버 실행 전에 요청형식과 결과부터 살펴보자.

curl -s http://localhost:8888/foo/default \ // a)
| python -mjson.tool 
{
    "label": null,
    "name": "foo",
    "profiles": [
        "default"
    ],
    "propertySources": [ // b)
        {
            "name": "file:***/config-repo/foo.yml", // c)
            "source": { // d)
                "foo.value": "FOO-value",
                "nginx.server.name": "foo.fs.com"
            }
        },
        {
            "name": "file:***/config-repo/application.yml",
            "source": {
                "foo.value": "APP-value"
            }
        }
    ],
    "version": "0b75e529da8289ec97336ccdc71c0cbe24996123" // e)
}

a) 리소스를 읽는 형식이 있다.
    - /{application}/{profile}[/{label}] 
    - /{application}-{profile}.yml 
    - /{label}/{application}-{profile}.properties 
    - /{application}-{profile}.properties 
    - /{label}/{application}-{profile}.yml 

{application} : 클라이언트bootstrap.yml 에 spring.application.name
{profile} : 클라이언트 spring.active.profiles
{label} : 특정 버전으로 명시된 설정파일들의 묶음을 의미. Git에서는 브랜치이름, 커밋ID 또는 태그값이 된다. 필수 파라미터는 아니다.

그래서 예제는 foo이름을 가진 클라이언트 애플리케이션의 기본 설정을 읽겠다는 의미가 된다.

b) "propertySources" 여러 propertySource를 리턴한다
Spring Boot은 기본 Profile이 아닐 경우 기본 설정값도 참고되기 때문에 요청된 Profile이 기본 Profile이 아니면 기본 Profile도 같이 반환된다. Profile에 없는 값은 부모(기본 Profile)에서 값을 찾기 때문이다.

그리고 /foo/default,development 과 /foo/development 의 결과값은 같다 - 멀티 프로파일은 콤마(,)로 구분 해서 요청 할 수 있다.

c) 설정파일 URL
예제는 프로토콜이 file이다. 실제 서비스에서는 정책에 따라 Git 저장소를 지정하거나 예제처럼 File Protocol을 사용할 수 있다. (File Protocol을 사용할 경우라 할지라도 지정된 디렉터리는 Git commit을 한번 이상 실행된 디렉터리 이어야 한다. 단 native Profile일 경우는 다르다)

d) “source"는 설정파일 전체 내용
yml 파일 포맷이지만 dot(.)으로 구분된 Key로 표현된 JSON을 출력한다. Placeholder가 있으면 치환된 값으로 표기된다. (1.2에서 설명)
e) git 커밋 ID





1. 2 Plain text 지원과 Placeholder



yml이나 properties 포맷 이외에 파일들은 plain text로 읽을 수 있다. 예를 들어 "nginx.conf" 같은 설정은 /{application}/{profile}/{label}/{path} endpoint를 사용할 수 있다. 

nginx.conf 파일을 읽어보면 아래와 같이 출력된다.


curl http://localhost:8888/foo/default/master/nginx.conf
server {  
    listen              80;
    server_name         foo.fs.com;
}

그렇지만 실제 nginx.conf 파일은 출력된 결과와는 다르다.

server {  
    listen              80;
    server_name         ${nginx.server.name};
}


일반적으로 nginx.conf 파일은 설정 거의 같지만 애플리케이션과 애플리케이션 Profile별로 일부 값이 다르다. 이럴 때 nginx.conf에 ${KEY_NAME} 형식으로 Placeholder를 기술해 주면 치환된 값을 받을 수 있다.


1.3 Spring Cloud Config Server 설정

이제 서버측 코드를 작성해 보자.

build.gradle

buildscript {
    ext {
        springBootVersion = '1.3.2.RELEASE'
        sprintCloudConfigStarterVersion 
          = 'Brixton.BUILD-SNAPSHOT' // a)
        springCloudBusVersion = '1.1.0.M4'

        gradleDependencyManagmentVersion 
          = '0.5.2.RELEASE'
    }
    repositories {
        mavenCentral()
    }
    dependencies {
      classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
      dependencies {
        classpath("io.spring.gradle:dependency-management-plugin:${gradleDependencyManagmentVersion}")
        }
    }
}

group 'fs.playground'
version '1.0-SNAPSHOT'

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

repositories {
    mavenCentral()
    maven {
        url ("http://repo.springsource.org/snapshot")
    }
    maven {
        url ("http://repo.springsource.org/milestone")
    }
}

dependencyManagement {
    imports {
        mavenBom("org.springframework.cloud:spring-cloud-starter-parent:${sprintCloudConfigStarterVersion}")
    }
    imports {
        mavenBom("org.springframework.cloud:spring-cloud-bus:${springCloudBusVersion}")
    }
}

dependencies {
 compile("org.springframework.cloud:spring-cloud-config-server")
// compile("org.springframework.cloud:spring-cloud-config-monitor")
// compile("org.springframework.cloud:spring-cloud-starter-bus-redis")
}

a) 버전은 Brixton.BUILD-SNAPSHOT을 사용한다 - 그냥 현재 최신 버전을 사용했다는 의미다.

-----

bootstrap.yml // a)
info:
  component: Config Server
spring:
  application:
    name: configserver
  cloud:
    config:
      server:
        git:
          uri: file:${CONF_PATH} // b)
server:
  port: 8888 // c)


a) bootstrap.yml 
Spring Cloud는 크게 "Spring Cloud Context"와 "Spring Cloud Commons"로 구성된다. "Spring Cloud Context"는 클라우드 애플리케이션의 ApplicaitonContext에서 bootstrap context, encryption, refresh scope, environment endpoint와 같은 서비스를 제공하는 라이브러리이고, "Spring Cloud Commons"는 "Spring Cloud Nexflix"와 "Spring Cloud Consul"과 같이 다른(여러) Spring Cloud 구현에 사용되는 공통 라이브러리를 말한다.

Spring Cloud 애플리케이션은 부모 context에 해당하는 bootstrap context를 생성하는데 bootstrap.yml이 bootstrap context에 사용되는 파일이다. 참조되는 시점을 제외하면 application.yml 파일과 같다. 그래서 Profile별로 구분될 수도 있다.

b) 저장소 URI
로컬 디렉터리를 저장소를 사용 하면 변경된 내용을 바로 적용해 볼 수 있으므로 예제는 편하게 "File Protocol"로 사용한다. (Github에 설정파일을 두고 push 하면서 테스트해도 무방하다)

c) 서버포트

-----

ConfigServer.java


import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.config.server.EnableConfigServer;

@SpringBootApplication
@EnableConfigServer // a)
public class ConfigServer {

    public static void main(String... args) {
        SpringApplication.run(Application.class, args);
    }

}

a) @EnableConfigServer 어노테이션만 등록해 주면 된다.


1.4 동작확인

예제를 실행 하려면 Gradle이 설치 되어 있어야 한다.

export CONF_PATH=***/config-repo && ./gradlew bootRun

예제는 저장소를 로컬파일(bootstrap.yml->b 참고)로 사용하고 있어서 환경변수로 CONF_PATH를 넘겨준다. (https://github.com/freestrings/config-repo.git 에서 받은 후 CONF_PATH=/doc/config-repo와 같이 지정하면 된다)


curl -s http://localhost:8888/foo/default | python -mjson.tool

Git Version 까지 잘 출력된다면 정상적이다.



2. Spring Cloud Config Client
이제 Client에서 어떻게 Configuration Server에 설정된 값들을 읽고 변경된 내용을 반영할 수 있는지 살펴보자.


2.1 Spring Cloud Config Client 설정

build.gradle

buildscript {
    ext {
        springBootVersion = '1.3.2.RELEASE'
        sprintCloudConfigStarterVersion = 'Brixton.BUILD-SNAPSHOT'
        springCloudBusVersion = '1.1.0.M4'

        gradleDependencyManagmentVersion = '0.5.2.RELEASE'
    }
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
        dependencies {
            classpath "io.spring.gradle:dependency-management-plugin:${gradleDependencyManagmentVersion}"
        }
    }
}

group 'fs.playground'
version '1.0-SNAPSHOT'

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

repositories {
    mavenCentral()
    maven {
        url ("http://repo.springsource.org/snapshot")
    }
    maven {
        url ("http://repo.springsource.org/milestone")
    }
}

dependencyManagement {
    imports {
        mavenBom("org.springframework.cloud:spring-cloud-starter-parent:${sprintCloudConfigStarterVersion}")
    }
    imports {
        mavenBom("org.springframework.cloud:spring-cloud-bus:${springCloudBusVersion}")
    }
}

dependencies {
    compile("org.springframework.cloud:spring-cloud-config-client") //
    compile("org.springframework.boot:spring-boot-starter-web")
    compile("org.springframework.boot:spring-boot-starter-actuator") // a)
//    compile("org.springframework.cloud:spring-cloud-starter-bus-redis")
}

a) actuator를 사용해야 ApplicationContext Life Cycle에 관련된 /refresh, /restart, /pause, /resume, /start, /stop과 같은 endpoint를 사용할 수 있다. 

-----

bootstrap.yml


spring:
  application:
    name: foo
  cloud:
    config:
      uri: http://localhost:8888

"spring.cloud.config.uri"에 "Spring Cloud Config Server" 주소를 기술한다. application.yml에 기술하면 웹서버가 실행될 때 설정을 읽어오지 못한다.

-----

ClientApplication.java

import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@RestController
@RefreshScope // a)
public class ClientApplication {

    @Value("${foo.value}")
    String name = "??";

    @RequestMapping("/")
    public String home() {
        return name;
    }

    public static void main(String[] args) {
        SpringApplication.run(ClientApplication.class, args);
    }

}

a) RefreshScope이 아니면 ApplicationContext를 재로딩 하지 못한다.

2.2 환경변수 확인


./gradlew bootRun
curl -s -X GET http://localhost:8080/env | python -mjson.tool
{
    "applicationConfig: [classpath:/application.yml]": {
        "foo.value": "DEVELOPMENT-useless",
        "spring.profile": "development"
    },
    "applicationConfig: [classpath:/bootstrap.yml]": {
        "spring.application.name": "foo",
        "spring.cloud.config.uri": "http://localhost:8888"
    },
    "configService:file:***/config-repo/application.yml":{// a)
        "foo.value": "APP-value"
    },
    "configService:file:***/config-repo/foo.yml": {// b)
        "foo.value": "FOO-value",
        "nginx.server.name": "foo.fs.com"
    },
    "defaultProperties": {
        "spring.application.name": "bootstrap"
    },
    "profiles": [],
    "server.ports": {
        "local.server.port": 8080
    },
    "servletContextInitParams": {},
    "springCloudClientHostInfo": { ... },
    "systemEnvironment": { ... },
    "systemProperties": { ... }
}

a), b) Spring Cloud Config Server를 통해 설정파일을 읽어 왔음을 알 수 있다.


curl http://localhost:8080                                   
FOO-value

컨트롤러에 바인딘됭 foo.value 값도 잘 반영되었음을 알 수 있다.


export SPRING_PROFILES_ACTIVE=development && ./gradlew bootRun
curl http://localhost:8080                                                      
FOO.DEVELOPMENT-value

그리고 프로파일을 바꿔서 실행해 보면 변경된 프로파일로 적용됨었음을 알 수 있다.


2.3 설정변경과 Context Refresh

이제 설정을 수정하고 재시작 없이 수정된 값을 적용해 보자. 간단히 Spring Cloud Config Server에 지정한 저장소의 설정파일을 변경한 후 refresh 시키면 된다.


cd <config_repo>

vi foo.yml
foo.value: FOO-changed!!

curl http://localhost:8080
FOO-value // a)

a) 아직 값이 바뀌지 않았다. Scope를 강제로 refresh 하기 전까지는 변경 되지 않는다.

curl -X POST http://localhost:8080/refesh

/refresh 는 어프리케이션 context를 리로딩할 수 있게 HTTP나 JMX로 노출된 endpoint다.

상태를 가진 Bean은 설정이 바뀌면 문제가 될 소지가 있음을 알아야 한다. 가령 풀링된 DataSource URL이 동적으로 바뀐다면 문제가 되기 때문에, Refresh ScopeBean은 Lazy Proxy로 동작해서 사용 될 때 (merthod가 호출될때) 초기화 되는데, Scope는 초깃값을 캐시하고 있다가 다음번 method 호출에 캐시를 비우고 Bean을 다시 초기화 하는 방식이다.

RefreshScope은 모든 Bean을 refresh하는 refreshAll() 메소드와 개별 Bean을 refresh(String)하는 method가 있는 Bean이다. /refresh endpoint로 기능이 노출되어있으며 POST method로 호출 되어야 한다.

-----

값을 다시 읽어 보면 바뀐값을 확인 할 수 있다.


curl http://localhost:8080                                   
FOO-value



3. 여러서버에 설정 동기화 하기


실제 서비스에는 서버 대수가 많아서 서버마다 수동으로 동기화하지 않는다. 물론, 간단한 스크립트를 작성해서 서버마다 /refresh를 호출해 주어도 되지만, 여기서는 spring-cloud-bus로 설정을 동기화 하는 방법에 대해 알아본다.

"Spring Cloud Bus"에 관한 글들을 찾아보면 /refresh 요청을 받은 Config Client가 Cloud Bus를 통해 다른 Config Client들에게 이벤트를 전파하는 아키텍쳐이다. 아래 그림이다.



그렇지만 저장소에서 설정이 변경되어 발생된 이벤트 처리와 전파는 "Cloud Config Server"에서 처리 하는게 맞기 때문에 아래와 같이 구성했다. (spring-cloud-config Notification 부분이나 관련글을 읽어보면 예제와 마찬가지로 monitor endpoint를 Spring Cloud Server에서 열고 있다.)


spring-cloud-bus는 "Filesystem watcher"를 기본 제공하고 있어 예제에서는 2. Notify changed -> b. Filesystem watcher가 사용된다.
Github이나 Bitbucket에 저장소를 두고 예제를 실행해 볼때는 /monitor endpoint를 WebHook으로 등록해야 하는데, public 도메인 문제가 있어 테스트하기 어렵다면 ngrok을 사용해 외부에서 접근 할 수 있는 도메인을 생성 하면 된다. 예를 들어 https://xxxx.ngrok.com/monitor와 같이 endpoint 등록 할 수 있다. 


3.1 spring-cloud-starter-bus-redis



현재 spring-cloud-bus는 Redis, RabbitMQ, Kafka 3가지 Message Broker가 지원되고, 예제는 Redis를 사용한다.

Redis가 없다면 설치 후 Redis Monitor를 띄워보자. (예제는 Docker를 사용했다.)


docker run --name redis -p 6379:6379 redis
docker exec redis redis-cli monitor
OK



3.2 Spring Cloud Bus 설정

예제 build.gradle에 주석만 제거하면 된다.
spring-cloud-config-server/build.gradle
dependencies {
    compile("org.springframework.cloud:spring-cloud-config-server")
    compile("org.springframework.cloud:spring-cloud-config-monitor")
    compile("org.springframework.cloud:spring-cloud-starter-bus-redis")
}

spring-cloud-config-client/buid.gradle
dependencies {
    compile("org.springframework.cloud:spring-cloud-config-client")
    compile("org.springframework.boot:spring-boot-starter-web")
    compile("org.springframework.boot:spring-boot-starter-actuator")
    compile("org.springframework.cloud:spring-cloud-starter-bus-redis")
}


3.3 설정파일 수정과 설정파일 동기화

"Spring Cloud Config Server"를 재시작 해 보자. Redis Monitor에 SUBSCRIBE가 하나 등록된다.

*********** [0 172.17.0.1:51258] "SUBSCRIBE" "topic.springCloudBus"

"Spring Cloud Config Client"는 여러개를 실행해야 하기 때문에 spring-cloud-config-client를 빌드하고 여러 인스턴스를 띄워보자.

cd spring-cloud-config-client

./gradlew build

cd build/libs
java -jar spring-cloud-config-client-1.0-SNAPSHOT.jar --server.port=9001
java -jar spring-cloud-config-client-1.0-SNAPSHOT.jar --server.port=9002
java -jar spring-cloud-config-client-1.0-SNAPSHOT.jar --server.port=9003

서버가 정상적으로 실행 되면 Redis Monitor에 SUBSCRIBE가 역시 하나씩 등록된다.

*********** [0 172.17.0.1:51258] "SUBSCRIBE" "topic.springCloudBus"
*********** [0 172.17.0.1:51258] "SUBSCRIBE" "topic.springCloudBus"
*********** [0 172.17.0.1:51258] "SUBSCRIBE" "topic.springCloudBus"

각 서버에 foo 값이 정상적으로 로딩 되었는지 확인해 보자. (위에서 FOO-changed로 변경했었다.)
curl -s -X GET http://localhost:9001
FOO-changed!!
curl -s -X GET http://localhost:9002
FOO-changed!!
curl -s -X GET http://localhost:9003
FOO-changed!!

이제 에디터에서 foo.yml에 foo.value를 다른값으로 수정해 보면, 몇 초 후 Redis Monitor에 PUBLISH 명령어가 출력되는걸 확인 할 수 있다.
************ [0 172.17.0.1:51429] "PUBLISH" "topic.springCloudBus" ...
************ [0 172.17.0.1:51429] "PUBLISH" "topic.springCloudBus" ...
************ [0 172.17.0.1:51429] "PUBLISH" "topic.springCloudBus" ...

그리고 각 웹서버에 foo 값을 조회해 보면 변경된 값으로 출력되는 것을 확인할 수 있다.
curl http://localhost:9001
FOO-redis-pubsub!!
curl http://localhost:9002
FOO-redis-pubsub!!
curl http://localhost:9003
FOO-redis-pubsub!!





4. 결론

Git 저장소를 벡엔드로 사용할 수 있기 때문에 설정파일의 이력관리나 애플리케이션별로 각자 브랜치 및 태그와 연동해서 운영 할 수 있는 점은 강점으로 생각한다. Spring Cloud Server 클러스터를 구성이나 refresh될 때 Block Time은 고려 되어야 할 문제로 보인다.



5. 예제코드




6. 참고





댓글