コンテナ内のGo製Webアプリケーションをリモートデバッグする方法をまとめています。
ハマった点とその回避策も載せています。解消策などありましたら教えていただけると嬉しいです。
TL;DR
- Delveというデバッガーを使うと、リモートデバッグができる
- Realizeというタスクランナーと組み合わせる場合は、いくつか回避策が必要になった
- リモートデバッグのクライアントにはGoLandを使用した(他のエディタも対応状況はこちらを参照)
サンプルコード
この記事にあげているコードは、こちらにあります。
bellwood4486/sample-go-containerized-debug
ディレクトリは次のように3段階になっています。
-
0_base
: リモートデバッグを組み込む前のコード -
1_dlv
: Delveを入れてリモートデバッグできるようにしたコード -
2_dlv_realize
: さらにRealizeも入れてホットリロードにも対応したコード
リモートデバッグを組み込む前のコード
次のようなシンプルなWebアプリケーションを題材にします。
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "hello world")
})
addr := ":8080"
fmt.Printf("Launching server at %q ...\n", addr)
log.Fatal(http.ListenAndServe(addr, nil))
}
hello world
を返すだけです。
❯ curl http://localhost:8080
hello world
Delveを組み込みデバッグする
Delveを組み込みリモートデバッグできるようにしていきます。
おおまかな流れは以下のとおりです。
-
GOPATH
配下の相対パスを、コンテナとローカルとで合わせる - 最適化を無効にして、アプリケーションをビルドする
- コンテナ起動時にDelveが立ち上がるようにする
-
docker run
コマンドに渡すオプションを追加する
一つずつ説明します。
GOPATH
配下の相対パスを、コンテナとローカルとで合わせる
ブレイクポイントで止まるようにするためには、GOPATH
配下の相対ファイルパスがコンテナ側と一致している必要があります。
一致していないと、下図のようにブレイクポイントが無効状態になります。
Dockerfileを次のように変更して、ローカル側のパスと一致するようにします。
FROM golang:1.13.5-alpine AS build
# In order to debug remotely with GoLand, the paths of the source code relative to GOPATH must match.
WORKDIR ${GOPATH}/src/github.com/bellwood4486/sample-go-containerized-debug/1_dlv
# ... snip ...
ただ、ググると以下の記事も見つかるため、必須というわけではないかもしれません(未確認)
- GoLand: GoLandでProject Pathを設定する
- VSCode: デバッグの設定に
remotePath
を指定する
最適化を無効にしてアプリケーションをビルドする
ビルドする際は、インライン化などの最適化を無効にしておく必要があります。
go build
に渡すオプションについては、Delveのexec
コマンドのマニュアルに載っています。
Please consider compiling debugging binaries with -gcflags="all=-N -l" on Go 1.10 or later, -gcflags="-N -l" on earlier versions of Go.
Goのバージョンによって、オプションは若干変わります。今回の環境は Go 1.13.5 なので、-gcflags="all=-N -l"
を指定しました。
オプションの値については、こちらが参考になります。
-
-N
:最適化オフ -
-l
:インライン化オフ
Dockerfileに次のように記述します。
# ... snip ...
# To debug with Delve, disable optimization.
# see: https://github.com/derekparker/delve/blob/master/Documentation/usage/dlv_exec.md
RUN CGO_ENABLED=0 go build -gcflags "all=-N -l" -o /bin/sample-go-server-debug ./app
# ... snip ...
コンテナ起動時にDelveが立ち上がるようにする
Delveがアプリケーションにアタッチできるようにするため、Delve経由でアプリケーションを起動させます。
Dockerfileを変更し、Delveが起動するようにします。マルチステージビルドのdebug用ステージを用意しました。
FROM golang:1.13.5-alpine AS build
# ... snip ...
RUN CGO_ENABLED=0 go build -gcflags "all=-N -l" -o /bin/sample-go-server-debug ./app
# ... snip ...
FROM build AS debug
RUN set -ex && \
apk update && \
apk add --no-cache git && \
go get -v -u github.com/go-delve/delve/cmd/dlv
# Port 8080 belongs to our application, 2345 belongs to Delve
EXPOSE 8080 2345
# Run Delve with the uninterrupted setting in the main function.
# see: https://github.com/derekparker/delve/blob/master/Documentation/faq.md#how-do-i-use-delve-with-docker
CMD /go/bin/dlv exec /bin/sample-go-server-debug \
--listen=:2345 \
--headless \
--api-version=2 \
--continue \
--accept-multiclient
# ... snip ...
debugステージでは、次の3つを行っています。
- Delveをgo getでインストールする
- リモートデバッグのコネクションを受け付けるポート(
2345
)を追加で開ける -
dlv exec
コマンドにビルドしたバイナリを指定し、Delveを起動する
dlv exec
コマンドのオプションについてはこちらを参照ください。
ポイントは、--continue
オプションです。このオプションの有無でリモートデバッグの挙動が次のように変わります。
- 【指定しない場合】アプリケーションのプロセスは起動しますが、デバッガでアタッチするまでmain関数の実行は中断されます。
- 【指定する場合】デバッガをアタッチしなくてもmain関数の実行は中断されません。
今回のサンプルで言うと、【指定しない場合】は、リモートデバッガのコネクションを張るまで、curlの実行結果は正しく返りません。main関数の実行が中断されるためです。通常の開発ではすこし不便なので、--continue
を指定しています。
このことはDelveのFAQに書いてあります。
The program will not start executing until you connect to Delve and send the continue command. If you want the program to start immediately you can do that by passing the --continue and --accept-multiclient options to Delve:
docker run
コマンドに渡すオプションを追加する
コンテナ内のアプリケーションをデバッグするときは、docker run
コマンドに2つのオプションを渡す必要があります。(参考1、参考2)
--security-opt=seccomp:unconfined
--cap-add=SYS_PTRACE
--security-opt=seccomp:unconfined
は、デフォルトで制限されているコンテナ内での動作を解除するオプションです。Linux Kernelには、Secure computing mode(seccomp)という機能あり、これを使うとコンテナ内の動作を制限させることができます。Dockerではこの機能のデフォルトのプロファイルを定義しています。このオプションを指定することで、デフォルトプロファイルを無効にすることができます。詳細は公式ドキュメントを参照ください。
Linuxのcapabilityの一つに、ptrace(2)というシステムコールを使って任意のプロセスをトレースするCAP_SYS_PTRACE
があります。--cap-add=SYS_PTRACE
は、このcapabilityを追加するオプションです。GoLandのガイドでは指定されていますが、もしかしたら不要かもしれません。こちらのブログがとても参考になります。
今回は、docker-composeからコンテナを起動しているので、docker-compose.yaml 上で指定しています。
version: '3.7'
services:
app:
# ... snip ...
# To debug containerized app with Delve, this option is required.
# see: https://github.com/derekparker/delve/blob/master/Documentation/faq.md#how-do-i-use-delve-with-docker
security_opt:
- apparmor:unconfined
# To debug with GoLand, this option is required.
# see: https://blog.jetbrains.com/go/2018/04/30/debugging-containerized-go-applications/
cap_add:
- SYS_PTRACE
コンテナを立ち上げる
以上で準備は完了です。コンテナを立ち上げてみます。
❯ cd 1_dlv
❯ docker-compose up -d --build
ログを確認すると、2つのポートをリッスンしているのがわかります。
8080
はアプリケーション、2345
はDelveのデバッグ用ポートです。
❯ docker-compose logs -f
Attaching to 1_dlv_app_1
app_1 | API server listening at: [::]:2345
app_1 | Launching server at ":8080" ...
アプリケーションも正常にレスポンスを返します。
❯ curl http://localhost:8080
hello world
次に、コンテナ内に入り、プロセスの状況を確認します。
2つのプロセスが動いています。PID:1がDelve、PID:12がアプリケーションのプロセスです。
❯ docker-compose exec app ash
/go/src/github.com/bellwood4486/sample-go-containerized-debug/1_dlv # ps
PID USER TIME COMMAND
1 root 0:00 /go/bin/dlv exec /bin/sample-go-server-debug --listen=:2345 --headless --api-version=2 --continue --accept-multiclient
12 root 0:00 /bin/sample-go-server-debug
# ... snip ...
ツリー形式で表示すると、Delveからアプリケーションが呼び出されていることがわかります。
/go/src/github.com/bellwood4486/sample-go-containerized-debug/1_dlv # pstree -p
dlv(1)---sample-go-serve(12)
GoLandからデバッグする
GoLandからコンテナ内のDelveに繋いで、アプリケーションをデバッグするまでの流れです。
Run/Debug Configuration から "Go Remote" を新規作成します。
ポート番号は、Dockerfileで定義したデバッグ用ポート番号を指定します。(今回の場合は2345
)
自分の環境では、デバッガからDelveに接続すると、次のメッセージがログに表示されました。今のところ支障はなさそうなので一旦棚上げしています。
❯ docker-compose logs -f
Attaching to 1_dlv_app_1
... snip ...
app_1 | 2020-04-18T07:03:47Z error layer=rpc writing response:write tcp 127.0.0.1:2345->127.0.0.1:48800: use of closed network connection
curlでリクエストを送ると、ハンドラー内のブレイクポイントで無事止まりました。
注意:
前述の「コンテナ起動時にDelveが立ち上がるようにする」でも書きましたが、このサンプルでは、main関数に貼ったブレイクポイントでは止めることができません。main関数をデバッグするには、Delve起動時に指定した--continue
オプションを外す必要があります。
さらに Realize と組み合わせる
Realizeは、Go向けのタスクランナーです。
ソースファイルの変更を検知して、自動で再ビルド&起動させられるので、ホットリロードを実現できます。
これを組み合わせて、ホットリロードとリモートデバッグの両方できる構成にしてみます。
現時点では、Delve -> app
という2段階でアプリケーションを起動しています。これを Realize -> Delve -> app
という3段階構成にします。
基本的には、これまで Dockerfile
や docker-compose.yaml
に書いた設定を、Realizeの設定ファイルである .realize.yaml
に移していくだけですが、いくつかハマったのでそのワークアラウンドも記載します。
最終的な .realize.yaml
ファイルは次のようになります。
ここでは3つの問題が発生し、それぞれ回避策を入れています。
-
-gcflags "all=-N -l"
を.realize.yaml
に直接書くと go build が失敗する - Delveとアプリケーションのプロセスが終了しない
- アプリケーションのプロセスがゾンビプロセスとして残る
settings:
legacy:
# If you want Realize running in a container to detect changes,
# it must be true (polling).
force: true
interval: 1s
schema:
- name: app-debug
path: .
commands:
build:
status: true
# WORKAROUND: The value of the -gcflags option ("all=-N -l") is separated by a space and is interpreted as an error
# if it is written directly in ".realize.yaml".
method: ./realize/go-debug-build.sh -o /bin/sample-go-server-debug ./app
run:
status: true
method: /go/bin/dlv
args:
- exec
- /bin/sample-go-server-debug
- --listen=:2345
- --headless
- --api-version=2
# Run Delve with the uninterrupted setting in the main function.
# see: https://github.com/derekparker/delve/blob/master/Documentation/faq.md#how-do-i-use-delve-with-docker
- --continue
- --accept-multiclient
watcher:
paths:
- /app
ignored_paths:
- .git
- .realize
- vendor
extensions:
- go
# WORKAROUND: If you don't explicitly kill a child process, Delve and its child don't terminate.
scripts:
- type: before
command: pkill -INT /bin/sample-go-server-debug
output: true
問題1:-gcflags "all=-N -l"
を .realize.yaml
に直接書くと go build が失敗する
最初は、次のように .realize.yaml
にビルドコマンドを直接書いていました。
# ... snip ...
schema:
- name: app-debug
path: .
commands:
build:
status: true
method: CGO_ENABLED=0 go build -gcflags "all=-N -l" -o /bin/sample-go-server-debug ./app
これを実行すると、-l"
を一つのオプションとして解釈しようとして失敗します。
[13:13:34][APP-DEV] : Watching 1 file/s 1 folder/s
[13:13:34][APP-DEV] : Build started
[13:13:34][APP-DEV] : Build
flag provided but not defined: -l"
usage: go build [-o output] [-i] [build flags] [packages]
Run 'go help build' for details.
exit status 2
エスケープによる解消方法を見つけられず、今回は別ファイルにビルドコマンドを書き、それを呼び出すかたちで回避しました。
.realize.yaml
# ... snip ...
# WORKAROUND: The value of the -gcflags option ("all=-N -l") is separated by a space and is interpreted as an error
# if it is written directly in ".realize.yaml".
method: ./realize/go-debug-build.sh -o /bin/sample-go-server-debug ./app
go-debug-build.sh
#!/usr/bin/env ash
# To debug with Delve, disable optimization.
# see: https://github.com/derekparker/delve/blob/master/Documentation/usage/dlv_exec.md
CGO_ENABLED=0 go build -gcflags "all=-N -l" "$@"
問題2:Delveとアプリケーションのプロセスが終了しない
Realizeは、ファイルの更新を検知すると、既存のプロセスを停止させるはずですが、Delveとアプリケーションが終了しないという問題が起きました。
次のIssueにある回避策を参考に、末端のアプリケーションのプロセスを明示的に終了させることで回避はできました。(原因は掴めていません…)
Realize is not killing the current app · Issue #247 · oxequa/realize
# ... snip ...
# WORKAROUND: If you don't explicitly kill a child process, Delve and its child don't terminate.
scripts:
- type: before
command: pkill -INT /bin/sample-go-server-debug
output: true
問題3:アプリケーションのプロセスがゾンビプロセスとして残る
上記2つの回避策を入れることで、再ビルドしたプロセスが起動するようにできました。しかし、なぜかアプリケーションのプロセスが以下のように残ってしまっていました。
COMMAND名の[sample-go-serv]
がそれです。
/go/src/github.com/bellwood4486/sample-go-containerized-debug/2_dlv_realize # ps
PID USER TIME COMMAND
1 root 0:00 /go/bin/realize start
990 root 0:00 [sample-go-serve]
1067 root 0:00 /go/bin/dlv exec /bin/sample-go-server-debug --listen :2345 -
1075 root 0:00 /bin/sample-go-server-debug
# ... snip ...
ツリー表示したものが以下です。もともとはDelveの子プロセスだったものが、realizeの子プロセスとして残ってしまっています。
/go/src/github.com/bellwood4486/sample-go-containerized-debug/2_dlv_realize # pstre
e -p
realize(1)-+-dlv(1067)---sample-go-serve(1075)
`-sample-go-serve(990)
こちらは、アプリケーションにGraceful Shtdownの実装を入れ、明示的にシグナルをハンドリングすることで回避できました。(こちらも原因は掴めておらず…)
動作確認
ようやくホットリロードしつつデバッグできる環境が整ったので確認してみます。
まずコンテナを立ち上げ、正常にレスポンスが返ることを確認します。
❯ cd 2_dlv_realize
❯ docker-compose up -d --build
❯ curl http://localhost:8080
hello world
コンテナのログを見ると、Realizeがビルドしているのがわかります。(Build completed in 7.766 s
の部分)
❯ docker-compose logs -f
Attaching to 2_dlv_realize_app_1
app_1 | [05:58:33][APP-DEBUG] : Watching 1 file/s 1 folder/s
app_1 | [05:58:33][APP-DEBUG] : Command "pkill -INT /bin/sample-go-server-debug"
app_1 | [05:58:33][APP-DEBUG] : Build started
app_1 | [05:58:41][APP-DEBUG] : Build completed in 7.766 s
app_1 | [05:58:41][APP-DEBUG] : Running..
app_1 | [05:58:41][APP-DEBUG] : API server listening at: [::]:2345
app_1 | [05:58:41][APP-DEBUG] : Launching server at ":8080" ...
コンテナの中に入り、プロセスを確認してみます。
3つのプロセスがあり、Realize -> Delve -> アプリケーション
の順に起動されていることがわかります。
❯ docker-compose exec app ash
/go/src/github.com/bellwood4486/sample-go-containerized-debug/2_dlv_realize # ps
PID USER TIME COMMAND
1 root 0:00 /go/bin/realize start
1000 root 0:00 /go/bin/dlv exec /bin/sample-go-server-debug --listen :2345 --headless --api-version 2 --continue --accept-multiclient
1007 root 0:00 /bin/sample-go-server-debug
# ... snip ...
/go/src/github.com/bellwood4486/sample-go-containerized-debug/2_dlv_realize # pstree -p
realize(1)---dlv(1000)---sample-go-serve(1007)
ホットリロードが機能しているか確認します。
main.go
を変更し、レスポンスを'hello world'から'foo bar'に変えて保存します。
//... snip ...
_, _ = fmt.Fprintf(w, "foo bar")
//... snip ...
コンテナのログを見ると、ファイルの変更が検知され、再ビルド&再起動しているのがわかります。
❯ docker-compose logs -f
Attaching to 2_dlv_realize_app_1
... snip ...
app_1 | [06:32:05][APP-DEBUG] : GO changed /go/src/github.com/bellwood4486/sample-go-containerized-debug/2_dlv_realize/app/main.go
app_1 | [06:32:05][APP-DEBUG] : Command "pkill -INT /bin/sample-go-server-debug"
app_1 | [06:32:05][APP-DEBUG] : Build started
app_1 | [06:32:06][APP-DEBUG] : Build completed in 0.894 s
app_1 | [06:32:06][APP-DEBUG] : Running..
app_1 | [06:32:06][APP-DEBUG] : API server listening at: [::]:2345
app_1 | [06:32:06][APP-DEBUG] : Launching server at ":8080" ...
curlの実行結果も期待通り変わっています。
❯ curl http://localhost:8080
foo bar
再度コンテナに入り、プロセスIDを確認すると、DelveとアプリケーションのPIDが変わっていることがわかります。ゾンビプロセスもありません。
/go/src/github.com/bellwood4486/sample-go-containerized-debug/2_dlv_realize # ps
PID USER TIME COMMAND
1 root 0:00 /go/bin/realize start
1091 root 0:00 /go/bin/dlv exec /bin/sample-go-server-debug --listen :2345 --headless --api-version 2 --continue --accept-multiclient
1100 root 0:00 /bin/sample-go-server-debug
# ... snip ...
/go/src/github.com/bellwood4486/sample-go-containerized-debug/2_dlv_realize # pstree -p
realize(1)---dlv(1091)---sample-go-serve(1100)
まとめ
コンテナ内のWebアプリケーションをリモートデバッグするまでの流れを記録がてらまとめてみました。
ただ、原因がつかめていないところも多いので、Goならわかるシステムプログラミング を読んで、Goによるプロセス周りの扱いを勉強したいところです。