Alpaquita Linux: Debugging apps running in Docker with JetBrains and VSCode: Java, C, and Python
1. Overview
If you have a setup to deploy your application using containers, debugging your application by connecting to debugging server that is running in your development docker container has the following advantages:
-
Environment consistency: minimize environment-related bugs.
-
Isolation of dependencies: less clutter in your local machine, avoid version conflicts.
-
Easy cleanup and reset: restart, delete or stop your container as necessary.
The goal of this guide is to display how to remotely debug an application written in Java, Python, or C running inside a docker container. We will use CLion, IntelliJ IDEA, and VSCode to demonstrate this. We do not cover PyCharm in the guide, because at the time of writing, the remote debugging feature is limited to the paid version of their software.
Each section includes a sample application for those without an existing project who still want to follow along.
Although this document covers remote debugging with three popular languages using three IDEs, the key patterns in remote debugging are the same for any environment:
-
Find out whether your IDE can connect to a debugging server.
-
Learn which debugging tool your IDE uses. For example, PyCharm uses
pydevd-pycharm
and VSCode usesdebugpy
for Python by default. -
Adjust or create a Docker image that exposes the debugging server over a network.
-
Configure your IDE to use the debugging server exposed by a Docker container for debugging.
After studying the examples in this guide, you should be able to adapt them to
your own needs. For example, you can use delve
debugger to debug Go
applications using GoLand. And as an alternative to IntelliJ IDEA, use Eclipse
for Java debugging and so forth.
The next parts demonstrate how to use popular IDEs to debug applications written in Java, Python, or C.
2. Java
We will use a sample Spring application, Spring PetClinic, to explain how to remotely debug Java docker applications. Spring PetClinic was dockerized using Liberica as a base image.
We will also use IntelliJ IDEA, as it is popular among Java developers.
Prerequisites
-
Docker installed and running
-
Installed IntelliJ IDEA
Dockerizing the PetClinic application
Note:
|
If you already have a containerized Java application, you can skip this step
and replace the petclinic-liberica with your application image name.
|
Application overview
Spring PetClinic is a CRUD application that uses technologies such as Spring Boot, Thymeleaf, Bootstrap, in-memory database H2.
Overall, it is a well-known and suitable application for use as an example.
Clone the PetClinic application from GitHub
Use the following command to clone the sample application.
$ git clone https://github.com/spring-projects/spring-petclinic.git
Dockerfile
Place the contents of the Dockerfile below into a file Dockerfile
inside the
spring-petclinic
directory we have just cloned.
# Create a stage for resolving and downloading dependencies.
FROM bellsoft/liberica-openjdk-alpine:21 AS deps
# Download dependencies as a separate step to take advantage of Docker's
# caching.
WORKDIR /build
COPY --chmod=0755 mvnw mvnw
COPY .mvn/ .mvn/
RUN --mount=type=bind,source=pom.xml,target=pom.xml \
--mount=type=cache,target=/root/.m2 \
./mvnw dependency:go-offline -DskipTests
# Create a stage for building the application based on the stage with
# downloaded dependencies.
FROM deps AS package
WORKDIR /build
COPY ./src src/
RUN --mount=type=bind,source=pom.xml,target=pom.xml \
--mount=type=cache,target=/root/.m2 \
./mvnw package -DskipTests && \
mv target/$(./mvnw help:evaluate -Dexpression=project.artifactId \
-q -DforceStdout)-$(./mvnw help:evaluate -Dexpression=project.version \
-q -DforceStdout).jar \
target/app.jar
# Create a stage for extracting the application into separate layers.
FROM package AS extract
WORKDIR /build
RUN java -Djarmode=layertools -jar target/app.jar \
extract --destination target/extracted
# Create a new stage for running the application that contains the minimal
# runtime dependencies. We use liberica-openjre-alpine:21
# because there is no need to use a full blown JDK just to run the app.
FROM bellsoft/liberica-openjre-alpine:21 AS final
# Create a non-privileged user that the app will run under.
ARG UID=10001
RUN adduser \
--disabled-password \
--gecos "" \
--home "/nonexistent" \
--shell "/sbin/nologin" \
--no-create-home \
--uid "${UID}" \
appuser
USER appuser
# Copy the executable from the "package" stage.
COPY --from=extract build/target/extracted/dependencies/ ./
COPY --from=extract build/target/extracted/spring-boot-loader/ ./
COPY --from=extract build/target/extracted/snapshot-dependencies/ ./
COPY --from=extract build/target/extracted/application/ ./
# Expose 8080, as we will use this as the application port.
EXPOSE 8080
# Specifying the command that will executed when the container starts.
ENTRYPOINT [ "java", "org.springframework.boot.loader.launch.JarLauncher" ]
Building the petclinic-liberica
image
Use the following commands to build the petclinic-liberica
image.
$ cd spring-petclinic
$ docker build --tag petclinic-liberica .
In this section we have created the example production image that we want to debug.
Creating a debugging image
Overview
To debug the PetClinic application, we use Java Debug Wire Protocol (JDWP), which is the protocol used for communication between a debugger (the IDE) and the Java virtual machine (the PetClinic app). It helps to perform debugging tasks such as setting breakpoints, stepping through code, and inspecting variables in IntelliJ IDEA.
Liberica and many other docker java images come with JDWP; therefore, you do not need to install other tools. We will instruct JVM to use JDWP using the command line arguments.
JAVA_TOOL_OPTIONS
environment variable can be used to specify command line options for java
launcher. The content of the JAVA_TOOL_OPTIONS
environment variable is a list
of arguments separated by space.
We can append a command line option required to start java debugging server to
JAVA_TOOL_OPTIONS
.
The option needed by java launcher is the following:
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005
Tip
|
If you want to use a port other than 5005 for debugging server, change
adress=5005 to address=<your_port> .
|
Tip
|
Change suspend=n to suspend=y if you want the app to be suspended
immediately before the main class is loaded. The app will wait until you
connect to the java debugging server.
|
Edit Dockerfile.debug
Copy the contents of the Dockerfile above into a file Dockerfile.debug
in the
spring-petclinic
directory.
Important:
|
This section assumes you are using java launcher for starting your app. If you
use another method to run your application inside a container, the approach
described here may not work. In that case, look for an alternative way to add
jdwp capabilities to your launcher.
|
# Change this to your production image
FROM petclinic-liberica
# Add command line option for enabling JDWP debugging server
ENV JAVA_TOOL_OPTIONS="${JAVA_TOOL_OPTIONS} \
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
Build and run debugging image
Use the following commands to build the debugging image.
Remember to expose the port that was specified, in this case 5005
, defined in
the JAVA_TOOL_OPTIONS
environment variable.
$ docker build --tag petclinic-liberica-debug -f Dockerfile.debug .
$ docker run -p8080:8080 -p5005:5005 petclinic-liberica-debug
Picked up JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:5005
Listening for transport dt_socket at address: 5005
...
As stated in the output, the application is listening for a debugging session on port 5005.
Remote debugging using IntelliJ IDEA
Before proceeding, ensure the debugging container is running and the Java debugging server is set up, waiting at port 5005.
Configure remote debugging
-
Open the previously cloned
spring-petclinic
directory using IntelliJ IDEA by clicking File > Open and then selecting the path tospring-petclinic
directory. Click OK. -
Click Open as Maven project and click Trust Project. You should be now in the
spring-petclinic
project. -
From the main menu, select Run > Edit Configurations.
-
Click "+" (plus) on the top left to add a new configuration and select Remote JVM Debug.
You can give this configuration a meaningful name, such as "Docker Pet Clinic Debugging".
Note that the default values for Host and Port are
localhost
and5005
respectively. Update these to match the host address and port configured in the previous steps if you changed the values. -
Click OK to save the configuration.
To ensure debugging is working as expected, let’s insert a breakpoint at the
processCreationForm
method of the OwnerController
class.
Let’s try to debug the application using the configuration we have created.
From the main menu, click Run > Debug 'Docker Pet Clinic Debugging'(or select the name assigned to the debug configuration earlier).
You should see a message in the debugging console that you are connected to
localhost:5005
or your specified port over the network.
processCreationForm
method is responsible for handling POST requests to the
endpoint /owners/new. It manages the creation of a new Owner entity, validates
the form input, and redirects users based on the result of the validation.
Let’s try to debug that method.
-
In your browser, go to http://localhost:8080 and click Add Owner on the Find Owners section.
-
Fill the form and click Add Owner.
Now the web page waits, because we have instructed the debugger to stop at the
processCreationForm
method in theOwnerController
class.We can see that the method first instantiates an owner object with the data we provided and then saves it to the
owners
which is a Spring Repository.We can step through the code, set other breakpoints, evaluate expressions, and more.
Note:See the debugging section of Intellij IDEA Documentation to get more information about debugging in IntelliJ IDEA. -
Click Continue to let the application continue normally.
Now we see that the application continues and finishes the POST request normally. Every time the application runs the code we marked with a breakpoint, it stops and waits for an input in IntelliJ IDEA, so we can debug it further.
3. Python
Prerequisites
-
Docker installed and running
-
Working VSCode with a Python extension installed
We will debug the FastAPI application running on uvicorn web server. It should be easy to adjust the setup to other web frameworks like Flask or Django.
We use debugpy
module since it is provided by
Python Debugger extension
and bundled with the
Python extension
from VSCode marketplace.
Dockerizing a FastAPI application
Note:
|
Skip this section if you already have a python image of your
application and replace the base image in Dockerfile.debug with your
application image name.
|
The final structure of the sample project should look like the following:
$ tree
.
├── Dockerfile
├── Dockerfile.debug
├── requirements.txt
└── src
└── main.py
2 directories, 4 files
Application: quadratic equation solver
We have a simple application that returns the roots of the following quadratic equation \$ax^2 + bx + c = 0\$.
$ curl -s \
--request GET \
--url 'http://localhost:8000/solve_quadratic?a=1&b=-8&c=15' \
| jq
{
"x1": 5.0,
"x2": 3.0
}
main.py
Create a directory src
and copy the contents of the following python code
into main.py
in the src
directory.
Note:
|
We have introduced a not-so-subtle bug in the logic where we calculate x1 and
x2. We should be dividing by (2 * a) instead of (2 * c) .
|
from fastapi import FastAPI, HTTPException
from math import sqrt
from typing import Dict
app = FastAPI()
@app.get("/solve_quadratic")
def solve_quadratic(a: float, b: float = 0, c: float = 0) -> Dict[str, float]:
if a == 0:
raise HTTPException(status_code=400,
detail="Coefficient 'a' cannot be zero.")
discriminant = b**2 - 4*a*c
if discriminant < 0:
raise HTTPException(status_code=400,
detail="No real solutions, discriminant is negative.")
# Bug introduced here: it should be (2 * a)
x1 = (-b + sqrt(discriminant)) / (2 * c)
x2 = (-b - sqrt(discriminant)) / (2 * c)
if x1 == x2:
return {"x": x1}
return {"x1": x1, "x2": x2}
Dockerfile
Create a file Dockerfile
with following contents.
FROM bellsoft/alpaquita-linux-python:3.12-musl
# Print log messages immediately instead of them being buffered
ENV PYTHONUNBUFFERED=1
WORKDIR /src
# Activate virtual environment
ENV VIRTUAL_ENV=/src/.venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
RUN python3 -m venv $VIRTUAL_ENV
# Install dependencies:
COPY requirements.txt .
RUN pip3 install -r requirements.txt
# Copy source code
COPY src/ .
# Start the uvicorn web server. 8000 is the default port
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0"]
requirements.txt
Put the following content into a file requirements.txt
fastapi
uvicorn
Build your image with the following command.
$ docker build --tag fastapi-app .
Building a debugging image
It is important that both local dev environment, where we debug the python
code, and the container, where the application is running, have the debugpy
module available.
We will install it in the debugging image and also instruct docker to run
debugpy
as a default command when the container starts.
Dockerfile.debug
-
Copy the contents of the following Dockerfile into a file
Dockerfile.debug
.Note:We used the default port 5678
fordebugpy
debugging server port. You can use any available port.# Base image FROM fastapi-app WORKDIR /src # Install debugpy for vscode remote debugging RUN pip3 install debugpy # Append debugpy to the uvicorn command so it will be executed in a debugging # mode CMD ["python", "-m", "debugpy", "--listen", "0.0.0.0:5678", \ "-m", "uvicorn", "main:app", "--host", "0.0.0.0"]
-
Build the debugging image with the following command.
$ docker build --tag fastapi-app-debug -f Dockerfile.debug .
-
Finally, run the debugging image.
$ docker run --rm -p8000:8000 -p5678:5678 fastapi-app-debug
Configuring VSCode for remote debugging
VSCode uses debugpy
python module for debugging remotely. We will install
Python Debugger for accessing the debugpy
module.
Also, launch.json
configuration file inside the .vscode
directory dictates
the behavior of the debugger in VSCode. We will be using this file to connect
to debugging session in the container.
Before continuing, make sure your debugging docker application is running.
-
Let’s open the source code directory of the FastAPI project.
-
Select the path for the parent directory of
main.py
from the previous steps. Click Yes, I trust the authors.
Install extensions
From activity bar, select extensions, and search for ms-python.debugpy
. Click
Install.
Create launch.json
-
launch.json
is necessary for configuring the debugger of VSCode. From activity bar, select Run and Debug. Click create a launch.json file. -
Select Python Debugger and select Remote Attach.
-
VSCode asks for an IP address of the debugging server. Keep the default option
localhost
. -
Either keep the default port number
5678
or change to your port in case you have used a different port. -
Verify that VSCode created a
launch.json
file and looks similar to this:{ "version": "0.2.0", "configurations": [ { "name": "Python Debugger: Remote Attach", "type": "debugpy", "request": "attach", "connect": { "host": "localhost", "port": 5678 }, "pathMappings": [ { "localRoot": "${workspaceFolder}", "remoteRoot": "." } ] } ] }
Start debugging
-
Create some breakpoints in the application.
-
Click Run > Start Debugging.
You should now see a Debug toolbar at the top center.
-
From the terminal, send a get request to the FastAPI application as follows.
$ curl -s \ --request GET \ --url 'http://localhost:8000/solve_quadratic?a=1&b=-8&c=15' \ | jq
You should see that the command "hangs". This is because we have set breakpoints in the
main.py
file and application freezes the execution for us at the breakpoints. -
Go to Run and Debug from the activity bar. Observe that we have received the parameters of the request:
a
,b
andc
in the variables section. -
Click Continue from the Debug toolbar to jump to the next breakpoint. Click Step over to execute one line of code. We now see the value of
discriminant
from the variables section. -
Step over until you reach the
x2 = …
. We see thatx1
andx2
are calculated incorrectly.
You may debug further or stop the debugging session by pressing the red square on the debug toolbar.
This concludes the guide for remote debugging in Python.
For more information about debugging in VSCode, visit the documentation section on the Visual Studio Code website.
4. C/C++
As an example in this part of the guide, we will use an application that returns a reversed version of the string received from the standard input. The application will be running inside an Alpaquita docker container. Then we will introduce a bug in the application and attempt to debug it remotely in CLion.
We will use gdbserver
, which is a lightweight debugging server for gdb
.
Prerequisites
-
Docker installed and running
-
Installed CLion
Dockerizing a C application
Upon completion of this section, you should have the following directory structure:
$ tree
.
├── Dockerfile
├── Makefile
└── src
└── main.c
Application: String reverser
We have a simple application that takes a string of characters from standard input, reverses it, and finally, prints the reversed string to the standard output.
Let’s provide the input "noel sees leon" to the program.
$ docker run -it reverser:latest
noel sees leon
Reversed string:
noel sees leon
main.c
#include <string.h>
#include <stdio.h>
/* reverse: reverse string s in place */
void reverse(char s[]) {
int n = strlen(s);
for (int i = 0,j = n - 1; i < j; i++, j--) {
char tmp = s[i];
s[i] = s[j];
s[j] = tmp;
}
}
/* main: get string from stdin, reverse and print it */
int main() {
char str[256];
fgets(str, sizeof(str), stdin);
reverse(str);
printf("Reversed string: %s\n", str);
}
Makefile
CC = gcc
SRC_DIR = src
BUILD_DIR := build
CFLAGS = -Wall -Wextra -O2
BIN = reverser
main: $(BUILD_DIR)/$(BIN)
$(BUILD_DIR)/$(BIN): $(SRC_DIR)/main.c
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) -o $@ $<
clean:
rm -rf $(BUILD_DIR)
.PHONY: main clean
Dockerfile
The following Dockerfile has two stages. The First stage is for compiling the code. The second stage is only for executing the binary, hence it is lightweight.
FROM bellsoft/alpaquita-linux-gcc:14.2-musl AS build
WORKDIR /build
# Copy src and Makefile
COPY src src
COPY Makefile .
# Compile the app
RUN make
# Use a lightweight base for final image
FROM bellsoft/alpaquita-linux-base:stream-musl
WORKDIR /app
# Follow best practices and use a non-root user
RUN adduser -D user
USER user
# Copy the binary
COPY --from=build /build/build/reverser .
# Execute the binary
ENTRYPOINT ["./reverser"]
Modified main.c
The main.c
above works fine. Let’s say we want to take the input from arguments, not from the standard input. While doing so, we will introduce a bug in the new main.c
.
#include <string.h>
#include <stdio.h>
/* reverse: reverse string s in place */
void reverse(char s[]) {
int n = strlen(s);
for (int i = 0,j = n - 1; i < j; i++, j--) {
char tmp = s[i];
s[i] = s[j];
s[j] = tmp;
}
}
/* reverse: print reversed string from args */
int main(const int argc, char *argv[]) {
/* We accept only one argument.If we receive more or less than one
argument, exit with error */
if (argc != 1) {
fprintf(stderr, "Usage: ./reverser <string>\n");
return 1;
}
// If we receive a too long string, exit
if (strlen(argv[1]) > 256) {
fprintf(stderr, "%s", "String too long\n");
return 1;
}
// Copy the argument into a string
char str[256];
strncpy(str, argv[1], sizeof(str) - 1);
reverse(str);
printf("Reversed string: %s\n", str);
}
The modified code prints out the "wrong usage" error:
$ docker build --tag reverser-arg .
[+] Building 0.8s (15/15) FINISHED
...
$ docker run --rm -it reverser-arg:latest "noel sees leon"
Usage: ./reverser <string>
Next, we will create a Dockerfile for debugging this image.
Creating a debugging image
Updated Makefile
For debugging the binary, compile the code with the -g
and -O0
flags. Flag -g
adds debugging information to the binary. Flag -O0
disables the optimization, which can rearrange, inline, or remove code, in turn, might make debugging difficult.
Make the following adjustments to accept variable DEBUG
in Makefile
, which is 0 by default. If you invoke make
with DEBUG=1
, it will adjust the CFLAGS
by adding debugging flag -g
and disabling optimization with -O0
. Also, it changes the build directory and binary names.
CC = gcc
CFLAGS = -Wall -Wextra
SRC_DIR = src
BUILD_DIR = build
BIN = reverser
DEBUG ?= 0
ifeq ($(DEBUG), 1)
CFLAGS := $(CFLAGS) -g -O0
BUILD_DIR := $(BUILD_DIR)-debug
BIN := $(BIN).debug
else
CFLAGS := $(CFLAGS) -O3
endif
main: $(BUILD_DIR)/$(BIN)
$(BUILD_DIR)/$(BIN): $(SRC_DIR)/main.c
@mkdir -p $(BUILD_DIR)
$(CC) $(CFLAGS) -o $@ $<
clean:
rm -rf $(BUILD_DIR)
.PHONY: main clean
Dockerfile.debug
Change the make
command to make DEBUG=1
, install gdb
and start gdbserver
.
Note:
|
We used port 2159 for gdbserver , which is the registered TCP port number for "GDB Remote Debug Port". You can use any available port.
|
Create a file Dockerfile.debug
with the following content:
FROM bellsoft/alpaquita-linux-gcc:14.2-musl AS build
WORKDIR /build
COPY Makefile .
COPY src src
# compile the code with debugging symbols
RUN make DEBUG=1
FROM bellsoft/alpaquita-linux-base:stream-musl
WORKDIR /app
# Install gdb package
RUN apk update && apk add --no-cache gdb
COPY --from=build /build/build-debug/reverser.debug .
COPY --from=build /build/src src
# Start gdbserver on port 2159 to debug the application
ENTRYPOINT ["gdbserver", ":2159","./reverser.debug"]
Tip
|
To minimize disk usage, you can remove the binaries and files provided by the gdb package and leave only the gdbserver binary. Note that gdbserver requires the libstdc++ library.
|
Building the image and running the container
Build the debugging image tagged as reverser-arg-debug
.
To use gdb for tracing, the process group of the tracee must allow ptrace operations. By default, Docker removes the SYS_PTRACE
capability, which restricts ptrace use inside the container. This capability needs to be re-enabled.
Additionally, Docker’s default seccomp profile blocks several system calls essential for gdb, including ptrace
, perf_event_open
, and process_vm_writev
. Using --security-opt
seccomp=unconfined will bypass seccomp filtering for all processes in the container.
Then, run the gdbserver
with the options explained above passing the string, "noel sees leon" as the first argument to the program.
$ docker build --tag reverser-arg-debug -f Dockerfile.debug .
$ docker run --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \
-p2159:2159 reverser-arg-debug:latest "noel sees leon"
Process ./reverser.debug created; pid = 9
Listening on port 2159
The gdb
server is now waiting on port 2159. The next section explains how to connect to the gdb
server using CLion
.
Configuring CLion for remote debugging
Important:
|
Before continuing, make sure that gdbserver is running inside a docker container.
|
-
First, open the project directory.
-
Click Trust Project.
Note:Ignore the "No compilation commands found" error in the Build sidebar, since we will be building our project inside a docker image anyway. -
On the main menu, select Run > Edit Configurations.
-
Click the + icon and then select Remote Debug.
-
Fill the 'target remote' args with IP address and the port of the
gdbserver
, in this case localhost and 2159. You may want to give this configuration a meaningful name like "Docker Remote Debugging". Click OK. -
On the side menu, open
main.c
. Set breakpoints in themain
andreverse
functions as in the following image: -
On the toolbar (top right), click the green bug icon to connect to
gdbserver
on port 2159, as we configured earlier.The program stops at line 20 in the
main
function, and you can see an argument count (argc
) of 2. The value ofargv[0]
is "./reverser.debug".We undoubtedly passed the string "noel sees leon" to the program as an argument in the previous steps. Let’s look at the value of
argv[1]
. -
On the debugging toolbar (bottom part of the interface), select Threads & Variables. At the top of the window, enter the value you want to evaluate, in this case,
argv[1]
. Press Enter.The "noel sees leon" string is displayed in the window.
The first argument of the program is always the program itself, "./reverser.debug". That’s why the conditional
if (argc != 1)
fails, since we provide an argument to the program,argc
should be 2, not 1. -
Stop the debugging session by pressing red square on the toolbar.
Now fix the bug, re-build the image and run the container again. Then click the debug icon on the toolbar.
The program now continues without exiting with a usage message. Click Step over a couple of times, inspect variables or explore the interface. Click resume program a few times until the program completes.
Upon finishing the program, it should display the reversed string and gdbserver
exits.
$ docker run --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \
-p2159:2159reverser-arg-debug:latest "noel sees leon"
Process ./reverser.debug created; pid = 9
Listening on port 2159
Remote debugging from host ::ffff:172.17.0.1, port 39550
Reversed string: noel sees leon
Note:
| See CLion’s documentation for more information about debugging in CLion. |