Backend Development
Docker Exec and Maven AppAssembler
Docker is a powerful tool for defining, building, deploying and running software in containers for self-contained distribution and isolation purposes. AppAssembler is a Maven plugin that is primarily used for generating start scripts for Java or other JVM applications. Docker and AppAssembler are great tools to include in your build/deployment toolkit. In this post, I am going to discuss some details regarding how we integrate Docker and Java services using Maven’s AppAssembler plugin at Qualtrics.
Launching your Application
There are two ways to launch your application in Docker; either via the CMD
or the ENTRYPOINT
instruction in your Dockerfile. These instructions tell Docker what to do when your image is launched using docker run. When you ask Docker to shut down your application using docker stop, it first sends SIGTERM
to allow the application to exit gracefully. After a configurable amount of time (you can specify the interval with -t
or --timeout
in seconds), Docker will forcefully shut down your process with SIGKILL
. Therefore, it is important that the SIGTERM
signal reach your application so it can perform a graceful shutdown. The exact form of your CMD
or ENTRYPOINT
statement in your Dockerfile determines whether you automatically receive the SIGTERM
signal or not.
CMD |
ENTRYPOINT |
|
Process does not receive signals | CMD top |
ENTRYPOINT top |
Process receives signals | CMD ["top"] |
ENTRYPOINT ["top"] |
The “bad” versions above launch your application by wrapping it in a shell; by default a shell process does not pass signals to its children. The “good” versions exec your application, making it the root process and thus able receive signals directly from Docker. The exec command replaces the current running process with the specified process, as opposed to forking which creates a new child process. If you are unsure what your application image is doing, simply shell into a running container and run ps
:
Process does not receive signals:
$ docker exec -it efbc20916f74 /bin/sh / # ps PID USER TIME COMMAND 1 root 0:00 /bin/sh -c top 7 root 0:00 top 8 root 0:00 /bin/sh 17 root 0:00 ps
Process receives signals:
$ docker exec -it 577a9dd09e33 /bin/sh / # ps PID USER TIME COMMAND 1 root 0:00 top 6 root 0:00 /bin/sh 13 root 0:00 ps
Notice that in the first example a shell process (/bin/sh
) is PID 1 while in the second (top
) is PID 1. The extra shell processes (PID 8 and 6 respectively) are the interactive shells from which I ran ps
(PID 17 and 13 respectively). Docker sends signals to the root process and shell processes do not propagate signals to their children. Therefore, the top process in the first example does not receive signals while it does in the second example.
In the case of our Java applications, the CMD
or ENTRYPOINT
will exec the start script generated by AppAssembler. The start script will, in turn, exec Java, resulting in the Java process becoming the root process in the container. You can see this in the AppAssembler Unix daemon script template (full template found on Github):
exec "$JAVACMD" $JAVA_OPTS @EXTRA_JVM_ARGUMENTS@ \ -classpath "$CLASSPATH" \ -Dapp.name="@APP_NAME@" \ -Dapp.pid="$$" \ -Dapp.repo="$REPO" \ -Dapp.home="$BASEDIR" \ -Dbasedir="$BASEDIR" \ @MAINCLASS@ \ @APP_ARGUMENTS@"$@"@UNIX_BACKGROUND@
Passing Arguments to the Containers
We pass two types of arguments to our containers:
- Arguments for the JVM
- Arguments for the application Main class.
The application arguments are usually very minimal, as most of our configuration is picked up from files or other sources. However, when needed, application arguments can be easily specified. One option is to pass arguments to the start script from your CMD
or EXEC
via "$@
" in the template. For example:
ENTRYPOINT [ "/opt/my-app/bin/my-app", "arg1", "arg2" ]
Alternatively, or in addition to the above, you can specify the arguments in the AppAssembler plugin configuration in your pom.xml file in which case they are inserted into the template via @APP_ARGUMENTS@
. For example:
<plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>appassembler-maven-plugin</artifactId> <configuration> <programs> <program> <mainClass>com.example.Main</mainClass> <commandLineArguments> <commandLineArgument>arg1</commandLineArgument> <commandLineArgument>arg2</commandLineArgument> </commandLineArguments> </program> </programs> </configuration> </plugin>
Passing arguments to the JVM offers similar options. First, you probably noticed that the JAVA_OPTS
environment variable is expanded in the template immediately following the Java command. Defining this environment variable in your Dockerfile using the ENV
directive is the simplest way to pass arguments to the JVM when using AppAssembler’s launch script:
ENV JAVA_OPTS="-Xms128m -Xmx256m" > docker run my-container > docker run my-container -e "JAVA_OPTS=-Xms128m -Xmx512m"
As you can see, you can provide default JVM arguments and then override them from the command line. However, the JAVA_OPTS
environment variable must either be set explicitly within the Dockerfile or specified explicitly on the commandline. You cannot build the environment variable at runtime using a combination of build default and command line input. For example, the following is not possible:
ENV JAVA_XMS="128m" ENV JAVA_XMX="256m" ENV JAVA_OPTS="-Xms${JAVA_XMS} -Xmx${JAVA_XMX}" > docker run my-container -e "JAVA_XMX=512m"
The JAVA_OPTS
environment variable is defined at build time and is not reinterpreted when the container is run. This is important because the first common workaround is to interpret JAVA_OPTS
just before you run your application by modifying your CMD
or EXEC
directive appropriately. For example:
ENV JAVA_XMS="128m" ENV JAVA_XMX="256m" CMD JAVA_OPTS="-Xms${JAVA_XMS} -Xmx${JAVA_XMX}"; echo ${JAVA_OPTS} > docker run -it my-container -e "JAVA_XMX=512m" -Xms128m -Xmx512m
This solves the immediate problem, but you have just unwittingly introduced a shell as the root process in your Docker container. We can exec our way around this with something like this:
ENTRYPOINT ["sh", "-c"] CMD [ \ "JAVA_OPTS=\"-Xms${JAVA_XMS} -Xmx${JAVA_XMX}\" \ exec \ /opt/my-app/bin/my-app" ]
This works great! It provides flexibility to pass both JVM and application arguments while still allowing your application process to receive signals from Docker. Cosmetically, though, it leaves something to be desired, as your Dockerfile is using an inline shell exec and interpolating environment variables. After all, isn’t that why we generated a start script with AppAssembler?
Cosmetic Improvements
One way to improve the appearance is to specify the JVM arguments in the AppAssembler plugin configuration and insert them into the start script via @EXTRA_JVM_ARGUMENTS@
; for example:
<plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>appassembler-maven-plugin</artifactId> <configuration> <programs> <program> <mainClass>com.example.Main</mainClass> <commandLineArguments> <commandLineArgument>arg1</commandLineArgument> <commandLineArgument>arg2</commandLineArgument> </commandLineArguments> </program> </programs> </configuration> </plugin>
Then in your Dockerfile declare the defaults:
ENV JAVA_XMS="128m" ENV JAVA_XMX="256m" ENTRYPOINT ["/opt/my-app/bin/my-app"]
The start script rendered from the template now passes the desired JVM arguments while still allowing us to use the exec form of either CMD
or EXEC
in our Dockerfile. However, while this strategy provides a cleaner look to our Dockerfile, our Dockerfile no longer documents the environment variables our application accepts; these are now split across the Dockerfile and pom.xml (or generated start script).
Our Solution
In order to keep the shell foo out of our Dockerfile and to fully document our application in its Dockerfile, our solution was to customize the AppAssembler template. In particular, our template accepts both JVM and application arguments and performs variable interpolation on each argument. First, here is the custom portion of our template (the complete custom template can be found here):
# Separate arguments for java (JAVA_OPTS) from application (APP_OPTS) BEFORE_OPTS="" AFTER_OPTS="" SPLIT_FOUND= for ARG in "$@"; do VALUE= eval VALUE="${ARG}" if [ -z "${SPLIT_FOUND}" ]; then if [ "${VALUE}" = "--" ]; then SPLIT_FOUND="true" else BEFORE_OPTS="${BEFORE_OPTS} '${VALUE}'" fi else AFTER_OPTS="${AFTER_OPTS} '${VALUE}'" fi done if [ -z "${SPLIT_FOUND}" ]; then JAVA_OPTS="" APP_OPTS="${BEFORE_OPTS}" else JAVA_OPTS="${BEFORE_OPTS}" APP_OPTS="${AFTER_OPTS}" fi # Build a single array of arguments for exec ARGUMENTS="${JAVA_OPTS} \ @EXTRA_JVM_ARGUMENTS@ \ -classpath '${CLASSPATH}' \ -Dapp.name='@APP_NAME@' \ -Dapp.pid='$$' \ -Dapp.repo='${REPO}' \ -Dapp.home='${BASEDIR}' \ -Dbasedir='${BASEDIR}' \ @MAINCLASS@ \ @APP_ARGUMENTS@ \ ${APP_OPTS}" eval "set -- ${ARGUMENTS}" # Exec the java process replacing this shell exec "${JAVACMD}" "$@" @UNIX_BACKGROUND@
Next, this is how you configure AppAssembler to use the custom template assuming you have installed the custom template into your target directory. We use the Maven Dependency Plugin to unpack the template from a shared resource package.
<plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>appassembler-maven-plugin</artifactId> <configuration> <unixScriptTemplate>${project.basedir}/target/customTemplate</unixScriptTemplate> <programs> <program> <mainClass>com.example.Main</mainClass> </program> </programs> </configuration> </plugin>
Finally, this is how you now invoke the application from your Dockerfile:
ENV JAVA_XMS="128m" ENV JAVA_XMX="256m" ENTRYPOINT [ \ "/opt/my-app/bin/my-app", \ "-Xms${JAVA_XMS}", \ "-Xmx${JAVA_XMX}", \ "--" \ "HardCodedArg1", \ "HardCodedArg2" ]
To execute your application in this form run:
> docker run -it my-container -e "JAVA_XMX=512m" VariableArg1 VariableArg2
The custom AppAssembler template splits the JVM and application arguments based on a special -- marker argument. Then it passes all the arguments prior to the marker to the JVM and all the arguments following marker to the application. If no marker argument is found then all the arguments are passed to the application. The additional arguments passed to Docker run on the command are appended to the list passed to the application in your Dockerfile.
This custom template combined with the exec in the Dockerfile allows you to define all your environment variables and arguments in the Dockerfile including interpretation of any environment variables as part of both JVM and application arguments. This approach suited our goals the best, but if you take away anything from this post it’s that there are several ways to approach this problem. Hopefully, I’ve shown you something that helps you define a pattern that works best for your needs!