How to use Docker for Java development

The promise of using Docker during development is to deliver a consistent environment for testing across developer machines and the various environments (like QA and production) in use. The difficulty is that Docker containers introduce an extra layer of abstraction that developers must manage during coding.

Docker enables application code to be bundled with its system requirements definition in a cross-platform, runnable package. This is a graceful abstraction for solving a fundamental need in deploying and managing software runtimes, but it introduces an extra layer of indirection that must be dealt with when programmers are doing what they do: iteratively modifying and testing the internals of the software and its dependencies.

The last thing you want to do is slow down the dev cycle. A good discussion of these matters at a conceptual level is here.

Even if you or your team are not committed to using Docker across dev machines as a matter of process, there are several use cases for modifying and debugging code running inside a container. For example, a developer can use Docker to mimic a production environment to reproduce errors or other conditions. Also, the ability to remotely debug into a host running the Dockerized app can allow for hands-on troubleshooting of a running environment like QA.

We are going to stand up a simple Java app in a VM on Google Cloud Platform (GCP), Dockerize it, then remotely debug it and modify its code from Visual Studio Code running on a local host.

We’ll cover two essential needs: updating the running codebase without restarting the container and debugging into a running, containerized app. As an additional benefit, we’ll do this process on a remotely running container. This means you’ll have an approach for remotely debugging a service like a QA server, as well as a local development host.

Set up Java and Spring Boot

Step one is to go to the GCP console (and sign up for a free account if you don’t have one). Now go to the Compute Engine link, which will give you a list of VMs and click Create Instance.

If you select an N1 micro server, it will be in the free tier. However, Docker is a bit of a resource hog so I recommend using a general purpose E2 server (clocking in around $25 per month for 24/7 use). I named mine dev-1.

Go ahead and configure the network for this instance. Click the Network tab in the middle of the VM details and in the Network Tags field, add port8080 and port8000.

Now go to the left-hand menu and open VPC Networks -> Firewall. Create two new rules (click the Create Firewall Rule button) to allow all source IPs (0.0.0.0/0) to access TCP port 8080, with label port8080, and TCP port 8000, with label port8000. With these in place, the new VM instance will allow traffic to the app server you will create on 8080 and to the default Java debug port of 8000.

SSH to the new server by clicking back to Computer Engine -> VM instances, finding the new instance (dev-1), and clicking the SSH button.

Now let’s set up Java. Type sudo apt-get update, followed by sudo apt-get install default-jdk. When that is done, java --version should return a value.

Next, install the Spring CLI via SDKMAN (an SDK manager) so we can use Initializr from the shell. Run the following commands:

sudo apt install zip
curl -s "https://get.sdkman.io" | bash
source "/home//.sdkman/bin/sdkman.sh"

Now sdk version should work.

Next install the Spring CLI tool with sdk install springboot. Now you can quickly create a new Spring Boot Java web app with the following command:

spring init --dependencies=web idg-java-docker

The new project will reside in /idg-java-docker. Go ahead and cd into that directory.

The Spring Boot app includes the mvnw script so you don’t need to install Maven manually. Spin up the app in dev mode by typing sudo ./mvnw spring-boot:run.

If you navigate to http://<your instance IP>:8080 in the browser (you can find the IP address in the list on the GCP console), you should now receive the Spring White Label Error page, because there are no routes mapped.

Map a URL route

Just add a quick-and-dirty endpoint for testing. Use vi src/main/java/com/example/javadocker/DemoApplication.java (or your editor of choice) to modify the main class to look like Listing 1.

Listing 1. Add an endpoint

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@RestController
public class DemoApplication
  @RequestMapping("/")
  public String home()
     return "Hello InfoWorld!";
 
  public static void main(String[] args)
    SpringApplication.run(DemoApplication.class, args);
 

Now you can stop Tomcat with Ctrl-c and rebuild/restart by typing ./mvnw spring-boot:run. If you navigate to the app in the browser, you’ll see the simple “Hello InfoWorld” response.

Dockerize the project

First install Docker as per the official Docker instructions for Debian. Type each of the following commands in turn:

sudo apt-get install apt-transport-https ca-certificates curl gnupg lsb-release
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update 
sudo apt-get install docker-ce docker-ce-cli containerd.io

Create a Dockerfile

There are several ways to create a Dockerfile, including using a Maven plug-in. For this project we’ll build our simple Dockerfile by hand to get a look at it. For a nice intro to Java and Docker, check out this InfoWorld article.

Use an editor to create a file called dockerfile and add the contents of Listing 2.

Listing 2. A basic Java/Spring Dockerfile

# syntax=docker/dockerfile:1

FROM openjdk:16-alpine3.13

WORKDIR /app

COPY .mvn/ .mvn
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline

COPY src ./src

CMD ["./mvnw", "spring-boot:run"]

We are ignoring groups and users for simplicity here, but in a real-world situation, you would need to deal with that.

This Dockerfile uses OpenJDK as a base layer, then we move to a /app working directory. Next we bring in all the Maven files and run Maven in offline mode. (This allows us to avoid re-downloading the dependencies later.) The Dockerfile then copies the app sources over, and runs the spring-boot:run command.

Note that we are driving towards a dev-enabled image, not a production one. You wouldn’t use spring-boot:run for production.

Stop the running app if it’s still up.

Let’s build and run this now. First run the docker build command:

sudo docker build --tag idg-java-docker

Wait for the build, then follow with docker run:

sudo docker run -d -p 8080:8080 idg-java-docker

This will build your Docker image and then start it in a new container. When you call the run command, it will spit back a UID, such as (in my case):

d98e4d19dab71fa69b2331485b70b5c87f20de864238e5798ad3aa8c5b576014

You can double check the app is running and available on port 8080 by visiting it with a browser again.

You can check the running containers with sudo docker ps. You should see the same UID running. Stop it with sudo docker kill. Note that you only have to type enough of the UID to be unique (similar to a Git check-in ID), so in my case sudo docker kill d98.

This Dockerfile is a reasonable beginning (users and layers would come next) to running the app, but pause for a moment and consider what you’d need to do to update the running application. To change even the simple greeting message you would need to make the code change, stop the running Docker container, build the image with docker build, and start the container with docker run.

How can we improve this situation?

Use Docker Compose

The answer is we’ll run Spring Boot with devtools with remote debug enabled, and expose the debug port in Docker. To manage this in a declarative way (instead of command-line arguments), we’ll use Docker Compose. Docker Compose is a powerful way to express how Docker runs, and it supports multiple targets (aka multi-stage builds) and external volume mounting.

The default config file is docker-compose.yml, which runs on top of the configuration found in the Dockerfile.

First install the Docker binary:

sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose

Then run:

sudo chmod +x /usr/local/bin/docker-compose

Now you can run:

docker-compose --version

Tip: If you ever need to explore inside a running container you can run one of the following commands (depending on the underlying OS in the image):

  • sudo docker exec -it 646 /bin/sh
  • sudo docker exec -it 646 /bin/bash
  • sudo docker exec -it 646 powershell

Now that Docker Compose is available, let’s create a config file for it, docker-compose.yml, as seen in Listing 3.

Listing 3. docker-compose.yml

version: '3.3'
services:
  idg-java-docker:
    build:
      context: .
    ports:
      - 8000:8000
      - 8080:8080
    environment:
      - SERVER_PORT=8080
    volumes:
      - ./:/app
    command: ./mvnw spring-boot:run -Dspring-boot.run.jvmArguments="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000"

The first key fact here is that both ports 8080 and 8000 are open. 8000 is the conventional Java debug port, and is referenced by the command string. The docker-compose command overrides the CMD definition in the Dockerfile. To reiterate, docker-compose runs atop the Dockerfile.

Type sudo docker-compose build --no-cache idg-java-docker to build the image.

Start the app with sudo docker-compose up. Now kill it with Ctrl-c.

Now run the container in the background with sudo docker-compose up -d for detached mode. Then you can shut it down with sudo docker-compose down.

Commit your new app with git init, git add ., git commit -m "initial".

Now visit GitHub.com and create a new repository. Follow the instructions to push the project:

git remote add origin https://github.com//.git
git branch -M main
git push -u origin main

Now open Visual Studio Code on your local system. (Or any remote debug-enabled Java IDE; for more info on running VS Code and Java check here. All modern IDEs will clone a repo directly from the GitHub repo clone address.) Do that now.

Now open the Java debug configuration for your IDE. In VS Code, this is the launch.json file. Create a configuration entry like that seen in Listing 4. Other IDEs (Eclipse, IntelliJ, etc.) will have similar launch config dialogs with the same fields for entry.

Listing 4. Debug configuration for IDE client


  "type": "java",
  "name": "Attach to Remote Program",
  "request": "attach",
  "hostName": "<The host name or ip address of remote debuggee>",
  "port": 8000
,

Plug in the IP address from your VM, then launch this config by going to Debug and run the “Attach to Remote Program” config.

Once the debugger is attached, you can modify the DemoApplication.java file (for instance, change the greeting message to “Hello InfoWorld!”) and save it. Now click the “Hot module swap” button (the lightning bolt icon). VS Code will update the running program.

Browse to the app in the browser again, and you’ll see it will reflect the change.

Now for the last trick. Set a breakpoint by double-clicking at line 13 in the IDE. Now visit the app again. The breakpoint will hit, the IDE will come up, and full debugging capabilities will be available.

There are other approaches to Dockerizing a dev flow, but the manner described in this article gives you code updating and debugging for both localhost and remote systems in a relatively straightforward setup.

Copyright © 2021 IDG Communications, Inc.