본문 바로가기
개발

Spring Boot 애플리케이션 docker 이미지 크기 줄여보기

by 상5c 2024. 3. 31.

도커 교과서 책을 읽고 Spring Boot 애플리케이션을 빌드하는 과정을 최적화 해봤다.

간단한 Dockerfile에서 조금씩 발전시키는 형태로 글을 작성했으며, 글에서 등장하는 명령어를 실행한 프로젝트는 Spring-initializr를 통해 생성한 간단한 프로젝트를 베이스로 사용했다.

불필요한 기능이 빠지면 이는 자연스레 보안 위협 감소로 이어진다.

때문에 작은 사이즈의 이미지로 시작해서 필요한 기능만 추가하는 방향으로 나아가는 것이 옳다.


나의 첫 Dockerfile을 되짚어 보면 아마 이런 식으로 작성했던 것 같다.

# 베이스 이미지 설정
FROM gradle:8.6-jdk21

# 작업 디렉토리 설정
WORKDIR /app

# 모든 파일 복사
COPY ./ /app

# 애플리케이션 빌드와 JAR 파일 생성
RUN gradle build

# 빌드된 실행 가능한 JAR 파일을 실행
ENTRYPOINT ["java", "-jar", "build/libs/demo-dockerfile-cache-0.0.1-SNAPSHOT.jar"]

gradle을 사용해서 애플리케이션을 빌드하고 실행시키는 Dockerfile이다.

애플리케이션 개발의 초기 단계라면 당장의 문제는 없다. 하지만 이대로 둔다면, 프로젝트 규모가 커져가면서 여러 파일과 코드가 추가될 것이고 빌드 속도가 점차 감소할 것이다.

현재 가진 문제는 다음과 같다.

  1. docker build context에 불필요한 파일들이 너무 많다.
  2. 이미지에 전체 파일을 복사하고 있다.
  3. 빌드 환경과 실행 환경을 구분하지 않았다.

아래 Dockerfile을 통해 이미지를 빌드하면 출력되는 내용이다.

docker build -t sample-docker:step0 -f ./Dockerfile.step0 .

[+] Building 79.2s (9/9) FINISHED                                                                                                  docker:desktop-linux
 => [internal] load build definition from Dockerfile.step0                                                                                         0.0s
 => => transferring dockerfile: 443B                                                                                                               0.0s
 => [internal] load metadata for docker.io/library/gradle:8.6-jdk21                                                                                1.9s
 => [internal] load .dockerignore                                                                                                                  0.0s
 => => transferring context: 2B                                                                                                                    0.0s
 => [1/4] FROM docker.io/library/gradle:8.6-jdk21@sha256:a337805a93ad42a5c7df5d81b8b3d44d8e1c8088f48ff11cc9e80eef15459ca6                         39.1s
 => => resolve docker.io/library/gradle:8.6-jdk21@sha256:a337805a93ad42a5c7df5d81b8b3d44d8e1c8088f48ff11cc9e80eef15459ca6                          0.0s
 => => sha256:71dca2167f9f5ee82e602460098ce45ba714cb60cd683d677d994dad97c74bb2 28.40MB / 28.40MB                                                   3.3s
 => => sha256:c458c950b14dc5c1d2602df03f36c768c6db626bc7cad3699f7872336dc9e9b6 10.24kB / 10.24kB                                                   0.0s
 => => sha256:799297b6f9210d0b08ff588516d8ab6fe2169cdda6b76a0b5f854b6e76aec0ce 18.86MB / 18.86MB                                                   4.6s
 => => sha256:877a42c8edee586e61c5c96c89248d95a2ee8505eb8cce5292c999f31ed98121 157.79MB / 157.79MB                                                36.1s
 => => sha256:a337805a93ad42a5c7df5d81b8b3d44d8e1c8088f48ff11cc9e80eef15459ca6 549B / 549B                                                         0.0s
 => => sha256:1658d2c33c14dbe46bf0e6c42d74a41d238e92560063e0c2712bd605aca7098e 2.21kB / 2.21kB                                                     0.0s
 => => extracting sha256:71dca2167f9f5ee82e602460098ce45ba714cb60cd683d677d994dad97c74bb2                                                          0.5s
 => => sha256:d64d0355d3edb6bcd47fa1352ad7c9597fd73ec4fa5f90c68903c4ff35cac981 175B / 175B                                                         3.5s
 => => sha256:9df44ddbe13bba3a84bde4c86e27847469f07c0b50b1e85cd29d22c3d8a19f0b 734B / 734B                                                         3.8s
 => => sha256:37a5fd23cb39d62bd69900a8d039d63926636d869eff80f650bf9149a08fea00 4.36kB / 4.36kB                                                     4.0s
 => => sha256:3453925adfd4198b5c4c5ed059fe1bef14f635ba4b354a6eb00cfb57cedde75f 51.12MB / 51.12MB                                                  22.1s
 => => extracting sha256:799297b6f9210d0b08ff588516d8ab6fe2169cdda6b76a0b5f854b6e76aec0ce                                                          0.5s
 => => sha256:e62bc1031f085f904c4116a83bf48d8f35b0a6ab64f636a152f387a6c3fb80d7 132.82MB / 132.82MB                                                29.1s
 => => sha256:e8efee8ab99072bcfdac8e0b2e75736db6ae172f02cf80362be857e3505c19ca 167B / 167B                                                        22.4s
 => => extracting sha256:877a42c8edee586e61c5c96c89248d95a2ee8505eb8cce5292c999f31ed98121                                                          1.0s
 => => extracting sha256:d64d0355d3edb6bcd47fa1352ad7c9597fd73ec4fa5f90c68903c4ff35cac981                                                          0.0s
 => => extracting sha256:9df44ddbe13bba3a84bde4c86e27847469f07c0b50b1e85cd29d22c3d8a19f0b                                                          0.0s
 => => extracting sha256:37a5fd23cb39d62bd69900a8d039d63926636d869eff80f650bf9149a08fea00                                                          0.0s
 => => extracting sha256:3453925adfd4198b5c4c5ed059fe1bef14f635ba4b354a6eb00cfb57cedde75f                                                          1.2s
 => => extracting sha256:e62bc1031f085f904c4116a83bf48d8f35b0a6ab64f636a152f387a6c3fb80d7                                                          0.6s
 => => extracting sha256:e8efee8ab99072bcfdac8e0b2e75736db6ae172f02cf80362be857e3505c19ca                                                          0.0s
 => [internal] load build context                                                                                                                  0.2s
 => => transferring context: 49.98MB                                                                                                               0.2s
 => [2/4] WORKDIR /app                                                                                                                             0.0s
 => [3/4] COPY .. /app                                                                                                                             0.0s
 => [4/4] RUN gradle build                                                                                                                        37.8s
 => exporting to image                                                                                                                             0.2s
 => => exporting layers                                                                                                                            0.2s
 => => writing image sha256:bc2a7d3387a35194a3bbab87b8dad4f0df7eda80af88d059c2165f7e6093f783                                                       0.0s
 => => naming to docker.io/library/sample-docker:step0                                                                                             0.0s

참고로, docker build 과정을 자세히 살펴보고 싶다면 —progress=plain 옵션을 추가해주면 된다.

docker build --progress=plain -t ....

문제를 하나씩 개선해 나가보자.

 

docker build context에 불필요한 파일들이 많다.

빌드 컨텍스트란, Docker 이미지를 빌드할 때 명령어와 함께 지정하는 컨텍스트를 칭하며 Docker 데몬에게 전송되는 파일과 디렉터리 집합을 의미한다.

docker build 명령어 실행시 실행되는 공간의 파일/폴더가 Docker 데몬에게 전송되는데, 여기서 따로 설정해주지 않으면 모든 파일/폴더가 전송된다.

빌드 컨텍스트를 작게 만들어주면 다음과 같은 장점이 있다.

  • 속도 & 효율
    • 불필요한 파일을 제외하면 전송 크기가 작아지면 전송 시간이 단축되고 이는 빌드 시간 단축으로 이어진다.
  • 보안 강화
    • 애초에 빌드에서 제외해두면 실수로 배포 파일에 포함될 가능성을 낮춘다.

그럼 작게 만들려면 어떻게 해야할까?

방법은 간단하다. .dockerignore 파일을 추가해주면 된다. 작성 방법은 우리에게 익숙한 .gitignore 파일과 동일하다.

여기서는 간단히 git, IDE, 빌드 결과물만 제외해줬다.

# .dockerignore
.git/
.idea/
build/

해당 파일을 컨텍스트에 위치시키고 새로 빌드를 실행해보자

docker build -t sample-docker:step0 -f ./Dockerfile.step0 .

=> [internal] load build context                                                                                                                  0.0s
 => => transferring context: 5.56kB
  • transferring context 출력을 찾아보면 기존 50MB에서 5KB로 큰 폭으로 감소했다.

 

번외로, gradlew를 사용하면 이미지 크기를 줄일 수 있다.

문제라고 하긴 애매하나, gradlew를 사용하면 다음과 같은 이점을 얻는다.

  • 소스 코드와 gradle 버전을 같이 관리 가능
  • 버전 업그레이드가 용이(base 이미지 버전을 올리지 않고 변경)

최초에 작성한 Dockerfile을 다시 가져와 변경해 보자.

# gradle 이미지를 사용하지 않아도 된다
FROM eclipse-temurin:21 

WORKDIR /app

COPY ./ /app

# 명령어도 변경해줘야 한다
RUN ./gradlew build

ENTRYPOINT ["java", "-jar", "build/libs/demo-dockerfile-cache-0.0.1-SNAPSHOT.jar"]

gradle wrapper를 꼭 사용해야 할까?

현재 사용하는 이미지는 gradle 이미지이다. wrapper를 사용하면 openjdk 이미지를 사용할 수 있다.

gradle 이미지를 base 이미지로 사용해서 만든 결과물(step0)의 크기는 951MB고, eclipse-temurin 이미지를 사용해서 만든 결과물(step1)의 크기는 797MB이다. 154MB의 용량이 감소했다.

 

이미지에 전체 파일을 복사하고 있다.

프로젝트의 모든 파일과 폴더를 복사하고 있다.

애플리케이션을 빌드하는 데 필요한 파일만 복사하면 이미지의 크기를 줄일 수 있고, 포함하면 안되는 파일이 포함되는 문제를 예방할 수 있다.

COPY 인스트럭션을 필요한 것들만 복사하는 형태로 변경해보자.

gradle wrapper를 사용한다면 필요한 내용물은 gradlew 명령어를 실행하기 위한 파일과 소스 코드이다.

  • gradlew
    • 명령어 bin
  • gradle, build.gradle, settings.gradle
    • gradle 관련 정보가 정의된 파일/폴더
  • src
    • 애플리케이션 소스 코드

변경된 Dockerfile은 다음과 같다.

FROM eclipse-temurin:21

WORKDIR /app

# gradle 관련 파일 복사
COPY gradlew /app/
COPY gradle /app/gradle
COPY build.gradle /app/
COPY settings.gradle /app/
# 소스 코드 복사
COPY src /app/src

RUN ./gradlew build

ENTRYPOINT ["java", "-jar", "build/libs/demo-dockerfile-cache-0.0.1-SNAPSHOT.jar"]

보안/이미지 크기 최소화 등의 장점을 갖는다.

 

빌드 환경과 실행 환경을 구분하지 않았다

docker multi-stage build를 통해 빌드와 실행 단계를 구분해보자. 이번에도 방법은 간단하다.

# 애플리케이션 빌드
FROM eclipse-temurin:21 AS build

WORKDIR /app

COPY build.gradle settings.gradle gradlew /app/
COPY gradle /app/gradle
COPY src /app/src

RUN ./gradlew build

# 최종 실행 이미지 생성
FROM eclipse-temurin:21-jre-alpine

WORKDIR /app

COPY --from=build /app/build/libs/*.jar /app/app.jar

ENTRYPOINT ["java", "-jar", "/app/app.jar"]

애플리케이션 실행시에는 jdk가 필요 없어 jre 이미지를 사용했다. 또한 불필요한 기능을 제외하기 위해 alpine 이미지를 사용했다.

최종 이미지(step3)는 238MB로 처음(step0) 대비 700MB 이상 감소했다.

 

마무리

초기에 작성 계획은 의존성 다운로드, 애플리케이션 빌드, 애플리케이션 실행 스테이지를 나눠 의존성이 변경되었을 때만 다운로드를 수행하는 등 레이어 캐시를 활용하려 했었다.

아쉽지만 gradle build시에 매번 의존성을 다운로드하는 현상이 있어 제외했고, 다음 번엔 의존성 캐시 관련된 글을 작성하려 한다.

 

참고

도커 교과서 (책)