본문 바로가기

Programming/Infra

Spring boot Dockerfile 최적화하기 (spring boot dockerfile best practice)

Spring Boot Dockerfile

간단하게 스프링 어플리케이션을 도커파일로 작성하면 아래와 같다.

FROM openjdk:11
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar

EXPOSE 8080/tcp
ENTRYPOINT ["java","-jar","/app.jar"]

Spring Boot의 빌드 결과는 바로 실행 가능한 하나의 jar 파일로 나오기 때문에, 단순히 jar 파일을 옮겨주고 실행 시켜주는 것만으로 Dockerfile 작성이 끝난다.

 

하지만, 여기서 Dockerfile을 하나씩 최적화 시켜보자.

 

.dockerignore 작성

Docker 이미지 생성 시 필요없는 파일은 최대한 제거해준다.

특히 Dockerfile의 경우 은근히 사이즈가 크기 때문에 Dockerfile은 ignore 해주도록 한다.

 

Unpack jar file

jar파일을 그대로 Dockerfile에 복사하는 것이 아니라, jar 파일의 압축을 풀고 Docker로 복사한다.

 

Docker는 빌드 시에 layer별로 캐시를 저장한다.

주의해야 할 점은 단계별로 빌드가 이뤄지기 때문에, 위에서 Layer A가 변경되면 그 뒤에도 자동적으로 캐시가 깨지게 된다.

즉, 도커는 Layer별로 캐시를 관리하므로, 자주 변경되는 Layer를 나누고 뒤에 배치하는 것이 좋다.

 

이러한 특징을 바탕으로 spring boot의 jar 파일을 살펴본다.

위에서 작성한 것처럼 jar 파일을 옮기는 작업을 통째로 하나의 Layer로 작성하면, 자그마한 수정이 생겨도 캐시가 적용되지 않는다.

jar 파일의 압축을 풀어서 layer를 구성해본다.

 

우선 빌드된 jar 파일에서 압축을 풀어주는 사전 작업이 필요하다.

$ jar -xf *.jar

 

이후 아래와 같이 Dockerfile을 수정한다.

FROM openjdk:11
ARG DEPENDENCY=build/libs

COPY ${DEPENDENCY}/org ${DEPLOY_PATH}/org
COPY ${DEPENDENCY}/BOOT-INF/lib ${DEPLOY_PATH}/BOOT-INF/lib
COPY ${DEPENDENCY}/META-INF ${DEPLOY_PATH}/META-INF
COPY ${DEPENDENCY}/BOOT-INF/classes ${DEPLOY_PATH}/BOOT-INF/classes

EXPOSE 8080/tcp
ENTRYPOINT ["java","org.springframework.boot.loader.JarLauncher"]

앞서 설명한 것처럼, 자주 변하는 layer일수록 뒤에 배치한다.

다만 Multi module 프로젝트로 구성한 경우, jar layer를 나누는 효과가 미미할 수 있다.

 

실제로 코드 변경을 하고 다시 한번 image build를 한다면, classes 폴더만 캐시가 미적용되고 나머지는 캐시가 적용되는 것을 확인할 수 있다.

[+] Building 1.1s (10/10) FINISHED
 => [internal] load build definition from Dockerfile                                                                     0.0s
 => => transferring dockerfile: 37B                                                                                      0.0s
 => [internal] load .dockerignore                                                                                        0.0s
 => => transferring context: 2B                                                                                          0.0s
 => [internal] load metadata for docker.io/library/openjdk:11                                                            0.9s
 => [internal] load build context                                                                                        0.0s
 => => transferring context: 44.36kB                                                                                     0.0s
 => [1/5] FROM docker.io/library/openjdk:11@sha256:9a4ecbdde7ef7278610361395ecf70f482180bab37c066c95d35fe64a5d5322e      0.0s
 => CACHED [2/5] COPY build/libs/org /org                                                                                0.0s
 => CACHED [3/5] COPY build/libs/BOOT-INF/lib /BOOT-INF/lib                                                              0.0s
 => CACHED [4/5] COPY build/libs/META-INF /META-INF                                                                      0.0s
 => [5/5] COPY build/libs/BOOT-INF/classes /BOOT-INF/classes                                                             0.0s
 => exporting to image                                                                                                   0.0s
 => => exporting layers                                                                                                  0.0s
 => => writing image sha256:686592a2f510120962c7bff36f7127206b93287daed60014d7630c9659bb36b3                             0.0s
 => => naming to docker.io/library/spring-docker-optimize:0.2                                                            0.0s

 

Spring boot Layer

위에서는 직접 jar파일을 압축을 풀고 layer를 만들었다면, spring boot 2.3.0 이상부터는 layer를 기능을 지원한다.

 

$ java -Djarmode=layertools -jar target/*.jar extract --destination target/extracted

위처럼 jar를 layertools 모드로 extract하면 자동으로 layer가 분리된다.

 

root 권한으로 Dockerfile을 실행시키지 않는다.

root 권한으로 Dockerfile을 쭉 작성하고 실행시키면 편리하다.

하지만, 보안적으로 좋은 방향은 아니며 non-root 유저로 컨테이너를 실행시키는 것을 권고하고 있다.

도커 실행 시 특정 스크립트 실행이 필요하다면, 해당 유저에게 권한을 주는 것을 잊지말자.
FROM alpine:3.12
# Create user and set ownership and permissions as required
RUN adduser -D myuser && chown -R myuser /myapp-data
# ... copy application files
USER myuser
ENTRYPOINT ["/myapp"]

 

Build in Docker

지금까지는 Dockerfile 외부에서 빌드를 하고, 빌드된 결과만을 복사해서 작성하는 방식을 살펴봤다.

그렇게되면 빌드조차 각 사용자의 영향을 받는다. (빌드 명령어를 각자 다르게 입력 또는 빌드 환경이 달라진다)

따라서, spring boot 빌드 자체도 Dockerfile에 녹이는게 좋다. 이로써 얻는 장점은 다음과 같다.

 

  • 빌드까지 따로 할 필요가 없으니, 하나의 Dockerfile로 진정한 이미지가 완성된다.
  • CI 도구와 통합할 필요가 없다. (연동할 필요가 없다.)
  • 빌드 명령어 또한 형상관리가 가능하다.

 

Dockerfile을 작성하면 다음과 같다.

FROM openjdk:11

# set arg
ARG WORKSPACE=/home/spring-docker
ARG BUILD_TARGET=${WORKSPACE}/build/libs
WORKDIR ${WORKSPACE}

# copy code & build
COPY . .
RUN ./gradlew clean bootJar

WORKDIR ${BUILD_TARGET}
RUN jar -xf *.jar

EXPOSE 8080/tcp
ENTRYPOINT ["java","org.springframework.boot.loader.JarLauncher"]

 

Multi-Stage Build

위처럼 Dockerfile에서 build를 하면, 소스코드를 이미지 내부로 해야하므로 이미지의 크기가 커진다.

최종적인 이미지 크기는 작을수록 좋으며, 이미지에 포함된 내용이 적을수록 외부의 공격으로부터도 안전하다.

 

이를 위해 Docker에서는 Multi-Stage build가 존재한다.

### build stage ###
FROM openjdk:11 AS builder

# set arg
ARG WORKSPACE=/home/spring-docker
ARG BUILD_TARGET=${WORKSPACE}/build/libs
WORKDIR ${WORKSPACE}

# copy code & build
COPY . .
RUN ./gradlew clean bootJar

# unpack jar
WORKDIR ${BUILD_TARGET}
RUN jar -xf *.jar


### create image stage ###
FROM openjdk:11

ARG WORKSPACE=/home/spring-docker
ARG BUILD_TARGET=${WORKSPACE}/build/libs
ARG DEPLOY_PATH=${WORKSPACE}/deploy

# copy from build stage
COPY --from=builder ${BUILD_TARGET}/org ${DEPLOY_PATH}/org
COPY --from=builder ${BUILD_TARGET}/BOOT-INF/lib ${DEPLOY_PATH}/BOOT-INF/lib
COPY --from=builder ${BUILD_TARGET}/META-INF ${DEPLOY_PATH}/META-INF
COPY --from=builder ${BUILD_TARGET}/BOOT-INF/classes ${DEPLOY_PATH}/BOOT-INF/classes

WORKDIR ${DEPLOY_PATH}

EXPOSE 8080/tcp
ENTRYPOINT ["java","org.springframework.boot.loader.JarLauncher"]

 

위처럼 작성하면 기존보다 이미지 크기가 훨씬 줄어든 것을 확인할 수 있다.

Multi-Stage 빌드를 작성할 때 주의할 점은, 변수(= ARG, ENV 등)를 스테이지마다 설정해줘야 한다는 것이다.

 

Gradle Cache

gradle build를 CI 도구를 사용하는 것이 아닌, Docker image build 내부에서 하기 때문에 단점이 하나 생기는데, 바로 gradle cache를 활용하지 못한다는 것이다.

 

Docker image build가 어떻게 이뤄지는지 알면 gradle 캐시를 적용하기가 왜 어려운지 알 수 있다.

위에서 계속 언급했듯이 Docker image build는 하나의 컨테이너가 띄어져서 수행되는 것이 아닌, 여러번의 임시컨테이너를 거쳐서 레이어를 쌓는 형식으로 빌드가 이뤄진다.

따라서, 일반적으론 gradle cache 저장소를 호스트와 마운트해서 저장할 방법이 없다.

 

이러한 문제를 해결하기 위해 대략 2가지 방법이 존재한다.

  • docker buildkit engine의 cache를 활용
  • gradle remote cache 활용

 

Docker 18.09부터 추가된 buildkit이라는 도구에서 cache를 지원한다.

사실 buildkit은 캐시의 기능만 있는 것이 아니다. (https://docs.docker.com/develop/develop-images/build_enhancements/)

 

buildkit 캐시 기능을 쓰는 것이 여의치 않다면 gradle remote cache 방법을 활용할 수도 있다. 

(https://docs.gradle.org/current/userguide/build_cache.html)

 

캐시 적용 및 자세한 내용은 다음에 기회가 된다면 자세히 다뤄보도록 한다.

 

코드 >  https://github.com/henry-jo/spring-docker-optimize