배포

이 장은 애플리케이션이 app.start(port)를 호출하는 대신 하는 일을 다룬다: 외부 서블릿 컨테이너에 배포하기, 템플릿을 미리 컴파일하기, Jib으로 컨테이너 이미지를 빌드하기, 그리고 플랫폼이 요구하는 곳에서 Dockerfile을 작성하기다.

외부 서블릿 컨테이너

외부 서블릿 컨테이너에 배포하려면 start를 건너뛰고 AppServlet을 직접 매핑한다:

ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS);
ServletHolder holder = new ServletHolder(new AppServlet(app));
holder.setInitOrder(0); // initialize while the context starts
context.addServlet(holder, "/*");

AppServlet은 컨테이너가 초기화할 때 자신의 App의 라우트와 설정을 가져가며, 그 App에 대한 등록은 컨테이너가 서블릿을 파괴할 때까지 닫힌다. 그 시점 이후에 등록한 라우트나 설정은 서블릿에 결코 반영되지 않으므로 IllegalStateException을 던진다. 컨테이너는 달리 지시받지 않으면 첫 요청에 서블릿을 초기화하므로, 시작 시 로드되도록 매핑한다: Jetty에서는 setInitOrder(0), web.xml에서는 <load-on-startup>0</load-on-startup>, 또는 그 밖의 컨테이너에서 이에 해당하는 설정이다. 그러면 등록은 처음 호출될 때가 아니라 애플리케이션이 배포될 때 닫힌다. init(ServletConfig)나 destroy()를 재정의하는 서브클래스는 서블릿 API가 모든 서블릿에 요구하는 대로 슈퍼클래스 메서드를 호출한다.

AppServlet은 표준 서블릿이므로 Jetty만이 아니라 어떤 컨테이너에서도 돌아간다. app.start(port)가 스스로 실행하는 임베디드 Tomcat은 별도 모듈인 spider-silk-tomcat에 있다: Tomcat을, 세 번째 임베디드 서버로는 Undertow를 참고한다. 직접 시작하지 않은 컨테이너에 배포할 때는 번들된 Jetty를 제외한다:

implementation('net.benelog.spidersilk:spider-silk-core') {
    exclude group: 'org.eclipse.jetty.ee10'
}

미리 컴파일한 템플릿

jte는 기본적으로 런타임에 템플릿을 컴파일하므로 배포 머신에 JDK가 있어야 한다. 프로덕션 빌드는 대신 미리 컴파일한다. jte Gradle 플러그인은 빌드 시점에 각 템플릿을 Java 클래스로 바꾸므로, jar는 컴파일된 클래스를 렌더링하고, 컴파일되지 않는 템플릿은 요청이 아니라 빌드를 실패시킨다. 플러그인 설정과 그에 맞는 생성자는 템플릿에 개발 모드의 대응물과 나란히 있다. 네이티브 이미지는 이 모드를 최적화가 아니라 필수로 만드는데, 네이티브 바이너리는 컴파일러를 싣고 있지 않기 때문이다.

Docker 없는 컨테이너 이미지

Jib은 Gradle 모델만으로 컨테이너 이미지를 빌드하므로, 빌드 머신에는 Docker 데몬이 필요 없다. gradle :example-flashcard:jibBuildTar는 어떤 Docker 호스트든 로드할 수 있는 build/jib-image.tar를 써낸다. 예제는 spider-silk 플러그인을 통해 Jib을 적용하는데, 이 플러그인이 JRE 21 베이스와 포트 8080을 이미 설정하므로 빌드는 이미지 이름만 명시한다:

plugins {
    id 'net.benelog.spidersilk'
}

jib {
    to {
        image = 'ghcr.io/benelog/spider-silk-flashcard'
    }
}

플러그인 없이 같은 이미지를 만들려면 Jib 자체의 플러그인(com.google.cloud.tools.jib)을 쓰고 위 블록에 from.image = 'eclipse-temurin:25-jre'와 container.ports = ['8080']을 더한다.

Jib은 빌드의 구조를 따라 이미지를 레이어로 나누며, 이 분할 덕분에 재빌드와 푸시의 비용이 작다. 네 계층이 각각 레이어가 된다: 외부 의존성(Jetty, Spring JDBC, H2 jar), 프로젝트 의존성(spider-silk-core와 spider-silk-openapi jar로, 프레임워크 수정이 더 큰 외부 레이어를 건드리지 않도록 따로 둔다), 리소스, 그리고 클래스다. 애플리케이션 코드를 수정하면 맨 위의 작은 클래스 레이어만 다시 쓴다. Jib은 또한 모든 타임스탬프를 에포크로 고정하므로, 변경되지 않은 레이어는 같은 다이제스트로 비트 단위까지 동일하게 재현되고, 레지스트리 푸시는 그 레이어를 다시 업로드하지 않고 건너뛴다.

spider-silk jar가 프로젝트 의존성으로 취급되는 것은 예제와 프레임워크가 함께 빌드되는 이 저장소에서만이다. 저장소에서 spider-silk를 가져오는 애플리케이션에서는 spider-silk도 외부 jar 중 하나일 뿐이며, Jetty 등과 함께 의존성 레이어에 캐시되고 버전을 올릴 때만 다시 쓰인다.

엔트리포인트는 application 블록의 mainClass에서 온다. 템플릿이 위 절의 미리 컴파일한 클래스로 함께 들어가고 이미지 안의 어느 것도 런타임에 컴파일하지 않으므로, JRE 베이스로 충분하다. 플러그인은 Jib의 검사 하나도 우회한다. Jib은 앱의 targetCompatibility를 베이스 이미지의 Java 버전과 대조하는데, options.release만으로 25를 목표로 하는 빌드는 targetCompatibility를 툴체인의 버전으로 남겨 두므로 더 새 툴체인에서는 검사가 JRE 25 베이스를 거부한다. 플러그인은 release 값을 targetCompatibility에 복사하며, 손으로 쓰면 java { targetCompatibility = JavaVersion.VERSION_25 }이다.

gradle :example-flashcard:jibBuildTar
# then, on a machine that runs containers:
docker load < example-flashcard/build/jib-image.tar
docker run -p 8080:8080 ghcr.io/benelog/spider-silk-flashcard

tar는 데몬 없이 가는 경로일 뿐이다: gradle jib은 자격 증명이 있으면 같은 이미지를 레지스트리로 곧장 푸시하고, gradle jibDockerBuild는 로컬 데몬이 있으면 그 데몬에 이미지를 로드한다. H2는 데이터베이스 파일을 프로세스 홈(이 이미지에서는 /root/db) 아래에 두므로, 거기에 볼륨을 마운트하지 않으면 데이터는 컨테이너와 함께 사라진다. 같은 빌드를 -Pnative로 실행하면 jar 대신 GraalVM 바이너리를 컨테이너화한다. 이 플래그가 베이스 이미지와 레이어를 바꾸며, 네이티브 이미지 장이 무엇이 달라지는지 보여준다.

플랫폼이 요구하는 곳의 Dockerfile

예제에는 Dockerfile로만 빌드하는 플랫폼을 위한 Dockerfile이 있다: example-flashcard/Dockerfile로, 멀티 모듈 빌드의 모든 모듈이 컨텍스트에 담기도록 저장소 루트에서 실행하는 멀티 스테이지 빌드다.

docker build -f example-flashcard/Dockerfile -t spider-silk-flashcard .
docker run -p 8080:8080 spider-silk-flashcard

이 파일은 Jib이 공짜로 해 주는 레이어 나누기를 손으로 재현하므로, 모든 명령이 레이어 캐시에 맞게 순서가 정해져 있다. 빌드 스테이지는 먼저 Gradle 스크립트만 복사하되 settings.gradle이 모든 모듈을 지목하므로 모든 모듈의 스크립트를 복사하고, 소스가 이미지에 들어오기 전에 의존성을 내려받는다. 따라서 소스를 수정하면 컴파일은 다시 실행되지만 다운로드는 결코 다시 실행되지 않는다. 그 다운로드가 resolveDependencies로, spider-silk 플러그인이 외부 런타임 jar만을 가져오도록 등록하는 태스크다. 기본 dependencies 리포트로는 대신할 수 없다: 메타데이터에서 멈추고 애플리케이션 jar를 전혀 캐시하지 않는다.

빌드 스테이지는 빌드 스크립트를 복사하고
Figure 1. Dockerfile의 두 스테이지

런타임 스테이지는 그다음 Jib의 네 계층을 네 개의 COPY 명령으로, 변동이 적은 것부터 복사한다: 외부 jar, spider-silk jar, 리소스, 그리고 애플리케이션의 클래스를 jar가 아니라 낱개 .class 파일로 복사한다. jar를 건너뛰면 코드가 바뀌지 않아도 바뀔 수 있는 유일한 것이 사라진다. 새 타임스탬프로 다시 묶은 아카이브는 새 콘텐츠로 읽히지만 변경되지 않은 .class 파일은 바이트가 그대로이므로, 주석만 고쳐 같은 바이트코드로 컴파일되는 수정은 클래스 레이어까지 캐시된 채로 둔다. (요즘 Gradle은 기본적으로 재현 가능한 jar를 빌드하므로 그 문제는 무뎌진다. Maven의 jar 플러그인은 project.build.outputTimestamp가 지정하지 않는 한 그렇지 않으며, 이 때문에 Maven 빌드에서는 낱개 클래스가 더 중요해진다.) 직접 만든 애플리케이션에서는 spider-silk의 jar가 외부 계층에 속하는데, 코드를 수정할 때가 아니라 버전을 올릴 때 바뀌기 때문이다. 그러면 core 계층은 사라진다: 이 계층은 프레임워크가 예제와 나란히 빌드되기 때문에 여기에만 있다. COPY는 콘텐츠로 캐시하므로, 코드 변경은 빌드 스테이지 전체를 다시 실행하지만 마지막의 작은 클래스 레이어만 다시 쓴다.

Docker의 캐시는 한 가지 점에서 Jib과 다르다. Docker는 명령을 사슬로 캐시하므로, 수정은 자신의 계층과 그 뒤의 계층들을 다시 쓴다: 템플릿이나 정적 파일을 수정하면 리소스와 클래스를 다시 쓰는데, 둘 다 작다. Jib의 레이어는 서로 독립적이다. 엔트리포인트는 jar의 매니페스트가 더 이상 담을 수 없는 것을 명시한다: Jib과 같은 순서의 클래스패스와 메인 클래스다. 루트의 .dockerignore도 이 구성의 일부다: build/ 디렉터리를 컨텍스트에서 빼는데, 그러지 않으면 이 디렉터리가 빌드할 때마다 소스 레이어를 무효화한다. 포트, 베이스, 프로세스 홈에 관한 H2 주의점은 Jib 이미지와 같다. Dockerfile은 eclipse-temurin:25-jdk에서 빌드하고, Jib이 실행하는 JRE인 eclipse-temurin:25-jre에서 실행한다.