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/

Getting start with Minikube

개발자 환경에서 간단하게 사용할 수 있는 k8s가 있어 시험적으로 사용하고 그 진행 과정을 정리해 보았다. 늘 기억의 한계가 있고 이렇게나마 기록을 해야 반복적인 실수를 줄일 수 있을 것 같아 거칠게라도 끄적여 본다.

Setup for mac os

설치를 편하게 하기 위해서 brew cask 를 사용한다. brew 에 설치할 수 있는 목록에 없는 앱들을 설치할 수 있다. brew-cask 참고 사이트

install docker

brew cask install docker

위의 명령으로 도커를 설치하면 docker, kubectl 명령을 사용할 수 있다.

install VM

맥에서 사용할 수 있는 vm 중에서 virtualbox 가 일반적이어서 이를 설치한다. vmware도 가능하나 유료이다. xhyve 역시 가능했지만 deprecate 되어서 최신 버전에서는 사용할 수 없다.

brew cask install virtualbox

설치 중에 오류가 나는데 맥 설정에서 oracle inc 의 앱들에 대해서 허가해 주고 다시 설치하면 성공적으로 설치가 완료된다.

install minikube

brew cask install minikube
minikube config set vm-driver virtualbox

minikube 설정 명령은 이전에 설치된 cluster 에 대해서 영향을 미치지 않기 때문에 삭제하고 다시 실행하라는 주의 사항이 나온다. 처음 실행 것이라면 흘려 넘기면 된다.

/usr/local/bin 아래에 kubectl, minikube 가 생성이 된다.

아래 명령으로 실행해보자.

$ minikube start
😄  minikube v1.0.1 on darwin (amd64)
🤹  Downloading Kubernetes v1.14.1 images in the background ...
🔥  Creating virtualbox VM (CPUs=2, Memory=2048MB, Disk=20000MB) ...
📶  "minikube" IP address is 192.168.99.100
🐳  Configuring Docker as the container runtime ...
🐳  Version of container runtime is 18.06.3-ce
⌛  Waiting for image downloads to complete ...
✨  Preparing Kubernetes environment ...
💾  Downloading kubelet v1.14.1
💾  Downloading kubeadm v1.14.1
🚜  Pulling images required by Kubernetes v1.14.1 ...
🚀  Launching Kubernetes v1.14.1 using kubeadm ... 
⌛  Waiting for pods: apiserver proxy etcd scheduler controller dns
🔑  Configuring cluster permissions ...
🤔  Verifying component health .....
💗  kubectl is now configured to use "minikube"
🏄  Done! Thank you for using minikube!

기존의 설치된 kubectl 이 버전이 안 맞을 수 있다. kubectl version 명령을 이용해서 클라이언트와 서버의 버전을 비교해 보면 알 수 있다. 안 맞는 경우는 minikube 를 설치하며 기존의 kubectl 을 대체하지 못해서 발생한다. 아래와 같이 이전 버전을 수동으로 대체시키면 된다.

sudo mv /usr/local/bin/kubectl /usr/local/bin/kubectl.org
brew link --overwrite kubernetes-cli

build container image

command line

자바 프로젝트에 아래와 같은 내용으로 Dockerfile 을 생성한다.

FROM openjdk:8-jre-alpine
VOLUME /tmp
ARG JAR_FILE
ADD target/${JAR_FILE} ${JAR_FILE}
ENV JAVA_OPTS=""
ENV JARFILE /${JAR_FILE}
EXPOSE 8080
ENTRYPOINT [ "sh", "-c", "java -Dspring.profiles.active=dev -jar $JARFILE" ]

JDK 는 8버전이고 가장 작은 크기인 jre-alpine 을 선택하였다. ADD 명령으로 jar 파일을 도커 안으로 추가한다. 컨테이너가 로딩되면 바로 실행할 명령을 ENTRYPOINT 에 기록한다. 바로 자바 프로젝트를 개발 환경으로 실행하는 명령이다. 실제 운영환경에서는 profile 을 dev 에서 prd 로 바꾸면 된다.

jar 파일을 JARFILE 변수명으로 처리한 것은 매번 배포할 때마다 바뀌는 버전에 따라 이 파일을 수정하지 않도록 변수로 처리하였다. pom.xml 파일에 지정된 jar 파일명을 이미지 빌드 시에 변수로 전달 받을 수 있다. dockerfile 에서 ARG 로 선언한다. java 실행 명령에도 파일명이 필요한데 sh 환경변수로 정의해서 명령어에 반영되도록 처리하였다.

도커 빌드를 사용해서 위에 정의된 dockerfile 에 따라 컨테이너 이미지를 빌드하기 위해 아래 명령을 실행한다.

docker build --build-arg JAR_FILE=hello-1.0.jar \
--tag demo/hello:1.0 .

이미지가 완성이 되면 아래 명령으로 컨테이너로 실행시킨다.

docker run -p 8080:8080 -d --rm --name hello -t demo/hello:1.0

-d 옵션으로 독립적으로 실행 되므로 터미널 창에서 다른 작업이 가능하다. –rm 옵션으로 컨테이너를 중지시키면 컨테이너 자체가 사라진다. 아니면 docker rm 명령으로 지워야한다. 아래 명령으로 컨테이너 실행 여부를 확인한다.

$ docker ps
CONTAINER ID  IMAGE           COMMAND                  CREATED
      STATUS              PORTS                    NAMES
ccd00eafafb6  demo/hello:1.0  "sh -c 'java -Dsprin…"   14 minutes ago
      Up 14 minutes       0.0.0.0:8080->8080/tcp   hello

컨테이너는 아래 명령으로 중지시킬 수 있다. 위 옵션 때문에 중지 후 바로 삭제가 된다. –rm 옵션이 없다면 STATUS 에 Exit 로 바뀌어 있고 목록에는 계속 나타난다.

docker stop hello

maven build

Maven 빌드를 이용해서 이미지를 생성할 수 있는데 dockerfile-maven-plugin 과 docker-maven-plugin 플러그인을 사용하면 된다. 아래와 같이 pom.xml 파일에 정의하면 된다.

우선 docker registry 에 동일 버전으로 계속 push 하게 되면 tag 명이 없이 이미지들이 쌓이게 된다. 따라서 아래와 같이 clean 단계에서 이미지를 지우는 작업을 추가할 수 있다.

   <build>
        <plugins>
            <plugin>
                <groupId>com.spotify</groupId>
                <artifactId>docker-maven-plugin</artifactId>
                <executions>
                    <execution>
                        <id>remove-image</id>
                        <phase>clean</phase>
                        <goals>
                            <goal>removeImage</goal>
                        </goals>
                        <configuration>
                            <imageName>${docker.prefix}/${project.artifactId}</imageName>
                            <imageTags>
                                <imageTag>${project.version}</imageTag>
                            </imageTags>
                            <verbose>true</verbose>
                        </configuration>
                    </execution>
                </executions>
                </plugin>

다음은 빌드된 jar 파일을 위에서 만든 dockerfile 에 따라 컨테이너 이미지에 넣기 위해 플러그인을 선언한다. 플러그인의 선언 순서는 “spring-boot-maven-plugin” 다음으로 위치해야 정상적으로 빌드된 jar 파일에 작업할 수 있다.

<plugin>
    <groupId>com.spotify</groupId>
    <artifactId>dockerfile-maven-plugin</artifactId>
    <version>1.4.3</version>
    <configuration>
        <repository>${docker.prefix}/${project.artifactId}</repository>
        <tag>${project.version}</tag>
        <buildArgs>
            <JAR_FILE>${project.build.finalName}.jar</JAR_FILE>
        </buildArgs>
    </configuration>
    <executions>
        <execution>
            <id>default</id>
            <phase>package</phase>
            <goals>
                <goal>build</goal>
            </goals>
        </execution>
    </executions>
</plugin>

변수로 선언된 부분에 들어가는 값을 아래와 같이 속성으로 정의해 주어야 합니다.

<properties>
    <java.version>1.8</java.version>
    <docker.prefix>demo</docker.prefix>
</properties>

프로젝트 폴더를 선택하고 마우스 우클릭으로 Run as > Maven build 를 하면 clean compile package 과정을 거치면서 위에 선언한 build goal 을 실행하게 된다. 처리 결과는 docker build 명령의 실행결과와 동일함을 알 수 있다.

run under minikube

minikube 의 동작 환경에 맞게 현재 터미널 세션의 환경 설정을 해야한다. 간단히 다음 명령으로 한번에 설정이 가능하다.

eval $(minikube docker-env)

위에서 생성한 image 를 다음 명령으로 실행할 수 있다. 옵션 중에 image-pull-policy 를 사용하는데 이는 로컬의 컨테이너 이미지를 사용하기 위해서는 Never 로 지정해 주어야 외부 docker 저장소에서 다운 받지 않고 로컬 이미지를 사용하게 되기 때문이다.

kubectl run hello --image=demo/hello:1.0 --port=8080 --image-pull-policy=Never

아래 명령으로 실행 결과를 확인할 수 있다.

$ kubectl get deployments
NAME      DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
hello     1         1         1            1           12s
$ kubectl get pods
NAME                   READY     STATUS    RESTARTS   AGE
hello-99f6c69f7-nj86p  1/1       Running   0          2m

주의사항

eclipse 에서 빌드하는 경우 docker 이미지를 찾을 수 없어서 STATUS 항목이 ErrImageNeverPull 로 표시가 된다. docker image list 명령으로 찾아 보면 해당 이름으로 등록된 이미지가 없다. 하지만 터미널에서 mvn 명령으로 빌드한 경우에는 등록이 된다. 이유는 eclipse 실행 환경과 터미널에 설정된 환경이 다르다. 위에서 터미널에서 실행한 환경 설정이 eclipse 에서는 안 되어있기 때문이다. 누락된 환경 설정을 eclipse 에 정의하여야 한다.

프로젝트 폴더 우클릭하고 Rus as > Run Configurations 을 클릭하면 maven 빌드 설정을 할 수 있는 창이 열린다. Environment 탭에 아래와 같이 환경변수와 값을 등록해주어야 한다. 값들이 사용자 환경마다 다르므로 minikube docker-env 명령으로 설정 값을 확인하고 맞춰주어야 한다.

변수명
DOCKER_HOST tcp://192.168.99.100:2376
DOCKER_CERT_PATH /Users/yourname/.minikube/certs

위의 설정이 없는 경우 minikube 에 도커 이미지를 등록할 수 없다. 현재 개발자의 pc 안에만 남기 때문에 minikube 에서는 이 이미지를 가지고 작업을 할 수 없다. 따라서 ErrImageNeverPull 라는 오류가 발생하는 것이다.

컨테이너 이미지가 pod에 배포가 성공적으로 되었다면 외부에서 이 컨테이너로 접근 할 수 있도록 아래 명령으로 노출을 시켜줘야 한다.

$ kubectl expose deployments hello --type=NodePort
service "hello" exposed

아래 명령은 브라우저를 실행해서 해당 웹 페이지를 볼 수 있게 한다.

curl $(minikube service hello-minikube --url)

만일 배포된 컨테이너 버전을 바꾸고 싶다면 (버전이 올라간 경우), 아래와 같이 명령을 실행하면 된다.

kubectl set image deployment/hello hello=demo/hello:1.1

using configuration file

위와 같이 명령을 이용하여 각각 배포와 서비스 노출을 하는 방법도 있으나 설정 파일을 만들고 필요한 설정을 미리 만들어서 한번에 실행하는 것이 더 편하다. 아래와 같이 YAML 파일을 만든다.

kind: Service
apiVersion: v1
metadata:
  name: hello
spec:
  selector:
    app: hello
  ports:
  - protocol: TCP
    port: 8080
    nodePort: 30001
  type: NodePort
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello
spec:
  selector:
    matchLabels:
      app: hello
  replicas: 1
  template:
    metadata:
      labels:
        app: hello
    spec:
      containers:
        - image: demo/hello-docker:1.0
          imagePullPolicy: Never
          name: hello
          ports:
            - containerPort: 8080

설정 파일을 지정해서 kubectl 을 실행한다.

kubectl create -f hello-docker.yaml

위의 명령으로 배포와 서비스 노출이 한번에 이루어진다. 아래 명령으로 서비스 생성까지 확인이 가능하다.

$ kubectl get services
NAME       TYPE       CLUSTER-IP   EXTERNAL-IP  PORT(S)         AGE
hello      NodePort   10.108.87.36 <none>       8080:30001/TCP  7m38s
kubernetes ClusterIP  10.96.0.1    <none>       443/TCP         5d20h

ending service

아래 명령으로 pod 에 실행되는 컨테이너를 종료할 수 있다.

kubectl delete service hello
kubectl delete deployment hello

만일 yaml 파일을 이용해서 실행한 경우라면 아래 명령으로 중지시킬 수 있다.

kubectl delete -f hello-docker.yaml

이력을 깨끗이 지우고 싶다면 아래와 같이 한다.

docker rmi demo/hello:1.0 -f
minikube stop
eval $(minikube docker-env -u)
minikube delete

Reference

https://howtodoinjava.com/docker/docker-hello-world-example/

https://kubernetes.io/ko/docs/setup/minikube/

Packaging a Java Application in a Docker Image with Maven

API Gateway

마이크로 서비스를 운영하기 위해서는 작은 기능들을 개발하고 수시로 배포하게 되는데 이런 기능들을 손쉽게 외부에 제공하는 것이 용이 하지는 않습니다. 누구나에게 오픈된 기능이라도 비정상적인 접근에 대해 차단이 필요하기도 하고 – 요즘 외부에 기능이 오픈되면 바로 admin 계정을 얻기 위한 불법저긴 시도들이 로그에 넘쳐납니다 – 그런 인증에 대한 고급 기능(예를 들어 oauth2 지원 등)이 아니더라도 간단한 로그를 남기려 한다면 서비스 마다 로그 기능을 추가하는 것이 마이크로 하다고 보기는 어렵습니다.

뿐만 아니라 서비스 운영 중에 패치나 업그레이드가 필요한 경우 끊김 없는 안정적인 – 동적으로 빌드해서 서비스하는 경우도 있지만 – 서비스를 하려 한다면 2대 이상의 서버에 서비스를 구성하고 패치하는 동안 1대로 운영을 하는 방법이 필요할 수 있습니다. 보통은 load balancing 을 위해 장비를 두고 장애 있는 서버는 온라인 상에서 제외하는 방식을 그대로 응용한 것이라고 볼 수 있습니다. 어찌 보면 일시적이지만 패치나 배포하는 중의 서비스 중단은 장애라고 볼 수 있기 때문입니다.

         +----------+                 +----------+
         |    L4    |                 |  Gateway |
         +----+-+---+                 +----+-+---+
              | |                          | |
        +-----+ +----+               +-----+ +----+
        |            |               |            |
        v            v               v            v
   +----------+  +---+-----+    +----------+  +---+-----+
   | Service  |  | Service |    | Service  |  | Service |
   |    1     |  |    2    |    |    1     |  |    2    |
   +----------+  +---------+    +----------+  +---------+

마이크로 서비스는 보통 쉽게 사용할 수 있는 REST API 형태로 서비스를 제공합니다. 결과물로 받을 수 있는 데이터의 형태도 json 타입으로 자바스크립트에서 객체 형태로 쉽게 변환해서 조작하기에 매우 쉽습니다. 웹 서비스 형태로 제공되던 xml 형태와 비교하면 크기도 작고 눈으로 봐도 이해가 쉬운 형식으로 되어 있습니다. (가독성을 위해 jsonformatter 사이트를 자주 이용합니다.)

$ curl https://jsonplaceholder.typicode.com/users/1
{
  "id": 1,
  "name": "Leanne Graham",
  "username": "Bret",
  "email": "Sincere@april.biz",
  "address": {
    "street": "Kulas Light",
    "suite": "Apt. 556",
    "city": "Gwenborough",
    "zipcode": "92998-3874",
    "geo": {
      "lat": "-37.3159",
      "lng": "81.1496"
    }
  },
  "phone": "1-770-736-8031 x56442",
  "website": "hildegard.org",
  "company": {
    "name": "Romaguera-Crona",
    "catchPhrase": "Multi-layered client-server neural-net",
    "bs": "harness real-time e-markets"
  }
}

위와 같이 curl 프로그램을 사용해서 간단하게 REST API를 호출해서 사용자의 정보를 조회해 보았습니다.  json 형식이 간단하고 쉽게 눈에 들어오는 모양이라는 것을 아실 수 있습니다.

마이크로 서비스는 위 예제에서 보신 것처럼 여러 곳에서 제공되는 작은 기능들을 가져와서 사용할 수 있고 직접 만들어서 제공하기도 합니다. 요즘 전자 상거래 사이트에서 구매 결제를 직접 제공하지 않고 사이트 외부의 결제 서비스를 이용하여 결제할 수 있게 하는 것과 마찬가지 입니다. 이렇게 내부 외부의 흩어져 있는 서비스를 하나의 gateway에서 묶어서 제공하므로 복잡함과 수고로움을 덜기도 합니다. 예를 들어 자신의 PC 에 gateway 를 설치하고 실행시켜서 위 예제 사이트 주소를 등록해 놓으면 개발하지 않고도 자신의 PC에서 동일하게 동작하는 것을 볼 수 있습니다. 세팅 방법은 다음에 자세히 설명하겠습니다.

$ curl http://localhost:8080/users/1

API 를 서비스 한다는 것은 제공하는 주소로 많은 사용자들이 호출하고 있다는 것을 의미합니다. 새로운 기능을 더 추가해서 서비스하려 할 때도 기존의 기능을 사용하는 사용자를 고려할 필요가 있습니다. 그래서 새로운 버전은 새로운 주소를 제공할 필요가 있습니다. 기존의 주소를 변경해서 2중으로 코드를 관리하는 것이 상당히 번거롭습니다. Gateway 에서 주소 내에 버전(v1,v2)만 추가해 놓으면  되므로 새로운 서버 주소를 만들 필요도 없고 동일한 코드로 – 소스코드에는 v1,v2 없이 /users/{id} 로 정의 되어 있어도 –  별도의 서버에 배포만 하면 됩니다.

$ curl http://your.host.com/v1/users/1
$ curl http://your.host.com/v2/users/1

저는 이런 Gateway 의 필요성이 있다고 생각이 되었고 AWS 에서는 API Gateway 서비스가 제공되고 있어 그대로 활용하는 것도 방법일 수 있지만 모든 서비스가 Cloud 에 다 있는 것이 아니기 때문에 기존의 IDC 에 있는 서비스들도 참여시킬 수 있도록 설치형 서비스를 찾아 보았습니다. 오픈 소스로 제공되는 Kong API Gateway 를 알게 되었고 이를 사용해서 마이크로서비스를 구성했습니다.

Kong 은 Nginx 기반에 lua 언어로 개발된 plugin 형태로 API Gateway 를 제공하고 있습니다. 일단 Nginx 기반이라 – proxy 기능, 캐시 기능, 부하 분산 기능 등을 그대로 사용 – 안정성에서 만족 하였고 실제 로그를 확인하면 Gateway 의 처리 속도나 부하가 극히 적은 것을 알 수 있습니다.  Community 버전을 제공하고 있어서 무료로 사용 가능하다는 것이 또한 강점입니다. 제가 처음 사용할 당시는 무료버전이던 것이 기업용 버전이 추가되어 유료버전으로도 제공되고 있습니다. kong 자체가 plugin 이라 추가적인 plugin 으로 확장이 가능하고 오픈 소스를 가져다가 자신에 맞게 수정해서 사용할 수 있습니다. 기존 plugin 을 수정해서 Kafka 로그 plugin 을 추가한 사례를 나중에 설명하겠습니다.

해당 사이트의 설명을 보면 기본적으로 제공되는 plugin 정보를 얻을 수 있습니다. 저희는 인증에 관련해서 JWT, OAuth2 를 사용해서 복잡한 개발없이 간단히 인증처리를 구현했습니다. 모바일 접근을 허용하기 위해서 CORS 를 사용하였고 아직 사용하지는 않았지만 성능을 높이기 위한 캐시 기능(proxy caching)이라든가 해커의 공격을 대비한 Rate limiting 기능도 사용해보려 합니다. AWS 의 lamda 서비스와 연동도 가능하고 다양한 Log 기능도 제공합니다. 저희가 로그 기능을 확장 하려는 이유는 요청되고 응답되는 데이터가 로그에 포함되지 않아서 그 부분을 추가해서 로그를 설치했습니다.

물론 AWS에는 막강한 서비스가 많습니다. AWS API Gateway 역시 강력합니다. 그러나 aws 외에 서비스를 하거나 내부 인증 시스템과 연동되어야 하거나 자체 서비스를 활용해야 한다면 Kong 과 같은 오픈 서비스가 필요하지 않을까 합니다. 간단히 개발, 확인할 수 있도록 Docker를 이용한 kong 설정을 시작으로 문서들을 작성해보겠습니다.