Problem
Production 환경 스케줄링된 배치(k8s CronJob)가 실패되는 현상이 발생하였습니다.
여기에 back-off 정책까지 활성화되어 있어서 같은 배치가 여러번 실행까지 되었습니다.
(k8s에서 .spec.backOffLimit
가 0 이상이면 재시도하게 됩니다.)
그런데 중요한 것은 이 배치 애플리케이션은 성공했다는 것입니다. 성공했는데 왜 exit 코드가 0이 아닌 값이 나왔는지 의문이었습니다.
spring-boot application exit code
아래는 배치 관련 코드 중 일부입니다.
public class BatchTemplate {
private final ApplicationContext applicationContext;
public void execute(Class<?> batchClass, Runnable batch) {
log.info("[BATCH] {} Start", batchClass.getSimpleName());
try {
batch.run();
} catch (Exception exception) {
log.warn("[BATCH] {} Failed", batchClass.getSimpleName(), exception);
throw exception;
}
log.info("[BATCH] {} Completed", batchClass.getSimpleName());
int exitCode = SpringApplication.exit(applicationContext, () -> 0);
if (isFailed(exitCode)) {
log.error("[BATCH][AUDIT] {} Failed. exitCode: {}", batchClass.getSimpleName(), exitCode);
}
}
private boolean isFailed(int exitCode) {
return exitCode != 0;
}
}
Java보시다시피 배치의 시작, 성공, 실패에 대해서 로깅을 해 둔 상태입니다.
예외가 발생하거나 내부에 ExitCodeGenerator가 0이 아닌 값을 반환한다면 exit 코드가 0이 아니게 되어서 실패로 처리됩니다.
구축한 코드에서는 명시적인 ExitCodeGenerator가 없으므로 예외가 발생했다는 뜻인데, 이 예외를 간단히 확인하기 위해서는 main()
메서드에서 명시적으로 예외를 catch 해보았습니다.
@SpringBootApplication
@Slf4j
public class SomeBootApplication {
public static void main(String[] args) {
try {
SpringApplication.run(SomeBootApplication.class, args);
} catch (Exception cause) {
log.error("Main Error", cause);
throw cause;
}
}
}
Java이렇게 해서 예외를 확인해보니 아래와 같이 SilentExitExceptionHandler$SilentExitException
라는 예외가 확인되네요.
org.springframework.boot.devtools.restart.SilentExitExceptionHandler$SilentExitException: null
at org.springframework.boot.devtools.restart.SilentExitExceptionHandler.exitCurrentThread(SilentExitExceptionHandler.java:92)
at org.springframework.boot.devtools.restart.Restarter.immediateRestart(Restarter.java:179)
at org.springframework.boot.devtools.restart.Restarter.initialize(Restarter.java:163)
at org.springframework.boot.devtools.restart.Restarter.initialize(Restarter.java:539)
at org.springframework.boot.devtools.restart.RestartApplicationListener.onApplicationStartingEvent(RestartApplicationListener.java:98)
at org.springframework.boot.devtools.restart.RestartApplicationListener.onApplicationEvent(RestartApplicationListener.java:51)
at org.springframework.context.event.SimpleApplicationEventMulticaster.doInvokeListener(SimpleApplicationEventMulticaster.java:174)
at org.springframework.context.event.SimpleApplicationEventMulticaster.invokeListener(SimpleApplicationEventMulticaster.java:167)
at org.springframework.context.event.SimpleApplicationEventMulticaster.multicastEvent(SimpleApplicationEventMulticaster.java:145)
at org.springframework.context.event.SimpleApplicationEventMulticaster.multicastEvent(SimpleApplicationEventMulticaster.java:133)
at org.springframework.boot.context.event.EventPublishingRunListener.multicastInitialEvent(EventPublishingRunListener.java:136)
at org.springframework.boot.context.event.EventPublishingRunListener.starting(EventPublishingRunListener.java:75)
at org.springframework.boot.SpringApplicationRunListeners.lambda$starting$0(SpringApplicationRunListeners.java:54)
at java.base/java.lang.Iterable.forEach(Iterable.java:75)
at org.springframework.boot.SpringApplicationRunListeners.doWithListeners(SpringApplicationRunListeners.java:118)
at org.springframework.boot.SpringApplicationRunListeners.starting(SpringApplicationRunListeners.java:54)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:316)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1317)
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1306)
at com.example.SomeApplication.main(SomeApplication.java:32)
Javaspring-boot-devtools
해당 예외는 spring-boot-devtools이 활성화 된 상태에서 auto-restart 기능 때문에 존재하는데, 왜 production 환경에서 spring-boot-devtools 가 활성화되었는지 의문이었습니다.
spring-boot-devtools 가 활성화 되려면 조건이 필요합니다.
- 패키지화된 경우에는 기본으로 비활성화.
java -jar som-application.jar
처럼-jar
옵션으로 실행하는 경우developmentOnly("org.springframework.boot:spring-boot-devtools")
로 의존성 추가했기 때문 - 특수한 ClassLoader로 시작하는 경우.
그래서 local에서 이것저것 확인하면서 테스트 해봤는데, 문제를 발견할 수 없었습니다.
- local 컴퓨터에서 IDE로 기동하면 devtools 가 활성화.
-
cp 옵션 실행 java -jar someApplication.jar
명령어로 실행하면 devtools가 비활성화
그럼 애플리케이션은 문제 없다고 보고 인프라 레벨에서 확인해보기로 했습니다.
k8s CronJob
컴퓨팅 시 AWS EKS를 사용하고 있고, 그 중 스케줄링되는 배치는 k8s CronJob을 사용하고 있기 때문에 CronJob(or Job) 설정에 문제가 있다고 판단을 했습니다.
애플리케이션은 정상적으로 종료하지만 CronJob 에서 어떤 제약으로 인해 정상 종료 되지 않았다고 봤습니다.
구성을 확인해보니 의심가는 부분이 있었습니다.
activeDeadlineSeconds
: Job이 기동되는 최대 시간terminationGracePeriodSeconds
: 셧다운 대기 유예 시간
2개의 값이 너무 작게 설정되어 있어서 넉넉하게 늘려뒀습니다.
activeDeadlineSeconds: 600
terminationGracePeriodSeconds: 100
그래도 문제는 해결되지 않았습니다.(지면 상으로는 간단하게 적어두었지만 실제로는 여러가지 종류의 실험을 많이 했습니다.)
결국 Java 애플리케이션의 프로세스가 어떻게 실행되는지 알기 위해서 Pod내 Shell로 직접 접속해서 여러가지 확인해보았습니다.
Into the pod
개발 환경에서 해당 pod에 접속하였습니다.
bash-5.2# jps -v
1 SomeApplication -Xms512m -Xmx512m -javaagent:/newrelic/newrelic.jar
33549 Jps -Dapplication.home=/usr/lib/jvm/java-21-amazon-corretto.x86_64 -Xms8m -Djdk.module.main=jdk.jcmd
Bashjps
명령어로 해당 프로세스가 확인됩니다. ps
명령어로도 확인해보겠습니다.
bash-5.2# ps -ef | grep SomeApplication
root 1 0 3 01:48 ? 00:05:10 java -Xms512m -Xmx512m -javaagent:/newrelic/newrelic.jar -cp @/app/jib-classpath-file com.example.SomeApplication
root 34103 33321 0 04:00 pts/0 00:00:00 grep SomeApplication
Bash저는 이것을 보고 눈을 의심했습니다. -cp
(classpath) 옵션으로 실행되고 있었습니다. 😬-cp
옵션은 -jar
옵션과 다르게 패키징된 jar를 기반으로 애플리케이션을 실행하는 것이 아니라 특정 classpath를 기반으로 애플리케이션을 실행합니다.
app/jib-classpath-file
도 바로 확인해보겠습니다.
/app/resources:/app/classes:/app/libs/spring-boot-devtools-3.1.10.jar:/app/libs/spring-boot-starter-actuator-3.1.10.jar:/app/libs/spring-boot-starter-cache-3.1.10.jar:/app/libs/spring-boot-starter-data-jpa-3.1.10.jar:/app/libs/spring-boot-starter-data-redis-3.1.10.jar:...
Bash이렇게 특정 폴더들과 엄청난 수의 jar 파일들이 선언되어 있습니다.
-cp 옵션은 특정 경로의 클래스 파일과 리소스 그리고 jar들로 JVM 기반 애플리케이션을 기동하는 옵션입니다.
위에서 기억하실지 모르겠지만 -jar
가 아닌 -cp
옵션을 사용하게 되면 spring-boot-devtools
가 활성화 됩니다.
드디어 문제 해결의 실마리를 찾았습니다!
Checkpoint
java -cp
로 애플리케이션이 기동되어서spring-boot-devtools
가 활성화.
현재 Container 내에서 디렉토리 구조를 살펴보겠습니다.
bash-5.2# cd /app
bash-5.2# tree -d -L 2
.
├── classes
│ ├── META-INF
│ └── com
├── libs
└── resources
├── config
├── db
├── font
├── message
└── templates
Bash원래 spring boot 애플리케이션을 패키징하면 하나의 jar 만 나오는데, 여기는 특이하게 위와 같은 디렉토리 구조네요. 자세히 확인해보니
- classes: 코드의 classes
- libs: 의존하는 jar들
- resources: classe 외 정적 파일들
이러면 Containerization 담당하는 Jib를 확인해봐야겠네요.
Jib
Jib은 자바로 된 애플리케이션을 컨테이너 이미지로 패키징(Containerization) 도구입니다.
Gradle나 Maven과 같은 자바의 인기있는 빌드 도구에 잘 통합되어 있습니다.
Jib 참고 자료
- https://cloudplatform.googleblog.com/2018/07/introducing-jib-build-java-docker-images-better.html
- https://speakerdeck.com/coollog/build-containers-faster-with-jib-a-google-image-build-tool-for-java-applications?slide=61
Gradle(kts)에서는 아래와 같은 선언으로 사용하고 있습니다.
jib {
from {
image = "amazoncorretto:21-al2023-jdk"
}
to {
image = System.getenv("ECR_REGISTRY") + "/" + System.getenv("ECR_REPOSITORY")
tags = setOf(System.getenv("IMAGE_TAG") ?: "default_tag")
}
container {
jvmFlags = listOf(
System.getenv("JVM_XMS") ?: "-Xms512m",
System.getenv("JVM_XMX") ?: "-Xmx512m",
"-javaagent:/newrelic/newrelic.jar"
)
}
}
Bashfrom
: base 이미지 설정. 여기에서는 아마존 Corretto 21 JDK를 아마존 리눅스 2023 OS로 설정함to
: 이미지 Registry와 경로, 태그 정보를 설정. 여기에서는 환경변수화 된 아마존 ECR로 설정container
: 컨테이너 내 설정. 여기에서는 jvm 옵션 설정. 이 외에 컨테이너 내 환경 변수 설정도 가능함.
위 설정만으로는 jar 패키징되지 않았는지 알 수가 없군요.
좀 더 자세하게 Jib 문서를 확인해봐야겠습니다.
jib-gradle-plugin 문서를 확인해보니 아래와 같은 옵션이 있습니다.
containerizingMode | String | exploded | If set to packaged , puts the JAR artifact built by the Gradle Java plugin into the final image. If set to exploded (default), containerizes individual .class files and resources files. |
오! 문제 해결에 거의 도달한 것 같습니다!
Solution
jib {
containerizingMode = "packaged" // 추가
from {
image = "amazoncorretto:21-al2023-jdk"
}
...
}
Kotlinjib.containerizingMode="packaged"
로 설정해서 문제를 해결할 수 있었습니다.
그런데 여기에서 한가지 의문이 들었습니다. 왜 Jib는 이미지 내에 단일 Fat jar로 패키징하지 않고, 모든 파일들을 풀어서 패키징했을까요? 감이 있으신 분은 아시겠지만 그 이야기를 한 번 해보겠습니다.
No Fat Jar
아시다시피 Docker 이미지는 레이어로 되어 있습니다.
하지만 Fat jar를 사용하게 되면 아래처럼 jar의 크기만큼 애플리케이션과 관련한 레이어가 커지게 됩니다.
그럼 배포본 jar 크기 별로 증분되네요.
하지만 실재 Fat jar의 내용을 살펴보면 아래와 같습니다.
위에 보시는 바와 같이 의존성 라이브러리(jars)가 대부분을 차지하고 있고, 그 외 Resouce(non classe)와 코드(class)가 있네요. 실제 우리가 자주 변경하는 코드가 가장 용량이 적네요.
만약 코드만 변경해서 재배포 하는 경우 실제 변경된 코드 2MB만 증분하면 되는데, Fat jar로 배포하면 전체 패키지 사이즈인 72MB 만큼 증분됩니다. 정말 비효율적이라고 보입니다.
그래서 Jib는 이러한 낭비를 줄이고 증분 빌드 효율을 최대한 내기 위해서 아래와 같은 구조로 컨테이너 이미지를 빌드하게 됩니다.
이전에 Pod에 접속해서 확인했던 디렉토리 구조로 확인되네요.
bash-5.2# tree -d -L 1
.
├── classes
├── libs
└── resources
Bash- classes: Code
- libs: Dependencies
- resources: Resources
이러한 사실을 알게되니 jib.containerizingMode="packaged"
옵션을 사용하지 않아야겠군요.
Checkpoint
컨테이너 이미지 레이어를 효율적으로 증분
!
Solution 2.
그렇다면 다시 원점으로 돌아가서 jib.containerizingMode="exploded"
(default) 옵션으로 해결해봐야겠네요. 이미 github 이슈로 등록되어 있었네요.
그 결과 아래와 같은 Jib Extensions 가 생성됐습니다.
이것을 현재 프로젝트에 적용하면 됩니다. (만약 spring-boot 2.3.0 이전이라면 useDeprecatedExcludeDevtoolsOption
을 true
로 설정해주세요 – 자세한 내용은 README.md 참조)
buildscript {
dependencies {
classpath("com.google.cloud.tools:jib-spring-boot-extension-gradle:0.1.0")
}
}
...
jib {
from {
image = "amazoncorretto:21-al2023-jdk"
}
to {
image = System.getenv("ECR_REGISTRY") + "/" + System.getenv("ECR_REPOSITORY")
tags = setOf(System.getenv("IMAGE_TAG") ?: "default_tag")
}
container {
jvmFlags = listOf(
System.getenv("JVM_XMS") ?: "-Xms512m",
System.getenv("JVM_XMX") ?: "-Xmx512m",
"-javaagent:/newrelic/newrelic.jar"
)
}
// 추가된 JibSpringBootExtension
pluginExtensions{
pluginExtension {
implementation = "com.google.cloud.tools.jib.gradle.extension.springboot.JibSpringBootExtension"
}
}
}
Kotlin추가한 JibSpringBootExtension
코드를 확인해보니 Container 이미지 빌드 시 spring-boot-devtools-*.jar
파일을 제외시키는 필터가 적용되어 있습니다.
static boolean isDevtoolsJar(File file) {
return file.getName().startsWith("spring-boot-devtools-") &&
file.getName().endsWith(".jar");
}
...
static LayerObject filterOutDevtools(LayerObject layerObject) {
String dependencyLayerName = JavaContainerBuilder.LayerType.DEPENDENCIES.getName();
if (!dependencyLayerName.equals(layerObject.getName())) {
return layerObject;
}
FileEntriesLayer layer = (FileEntriesLayer) layerObject;
Predicate<FileEntry> notDevtoolsJar =
fileEntry -> !isDevtoolsJar(fileEntry.getSourceFile().toFile());
List<FileEntry> newEntries =
layer.getEntries().stream().filter(notDevtoolsJar).collect(Collectors.toList());
return layer.toBuilder().setEntries(newEntries).build();
}
Java이로써 이슈가 해결됐네요. 😀
Refs.
- https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#using.devtools
- https://kubernetes.io/ko/docs/concepts/workloads/controllers/cron-jobs/
- https://kubernetes.io/ko/docs/concepts/workloads/controllers/job/
- https://github.com/GoogleContainerTools/jib/issues/2336
- https://github.com/GoogleContainerTools/jib-extensions/pull/31
- https://phauer.com/2019/no-fat-jar-in-docker-image/
- https://github.com/GoogleContainerTools/jib-extensions/tree/master/first-party/jib-spring-boot-extension-gradle