Kubernetes 를 위한 Spring Boot 개발 (feat. 무중단 배포/운영)
Tags: kubernetes, java
Published: 2022-02-18 11:55:36

## 들어가며
현재 Kubernetes, Istio를 사용하여 Java, Vue, Python, Go 등으로 개발된 서비스를 개발/운영하고 있다. 우선 여기에서는 (Part 1) Spring Boot 설정 및 개발에 관련된 내용을 정리하고 Part 2에서는 kubernetes 에서 필요한 설정을 정리한다. 

Spring Boot 을 Kubernetes 에 넣기 위해서는 아래와 같은 설정 및 개발을 기존 코드에 추가해야 한다. 

## Dockerize 
Spring Boot Application 을 dockerize 하는 방법은 [Jib](https://github.com/GoogleContainerTools/jib), Buildpacks, Dockerfile 이렇게 세가지가 있다. 이 중 가장? 간단하고 build time이 빠르고 (즉, 효율적인 layering), image 사이즈가 가장 작아지는 Jib 을 사용한다. Jib 의 사용법은 간단하다. 아래와 같이 `build.gradle` 에 [jib gradle plugin](https://github.com/GoogleContainerTools/jib/tree/master/jib-gradle-plugin)을 추가하면 된다. 
```gradle
plugins {
  id 'com.google.cloud.tools.jib' version '3.2.0'
}
```
그리고 `./gradlew jib`을 통해 image를 빌드 할 수 있다.

참고로 로컬 kubernetes 개발 환경에서 [skaffold](https://skaffold.dev/) 를 사용한다면 아래와 같이 `jib: {}` 만 추가하면 알아서 로컬 kubernetes 에 빌드/배포해준다. 
```yaml
apiVersion: skaffold/v2beta27
kind: Config
build:
  local:
    push: false
  artifacts:
    - image: example/image-name
      context: ./example-app
      jib: {}
```
아래는 Github Actions를 사용하여 이미지를 build 하고 AWS ECR에 image를 push 하는 코드의 일부이다
```yaml
- name: Set up JDK 11
  uses: actions/setup-java@v1
  with:
    java-version: 11
- name: Grant execute permission for gradlew
  run: chmod +x gradlew
- name: Build and Push with Gradle
  id: build-and-push-to-ecr
  run: ./gradlew jib -x test --image $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
```
> Java 11 이상 사용을 권장한다. Java 8 은 containerize 된 환경에 최적화되지 않아 JVM이 효율적으로 운영되지 못한다.

## Health Check APIs
Kubernetes 의 readiness, liveness 설정을 위해 Spring Boot application 의 health check API 가 필요하다. 다행히 [Spring Boot Actuator](https://github.com/spring-projects/spring-boot/tree/main/spring-boot-project/spring-boot-actuator) 에서 이 기능을 제공한다. 아래와 같이 `build.gradle` 에 dependency로 추가만 하면 된다. 
```gradle
dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
``` 
> Spring Boot 2 부터는 Actuator의 `/health` 와 `/info` 를 제외한 모든 endpoint가 disable 되어 있다. 만약 다른 endpoint 도 사용하고 싶다면 `application.properties` 에 추가적인 설정이 필요하다. 그렇지 않다면 그냥 dependenty 만 추가하고 그 외 추가적인 설정이나 개발은 필요없다. 

kubernetes 의 readiness 설정을 하지 않으면 rolling update 시 또는 autoscale을 통해 새로 pod 가 생길때 spring boot application이 완전히 뜨기 전에 request가 들어가고 이렇게 들어온 request는 ingress가 `503`을 리턴하게 된다. 또한 liveness 설정이 없으면 예기치못한 상황으로 spring boot application 이 죽었을 때 서비스가 새로 시작하지 않고 계속 죽어있게 된다.

## Graceful shutdown
운영환경에서 Pod가 termination 되는 상황은 rolling update로 배포를 하거나 kubernetes autoscale 을 통해 늘어난 Pod가 줄어들 때 등이 있을수 있다. 이때 kubernetes는 SIGTERM 시그널을 보내고 Pod안의 Spring Boot Application 은 종료가 된다. 

하지만 Spring Boot의 default 설정은 시그널을 받자마자 종료되도록 되어 있고, 만약 종료될때 들어온 request가 완료되기 전에 Spring Boot application 이 내려가면 해당 request를 보낸쪽에서는 HTTP STATUS `503`을 받게 된다.
> 실제 운영환경 (또는 load testing 시)에서 이 문제는 application log 에서는 확인하지 못하고 kubernetes ingress 에서 `503` 확인이 가능하다.

이를 graceful 하게 처리하기 위해서는 graceful shutdown 설정을 해야 한다. 이것 또한 간단하다. 아래와 같이 `application.properties` 파일에 아래와 같이 추가하면 된다. (단, Spring Boot 2.3 부터 가능한 option 이다.)
```yaml
server.shutdown=graceful
```
해당 설정이 추가되면 tomcat 이 종료 시그널을 받았을때 처리중인 request가 있다면 이를 모두 처리하고 application 이 종료된다. 하지만 들어온 request가 종료될 때까지 무한정 기다리는것은 아니다. default 설정은 30초간 기다리고 그때까지 종료하지 못한다면 강제 종료된다. (일반적인 경우는 default 설정이면 충분하다) 이 설정은 아래와 같이 변경 가능하다. 
```yaml
spring.lifecycle.timeout-per-shutdown-phase=1m
```
## Loading HikariCP
Spring Boot 2부터 Hikari 가 default DataSouce 구현체이다. `spring-boot-starter-data-jpa` 나 `spring-boot-starter-jdbc`를 사용한다면 별도의 설정없이 Hikari를 사용하게 된다. Hikari는 Connection Pool (HikariCP)을 사용하여 DB connection 을 관리하는데 Spring Boot application 이 설정/개발에 따라 Hikari connection pool을 Spring Boot 이 시작할 때 바로 만들지 않고, DB 관련 request가 처음 들어와서 처리 할 때 그제서야 Hikari를 initialize 하면서 connection pool을 생성하기도 한다. (경험으로는 Hibernate를 사용하면 application 이 올라갈때 바로 connection pool을 생성하고 MyBatis는 그렇지 않았다)

사실 이러한 과정은 일반적인 상황에서는 문제가 되지 않는다. 하지만 요청이 폭발하는 상황에서 kubernetes 가 autoscale을 통해 새로운 pod를 생성하고 이렇게 생성된 pod가 바로 많은 요청을 받는 상황에서는 몇초간 latency가 매우 높아진다. 

우선 현재 Spring Boot application 이 언제 connection pool을 생성하는지 확인하려면 `application.properties` 에 아래와 같이 설정을 추가하여 hikari log를 남기고, application을 실행해 보자. (테스트 후 반드시 해당 설정을 제거하자. 특히 운영환경에서는...)
```yaml
logging.level.com.zaxxer.hikari.HikariConfig=DEBUG 
logging.level.com.zaxxer.hikari=TRACE
```
만약 application 이 시작하면서 Hikari 설정 관련 log 가 나오면서 connection pool 이 생성된다면 문제가 되지 않는다. 하지만 그렇지 않다면 아래와 같이 connection pool을 application 이 시작할 때 만들어주어 몇초동안 latency가 급격히 높아지는 현상을 줄일 수 있다.

```java
@Component
public class HikariLoader implements ApplicationRunner {

    private final HikariDataSource hikariDataSource;

    public HikariLoader(HikariDataSource hikariDataSource) {
        this.hikariDataSource = hikariDataSource;
    }

    @Autowired
    public void run(ApplicationArguments args) throws SQLException {
        hikariDataSource.getConnection();
    }
}
```

## 마치며
Kubernets가 운영에 필요한 많은 일들을 해주지만 이렇게 추가적인 개발/설정 없이는 제대로 운영할 수가 없다. 그래도 Java는 다른 언어에 비해 굉장히 간단하게 이러한 설정들을 추가 할 수가 있다. Part 2에서는 실제 운영환경에서 필요한 Kubernetes 관련 설정들을 정리하려고 한다.