티스토리 뷰

반응형

이 글은 아래의 링크된 포스트의 확장판이다.

아래 포스트에서는 빌드시스템으로 Gradle을 이용하고 있는데, 이번 포스팅에서는 Maven을 이용하여 Dockerfile을 최적화한다. 독자가 도커 이미지를 실행해본 경험이 있으며, 도커 파일을 만들어본 경험이 있고 도커 이미지의 레이어에 대한 이해가 있다는 것을 전제로 한다.

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

문제 상황

위 포스팅은 스프링 부트 서버 애플리케이션을 도커 이미지로 만들 때 Dockerfile을 최적화 하는 것을 설명하고 있다.

스프링 부트 앱을 도커 이미지로 만드는 가장 유명한 방식은 스프링 부트 애플리케이션을 Jar 파일로 빌드하고 그 Jar 파일을 도커 파일에서 ADD 혹은 COPY 하는 것이다.

FROM adoptopenjdk/openjdk8:x86_64-alpine-jre8u222-b10
RUN apk --no-cache add curl
RUN adduser -D -s /bin/sh voyagerwoo
USER voyagerwoo

WORKDIR /home/voyagerwoo
ARG JAR_FILE
COPY target/${JAR_FILE} app.jar
ENV PROFILE=local
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom", "-Dspring.profiles.active=${PROFILE}","-jar","app.jar"]

당연하게도 이렇게 만들어진 Jar 파일은 단독으로 실행가능하며 그렇게 동작할 수 있도록 의존하는 라이브러리를 포함하고 있다. 이를 Fat Jar라고 하는데, 이 방식은 도커 이미지를 만들때 공간을 낭비하게 한다.

왜냐하면 Jar는 의존하는 라이브러리를 모두 가지고 있고 COPY target/${JAR_FILE} app.jar 이 부분이 하나의 레이어로 캐시되지 않고 매번 빌드될 때 마다 새로운 레이어로 계속 저장이 되기 때문이다.

당연하게도 의존하는 라이브러리와 애플리케이션 코드는 변화의 주기가 다르다. 그렇기 때문에 위 포스팅에서는 빌드된 Jar를 다시 unpack하고 Dockerfile에서 라이브러리 따로, 애플리케이션 코드 따로 ADD 또는 COPY하고 있다. 그렇다면 이번에는 Maven에서 해보려고 한다.

(하고 나서 보니 공식 사이트에서도 잘 나와있다.)

메이븐 설정 추가

https://github.com/voyagerwoo/springboot-hello-world

간단하게 스프링 부트 웹 프로젝트를 만들어서 따라해 해볼 수 있다. 실습 프로젝트는 스프링 부트 2.1.6.RELEASE 버전이고 spring-boot-starter-web 을 의존하고 있다. (코틀린으로 되어있는데 이것은 중요하지 않다)

실습에 들어가기 전에 어떤 과정이 추가될 것인지 미리 정리하면,

  1. mvn clean package 를 통해서 만들어진 Fat Jar를 unpack 한다.
  2. unpack한 결과물에서 app 코드와 lib 코드를 분리한다.
  3. Dockerfile에서 lib 코드를 먼저 추가(ADD or COPY)하고, app 코드를 추가한다.

이렇게 해서 의존성이 변하지 않는다면, 라이브러리를 추가한 레이어를 재사용할 수 있도록 할 것이다.

위 과정들은 maven의 package 라이프 사이클에 포함시켜서 mvn clean package를 하게 되면 도커 이미지가 만들어지도록 할 것이다.

기본 pom.xml

간단한 스프링 웹 프로젝트를 컨테이너라이즈 해본다.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.6.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>vw</groupId>
    <artifactId>hello-world</artifactId>
    <version>v1</version>
    <name>hello-world</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
        <kotlin.version>1.2.71</kotlin.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.module</groupId>
            <artifactId>jackson-module-kotlin</artifactId>
        </dependency>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-reflect</artifactId>
        </dependency>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-stdlib-jdk8</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <finalName>hello-world</finalName>
        <sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
        <testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.jetbrains.kotlin</groupId>
                <artifactId>kotlin-maven-plugin</artifactId>
                <configuration>
                    <args>
                        <arg>-Xjsr305=strict</arg>
                    </args>
                    <compilerPlugins>
                        <plugin>spring</plugin>
                    </compilerPlugins>
                </configuration>
                <dependencies>
                    <dependency>
                        <groupId>org.jetbrains.kotlin</groupId>
                        <artifactId>kotlin-maven-allopen</artifactId>
                        <version>${kotlin.version}</version>
                    </dependency>
                </dependencies>
            </plugin>
        </plugins>
    </build>
</project>

Properties 추가

spring-boot-maven-plugin이 만든 Fat Jar 파일을 unpack하고 app과 lib 패키지로 분리할 예정이다. 그 디렉토리를 properties 설정에 추가한다.

    <properties>        
        <jar.unpack.app.dir>${project.build.directory}/unpack-app</jar.unpack.app.dir>
        <jar.unpack.lib.dir>${project.build.directory}/unpack-lib</jar.unpack.lib.dir>
    </properties>

Unpack plugin 추가

https://maven.apache.org/plugins/maven-dependency-plugin/examples/unpacking-artifacts.html

maven-dependency-plugin 으로 unpack을 할 수 있다.

    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-dependency-plugin</artifactId>
        <version>3.1.1</version>
        <executions>
            <execution>
                <id>unpack</id>
                <phase>package</phase>
                <goals>
                    <goal>unpack</goal>
                </goals>
                <configuration>
                    <artifactItems>
                        <artifactItem>
                            <groupId>${project.groupId}</groupId>
                            <artifactId>${project.artifactId}</artifactId>
                            <version>${project.version}</version>
                            <destFileName>${project.build.finalName}</destFileName>
                        </artifactItem>
                    </artifactItems>
                    <outputDirectory>${jar.unpack.app.dir}</outputDirectory>
                </configuration>
            </execution>
        </executions>
    </plugin>

unpack을 하게 되면 다음과 같은 구조로 Jar 파일이 압축이 풀린것을 확인할 수 있다.

간단히 들여다 보면 BOOT-INF에 애플리케이션 코드와 라이브러리가 있다. 그리고 META-INF에 프로젝트 설정 파일인 pom.xml 등이 있다. 그다음 org로 시작하는 디렉토리에는 스프링 부트 실행 관련한 클래스 파일이 들어있다.

Ant run으로 Library 분리

이제 unpack한 결과물에서 app과 관련된 코드와 lib를 분리해야한다. maven-antrun-plugin 을 이용해서 BOOT-INF/lib 를 미리 정해둔 lib 디렉토리로 옮긴다.

    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-antrun-plugin</artifactId>
        <executions>
            <execution>
                <id>move-lib</id>
                <phase>package</phase>
                <configuration>
                    <target>
                        <move todir="${jar.unpack.lib.dir}">
                            <fileset dir="${jar.unpack.app.dir}/BOOT-INF/lib"/>
                        </move>
                    </target>
                </configuration>
                <goals>
                    <goal>run</goal>
                </goals>
            </execution>
        </executions>
    </plugin>

플러그인이 실행되고 나면 아래처럼 분리된 것을 확인할 수 있다.

(이거 작성하면서 든 생각은 lib를 분리할게 아니라 app(classes)을 분리했어야 하는게 아닌가 라는 생각이... 아 귀찮...)

Maven Dockerize 플러그인 추가 및 도커파일 작성

spotify에서 만든 dockerfile-maven-plugin으로 도커 이미지를 빌드할 것이다. 도커 레파지토리는 도커 허브로 설정했다.

    <plugin>
        <groupId>com.spotify</groupId>
        <artifactId>dockerfile-maven-plugin</artifactId>
        <version>1.4.10</version>
        <executions>
            <execution>
                <id>default</id>
                <goals>
                    <goal>build</goal>
                    <goal>push</goal>
                </goals>
            </execution>
        </executions>
        <configuration>
            <repository>voyagerwoo/hello-world</repository>
            <tag>${project.version}</tag>
            <buildArgs>
                <APP_NAME>${project.artifactId}</APP_NAME>
            </buildArgs>
        </configuration>
    </plugin>

도커 이미지를 만들 때 가장 중요한 것은 Dockerfile이다.

FROM adoptopenjdk/openjdk8:x86_64-alpine-jre8u222-b10
RUN apk --no-cache add curl 
RUN adduser -D -s /bin/sh voyagerwoo
USER voyagerwoo

ARG APP_NAME=app
ARG LIB_DIR=target/unpack-lib
ARG APP_DIR=target/unpack-app

RUN mkdir /home/voyagerwoo/${APP_NAME}
WORKDIR /home/voyagerwoo/${APP_NAME}

COPY ${LIB_DIR} BOOT-INF/lib
COPY ${APP_DIR} .
ENV PROFILE=local

ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom", "-Dspring.profiles.active=${PROFILE}","org.springframework.boot.loader.JarLauncher"]

천천히 살펴보면, 우선 도커 허브의 공식 openjdk 이미지가 아닌 adoptopenjdk의 이미지를 사용했다. 그 이유는 자바 버전을 좀더 자세히 명시하고 싶어서이다.

이 이미지는 alpine 리눅스에 openjdk를 설치한 이미지인데, 여기에 디버깅 용으로 curl을 설치했다. 그리고 보안을 위해서 컨테이너의 프로세스를 실행하는 유저를 기본 ROOT에서 voyagerwoo라는 유저로 변경했다.

빌드 Argument로 앱의 이름과 lib 디렉토리 경로, app 디렉토리 경로를 추가했다. 그리고 해당 경로를 카피했다.

환경변수로는 PROFILE을 추가하고 기본값으로 local을 넣었다.

실행

./mvnw clean package
... 생략
[INFO] dockerfile: null
[INFO] contextDirectory: /home/ubuntu/environment/springboot-hello-world
[INFO] Building Docker context /home/ubuntu/environment/springboot-hello-world
[INFO] Path(dockerfile): null
[INFO] Path(contextDirectory): /home/ubuntu/environment/springboot-hello-world
[INFO] 
[INFO] Image will be built as voyagerwoo/hello-world:v1
[INFO] 
[INFO] Step 1/13 : FROM adoptopenjdk/openjdk8:x86_64-alpine-jre8u222-b10
[INFO] 
[INFO] Pulling from adoptopenjdk/openjdk8
[INFO] Image 050382585609: Pulling fs layer
... 생략
[INFO] Image 371a0a57b3c9: Pull complete
[INFO] Digest: sha256:58c26934ccf8ffb1de6ca607d49241f4778afaf2644f88fe232f2a28487dea07
[INFO] Status: Downloaded newer image for adoptopenjdk/openjdk8:x86_64-alpine-jre8u222-b10
[INFO]  ---> 860778ed94ed
[INFO] Step 2/13 : RUN apk --no-cache add curl
[INFO] 
[INFO]  ---> Running in 18a6b42ee4df
[INFO] fetch http://dl-cdn.alpinelinux.org/alpine/v3.10/main/x86_64/APKINDEX.tar.gz
... 생략
[INFO] OK: 13 MiB in 21 packages
[INFO] Removing intermediate container 18a6b42ee4df
[INFO]  ---> 5a3981ecb305
[INFO] Step 3/13 : RUN adduser -D -s /bin/sh voyagerwoo
[INFO] 
[INFO]  ---> Running in 2e549076a7f5
[INFO] Removing intermediate container 2e549076a7f5
[INFO]  ---> a5f87be7b8a1
[INFO] Step 4/13 : USER voyagerwoo
[INFO] 
[INFO]  ---> Running in 5d9dca076ff7
[INFO] Removing intermediate container 5d9dca076ff7
[INFO]  ---> 510e8cc62720
[INFO] Step 5/13 : ARG APP_NAME=app
[INFO] 
[INFO]  ---> Running in a272ab118f15
[INFO] Removing intermediate container a272ab118f15
[INFO]  ---> b5ef05f9d84f
[INFO] Step 6/13 : ARG LIB_DIR=target/unpack-lib
[INFO] 
[INFO]  ---> Running in d6f344cf0cd8
[INFO] Removing intermediate container d6f344cf0cd8
[INFO]  ---> 4d310ac38f17
[INFO] Step 7/13 : ARG APP_DIR=target/unpack-app
[INFO] 
[INFO]  ---> Running in e82c8e3933ff
[INFO] Removing intermediate container e82c8e3933ff
[INFO]  ---> 738d535ac4a2
[INFO] Step 8/13 : RUN mkdir /home/voyagerwoo/${APP_NAME}
[INFO] 
[INFO]  ---> Running in f0088049dc15
[INFO] Removing intermediate container f0088049dc15
[INFO]  ---> 22d4871ea835
[INFO] Step 9/13 : WORKDIR /home/voyagerwoo/${APP_NAME}
[INFO] 
[INFO]  ---> Running in f88727ffda9d
[INFO] Removing intermediate container f88727ffda9d
[INFO]  ---> dcd8b9b2e574
[INFO] Step 10/13 : COPY ${LIB_DIR} BOOT-INF/lib
[INFO] 
[INFO]  ---> ed2bdfa26688
[INFO] Step 11/13 : COPY ${APP_DIR} .
[INFO] 
[INFO]  ---> cbcd1b6d5a48
[INFO] Step 12/13 : ENV PROFILE=local
[INFO] 
[INFO]  ---> Running in 57a9e3d46a0f
[INFO] Removing intermediate container 57a9e3d46a0f
[INFO]  ---> f21fcc026139
[INFO] Step 13/13 : ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom", "-Dspring.profiles.active=${PROFILE}","org.springframework.boot.loader.JarLauncher"]
[INFO] 
[INFO]  ---> Running in ace3d6ee7231
[INFO] Removing intermediate container ace3d6ee7231
[INFO]  ---> 3ce4f9d34d5c
[INFO] Successfully built 3ce4f9d34d5c
[INFO] Successfully tagged voyagerwoo/hello-world:v1
[INFO] 
[INFO] Detected build of image with id 3ce4f9d34d5c
[INFO] Building jar: /home/ubuntu/environment/springboot-hello-world/target/hello-world-docker-info.jar
[INFO] Successfully built voyagerwoo/hello-world:v1
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  04:36 min
[INFO] Finished at: 2019-08-12T23:57:32Z
[INFO] ------------------------------------------------------------------------

이미지가 잘 만들어졌다. 코드를 조금만 수정해서 테스트를 해보면 Step 10인 lib를 복사하는 부분까지 캐시를 하는 것을 확인할 수 있다. 기존 레이어를 재사용한다는 뜻이다.

[INFO] --- dockerfile-maven-plugin:1.4.10:build (default) @ hello-world ---
[INFO] dockerfile: null
[INFO] contextDirectory: /home/ubuntu/environment/springboot-hello-world
[INFO] Building Docker context /home/ubuntu/environment/springboot-hello-world
[INFO] Path(dockerfile): null
[INFO] Path(contextDirectory): /home/ubuntu/environment/springboot-hello-world
[INFO] 
[INFO] Image will be built as voyagerwoo/hello-world:v1
[INFO] 
[INFO] Step 1/13 : FROM adoptopenjdk/openjdk8:x86_64-alpine-jre8u222-b10
[INFO] 
[INFO] Pulling from adoptopenjdk/openjdk8
[INFO] Digest: sha256:0f79600881d399db5c58afdae9b797093e5a72a66875ac3a131a274921847f5d
[INFO] Status: Image is up to date for adoptopenjdk/openjdk8:x86_64-alpine-jre8u222-b10
[INFO]  ---> f105c24135a8
[INFO] Step 2/13 : RUN apk --no-cache add curl
[INFO] 
[INFO]  ---> Using cache
[INFO]  ---> 215eb45119d0
[INFO] Step 3/13 : RUN adduser -D -s /bin/sh voyagerwoo
[INFO] 
[INFO]  ---> Using cache
[INFO]  ---> 14fa0279c803
[INFO] Step 4/13 : USER voyagerwoo
[INFO] 
[INFO]  ---> Using cache
[INFO]  ---> b9d18144c27e
[INFO] Step 5/13 : ARG APP_NAME=app
[INFO] 
[INFO]  ---> Using cache
[INFO]  ---> 76eedf3aaaeb
[INFO] Step 6/13 : ARG LIB_DIR=target/unpack-lib
[INFO] 
[INFO]  ---> Using cache
[INFO]  ---> e870f631e07b
[INFO] Step 7/13 : ARG APP_DIR=target/unpack-app
[INFO] 
[INFO]  ---> Using cache
[INFO]  ---> 0bdbbcfec385
[INFO] Step 8/13 : RUN mkdir /home/voyagerwoo/${APP_NAME}
[INFO] 
[INFO]  ---> Using cache
[INFO]  ---> 023457348b45
[INFO] Step 9/13 : WORKDIR /home/voyagerwoo/${APP_NAME}
[INFO] 
[INFO]  ---> Using cache
[INFO]  ---> b427153ea810
[INFO] Step 10/13 : COPY ${LIB_DIR} BOOT-INF/lib
[INFO] 
[INFO]  ---> Using cache
[INFO]  ---> 436c30e82784
[INFO] Step 11/13 : COPY ${APP_DIR} .
[INFO] 
[INFO]  ---> a3a192a64478
[INFO] Step 12/13 : ENV PROFILE=local
[INFO] 
[INFO]  ---> Running in ff44f85ecf85
[INFO] Removing intermediate container ff44f85ecf85
[INFO]  ---> 60b135c03bc7
[INFO] Step 13/13 : ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom", "-Dspring.profiles.active=${PROFILE}","org.springframework.boot.loader.JarLauncher"]
[INFO] 
[INFO]  ---> Running in 34e94a95fa4b
[INFO] Removing intermediate container 34e94a95fa4b
[INFO]  ---> 9d2ebd5b50f2
[INFO] Successfully built 9d2ebd5b50f2
[INFO] Successfully tagged voyagerwoo/hello-world:v1
[INFO] 
[INFO] Detected build of image with id 9d2ebd5b50f2
[INFO] Building jar: /home/ubuntu/environment/springboot-hello-world/target/hello-world-docker-info.jar
[INFO] Successfully built voyagerwoo/hello-world:v1

Dockerignore Whitelist

결론만 먼저 이야기하면 spotify에서 만든 maven 플러그인의 버그(?) 때문에 안된다.

참고 링크: https://github.com/spotify/dockerfile-maven/issues/25

기존 Jar 파일과 Jar를 unpack한 파일까지 있어서 도커 빌드하는 컨텍스트의 사이즈가 예제 기준으로 41mb 정도이다. 근데 이걸 카피하는게 좀 아까워서 .dockerignore 파일을 화이트 리스트 방식으로 딱 필요한 unpack-app, unpack-lib만 컨텍스트에 추가하려고 했다.

참고 링크: https://dev.to/kevinpollet/how-to-use-your-dockerignore-as-a-whitelist-3b77

# Ignore everything
*

!target/unpack-app
!target/unpack-lib

아래 docker 명령어로 빌드하면 잘 된다.

docker build -t voyagerwoo/hello-world:manual .

정리

스프링 부트를 도커 이미지로 빌드할 때, 성격이 다른 app 코드 영역와 lib 영역을 분리하고 자주 변하지 않는 lib 부분을 재사용하도록 만들었다.

maven을 빌드 시스템을 사용해서 구현했다. Gradle에 비하면 너무 장황하다. 최근까지 Gradle을 왜 써야할지에 대해서 의구심이 많이 있었는데, 이번에 왜 Gradle이 좋은지에 대해서 실감했다. 사내에서 아직 maven을 사용하고 있는데, Gradle 도입을 진지하게 고민해봐야 할 것 같다.

참고: http://egloos.zum.com/kwon37xi/v/4747016

의문점

  • 빌드된 Jar를 다시 unpack 하지 말고 빌드 결과가 바로 lib, app을 분리해주면 안될까?
  • 도커 캐시는 어떻게 동작할까?
반응형
댓글
댓글쓰기 폼