[container] jib 기반의 Java 애플리케이션 컨테이너 이미지

2022. 9. 14. 19:34[ DevOps ]/[ CI-CD ]

jib 개요

  

Jib은 Docker 데몬 없이 Java 애플리케이션에 최적화된 Docker 및 OCI 이미지를 빌드하고 Docker Hub와 같은 레지스트리로 저장하는 '플러그인'이다. jib은 gradle, maven과 같은 빌드툴과 함께 사용된다.

 

 

jib과 기존 Dockerfile 기반 빌드 비교

https://cloud.google.com/java/getting-started/jib


위 그림과 같이 jib은 빌드와 Dockerfile 작성의 과정을 하나의 파이프라인으로 단일화한다. jib은 프로젝트를 빌드함과 동시에 컨테이너 이미지까지 만들어서 Docker Hub, AWS ECR 등 원하는 레지스트리에 푸시까지 해준다.


jib 장점

1) Fast

Jib은 애플리케이션을 여러 계층으로 분리하여 클래스에서 종속성을 분리한다. 이제 Docker가 전체 Java 애플리케이션을 다시 빌드할 때까지 기다릴 필요가 없다. 변경된 레이어만을 배포하기만 하면 되기 때문이다.

 

2) Reproducible (재생 가능한)

동일한 콘텐츠로 컨테이너 이미지를 다시 빌드하면 항상 동일한 이미지가 생성된다. 즉, 불필요한 업데이트가 다시 실행되지 않으며 변경된 내용이 없다면 이미지 빌드 과정이 수행되지 않는다. (layer caching)

 

3) Daemonless

CLI 종속성을 줄인다. Maven 또는 Gradle 내에서 Docker 이미지를 빌드하고 원하는 레지스트리로 푸시한다. 더 이상 Dockerfile을 작성하고 docker build/push를 호출하지 않아도 된다.

 

cf. 컨테이너 이미지를 만들 때 이미지 내부에서 gradle을 빌드할게 아니라면 jdk는 불필요하다. 자바 애플리케이션을 실행시킬 수만 있으면 되기 때문에 jre만으로 컨테이너 이미지를 만들 수 있다. 즉, jdk 가 불필요하고 Jre를 base image로 하여 jre만으로 가벼운 배포, 가벼운 컨테이너를 만들 수 있다.

 


jib 동작 방식

jib의 동작 방식을 파악하면 Jib의 장점을 이해할 수 있다. 전통적으로 Java 애플리케이션은 하나의 jar를 기반으로 '단일 이미지 레이어'로 빌드된다. 하나의 jar 파일을 복사하여 Image를 생성하는 방식은 Docker의 Layer Caching이라는 장점을 잘 활용하지 못한다. Jib의 빌드 전략은 보다 세분화된 증분 빌드를 위해 Java 애플리케이션을 여러 레이어로 분리한다. 코드를 변경하면 전체 애플리케이션이 아니라 변경 사항만 다시 빌드한다. 

 

컨테이너 이미지 레이어 생성 원리

docker build 명령을 실행할 때 docker는 dockerfile의 각 명령어에 대해 하나의 레이어를 빌드한다. 그리고 이러한 이미지 레이어는 'read only' 레이어이다. docker run 명령을 실행하면 docker는 'read-write' 계층인 '컨테이너 계층'을 빌드한다.

https://towardsdatascience.com/docker-storage-598e385f4efe

 

Read Only 레이어의 활용: 'Copy-on-Write'

https://towardsdatascience.com/docker-storage-598e385f4efe


컨테이너 실행 중에 컨테이너에서는 파일(위 그림에서 temp.txt)을 생성할 수도 있고 read only 이미지 레이어에 있던 파일(위 그림에서 app.py)을 수정할 수도 있다. 사실, read only 이미지 레이어에 있던 파일을 수정하는 작업에는 Copy-on-Write가 적용된다. 즉, 파일을 수정하면 복사본이 컨테이너 레이어에 생성되고 변경 사항은 컨테이너에서만 적용되는 것이다.

이렇게 컨테이너 이미지 레이어를 Read Only로 두고 Copy-on-Write를 적용한 이유는 여러 컨테이너가 해당 이미지 레이어를 공유하도록 하기 위해, 또는 자식 이미지가 동일한 이미지 레이어를 사용하게 하기 위함이다.

참고) 위와 같이 컨테이너에서 새로 생성한 파일이나 이미지에 있던 파일을 변경해서 만들어진 파일들(Copy-on-Write로 생성된 파일)의 수명은 컨테이너가 살아 있는 동안이다. 이를 영구적으로 저장하기 위해 호스트와 'Volume Mounting'을 이용한다. 또는 호스트에 존재하던 기존 파일을 컨테이너에서 읽어가도록 할 수도 있으며 이를 'Bind Mounting'이라고 한다. Volume Mounting과 Bind Mounting 모두 docker run 커맨드의 v옵션을 사용한다.

 

cf. union file system

실제 컨테이너를 실행할 때에는 각 이미지 레이어들이 하나의 파일 시스템으로 보이는데 이는 union file system을 이용하기 때문이다. union file system을 통해 여러 개로 나뉜 파일 시스템을 하나의 파일 시스템으로 만들 수 있다.

 

cf. 효율적인 이미지 레이어 위치

하위 수준 레이어의 변경 사항은 상위 수준 레이어를 다시 빌드한다. 따라서 자주 변경되지 않는 레이어는 아래에 위치하고 자주 변경되는 레이어는 보다 위에 위치해야 이미지 빌드 속도가 빨라진다.

 

 

 

Jib의 컨테이너 이미지 레이어링

https://github.com/GoogleContainerTools/jib/blob/master/docs/faq.md

위와 같이 기존에 Dockerfile을 이용하여 빌드 artifact(jar file)를 하나의 레이어로 두는 게 아니라 여러 레이어로 나누어 변경된 부분만 재빌드하고 그 부분만을 컨테이너 이미지로 교체한다는 점에서 jib의 장점을 소개하고 있다. 즉, 이미지 레이어를 나눠 관리하며 layer caching을 적용해서 변경된 부분만을 gradle로 빌드하고 이미지로 빌드한다.


 

Dockerfile과 jib 비교 실습

Case 1. 일반 Dockerfile 기반 이미지 생성

- Dockerfile 작성

FROM adoptopenjdk/openjdk11:alpine-jre
EXPOSE 8080

ARG JAR_FILE=/build/libs/jenkins-test-0.0.1-SNAPSHOT.jar
COPY ${JAR_FILE} /

cf. alpine은 linux는 경량화를 추구하면서 보안을 강화한 이미지로 꼭 필요한 라이브러리와 시스템 데몬이 포함되어 있기 때문에 일반적인 이미지에 비해서 그 사이즈가 매우작다. 리눅스를 위한 이미지이기 때문에 Linux 외 다른 OS에서는 위 Dockerfile을 빌드할 수 없다. 현재 openjdk11:alpine-jre는 약 50MB이다. 이미 빌드된 Jar 파일을 실행만 시킬 것이기 때문에 jdk는 넣지 않고 jre만 넣었다.

 


- Gradle 빌드 후 이미지 빌드, 레지스트리 Push

$ time ./gradlew build
$ time docker build . -t <user>/<repo>:<tag> -f Dockerfile
$ time docker push <user>/<repo>:<tag>

 



Case2. jib 기반 이미지 생성

- build.gradle에 jib 플러그인 추가

plugins {
    id 'com.google.cloud.tools.jib' version '3.1.4'
}


- jib 이미지 빌드 설정

jib {
    from { 
        image = 'adoptopenjdk/openjdk11:alpine-jre'
    }
    to { 
        image = '<도커계정>/<레포지토리명>'
        tags = ['1.0']
    }
    container {
        entrypoint = ['java', '-Dspring profiles.active=test', '-jar', 'app.jar']
        ports = ['8080'] 

        environment = [SPRING_OUTPUT_ANSI_ENABLED: "ALWAYS"]
        labels = [Iversion: project.version, name: project.name, group: project.group]

        format = 'Docker'
    }
    extraDirectories {
        paths { 
            path {
                from = file('build/libs') 
            }
        }
    }
}

 

image에 url이 없으면 default로 docker hub에 push한다.

 


- jib 기반 빌드 (gradle 빌드 + 이미지 빌드 + 레지스트리 push)

# gradle 설치 후 사용할 경우
$ gradle jib

# 설치 없이 wrapper 사용할 경우
$ ./gradlew jib

 

 

 

코드 일부 수정 후 jib빌드 과정 확인

실제로 gib으로 이미지를 빌드하니 아래와 같이 여러 레이어가 생성되었음을 확인할 수 있다.

Building classes layer...
Building jvm arg files layer...
Building dependencies layer...
Building resources layer...
Building extra files layer...

 

소스코드를 일부 변경하고 gib으로 빌드하여 도커 허브에 올리고 docker pull을 하였더니 아래와 같이 이미 존재하는 Layer들이 있기 때문에 필요한 layer들만 pull 하고 있음을 확인할 수 있다. 

c7ed990a2339: Already exists
379b934519f8: Already exists
979e448553fd: Already exists
f98d0fd5517d: Already exists
8f4d7045faf1: Already exists
c4cdd16abac8: Pulling fs layer
f2f8166ccfe4: Pulling fs layer
f64c05ec4566: Pulling fs layer
f2f8166ccfe4: Verifying Checksum
f2f8166ccfe4: Download complete
c4cdd16abac8: Verifying Checksum
c4cdd16abac8: Download complete
c4cdd16abac8: Pull complete
f2f8166ccfe4: Pull complete
f64c05ec4566: Verifying Checksum
f64c05ec4566: Download complete
f64c05ec4566: Pull complete

java 애플리케이션 전체를 하나의 jar 파일 레이어로 두는 것보다 이처럼 jib기반으로 여러 이미지 레이어로 나누어 두면 변경된 부분만 pull 받을 수 있기 때문에 보다 빠르게 java 애플리케이션 컨테이너 이미지를 만들고 실행시킬 수 있다. 즉, 실질적인 이미지 빌드 시간, 이미지 pull 시간이 줄어드는 것이다.

 

 

코드 변경 후 이미지 재빌드 시간 비교

아래는 각각 Dockerfile 및 jib 기반으로 이미지를 빌드, Push할 때의 성능 차이를 대략적으로 비교해본 결과이다. 코드 변경 과정에서는 main 메소드에 일반 출력문을 추가해주었다. 두 경우에 대해 시스템의 상태가 다를 수 있었다는 점에서 대략적인 비교만 가능하니 참고만 하자.

 

1) Dockerfile 기반인 경우

 

-> user mode, kernel mode 합산 18s 소요

 

2) jib기반인 경우

-> user mode, kernel mode 합산 10.852s 소요

 

 

결론 

jib 기반의 이미지는 단일 레이어가 아니기 때문에 layer caching이 적용되어 변경된 부분만 이미지 레이어 빌드가 이루어 지기 때문이다. 위와 같이 컨테이너 이미지는 여러 layer를 두고 2번 이상 같은 이미지를 빌드할 때 기존 이미지가 캐싱된다. 즉, jib을 기반으로 하면 컨테이너 이미지의 특징인 이미지 레이어를 더욱 잘 활용할 수 있게 되는 것이다. jib을 기반으로 변경된 코드 부분만을 재빌드하는 증분빌드 방식을 사용하면 더 빠르고 가벼운 배포를 진행할 수 있다. Dockerfile을 기반으로 이미지를 빌드할 때에도 cache를 사용했지만 이는 Jar 파일 레이어에 대한 캐시가 아니라 단순 Dockerfile 커맨드에 대한 캐시이다. 즉, 성능에 크게 영향을 주는 캐싱이 아니다.

 

 

 

cf. 추가로 알아볼 내용
- gradle의 증분빌드와 gib 간의 관계

 

 

 

Reference

- jib github, https://github.com/GoogleContainerTools/jib
- jre와 jib, https://alden-kang.tistory.com/1
- serverless jib, https://gc.hosting.kr/blog-serverless-jib-container/
- jib 공식 블로그, https://cloudplatform.googleblog.com/2018/07/introducing-jib-build-java-docker-images-better.html
- jib의 원리, https://medium.com/@gaemi/spring-boot-%EA%B3%BC-docker-with-jib-657d32a6b1f0
- container image layer 생성과정, https://towardsdatascience.com/docker-storage-598e385f4efe
- jib의 컨테이너 이미지 레이어링, https://github.com/GoogleContainerTools/jib/blob/master/docs/faq.md