Go adventures: Live reload and debugging in a Docker container
Welcome to part 1 of this series about exploring Go. Today we are going to set up a comfy development environment in Docker containers. Let’s dive in!
Goals
Let’s say we are trying to develop a Go-based backend service that will run on Kubernetes. In practice, this usually means that the service is going to run in a Docker container (though there are alternatives, such as Podman). It would be a logical idea to also want to run the service in a container during development, to keep it as close to the production environment as possible.
This latter idea brings up some problems, though.
Firstly, if we want our code changes to show up, we need to either
- rebuild the container (if the source files or the executable is copied to the container)
- or restart the container (if the source code is mounted via volumes and the executable is built in the Dockerfile)
Both of those can be circumvented by the use of a live-reload solution.
Secondly, during development we often resort to the debugger to find elusive problems. By running the service in a container, it’s harder to attach a debugger, since we need to reach the service inside somehow.
So here we have our goals:
- Run a service in a docker container so we don’t need different setups for development and production environments
- Add live-reload support so we don’t need to constantly restart the container
- Add debugger support
Let’s tackle the problem step by step!
A basic containerized service
For the purposes of the article, we will just assume the service in question is an HTTP server listening on port 8080.
We are also using docker-compose
to run the development setup so we don’t have to deal with docker commands directly.
All code can be found in repository kristofaranyos/blog-content. The folder structure of our imaginary web service is going to be simple — and yes, it doesn’t follow the usual Go folder structure, but we only care about the environment here:
|
|
Here is what our main.go
file looks like:
|
|
The Dockerfile
:
|
|
And lastly, the docker-compose.yml
:
|
|
You can see the state of the code here.
Now let’s run the service to verify it’s indeed working, by first running $ docker compose build
and then
$ docker compose up
.
Tip:
docker compose
anddocker-compose
are two different commands. Compose is integrated into the Docker CLI from V2 onwards.
If everything went well, you should be greeted with the message Hello world!
after visiting
localhost:8080.
Adding live reload
Now that our basic containerized service is running, we can start tuning the setup. Let’s add live reload! Thanks to the flourishing Go ecosystem, we have a variety of choices to do this. One option would be cosmtrek/air. However, I find it a little convoluted so we’re going to use githubnemo/CompileDaemon. It’s wastly simpler, and it’s much lighter, while it’s still enough for basic development.
We begin by adding CompileDaemon to the Dockerfile
:
|
|
You can notice we changed two things. First, we download the latest version of CompileDaemon, and after that, instead of directly building the Go executable, we tell CompileDaemon what to do. There are a few things happening here:
- With the
directory
flag, we tell CD where to look out for changes. - With the
build
flag, we tell CD what to do once any.go
files change. - With the
command
flag, we tell CD what to do once it has rebuilt the executable.
With these additions, our container is now capable of updating the executable it’s running, if the source code changes.
The catch here is that it won’t ever change, since we copy the source code upon building the Dockerfile
. To overcome
this limitation, we can utilize Docker volumes to keep the contents of the
container up to date with the host computer. Let’s add a few lines to the docker-compose.yml
file:
|
|
This adds three volumes — technically we could get away with one using wildcards, but it’s a good practice not to rely on those — that will keep our code up to date.
Now we can rebuild and run our setup using docker compose
the usual way.
Tip: Don’t forget to
$ docker compose down
before running$ docker compose up
to make sure your setup is completely fresh.
Assuming we did everything correctly, we can now go on and change something in main.go
and have the changes take
effect. Let’s add a simple parameter to our endpoint:
|
|
After changing the file, we should see something like this in the terminal:
|
|
Connecting to localhost:8080?name=Anonymous in the browser, we should be
greeted with Hello world! I'm Anonymous
. You can find the code for the current state here.
Time to debug
We reached the last goal — adding the debugger. For this, we are going to use Delve, the de-facto standard Go debugger.
There are two issues that we need to deal with to get the setup working.
- Delve has to interact with CompileDaemon. But which comes first and why does this matter?
- Containers insulate the processes within from the outside world. How do we let Delve communicate with the client on the host machine?
Starting with the easier issue — Delve and CompileDaemon cooperating — we need to think about what cases there are.
We could either have Delve run CD or vice versa. If we think about it, the former solution won’t work since CD runs
commands as external processes, and because of this, Delve won’t be able to debug the service.
The latter way, we also don’t waste time running CD with the debugger. We continue by adding Delve to the Dockerfile
.
|
|
There are again a few things happening here. We added a RUN
command to download the Delve executable. We also exposed
port 9090
which we will use to connect the debugger through. And lastly, we changed the command flag of CompileDaemon.
Let’s dissect the flags:
-headless=true
tells Delve we are not going to use an interactive shell, but connect remotely--listen=:9090
tells Delve which port to listen on — this should be the same one we are exposing--api-version=2
tells Delve to use its V2 (currently latest and the only maintained) API. It supports both JSON-RPC and DAP to communicate with clients.--accept-multiclient
tells Delve to expect multiple clients connecting. This lets you reconnect after disconnecting--continue
together tell Delve to start running the process even if we don’t connect to it. By default, Delve waits until you connect the debugger. This is required so the service will be started even if you don’t want to debug it
The second issue we talked about is a little more arcane. We begin with taking a look at the docker-compose.yml
file:
|
|
The first and obvious change is the added port-forward. But what are these changes?
|
|
Essentially, what is happening during the debugging is that Delve traces the service process using
ptrace(2). The cap_add
directive gives the container permission
to do this using capabilities(7). This is a pretty obscure
Linux feature, but for our purposes it’s enough to know that we just need to include this directive for the debugging to
work.
The other thing is the security_opt
directive that tells Docker to run the container with — in this case — no
seccomp(2) profile. Secure computing is another little-known
Linux kernel feature that we won’t be diving into. It just works. ( ͡° ͜ʖ ͡°)
With all this out of the way, we can just again rebuild and restart out compose setup, and now debugging should work. You can check the final state of the code here.
To test it, we are going to be using Goland because obviously it’s the best IDE for Go
development. Add a run configuration called Go remote
and set the port to 9090
. After running it and adding a
breakpoint in our code (click the empty space to the right of the line number), our setup should be live.
Tip: I recommend setting the run configuration to leave Delve running after disconnecting so we don’t need to restart the service if we accidentally disconnect the debugger.
We can query the endpoint in our browser as usual to make the handler run. Our the system should stop at the breakpoint and we should see the variables in the scope.
Tip: If you have multiple services running locally, you can enable all of the debuggers with one click using the
Compound
run configuration.
With that, we’ve reached all three goals we set for ourselves. Happy coding!
Afterthoughts
I hope this was an informative article and I could help to make your DX be more comfortable. Don’t hesitate to reach out to me with any questions or thoughts!