Reproducible Local Development with Dev Containers
Maintaining legacy software is a hefty job, beginning with the chore of getting it up and running locally. Many questions come to mind when starting the task, including:
- Which extra tools need to be installed?
- Where do these configuration files need to be placed?
- What magical incantation of environment variables need to be set?
- How do I get help with setting this up?
- Who knows the answers to these questions?
- When are they going to get back to me?!
For those new to the stack or the company, the process of transitioning from having nothing installed to running the team’s stack on their machine can take multiple days, depending on what kind of difficulties they encounter. The frustration heightens for seasoned employees transitioning between teams or projects.
This article aims to present an approach to alleviate the challenges associated with onboarding for legacy software maintenance. By capitalizing on a relatively new specification called Development Containers, this solution can reduce the time investment down to an hour or less.
Casting the vision
Developers use a variety of tools for software development work: SDKs, package managers, servers, git, and an IDE to tie it all together in one pane of glass. In the old world, you would have to spend a day just getting these supporting tools installed and configured correctly to allow you to start trying to get the actual code project running successfully.
When leveraging Dev Containers, the steps are drastically reduced in number and the speed and reliability of the steps goes up.
In the new world, there are only four steps to get running (and the first three are just first-time only):
- Install Docker Desktop
- Install an IDE capable of connecting to a remote development environment (Visual Studio Code has the best support for this today)
- Download your source code
- Two clicks to build your development environment in a container
That’s it; the project is 99.99% guaranteed to run successfully! Note which steps are not included in the list above:
- Don’t bother installing Java, C#, Python, or any other SDK or interpreter
- No need for Maven, NuGet, pip, or any other package manager
- Forget about Tomcat, .NET, or any other application server
- No need to figure out the perfect incantation of environment variables and application runtime args that you need
- No databases, message brokers, distributed caches, or other third-party technologies that your stack depends on.
Steps 3 and 4 above take care of all of these things automatically for you.
Prove it
If you’re interested in testing my claims, I invite you to pretend you’re a new recruit to the team and try getting our public example repository running on your machine following these more-fleshed-out instructions:
1. Install Docker Desktop
2. Install Visual Studio Code
3. Install the Visual Studio Code Dev Containers extension
4. Install Git and clone the devcontainer-java-example
- If you’re on Windows, we recommend you do this within WSL2 for disk-I/O performance reasons.
-wsl --install -d Ubuntu
+wslconfig /setdefault Ubuntu
in your terminal
- Install the WSL extension in Visual Studio Code too.
5. Open the repo folder in Visual Studio Code and run the Reopen in Container
action. You can do this in three different places
- There may be an automatic popup in the bottom-right prompting you with a button
- The
><
button in the bottom-left corner + menu-option in the top-center
- Search for it in the command palette
6. Separate configurations are provided for each environment that has the dependencies needed to support the application. Choose which you’d like to use, and the appropriate DevContainer will launch
- Fair warning, the first time you do this will take a little longer because of the need to download docker images that you likely don’t have present on your localhost. This step is snappy in subsequent attempts.
7. When you see port 8080 exposed, you’re done! Open up http://localhost:8080/ in your browser.
Peeling back the curtain
Let’s talk about how this magic works and help you get a similar setup in place in your projects. This part requires a little more technical knowledge; familiarity with Docker container concepts will help you here. All of the files described in this section should be placed in a folder called .devcontainer
at the root of your repository. For any details or files that are mentioned but not spelled out here, check out the example files in https://github.com/clearwater-analytics/devcontainer-java-example/tree/main/.devcontainer.
devcontainer.json
It all starts with the devcontainer.json
file. The specification is open-source and is hosted at https://containers.dev/implementors/json_reference/. Visual Studio Code (via the Dev Container extension) is hard-wired to sniff out this file as the entry point.
The
devcontainer.json
file contains any needed metadata and settings required to configurate a development container for a given well-defined tool and runtime stack. It can be used by tools and services that support the dev container spec to create a development environment that contains one or more development containers.
You can have any number of devcontainer.json
files in your repository. When Visual Studio Code detects this, it will prompt you to know which one to use when you ask it to reopen the folder in a dev container. The example repository leverages this fact to store one for each simulated environment that we want to provide development support for.
Here is a sample devcontainer.json
file that you can use as a launch point.
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/java
{
"name": "team-environment",
// Explicitly declaring which source-code folder to mount and where to mount it in the container
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached",
"workspaceFolder": "/workspace",
"build": {
// https://containers.dev/guide/dockerfile
"dockerfile": "../Dockerfile"
},
"features": {
// Ensures that Quarkus is installed in the container
"ghcr.io/devcontainers-contrib/features/quarkus-sdkman:2": {
"jdkVersion": "17"
}
},
"mounts": [
"source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind", // Binding the host docker socket to the container
"source=m2volume,target=/home/vscode/.m2/repository,type=volume" // Establishing a persistent volume for maven local repository downloads
],
// Docker-from-Docker recommendations
// Per https://github.com/microsoft/vscode-dev-containers/tree/main/containers/docker-from-docker
"overrideCommand": false,
"remoteUser": "vscode",
"runArgs": ["--init"],
// End of Docker-from-Docker recommendations
"containerEnv": {
// You can specify environment variables here, which will be available to the application via System.getenv()
"CUSTOM_PROP": "Team"
},
"postStartCommand": "./mvnw compile quarkus:dev",
"customizations": {
"vscode": {
"extensions": [
"vscjava.vscode-java-pack",
"redhat.vscode-quarkus",
"ms-azuretools.vscode-docker"
]
}
}
}
Both workspaceMount
and workspaceFolder
change the default behavior that uses the name of your local parent folder as the name of the container’s workspace folder. By picking an explicit workspace folder name in the container, it helps ensure that the other pieces of this solution will work out of the box for everyone.
The build
section specifies the location of the Dockerfile
that should be used to build the image for your dev container. We’ll explore that file in the next section. FYI, there is an args
key that is available in here which will pass custom arguments to the Dockerfile
.
The features
section allows you to include reusable build components so you don’t have to always bake certain tools into your Dockerfile. In this example, I’m using a feature to handle the installation of Quarkus. The full list of available features is listed at https://containers.dev/features
mounts
allow you to leverage Docker volumes and bind mounts to persist data that survives container rebuilds. This is perfect for caching all the files that your package manager downloads. We also use it here to bind the local/host docker socket to the container, which allows Docker-from-Docker scenarios to work (this can be useful for e.g., unit tests that leverage docker containers).
containerEnv
sets environment variables within the container. By leveraging these, we’re able to have a single Dockerfile that serves the needs of multiple devcontainer.json
files – one for each simulated environment that we want to provide development support for.
customizations
provides a space for IDEs to house their own custom settings. In this example, we use it to instruct VSCode to install certain extensions automatically in the container instance.
Dockerfile
The Dockerfile
provides additional instructions beyond the devcontainer.json
file to build the image that will produce your development containers.
Here is a sample Dockerfile
file that you can use as a launch point.
FROM mcr.microsoft.com/devcontainers/java:17-jdk-bookworm
################### Docker-from-Docker installation ###################
# Install required packages
RUN apt-get update \
&& apt-get install -y apt-transport-https ca-certificates curl gnupg lsb-release
# Import the Docker repository GPG key
RUN curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
# Set permissions on GPG key
RUN chmod a+r /usr/share/keyrings/docker-archive-keyring.gpg
# Add the Docker repository to the APT sources
RUN echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu jammy stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
# Update package lists and install Docker
RUN apt-get update \
&& apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \
&& rm -rf /var/lib/apt/lists/*
# https://github.com/microsoft/vscode-dev-containers/tree/main/containers/docker-from-docker
ARG NONROOT_USER=vscode
RUN echo -e "#!/bin/sh\n\
sudoIf() { if [ \"\$(id -u)\" -ne 0 ]; then sudo \"\$@\"; else \"\$@\"; fi }\n\
SOCKET_GID=\$(stat -c '%g' /var/run/docker.sock) \n\
if [ \"${SOCKET_GID}\" != '0' ]; then\n\
if [ \"\$(cat /etc/group | grep :\${SOCKET_GID}:)\" = '' ]; then sudoIf groupadd --gid \${SOCKET_GID} docker-host; fi \n\
if [ \"\$(id ${NONROOT_USER} | grep -E \"groups=.*(=|,)\${SOCKET_GID}\(\")\" = '' ]; then sudoIf usermod -aG \${SOCKET_GID} ${NONROOT_USER}; fi\n\
fi\n\
exec \"\$@\"" > /usr/local/share/docker-init.sh \
&& chmod +x /usr/local/share/docker-init.sh
############## End Docker-from-Docker section #############
################### Maven configuration ##################
# Establish .m2 folder and set ownership
RUN mkdir -p /home/vscode/.m2/repository
RUN chown -R vscode:vscode /home/vscode/.m2
################### End Maven section ####################
######### More Docker-from-Docker recommendations ########
# https://github.com/microsoft/vscode-dev-containers/tree/main/containers/docker-from-docker
# VS Code overrides ENTRYPOINT and CMD when executing `docker run` by default.
# Setting the ENTRYPOINT to docker-init.sh will configure non-root access to
# the Docker socket if "overrideCommand": false is set in devcontainer.json.
# The script will also execute CMD if you need to alter startup behaviors.
ENTRYPOINT [ "/usr/local/share/docker-init.sh" ]
CMD [ "sleep", "infinity" ]
######### End Docker-from-Docker recommendations #########
The image specified in the FROM
clause should ideally base off of one of the many mcr.microsoft.com/devcontainers
images. The full list of options are at https://mcr.microsoft.com/en-us/catalog?search=devcontainer. For those not familiar, the “bookworm” part of “java:17-jdk-bookworm” clarifies which version of Ubuntu should be running in the container.
Handling more complexity with Docker Compose
Despite this example only illustrating development in Quarkus, it is decently general-purpose and fairly straightforward to extend or transform to meet your needs.
There is one interesting major feature that is worth mentioning, in case you have more complex local-development requirements: the build
section in the devcontainer.json
file supports specifying a docker-compose.yml
file as well. This will allow for the ability to codify how to spin up multiple networked containers with one click. Here are some inspiring use cases where this would prove useful:
- Needing to spin up multiple separate web services at the same time for your local development.
- Hooking up a database container, message queue broker server container, distributed cache container, or any other third-party tool container to test the integration between them and your application
Read through https://containers.dev/guide/dockerfile to get started!
About the Author
Jeff Powell is a Software Development Manager at Clearwater Analytics with nearly a decade of industry experience. Jeff’s proficiency stretches beyond mere software coding to the elements that surround the development process, including UX design, software and network architecture design, project management, communication, and effective team leadership. To get in touch with Jeff or to follow his latest projects, visit his website at https://jeffpowell.dev.