[Docker] 이미지 크기를 줄이기 위한 여정

soodo·2024년 5월 21일

이미지 경량화의 필요성

현재 프로젝트는 AWS 프리티어를 사용하고 있고, RAM 메모리 부족으로 인해 현재 2GB의 디스크 메모리를 스왑 공간으로 활용하고 있습니다.
RAM 메모리는 해결했지만, 덕분에 이제는 Docker의 이미지를 불러올 때마다 디스크 공간이 부족한 지경에 이르렀는데, 최초 550MB의 이미지 크기를 개선하기 위해 Docker의 이미지 크기를 최대한 줄여 문제를 해결해봅시다.!

Base 이미지 경량화하기

FROM openjdk:17
WORKDIR ../
CMD ["./gradlew", "clean", "build"]
ARG JAR_FILE=./build/libs/*.jar
COPY ${JAR_FILE} app.jar
CMD ["java", "-jar", "-Dspring.profiles.active=default", "app.jar"]

현재 저희 프로젝트는 Github Action을 사용해 빌드한 .jar파일을 docker 공간에 불러오고 실행합니다. 여기서 주목할 점은 저희가 이미 빌드된 파일을 사용한다는 점 입니다.

JDK와 JRE

Java를 처음 접하신 분들도 실행을 위해서 JDK, JRE라는 패키지가 필요하다는 것을 알 것이고, 모른다 하시더라도 이미 사용하고 계실 것입니다.

그러면 JDK와 JRE에 차이에 대해 다시 짚고 가보겠습니다.

  • JDK(Java Development Kit): JDK는 Java 개발자들이 개발하는데 사용되는 SDK 키트입니다.
    • javac, javadoc등 개발 도구를 포함합니다.
    • JRE는 JDK의 일부에 속합니다.
  • JRE(Java Runtime Environment): JRE는 자바 프로그램을 실행시킬 떄 필요한 API를 묶어 배포되는 패키지입니다.

위에서 보았듯이 JDK는 JRE에 javac, javadoc 등 개발 키트를 추가로 포함하는 패키지입니다.

저희 프로젝트가 실행하고자 하는 .jar파일은 .class 형식의 클래스 파일, 라이브러리 등을 실행 가능한 형태로 보관하기 때문에 JDK가 추가로 가지는 개발 키트는 불필요합니다.

이미지 교체하기

FROM eclipse-temurin:17-jre 
WORKDIR ../
CMD ["./gradlew", "clean", "build"]
ARG JAR_FILE=./build/libs/*.jar
COPY ${JAR_FILE} app.jar
CMD ["java", "-jar", "-Dspring.profiles.active=default", "app.jar"]

결과적으로 jdk를 jre로 교체했습니다.

또한 위와 같은 맥락으로 Docker는 실행을 위해 필요한 파일, 라이브러리만 존재하면 되기 때문에 이를 위해 만들어진 ecliipse-temurin과 같은 경량 이미지를 사용하면 이미지를 더욱 줄일 수 있습니다.

배포된 이미지 크기도 521MB → 457MB로 70MB가 감소한 것을 볼 수 있습니다.

Multi Stage Build

Docker의 이미지 레이어를 알아봅시다.

Docker에서 이미지 레이어는 계속해서 쌓이는 형태를 띄게 됩니다.

COPY를 사용해 파일을 추가하면 . 그 위에 레이어가 쌓여 Read/Write가 되고, 그 이후 해당 영역을 포함한 새로운 이미지가 Read Only가 됩니다.

때문에 한 번 Image Layer가 쌓이게 되면 파일을 삭제해도 이미지 사이즈는 줄어들지 않습니다.

이런 이유로 이미지 크기를 줄이기 위해 적합한 순서로 명령을 실행하는 것이 중요하고, 이것에 따라 생성되는 이미지의 구조가 달라질 수도 있습니다.

그렇다면 어떻게 이미지 크기를 줄일 수 있을까?

이런 문제를 해결하기 위해 Multi-Stage Build를 적용할 수 있습니다.

멀티 스테이지 빌드에서 Dockerfile은 여러개의 FROM절을 사용하고, 각 FROM 명령어는 서로 다른 이미지 영역을 가지게 됩니다.

이런 점을 활용해 최종적으로 사용할 이미지에는 불필요한 환경을 제거하고 실행을 위해 필요한 환경만을 구축한 이미지를 사용할 수 있습니다.

이거 이상합니다…?

웹 개발에서는 대표적으로 빌드/실행 2가지 환경을 분리해 실행 이미지만을 최종 이미지로 넘기는 방식이 대표적이기 때문에 해당 방식을 사용하기로 했습니다.

다만, 생각해보니 현재 프로젝트 Github Action 내에서 .jar 파일로 빌드 → Docker 이미지에 복사 하는 과정을 거치기 때문에 이미 빌드/실행 환경이 분리되어 있는데….

스프링 이미지를 최적화 해봅시다.

Docker는 매번 모든 레이어를 빌드하지 않고 가능한 경우 캐시된 레이어를 사용는데, 이런 점을 활용해 스프링에서는 제공하는 layered jar를 활용해 빌드 시간을 단축시킬 수 있습니다.

Docker Cache의 경우 여러 경우에 무효화 되게 되는데, 대표적으로 자신의 하위 레이어가 변경되었을 때 무효화 됩니다. 따라서, 변경이 가장 빈번한 Layer를 최상단에 위치시켜 캐시 기능을 활용할 수 있습니다.

Spring은 Docker Cache를 제공해 스프링 이미지 빌드 시간을 단축시키기 위해 layered jar를 제공합니다.

Layered jar는 4가지 레이어로 분리되는데 이미지처럼 대부분의 변경은 application 즉, 개발자가 작성하는 코드레벨에서 가장 많이 발생하기 때문에 이런 계층을 Docker Layer의 가장 하위에 위치시킴으로써 Docker Cache를 활용할 수 있습니다.

  • Layered jar 구조:
    • Spring-Boot Loader: Spring Boot 실행에 필요한 로더 클래스를 포함합니다. 변경이 거의 일어나지 않습니다.
    • dependencies: 애플리케이션에서 사용하는 모든 라이브러리(JAR 파일)들을 포함합니다. 이 계층은 변경이 적고, 재사용될 가능성이 높습니다.
    • Snapshot Dependencies: 개발 중인 라이브러리의 스냅샷 버전을 포함합니다. 이는 상대적으로 자주 변경될 수 있습니다.
    • Application Classes: 애플리케이션의 컴파일된 코드 (예: .class 파일)을 포함하며, 변경이 잦습니다.

멀티 스테이지 빌드 적용하기

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

FROM eclipse-temurin:17-jre
WORKDIR app
COPY --from=builder app/dependencies/ ./
COPY --from=builder app/spring-boot-loader/ ./
COPY --from=builder app/snapshot-dependencies/ ./
COPY --from=builder app/application/ ./
ENTRYPOINT ["java", "-Dspring.profiles.active=${USE_PROFILE}", "org.springframework.boot.loader.launch.JarLauncher"]

실제 멀티 스테이지 빌드를 적용해 빌드 단계와 실행 단계를 나누는 Dockerfile입니다.

layertools를 사용해 layered jar를 사용합니다.

결과 확인

멀티 스테이지로 각 레이어를 분리하고, 변경된 레이어만을 새롭게 설치해 활용할 수 있었습니다.
여기서 신기했던 점은 예상과 달리 빌드 시간 뿐만 아니라, 이미지 크기도 457MB → 304MB로 감소한 것 이었습니다.
history 확인을 통해 최초 ubuntu 환경을 구축할 때 대략 100MB 이상의 차이가 발생했는데, 아직 정확한 이유를 파악하지는 못했습니다…

끝 🧨

Docker의 여러 특성을 알아보고 적합한 이미지, 캐시 기능을 활용해 이미지 파일의 크기를 줄이고 빌드 시간을 단축해 보았습니다.
Docker를 잘 모른 채 사용했을 때 보다 확실히 어떤 적합한 빌드 파일의 선택과 적합한 Layer 순서등이 왜 필요한지 이유를 알 수 있었던 과정이라 만족합니다.
최종적으로 이미지 크기를 500MB → 300MB 규모로 축소할 수 있었지만, 최초 목적이었던 디스크 메모리 부족은 아쉽게도 해결하지 못해 EBS를 확장하는 것으로 결정했습니다. 🤣

참고

https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#container-images.dockerfiles
https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#container-images.efficient-images.layering
https://tech.cloudmt.co.kr/2022/06/29/도커와-컨테이너의-이해-3-3-docker-image-dockerfile-docker-compose/
https://docs.docker.com/build/building/multi-stage/

0개의 댓글