springboot 컨테이너 이미지 사이즈 경량화 방법
스프링 부트 기반으로 개발한 어플리케이션을 컨테이너 이미지로 만들 때 이미지 사이즈를 줄이는 방법에 대해서 알아보자.
먼저, sample source 를 다운 받는다.
$ git clone https://github.com/seungkyua/springboot-docker.git
$ cd springboot-docker
다운 받은 소스에서 mvn 으로 package 를 빌드한다.
$ mvn package
빌드 후에 소스의 디렉토리 구조는 다음과 같다.
$ tree . -L 2
.
├── LICENSE
├── README.md
├── docker
│ ├── Dockerfile
│ ├── Dockerfile2
│ └── Dockerfile3
├── logs
│ └── access_log.log
├── pom.xml
├── scripts
│ └── run.sh
├── src
│ └── main
├── target
│ ├── classes
│ ├── example-0.0.1-SNAPSHOT.jar
│ ├── example-0.0.1-SNAPSHOT.jar.original
│ ├── generated-sources
│ ├── maven-archiver
│ └── maven-status
└── work
└── Tomcat
오늘은 자바 소스는 상관없이 도커 이미지 빌드에 대해서만 설명하므로 Dockerfile 만 살펴보자.
컨테이너 이미지 만들기 - 기본편
docker/Dockerfile2 을 보면 아래와 같다.
FROM openjdk:17.0.2-jdk-slim
MAINTAINER Ahn Seungkyu
ARG JAR_FILE=example-0.0.1-SNAPSHOT.jar
RUN mkdir -p /app
WORKDIR /app
COPY target/${JAR_FILE} /app/app.jar
COPY scripts/run.sh .
ENTRYPOINT ["./run.sh"]
base 이미지로 openjdk:17.0.2-jdk-slim
이미지를 사용하였고 target 아래의 빌드된 jar 파일을 복사하여 실행하는 방법이다.
실행 명령어는 scripts/run.sh
파일을 보면 알 수 있다.
#!/bin/sh
java ${JAVA_OPTS} -jar app.jar ${@}
JAVA_OPTS 는 환경 변수 이므로 컨테이너를 실행할 때 해당 값을 전달하는 것이 가능하다.
이제 컨테이너 이미지를 빌드하면 그 사이즈는 다음과 같다.
$ docker build -t seungkyua/springboot-docker -f docker/Dockerfile2 .
------- output -----------
[+] Building 2.6s (11/11) FINISHED docker:desktop-linux
=> [internal] load build definition from Dockerfile2 0.0s
=> => transferring dockerfile: 442B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/openjdk:17.0.2-jdk-slim 2.2s
=> [auth] library/openjdk:pull token for registry-1.docker.io 0.0s
=> CACHED [1/5] FROM docker.io/library/openjdk:17.0.2-jdk-slim@sha256:aaa3b3cb27e3e520b8f116863d0580c438ed55ecfa0bc126b41f68c3f62f9774 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 291B 0.0s
=> [2/5] RUN mkdir -p /app 0.1s
=> [3/5] WORKDIR /app 0.0s
=> [4/5] COPY target/example-0.0.1-SNAPSHOT.jar /app/app.jar 0.1s
=> [5/5] COPY scripts/run.sh . 0.0s
=> exporting to image 0.1s
=> => exporting layers 0.1s
=> => writing image sha256:6d6ba6764805971eef0532e21ec28feb6308ddb04bb650a7d087ab689d0d65be 0.0s
=> => naming to docker.io/seungkyua/springboot-docker
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
seungkyua/springboot-docker latest 6d6ba6764805 54 seconds ago 473MB
이미지 크기가 473M 로 만들어졌다. 어플리케이션 jar 파일의 크기가 68M 이므로 JVM 을 포함하는 이미지가 405M 의 크기가 된다는 의미이다.
보통 이미지를 작게하기 위해서 base 이미지 태그로 alpine
이나 slim
을 많이 사용한다. 여기서 slim 을 사용했는데도 이 정도 크기라면 사이즈가 작다고 할 수 없다.
더 작은 사이즈를 만들기 위해서 alpine 이미지를 찾아서 적용해 보자.
base 이미지를 amazoncorretto
로 변경하고 다시 사이즈를 비교해 보자.
FROM amazoncorretto:17-alpine
MAINTAINER Ahn Seungkyu
ARG JAR_FILE=example-0.0.1-SNAPSHOT.jar
RUN mkdir -p /app
WORKDIR /app
COPY target/${JAR_FILE} /app/app.jar
COPY scripts/run.sh .
ENTRYPOINT ["./run.sh"]
$ docker build -t seungkyua/springboot-docker -f docker/Dockerfile2 .
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
seungkyua/springboot-docker latest ed08524545ba 31 seconds ago 358MB
<none> <none> 6d6ba6764805 7 minutes ago 473MB
조회되는 이미지를 보면 base 이미지만을 바꿨을 뿐인데 358M 로 줄어들었다.
컨테이너 이미지 만들기 - 멀티 스테이지
컨테이너 이미지 사이즈를 줄이기 위해서 멀티 스테이지를 써야 한다는 말을 들어봤을 것이다. 여기서도 사이즈를 줄이기 위해 멀티 스테이지를 사용해 보자.
docker/Dockerfile3 를 보면 멀티 스테이지를 어떻게 구성하는지 알 수 있다.
# syntax=docker/dockerfile:1
FROM maven:3.8.5-openjdk-17 as build
MAINTAINER Ahn Seungkyu
WORKDIR /app
COPY . /app
RUN --mount=type=cache,target=/root/.m2 mvn -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true package
FROM amazoncorretto:17-alpine
MAINTAINER Ahn Seungkyu
ARG JAR_FILE=example-0.0.1-SNAPSHOT.jar
WORKDIR /app
COPY --from=build /app/target/${JAR_FILE} /app/app.jar
COPY scripts/run.sh /app/
ENTRYPOINT ["./run.sh"]
빌드하는 이미지와 런타임에서 실행되는 이미지가 나눠져 있다.
# syntax=docker/dockerfile:1
는 이미지 안에서 소스 빌드를 할 때 디펜던시가 있는 파일을 매번 가져오지 말고 한 번만 가져와서 캐싱하여 효율적으로 사용하고자 할 때 사용한다.
--mount=type=cache,target=/root/.m2
는 로컬에 저장해서 재활용하자는 의미이고 -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true
메이븐으로 패키지할 때 jvm 이 ssl 인증서 없이 사용하기 위해서 추가되었다.
첫번째는 어플리케이션을 빌드하기 위해 필요한 이미지 정의이고 두번째는 빌드된 어플리케이션을 복사하는 이미지 정의로 두번째는 앞서 기본편에서 설명한 것과 동일하다.
jar 파일만 빌드해서 복사하는 구조라 실제로 이미지 차이는 없을 것이다. 다만, 빌드를 이미지를 만들 때 로컬 환경 구성 없이도 만들 수 있다는 장점이 있다.
$ docker build -t seungkyua/springboot-docker -f docker/Dockerfile3 .
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
seungkyua/springboot-docker latest 33ed3cbf0300 31 seconds ago 358MB
<none> <none> ed08524545ba 4 minutes ago 358MB
<none> <none> 6d6ba6764805 7 minutes ago 473MB
컨테이너 이미지 만들기 - alpine 에 추가
여전히 사이즈가 358M 로 작지 않은 사이즈이다.
이제 기본 alpine 이미지에 JRE 와 어플리케이션을 설치하는 방법으로 이미지 사이즈를 줄여보자.
docker/Dockerfile 을 살펴보자.
FROM amazoncorretto:17-alpine3.18 as builder-jre
RUN apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main/ binutils=2.41-r0
RUN $JAVA_HOME/bin/jlink \
--module-path "$JAVA_HOME/jmods" \
--verbose \
--add-modules ALL-MODULE-PATH \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress=2 \
--output /jre
#=========================================================================
# syntax=docker/dockerfile:1
FROM maven:3.8.5-openjdk-17 as build
MAINTAINER Ahn Seungkyu
WORKDIR /app
COPY . /app
RUN --mount=type=cache,target=/root/.m2 mvn -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true package
#=========================================================================
FROM alpine:3.18.4
MAINTAINER Ahn Seungkyu
ENV JAVA_HOME=/jre
ENV PATH="$JAVA_HOME/bin:$PATH"
ARG JAR_FILE=example-0.0.1-SNAPSHOT.jar
COPY --from=builder-jre /jre $JAVA_HOME
ARG APPLICATION_USER=appuser
RUN adduser --no-create-home -u 1000 -D $APPLICATION_USER
RUN mkdir /app && chown -R $APPLICATION_USER /app
USER 1000
COPY --chown=1000:1000 --from=build /app/target/${JAR_FILE} /app/app.jar
COPY scripts/run.sh /app/
WORKDIR /app
EXPOSE 8080
ENTRYPOINT ["./run.sh"]
이미지가 3개로 구성되어 있다.
첫번째는 작은 사이즈의 jre 를 만드는 이미지, 두번째는 어플리케이션을 빌드하는 이미지, 마지막으로 세번째는 작은 alpine
베이스 이미지에 jre 와 빌드된 어플리케이션 jar 파일을 복사하는 이미지이다.
첫번째 이미지 만드는 부분에서 binutils
을 설치하는데 다운 받을 리파지토리를 지정하여 에러가 없게 한다. binutils
는 jre 을 만들 때 strip-debug
옵션을 사용하기 위해서 설치한다.
세번째 이미지 만드는 부분에서 사용자를 추가하는 로직이 있는데 이는 보안상 이미지내 실행 프로세스를 root 가 아닌 지정된 사용자로 하기 위해서 일반적으로 추가한다.
이미지 사이즈는 다음과 같다.
$ docker build -t seungkyua/springboot-docker -f docker/Dockerfile .
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
seungkyua/springboot-docker latest f5dc2f994864 28 seconds ago 168MB
<none> <none> ed08524545ba 24 minutes ago 358MB
<none> <none> 6d6ba6764805 31 minutes ago 473MB
이미지가 최종적으로 168M 로 줄어들었다.
이제 실제 이미지가 정상적으로 실행되는지 확인해 보자.
mysql 을 띄우고 어플리케이션을 띄운 후에 curl 로 데이터를 입력해 본다.
1. mysql 실행
$ mkdir -p ~/.docker-data/mysql
$ docker run --cap-add=sys_nice -d --restart=always -p 3306:3306 -e MYSQL_ROOT_PASSWORD=password \
-v ~/.docker-data/mysql:/var/lib/mysql \
--name mysql-ask mysql:8.0.34 \
--character-set-server=utf8mb4 \
--collation-server=utf8mb4_unicode_ci
2. database 생성
$ docker exec -it mysql-ask bash
# mysql -uroot -ppassword
mysql> create database if not exists order_service;
3. 어플리케이션 실행
$ docker run -d --name springboot --rm -p 19090:19090 --link mysql-ask:localhost seungkyua/springboot-docker
4. 데이터 조회
$ curl -X POST http://127.0.0.1:19090/orders \
-H "Content-Type: application/json" \
-d '{"customerId": 1, "orderTotal": 12.23}'
--- output ---
{"orderId":1}
정상적으로 어플리케이션이 실행되어 동작하는 것을 확인할 수 있다.