10/22/2024

jlink로 Spring Boot 애플리케이션 Docker 이미지 크기 줄이기

항상 NodeJS 기반 프레임워크의 Docker 이미지만 만들다가 Spring 애플리케이션의 Docker 이미지를 Dockerfile로 생성했는데 크기가 무려 500MB가 넘어갔다... ExpressJS 만큼 작을 수는 없어도 크기가 너무 컸다. 무엇보다도 Dockerfile을 어떻게 작성해야 하는지 감이 전혀 잡히지 않았다. 그래서 이번 기회에 Spring 애플리케이션의 Docker 이미지 크기를 줄이는 방법에 대해 알아본다.


기본 이미지

Docker 이미지의 크기가 작으면 컨테이너 빌드와 배포 속도가 빨라질 뿐만 아니라 불필요한 파일을 없애서 취약점을 제거하고 이로 인해 보안도 향상시킬 수 있다. NodeJS 기반 Docker 이미지처럼 첫 단계는 항상 어떤 기본 이미지를 선택하는 것인데 당연히 작아야 한다. 그 기본 이미지는 Alpine Linux이다. Alpine Linux는 musl libc와 BusyBox를 기반으로 구축된 Linux 배포판으로 이미지 크기는 단 5MB에 불과하다. 하지만 Java 자체도 이미지 내에서 일부 공간을 차지한다. 어떻게 이 크기를 줄일 수 있을까? 그것은 jlink 라는 도구로 Java의 크기를 줄일 수 있다. 다시 말해, jlink를 사용하면 애플리케이션에 필요한 모듈만 선택하여 런타임 이미지로 연결할 수 있다.


Dockerfile

기본 이미지와 jlink를 사용해서 이제 Dockerfile을 작성한다. base 단계에서 Spring 애플리케이션을 위한 Java 런타임을 설정하고 BellSoft에서 로컬 환경에서 사용한 OpenJDK 버전을 다운로드하고 압축을 푼다. 이 때 반드시 Alpine Linux로 설정된 릴리즈를 다운로드 해야한다. 그런 다음 jlink 명령을 실행하여 JDK가 포함된 사용자 정의 이미지를 생성한다. 애플리케이션을 실행하기 필요한 최소한의 Java 모듈을 포함해야 한다. 이는 jdeps 명령을 실행하여 JAR 파일에 필요한 모듈 목록을 확인할 수 있다. 사실 jdeps를 사용해서 필요한 모듈을 찾으려고 했는데 not found 오류 때문에 고생을 좀 했다. 검색을 해도 마땅한 해결책을 찾을 수 없어 Dockerfile로 빌드에서 오류로 나오는 모듈을 그대로 적었다. 검색을 조금 더 해보니 내가 지금 사용하는 JDK 17에 버그가 있다고 한다... 여하튼 jdeps 문제는 해결이 필요하다! 마지막으로 prod 단계에서 base 단계에서 생성된 디렉터리에서 최적화된 JDK 버전을 /opt/jdk 경로에 복사한 후java -jar 명령을 사용하여 애플리케이션을 실행한다. Dockerfile은 다음과 같다!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
FROM alpine:3.16 AS base
 
ENV JAVA_HOME /opt/jdk/jdk-17.0.12
ENV PATH $JAVA_HOME/bin:$PATH
 
ADD https://download.bell-sw.com/java/17.0.12+10/bellsoft-jdk17.0.12+10-linux-x64-musl.tar.gz /opt/jdk/
RUN tar -xzvf /opt/jdk/bellsoft-jdk17.0.12+10-linux-x64-musl.tar.gz -C /opt/jdk/
 
WORKDIR /opt/app
COPY . .
 
RUN ./gradlew clean build -x test
RUN ["jlink", "--compress=2", \
     "--module-path", "/opt/jdk/jdk-17.0.12/jmods/", \
     "--add-modules", "java.base,java.sql,java.xml,java.management,java.compiler,java.security.jgss,java.instrument,java.scripting,java.logging,java.naming,java.desktop,jdk.unsupported", \
     "--no-header-files", "--no-man-pages", \
     "--output", "/runtime"]
 
 
FROM alpine:3.16 AS prod
 
COPY --from=base /runtime /opt/jdk
COPY --from=ghcr.io/ufoscout/docker-compose-wait:latest /wait /wait
 
ENV PATH=$PATH:/opt/jdk/bin
EXPOSE 3000
 
COPY --from=base /opt/app/build/libs/whooa-blog-0.0.1-SNAPSHOT.jar /opt/app/
 
CMD ["java", "-jar", "/opt/app/whooa-blog-0.0.1-SNAPSHOT.jar"]
cs


Eclipse Temurin

이미지의 크기는 168MB로 jlink 사용 전에 크기에 비하면 약 67%가 넘게 감소했다. 하지만 여기서 더 줄일 수 없을까? dive라는 Docker 이미지 분석 도구를 통해서 생성된 Docker 이미지를 보면 168MB 중에 Java가 84MB로 많은 용량을 차지하고 있다. Eclipse Temurin에서 제공하는 OpenJDK를 사용하면 용량을 더 줄일 수 있다! Dockerfile의 차이는 BellSoft를 Eclipse Temurin으로 변경하는 것 밖에 없다. Docker 이미지 빌드 결과 크기가 147MB 21MB 정도 감소했다! dive로 분석을 하면 Java 용량이 62MB로 줄어들었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
FROM alpine:3.16 AS base
 
ENV JAVA_HOME /opt/jdk/jdk-17.0.12+7
ENV PATH $JAVA_HOME/bin:$PATH
 
ADD https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.12%2B7/OpenJDK17U-jdk_x64_alpine-linux_hotspot_17.0.12_7.tar.gz /opt/jdk/
RUN tar -xzvf /opt/jdk/OpenJDK17U-jdk_x64_alpine-linux_hotspot_17.0.12_7.tar.gz -C /opt/jdk/
 
 
FROM base AS source
 
WORKDIR /opt/app
COPY . .
 
RUN ./gradlew clean build -x test
RUN ["jlink", "--compress=2", \
     "--module-path", "/opt/jdk/jdk-17.0.12+7/jmods/", \
     "--add-modules", "java.base,java.sql,java.xml,java.management,java.compiler,java.security.jgss,java.instrument,java.scripting,java.logging,java.naming,java.desktop,jdk.unsupported", \
     "--no-header-files", "--no-man-pages", \
     "--output", "/runtime"]
 
 
FROM alpine:3.16 AS prod
 
COPY --from=source /runtime /opt/jdk
COPY --from=ghcr.io/ufoscout/docker-compose-wait:latest /wait /wait
 
ENV PATH=$PATH:/opt/jdk/bin
EXPOSE 3000
 
COPY --from=source /opt/app/build/libs/whooa-blog-0.0.1-SNAPSHOT.jar /opt/app/
 
CMD ["java", "-jar", "/opt/app/whooa-blog-0.0.1-SNAPSHOT.jar"]
cs


비루트 사용자

NodeJS에서 파일과 디렉터리를 생성 및 복사할 때 항상 node라는 비루트(non-root) 사용자를 사용했으며 Distroless 이미지의 경우 1000이라는 사용자가 존재한다. Spring은 어떠할까? 검색을 했는데 Spring은 딱히 비루트 사용자가 존재하지 않아 사용자를 만들어야 한다. Linux 명령어를 사용해서 만들 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
FROM alpine:3.16 AS base
 
ENV JAVA_HOME /opt/jdk/jdk-17.0.12+7
ENV PATH $JAVA_HOME/bin:$PATH
 
ADD https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.12%2B7/OpenJDK17U-jdk_x64_alpine-linux_hotspot_17.0.12_7.tar.gz /opt/jdk/
RUN tar -xzvf /opt/jdk/OpenJDK17U-jdk_x64_alpine-linux_hotspot_17.0.12_7.tar.gz -C /opt/jdk/
 
 
FROM base AS source
 
WORKDIR /opt/app
COPY . .
 
RUN ./gradlew clean build -x test
RUN ["jlink", "--compress=2", \
     "--module-path", "/opt/jdk/jdk-17.0.12+7/jmods/", \
     "--add-modules", "java.base,java.sql,java.xml,java.management,java.compiler,java.security.jgss,java.instrument,java.scripting,java.logging,java.naming,java.desktop,jdk.unsupported", \
     "--no-header-files", "--no-man-pages", \
     "--output", "/runtime"]
 
 
FROM alpine:3.16 AS prod
 
ARG USER=spring
RUN adduser --no-create-home -u 1000 -D $USER
 
WORKDIR /opt/app
RUN chown -R 1000:1000 /opt/app
 
USER 1000
 
COPY --from=source --chown=1000:1000 /runtime /opt/jdk
COPY --from=ghcr.io/ufoscout/docker-compose-wait:latest /wait /wait
 
ENV PATH=$PATH:/opt/jdk/bin
EXPOSE 3000
 
COPY --from=source --chown=1000:1000 /opt/app/build/libs/whooa-blog-0.0.1-SNAPSHOT.jar /opt/app/
 
CMD ["java", "-jar", "/opt/app/whooa-blog-0.0.1-SNAPSHOT.jar"]
cs

update: 2024.10.22

댓글 없음:

댓글 쓰기