001/* 002 * Copyright 2017-2026 original authors 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package io.micronaut.maven.jdkaotcache; 017 018import com.github.dockerjava.api.command.InspectImageResponse; 019import com.github.dockerjava.api.model.ExposedPort; 020import com.github.dockerjava.api.model.InternetProtocol; 021import io.micronaut.core.annotation.Internal; 022import io.micronaut.maven.services.DockerService; 023import org.apache.maven.plugin.MojoExecutionException; 024import org.apache.maven.plugin.logging.Log; 025 026import java.io.IOException; 027import java.io.InputStream; 028import java.nio.charset.StandardCharsets; 029import java.nio.file.Files; 030import java.nio.file.Path; 031import java.util.ArrayList; 032import java.util.Arrays; 033import java.util.LinkedHashMap; 034import java.util.List; 035import java.util.Locale; 036import java.util.Map; 037import java.util.Objects; 038import java.util.Optional; 039import java.util.regex.Matcher; 040import java.util.regex.Pattern; 041 042/** 043 * Trains a JDK AOT cache in a container of the training image that the {@code docker} goal built with Jib. The 044 * container runs the image's own entrypoint, with {@code -XX:AOTCacheOutput} and {@code -XX:-UsePerfData} in 045 * {@code JDK_JAVA_OPTIONS}. What the application does in it is the {@link TrainingRun}: 046 * <ul> 047 * <li>In a {@code load} run, it loads its bean definitions and exits without starting.</li> 048 * <li>In a {@code start} run, if its Micronaut version has the training-run switch, it starts, warms 049 * itself up and exits. It fails on a request without a response or with a status of 500 or more, and only warns 050 * about a status from 400 to 499, so its output is copied to the build log when there are training paths.</li> 051 * <li>Otherwise, it starts, the warm-up script runs in the container with {@code docker exec} and the application 052 * is stopped with SIGTERM.</li> 053 * </ul> 054 * 055 * @author Álvaro Sánchez-Mariscal 056 * @since 5.1.0 057 */ 058@Internal 059public final class JdkAotCacheTraining { 060 061 private static final String JDK_JAVA_OPTIONS = "JDK_JAVA_OPTIONS"; 062 private static final String JDK_AOT_VM_OPTIONS = "JDK_AOT_VM_OPTIONS"; 063 064 /** 065 * The minimum Java version that can train a JDK AOT cache in one step ({@code -XX:AOTCacheOutput}). 066 */ 067 private static final int MINIMUM_JAVA_VERSION = 25; 068 069 /** 070 * The warm-up and training script, shipped with the plugin. 071 */ 072 private static final String SCRIPT_RESOURCE = "/jdkAotCacheScripts/training.sh"; 073 074 private static final String CONTAINER_CACHE_FILE = "/tmp/app.aot"; 075 /** 076 * Keeps the training JVMs from writing performance data files ({@code /tmp/hsperfdata_<user>/<pid>}). The JVM that 077 * {@code -XX:AOTCacheOutput} starts to write the cache reads {@code JDK_JAVA_OPTIONS} too, and leaves its file 078 * behind. The training script passes the same option, because the layer of the {@code RUN} instruction that trains 079 * the cache in the generated Dockerfile would keep that file. 080 */ 081 private static final String NO_PERF_DATA = "-XX:-UsePerfData"; 082 private static final String COMPATIBLE_OOP_COMPRESSION_FLAG = "AOTCompatibleOopCompression"; 083 private static final int SIGTERM_EXIT_CODE = 143; 084 private static final int COMMAND_NOT_EXECUTABLE = 126; 085 private static final int COMMAND_NOT_FOUND = 127; 086 private static final Pattern JAVA_VERSION = Pattern.compile(" version \"(\\d+)(?:\\.(\\d+))?"); 087 private static final Pattern COMPATIBLE_OOP_COMPRESSION = Pattern.compile("(?m)^\\s*bool\\s+" + COMPATIBLE_OOP_COMPRESSION_FLAG + "\\s"); 088 089 private final DockerService dockerService; 090 private final Log log; 091 private final List<String> trainingPaths; 092 private final int timeoutSeconds; 093 private final String networkMode; 094 095 /** 096 * @param dockerService the Docker service 097 * @param log the Maven log 098 * @param trainingPaths the paths of the warm-up GET requests 099 * @param timeoutSeconds the timeout of each step of the training run 100 * @param networkMode the network of the training container, if any 101 */ 102 public JdkAotCacheTraining(DockerService dockerService, Log log, List<String> trainingPaths, int timeoutSeconds, 103 String networkMode) { 104 this.dockerService = dockerService; 105 this.log = log; 106 this.trainingPaths = trainingPaths; 107 this.timeoutSeconds = timeoutSeconds; 108 this.networkMode = networkMode; 109 } 110 111 /** 112 * Runs the training image once and copies the cache it writes out of the container. 113 * 114 * @param imageId the training image 115 * @param run the training run 116 * @param cacheFile where to write the cache 117 * @throws MojoExecutionException if the training fails 118 */ 119 public void train(String imageId, TrainingRun run, Path cacheFile) throws MojoExecutionException { 120 InspectImageResponse image = dockerService.inspectImage(imageId); 121 String[] imageEnvironment = image.getConfig() == null ? null : image.getConfig().getEnv(); 122 JavaRuntime java = probeJava(imageId); 123 Integer port = run.usesSwitch() ? null : httpPort(image); 124 Map<String, String> environment = trainingEnvironment(imageEnvironment, java, run, trainingPaths); 125 environment.forEach((name, value) -> log.info("JDK AOT cache: training with " + name + "=" + value)); 126 127 String containerId = dockerService.createContainer(imageId, networkMode, environment); 128 try { 129 if (run.mode() == TrainingMode.LOAD) { 130 log.info("JDK AOT cache: the application loads its bean definitions and exits without starting"); 131 dockerService.startAndWait(containerId, imageId, timeoutSeconds); 132 // The summary of the run and the bean definitions it skipped say what the cache was trained on 133 dockerService.logContainerOutput(containerId); 134 } else if (run.usesSwitch()) { 135 log.info("JDK AOT cache: the application warms itself up and exits (Micronaut training-run switch)"); 136 dockerService.startAndWait(containerId, imageId, timeoutSeconds); 137 if (!trainingPaths.isEmpty()) { 138 // Micronaut only warns about a request answered with 400 to 499, in the output of the application 139 dockerService.logContainerOutput(containerId); 140 } 141 } else { 142 log.info("JDK AOT cache: warming the application up with docker exec, then stopping it with SIGTERM " 143 + "(the application's Micronaut version has no training-run switch)"); 144 dockerService.startContainer(containerId); 145 // httpPort(image) set the port, because this run does not use the switch 146 warmUp(containerId, Objects.requireNonNull(port, "port")); 147 log.info("JDK AOT cache: stopping the application with SIGTERM"); 148 dockerService.signalContainer(containerId, "SIGTERM"); 149 int exitCode = dockerService.awaitExit(containerId, timeoutSeconds); 150 if (exitCode != SIGTERM_EXIT_CODE && exitCode != 0) { 151 dockerService.logContainerOutput(containerId); 152 throw new MojoExecutionException("JDK AOT cache training failed: the application exited with status " 153 + exitCode + " after SIGTERM"); 154 } 155 } 156 copyCache(containerId, cacheFile); 157 } catch (IOException e) { 158 throw new MojoExecutionException("JDK AOT cache training failed: " + e.getMessage(), e); 159 } finally { 160 dockerService.removeContainer(containerId); 161 } 162 } 163 164 /** 165 * @param paths the configured training paths 166 * @return the paths, trimmed 167 * @throws MojoExecutionException if a path is not an absolute request path 168 */ 169 public static List<String> validateTrainingPaths(List<String> paths) throws MojoExecutionException { 170 if (paths == null) { 171 return List.of(); 172 } 173 var result = new ArrayList<String>(paths.size()); 174 for (String path : paths) { 175 String trimmed = path == null ? "" : path.trim(); 176 if (trimmed.isEmpty()) { 177 continue; 178 } 179 if (!trimmed.startsWith("/") || trimmed.chars().anyMatch(c -> Character.isWhitespace(c) || Character.isISOControl(c))) { 180 throw new MojoExecutionException("Invalid micronaut.docker.jdkAotCache.trainingPaths entry '" + trimmed 181 + "': it must start with / and contain no spaces or control characters"); 182 } 183 result.add(trimmed); 184 } 185 return List.copyOf(result); 186 } 187 188 /** 189 * @return the warm-up and training script 190 * @throws IOException if the script cannot be read 191 */ 192 static String readScript() throws IOException { 193 try (InputStream in = JdkAotCacheTraining.class.getResourceAsStream(SCRIPT_RESOURCE)) { 194 if (in == null) { 195 throw new IOException("Could not find " + SCRIPT_RESOURCE); 196 } 197 return new String(in.readAllBytes(), StandardCharsets.UTF_8); 198 } 199 } 200 201 static Map<String, String> trainingEnvironment(String[] imageEnvironment, JavaRuntime java, TrainingRun run, 202 List<String> trainingPaths) { 203 var environment = new LinkedHashMap<String, String>(); 204 var options = new ArrayList<String>(); 205 imageVariable(imageEnvironment, JDK_JAVA_OPTIONS).ifPresent(options::add); 206 options.add("-XX:AOTCacheOutput=" + CONTAINER_CACHE_FILE); 207 options.add(NO_PERF_DATA); 208 options.addAll(run.systemProperties(trainingPaths)); 209 environment.put(JDK_JAVA_OPTIONS, String.join(" ", options)); 210 if (java.compatibleOopCompression()) { 211 // JDK 27 and later: lets the cache work with another compressed oops encoding than the training one 212 var aotOptions = new ArrayList<String>(); 213 imageVariable(imageEnvironment, JDK_AOT_VM_OPTIONS).ifPresent(aotOptions::add); 214 aotOptions.add("-XX:+UnlockDiagnosticVMOptions"); 215 aotOptions.add("-XX:+" + COMPATIBLE_OOP_COMPRESSION_FLAG); 216 environment.put(JDK_AOT_VM_OPTIONS, String.join(" ", aotOptions)); 217 } 218 return environment; 219 } 220 221 static Optional<Integer> firstTcpPort(ExposedPort[] exposedPorts) { 222 if (exposedPorts == null) { 223 return Optional.empty(); 224 } 225 return Arrays.stream(exposedPorts) 226 .filter(Objects::nonNull) 227 .filter(port -> port.getProtocol() == null || port.getProtocol() == InternetProtocol.TCP) 228 .map(ExposedPort::getPort) 229 .findFirst(); 230 } 231 232 private static Optional<String> imageVariable(String[] imageEnvironment, String name) { 233 if (imageEnvironment == null) { 234 return Optional.empty(); 235 } 236 String prefix = name + "="; 237 return Arrays.stream(imageEnvironment) 238 .filter(variable -> variable != null && variable.startsWith(prefix)) 239 .map(variable -> variable.substring(prefix.length())) 240 .filter(value -> !value.isBlank()) 241 .reduce((first, second) -> second); 242 } 243 244 private JavaRuntime probeJava(String imageId) throws MojoExecutionException { 245 DockerService.ContainerOutput output; 246 try { 247 output = dockerService.runAndCaptureOutput(imageId, timeoutSeconds, 248 List.of("java", "-XX:+UnlockDiagnosticVMOptions", "-XX:+PrintFlagsFinal", "-version")); 249 } catch (IOException e) { 250 throw new MojoExecutionException("JDK AOT cache training failed: could not run java -version in the training image: " 251 + e.getMessage(), e); 252 } 253 Optional<JavaRuntime> java = JavaRuntime.parse(output.output()); 254 if (output.exitCode() != 0 || java.isEmpty()) { 255 throw new MojoExecutionException("JDK AOT cache training failed: java -version exited with status " 256 + output.exitCode() + " in the training image: " + lastLines(output.output())); 257 } 258 if (java.get().majorVersion() < MINIMUM_JAVA_VERSION) { 259 throw new MojoExecutionException("micronaut.docker.jdkAotCache needs Java " + MINIMUM_JAVA_VERSION 260 + " or later in the image, but the base image runs Java " + java.get().majorVersion() 261 + ". Use a base image with Java " + MINIMUM_JAVA_VERSION + " or later."); 262 } 263 log.info("JDK AOT cache: the training image runs Java " + java.get().majorVersion()); 264 return java.get(); 265 } 266 267 private int httpPort(InspectImageResponse image) throws MojoExecutionException { 268 return firstTcpPort(image.getConfig() == null ? null : image.getConfig().getExposedPorts()) 269 .orElseThrow(() -> new MojoExecutionException("micronaut.docker.jdkAotCache needs the HTTP port of the " 270 + "application to warm it up, but the image exposes no port because the server port is resolved " 271 + "dynamically. Expose the port with the Jib container.ports configuration.")); 272 } 273 274 private void warmUp(String containerId, int port) throws IOException, MojoExecutionException { 275 var command = new ArrayList<>(List.of("bash", "-c", readScript(), "training.sh", "warm-up", 276 String.valueOf(port), String.valueOf(timeoutSeconds))); 277 command.addAll(trainingPaths); 278 var output = new ArrayList<String>(); 279 int exitCode = dockerService.execInContainer(containerId, (trainingPaths.size() + 1) * timeoutSeconds, line -> { 280 output.add(line); 281 log.info(line); 282 }, command.toArray(String[]::new)); 283 if (exitCode != 0) { 284 dockerService.logContainerOutput(containerId); 285 if (exitCode == COMMAND_NOT_EXECUTABLE || exitCode == COMMAND_NOT_FOUND) { 286 throw new MojoExecutionException("JDK AOT cache training failed: the warm-up needs bash in the image, " 287 + "which it could not run (status " + exitCode + "). Use a base image with bash, or a Micronaut " 288 + "version with the training-run switch."); 289 } 290 throw new MojoExecutionException("JDK AOT cache training failed: " 291 + (output.isEmpty() ? "the warm-up exited with status " + exitCode : output.get(output.size() - 1))); 292 } 293 } 294 295 private void copyCache(String containerId, Path cacheFile) throws IOException, MojoExecutionException { 296 try { 297 dockerService.copyFileFromContainer(containerId, CONTAINER_CACHE_FILE, cacheFile); 298 } catch (IOException e) { 299 dockerService.logContainerOutput(containerId); 300 throw new MojoExecutionException("JDK AOT cache training failed: the training run did not write the cache (" 301 + e.getMessage() + ")", e); 302 } 303 long size = Files.size(cacheFile); 304 if (size == 0) { 305 dockerService.logContainerOutput(containerId); 306 throw new MojoExecutionException("JDK AOT cache training failed: the training run wrote an empty cache"); 307 } 308 // Decimal megabytes, as Docker gives the size of the image layer that holds the cache 309 log.info("JDK AOT cache: trained " + cacheFile + " (" + String.format(Locale.ROOT, "%.1f MB", size / 1e6) 310 + ")"); 311 } 312 313 private static String lastLines(String output) { 314 List<String> lines = output.lines().filter(line -> !line.isBlank()).toList(); 315 return String.join(System.lineSeparator(), lines.subList(Math.max(0, lines.size() - 3), lines.size())); 316 } 317 318 /** 319 * The Java runtime of the training image. 320 * 321 * @param majorVersion the Java feature version 322 * @param compatibleOopCompression whether the JVM has the {@code AOTCompatibleOopCompression} diagnostic flag 323 */ 324 record JavaRuntime(int majorVersion, boolean compatibleOopCompression) { 325 326 /** 327 * @param output the output of {@code java -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal -version} 328 * @return the Java runtime, if the output has a version line with a valid version number 329 */ 330 static Optional<JavaRuntime> parse(String output) { 331 Matcher matcher = JAVA_VERSION.matcher(output); 332 if (!matcher.find()) { 333 return Optional.empty(); 334 } 335 try { 336 int major = Integer.parseInt(matcher.group(1)); 337 if (major == 1 && matcher.group(2) != null) { 338 major = Integer.parseInt(matcher.group(2)); 339 } 340 return Optional.of(new JavaRuntime(major, COMPATIBLE_OOP_COMPRESSION.matcher(output).find())); 341 } catch (NumberFormatException _) { 342 // a version number too large for an int 343 return Optional.empty(); 344 } 345 } 346 } 347}