🆙

2024年版 Go + Docker + VSCode + air + delveの環境でサクサクローカル開発

2024/11/20に公開3

こんにちは、kensho(@iwashi623)です。
皆さんはデバッガー、使ってますか?

文明の利器たるデバッガー、私はローカル開発をするときにどうしても使いたい派の人間です。
最近担当することになったアプリケーションのローカル環境でも、デバッガーを使いたかったのですが、あいにく未導入の状態でした。
そこで、Goのデバッガーであるdelveを導入して、ブレークポイントを貼って開発できる状態にしました。
構成は、今どきよくありそうな、

  • Docker
  • Golang(echo)
  • air

といった環境です。

delveの導入時、先人達のZennやQiita、ブログの記事をみて色々試してみたのですが、どうにも僕の手元ではおかしな挙動をして困ったりしていました。
今回はその格闘のログと、これでいいかと落ち着いた設定を書いていきます。

環境

ネタバレで先に僕が最終的に落ち着いた設定を書いておきます。

GitHubで公開しています。
リポジトリ

  • エディタ: VSCode
  • 実行環境: Docker(Docker Compose)
  • Golang: 1.23.0
  • FW: echo v4
  • ホットリロードをしたいのでair導入済み

設定のざっくり解説

Docker

今回のリポジトリはechoで作られたGoのアプリケーションサーバーがDocker上で実行されています。
開発環境には後述するairdelveを使うためにそれ用の記述がされたDockerfile.devを使用します。

Dockerfile.dev
FROM golang:1.23 AS builder
WORKDIR /app

# airとdelveのインストール
RUN go install github.com/air-verse/air@latest && \
    go install github.com/go-delve/delve/cmd/dlv@latest

RUN --mount=type=cache,target=/go/pkg/mod/,sharing=locked \
    --mount=type=bind,source=go.sum,target=go.sum \
    --mount=type=bind,source=go.mod,target=go.mod \
    go mod download -x

RUN --mount=type=cache,target=/go/pkg/mod/ \
    --mount=type=bind,target=. \
    go build -o /bin/myapp

COPY . .

# airの実行
CMD ["air", "-c", ".air.toml"]

ローカル上から、Dockerコンテナで動作してるアプリケーション実行時にブレークポイントを刺したいので、compose.yamlの設定をいじって、ローカル上の2345ポートをコンテナ上の2345ポートにバインドしておきます。

compose.yaml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.Dev
    ports:
      - '${PORT:-3333}:${PORT:-3333}'
      - '${DEBUG_PORT:-2345}:${DEBUG_PORT:-2345}' # ここを追記
    env_file:
      - .env
    volumes:
      - .:/app

airとdelve

Goで開発するとき、都度Buildし直すのは大変なので、airを使用されている現場も多いのではないでしょうか?

airは、ファイルに更新が走るたびにソースコードをBuildし実行してくれる、いわゆるホットリロードを導入するツールです。

go install github.com/air-verse/air@latestを実行してairをインストールしたあと、air initをすることで生成されるair.tomlを以下のように修正しています。

air.toml
root = "."
tmp_dir = "/tmp"

[build]
  args_bin = []
  bin = "/bin/myapp"
  cmd = "go build -gcflags=\"all=-N -l\" -o /bin/myapp ."
  full_bin = "dlv --headless=true --listen=:2345 --api-version=2 --accept-multiclient exec --continue /bin/myapp"

---以下自動生成されたままなので省略---

詳細は割愛しますが、cmdでGoをBuildする際のコマンド、full_binでアプリケーションを実行する際のコマンドが書かれている事がわかると思います。
今回はデバッグツールのdelveを使用するため、アプリケーションの実行時にdlv execコマンドを使用して、Goアプリケーションを実行しています。

VSCode

VSCodeのデバッガー起動時の設定ファイル、launch.jsonはこちら。

launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Attach to Server",
      "type": "go",
      "request": "attach",
      "mode": "remote",
      "debugAdapter": "dlv-dap",
      "substitutePath": [
        {
          "from": "${workspaceFolder}",
          "to": "/app"
        }
      ],
      "port": 2345,
      "host": "localhost",
      "showLog": true,
      "trace": "verbose"
    }
  ]
}

ただデバッガーを動かしたいだけなら、前述までの設定をして、デバッガーをF5キーを使って実行して、アプリケーションを呼び出せばブレークポイントで処理が止まるはずです。

こちらのデモでは、3回目のcurl時にブレークポイントを貼った後にリクエストを飛ばしたため、ブレークポイントの時点で処理が止まっています。

この記事の以下は、この設定に至るまでの格闘のログです。

苦しみpoint

いろいろと調べた結果、僕が最初にかいたlaunch.jsonは以下です。

launch.json(ver.1)
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Attach to Server",
      "type": "go",
      "request": "attach",
      "mode": "remote",
      "remotePath": "/app",
      "port": 2345,
      "host": "localhost",
      "showLog": true,
      "trace": "verbose"
    }
  ]
}

この状態でも、まぁなんとなく動いてはいたのですが様々問題を抱えていました。

問題は以下です。

  1. ブレークポイントで処理が止まらないことがある(止まることもある)
  2. デバッガーを落としても、引き続きブレークされ続ける

色々と格闘して、デバッガーとして致命的な1つ目は解消できたので、詳細な症状と解消法を記載します。

ブレークポイントで処理が止まらないことがある(止まることもある)

致命的でした。

  • コンテナが動いている
  • curlをするとレスポンスが返ってくる
  • ホットリロードも効いている

といったアプリケーションが完全に動作している状態でも、何故かブレークポイントで処理が停止しないことがありました。
そして、1回目のリクエストで停止しなかった箇所でも、2回目以降のリクエスト時は意図通り刺さることがあり、挙動として不安定なところも問題でした。

論より証拠ということで以下がデモの様子です。

↑のgif画像では、デバッガー起動時に合計6箇所のブレークポイントを貼っていますが、1度目のcurlでは4個所しかブレークされていません。そして、2度目以降のcurlの際は6箇所すべてのブレークポイントで停止がされていることがわかると思います。

色々と調べていくと、vscode-go(VSCodeのGolang拡張)のドキュメントにたどり着きました。

そこでremotePathという設定がdeprecatedになり、代わりにsubstitutePathという設定が推奨となっていたことに気づきました。

設定を以下のように書き換えます。

launch.json(ver.2)
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Attach to Server",
      "type": "go",
      "request": "attach",
      "mode": "remote",
      "substitutePath": [
         {
           "from": "${workspaceFolder}",
           "to": "/app"
         }
       ],
      "port": 2345,
      "host": "localhost",
      "showLog": true,
      "trace": "verbose"
    }
  ]
}

substitutePathの説明には、以下のように書いてあります。

ローカルパス(editor)からリモートパス(debugee)へのマッピングの配列。 この設定は、シンボリックリンクのあるファイルシステムで作業したり、 リモートデバッグを実行したり、外部でコンパイルされた実行可能ファイルをデバッグ したりする場合に便利です。 デバッグ・アダプタは、すべてのコールでローカル・パスをリモート・パスに置き換えます。 remotePath によってオーバーライドされます。

実は、launch.json(ver.2)のまで状態では、launch.json(ver.1)と挙動は変わりません。deprecatedのものを使わなくて気持ちよくなっただけです。

しかしながら、ここで"debugAdapter": "dlv-dap"という設定を加えると状況が変わりました。

launch.json(ver.3 最終形)
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Attach to Server",
      "type": "go",
      "request": "attach",
      "mode": "remote",
      "debugAdapter": "dlv-dap",
      "substitutePath": [
         {
           "from": "${workspaceFolder}",
           "to": "/app"
         }
       ],
      "port": 2345,
      "host": "localhost",
      "showLog": true,
      "trace": "verbose"
    }
  ]
}

DAP(Debug Adapter Protocol)はVSCodeのでデバッグ機能の裏で動いている通信プロトコルです。以前はdelve(Goのデバッガー)DAPに対応していなかったため、VSCodeはGoのデバッグの際にはDAPの通信をDebugAdapterという機構で中継してやり取りするような通信モデルを採用していていました。これが、"debugAdapter": "legacy"という設定です。

しかしながら、delveDAPに対応した事によりこちらの通信モデルは過去のものとなり、今ではVSCodeのデバッグ機能が直接DAPプロトコルを使ってdelveとやり取りできるようになっています。こちらが"debugAdapter": "dlv-dap"という設定になります。

出典: Go debug extension architecture overview(https://github.com/golang/vscode-go/blob/master/docs/debugging.md#go-debug-extension-architecture-overview)

すでに起動されているGoのサーバーのデバッグを行う際、launch.jsonでは、"request": "attach"を使います。
ドキュメントを読んだところ、"request": "attach"際にdebugAdapterlegacyがデフォルトの設定となっていました。
(なぜか"request": "launch"のときは、dlv-dapがデフォルト)

こちらを"debugAdapter": "dlv-dap"に切り替えて、かつremotePathではなくsubstitutePathを指定するようにしたところ、所望のブレークポイントでいつでも処理を止めることができるようになりました。

デバッガーを落としても、引き続きブレークされ続ける

こちらも論より証拠ということで、まずデモをしている画像を貼ります。

1回目のcurlの時は問題ありません。デバッガーで指定したブレークポイントで、しっかりとブレークされています。
問題は2回目のcurlの時です。2回目のcurlをしたとき、VSCodeのデバッグ機能の実行を停止しているにも関わらず、ブレークポイントで処理が止まっている事がレスポンスが返ってきていないことからわかります。実際、F5キーなどでVSCodeのデバッグ機能を再実行したらブレークポイントで処理が止まっています。

この状況を見れば、「おそらく裏側でdelveの実行が生きていて、処理が停止しているというのだろう」ところまでは予測できます。
実際に、ブレークポイントをすべて剥がした上でVSCodeのデバッグ機能を停止すれば、当たり前ですがブレークされなくなります。
しかしながら、

  • なぜVSCodeのデバッグ機能の実行をやめてもdelveは動き続けるのか?
  • 回避策はないのか?

については不明のままです。

一応対症療法ですが、docker restartをすると一旦delveも再起動がかかり意図しないブレークはされなくなるですが、地味にストレスな挙動です。直したい。

おわりに

2つ目の問題が解消できていないので志半ば感はありますが、ひとまずは使い物になるデバッガーの状態にはできたと思います。

Printデバッグでも開発を進められないことはないのですが、

  • 入れ子になった構造体のフィールドの値の確認
  • 外部パッケージ内のデバッグで、Print関数を挟みづらい状況のとき

などで、デバッガーは強烈な価値を出すと思っています。
そういったときにいちいち悩みたくないので、開発環境を作る際はまず最初に用意したいものだなと個人的には思っています。

Discussion

Hiroshi KoyamaHiroshi Koyama

参考になるかわかりませんが、たとえば Java で Tomcat で動作するアプリを Maven + cargo-maven3-plugin を使ってリモートデバッグするときは、起動用タスク、停止用タスクを用意し、それらを使って launch で起動や停止ができます。

そういった方法を参考にすればできそうだと最初のコメントには Maven + cargo-maven3-plugin の例を記載しました。さきほど Golang Air で確認したらできました。

次のようなワークスペースファイルを用意して試してみたところ、デバッグ開始で air の起動、デバッグ停止で air 停止ができました。記事ではDocker を使っているので、air-start の command を docker compose up -d とし、air-kill の command を docker compose down コマンドにすればいけるはずです。(省略しましたが docker-compose.yaml ファイルの指定も必要)

ただし、再起動は、自分で「停止」「起動」とする必要があります。

{
    "folders": [
        {
            "path": "."
        }
    ],
    "tasks": {
        "version": "2.0.0",
        "tasks": [
            {
                // 「実行とデバッグ」から、Air も起動するときはこちらを使用
                "label": "air-start",
                "type": "shell",
                "command": "cd /app && air -c .air.toml",
                "group": "build",
                "isBackground": true,
                "presentation": {
                    "echo": true,
                    "reveal": "always",
                    "focus": true,
                    "showReuseMessage": false,
                },
                // https://github.com/microsoft/vscode-java-debug/blob/main/Configuration.md#attach-to-embedded-maven-tomcat-server を参考
                "problemMatcher": [
                    {
                        "pattern": [
                            {
                                // 現在の指定は意味のない指定となっている
                                "regexp": "\\b\\B",
                                "file": 1,
                                "location": 2,
                                "message": 3
                            }
                        ],
                        "background": {
                            "activeOnStart": true,
                            "beginsPattern": "^.*API server listening",
                            "endsPattern": "^.*server started at port 8080*"
                        }
                    }
                ]
            },
            {
                // タスクから直接 Air を停止
                "label": "air-kill",
                "type": "shell",
                "command": "kill -TERM $(ps ax|grep -e \"[a]ir\" -e \".air.toml\" | awk '{print $1}')",
                "group": "build",
                "isBackground": true
            },
            {
                // 入力欄を表示して Tomcat を停止
                "label": "air-stop-app",
                "type": "shell",
                "command": "echo ${input:terminate}}",
                "problemMatcher": []
            }
        ],
        "inputs": [
            {
                "id": "terminate",
                "type": "command",
                "command": "workbench.action.tasks.terminate",
                "args": "air-stop-app"
            }
        ]
    },
    "launch": {
        "version": "0.2.0",
        "configurations": [
            {
                "name": "Attach to Server",
                "type": "go",
                "request": "attach",
                "mode": "remote",
                "debugAdapter": "dlv-dap",
                "apiVersion": 2,
                "substitutePath": [
                    {
                        "from": "${workspaceFolder}",
                        "to": "/app"
                    }
                ],
                "port": 2345,
                "host": "localhost",
                "showLog": true,
                "trace": "verbose",
                "preLaunchTask": "air-start",
                "postDebugTask": "air-kill"
            }
        ]
    }
}
Hiroshi KoyamaHiroshi Koyama

最初にコメントさせていただいたコメントについて、Tomcat の例を Go Air のものに変更しました。コメント更新が通知でいくかわからないので、コメントいたします。

kenshokensho

コメントありがとうございます。
なるほど、コンテナ立ち上げ自体をVSCodeのデバッグ機能起動と同時に行う作戦なのですね。
確かにこれならデバッグ機能停止時にも謎にブレークされる問題は生じませんね!

それにしてもlaunch.jsonでそんな詳細な設定ができるとは知りませんでした…
めちゃくちゃ勉強になりました!