배포
이 장은 애플리케이션이 `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'
}
Docker 없는 컨테이너 이미지
Jib은 Gradle 모델만으로 컨테이너 이미지를 빌드하므로, 빌드 머신에는 Docker 데몬이 필요 없다. `gradle :example-flashcard:jibBuildTar`는 어떤 Docker 호스트든 로드할 수 있는 `build/jib-image.tar`를 써낸다. 예제는 spider-silk 플러그인을 통해 이를 설정하는데, 이 플러그인은 JRE 21 베이스와 포트 8080이 이미 설정된 채로 Jib을 함께 가져오므로 이미지 이름만 남는다:
plugins {
id 'net.benelog.spidersilk'
}
jib {
to {
image = 'ghcr.io/benelog/spider-silk-flashcard'
}
}
플러그인 없이는, 같은 이미지는 Jib 자체의 플러그인(com.google.cloud.tools.jib)에 `from.image = 'eclipse-temurin:21-jre'`와 `container.ports = ['8080']`을 위 블록에 더한 것이다.
Jib은 빌드가 구조화된 방식대로 이미지를 레이어로 나누며, 그 분할이 재빌드와 푸시를 값싸게 만드는 요인이다.
네 계층이 각각 레이어가 된다: 외부 의존성(Jetty, Spring JDBC, H2 jar), 프로젝트 의존성(spider-silk-core jar로, 프레임워크 수정이 더 큰 외부 레이어를 건드리지 않도록 따로 둔다), 리소스, 그리고 클래스다.
애플리케이션 코드를 수정하면 맨 위의 작은 클래스 레이어만 다시 쓴다.
Jib은 또한 모든 타임스탬프를 에포크로 고정하므로, 변경되지 않은 레이어는 같은 다이제스트로 비트 단위까지 동일하게 재현되고, 레지스트리 푸시는 다시 업로드하는 대신 그것을 건너뛴다.
`spider-silk-core`가 프로젝트 의존성으로 셈해지는 것은 예제와 프레임워크가 함께 빌드되는 이 저장소에 관한 사실이다. 저장소에서 spider-silk를 가져오는 애플리케이션은 그것을 외부 jar 하나로 더 보게 되며, Jetty와 나머지와 함께 의존성 레이어에 캐시되고 버전을 올릴 때만 다시 쓴다.
엔트리포인트는 application 블록의 `mainClass`에서 온다.
템플릿이 위 절의 미리 컴파일한 클래스로 함께 실려 오고 이미지 안의 어느 것도 런타임에 컴파일하지 않으므로, JRE 베이스로 충분하다.
알아 둘 만한 문제가 하나 있고, 플러그인이 그것을 흡수한다.
Jib은 앱의 `targetCompatibility`를 베이스 이미지의 Java 버전과 대조하는데, `options.release`만으로 21을 목표로 하는 빌드는 그 자리에 툴체인의 버전을 남겨 두어 검사가 그것을 읽고 JRE 21 베이스를 거부하게 된다.
플러그인은 Jib이 보는 자리에 release를 다시 명시하며, 손으로 쓰면 `java { targetCompatibility = JavaVersion.VERSION_21 }`이다.
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 아래에 두므로, 거기에 볼륨을 마운트하지 않는 한 데이터는 컨테이너와 함께 살고 죽는다.
같은 빌드는 요청하면 대신 GraalVM 바이너리를 컨테이너화한다.
`-Pnative`가 베이스 이미지와 레이어를 바꾸며, 네이티브 이미지 장이 무엇이 달라지는지 보여준다.
플랫폼이 요구하는 곳의 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를 전혀 캐시하지 않는다.
런타임 스테이지는 그다음 Jib의 네 계층을 네 개의 COPY 명령으로, 변동이 적은 것부터 복사한다: 외부 jar, spider-silk-core jar, 리소스, 그리고 애플리케이션의 클래스를 jar가 아니라 낱개 .class 파일로 복사한다.
jar를 건너뛰면 코드가 바뀌지 않아도 바뀔 수 있는 유일한 것이 사라진다.
새 타임스탬프로 다시 포장된 아카이브는 새 콘텐츠로 읽히지만 변경되지 않은 .class 파일은 있는 그대로 읽히므로, 동일한 바이트코드로 컴파일되는 주석만의 수정은 클래스 레이어까지 캐시된 채로 둔다.
(요즘 Gradle은 기본적으로 재현 가능한 jar를 빌드하므로 그 문제는 무뎌진다.
Maven의 jar 플러그인은 `project.build.outputTimestamp`가 지정하지 않는 한 그렇지 않으며, 이 때문에 Maven 빌드에서는 낱개 클래스가 더 중요해진다.)
자신의 애플리케이션이라면 spider-silk의 jar는 외부 쪽에 속하는데, 그것은 자신의 수정이 아니라 버전을 올릴 때 바뀌기 때문이며, core 계층은 사라진다.
그 계층은 프레임워크가 예제와 나란히 빌드되기 때문에 여기에만 존재한다.
`COPY`는 콘텐츠로 캐시하므로, 코드 변경은 빌드 스테이지 전체를 다시 실행하지만 마지막의 작은 클래스 레이어만 다시 쓴다.
Jib과 다른 비대칭이 하나 있다.
Docker는 명령을 사슬로 캐시하므로, 수정은 자신의 계층과 그 뒤의 계층들을 다시 쓴다: 템플릿이나 정적 파일을 수정하면 리소스와 클래스를 다시 쓰는데, 둘 다 작다.
Jib의 레이어는 서로 독립적이다.
엔트리포인트는 jar의 매니페스트가 더 이상 말할 수 없는 것을 명시한다: Jib의 순서대로의 클래스패스와 메인 클래스다.
루트의 .dockerignore`는 이 구성의 일부다: `build/ 디렉터리를 컨텍스트 밖에 두는데, 그러지 않으면 빌드할 때마다 소스 레이어가 무효화된다.
이미지의 그 밖의 모든 것은 Jib 이미지와 같다: 같은 eclipse-temurin:21-jre 베이스, 같은 포트, 그리고 프로세스 홈에 관한 같은 H2 주의점이다.