기맹기 개발 블로그

스프링부트 도커 이미지 최적화 본문

Docker

스프링부트 도커 이미지 최적화

기맹기 2023. 3. 22. 15:40
'도커 교과서' 책을 읽으면서 도커 이미지 빌드 캐싱에 대해서 접하게 되었고, 스프링부트 애플리케이션은 어떻게 최적화할 수 있을지 생각해보며 정리하게 되었습니다.

 

가장 기본적인 형태의 도커 이미지는 아래와 같습니다.

FROM adoptopenjdk:11-jre-hotspot
WORKDIR my-application
COPY target/my-application.jar .
ENTRYPOINT ["java", "-jar", "my-application.jar"]

 

도커 이미지는 Dockerfile에 정의된 인스트럭션마다 layer를 가집니다.

위의 예시에서는 기반이 되는 jre 이미지 위에 WORKDIR, COPY, 그리고 jar를 실행하는 ENTRYPOINT 레이어로 구성되어 있습니다.

 

하지만 개발 시에 라이브러리보다는 서비스 코드가 자주 변경됩니다.

따라서 매번 jar 전체를 빌드하고 이미지로 만드는 것은 불필요한 리소스 낭비가 됩니다.

도커 이미지는 컴퓨팅 리소스뿐만 아니라 푸시 및 풀을 위한 네트워크 리소스와도 관련 있으므로 최적화하는 것은 많은 이점을 줄 수 있습니다.

 

도커 엔진은 이미지 빌드 때 변경되지 않은 레이어는 캐싱된 것을 이용합니다.

따라서 jar를 여러 레이어로 나누어서 변경이 일어나지 않은 레이어는 다시 빌드하지 않도록 할 수 있습니다.

 

스프링 부트는 layered jar를 지원합니다.

jar --xvf my-application.jar

다음 명령어를 이용해서 jar의 압축을 해제하고 내부를 직접 확인해볼 수 있습니다.

 

- BOOT-INF : 사용자가 정의한 클래스 파일들과 의존성 있는 라이브러리들이 위치한 경로입니다.

- META-INF : 프로젝트의 메타데이터를 포함하는 매니페스트 파일이 위치한 경로입니다.

- org.springframework.boot : 스프링부트 로더가 위치한 경로입니다.

 

BOOT-INF/classpath.idx에는 classpath에 추가할 jar들의 목록을 정의합니다.

BOOT-INF/layers.idx에는 jar의 계층 구조가 정의되어 있습니다.

- "dependencies":
  - "BOOT-INF/lib/"
- "spring-boot-loader":
  - "org/"
- "snapshot-dependencies":
- "application":
  - "BOOT-INF/classes/"
  - "BOOT-INF/classpath.idx"
  - "BOOT-INF/layers.idx"
  - "META-INF/"

위와 같이 dependencies가 가장 변화가 적은 하위 계층으로, BOOT-INF/lib 경로에 있는 jar 파일들이 여기에 해당됩니다.

이후에는 springboot loader, snapshot dependencies가 있습니다.

가장 상위 계층에는 사용자가 정의한 클래스들인 application이 있습니다.

 

따라서 각 계층별로 도커 레이어를 구성하면 이미지를 빌드하더라도 의존성에 의해 빌드된 것들은 캐싱하여 재사용할 수 있게 됩니다.

 

이제 도커 이미지를 레이어 구조로 나누어보겠습니다.

앞서 jar --xvf를 이용해서 jar 압축을 풀고 직접 복잡하게 구성할 필요는 없습니다.

다음의 명령어를 수행하면됩니다.

java -Djarmode=layertools -jar my-application.jar extract

 

깔끔하게 레이어별로 분리하여 추출된 모습을 볼 수 있습니다.

 

이제 이를 도커 파일에 적용하여 이미지를 빌드해봅시다.

 

FROM adoptopenjdk:11-jre-hotspot AS builder
WORKDIR application
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=layertools -jar application.jar extract

FROM adoptopenjdk:11-jre-hotspot
WORKDIR application
COPY --from=builder application/dependencies/ ./
COPY --from=builder application/spring-boot-loader/ ./
COPY --from=builder application/snapshot-dependencies/ ./
COPY --from=builder application/application/ ./

ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

 

jar의 레이어를 추출하는 것까지 도커 레이어에 포함시킬 필요는 없습니다.

이는 빌드 시에만 필요하고 컨테이너에는 최종적으로 jar만 실행되면 됩니다.

따라서 멀티스테이지 빌드를 사용하여 필요한 부분만 컨테이너에서 사용합니다.

 

빌더 스테이지

1. jre 이미지를 기반으로 합니다.

2. 작업 디렉터리는 application을 이용합니다.

3. 미리 빌드된 jar 파일을 컨테이너 이미지로 복사합니다.

4. 앞서 설명한 명령으로 레이어를 추출합니다.

 

배포할 이미지

1. jre 이미지를 기반으로 합니다.

2. 작업 디렉터리는 마찬가지로 application을 이용합니다.

3. 앞서 빌더에서 추출한 레이어를 도커 이미지 레이어로 분리하여 올려줍니다.

4. JarLauncher를 이용해 스프링부트를 실행합니다.

 

여기서 springframework.boot.loader.JarLauncher는 main 메서드가 포함된 진입점입니다.

public class JarLauncher extends ExecutableArchiveLauncher {
    static final Archive.EntryFilter NESTED_ARCHIVE_ENTRY_FILTER = (entry) -> {
        return entry.isDirectory() ? entry.getName().equals("BOOT-INF/classes/") : entry.getName().startsWith("BOOT-INF/lib/");
    };

    public JarLauncher() {
    }

    protected JarLauncher(Archive archive) {
        super(archive);
    }

    protected boolean isPostProcessingClassPathArchives() {
        return false;
    }

    protected boolean isNestedArchive(Archive.Entry entry) {
        return NESTED_ARCHIVE_ENTRY_FILTER.matches(entry);
    }

    protected String getArchiveEntryPathPrefix() {
        return "BOOT-INF/";
    }

    public static void main(String[] args) throws Exception {
        (new JarLauncher()).launch(args);
    }
}

 

JarLauncher -> ExecutableArchiveLauncher -> Launcher의 상속 관계를 가지고 있습니다.

추상 클래스인 Launcher에 launch가 구현되어 있는데 간략한 플로우는 다음과 같습니다.

 

1. 클래스로더를 생성하고, 이를 MainMethodRunner 생성에 사용합니다.

    protected void launch(String[] args) throws Exception {
        if (!this.isExploded()) {
            JarFile.registerUrlProtocolHandler();
        }

        ClassLoader classLoader = this.createClassLoader(this.getClassPathArchivesIterator());
        String jarMode = System.getProperty("jarmode");
        String launchClass = jarMode != null && !jarMode.isEmpty() ? "org.springframework.boot.loader.jarmode.JarModeLauncher" : this.getMainClass();
        this.launch(args, launchClass, classLoader);
    }
    ...
    protected void launch(String[] args, String launchClass, ClassLoader classLoader) throws Exception {
        Thread.currentThread().setContextClassLoader(classLoader);
        this.createMainMethodRunner(launchClass, args, classLoader).run();
    }
    ...
    protected MainMethodRunner createMainMethodRunner(String mainClass, String[] args, ClassLoader classLoader) {
        return new MainMethodRunner(mainClass, args);
    }

 

2. MainMethodRunner에서는 리플렉션을 이용해 사용자가 지정한 main 메소드를 찾아 실행합니다.

public class MainMethodRunner {
    private final String mainClassName;
    private final String[] args;

    public MainMethodRunner(String mainClass, String[] args) {
        this.mainClassName = mainClass;
        this.args = args != null ? (String[])args.clone() : null;
    }

    public void run() throws Exception {
        Class<?> mainClass = Class.forName(this.mainClassName, false, Thread.currentThread().getContextClassLoader());
        Method mainMethod = mainClass.getDeclaredMethod("main", String[].class);
        mainMethod.setAccessible(true);
        mainMethod.invoke((Object)null, this.args);
    }
}

 

위처럼 SpringApplication의 main 메소드를 직접 실행하도록 노출하지 않고, JarLauncher를 통해 접근합니다.

따라서 application 레이어에 변화가 생겨도 ENTRYPOINT가 spring-boot-loader 레이어의 JarLauncher이므로 영향을 주지 않습니다.

따라서 의존성 변화가 있지 않는 이상 dependencies, spring-boot-loader 레이어를 캐싱하여 빌드할 수 있게 됩니다.