Golang-Anwendungen debuggen

Golang-Anwendungen debuggen

Ein Debugger ist bei der Fehlersuche in modernen Programmiersprachen wie Go/Golang nicht mehr wegzudenken. Er ermöglicht es ein Programm Schritt für Schritt auszuführen und die jeweiligen Ergebnisse zu untersuchen. Unter Umständen kann auch der Ablauf oder das Verhalten einer Anwendung während einer Debugging-Session durch Veränderung von einzelnen Werten im Speicher verändert werden.

In diesem Artikel wollen wir uns den Debugger Delve für Golang anschauen und damit eine Anwendung innerhalb eines Docker-Containers debuggen. Im Beispiel verwenden wir einen einfachen Microservice.

Das gezeigte Vorgehen funktioniert ebenfalls für Remote-Debugging-Sitzungen oder lokales Debugging, bei denen die Anwendung nicht in einem Docker-Container ausgeführt wird.

Delve is a debugger for the Go programming language. The goal of the project is to provide a simple, full featured debugging tool for Go. Delve should be easy to invoke and easy to use. Chances are if you’re using a debugger, things aren’t going your way. With that in mind, Delve should stay out of your way as much as possible. - Delve Website

Ich werde im Artikel die Verwendung innerhalb von VSCode und IntelliJ bzw GoLand zeigen. Der Einsatz innerhalb einer anderen IDE funktioniert analog.

Debugging Ablauf

Prinzipiell spielt es keine Rolle ob Sie eine Go-Anwendung direkt innerhalb einer Entwicklungsumgebung debuggen oder sich zu einer entfernten Maschine verbinden, um dort ihre Anwendungen zu untersuchen. Die Kommunikationsdetails unterscheiden sich nur minimal. Bei einer lokalen Debugging-Sitzung wird Ihre Entwicklungsumgebung Sie maximal unterstützen und Sie werden im Idealfall nichts von einer Kommunikation zwischen den Bestandteilen mitbekommen.

Wie erwähnt, ist in beiden Fällen der Ablauf einer Debugging Sitzung über Delve gleich (siehe Abbildung). Die Anwendung wird durch Delve gestartet und kann dann normal eingesetzt werden. Ein Delve-Frontend, z. B. ihre IDE, kann sich nach dem Start mit der laufenden Delve-Instanz verbinden und die eigentliche Anwendung untersuchen.

Ob Delve die Anwendung selbst übersetzt, oder ob ein bereits erstelltes Go-Binary verwendet werden soll, entscheiden Sie beim Start. Wenn Sie die Debugging-Sitzung über eine Remote-Verbindung durchführen, geben Sie die entsprechenden Kommunikationsparameter dafür beim Start von Delve an.

UML-Klassendiagramm Go VehicleService

UML-Klassendiagramm Go VehicleService

Im gezeigten Beispiel aus der Grafik handelte es sich um einen einfachen Microservice, der über Port 8080 erreichbar ist und dort seine Anwendungsdaten ausliefert. Die Anwendung wird nicht direkt sondern über Delve gestartet, das den Port 4040, über den sich eine Debugger-GUI (im Bild eine IDE) verbinden kann, öffnet.

Delve Installation

Die Installation von Delve ist gewohnt einfach. Seit Version 1.16 ist folgendes Kommando für die Installation der neusten Delve-Version ausreichend:

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

Zu beachten ist allerdings, dass nicht alle Kombinationen aus Betriebssystem und Rechnerarchitektur unterstützt werden. Die folgende Liste zeigt alle bisher möglichen Kombinationen:

  • linux / amd64 (86x64)
  • linux / arm64 (AARCH64)
  • linux / 386
  • windows / amd64
  • darwin (macOS) / amd64

Falls eine Kombination noch nicht angeboten wird, erhalten Sie während der Installation folgende Fehlermeldung und Sie können in den entsprechenden Bugtickts nach Informationen suchen (32bit ARM support, PowerPC support und OpenBSD).

found packages native (proc.go) and your_operating_system_and_architecture_combination_is_not_supported_by_delve (support_sentinel.go) in /home/pi/go/src/github.com/go-delve/delve/pkg/proc/native

Für macOS finden Sie auf den Wiki-Seiten des Delve Projektes weitere Überlegungen und Tipps für eine weiterführende Installation.

Ist die Installation geglückt und befindet sich Ihr $GOBIN-Verzeichnis im Ausführungspfad, können Sie delve auf der Kommandozeile mittels dlv starten:

$ dlv
Delve is a source level debugger for Go programs.

Delve enables you to interact with your program by controlling the execution of the process,
evaluating variables, and providing information of thread / goroutine state, CPU register state and more.
...

Beispielanwendung

Die Beispielanwendung für diesen Artikel ist recht einfach aufgebaut. Es handelt sich um einen Microservice, der einen fixen String als Rückgabewert über HTTP liefert. Innerhalb des Projektes ist zusätzlich ein Dockerfile vorhanden, mit dem der Service übersetzt und gestartet werden kann.

Folgende Dateien befinden sich innerhalb des Projektes:

.
├── cmd
│   └── main.go
├── Dockerfile
├── go.mod
├── internal
│   └── SomethingImportant.go
└── startup.sh

Die main.go Datei wird nur dazu benutzt den eigentlichen Service innerhalb der Datei SomethingImportant.go zu starten. Die Implementierung ist absichtlich extrem simple gehalten. Hier der Inhalt der Datei SomethingImportant.go:

package internal

import (
	"fmt"
	"net/http"
)

func sayHello(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Hello world!")
}

func StartServer() {

	http.HandleFunc("/", sayHello)
	fmt.Println(http.ListenAndServe(":8080", nil))

}

Innerhalb der main.go-Datei im cmd-Verzeichnis wird die Anwendung, wie gesagt, ausschließlich gestartet:

package main

import "github.com/SourceFellows/samples/delve/internal"

func main() {
	internal.StartServer()
}

Mit Hilfe des Dockerfiles wird der Service übersetzt und kann ausgeführt werden. Den Start übernimmt das Skript startup.sh, das wir uns gleich noch etwas genauer anschauen werden. Hier werden sich ein paar Delve-spezifische Werte wiederfinden.

Zum Debuggen der Anwendung innerhalb des Docker-Containers muss innerhalb des Containers Delve installiert sein. Mit dem Basis-Image golang:latest können Sie dies recht leicht mit dem Kommando go install github.com/go-delve/delve/cmd/dlv@latest erreichen und die neuste Delve Version installieren. Der restliche “Bauvorgang” ist analog zu einem normalen Imagebau (Kompilierflags schauen wir uns gleich noch gesondert an).

from golang:latest

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

RUN mkdir -p /app/cmd
WORKDIR /app/
COPY . .

#Anwendung kompilieren
RUN go build -gcflags="-N -l" cmd/main.go

#Port auf den sich der Debugger verbinden kann
#Angabe in 'startup.sh'
EXPOSE 4040

#Anwendung starten
RUN chmod +x /app/startup.sh
ENTRYPOINT [ "/app/startup.sh" ]

Container erstellen und ausführen

Den Docker-Container für das Beispiel können Sie über folgendes Kommando bauen (testing bezeichnet den Image-Namen):

docker build -t testing .

… und schließlich Starten.

docker run -p 8080:8080 -p 4040:4040 testing

Die Portangaben 8080 und 4040 im Kommando beziehen sich hierbei einmal auf die Anwendung selbst (Port 8080) und einmal auf den Port, über den die Delve-Debugging-Sitzung durchgeführt wird (Port 4040). Woher der Port 4040 kommt, schauen wir uns gleich an.

Was noch zu klären wäre

Folgende Punkte fallen, im Unterschied zu einem normalen Go-Microservice, auf und wurden entweder bereits kurz angesprochen oder wurden nur ohne Erklärung verwendet.

Kompilerfllag

In der Beispielanwendung wurden innerhalb des Dockerfiles die Kompilerflags -N -l mitgegeben. Mit Ihnen werden Optimierungen und das sogenannte Inlining von Code ausgeschaltet damit der Sourcecode besser mit dem Debugger zugeordnet werden kann.

-gcflags="-N -l"

Übrigens: Alle möglichen Kompilerflags für Go lassen sich mit go tool compile -help anzeigen.

Startup.sh

Gestartet wird die Anwendung innerhalb des Docker-Containers über das Skript startup.sh. Hier werden Delve wichtige Startoptionen mitgegeben und so z. B. die Debugging-Kommunikation über Port 4040 konfiguriert. Das Skript zeigt den kompletten Aufruf von Delve:

#!/bin/bash

echo "You have to kill this container because delve ignores SIGINT in headless mode"

dlv exec --api-version 2 --headless --log --listen :4040 /app/main

Folgende Parameter werden mitgegeben:

  • --api-version 2 Beim Aufruf von Delve kann man definieren mit welcher API-Version kommuniziert werden soll. Frontend und Delve müssen hierbei auf die selbe Version konfiguriert sein.

  • --headless Standardmäßig startet Delve direkt in einem interaktiven Modus. Dann kann der Debugger auf Kommandozeile gesteuert werden. Da wir uns remote verbinden wollen, starten wir Delve mit dieser Option und “unterdrücken” das eingebaute Kommandozeilen-Frontend.

  • --log Steuert eine erweiterte Ausgabe

  • --listen :4040 Angabe des Binding-Ports des Debuggers

Remote-Debugging in GoLand/ IntelliJ

Haben Sie die Anwendung mittels Docker gestartet können Sie sich mit IntelliJ bzw. GoLand von Jetbrains mit der Anwendung verbinden. Über das Menü “Run / Edit Configurations…” können Sie recht einfach eine neue Debugging Konfiguration erstellen (siehe Screenshot) und verwenden.

Achten Sie auf die Angabe des korrekten Ports - in unserem Beispiel 4040.

GoLand/IntelliJ Debugging Konfiguration

GoLand/IntelliJ Debugging Konfiguration

Hier werden Ihnen nocheinmal die Einstellungen für Delve gezeigt, die Sie beim Start der Anwendung mitgeben müssen (diese sind ja bereits im Shell-Skript vorhanden).

Jetzt können Sie wie gewohnt debuggen (HTTP aufruf nicht vergessen ;-) - http://localhost:8080). Innerhalb des Docker-Containers! Eine lokale Konfiguration funktioniert übrigens analog.

GoLand/IntelliJ Debugging

GoLand/IntelliJ Debugging

Konfiguration für VSCode

In Visual Studio Code (VSCode) ist der Ablauf ähnlich. Zuerst muss eine Debugging-Konfiguration angelegt werden, danach kann man sich mit dem Container verbinden.

Die Einrichtung ist nicht ganz so komfortable, da man direkt mit der JSON-Konfigurationsdatei in Berührung kommt. Über die Command-Palette kann eine neue launch.json Konfiguration erstellt werden. Hier wählen Sie unter “Umgeubung” -> Go und beim Punkt “Debug Configuration” -> “Connect to server” (siehe Screenshot).

VSCode Debugging Konfiguration

VSCode Debugging Konfiguration

VSCode erstellt Ihnen nun eine Debugging Konfiguration, mit der Sie bereits den Service debuggen können:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Connect to server",
            "type": "go",
            "request": "attach",
            "mode": "remote",
            "port": 4040,
            "host": "127.0.0.1",
            "apiVersion": 2
        }
    ]
}

Danach muss man auch hier nur noch auf “Play” drücken und kann den Service Schritt für Schritt überwachen (HTTP aufruf nicht vergessen ;-) - http://localhost:8080).

VSCode Debugging

VSCode Debugging

the end

Viel Spaß beim Debuggen!

Das komplette Beispiel kann auch in GitHub gefunden werden.

12.05.2021

 

Der Author auf Twitter: