Spring과 kubernetes를 이용한 서비스 배포(WIP)

Table of Contents

아래 레퍼런스의 내용을 나름 요약한 것이다.

Kubernetes에서 Spring Boot 앱을 실행하려면 Dockerfile을 간단하게 작성해 놓기만 하면 끝이라고 생각할 수도 있다. 하지만 그것은 결코 쉬운 길이 아니라고 말한다. (특정 트위터 내용을 링크로 걸었지만 해당 내용은 보이지 않는다.)

1 Build & Run

쿠버네티스에서 프로덕션 준비된 Spring 애플리케이션을 실행하는데 두 가지 중요한 측면이 있다.

  • Building the OCI image (a.k.a., the "Docker" image)
  • Running the OCI image on Kubernetes

1.1 Build Image

간단하게 Google에서 Dockerfile을 찾아봐서 작성한다. 여기에는 우리가 결정해야 할 것들이 몇가지 있다.

1.1.1 Choose a Linux distribution

어떤 배포판을 사용해야 할까? Ubuntu? Debian? Suse? Alpine? Distroless? JDK기반 OCI image에 알맞는 것은 무엇인가? 컨테이너화를 위함이기 때문에 컨테이너를 기반으로 할 Linux 커널에 초점을 맞춰야함.

RedHat Enterprice Linux container 가 Ubuntu VM 위에서 동작하거나, Photon OS VM 위에 Debian Linux container가 동작할 수 있는 이유가 있다.

Linux 기반 컨테이너의 중요한 요소는

  • libc, OCI image의 핵심역할을 한다. 이것은 사용하려는 Linux 배포판에 관계없이 거의 동일한 프로그래밍 인터페이스이다.
  • Linux kernel Application Binary Interface (ABI), 서로 다른 Linux 커널 버전 간의 이전 버전과의 호환성을 보장한다.

이것만 보면 인터페이스가 동일하고, 하위 호환도 되니 어떤 걸 사용해도 상관 없을 것 같다. 하지만 중요한 것은 디테일에 있다. libcglibcmusl 라이브러리가 있다. 이 두 라이브러리는 호환성이 없다.

  • glibc (GNU C Library)는 GNU 프로젝트에서 개발한 C 라이브러리이다. 이것은 표준 C 라이브러리이다. 대부분의 Linux 배포판에서 기본적으로 사용된다. 예를들어 Ubuntu, Debian, CentOS, RHEL 등이 있다. 단점은 상당히 크고 무거운 코드 베이스를 가지고 있다는 것이다.
  • musl (MUSL C Library)는 C 라이브러리이다. 이것은 glibc보다 훨씬 가벼우며, 빠르다. musl은 glibc와 호환되지 않는다. musl은 Alpine Linux에서 사용된다. Alpine Linux는 가벼운 Linux 배포판이다. 이것은 BusyBox 라는 유틸리티를 기반으로 한다. BusyBox는 glibc 를 사용하지 않고, musl 라이브러리를 사용한다. 따라서 Alpine Linux는 musl 라이브러리를 사용한다. musl libc는 정규식, EOF 및 멀티스레딩 같은 기능이 구현에 따라 다르게 동작할 수 있다. (이것은 glibc와 호환되지 않는다는 것을 의미한다.)

인터페이스를 이용해 작업을 할 때는 세부 구현 사항이 덜 중요할 것 같지만 jvm은 세부 구현 사항에 매우 민감하다. 최신 버전까지만 해도 jvm은 gnu libc에서만 사용할 수 있는 기능에 의존했다. musl 구현을 지원하기 위해서는 수정이 필요했다.

java 17 부터는 두가지 구현이 모두 지원된다. 하지만 LTS지원인 java17을 더 선호할 것이니 java17 이상 Alpine-based distros(알파인 기반 배포판)를 사용해야 한다.

하지만 최적의 호환을 위해선 GNU-based libc Linux 구현을 사용하는 것이 좋다. VMware Tanzu에서는 Spring 기반 이미지에 우분투를 광범위하게 사용하고 있으며, 이는 탁월환 선택이지만 다른 솔루션도 마찬가지로 잘 작동한다.

1.2 Choose a JDK distribution

OCI Image를 위해서는 몇가지 선택사항이 있다.

  • 오라클 라이센스가 있다면, 그것을 사용하자.
  • 하나의 퍼블릭 클라우드 제공 서비스 위에서 실행한다면, 해당 서비스에서 제공하는 JDK를 사용하자. (Amazon Corretto, Microsoft's build of OpenJDK)
  • 커뮤니티가 주도하고 어디에서나 실행되는 프로덕션레디 배포판을 찾고 있다면 Adoptium기반 이미지를 사용하라.
  • 혹은 Bellsoft Liberica와 Azul 같이 상업적 지원을 통해 이를 기반으로 하는 다양한 버전 중 하나를 선택할 수 있다. VMWare Tanzu(Spring팀이 속한 사업부)는 Tanzu Build Service 및 Tanzu Application Platform과 같은 제품에서 Bellssoft Liberia를 사용하고 있다.

정리하면 eclipse-temurin:17-jre-jammy 와 같은 Adoptium 기반 이미지 또는 지원되는 빌드 버전을 사용하자. 단일 퍼블릭 클라우드의 경우 해당 공급자의 기본 이미지를 사용하는 것이 좋다. Oracle JDK는 라이센스가 있으면 사용하자.

1.3 Choose a JDK version

이건 쉽다. Java JDK 17을 사용하면 된다. Spring 6.0은 Java 17을 기반으로 한다. macos 4코어 bellsoft liberica 기반 java 11과 java17의 실행시작시간을 비교했을 때 17로 버전업 한 것만으로 1.5초가 빨라졌다.(8.9s -> 7.4s) 이것은 별거 아닌 것 같지만 쿠버네티스 환경에서 pod를 상시로 올리고 내리는 상황에서는 큰 차이가 난다.

2 Writing a Dockerfile

처음으로 당신이 만날 도커파일을 이것이다.

FROM eclipse-temurin:17-jre-jammy
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

이것은 eclipse-temurin:17-jre-jammy 이미지를 기반으로 하고, target 디렉토리에 있는 jar 파일을 복사하고, java -jar 명령어를 실행한다. ARG는 빌드시에만 사용되는 변수이다. ENTRYPOINT는 컨테이너가 실행될 때 실행되는 명령어이다.

이 도커파일을 잘 작동하지만 피하는 것이 좋다. 위 도커파일은 Spring Boot "fat jar"를 단일 OCI 레이어에 배치한다. 이 uber jar는 순식간에 150mb 이상에 도달한다. 이것이 무엇을 의미할까? 모든 새 빌드에 대해 애플리케이션에서 가장 간단한 코드 변경에 대해서도 아래 내용들이 수행된다는 것을 말한다.

  • a new 150mb layer (read: tarball) will be created : 새로운 150mb의 레이어가 생성된다.
  • The layer will be uploaded to your OCI registry : 이 레이어가 OCI 레지스트리에 업로드된다.
  • When kubernetes pulls the latest image to the node that runs the container, the entire 150mb layer will need to be pulled.

    쿠버네티스가 컨테이너를 실행하는 노드에 최신 이미지를 가져올 때, 150mb의 레이어가 전부 다운로드된다.
    

아주 단순한 코드 변경과 이미지 리빌드를 할 때 마다 150mb의 레이어가 생성되고, 전부 다운로드된다. Dockerfile은 각 라인을 새로운 레이어로 취급하므로, 서드파티 종속성을 자체 레이어에 넣어 두는 것이 훨씬 합리적이다.

스프링 부트 이미지는 어떻게 계층화를 도모할 수 있을까? 어떤 앱스택을 자체 레이어로 취급해야 하나?

2.1 layertools

앱을 빌드한 이후 스프링에게 앱을 어떻게 계층화할지 물어볼 수 있는 방법이 있다.

$ ls -l target
total 105992
-rw-r--r--   1 odedia  staff  53169785 Sep  5 11:10 spring-petclinic-2.7.0-SNAPSHOT.jar
-rw-r--r--   1 odedia  staff    391665 Sep  5 11:10 spring-petclinic-2.7.0-SNAPSHOT.jar.original
drwxr-xr-x  22 odedia  staff       704 Sep  5 11:10 surefire-reports
drwxr-xr-x   3 odedia  staff        96 Sep  5 11:09 test-classes

$ java -Djarmode=layertools -jar target/spring-petclinic-2.7.0-SNAPSHOT.jar list
dependencies
spring-boot-loader
snapshot-dependencies
application

이처엄, Spring Boot 애플리케이션을 실행하는 대신 Djarmode=laytertools list 명령어로 레이어들을 리스트업 할 수 있다.

  • dependencies : 애플리케이션의 릴리즈 기반 종속성을 포함하는 레이어. Spring Boot버전 혹은 서드파티 프레임워크 버전이 변경되면 이 레이어가 변경된다.
  • spring-boot-loader : Spring 빈 라이프사이클을 관리하기 위해 Spring Boot 앱을 JVM에 로드하는 코드. 이 레이어는 거의 변경되지 않는다.
  • snapshot-dependencies : 이 종속성은 자주 변경된다. 새 빌드를 할 때마다 최신 스냅샷을 가져와야할 수 있다. 이 계층은 애플리케이션 코드와 가장 가깝다.
  • application : src/main/java 코드를 말한다.

이제 스프링부트가 애플리케이션을 레이어링 하는 방법에 대한 힌트를 제공했지만, 실제로 레이어를 준비해주면 좋겠다. 이를 위해 Djarmode=layertools 명령어를 사용할 수 있다.

$ java -Djarmode=layertools -jar target/spring-petclinic-2.7.0-SNAPSHOT.jar extract --destination target/tmp
$ ls -l target/tmp
total 0
drwxr-xr-x  3 odedia  staff  96 Sep  5 11:10 application
drwxr-xr-x  3 odedia  staff  96 Sep  5 11:10 dependencies
drwxr-xr-x  3 odedia  staff  96 Sep  5 11:10 spring-boot-loader
drwxr-xr-x  3 odedia  staff  96 Sep  5 11:10 snapshot-dependencies

--destination target/tmp 인자가 없으면, 디폴트로 target/dependency 디렉토리에 레이어를 추출한다.

dependencies 폴더를 보면 Spring Boot의 종속성이 있다.

$ ls target/tmp/dependencies
BOOT-INF
$ ls target/tmp/dependencies/BOOT-INF
lib
$ ls target/tmp/dependencies/BOOT-INF/lib
HikariCP-4.0.3.jar
LatencyUtils-2.0.3.jar
...

And here is our application's compiled code:

$ ls target/tmp/application/BOOT-INF/classes
application-mysql.properties    application.properties          db                              messages                        static
application-postgres.properties banner.txt                      git.properties                  org                             templates

이처럼 uber jar를 layered OCI image에 맞게 압축을 풀어 분리할 수 있다. 저자는 폭발하는 EAR(https://docs.oracle.com/cd/E13222_01/wls/docs61/deployment/deployment.html)와 비슷한 느낌이라고 한다. 기술은 반복된다.

FROM eclipse-temurin:17-jre-jammy as builder
WORKDIR application
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=layertools -jar application.jar extract

FROM eclipse-temurin:17-jre-jammy
WORKDIR application
COPY --from=builder application/dependencies/ ./
COPY --from=builder application/spring-boot-loader/ ./
COPY --from=builder application/snapshot-dependencies/ ./
COPY --from=builder application/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

OCI image를 실제로 확인해보자.

$ docker build -t spring-petclinic:2.7.0-SNAPSHOT .
$ docker run -it --rm -p 8080:8080 spring-petclinic:2.7.0-SNAPSHOT
;; 간단하게
$ docker run -p 8080:8080 spring-petclinic:2.7.0-SNAPSHOT
2022-09-05 08:40:30.802  INFO 1 --- [           main] o.s.s.petclinic.PetClinicApplication     : Starting PetClinicApplication v2.7.0-SNAPSHOT using Java 17.0.4.1 on 93195813a338 with PID 1 (/app.jar started by root in /)
...

--rm 은 컨테이너가 종료되면 컨테이너를 삭제한다는 옵션이다. --it 는 컨테이너의 표준 입출력을 터미널에 연결한다는 옵션이다.

여튼 이렇게 하면 동작해야 한다.

2.2 Customizing Layers

Spring이 아닌 자체 릴리스 종속성 집합이 있을 수 있다. 예를 들어, 회사 내 보안, 로깅 및 감사를 위한 내부 종속성 집합이 있을 수 있다.

이러한 종속성은 Spring 종속성보다 더 자주 변경될 수 있으므로 이러한 종속성을 자체 계층으로 추출하는 것이 합리적일 수 있다. 이를 위해 사용자 정의 레이어 구성을 생성할 수 있다.

<layers xmlns="http://www.springframework.org/schema/boot/layers"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://www.springframework.org/schema/boot/layers
			  https://www.springframework.org/schema/boot/layers/layers-2.7.xsd">
    <application>
	<into layer="spring-boot-loader">
	    <include>org/springframework/boot/loader/**</include>
	</into>
	<into layer="application" />
    </application>
    <dependencies>
	<into layer="snapshot-dependencies">
	    <include>*:*:*SNAPSHOT</include>
	</into>
	<into layer="custom-security-dependencies">
	    <include>com.mycompany.security:*</include>
	</into>
	<into layer="dependencies"/>
    </dependencies>
    <layerOrder>
	<layer>dependencies</layer>
	<layer>spring-boot-loader</layer>
	<layer>snapshot-dependencies</layer>
	<layer>custom-security-dependencies</layer>
	<layer>application</layer>
    </layerOrder>
</layers>

custom-security-dependencies 레이어를 추가했다. 이 구성을 적용하려면 스프링 부트 메이븐 플러그인(spring-boot-maven-plugin)에 layers.xml 파일을 추가해야 한다.

<project>
    <build>
	<plugins>
	    <plugin>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-maven-plugin</artifactId>
		<configuration>
		    <layers>
			<enabled>true</enabled>
			<configuration>${project.basedir}/src/layers.xml</configuration>
		    </layers>
		</configuration>
	    </plugin>
	</plugins>
    </build>
</project>

이제 도커파일을 다시 작성해보자.

FROM eclipse-temurin:17-jre-jammy as builder
WORKDIR application
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=layertools -jar application.jar extract

FROM eclipse-temurin:17-jre-jammy
WORKDIR application
COPY --from=builder application/dependencies/ ./
COPY --from=builder application/spring-boot-loader/ ./
COPY --from=builder application/snapshot-dependencies/ ./
COPY --from=builder application/custom-security-dependencies/ ./
COPY --from=builder application/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

도커파일에 uber jar를 사용하지 말자. Spring builtin layertools 로 jar를 분해하여 OCI 이미지를 보다 효율적으로 패키징하자.

2.3 Setting bootstrap arguments

위 예제에서 우리는 다음과 같은 명령을 보았다.

ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

이런 기본값은 괜찮을 수 있지만, 그렇지 않을 수도 있다. 힙 크기, 사용가능한 메모리, 가비지 수집 알고리즘 등을 결정하기 위해 JVM에서 고려해야할 변수가 너무 많다. 항상 프로덕션 전에 앱을 프로파일링하고 프로덕션과 유사한 환경에서 검증되었다고 생각되는 설정을 적용하라. 이 글의 마지막 섹션에서는 도움이 될 수 있는 몇 가지 프로파일링 팁을 소개한다.

JVM에 인수를 전달하는 방법은 여러 가지가 있다. 컨테이너화된 환경에서는 컨테이너 내부의 JVM이 읽을 수 있는 가장 안전한 선택지는 JAVATOOLOPTIONS 환경 변수를 사용하는 것이다. JAVAOPTS는 shell script convention이다. JVM은 이를 제공하지 않는다.

JVM에 전달해야 할 더 중요한 인자는 메모리 관리에 관련있다. 몇가지 사항을 고려해야 한다.

  • Your JVM memory is (mostly) a combination of the heap memory and the non-heap memory (Stack Memory, Direct Memory etc.).

    힙 메모리와 비 힙 메모리(스택 메모리, 디렉트 메모리 등)의 조합으로 구성된다.
    
  • Most of your application's memory will be allocated to the heap. How much memory should you assign to it? It depends!

    대부분의 애플리케이션 메모리는 힙에 할당된다. 얼마나 할당해야 할까? 그것은 경우에 따라 다르다.
    
  • How much memory does the container has?

    컨테이너가 얼마나 많은 메모리를 가지고 있나?
    
  • How many classes are in-memory?

    메모리에 얼마나 많은 클래스가 있는가?
    
  • How many threads are running at the same time (including Thread Pools)?

    동시에 실행되는 스레드 수는 얼마인가?
    
  • How much memory is taken up by the Stack memory?

    스택 메모리가 얼마나 많은 메모리를 차지하고 있는가?
    
  • Your application may also use a considerable amount of non-heap memory for things like NIO Direct Memory. For example, Kafka and many other socket-based frameworks make use of NIO for better performance. Without setting proper arguments, it might kill your JVM without you realizing what happened (as I learned the hard way…). Profile your application for direct memory by including the JVM arguments -XX:NativeMemoryTracking=summary-XX:+PrintNMTStatistics. Add the resulting value to the total memory footprint calculations.

    애플리케이션은 NIO 디렉트 메모리와 같은 것들을 위해 비 힙 메모리를 상당양을 사용할 수 있다.
    

    예를 들어, 카프카와 다른 많은 소켓 기반 프레임워크는 NIO를 사용하여 성능을 향상시킨다. 적절한 인수를 설정하지 않으면 JVM이 무슨 일이 일어났는지 알지 못하고 죽을 수 있다. (힘들게 경험한 것.) 애플리케이션을 프로파일링하여 다이렉트 메모리를 포함하여 JVM 인수 -XX:NativeMemoryTracking=summary-XX:+PrintNMTStatistics를 사용하라. 결과 값을 총 메모리 풋프린트(총량) 계산에 추가하라.

이런 자바 메모리를 쉽게 계산하는 도구가 있다. 바로 java-buildpack-memory-calculator이다. 이 도구는 애플리케이션의 메모리 사용량을 예측하는 데 도움이 된다. 계산하는 알고리즘은 아래 링크에 있다. 예를 들어, 애플리케이션에 750개의 클래스가 있고, 컨테이너의 총 메모리가 1G이며, 스택 크기가 1mb정도이며, 애플리케이션에서 사용하는 총 스레드가 100개 정도라고 계산 해보자.

$ ./java-buildpack-memory-calculator --loaded-class-count=750 --thread-count=100 --total-memory=1024M --jvm-options "-Xss1024k"
;; 리턴값
-XX:MaxDirectMemorySize=10M -XX:MaxMetaspaceSize=17919K -XX:ReservedCodeCacheSize=240M -Xmx672256K

결과로 max heap을 670mb로 설정하라는 메시지를 얻을 수 있다. 알고리즘은 total memory - (headroom + direct memory + metaspace + reserved code cache + (thread stack * thread count)) 로 계산한다. 이 경우에는 1024 - (128 + 10 + 17 + 240 + (1 * 100)) = 670mb가 된다.

스레드 풀의 중요성에 대해서 명확히 하기 위해 tomcat을 사용하고 웹 요청에 200개의 추가 스레드를 사용하여 총 스레드 수를 300개로 늘린다고 가정해보자.

$ ./java-buildpack-memory-calculator --loaded-class-count=750 --thread-count=300 --total-memory=1024M --jvm-options "-Xss1024k"
;; 리턴값
-XX:MaxDirectMemorySize=10M -XX:MaxMetaspaceSize=17919K -XX:ReservedCodeCacheSize=240M -Xmx467456K

이 경우에는 max heap을 465mb로 설정하라는 메시지를 얻을 수 있다. MaxDirectMomorySize는 계속 10M 기본값으로 유지된다. 이유는 애플리케이션이 프로파일링하지 않고는 실제 사용량을 알 수 없기 때문이다.

위에서 언급한 직접 메모리 사용량을 확인하는 것을 잊지말자. 힙 크기에 의미 있는 영향을 미칠 수 있다.

애플리케이션이 많은 동시 사용자와 함께 Kafka를 사용하므로 직접 메모리에 64mb가 필요하다고 가정해보자.

$ ./java-buildpack-memory-calculator --loaded-class-count=750 --thread-count=300 --total-memory=1024M --jvm-options "-Xss1024k -XX:MaxDirectMemorySize=64M"

-XX:MaxMetaspaceSize=17919K -XX:ReservedCodeCacheSize=240M -Xmx412160K

이제 힙 크기가 약 410MB로 줄었습니다. 이 시점에서 컨테이너에 1기가바이트는 충분하지 않을까?

앞에서 언급했듯이, JVM 메모리 인수는 target deployment environment와 available container memory에 의존한다. 이러한 값은 계속 변경되므로 Dockerfile에 하드코딩하고 싶지 않을 것이다. 여기에 3가지 옵션이 있다.

  1. JAVATOOLOPTIONS 환경변수를 당신의 kubernetes deployment yaml의 부분으로 추가한다.
  2. 컨테이너가 시작될 때, 인수를 계산한다. 하지만 그 전에 Spring-based application이 실제로 실행되기 전에 실행되어야 한다. (다음 섹션에서 설명)
  3. 옵션 1,2를 혼합하여 사용한다. deployment manifest에서 설정할 수 있는 것을 설정하고, 나머지는 컨테이너가 시작될 때 계산한다.

2.4 Who needs Dockerfiles?

거의 2023년입니다. 더 이상 리눅스 커널을 직접 컴파일하지 않으시길 바랍니다. 왜 아직도 도커파일을 작성하시나요?

Docker images (정확히 말하자면, OCI 이미지)는 훌륭하다. 어디에서나 실행할 수 있는 컨테이너 패키징의 표준이다. 하지만 Dockerfile 은 이러한 이미지를 빌드하기 위한 스크립트일 뿐이다.

OCI 이미지가 대중화될 때부터 사용되어 왔기 때문에 기본값으로 간주된다. 작동하는 OCI 이미지를 얻을 수 있는 다른 방법이 있으며, 이제 how보다는 what에 중점을 두는 방법을 모색하자. 이러한 도구 중 하나가 jib이다. Google에서 개발한 이 도구는 빌드 도구와 통합되어 완벽한 Dockerfile을 작성하는 번거로움 없이 자동으로 Java distroless container image를 생성할 수 있다.

하지만 java 이미지보다 더 범용으로 사용할 수 있는 표준이 있다.

3 Cloud Native Buildpacks

Cloud Native Buildpacks는 OCI 이미지를 생성하기 위한 표준이다. 도커파일 없이 만들 수 있으면 크게 세 가지 장점이 있다.

  1. CNCF 프로젝트이다.
  2. 새로운 빌드마다 base Linux distribution 과 나의 OpenJDK (혹은 다른 미들웨어 런타임)을 사전에 패치한다.(proactively patch)
  3. 스프링 빌드 플러그인에 통합되어 있다.

메이븐을 예로 들자:

$ mvn spring-boot:build-image

이 명령어는

  • layertools 명령어로 나열된 레이어를 기반으로 최적화된 OCI 이미지를 생성한다.
  • Java Buildpack Memory Calculator를 사용하여 올바른 힙 크기를 계산한다.
  • 컨테이너가 시작하면, 사용자가 제공한 변수와 메모리 계산한 정보를 이용하여 JAVATOOLOPTIONS를 설정한다.

다음은 위의 명령어를 실행한 결과이다. 환경은 Mac OS X 10.15.7, Docker Desktop 3.3.3, 4 CPUs, 4 GB assigned memory

> docker run -p 8080:8080 spring-petclinic:2.7.1-SNAPSHOT
Setting Active Processor Count to 4
Calculating JVM memory based on 3198728K available memory
For more information on this calculation, see https://paketo.io/docs/reference/java-reference/#memory-calculator
Calculated JVM Memory Configuration: -XX:MaxDirectMemorySize=10M -Xmx2572558K -XX:MaxMetaspaceSize=114169K -XX:ReservedCodeCacheSize=240M -Xss1M (Total Memory: 3198728K, Thread Count: 250, Loaded Class Count: 17743, Headroom: 0%)
Enabling Java Native Memory Tracking
Adding 127 container CA certificates to JVM truststore
Spring Cloud Bindings Enabled
Picked up JAVA_TOOL_OPTIONS: -Djava.security.properties=/layers/paketo-buildpacks_bellsoft-liberica/java-security-properties/java-security.properties -XX:+ExitOnOutOfMemoryError -XX:ActiveProcessorCount=4 -XX:MaxDirectMemorySize=10M -Xmx2572558K -XX:MaxMetaspaceSize=114169K -XX:ReservedCodeCacheSize=240M -Xss1M -XX:+UnlockDiagnosticVMOptions -XX:NativeMemoryTracking=summary -XX:+PrintNMTStatistics -Dorg.springframework.cloud.bindings.boot.enable=true


	      |\      _,,,--,,_
	     /,`.-'`'   ._  \-;;,_
  _______ __|,4-  ) )_   .;.(__`'-'__     ___ __    _ ___ _______
 |       | '---''(_/._)-'(_\_)   |   |   |   |  |  | |   |       |
 |    _  |    ___|_     _|       |   |   |   |   |_| |   |       | __ _ _
 |   |_| |   |___  |   | |       |   |   |   |       |   |       | \ \ \ \
 |    ___|    ___| |   | |      _|   |___|   |  _    |   |      _|  \ \ \ \
 |   |   |   |___  |   | |     |_|       |   | | |   |   |     |_    ) ) ) )
 |___|   |_______| |___| |_______|_______|___|_|  |__|___|_______|  / / / /
 ==================================================================/_/_/_/

:: Built with Spring Boot :: 2.7.1
...
...

리소스가 적은 linux 머신에서 동일한 이미지를 실행하면 당연히 메모리 값이 달라진다.

# docker run -p 8080:8080 harbor.apps.cf.tanzutime.com/apps/spring-petclinic:2.7.1-SNAPSHOT
Setting Active Processor Count to 1
JVM DNS caching disabled in favor of link-local DNS caching
Calculating JVM memory based on 1221856K available memory
For more information on this calculation, see https://paketo.io/docs/reference/java-reference/#memory-calculator
Calculated JVM Memory Configuration: -XX:MaxDirectMemorySize=10M -Xmx595686K -XX:MaxMetaspaceSize=114169K -XX:ReservedCodeCacheSize=240M -Xss1M (Total Memory: 1221856K, Thread Count: 250, Loaded Class Count: 17743, Headroom: 0%)
Enabling Java Native Memory Tracking
Adding 127 container CA certificates to JVM truststore
Spring Cloud Bindings Enabled
Picked up JAVA_TOOL_OPTIONS: -Djava.security.properties=/layers/paketo-buildpacks_bellsoft-liberica/java-security-properties/java-security.properties -XX:+ExitOnOutOfMemoryError -XX:ActiveProcessorCount=1 -XX:MaxDirectMemorySize=10M -Xmx595686K -XX:MaxMetaspaceSize=114169K -XX:ReservedCodeCacheSize=240M -Xss1M -XX:+UnlockDiagnosticVMOptions -XX:NativeMemoryTracking=summary -XX:+PrintNMTStatistics -Dorg.springframework.cloud.bindings.boot.enable=true


	      |\      _,,,--,,_
	     /,`.-'`'   ._  \-;;,_
  _______ __|,4-  ) )_   .;.(__`'-'__     ___ __    _ ___ _______
 |       | '---''(_/._)-'(_\_)   |   |   |   |  |  | |   |       |
 |    _  |    ___|_     _|       |   |   |   |   |_| |   |       | __ _ _
 |   |_| |   |___  |   | |       |   |   |   |       |   |       | \ \ \ \
 |    ___|    ___| |   | |      _|   |___|   |  _    |   |      _|  \ \ \ \
 |   |   |   |___  |   | |     |_|       |   | | |   |   |     |_    ) ) ) )
 |___|   |_______| |___| |_______|_______|___|_|  |__|___|_______|  / / / /
 ==================================================================/_/_/_/

:: Built with Spring Boot :: 2.7.1
...
...

3.1 Security

Cloud Native Buildpacks는 사전 보안 패치라는 부가 가치도 제공한다. 플러그인을 사용할 때마다 스프링은 가장 최신 paketo 기본 빌더 이미지를 다운로드한다.

빌더는 최신 보안 패치로 지속적으로 업데이트된다. 이미지 빌드는 전적으로 이 빌더 컨테이너 내에서 이루어진다. 최신 보안 패치가 포함된 새 빌드팩이 제공될 때마다 최신 빌드에 적용된다.

이 프로세스를 대규모로 수행하려면 kpack 오픈소스 프로젝트를 이용하여 kubernetes에서 직접 빌드할 수도 있다.

3.2 Cloud Native Buildpacks in different Spring Boot versions.

여러 버전별로 등록하는 방식이 다르다는 것 같다. 새로운 프로젝트에는 그냥 Native Support를 추가하면 저절로 되지 않나? 싶다.

4 Running the image

쿠버네티스에서 이미지를 실행할 때 고려해야 할 요소가 많이 있다. 12가지 팩터 앱을 개발한다는 개념은 여전히 유효하다. 컨테이너와 쿠버네티스에서 실행할 때 몇가지 요소가 명백해 보이더라도 이 북극성을 갖는 것은 여전히 좋다.

  • dev/prod parity
  • scailing out
  • disposability
  • outputting logs

모두 쿠버네티스 모델에 적합하다. 또한 최근에는 12가지 요소를 넘어서 다른 요소들이 추가 제안되고 있다. (https://12factor.net/ko/) 모든 요소가 중요하지만 쿠버네티스에 관련해서는 무엇보다도 고려해야 할 한가지 요소가 있다. 이것은 "1-Factor app" 이라고도 불린다. 이것은 (Can it be restarted gracefully?)로 축약된다. 이것이 가능하다면, 애플리케이션이 모든 모범 사례에 따라 완벽하게 작성되지 않았더라도 쿠버네티스가 백그라운드에서 많은 작업을 수행하도록 할 수 있다.

4.1 Liveness and readiness probes

Spring 앱이 쿠버네티스에서 잘 실행되려면, 다양한 라이프사이클 단계에서 쿠버네티스가 애플리케이션과 상호 작용하는 방식을 이해해야 한다. 이는 스프링 애플리케이션이 실행 중이며 트래픽을 수신할 준비가 되었음을 쿠버네티스 런타임에 알리는 것으로 시작된다.

4.1.1 Actuator health endpoint

스프링 부트는 이와 같은 용도로 설계된 엔드포인트가 있다. 스프링 부트 스타터 액추에이터를 클래스 경로에 추가하면 애플리케이션의 상태와 메트릭을 보고하는 다양한 프로덕션 지원 엔드포인트르 가질 수 있다. 기본적으로 /health 엔드포인트는 항상 노출되어있다. 이전 버전의 스프링 부트는 info 엔드포인트를 사용했지만 사라졌다.

classpath 내에서 액츄에이터를 사용하면, 애플리케이션이 트래픽을 수락할 준비가 되었는지, 그리고 애플리케이션이 여전히 "활성(active)"으로 간주되는지 여부를 쿠버네티스에 알리기 위해 쿠버네티스 배포 매니페스트에 다음을 추가할 수 있다.

spec:
  containers:
      ...
      livenessProbe:
	httpGet:
	  port: 8080
	  path: /actuator/health
      readinessProbe:
	httpGet:
	  port: 8080
	  path: /actuator/health

좋은 출발점이다. Spring Boot 액추에이터 상태 엔드포인트가 200 OK를 반환 할 때만 트래픽을 허용하도록 애플리케이션에 지시하고 있다. 런타임 중에 엔드포인트가 오류를 반환하는 경우, Kubernetes에 파드를 다시 시작하도록 지시한다. 하지만 항상 그렇듯이 주의할 점이 있다.

  1. 상태 엔드포인트가 오류를 반환할 때마다 정말로 파드를 다시 시작하고 싶은가? 기본 액추에이터 앤드포인트는 해당 서비스만의 헬스가 아닌 전체적인 데이터베이스 또는 메시지 브로커에 대한 연결이 잘 되어있는지 여부도 함께 확인하는 애플리케이션의 전반적인 상태를 말한다. 그러므로 데이터베이스를 엑세스할 수 없는 경우 애플리케이션을 다시 시작하면 다시 시작할 때마다 커넥션풀이 생성될 때 데이터베이스에 부담을 가중시키는 것외에는 효과가 없고, 연쇄장애가 일어날 수 있다. 회로차단기를 구현하고 모니터링 시스템에 경고를 추가하는 것이 더 나은 접근 방식일 수 있다. 애플리케이션의 외부 구성 요소를 기반으로 준비 프로브(readiness probe)를 구성하는 것이 합리적이지만, 이 경우도 상황에 따라 다르다. 시작 시 Redis 캐시에 액세스 하 ㄹ수 없는 것은 괜찮지만 기본 데이터베이스에 엑세스할 수 없는 것은 괜찮지 않을 수 있다.
  2. 액추에이터 엔드포인트에 별도의 포트를 할당하는 것이 모범 사례로 간주된다. 이러한 엔드포인트는 애플리케이션에 대한 민감함 지표와 정보를 노출할 수 있으므로 다른 포트로 이동하면 이러한 정보에 대한 액세스를 쉽게 차단하는 동시에 주 애플리케이션 포트는 외부 서비스나 침입에 개발된 상태로 유지할 수 있기 때문이다. 이는 특히 쿠버네티스와 같은 분산 및 멀티테넌트 환경의 경우에 해당된다. 다른 관리 포트를 설정하려면 다음 속성을 설정하라.
management.server.port=8081
  1. Actuator health endpoint는 대부분 HTTP readiness(준비 상태)를 기반으로 한다. 애플리케이션이 배치 혹은 스트리밍 앱인 경우, 준비 여부를 보고하기 위해 다른 검증자를 제공해야 한다.

Spring는 전부 제공함.

4.2 Spring Liveness and Readiness Headlth Groups

Spring Boot 2.2에는 상태 그룹이라는 개념이 도입되어 여러 액추에이터 상태 표시기(health indicator)를 그룹화할 수 있다. 특정 그룹에 대해서만 특정 속성을 정의할 수 있다. Spring Boot 2.3에서는 준비 상태 그룹과 활성 상태 그룹이라는 두 가지 상태 그룹을 기본으로 생성하여 이 개념을 더 확장시켰다. (https://spring.io/blog/2020/03/25/liveness-and-readiness-probes-with-spring-boot) 이 두 그룹을 사용하도록 설정하려면 다음 설정을 사용하도록 설정해야 한다.

management.endpoint.health.probes.enabled=true

사용자 편의를 위해 Spring 앱이 Kubernetes에서 실행되는 것을 감지하면 이 속성을 자동으로 true 로 설정한다.

이 새로운 설정을 사용하면 /actuator/health 아래의 종속 리소스와 /actuator/health/readiness/actuator/health/liveness 라는 두 개의 개별 하위 그룹을 포함하여 완전하고 자세한 상태 정보를 얻을 수 있다.

1번항목(상태 엔드포인트가 오류를 반환할 때마다 정말로 파드를 다시 시작하고 싶은가?)에 대해서 설명한 이유에 따라서 Spring Boot는 기본적으로 준비 상태와 활력을 위해 외부 상태에 의존하지 않고 내부 애플리케이션 상태만 확인합니다.

이제 두 개의 별도 그룹으로 분리되었으므로, 애플리케이션에 적합한 자체적인 검사 지밥으로 각 그룹을 커스터마이즈 할 수 있다. 예를 들어, 애플리케이션이 트래픽을 수락할 준비가 되었다고 보고하기 전에 데이터베이스 연결이 되어야 하는 경우 해당 검사를 목록에 추가할 수 있다.

management.endpoint.health.group.readiness.include=readinessState,db

기본으로 제공되는 검사를 넘어서 커스터마이즈 한 검사를 작성할 수도 있다. 예를 들어, 애플리케이션이 트래픽을 수락할 준비가 되기 전에 Twillio와 같은 타사 SaaS API에 크게 의존한다고 가정하자. API 엔드포인트를 검사하는 로직을 추가할 수 있다.

management.endpoint.health.group.readiness.include=readinessState,externalTwilioVerifier

반대로 애플리케이션이 웹 트픽을 허용하지 않는 배치 또는 스트리밍 애플리케이션의 경우, 메시지 브로커와 같은 특정 외부 서비스에만 의존하는 경우도 있을 것이다.

management.endpoint.health.group.readiness.include=rabbitHealthIndicator

이제 애플리케이션에 "ready" 와 "alive" 가 무엇을 의미하는지 더 잘 제어할 수 있게 되었다.

하지만 대부분 엑추에이터 엔드포인트는 보안상의 이유로 별도의 "관리 포트(management port)"에 있어야 하는 반면, readiness 와 liveness probe(준비 빛 활성상태 확인절차)는 애플리케이션 포트에 있어야 한다는 점이 문제가 될 수 있다.

버전 2.3의 Spiring 설명서에는 이에 대한 큰 노란색 경고가 있다.

#+BEGINQUOTE If your Actuator endpoints are deployed on a separate management context, be aware that endpoints are then not using the same web infrastructure (port, connection pools, framework components) as the main application. In this case, a probe check could be successful even if the main application does not work properly (for example, it cannot accept new connections). #ENDQUOTE

다행히고 Spring 2.6 부터는 주 애플리케이션 포트 또는 관리 포트에 대한 추가 경로로 상태 그룹을 설정할 수 있다.

management.endpoint.health.group.readiness.additional-path="server:/readyz"
management.endpoint.health.group.liveness.additional-path="server:/livez"

이 설정은 readiness endpoint 가 메인포트(:server)에서 readyz 라는 HTTP 패스로 접근이 가능하다는 것이다. 만약 health group을 management port로 사용하고 싶으면 menagement: prefix로 바꾼다.

4.2.1 Some more liveness and readiness settings

컨테이너는 200-399 사이의 HTTP 응답 코드를 반환하면 컨테이너가 살아있거나 준비된 것으로 간주한다. 쿠버네티스는 4xx, 5xx가 아닌 한 반환되는 코드가 무엇이든 상관하지 않는다.

readiness, liveness probe 관련 몇가지 쿠버네티스 추가 설정할 수 있는 것이 있다.

livenessProbe:
    failureThreshold: 3  // 실패 응답을 정상으로 간주하는 횟수
    httpGet:
    path: /livez
    port: 8080
    periodSeconds: 10  // 엔드포인트를 폴링할 빈도 
    timeoutSeconds: 1  // HTTP 요청을 시간초과하고 파드를 재시작해야 하는 오류로 간주할 때 초 단위를 알려준다.
    initialDelaySeconds: 5  // 프로브하기 전에 대기할 시간을 나타낸다. 시작시간이 오래 걸릴 수 있는 Spring Boot 앱에선 특히 중요하다.   
readinessProbe:
    failureThreshold: 3
    httpGet:
    path: /readyz
    port: 8080
    initialDelaySeconds: 30
    periodSeconds: 10
    timeoutSeconds: 1

Spring에 내장된 readiness and liveness health probes를 사용하라. probe는 기본 애플리케이션 포트에 두고 다른 액추에이터 엔드포인트에 대해서는 별도의 간리 포트를 설정하라.

5 Graceful Shutdown

지금까지 startup lifecycle을 다뤘다. 하지만 shutdown은 어떨까? 쿠버네티스는 종료가 불가피하다. 클러스터 노드가 upgraded, removed or resized가 일어날 때 두 가지 일이 일어난다.

  • cordon 이라는 프로세스는 쿠버네티스가 노드에서 새 pod를 스케줄링하지 못하도록 한다. (상태가 SchedulingDisabled 로 변경됨)
  • drain 이라는 프로세스는 노드에서 모든 파들르 제거한다.
  • 이제 노드에서 유지관리(delete/upgrade/resize 등)을 수행할 수 있다.

종료 이유와 관계없이, 애플리케이션이 여전히 HTTP 요청을 처리중이거나 데이터베이스에 대한 쿼리를 실행 중일 때 갑자기 종료되지 않도록 해야 한다. 너무 빨리 종료되면 일부 클라이언트에 오류가 발생하고 일부 데이터베이스 트랜잭션이 업데이트 되지 않거나 데이터베이스에 따라 손상될 수 있다.

5.1 Configuring graceful shutdown

Spring Boot 2.3부터 graceful shutdown이 기본으로 제공된다.

server.shutdown=graceful

위 프로퍼티를 설정하고 컨테이너가 종료 시그널(SIGTERM)을 받으면 Spring은 실제로 종료하기 전에 ㅇ녀결된 웹 서버가 새 연결을 수락하지 못하도록 차단한다.

이제 Spring 애플리케이션을 종료하기 전에 모든 요청이 완료될 때까지 기다려야 한다. 이를 위해 종료 단계에 대한 시간 초과 속성이 있으며, 기본값은 30초이다. 아래처럼 설정할 수 있다.

spring.lifecycle.timeout-per-shutdown-phase=20s

이러한 매개변수는 쿠버네티스의 컨텍스트에서 실행하는 동안만 설정하고 싶을 것이다. 다행히는 스프링은 애플리케이션이 쿠버네티스에서 실행되는 것을 감지하면 자동으로 쿠버네티스라는 프로파일을 추가하기 때문에 이 작업을 쉽게 수행할 수 있다.

spring.application.name: "SpringPetclinic"
---
spring.application.name: "SpringPetclinicKubernetes"
spring.config.activate.on-cloud-platform: "kubernetes"
spring.lifecycle.timeout-per-shutdown-phase=20s
server.shutdown=graceful

위 프로퍼티는 쿠버네티스에서 실행되는 경우에만 적용된다. 쿠버네티스 외부에서는 서버가 즉시 종료된다.

5.2 종료되는 pod에 새로운 리퀘스트 방지

파드가 종료 수명 주기를 시작하는 동안 다른 쿠버네티스 리소스가 계속해서 트래픽을 해당 파드로 라우팅 할 수 있다. 셧다운이 프로세스가 시작될 때 파드로 들어오는 새로운 요청을 차단하기 위해, 하드 라이프사이클에서 사전 정지 단계를 구성할 수 있다.

spec:
  containers:
    image: odedia/spring-petclinic
    ...
    lifecycle:
      preStop:
	exec:
	  command: ["sh", "-c", "sleep 10"]
  terminationGracePeriodSeconds: 30

이 간단한 사례에는 종료 수명 주기가 시작되면(preStop) 10초 동안 절전 모드로 전환되도록 한다. 이 시간동안 다른 컴포넌트가 파드가 더 이상 게임에 있지 않다는 것을 파악할 수 있도록 한다. 이 시간 동안 처음에는 컨테이너에 실행 중인 메인 애플리케이션에 새 요청이 계속 들어올 수 있지만 결국에는 새 요청이 라우팅 되지 않아야 한다.

6 Profiling your Spring Boot application

7 Reference

Date: 2023-03-10 Fri 00:00

Author: Younghwan Nam

Created: 2024-12-21 Sat 16:39

Emacs 27.2 (Org mode 9.4.4)

Validate