Published on

Part 7 - Live Reload and Debugging

Introduction

Our OneHub chat service is taking shape in our gRPC Series. In Part 5 we introduced Docker for packaging all the components of our services. At that point it only contained the Database (postgres and pgadmin) but we left out the main service binary out of this packaging (via docker-compose.yml file). This was because go binaries needed a rebuild each time source files changed and this in turn would need a rebuild of our docker image - a time consuming process. A live reload/compilation of the service binary without a rebuild of the docker image was missing.

In this post we will rectify this by using the popular Air framework to enable live reloading/compilation of our service binaries.

To summarize, our goals are:

  • Enable live reloading so that we can avoid restarts (go run cmd/server.go) when making changes.
  • Ensure our servers can be connected via debugger (eg VSCode)
  • Package and run the server as a docker image in our docker-compose setup so that we can run a single docker compose up command to bring up everything instead of starting the server seperately.

Getting started

Our code for this part can be found in the PART7_AIR branch of the OneHub repo.

We will be installing 2 main tools:

  • Air - For enabling live reloading
  • Delve - For debugging go binaries/services.

Both of these have excellent documentation and we will be pretty much following those here.

To install these:

go install github.com/go-delve/delve/cmd/dlv@latest
go install github.com/cosmtrek/air@latest

Initializing and Running Air

Initialize Air by running:

air init

This would create a .air.toml config file that instructs air on how to build binaries, how to run the built binaries (in case any flags/env vars are to be passed) and which files to watch for changes.

Modified .air.toml

root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"

[build]
  args_bin = []
  cmd = "go build -o ./tmp/main cmd/server.go"
  bin = "./tmp/main"

  delay = 1000
  exclude_dir = ["assets", "testdata", "tmp", "cli", "vendors", "ohfe"]
  exclude_file = []
  exclude_regex = ["_test.go"]
  exclude_unchanged = false
  follow_symlink = false
  full_bin = ""
  include_dir = []
  include_ext = ["go", "tpl", "tmpl", "html"]
  include_file = []
  kill_delay = "0s"
  log = "build-errors.log"
  poll = false
  poll_interval = 0
  rerun = false
  rerun_delay = 500
  send_interrupt = false
  stop_on_error = false

[color]
  app = ""
  build = "yellow"
  main = "magenta"
  runner = "green"
  watcher = "cyan"

[log]
  main_only = false
  time = false

[misc]
  clean_on_exit = false

[screen]
  clear_on_rebuild = false
  keep_scroll = true

Key things to notice are:

  • root - The root of the project to be watched.
  • build.cmd - Specifies How to build the binary that we will run (cmd/server.go)
  • build.bin - How to run the built binary. Instead of just running the binary, we are running it through delve so that we can connec to this via a debugger (such as VSCode). This is especially useful when our binary will be running as a docker image in our docker-compose setup.
  • build.exclude_dir - Specifies which files/folders are NOT to be watched. This will prevent excessive rebuilding and restarting of our binary.
  • build.exclude_regex - Specifies which file patterns are to be ignored.

Other than this by default all files and sub directories under the "root" path will be watched.

Running our watched Server

Now we can run our server in watched mode (make sure the database is also running first):

air -c .air.toml

(-c is optional and by default air uses .air.toml as its config file)

With this Air will first build the binary (./tmp/main) and execute it. Try changing one of the source files and see air rebuild and restart the binary! (Note - simply touching a file will not work as air compares file contents too for changes to count).

Dockerizing our setup

Now that we have air setup and live reloading enabled, we are ready to dockerize our services so we can bring up all the components (the database, service binary etc) as one packaged unit (via docker compose up) instead of seperately.

We will create a Dockerfile that will describe how our docker images (for our service) are to be built. We wont go into details on Dockerfiles here. The Dockerfile reference is an amazing resource.

Service Dockerfile

FROM golang:latest

WORKDIR /app

RUN go install github.com/cosmtrek/air@latest
RUN go install github.com/go-delve/delve/cmd/dlv@latest

COPY .air.* ./
COPY go.mod go.sum ./
RUN go mod download

# Command to run the executable
CMD ["air", "-c", ".air.toml"]

We now have a base image that:

  • Installs Air, Delve (to be used soon)
  • Copies our go.mod file and downloads dependencies so that when air takes over it can find these for rebuilding our binary.

Now that we have a base image, we need to add an extra service to our docker-compose.yml:

  onehub:
    build:
      context: .
      dockerfile: ./Dockerfile
    depends_on:
      postgres:
          condition: service_healthy
            # webapp: condition: service_healthy
    volumes:
      - ./cmd:/app/cmd
      - ./gen:/app/gen
      - ./datastore:/app/datastore
      - ./services:/app/services
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      ONEHUB_DB_ENDPOINT: ${ONEHUB_DB_ENDOINT}
    ports:
      - 8080:8080
      - 9000:9000
      - 9091:9091

This ensures that all our "source" folders (cmd, gen, datastore, services) are mounted into the docker image as volumes. This is needed so that Air can find the needed files when it initiates a rebuild. We are also exposing an extra port - 9091 - which will be used by Delve to enable remote debugging described later.

Now with the image built we are ready to try out this single endpoint:

# Bring down if already up
docker compose down

# Bring it up again
docker compose up

Now you will see both the database and the service running! Go ahead and make some calls to it either via our CLI or using curl, eg:

oh topics list --username auser --password auser123

You should see results from the service!

A few reminders:

  • If you add/remove dependencies to go.mod a rebuild of the docker image will be needed:
docker compose build --no-cache
  • If you add new folders for sources - then make sure to add them to the volumes list in the onhub service section of the docker-compose.yml file (so it is available for builds) and restart (docker compose).

  • Note that we are adding the gen folder as a Volume mapping. So if the protos change we will simply regenerate the artifacts (buf generate) and restart (docker compose down/up).

Enable Debugging

Bringing up our service from an IDE (say VSCode) was simple and made interactive debugging a breeze. However doing so for a binary running inside a docker container needs a few small changes. Delve is the standard tool for remote attaching/debugging of Go applications. Thankfully we had already installed Delve in our dependencies above.

All that is needed now is to modify our .air.toml file to run our app "wrapped" by Delve instead of running it directly. Our "build.bin" value is now:

  bin = "/go/bin/dlv --listen=:9091 --headless=true --log=true --accept-multiclient --api-version=2 exec --continue ./tmp/main"

Here we are instructing Air to use Delve as the binary which in turns loads our real binary (./tmp/main). Delve is also instructed to start the debugger service on port 9091. (Go) IDEs can now connect to this port and interactively debug the binary.

As an example, if you are using VSCode, add the following configuration to .vscode/launch.json (along with other configurations):

{
    "name": "Remote Debugger for OneHub",
    "type": "go",
    "request": "attach",
    "mode": "remote",
    "port": 9091,
    "showLog": true,
    "host": "127.0.0.1"
}

Now launch this configuration and you will be able to interactively debug the service (eg by setting breakpoints, stepping in/out/over etc).

Conclusion

That's it we now have a Dockerized setup with Live Reloading and Debugging enabled which lets us:

  1. Package all components and start/stop them as a single unit instead of with multiple start/stop commands
  2. Live reload/rebuild/restart our service when any source files change so we do not have to remember to do this each time.
  3. Enabled a remote debugger so that we can attach a debugger to our service running in a docker container.

We can now build upon these foundations in future posts as we add more features and operational capabilities to our OneHub service.