Bootstrapping the System, Part 3: Making It Run (Chapter 13)
DEV Community

Bootstrapping the System, Part 3: Making It Run (Chapter 13)

Parts 1 and 2 left the application with every bean it needs and a correct home for every setting it reads. What neither of them touched is the process.

A process has properties that no properties file decides. How many requests it is allowed to hold at once, which is a concurrency model rather than a number. How a container's environment becomes something a JVM will accept, given that nothing hands a properties file to a container. What a log line looks like and where it lands, which no amount of logging.level configuration answers. And what the thing can tell you about itself at three in the morning, when the only question that matters is which version is running.

Those four are this part, and it ends with an image and a compose file, so that from here on the whole project starts with one command.

Virtual threads change what the pool means

The first of them is a single line that reaches further than anything else in the file. It is this one:

spring.threads.virtual.enabled = true

Boot responds by running the servlet container's request handling on virtual threads, so every inbound request gets its own, and the asynchronous machinery inside Spring follows. The change is small to write and large in what it removes, because the thread pool that used to sit in front of everything was doing a job nobody had asked it to do.

A classic Tomcat serves requests from a fixed pool, two hundred threads by default. A request that blocks on a database call holds one of those two hundred for the duration, which means that two hundred was never a thread count so much as a concurrency limit, and the limit was enforced in the one place in the stack that knew nothing about what the requests were doing. Exceed it and requests queue in the connector, which is admission control by accident: nobody chose two hundred, nobody tuned it against the database, and the number is in a different file from everything it affects.

With virtual threads there is effectively no such ceiling, and the consequence is that the first real limit a request meets is the one in Part 2's datasource block. Ten thousand concurrent requests become ten thousand virtual threads, cheaply, and all ten thousand of them arrive at Hikari asking for one of twenty-five connections. Twenty-five proceed and the rest wait.

This is the same backpressure as before, relocated to somewhere that actually understands the constraint, which is the right place for it, and it makes connection-timeout=5000 a far more consequential line than it looks. Five seconds is now the application's admission control: a request that cannot get a connection within it fails, visibly, with something the handler from Chapter 11 can turn into a response, rather than sitting in a queue nobody is measuring.

Enabling this two years ago would have been a worse idea than it is now, and the reason is worth knowing because it is the sort of thing that silently undoes the whole feature. A virtual thread that blocked inside a synchronized block used to pin itself to its carrier platform thread, so an application with a synchronized hot path quietly reverted to a small thread pool under load, which is the opposite of what the configuration appeared to request. JDK 24 removed that behaviour, and on the JDK 25 this project targets, a virtual thread blocking on a monitor unmounts like any other blocking operation.

What remains is narrow: native frames can still pin, through a native method or the Foreign Function and Memory API calling back into blocking Java. Nothing in this codebase does either.

What does not change is anything about the transaction model, which is worth stating because it is the first thing people assume breaks. A virtual thread is a thread. @Transactional binds its entity manager to the current thread exactly as before, the ThreadLocal machinery underneath it works unchanged, and the service layer cannot tell the difference. The one thing to be careful with is a ThreadLocal used as a cache rather than as context: one that held two hundred copies of something under the old model will hold as many copies as there are concurrent requests under this one. There is no such cache here, which is the only reason this is a footnote rather than a section of its own.

From compose to the JVM

In a container, nothing passes a properties file. The entrypoint script does the translation:

#!/bin/sh
set -e
for var in DB_URL DB_USER DB_PASSWORD ACTIVE_PROFILE ; do
eval "value= \$ $var "
if [ -z " $value " ] ; then
echo " $( date + "%T" ) : FATAL: $var is not set"
exit 1
fi
done
DEBUG_OPTS = ""
if [ " ${ DEBUG :- false } " = "true" ] ; then
DEBUG_OPTS = "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8787"
fi
# Strip carriage returns: .env files edited on Windows carry them,
# and a trailing \r makes the JVM reject the last option.
JAVA_OPTS = $( printf '%s' " ${ JAVA_OPTS :-} " | tr -d '\r' )
echo " $( date + "%T" ) : ===== Environment ====="
echo " $( date + "%T" ) : ACTIVE_PROFILE= $ACTIVE_PROFILE "
echo " $( date + "%T" ) : DB_URL= $DB_URL "
echo " $( date + "%T" ) : DB_USER= $DB_USER "
echo " $( date + "%T" ) : ======================="
cd /runtime
# $DEBUG_OPTS and $JAVA_OPTS are unquoted: word splitting is how
# whitespace-separated JVM options arrive at java as separate arguments.
# Options whose values contain spaces belong in JAVA_TOOL_OPTIONS instead.
# exec allows Java to receive container shutdown signals directly.
exec java $DEBUG_OPTS $JAVA_OPTS \
"-Dspring.profiles.active= $ACTIVE_PROFILE " \
"-Dspring.datasource.url= $DB_URL " \
"-Dspring.datasource.username= $DB_USER " \
"-Dspring.datasource.password= $DB_PASSWORD " \
-Ddebug = false \
-jar " ${ JAR_LOCATION :?JAR_LOCATION must be set } "

Spring Boot would have accepted SPRING_DATASOURCE_URL directly, through relaxed binding, and this script could have been one line. Translating by hand buys two things.

The first is the echo block. When a container will not start and the question is whether the variable arrived at all, this is the first thing in the log. With relaxed binding there is no such point: either Spring complains about something downstream, or you go digging through docker inspect.

The second is that DB_URL is a name this project owns. Compose files, deployment manifests and whatever CI does all refer to it, and none of them know that there is a Spring application on the other side. SPRING_DATASOURCE_URL leaks the framework into the orchestration, and the day the framework changes, every one of those files changes with it.

The cost is that the script needs maintaining, and a new setting has to be added to it rather than working automatically. That is fine while the list is short, and the list staying short is itself useful pressure.

The explicit check at the top is there because an unset variable produces -Dspring.datasource.password= and an attempt to connect with an empty password, which fails much further along and much less clearly. exec makes the JVM process 1 in the container, so signals from Docker reach it directly rather than being swallowed by a shell that would have stayed between them. -Ddebug=false is not redundant either. Spring reads the debug property, and if it is true, every autoconfiguration logger goes to DEBUG regardless of what logback.xml says. Setting it explicitly means nothing in the environment can turn the log into a wall of Spring internals by accident.

Three smaller things in that script are each a bug I would otherwise have hit twice. The debug agent is attached only when DEBUG is true, which keeps the decision in the environment rather than in the image, and makes a remote debugger something you turn on for a container instead of something you rebuild for. JAVA_OPTS is piped through tr -d '\r' because a .env file edited on Windows carries carriage returns and a trailing one makes the JVM reject the last option with a message about a flag that looks perfectly spelled. And $DEBUG_OPTS $JAVA_OPTS are the only unquoted expansions in the file: word splitting is how a whitespace-separated list of JVM options arrives at java as separate arguments, which is also why an option whose value contains spaces belongs in JAVA_TOOL_OPTIONS rather than here. The comments saying so are in the script because this is exactly the kind of line somebody tidies up by adding quotes.

${JAR_LOCATION:?JAR_LOCATION must be set} is the same instinct as the loop above it, applied to the one variable the loop cannot check, because it comes from the image rather than from the environment.

Appenders in XML, levels in properties

Logging is configured in two files, and the division between them is not arbitrary.

<configuration>
<appender name= "console" class= "ch.qos.logback.core.ConsoleAppender" >
<encoder class= "ch.qos.logback.classic.encoder.PatternLayoutEncoder" >
<pattern> %d{HH:mm:ss.SSS} [%thread] %highlight(%-5level) %cyan(%logger{36}) - %msg%n </pattern>
</encoder>
</appender>
<appender name= "file" class= "ch.qos.logback.core.rolling.RollingFileAppender" >
<file> ${LOG_DIR:-.}/advanced.log </file>
<append> true </append>
<encoder class= "ch.qos.logback.classic.encoder.PatternLayoutEncoder" >
<pattern> %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n </pattern>
</encoder>
<rollingPolicy class= "ch.qos.logback.core.rolling.TimeBasedRollingPolicy" >
<fileNamePattern> ${LOG_DIR:-.}/%d{yyyy-MM, aux}/advanced-%d{yyyy-MM-dd}.log.gz </fileNamePattern>
</rollingPolicy>
</appender>
<root level= "INFO" >
<appender-ref ref= "console" />
<appender-ref ref= "file" />
</root>
</configuration>

logback.xml holds structure: which appenders exist, what a line looks like, where the file goes and when it rolls. None of that differs between a laptop and production, and all of it is awkward to express as flat properties, which is exactly the case where a nested format earns its keep.

The levels are not here. Every logging.level.* is a property, because a level is a statement about an environment, and an environment is what profiles are for. That boundary is easy to state and easy to violate, and the reference project I lifted this file from violated it: a committed <logger name="..." level="DEBUG" /> for the application's own package. It works on a laptop, it is invisible in review, and it ships DEBUG to production while application-dev.properties sits there looking authoritative about log levels it no longer controls. Two files disagreeing about the same setting is worse than either of them being wrong, because the losing one still reads as true.

The console pattern is coloured and the file pattern is not. Highlight escape codes are useful in a terminal and are literal bytes in a file, where they turn every grep into a question about whether the match failed or the colour code got in the way. Same pattern otherwise, so a line copied out of one reads identically in the other.

The log file's directory comes from LOG_DIR with the working directory as its fallback, and that indirection exists because of a bug in the file I started from. That project wrote to ./app.log from logback and told the actuator its log file was at /runtime/logs/app.log, so the logfile endpoint returned a 404 for a file that existed somewhere else entirely, and had done for long enough that nobody expected the endpoint to work. One variable, read by both, is the whole fix: logback writes there and management.endpoint.logfile.external-file points at the same place.

An application that knows which commit it is

The first question anyone asks a service that is behaving strangely is which version of itself it is running, and the answer is usually a guess based on when it was last deployed. Making the application answer it costs one resource file and one bean.

Maven writes the file at build time by filtering a template:

buildJdk = ${java.version}
buildJvm = ${java.vm.vendor} ${java.vm.name} ${java.vm.version}
buildOs = ${os.name} ${os.version} (${os.arch})
buildTime = ${buildTime}
builtBy = ${user.name}
implementationBranch = ${scmBranch}
implementationRevision = ${scmRevision}
implementationTitle = ${project.artifactId}
implementationVendor = ${project.organization.name}
implementationVendorId = ${project.groupId}
implementationVersion = ${project.version}
specificationTitle = ${project.name}

Those placeholders are three different kinds of thing, which matters when one of them silently stays a placeholder. project.* comes from the POM. java.version and user.name are JVM system properties that the resources plugin resolves while it runs, so they describe the machine that built the jar rather than the machine running it, which is the point. scmBranch, scmRevision and buildTime all come from buildnumber-maven-plugin, under names its configuration chooses rather than names Maven provides, which is why timestampPropertyName is set to buildTime and buildNumberPropertyName to scmRevision.

None of the three exists unless the plugin's create goal has already run, so it is bound to generate-resources, one phase before the resources plugin reads the template. Get that wrong and the file ships with ${scmRevision} in it as a literal string, which is the worst outcome available: an endpoint that answers confidently with nothing.

There is one hole in that - revisionOnScmFailure is set to 0, so a build from an exported copy with no git metadata reports a revision of zero instead of failing. That is the same failure the paragraph above warns about, chosen rather than stumbled into: a build that is not from a checkout has no answer to give, and a conspicuous zero is better than a hash that happens to parse.

Exposing it is a MapInfoContributor:

public class BuildInfoContributor extends MapInfoContributor {
private static final String BUILD_PROPERTIES = "/build.properties" ;
public BuildInfoContributor () throws IOException {
super ( readBuildProperties ());
}
private static Map < String , Object > readBuildProperties () throws IOException {
try ( final InputStream is = BuildInfoContributor . c
Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.