A Balanced Stance on Debugging Containers

By: Matt Rixman

Putting your code in a container shouldn't make it harder to debug. In this post I'll share some ideas about how you can spend less time debugging containerized code.

We'll start with the quick 'n dirty approach and work towards the full IDE experience.

a breakpoint in vscode

Each section below describes a debug workflow that answers these questions differently:

  • Which tools are hostside?
  • Which tools are containerside?
  • Is the program copied or mounted into the container?

Debugging by Braille

In this method:

  • program is copied into image
  • other tools are hostside
  • who needs a debugger?

I call it "braille" because unless you've planned ahead, you're blind to what's going on inside the container.

App in the container, everything else outside

The strategy here is to alter the program so that it gives hints about what's going on. The simplest way is to add print statements.

for target in targets:
  if not dexsave(target):
      print(target.dict())      damage(target, 8 * d(6))
      ignite(target)

With the added print statement we can see that Megalo-centipede is missing the hp attribute:

{'name': 'Maggris', 'x': 150, 'y': 295, 'hp': 36}
Maggris takes damage
Maggris stops looking angry and starts looking scared
{'name': 'Megalo-centipede', 'x': 150, 'y': 285}
Traceback (most recent call last):
  ...
  File "cast_fireball.py", line 52, in damage
    target.hp -= amount
AttributeError: 'Mob' object has no attribute 'hp'

Pros

  • familiar workflow
  • low toolchain complexity

Cons

  • waiting for a new image to build for each code change
  • cleaning up the braille once it's no longer needed

Tips

  • git commit before you start adding braille
  • fix the bug, but also improve the error message for next time
  • print braille to stderr to avoid confusing callers that parse your stdout
  • use a logging library to en/disable braille as needed
  • trace functionality can generate braille automatically
  • instead of redownloading packages, --mount=type=cache in your image build

Debugging Live Code

Code is "live" if it's available hostside--ready to edit, and also conainerside--ready to run. To make this work, mount the code into the container.

It's different from the previous strategy because the mounted files are not copies. Writing to them from one filesystem will affect how they're seen by both filesystems.

The mounted files will hide whatever was copied during the image build. This is handy because the copies stale as soon as you start making edits.

(Note that this is only true for a bind mount, which is the type recommended here--volume mounts play by different rules.)

Mount local code in the container

Pros

  • save file -> try changes, less waiting
  • no need to migrate changes from container back to codebase

Cons

  • files created containerside might seem out of place hostside
  • ...and they'll have containerside ownership/permissions
  • filesystem mappings can get complex if you have many containers

Tips

  • files are mounted during container creation (not at image build time)
  • docker-compose can automate your host/container folder mappings
  • use .gitignore to prevent container-created files from showing up in your commits

Shell-Style Debugging...

In this mode your debugger is entirely containerside. There are two ways to start such a debugger.

...Attached to App Process

Some debuggers require extra runtime steps before they'll attach to a process:

Attach a debugger at runtime

This means either providing a process ID, or having the debugger launch the app:

$ node inspect hello.js
< Debugger listening on ws://127.0.0.1:...
Break on start in file:///containerside/path/to/hello.js:1
> 1 console.log('hello world')
debug> ▉ <-- debugger commands go here

...Called by App Code

Several other debuggers expect your program's code to call them.

Call a debugger from source

This means calling a function to start the debugger:

for target in targets:
  if not dexsave(target):
      breakpoint()      damage(target, 8 * d(6))
      ignite(target)

Either Way

In either case, certain conditions will cause the debugger to pause.

> /containerside/path/to/cast_fireball.py(10)fireball()
-> damage(target, 8 * d(6))
(Pdb) ▉ <-- debugger commands go here

Then you can ask for information or advance the state of your program.

(Pdb) target.hp
36
(Pdb) continue
Maggris takes damage
Maggris stops looking angry and starts looking scared
> /containerside/path/to/cast_fireball.py(9)fireball()
-> breakpoint()
(Pdb) target.hp
*** AttributeError: 'Mob' object has no attribute 'hp'

Unlike with braille, you can also test ideas about how to fix the bug without restarting the program. We'll do this by setting an attribute on the fly:

(Pdb) target.hp = 1
(Pdb) continue
Megalo-centipede dies

The exception wasn't raised, so we know we're on the right track.

Pros

  • requires only stdin, stdout, and stderr access to use
  • debuggers are powerful tools
  • minimal hostside setup

Cons

  • limited to shell-only debuggers
  • more containerside setup

Tips

Graphically Debug an Instrumented Container

Some debuggers come in two parts, a debug server and an IDE. It's the server's job to be near the bug, and it's the up to the IDE (integrated development environment) to provide a human-friendly interface.

In this mode, the debugger is partly containerside, and partly hostside.

remote debugging workflow

Some IDE's have container-specific debugging features. These will place the debug server in the container and handle the networking for you. Others provide "remote debugging" without addressing containers specifically. You can make these work too, but you'll need to place the pieces by hand.

Once the IDE and the debug-server connect, we can use them to debug the process in the container.

An IDE showing the code that threw an exception

In this case, the debug server saw that line 40 raised an exception, so the IDE focused the edtor on that line.

Pros

  • feature-rich GUI
  • familiar workflow

Cons

  • more hostside setup
  • extra network requirements

Tips

Summary

We started with a simple (but slow) container debug workflow: Treat the container like a black box and reason based on its inputs and outputs.

This works, but you have to wait for the image to build every time you make a code change. To speed things up, we mounted the code into our debug container.

The tighter debug loop let us iterate faster, but line-level execution control was still lacking, so we added a debugger to the container. This reclaimed much of the functionality that often gets lost in the transition to containers.

Finally, we added a hostside user interface which matched the containerside debugger. Now we can control the debugger with a GUI.

Different situations will call for different configurations, so it's worth getting comfortable with more than one debug workflow. That way you can balance your hostside and containerside tooling in a way that fits the task.

We hope these ideas save you as much time as they've saved us.

P.S.

The tricks in this post are baked into Conducto, which makes it nice to debug with. We're always looking for ways to improve it, so if you know a trick that deserves a mention here, please stop into our slack and let us know about it.

Happy Debugging.