(Docker) Spring Boot Application Image 최적화하기

들어가기에 앞서
이 글에서 Docker와 Spring Boot, Gradle에 대한 기본적인 지식은 있다고 판단하고 설명한다.
프로젝트는 spring-boot-docker-demo 저장소에서 단계별로 브랜치를 확인해보면 된다.
이해를 돕기 위해 docker image tag 단위로 branch를 땄다.
프로젝트의 build.gradle은 아래와 같다.
1 | plugins { |
가장 간단한 Spring Boot Docker Image 만들기
이 상태에서 gradle wrapper를 이용해 build를 수행해보자.
1 | ./gradlew build |
그렇다면 build/libs 디렉토리에 demo-0.0.1-SNAPSHOT.jar란 파일이 만들어진다.
(build.gradle의 archivesBaseName과 version 값에 의해 위와 같은 이름으로 생성된다.)
이제 실행 가능한 jar 파일이 생성됐으니 Docker 이미지를 만들어서 해당 jar 파일을 실행하게 만들어보기 위해서 Dockerfile을 생성하자.
1 | FROM openjdk:11-jre-slim |
이제 이미지를 빌드해보자.
1 | # docker build -t ${imageName}:${tagName} . |
이제 이미지를 통해 컨테이너를 띄워보자.
1 | # docker run --rm -d -p ${hostPort}:${containerPort} --name ${containerName} ${imageName}:${tagName} |
실제로 어플리케이션이 잘 떴는지 확인해보자.
1 | # 프로젝트의 com.example.demo.Router 파일에서 GET / 에 대한 라우터, 핸들러를 만들어두었다. |
ok라는 텍스트가 출력이 됐다면 성공적으로 컨테이너가 뜬 것이다.
혹시나 curl: (52) Empty reply from server
란 오류가 뜬다면 서버가 아직 뜨지 않은 것이니 10초 정도 기다렸다가 다시 시도해보자.
이제 Docker Hub에 우리가 작업한 이미지를 올려보자.
(물론 Docker Hub에 Repository가 존재하는 상태로 시작해야한다.)
1 | # docker push ${repositoryName}:${tagName} |
Docker Image는 여러 레이어로 겹겹이 쌓여있다.
우리가 Dockerfile에 선언한 FROM openjdk:11-jre-slim
부분에 의해 openjdk:11-jre-slim 이미지의 레이어에서부터 쌓아가는 것이다.
4bbad98352e9 ~ 5dacd731af1b까지가 openjdk:11-jre-slim 이미지의 레이어를 사용한 것이다.
그리고 제일 윗 라인에 b61d0959344e 이 부분이 Dockerfile의 COPY build/libs/demo-0.0.1-SNAPSHOT.jar .
에 의해 생긴 레이어이다.
바로 저 jar 파일이 하나의 레이어를 차지하고 있는 것이다.
그럼 이 레이어란 건 어떻게 쓰이는지는 좀이따 살펴보자.
이제 어플리케이션 코드를 한 번 수정해보자.com.example.demo.Router
파일을 아래와 같이 수정해보자.
1 | package com.example.demo; |
ok
에서 ok!
로 바꿨을 뿐이다.
이제 다시 소스 코드를 빌드해주자.
1 | ./gradlew build |
바뀐 소스 코드를 토대로 도커 이미지를 만들자.
1 | docker build -t perfectacle/spring-boot-demo:basic-change-app . |
이제 새롭게 빌드한 이미지를 통해 컨테이너를 띄워보자.
1 | # 포트 및 컨테이너 이름 충돌을 방지하고자 전에 띄워놨던 컨테이너를 멈추자. |
실제로 어플리케이션이 잘 떴는지 확인해보자.
1 | curl localhost |
이제 Docker Hub에 좀 전에 새로 생성한 이미지를 올려보자.
1 | docker push perfectacle/spring-boot-demo:basic-change-app |
레이어의 진가가 여기서 나온다.
4bbad98352e9 ~ 5dacd731af1b까지가 openjdk:11-jre-slim 이미지의 레이어이고,
perfectacle/spring-boot-demo:basic 이미지에서 이미 사용했고 해당 이미지는 이미 Docker Hub에 올려두었다.
따라서 해당 레이어를 재활용하는 것이다.
이건 push 뿐만 아니라 pull에도 해당하는 내용이다.
실제 디스크에서 차지하는 용량도 해당 레이어를 재활용하기 때문에 이미지 push/pull 속도 및 용량 측면에서도 매우 효율적이다.
Spring Boot Docker Image 최적화하기
이렇게 레이어를 잘 구성해서 재활용할 수 있는 부분을 최대한 늘리는 게 이번 포스트에서 진행할 최적화의 한 방법이다.
하지만 우리는 레이어를 잘 활용하고 있지 못하고 있다.
basic 태그의 이미지를 올릴 때도 b61d0959344e: 18.22MB
를 업로드 했고,
basic-change-app 태그의 이미지를 올릴 때도 54f0c4fe51ff: 590.8kB/18.22MB
를 업로드 했다.
우리가 변경한 부분은 매우 작은 것 같은데, 왜 이렇게 많은 용량을 업로드하는 것일까?
그건 우리가 jar 파일을 하나의 레이어로 구성했기 때문이다.
우선 jar 파일이 어떻게 구성돼있는지 한 번 까보자.
1 | cd build/libs |
즉, 우리는 classes에 있는 파일만 수정했음에도 불구하고 lib에 있는 파일까지 같은 레이어로 묶어서 push하고 있던 것이다.
레이어를 재활용하기 위해선 jar 파일을 분해해서 이렇게 어플리케이션 레이어와 라이브러리 레이어를 쪼개야 최대한 레이어를 재활용할 수 있다.
빌드 후에 매번 저렇게 jar 파일을 분해하기 귀찮으니 build task를 손 봐주자.
build.gradle에서 아래 내용을 추가해주자.
1 | task unpackJar(type: Copy) { |
그리고 Dockerfile에서 어플리케이션 레이어와 라이브러리 레이어를 분리시키자.
1 | FROM openjdk:11-jre-slim |
이제 바뀐 task로 빌드해보자.
1 | ./gradlew build |
jar 파일이 build/libs/unpack
에 제대로 풀어졌는지 확인해보고 이제 새로운 도커 이미지를 빌드하자.
1 | docker build -t perfectacle/spring-boot-demo:unpack-jar . |
이제 새롭게 빌드한 이미지를 통해 컨테이너를 띄워보자.
1 | docker stop demo |
실제로 어플리케이션이 잘 떴는지 확인해보자.
1 | curl localhost |
이제 Docker Hub에 좀 전에 새로 생성한 이미지를 올려보자.
1 | docker push perfectacle/spring-boot-demo:unpack-jar |
aefdad4cf83c는 라이브러리 레이어이고, c132ceeeb517는 어플리케이션 레이어이다.
여기까지 문제가 없긴한데 Dockerfile에서 메인 클래스(com.example.demo.DemoApplication)를 하드코딩하는 게 매우 귀찮다.
JarLauncher를 이용해서 하드코딩 하는 부분을 없애보자! (물론 JarLauncher를 쓰면 main class를 하드코딩하는 거 보다 아주 조금 느리게 서버가 뜬다.)
Dockerfile을 아래와 같이 수정해주자.
1 | FROM openjdk:11-jre-slim |
덕지덕지 클래스패스 붙던 게 사라지고, 메인 클래스 하드코딩하던 부분도 사라졌다.
이미 빌드는 했고, 소스코드에 변경된 건 없으므로 새로운 도커 이미지를 빌드하자.
1 | docker build -t perfectacle/spring-boot-demo:unpack-jar-launcher . |
이제 새롭게 빌드한 이미지를 통해 컨테이너를 띄워보자.
1 | docker stop demo |
실제로 어플리케이션이 잘 떴는지 확인해보자.
1 | curl localhost |
이제 Docker Hub에 좀 전에 새로 생성한 이미지를 올려보자.
1 | docker push perfectacle/spring-boot-demo:unpack-jar-launcher |
위 Dockerfile에도 단점이 존재한다.
바로 레이어가 4개나 존재한다는 것이다.
우리는 어플리케이션 레이어/라이브러리 레이어로만 구분하려고 했는데 메인 클래스 하드코딩, 클래스패스 두 가지 문제점 때문에 또 다른 문제점을 만들어냈다.
이제 레이어를 다시 두 개로 줄여보자.
먼저 Copy의 횟수를 줄여야 레이어를 줄일 수 있으니 Copy하기 좋게 BOOT-INF/lib 폴더만 다른 곳으로 빼야한다.
그러기 위해서는 build task와 관련된 task들을 아래와 같이 수정해야한다.
1 | task moveLib { |
그리고 Dockerfile을 아래와 같이 수정해서 레이어를 두 개(어플리케이션, 라이브러리)로 만들자.
1 | FROM openjdk:11-jre-slim |
이제 바뀐 task로 빌드해보자.
1 | ./gradlew build |
lib 폴더가 build/libs/unpack/app/BOOT-INF
에 없고 build/libs/unpack/
에 있는지 확인해보고
이제 새로운 도커 이미지를 빌드하자.
1 | docker build -t perfectacle/spring-boot-demo:unpack-jar-launcher-decrease-layer . |
이제 새롭게 빌드한 이미지를 통해 컨테이너를 띄워보자.
1 | docker stop demo |
실제로 어플리케이션이 잘 떴는지 확인해보자.
1 | curl localhost |
이제 Docker Hub에 좀 전에 새로 생성한 이미지를 올려보자.
1 | docker push perfectacle/spring-boot-demo:unpack-jar-launcher-decrease-layer |
다시 레이어가 두 개로 줄어들었다.
그럼 이제 어플레이션 코드만 수정하고 과연 라이브러리 레이어는 재활용하는지 살펴보자.com.example.demo.Router
파일을 아래와 같이 수정해보자.
1 | package com.example.demo; |
ok!
에서 ok!!
로 바꿨을 뿐이다.
소스코드가 바뀌었으니 다시 빌드하자.
1 | ./gradlew build |
새로운 도커 이미지로 빌드하자.
1 | docker build -t perfectacle/spring-boot-demo:unpack-jar-launcher-decrease-layer-change-app . |
이제 새롭게 빌드한 이미지를 통해 컨테이너를 띄워보자.
1 | docker stop demo |
실제로 어플리케이션이 잘 떴는지 확인해보자.
1 | curl localhost |
이제 Docker Hub에 좀 전에 새로 생성한 이미지를 올려보자.
1 | docker push perfectacle/spring-boot-demo:unpack-jar-launcher-decrease-layer-change-app |
엥? 어플리케이션 소스코드만 바꿨는데 왜 라이브러리 레이어는 재활용하지 못하는 거지?
그럼 혹시 라이브러리를 추가했을 때 어플리케이션 레이어는 재활용할까?
build.gradle에 modelmapper를 디펜던시로 추가해보자.
1 | dependencies { |
디펜던시를 추가했으니 다시 빌드하자.
1 | ./gradlew build |
새로운 도커 이미지로 빌드하자.
1 | docker build -t perfectacle/spring-boot-demo:unpack-jar-launcher-decrease-layer-change-lib . |
이제 새롭게 빌드한 이미지를 통해 컨테이너를 띄워보자.
1 | docker stop demo |
실제로 어플리케이션이 잘 떴는지 확인해보자.
1 | curl localhost |
이제 Docker Hub에 좀 전에 새로 생성한 이미지를 올려보자.
1 | docker push perfectacle/spring-boot-demo:unpack-jar-launcher-decrease-layer-change-lib |
어플리케이션 레이어는 재활용이 잘 되고 변경된 라이브러리 레이어만 push 하는 걸 볼 수 있다.
근데 통상적으로 라이브러리 레이어보다 어플리케이션 레이어의 변경이 잦고,
라이브러리 레이어의 용량이 더 커서 라이브러리 레이어를 재활용하는 게 훨씬 효율적이다.
혹시 Dockerfile에 선언한 레이어의 순서에 뭔가 연관이 있지 않을까 싶어 Dockerfile을 아래와 같이 수정해보았다.
1 | FROM openjdk:11-jre-slim |
COPY 구문의 순서만 뒤바꾼 것이다. (lib 먼저, 그 다음에 app 레이어를 쌓게 끔)
이미 빌드는 했고, 소스코드에 변경된 건 없으므로 새로운 도커 이미지를 빌드하자.
1 | docker build -t perfectacle/spring-boot-demo:change-layer-order . |
이제 새롭게 빌드한 이미지를 통해 컨테이너를 띄워보자.
1 | docker stop demo |
실제로 어플리케이션이 잘 떴는지 확인해보자.
1 | curl localhost |
이제 Docker Hub에 좀 전에 새로 생성한 이미지를 올려보자.
1 | docker push perfectacle/spring-boot-demo:change-layer-order |
레이어 순서를 바꾼 후 첫 Push이기 때문에 어플리케이션/라이브러리 레이어 모두 push 하고 있다.
이제 어플리케이션 코드를 바꿔보자.com.example.demo.Router
파일을 아래와 같이 수정해보자.
1 | package com.example.demo; |
ok!!
에서 ok!!!!
로 바꿨을 뿐이다.
소스코드가 바뀌었으니 다시 빌드하자.
1 | ./gradlew build |
새로운 도커 이미지로 빌드하자.
1 | docker build -t perfectacle/spring-boot-demo:change-layer-order-and-app . |
이제 새롭게 빌드한 이미지를 통해 컨테이너를 띄워보자.
1 | docker stop demo |
실제로 어플리케이션이 잘 떴는지 확인해보자.
1 | curl localhost |
이제 Docker Hub에 좀 전에 새로 생성한 이미지를 올려보자.
1 | docker push perfectacle/spring-boot-demo:change-layer-order-and-app |
우리가 바라던대로 용량이 큰 라이브러리 레이어는 재활용하고 있고, 용량이 작은 어플리케이션 레이어는 변경했기 때문에 push하고 있다.
그럼 혹시 라이브러리 레이어를 수정했을 때 어플리케이션 레이어는 재활용할지 한 번 실험을 해보자.
build.gradle에 modelmapper의 버전을 바꿔보.
1 | dependencies { |
디펜던시를 변경했으니 다시 빌드하자.
1 | ./gradlew build |
새로운 도커 이미지로 빌드하자.
1 | docker build -t perfectacle/spring-boot-demo:change-layer-order-and-lib . |
이제 새롭게 빌드한 이미지를 통해 컨테이너를 띄워보자.
1 | docker stop demo |
실제로 어플리케이션이 잘 떴는지 확인해보자.
1 | curl localhost |
이제 Docker Hub에 좀 전에 새로 생성한 이미지를 올려보자.
1 | docker push perfectacle/spring-boot-demo:change-layer-order-and-lib |
아쉽지만 라이브러리 레이어만 바꿨다고 해서 어플리케이션 레이어를 재활용 할 순 없다.
그래도 어플리케이션 레이어는 대부분 라이브러리 레이어 보다 용량이 적고,
라이브러리 레이어가 변경이 되는 거보다 어플리케이션 레이어가 변경될 확률이 훨씬 높다.
따라서 어플리케이션 레이어를 재활용하는 것보다 라이브러리 레이어를 재활용하는 것이 훨씬 낫다.
레이어 순서에 따라서 재활용할 수 있는 레이어가 달라진다
우리의 Dockerfile을 보면 아래와 같다.
1 | COPY ${buildDir}/lib BOOT-INF/lib |
어플리케이션 레이어
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
라이브러리 레이어
위와 같이 라이브러리 레이어 위에 어플리케이션 레이어를 쌓고 있다.
이 상황에서 어플리케이션 레이어만 수정하면 아래 있는 라이브러리 레이어를 재활용 할 수 있다.
하지만 라이브러리 레이어를 바꾼다면 라이브러리 레이어를 쌓고 그 위에 다시 어플리케이션 레이어를 쌓아야한다.
따라서 어플리케이션 레이어를 재활용하지 못하는 것이다.
도커 이미지는 마치 스택 자료구조 안에 레이어들을 쌓아간다고 생각하면 좀 더 이해하기 쉬운 것 같다.