Spring for Kubernetes

스프링으로 개발된 java 서비스를 kubernetes 기반에 서비스하는 방법에 대해서 간략히 살펴보자. 이를 쉽게하는 maven 라이브러리가 존재하는데 아래와 같이 pom.xml에 정의하고 사용할 수 있다.

	<properties>
		<java.version>1.8</java.version>
		<docker.prefix>demo</docker.prefix>
		<spring-cloud-dependencies.version>Finchley.RELEASE</spring-cloud-dependencies.version>
        <spring.cloud.k8s.version>1.0.1.RELEASE</spring.cloud.k8s.version>
	</properties>
	
	<dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud-dependencies.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-kubernetes-dependencies</artifactId>
                <version>${spring.cloud.k8s.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

aspectjweaver 모듈의 버전의 충돌이 있어 aspectj 라이브러리를 별도로 정의하게 된다. spring boot 의 버전을 2.0.x 에 맞추면 추가로 정의할 필요가 없다. 2.1 버전에 맞는 모듈이 나오는 것을 확인하고 boot 버전을 올리자.
현재 문서 작성 시점에는 2.0.9 버전을 사용하였다.

ConfigMap

동일 서비스가 각기 다른 환경에서 수행될 수 있다. 이를 위해서 profile 방식으로 속성 설정 파일을 구분해서 서비스가 동작하도록 구성되었다. 도커 기반의 배포에서 다양한 환경으로의 배포가 쉬워지면서 결국 다양한 설정 파일들이 필요한데 이를 컨테이너 기반의 동적인 설정 방식이 필요하다.

kubenetes 기반의 configmap 을 사용하기 위해서 pom.xml 에 아래와 같이 라이브러리를 정의해 주어야 한다.

	<dependency>
	    <groupId>org.springframework.cloud</groupId>
	    <artifactId>spring-cloud-starter-kubernetes</artifactId>
	</dependency>
	<dependency>
	    <groupId>org.springframework.cloud</groupId>
	    <artifactId>spring-cloud-starter-kubernetes-config</artifactId>
	</dependency>

config class

서비스 속성을 정의한 application.yaml 파일의 설정 값을 받을 수 있도록 config class 를 아래와 같이 작성한다.

@Configuration
@ConfigurationProperties(prefix="default")
public class DefaultConfig {
	private String name = "undefined";

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}
}

일반적인 방식으로 /src/main/resources 에 application.yaml 파일을 아래와 같이 정의한다.

spring:
  application.name: hello
server.port: 8080
default.name: Default

서비스에 접근하면 설정값을 반환하는 controller 를 아래와 같이 작성한다.

@RestController
class HelloController {
    @Autowired
    private DefaultConfig config;
    
	@RequestMapping("/")
	public String index() {
		String response = "Hello, world.(" + config.getName() + ")";
		System.out.println(response);
		return response;
	}
}

maven 빌드해서 도커 이미지를 등록하고 kubectl create 명령으로 배포한다. curl 프로그램으로 해당 서비스를 접근하면 아래와 같이 application.yaml 에 정의한 값으로 결과가 나타난다.

$ curl $(minikube service hello --url)
Hello, world.(Default)

create ConfigMap

YAML 파일로 아래와 같이 설정값을 정의한다. @ConfigurationProperties 를 이용해서 설정 클래스를 만들 수 있도록 applicaltion.properties 키 이름에 embeded 하여 설정값들을 정의한다. kubectl create 로 등록한다.

apiVersion: v1
kind: ConfigMap
metadata:
  name: hello
data:
  application.properties: |-
    default.name=Hello
    special.name=SpecialName

application.yaml 설정 파일에 kubernetes 의 config map 을 사용하도록 아래와 같이 수정한다. spring.cloud.kubernetes.config 항목이 추가된 것을 볼 수 있다. ConfigMap 이 hello 라는 이름으로 등록되어 있어서 이를 지칭하도록 구성한다.

spring:
  application.name: hello
  cloud.kubernetes:
    reload.enabled: true
    config:
      sources:
        - name: hello
server.port: 8080
default.name: Default

kubectl delete/create 로 위에 정의한 서비스를 다시 배포한다. spring java가 다 로딩되도록 잠시 기다린 후에 다시 서비스를 접근하여 결과를 보면 config map 에 정의된 값이 적용되었음을 알 수 있다.

$ curl $(minikube service hello --url)
Hello, world.(Hello)

RBAC

그러나 처음 서비스를 배포하고 설정을 잘 가져오는지 확인해보면 아직 생성된 값(Hello)이 바뀌지 않고 기존 설정값(Default)이 그대로 임을 알 수 있다. 뭔가 아직 더 남은 작업이 있음을 알 수 있다. 원인을 알기 위해 pod 의 로그를 확인하면 아래와 같은 오류를 발견할 수 있다.

2019-05-15 00:18:28.761 WARN 1 — [ main] o.s.cloud.kubernetes.StandardPodUtils : Failed to get pod with name:[hello-5d45d95969-brn7x]. You should look into this if things aren’t working as you expect. Are you missing serviceaccount permissions?
io.fabric8.kubernetes.client.KubernetesClientException: Failure executing: GET at: https://10.96.0.1/api/v1/namespaces/default/pods/hello-5d45d95969-brn7x. Message: Forbidden!Configured service account doesn’t have access. Service account may have been revoked. pods “hello-5d45d95969-brn7x” is forbidden: User “system:serviceaccount:default:default” cannot get resource “pods” in API group “” in the namespace “default”.

Kubernetes 클러스터는 역할 기반의 권한관리를 하고 있다. 실제 계정은 다른 계정관리 시스템에서 관리하고 이와 연계해서 권한관리가 되고 있다. 위의 오류 메시지를 토대로 현재의 서비스는 default 계정으로 동작하고 있고 configmap 에 접근할 수 있는 역할을 배정받지 못했기 때문이다. 가장 간단한 방법은 모든 권한을 가지고 있는 admin 역할을 default 계정에 부여하면 된다. 물론 운영환경에서는 위험한 결정일 수 있다. 아래 명령으로 계정에 권한을 부여하자.

kubectl create clusterrolebinding sa-admin \
--clusterrole=cluster-admin --serviceaccount=default:default

kubectl delete/create 로 재 배포하면 아래와 같이 설정값이 바뀌어서 나온다.

$ curl $(minikube service hello --url)
Hello, world.(Hello) 

TODO: 현재 reload 는 반영이 안되고 있음. 재배포를 해야 반영이 됨.

Ribbon

외부 서비스를 호출하는 경우 장애가 발생하면 대처하기 어렵다. 장애에 대비한 처리를 쉽게 추가할 수 있게 한다.

아래와 같이 pom.xml 에 ribbon 관련 라이브러리를 추가한다.

	<dependency>
		<groupId>org.springframework.cloud</groupId>
		<artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.cloud</groupId>
		<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
	</dependency>
	<dependency>
	    <groupId>org.springframework.cloud</groupId>
	    <artifactId>spring-cloud-starter-kubernetes</artifactId>
	</dependency>
	<dependency>
	    <groupId>org.springframework.cloud</groupId>
	    <artifactId>spring-cloud-starter-kubernetes-ribbon</artifactId>
	</dependency>

application.yaml 파일에 아래와 같이 ribbon 의 기본 설정 값을 지정한다.

backend:
  ribbon:
    eureka:
      enabled: false
    client:
      enabled: true
    ServerListRefreshInterval: 5000

hystrix.command.BackendCall.execution.isolation.thread.timeoutInMilliseconds: 5000
hystrix.threadpool.BackendCallThread.coreSize: 5

Ribbon 이 동작할 방식을 아래와 같이 설정 클래스를 통하여 정의한다. 상대 서비스의 확인 방법으로 ping url 을 사용하고 서버 관리 방식은 장애있는 서버는 다시 회복할 때까지 제외하는 방식을 규칙으로 사용한다. 빌드하기 전에 maven update 하는 것 잊지말자.

public class RibbonConfig {

	@Autowired
	IClientConfig ribbonClientConfig;

	@Bean
	public IPing ribbonPing(IClientConfig config) {
		return new PingUrl();
	}

	@Bean
	public IRule ribbonRule(IClientConfig config) {
		return new AvailabilityFilteringRule();
	}

}

application 이 필요한 모듈들과 같이 동작하도록 아래와 같이 annotation 들을 정의한다. ribbon client가 위에서 정의한 설정 클래스를 사용하도록 정의해 준다. 클러스터에 배포된 이름으로 서비스를 찾을 수 있도록 discovery client 도 사용한다.

@SpringBootApplication
@EnableDiscoveryClient
@EnableCircuitBreaker
@RibbonClient(name = "name-service", configuration = RibbonConfig.class)
public class HelloDockerApplication {
	@LoadBalanced
	@Bean
	RestTemplate restTemplate() {
		return new RestTemplate();
	}

호출할 대상의 서비스를 관리하는 클래스를 아래와 같이 작성한다. 호출에 실패할 경우 대신 처리할 함수의 이름을 선언하게 되어있다.

@Service
public class SlackService {

	private final RestTemplate restTemplate;

	public SlackService(RestTemplate restTemplate) {
		this.restTemplate = restTemplate;
	}

	@HystrixCommand(fallbackMethod = "getFallbackStatus", commandProperties = {
			@HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "1000") })
	public String getStatus() {
		return this.restTemplate.getForObject("https://name-service/", String.class);
	}

	private String getFallbackStatus() {
		return "{ \"health\": false }";
	}
	
    public String getUnsafeStatus() {
        return this.restTemplate.getForObject("http://name-service/", String.class);
    }

}

이제 서비스의 기능을 API 로 노출하도록 controller 에 함수를 추가한다.

@RestController
class HelloController {
    @Autowired
    private NameService service;
    
...
	
	@GetMapping("/health")
	public String slackCheck(@RequestParam(name="safe", 
	    defaultValue="true") boolean safe) {
		if (safe)
			return service.getStatus();
		else
			return service.getUnsafeStatus();
	}
}

kubectl 로 배포하고 아래 명령으로 실행 결과를 보자. 처음 실행하면 서비스 연결에 시간이 걸린다. 제한 시간을 1초로 해서 오류가 발생한다. 하지만 다시 실행하면 아래와 같이 성공되었음을 볼 수 있다.

$ curl $(minikube servith hello --url)/health
{ "health": true }

아래 명령으로 name-service 를 삭제하고 장애를 확인해 보자

kubectl delete -f name-service.yaml 

동일할 명령을 실행해 보면 아래와 같다.

$ curl $(minikube service hello --url)/health
{ "healthy": false }

보통 연관 서비스에 문제가 생기면 이를 이용한 서비스도 장애로 이어진다. 하지만 fallback 함수를 정의해서 문제 발생 시 대처할 수 있다. 위와 같이 오류가 발생하지 않고 결과를 보여줄 수 있다. 좀더 수준을 높인다면 캐시 등을 활용해서 사용하는 서비스의 장애에 대비하는 처리를 추가할 수 있겠다. 만일 ribbon 을 사용하지 않으면 아래와 같이 오류 메시지가 나타난다.

$ curl $(minikube service hello --url)/health?safe=false
{"timestamp":"2019-05-21T09:21:00.157+0000","status":500,"error":"Internal Server Error","message":"I/O error on GET request for \"http://name-service/\": com.netflix.client.ClientException: Number of retries on next server exceeded max 1 retries, while making a call for: name-service:80; nested exception is java.io.IOException: com.netflix.client.ClientException: Number of retries on next server 
exceeded max 1 retries, while making a call for: name-service:80","path":"/health"}

Ribbon 은 해당 서비스가 살아있는지 주기적으로 점검한다. 해당 서비스를 다시 배포해서 실행시키지 않으면 아래와 같은 로그가 쌓이게 된다.

Did not find any endpoints in ribbon in namespace [default] for 
name [name-service] and portName [null]

Secret

데이터베이스 접근에 필요한 계정 및 비번 같은 중요한 값들은 노출되지 않도록 보관 되어야 한다. 이러한 키와 값들을 관리하는 좋은 방법이 secret 이다.

kubectl create secret generic name-secret \
--from-literal=username=app_admin \
--from-literal=password=XpqlrmfOD328#\!2

비밀번호의 특수문자는(예 !) 앞에 \ 문자로 표기(예 \!)를 해주어야 한다. –from-file 옵션의 경우 파일을 지정할 수 있는데 파일 안에 기록할 경우는 그럴 필요가 없다.

아래와 같이 등록된 secret 을 확인할 수 있다. yaml 형식으로 그 내용을 볼 수 있는데 정의된 키명은 평문으로 보이고 그 값은 base64 로 변환되어 보여진다.

$ kubectl get secrets
NAME                TYPE                                DATA   AGE
name-secret         Opaque                              2      55s
default-token-5q86c kubernetes.io/service-account-token 3      7d
$ kubectl get secret name-secret -o yaml
apiVersion: v1
data:
  password: WHBkbHFtZk9EMzI4IyEy
  username: YXBwX2FkbWlu
kind: Secret
metadata:
  creationTimestamp: "2019-05-14T04:21:15Z"
  name: db-user-pass
  namespace: default
  resourceVersion: "157074"
  selfLink: /api/v1/namespaces/default/secrets/db-user-pass
  uid: b68ef20b-75ff-11e9-bd6c-080027aba474
type: Opaque

서비스에서 데이터베이스에 연결하려면 이렇게 정의된 secret 를 사용할 수 있어야 한다. 서비스를 배포하는 yaml 파일에 컨테이너가 실행하는 환경변수로 선언할 수 있다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: name-service
spec:
  ...
  template:
    metadata:
      labels:
        app: name-service
    spec:
      containers:
        ...
        - env:
            - name: DB_USERNAME
              valueFrom:
                secretKeyRef:
                  name: name-secret
                  key: username
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: name-secret
                  key: password

이전에 다룬 선언들은 … 표시로 생략하였다.

이제 컨테이너가 실행될 때, 환경 변수로 DB_USERNAME, DB_PASSWORD가 설정된다. 이 값을 application.properties 에 전달할 수 있도록 ${key_name} 형식으로 정의할 수 있다.

spring:
  application:
    name: name-service
  cloud.kubernetes:
    reload.enabled: true
    secrets.name: name-secret
  datasource: 
    url: jdbc:mysql://name-rds.cgbxh8v0llox.ap-northeast-2.rds.amazonaws.com:3306/database123?useUnicode=true&characterEncoding=utf-8&useSSL=false
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    driverClassName: com.mysql.jdbc.Driver
    maxActive: 500
  data.jpa.repositories.enabled: true
  jpa:
    hibernate.ddl-auto: update
    show-sql: false
    
server:
  port: 8080

기존의 속성 설정값에서 username, password 만 secret에 맞게 설정하였다. 서비스를 다시 배포하면 소스 상에는 정의되어있지 않지만 실행 시점에 컨테이너에 전달된 값으로 데이터베이스에 접속이 되는 것을 확인할 수 있다.

Reference

https://github.com/spring-cloud/spring-cloud-kubernetes
https://github.com/eugenp/tutorials/tree/master/spring-cloud/spring-cloud-kubernetes
https://medium.com/containerum/configuring-permissions-in-kubernetes-with-rbac-a456a9717d5d
https://github.com/wardviaene/kubernetes-course
https://www.poeticoding.com/create-a-high-availability-kubernetes-cluster-on-aws-with-kops/

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Google photo

Google의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Twitter 사진

Twitter의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

%s에 연결하는 중