Debug Containers in VS Code

By: Matt Rixman

In an separate post we recommended graphically debugging an instrumented container. This post will focus on VS Code specifically.

There are two approaches to this problem:

  • start a new container
  • pick an existing container

Each of these gets a section below. But first, let's look at how VS Code handles debugging in general.

Debugging in VS Code (launch.json)

Consider a project that is empty except for a single file: total.py

a mostly empty project

By default, VS Code will not supply any arguments. If that's your case, just set a breakpoint and run Debug: Start Debugging

assume no args

By the way, Ctrl + Shift + P will open the command palette, which lets you quickly find and run commands.

If you want to use some CLI arguments, or start the app in some other way, you'll need to create .vscode/launch.json. To do this, run Debug: Open launch.json, then VS Code will prompt you to pick a language.

Modify the JSON to add any command line args:

pass "stock" to total.py

Then save the file and run Debug: Start Debugging. This time VS Code uses your arguments, and execution will stop at the appropriate breakpoint.

VS Code did what we asked

Here we can see that VS Code used the stock arg that we provided.

Notice that configurations in launch.json is an array, so you can configure more than one of these. You can pick the active one with the dropdown at the top of the debug view.

The debug configuration selector is in the upper left corner

Another thing to notice is that there is no text in the green box in the bottom left corner. This means that VS Code is configured for local debugging. In the next section we'll configure it for container use.

VS-Code-Created Debug Container (devcontainer.json)

VS Code needs the "Remote-Containers" extension to create debug containers--so make sure it's installed. Then run Remote-Containers: Add Development Container Configuration Files and pick a configuration that resembles what you need.

Don't worry if it's not a perfect match, you can tweak it later. You'll end up with some files like these:

  • .devcontainer/devcontainer.json
{
  "name": "Python 3",
  "build": {
    "dockerfile": "Dockerfile",
    "context": "..",
    "args": {
      "VARIANT": "3.9"
    }
  }
}
  • .devcontainer/Dockerfile
ARG VARIANT="3.9"
FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT}

Next run Remote-Containers: Open Folder in Container. The green box in the bottom left corner will change to indicate that VS Code is connected to a Dev Container. Try debugging your file again.

local package dependencies might be missing in the container

It may not feel like it, but that ModuleNotFoundError is a good thing. It's telling you that your app has a dependency that is not part of its image. If we don't fix it, other users will have this problem too.

Let's make the dependency on pandas explicit by asking pip to install it into the image.

.devcontainer/Dockerfile

# .devcontainer/Dockerfile
ARG VARIANT="3.9"
FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT}
RUN pip install pandas

Run Remote-Containers: Rebuild Container to connect to a new container which contains the Dockerfile changes. Once your dependencies are handled, you should find that using VS Code connected to a container is more or less the same as using it to develop locally.

This workflow preserves the benefits of having the environment defined in a Dockerfile, but you also get the convenience of editing local code using a local debug interface. VS Code makes this possible by mounting your code into the container, and by separating the debug server from the debug GUI.

One debug component is hostside, the other is containerside

The debug server shows up in the command that VS Code uses to start debugging:

$ python /home/vscode/.vscode-server/.../debugpy/launcher ... -- /containerside/path/to/total.py stock

And you can make sense of the code mount by inspecting the container:

$ docker container ls  | grep vsc
    c010ebd7a593   vsc-project-fbccdfbab06761b118a30a165fa33856-uid

$ docker inspect c010 --format '{{ json .Mounts}}' | jq .
    [
      {
        "Type": "bind",
        "Source": "/hostside/path/to/project/",
        "Destination": "/containerside/path/to/project/",
      },
    ]

Attach VS Code to an Existing Container

It's an ugly truth, but there are bugs out there that only appear sometimes. We can't assume that they will show up in our dev environment, so we'll need to bring our tools to them.

Let's look at how to attach VS Code to a container and use it to find a bug.

The Bug

There's a bug somewhere in this code, but it's not obvious:

from time import sleep

print("waiting for the signal")
while True:
    try:
        with open("./the_signal", 'r') as f:
            content = f.read()
            if 'Now' in content:
                break  # signal received
    except FileNotFoundError:
        pass
    sleep(1)           # no signal, keep waiting
print("signal received, execute the plan!")

The code waits for a separate process to put some data in a file.

We expect that:

  • ./the_signal appears at the appropriate time
  • within a second, the conditional evaluates True
  • break takes us out of the loop

But when we test it with whatever writes ./the_signal, that doesn't happen. The only logs are:

waiting for the signal

What's going on?

The Debug

We can see that the container is still running:

$ docker container ls
CONTAINER ID   IMAGE     NAMES
c4f6bc0df4d4   wait      waiter

Let's get a debugger on that script and take a closer look.

Start by launching VS Code and installing the "Remote-Containers" extension if it isn't already. Then run Remote-Containers: Attach to Running Container and select the troublesome container.

VS Code, connected and waiting

Once the green rectangle in the bottom left says that you're connected, run Extensions: Install Extension and pick whichever language you need. In our case, it's Python.

Next, open the folder with the suspicious code (you're looking for its containerside path).

Since you've installed the appropriate language extension, you should be able to set breakpoints.

Finally we'll run Debug: Start Debugging and select Python: Current File, just as we would if debugging locally.

Indicate the point in the code that you'd like answers about

The debugger makes the problem obvious. We're looking for Now but the signal we got was NOW.

Let's alter the container so that our stuck process can continue:

$ docker logs c4f6
    waiting for the signal

$ docker exec c4f6 sh -c 'echo Now > the_signal'

$ docker logs c4f6
    waiting for the signal
    signal received, execute the plan!

Next, we'll change the code so that this doesn't happen again:

# if 'Now' in content:
if 'now' in content.lower():

Summary

One of the reasons to use containers is that you can easily recreate execution environments. The extra consistency means that debugging in a similar container is usually just as good as debugging in the same container.

For this reason, it's usually sufficient let VS Code create a debug container for you. We worked through an example of this with an eye towards ensuring that our app's dependencies were well understood.

In a later example we considered a case where something unexpected happened in a specific container. Rather than trying to recreate the problem elsewhere, we used VS Code to investigate the exact container with the problem.

In both cases, the "Remote-Containers" extension handled the containers, and the VS Code debug experience was mostly unchanged. Armed with these techniques, you'll be able to isolate bugs to containers without sacrificing on transparency.

This has not been a complete guide to working with containers in VS Code, so be sure to keep the docs at hand. Happy Debugging.